Engineering
추가배포 없이 API의 case 통일시키기
luis.kim카카오
2024년 11월 19일
원문에서 보기 ↗안녕하세요. 카카오 마이구독 서비스에서 백엔드 개발을 맡고 있는 루이스입니다.
7년 동안 백엔드를 개발하면서 다양한 경험들을 많이 해보았지만, 그간의 경험을 돌이켜보면 시스템의 작은 부분이라고 넘어간 부분이 부메랑이 되어 돌아온 사례가 많이 있었습니다.
그중에서도 자주 마주치는 문제로는 DTO를 꼽을 수 있을 텐데요. 개발자들 간의 서로 다른 DTO의 네이밍룰은 크고 작은 에러를 발생시키며 개발속도를 지연시키는, 작아 보이지만 무시할 수 없는 이슈로 작용합니다. 이렇게 서버 통신 시 사용하는 DTO들을 통일시키기 위해 고군분투하며 배우고 느낀 점들을 이번 글에서 독자 분들께 공유드리고자 합니다.
문제상황
서비스에서 백엔드 개발을 하다 보면 다양한 요구조건이 들어오기 마련인데요. 케이스(Case) 역시 마찬가지입니다. ‘Admin에서는 Camel을 써요’, ‘외부 연동처에서 Snake로 내려달라고 하네요’ 등 의도치 않게 코드 안의 케이스는 다양한 케이스들이 섞이기 마련입니다.
저희 부서에서도 위와 같이 다양한 케이스를 혼용해서 사용하고 있는 상황이었습니다. 그래서 회의 끝에 앞으로의 개발 과정에서 케이스는 모두 Camel case로 통일하기로 합의하였고, 점진적으로 케이스를 변경해 나가기로 했습니다.
점진적 변경 시 문제점들
그러나 점진적으로 케이스를 변경시키는 계획은 전환이 어느 정도 진행된 이후 몇 가지 문제점들이 발생하게 되었습니다.
첫째로 API가 특정 DTO를 그대로 다른 서버로 전달하는 경우에, 해당 서버에서 사용하는 케이스에 따라 DTO가 한 벌 더 필요하게 되는 경우가 있었습니다.

