Standardizing API Case Handling Without Redeployment

추가 배포 없이 API의 case 통일시키기 / if(kakaoAI)2024

https://www.youtube.com/watch?v=ZE5xgQuvHFQ&list=PLwe9WEhzDhwGXZ0REjCOYosGkrDkkHZKk&index=7&ab_channel=kakaotech

서비스 스펙

프레임 워크 : Spring

Parser : Jackson

Domain : 한달 주기로 결제를 진행하는 구독 서비스

사용 중인 CASE 종류: 카멜, 스네이크, 케밥

변경하려고 했을 때 만난 문제들

  1. API 전달 시 CASE가 다른 경우

    • 최초 요청은 카멜, 전달받는 쪽에서 스네이크를 요구하는 경우

    • 하나의 요청에 두 개의 DTO가 필요하게 됨

  2. DTO 내부에 CASE가 다른 DTO를 포함시켜야 하는 경우

    • DTO 내, 외부의 CASE가 섞여있음

    • 받는 쪽에서는 어느 CASE로 파싱을 해야할 지 모르는 경우가 생김

  3. Jackson에서 Unknown Property설정의 default false설정으로 인해 매핑되지 않는 필드가 있더라도 예외를 던지지 않고 런타임에 도달해야 해당 필드가 null임을 알 수 있음.

  4. MSA 환경에서 무중단 배포를 하기 위해서는:

    1. Callee쪽에서 변경할 CASE의 API를 새로운 버전으로 배포

      1. Caller: v1/api/users

      2. Callee: v1/api/users, v2/api/users (new!)

    2. Caller쪽에서 버전 변경한 API 배포

      1. Caller: v1 → v2/api/users (new!)

      2. Callee: v1/api/users(deprecated), v2/api/users

    3. Callee쪽에서 이전 버전 API 삭제

      1. Caller: v2/api/users

      2. Callee: v2/api/users

    Callee가 많아지면, 대규모 배포하기 위해 서버점검을 걸고 작업을 하는게 문제

처리방안

  1. 모든 서버에 CASE와 상관없이 파싱이 가능하도록 해주는 모듈 추가

  • 대상이 되는 서버에 모듈을 설정

  • Request DTO에는 RequestBodyAdvice 사용

  • Response DTO에는 Custom Deserializer 사용한 모듈 설정

    • 케이스와 상관 없이 받아주는 서버 케이스에 맞춰 요청을 변경하는 전략

  1. 각 서버의 CASE를 하나로 통합하고, 변경 모듈 삭제

Request 처리