위의 그림처럼 게이트웨이 역할을 하는 서버에서는 Camel case를 사용하고 있는데, 실제 비즈니스 로직을 담당하는 서버에서 Snake case를 사용하는 경우 동일한 값의 DTO지만 받아줄 때는 Camel이, 보내줄 때는 Snake case가 필요한 상황이 된 것이죠. DTO를 한벌 더 늘리면 되긴 하지만, DTO에 필드가 추가되거나 삭제될 시 유지보수가 쉽지 않아 진다는 문제점이 있었죠.
두 번째로, DTO 내부에 다른 DTO가 포함될 때 서로 케이스가 다른 경우 문제가 되었습니다.
public class OutDTO {
private InnerDTO innerDto;
private String outerField;
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class InnerDTO {
private String fieldOne;
private String fieldTwo;
}
기존에 사용하던 DTO를 포함해야 하는 새로운 DTO를 생성할 때, 기존 DTO의 케이스가 새 DTO의 케이스와 다를 경우에 문제가 되었는데요. OutDTO는 Camel case, InnerDTO는 Snake case를 사용하고 있고, 이럴 경우 해당 DTO를 받아주는 쪽에서는 어느 케이스로 파싱해야 할지 몰라 파싱 오류를 발생시키는 원인이 되었습니다.
마지막으로 컴파일 타임에 잡히지 않는 에러가 발생하는 문제가 있었습니다. Spring Boot의 기본 Parser인 Jackson에서는 필드명에 해당하는 값이 없을 경우 Exception을 발생시키거나, Null 값을 채울 수 있습니다. Null 값을 채우는 옵션일 때는, 런타임에 해당 필드를 사용하는 순간에 도달해야만 오류를 인지할 수 있었는데요. 저희 부서는 Null 값을 채우는 옵션을 사용하면서, 케이스 변경 작업을 진행하니 필드명을 인식하지 못하는 경우가 자주 생기면서 이러한 오류가 발생했습니다.
처리방안 고민
위와 같은 문제점은 MSA 환경에서 더욱 빈번하게 발생할 수 있었고, 또한 무중단 서비스에서는 쉽게 고치기 어려웠는데요.
MSA환경에서 이와 같은 문제가 빈번한 이유는 애플리케이션(Application) 간의 통신을 REST API를 통해 처리했기 때문에 많은 DTO가 사용되기 때문이었고, 이를 무중단 서비스에서 고치기 어려웠던 이유는 서비스 중단 없이 DTO를 교체하는 과정이 쉽지 않았기 때문입니다.
무중단 서비스에 서비스 중단 없이 기능을 배포하려면 일반적으로 아래와 같은 단계를 거쳐야 합니다(편의상 요청을 보내는 쪽 서버를 Caller, 요청을 받는 쪽 서버를 Callee라고 표현하겠습니다).

즉, 위 그림과 같은 순서로 배포를 진행해야 배포과정 중에도 v1 URI를 통한 요청이 실패하지 않습니다. 하지만, 위와 같은 배포 방식을 사용하려면 배포 순서를 상당히 신경 써야 하는데요. 한 두 개의 서버 배포는 상관없지만, 여러 개의 서버에 배포를 해야 하는 상황에서는 API들이 서로 참조하는 경우가 있기 때문에 이 순서를 맞추기 어렵고, 서비스의 안정성을 위해 서버 점검으로 모든 요청을 중지시킨 후 배포를 진행하고는 합니다.
저희 서비스의 경우, 900개 정도 되는 DTO 케이스의 변경을 위해 여러 번의 서버 점검이 필요했습니다. 그러나 고작 케이스 변경 건 때문에 서버 점검을 여러 번 진행한다는 건 배보다 배꼽이 더 큰 작업이었죠. 그렇다고 한 번에 모든 케이스를 배포하는 것은 리스크가 너무 크다고 판단했기 때문에, 조금 안정적이고 나은 방법이 없을지를 모색해 봤습니다.
Request처리
고심 끝에 생각해 낸 수는 Caller의 케이스와 상관없이 Callee 쪽에서 DTO 파싱 시에 자기 자신에게 선언된 DTO 케이스에 맞게 파싱을 자동으로 처리해 주는 모듈을 붙이는 방법이었습니다.

위의 이미지처럼, 모든 Application 서버에 모듈을 붙여준 후에, 각 서버의 케이스를 일원화시키고 변경 모듈을 삭제해 주면 서버를 점진적으로 배포할 수 있고, 서버 점검 없이도 모든 DTO를 바꿀 수 있을 것 같았습니다.
이를 위해 Request 및 Response에 각각 파싱 직전 동작하는 모듈을 추가하였습니다.
먼저 Request 쪽은 스프링에서 제공하는 RequestBodyAdvice를 사용했습니다. Filter의 경우 타겟이 되는 클래스를 알아낼 수 없었고, Interceptor의 경우 원본 Request의 Body를 변경시키기 어려웠기 때문에 찾아서 적용한 방법이었습니다.
RequestBodyAdvice는 SpringMVC에서 사용할 수 있으며, @RequestBody로 선언된 변수에 값이 맵핑되기 직전에 동작하는데요.
public interface RequestBodyAdvice {
boolean supports(…);
HttpInputMessage beforeBodyRead(…);
Object afterBodyRead(…);
…
}
위의 예시 코드처럼, beforeBodyRead 메서드에서 타겟 클래스를 알아내고, 이에 맞춰 파싱을 해줄 수 있었습니다.
이때, 타겟 클래스가 어느 케이스로 변경이 필요한지는 해당 클래스의 어노테이션을 보고 판단할 수 있었습니다. 저희 코드에서는 Snake case로 변경 시에 @JsonNamingStrategy, @JsonProperty를 붙여 Snake case임을 나타내고 있었는데요. 위의 두 어노테이션이 존재하는지 여부를 Java reflection을 이용해 파악하고, 이에 맞춰 변경해 주는 코드를 beforeBodyRead에 적용했습니다.
해당 예시 코드는 아래와 같습니다.
@Override
public HttpInputMessage beforeBodyRead(
HttpInputMessage inputMessage,
MethodParameter parameter,
Type targetType,
Class> converterType
) throws IOException {
...
if (hasJsonNamingAnnotation(Class.forName(currentType.getTypeName())) ||
hasJsonPropertyAnnotation(Class.forName(currentType.getTypeName()))
) { hasSnake = true; }
if (hasSnake) {
var snakeNode = convert(objectMapper.readTree(originString), "snake");
var snakeString = objectMapper.writeValueAsString(snakeNode);
result = new ModifiedHttpInputMessage(inputMessage, snakeString);
} else {
var camelNode = convert(objectMapper.readTree(originString), "camel");
var camelString = objectMapper.writeValueAsString(camelNode);
result = new ModifiedHttpInputMessage(inputMessage, camelString);
}
return result;
}
다만, 이 코드를 구현하는 과정에서 Generic을 처리하는 것이 쉽지는 않았는데요, Generic의 경우 내부에 여러 개의 클래스를 포함하고 있기 때문에, 어느 클래스를 기준으로 케이스를 파싱해야 할지를 정해주어야 했습니다. 저희가 정한 기준은 Generic의 가장 안쪽 클래스를 기준으로 케이스를 파싱하는 것이었습니다. Generic에 포함된 클래스들의 케이스가 다른 경우는 에러 상황이라 보았고, 변경 작업 중 해당 케이스가 발생할 경우 전환하는 작업을 먼저 진행해 줬습니다.
이를 구현한 코드 예시는 아래와 같았습니다.
// Generic의 가장 안쪽 클래스를 기준으로 하기 위한 탐색
while (type instanceof ParameterizedType) {
var generics = ((ParameterizedType)type).getActualTypeArguments();
// 두 개 이상의 클래스를 가진 Generic은 제외
if (generics.length > 1) {
return new ModifiedHttpInputMessage(inputMessage, origin);
}
type = generics[0];
}
또한, 타입을 두 개 이상 가진 Generic의 경우 변경 대상에서 제외했습니다. 이는 두 개 이상의 클래스를 가진 Generic의 경우, 각각의 클래스를 다시 재귀적으로 탐색해야 하는 등 변경 로직이 너무 복잡해지는 것 같았기에 어느 정도 타협한 부분입니다. 실제로 Map 이외에는 두 개의 클래스를 담는 Generic이 거의 없기도 했기 때문입니다.
아울러 Map의 경우에도 변경 대상에서 제외했는데요, Map의 Key 값은 실제로 사용하는 변수명일 때가 종종 있었기 때문입니다.
{
"id":0,
"name":"string",
"itemPrice":0,
"startDate":"2024-10-24",
"map":{
“SUBSCRIBE_YN”:"Y",
“PROMOTION_YN”:”N”
}
}
다행히 Map의 경우, 내부적으로 다양한 이유에서 REST API의 응답값으로 사용하는 것은 지양하고 있었기 때문에 제외 시에도 큰 문제가 없었습니다(Map을 지양하는 이유).
Response처리
이렇게 Request를 처리한 후에는, Response를 처리하는 방법에 대해서도 고민이 필요했습니다. RequestBodyAdvice대신 쓸 수 있어 보이는 ResponseBodyAdvice는 데이터를 전달받은 후 적용되는 것이 아니라, 데이터를 외부로 전달하기 전에 작동하는 모듈이었기 때문입니다.
이때 이를 개선하고자 적용한 방법은 Jackson에서 제공하는 Custom Deserializer를 사용하는 것이었습니다. Jackson에서는 특정 클래스에서 사용자가 정의한 역직렬화 로직을 수행하도록 할 수 있는데요, 저희 부서의 Response 코드는 기본적으로 ApiResponse라는 클래스로 한 번 Wrapping 되고 있었기 때문에 해당 방법을 사용해 볼 법하다는 생각이 들었습니다.
# 응답값을 표준화하기 위해 사용하고 있는 Wrapper class
public class ApiResponse {
private int code;
private String message;
private T data;
}
Custom Deserializer를 사용하기 위해서는 JsonDeserializer를 extend 한 클래스를 구현하면 됩니다. 이때, 내부에 Generic을 포함한 클래스일 경우 한 가지를 더 추가해줘야 하는데요, 바로 ContextualDeserializer를 implement 하는 것입니다.
이를 구현한 코드 예시는 아래와 같습니다.
public class CustomDeserializer extends JsonDeserializer> implements ContextualDeserializer {
private JavaType javaType;
public CustomDeserializer(JavaType javaType) {
this.javaType = javaType;
}
ContextualDeserializer를 implement 하면, 역직렬화가 시작되는 시점에 기록된 context를 통해 Generic 내부의 타겟 클래스 타입을 알아낼 수 있습니다. 이렇게 알아낸 타입은 멤버 변수로 관리하고 있어야만, 실제 로직 적용 시까지 해당 타입에 대한 정보를 잃지 않고 필요한 시점에 사용할 수 있습니다.
타겟 클래스의 타입을 확보한 이후엔 Request 처리에서 수행한 로직을 그대로 적용할 수 있습니다. Deserialize 메서드는 JsonDeserializer에서 선언된 메서드로, 역직렬화 시 수행하는 메서드입니다. 여기에 RequestBodyAdvice에 사용되었던 로직을 그대로 사용하면 Response에서도 동일하게 타겟 클래스 타입에 맞춘 케이스로 변경할 수 있게 됩니다.
@Override
public ApiResponse deserialize(
JsonParser p,
DeserializationContext ctxt
) throws IOException {
...
if (hasJsonNamingAnnotation(Class.forName(currentType.getTypeName())) ||
hasJsonPropertyAnnotation(Class.forName(currentType.getTypeName()))
) { hasSnake = true; }
if (hasSnake) {
var snakeNode = convert(objectMapper.readTree(originString), "snake");
var snakeString = objectMapper.writeValueAsString(snakeNode);
return objectMapper.readValue(snakeString, javaType);
} else {
var camelNode = convert(objectMapper.readTree(originString), "camel");
var camelString = objectMapper.writeValueAsString(camelNode);
return objectMapper.readValue(camelString, javaType);
}
}
한 가지 유의할 사항은 createContextual 메서드에서 새로운 객체를 반환(Return) 해야 한다는 점입니다. 해당 메서드는 ContextualDeserializer의 구현에 필요한 메서드인데요, Custom Deserializer에 필요한 타입 등의 정보를 설정하는 역할을 수행합니다.
@Override
public JsonDeserializer createContextual(
DeserializationContext ctxt,
BeanProperty property
) {
var type = property != null?
property.getType() : ctxt.getContextualType();
// this.javaType = type;
// return this; <- 이렇게 할 경우 실제 서비스에서는 Error 발생!!!
return new CustomDeserializer(type);
}
하지만 문제는 Jackson이 내부적으로 캐싱되지 않은 Deserializer를 방금 말씀드린 createContextual 메서드를 통해 가져온다는 점입니다.
// DeserializationContext.class
public final JsonDeserializer findRootValueDeserializer(JavaType type) throws JsonMappingException {
JsonDeserializer deser = this._cache.findValueDeserializer(this, this._factory, type);
if (deser == null) {
return null;
} else {
deser = this.handleSecondaryContextualization(deser, (BeanProperty)null, type);
TypeDeserializer typeDeser = this._factory.findTypeDeserializer(this._config, type);
if (typeDeser != null) {
typeDeser = typeDeser.forProperty((BeanProperty)null);
return new TypeWrappedDeserializer(typeDeser, deser);
} else {
return deser;
}
}
}
...
public JsonDeserializer handleSecondaryContextualization(JsonDeserializer deser, BeanProperty prop, JavaType type) throws JsonMappingException {
if (deser instanceof ContextualDeserializer) {
this._currentType = new LinkedNode(type, this._currentType);
try {
deser = ((ContextualDeserializer)deser).createContextual(this, prop);
} finally {
this._currentType = this._currentType.next();
}
}
return deser;
}
그렇기 때문에 저희가 선언한 Custom Deserializer를 그때그때 새로운 인스턴스로 생성해주지 않으면, 여러 세션에서 접근한 요청들이 최초로 생성된 Custom Deserializer 인스턴스를 동일하게 사용하게 됩니다. 이 때문에 멤버 변수로 선언한 타겟 클래스 타입변수는 세션들 사이에서 공통으로 사용되면서 결국 파싱 시에 에러가 발생합니다. 실제 개발 과정에서는 로컬 환경의 경우 단건 요청을 전송 시 문제가 없었던 코드인데, 다행히 스테이지 서버 환경에서 여러 요청을 테스트하며 오류를 발견하여 배포 전에 이슈를 수정할 수 있었습니다.
위와 같은 전략을 통해 Response 변경을 위한 모듈까지 모두 붙여준 후, 계획한 대로 DTO 변경을 진행했는데요, 실제 구현 과정에서는 모듈 적용 및 케이스 변경 후 일정한 기간 동안 의도하지 않은 케이스가 인입되는지 확인하였습니다. 이후 최종적으로 이상이 없는 것을 확인하는 단계까지 거쳐 총 900개가량의 DTO를 서버의 중단 없이 변경하는 작업을 완료할 수 있었습니다.
마치며
구약성서에는 바벨탑에 관한 일화가 있습니다. 그 내용은 인간이 하늘에 닿으려고 마천루를 쌓기 시작했으나, 이를 본 신이 괘씸히 여겨 막았다는 이야기입니다. 신이 취한 조치는 그저 인간의 언어를 여러 개로 나누어 소통을 막은 것뿐이었지만, 그 이후로 공사는 실패하고 말지요.
DTO의 케이스 역시 서비스라는 커다란 탑을 쌓기 전 필요한 공통의 언어라고 볼 수 있을 것 같습니다. 서로 다른 언어(혹은 규약) 때문에 작업이 힘들어지는 건 비단 바벨탑뿐 아니라 소프트웨어도 마찬가지라고 생각합니다. 반대로 단단한 언어의 토대 위에 세워진 서비스는 쉽게 무너지지 않고 변경사항에 좀 더 쉽게 대처할 수 있는 것입니다.
케이스 변경 작업 또한 마찬가지였습니다. 저희 팀 내부에 공통의 규약이 있었기 때문에 이런 처리 방식을 원활히 적용할 수 있었는데요. 변수 네이밍룰, Wrapper class를 사용하는 룰처럼 팀 내에 공유되고 널리 사용되는 방식이 있었기에 적용할 수 있는 방법이었습니다.
여러 시행착오가 많았던 작업이지만, 이 글을 통해 비슷한 작업을 고민하고 계신 분들께 조금이라도 도움이 되며 영감을 줄 수 있으면 좋겠습니다.
지금까지 긴 글을 읽어주셔서 감사합니다.
🔎 if(kakaoAI)2024 발표 영상으로 다시 보기
Written by Luis.kim
Edited by June.6