Image
  • Filter 사용: 아직 어느 Controller에 매핑됐는지 모르는 상태. Target class에 맞춘 parsing불가.

  • Intercepter 사용: Target class는 알 수 있지만, Request를 DispatcherServlet에서 받은 그대로 돌려주기 때문에 변경 불가.

    • 인터셉터에서는 기존의 Request를 변조할 수 없음

    • 변조하려면 Byte 코드를 조작해야 하는데, 위험성이 존재함

    • 스프링에서 지원하는 인터페이스 또한 없기에 불가능.

  • AOP 사용

    • SpringMVC에서 사용할 수 있는 RequestBodyAdvice를 사용

    • Controller를 찾아, RequestBody에 Value를 매핑하기 직전 적용됨.

    public interface RequestBodyAdvice {
    	boolean supports(..);
    	HttpInputMessage beforeBodyRead(..); <-body를 읽기전에, 작업
    	Object afterBodyRead(..); }
  • RequestBodyAdvice 사용 전략, beforeBodyRead 구현

    1. 스네이크로 먼저 변환하고, Error가 잡히면 Camel로 변경 → 실패

      • Unknwon Property=FALSE 설정 때문에 에러로 잡히는 것이 아니라 null 값이 설정

    2. 어노테이션 활용 → 성공

      • Snake 변환 시 @JsonNamingProerty, @JsonProperty 사용

      • 타겟 클래스에 두 가지의 어노테이션이 존재하는지 확인하고 Snake 변경 여부 판단

  • Edge Cases 처리:

    • Json의 Key만 변경해야하는 경우

      • JsonNode로 Key만 뽑아내서 convert

    • 여러개의 Generic이 중첩되는 경우

      • Generic 사이에 case가 다른 경우 오류라고 가정. 가장 InnerDto를 기준으로 parsing 작업 처리.’

      • "Generic 사이에 case가 다른 경우 오류라고 가정"하는 이유는?

        현실적으로 모든 중첩 Generic의 케이스를 정확히 감지하고 처리하는 건 불가능에 가깝습니다

        • ApiResponse<PageResponse<YourDto>>처럼 중첩 구조에서,

        • ApiResponse는 camelCase, PageResponse는 kebab-case, YourDto는 snake_case라면?

        • 각 레벨마다 서로 다른 변환 전략을 적용해야 하고, JSON 파싱 시 중간 구조(Wrapper)를 지나서 내부 DTO까지 정확하게 매핑하기 위해선 각 타입을 다 추적해야 함.

        • 그래서 그냥 InnerDto를 기준으로 parsing작업을 처리해서, 나머지 필드는 무시.

          • OuterDto: Kebab → CamelToSnake: null값

          • InnerDto: Camel → CamelToSnake: 정상적으로 변환

    • Map과 같이 타입이 두개 이상 가진 제너릭일 경우 제외

      • 클래스의 클래스를 재귀적으로 탐색해야함

        • 타입이 두개 이상인 클래스는 Map 제외하고는 없었음.

        • Map의 Key값을 실제 변수명으로 사용할 때가 있어 Map을 제외함.

Response 처리

  • 서버에서 필요한 응답값을 외부에 요청하고, 받을때 Wrapper class 사용중.

  • Jackson에서 특정 클래스에 Custom한 Deserializer를 적용하는 기능을 사용하기로 결정

    • Override된 deserialize 메서드 사용

    • RequestBodyAdvice에 적용한 로직을 그대로 적용

      • 기준 Generic 추출

      • Map타입 제외

      • 어노테이션을 통한 Case 판단

  • Trouble Shooting

    • Jackson 내부에서 Deserializer는 map에 캐싱되어 재사용됨 (동시성 문제)

    @Override
    public JsonDeserializer<?> createContextual(
    		DeserializationContext ctxt,
    		BeanProperty property
    ){
    		var type = property != null?
    		property.getType() : ctxt.getContextualType();
    		
    		//return this; 일 경우 이전에 캐싱된 type으로 파싱되기에 error발생
    		return new CustomDeserializer(type); //따라서 원하는 타입으로 인스턴스를 따로 생성함
    }

결론

Image

외부에서 어떤 케이스든, 두 가지 모듈이 적용된 상태에서는 원하는 형태의 케이스로 변환할 수 있음

  • Caller와 Callee를 반복적으로 배포할 필요가 없어짐

  • 배포 순서 문제도 없어짐

  • 의도치 않은 케이스가 있는지는 로그를 통해 일정기간 모니터링

결과:

  • 총 900개 가량의 DTO를 서비스 중단 없이 일원화

  • CASE 또한 프로토콜의 일부로, 복잡성을 높이는 원인이 될 수 있음

  • 팀 내 코딩 컨벤션이 있었기 때문에 가능했던 작업

    • 정규표현식을 이용해 case를 변경했기 때문에 숫자나 영어 대문자가 있었으면 의도하지 않은 결과가 나올 수 있었음

    • Naming, Wrapper class rule이 있어서 ApiResponse Dto에 커스텀 Deserializer를 일괄적으로 처리할 수 있었음.

  • 팀 내 코딩 컨벤션은 쉬운 변경을 위한 기초가 됨

제한사항:

  • 변환 과정에서 리플렉션을 사용하기 떄문에 속도에 민감하면 적용 고려 필요

  • RequestBodyAdvice는 MVC에만 적용이 가능하기 때문에 Webflux에는 적용 불가

Last updated