Spring Boot @JsonAlias 대소문자 매핑

퇴근 직전, Spring Boot 기반 레거시 API에서 외부 연동 데이터 등록이 실패한다는 장애 알림이 들어왔습니다. 원인은 @JsonAlias 미적용으로 인한 JSON 키 대소문자 미스매치였습니다. 외부 측 로그에서는 9,400건이 정상적으로 전송되고 있었지만, 수신 측 데이터베이스에는 단 한 건도 INSERT되지 않고 있었습니다.

이 글에서는 Spring Boot @JsonAlias 대소문자 매핑 이슈로 발생한 레거시 API 장애를 어떻게 추적하고 해결했는지, 그리고 외부 연동 API를 설계할 때 반드시 고려해야 할 직렬화 전략을 정리했습니다.

같은 증상으로 고민 중인 개발자라면, 단순히 코드를 고치는 것을 넘어 왜 Map 기반 파라미터가 운영 환경에서 위험한지를 함께 점검하시기 바랍니다.


증상: API는 200을 반환하지만 DB에는 아무것도 들어오지 않는다

외부 시스템에서 전송한 JSON 본문은 다음과 같은 형태였습니다.

{
  "result_code": "211",
  "result_type": "reg",
  "result_count": 9400,
  "json_result_db": [
    {
      "PHONE_KEY": "173",
      "PHONE_NAME": "(마스킹)",
      "PHONE1": "(마스킹)",
      "PHONE_MEMO": ""
    }
  ]
}

수신 측 API 게이트웨이까지는 정상적으로 들어왔습니다. 응답 코드 역시 처음에는 200으로 보였고, 외부 측에서는 “요청은 잘 보내지는 것으로 보이는데 등록만 안 되는 것 같다”는 문의가 도착했습니다.

등록 직전 단계의 로그 INSERT부터 다음과 같은 예외가 쏟아지고 있었습니다.

org.mybatis.spring.MyBatisSystemException:
### Error updating database.
### Cause: org.apache.ibatis.type.TypeException:
    Could not set parameters for mapping:
    ParameterMapping{property='writeParam.parameter', mode=IN, javaType=class java.lang.Object, jdbcType=null, ...}
### Cause: java.sql.SQLException: Value length(4549587) exceeds the maximum size(65534)

표면적인 오류는 “파라미터 #6에 대해 JdbcType을 설정할 수 없다” 였습니다. 하지만 이 메시지는 결과이지 원인이 아니었고, 진짜 문제는 더 위쪽에 숨어 있었습니다.


1차 분석: MyBatis TypeException은 진짜 원인이 아니다

MyBatis의 TypeException은 운영 환경에서 가장 오해받기 쉬운 예외입니다. 처음 메시지를 보면 마치 매퍼 XML이나 JdbcType 설정 문제처럼 보입니다. 그러나 이 사례에서 메시지를 끝까지 따라가 보면 다음 두 가지 단서가 보입니다.

첫째, Value length(4549587) exceeds the maximum size(65534) 라는 부수 오류가 함께 나타났습니다. 이는 공통 로그 테이블에 요청 본문 전체를 텍스트로 저장하던 중, Altibase의 VARCHAR 최대 길이(65,534바이트)를 초과해 발생한 것이었습니다. 즉 장애 그 자체가 아니라, 장애를 기록하려다 또 실패한 흔적이었습니다.

둘째, 진짜 원인은 그 직전 비즈니스 로직에서 발생한 필수값 누락 오류였습니다. 비즈니스 INSERT 쿼리에 NOT NULL 컬럼이 비어 있었고, 해당 컬럼은 분명히 외부에서 보낸 JSON에 값이 채워져 있는 항목이었습니다.

여기서 가설은 하나로 좁혀졌습니다. API가 JSON을 받기는 했지만, 실제 비즈니스 로직에서 필드 값을 꺼낼 때 ‘null’로 인식하고 있다는 것. 코드를 열어 확인한 결과, 의심은 사실이었습니다.


진짜 원인: Map 기반 파라미터의 대소문자 미스매치

문제의 API는 다음과 같은 형태였습니다. 컨트롤러에서 @RequestBody Map<String, Object> 로 본문을 그대로 받고, 서비스 단에서 키 이름으로 값을 꺼내 매퍼에 전달하는 전형적인 레거시 구조입니다.

@PostMapping("/api/data/insert")
public Response insert(@RequestBody Map<String, Object> body) {
    List<Map<String, Object>> rows =
        (List<Map<String, Object>>) body.get("json_result_db");

    for (Map<String, Object> row : rows) {
        String memo = (String) row.get("phone_memo");   // 소문자 키로 조회
        String name = (String) row.get("phone_name");
        // ... INSERT
    }
}

반면 외부 시스템이 보낸 JSON의 키는 전부 대문자(SCREAMING_SNAKE_CASE)였습니다.

{ "PHONE_MEMO": "", "PHONE_NAME": "(마스킹)", "PHONE1": "(마스킹)" }

Map은 단순한 키-값 컨테이너이기 때문에 대소문자가 다르면 같은 키로 인식하지 않습니다. row.get("phone_memo")는 무조건 null을 반환했고, 그 값이 NOT NULL 컬럼에 들어가면서 INSERT가 실패한 것이었습니다.

처음 보면 단순한 실수처럼 느껴지지만, 이 구조에는 더 근본적인 문제가 있습니다.

관점Map 기반 파라미터DTO 기반 파라미터
컴파일 타임 검증불가능 (런타임에만 발견)가능 (필드/타입 체크)
키 오타 방지불가능IDE 자동완성으로 방지
대소문자 대응호출 측 규약에 100% 의존@JsonAlias로 흡수 가능
명세 문서화별도 작성 필요DTO 자체가 명세

레거시 시스템에서 Map<String, Object>는 빠르게 만들기에는 좋지만, 외부 시스템 호환성과 운영 안정성에는 매우 취약한 선택입니다.


해결: DTO 전환과 @JsonAlias로 대소문자 모두 흡수

해결 방향은 두 단계로 나누어 진행했습니다. 첫 번째는 파라미터를 Map에서 명시적인 DTO로 바꾸는 것이고, 두 번째는 @JsonAlias를 사용해 외부 시스템이 어떤 케이스로 보내더라도 동일하게 매핑되도록 만드는 것입니다.

@Getter
@Setter
public class DataInsertRow {

    @JsonAlias({"PHONE_MEMO", "phone_memo"})
    private String phoneMemo;

    @JsonAlias({"PHONE_NAME", "phone_name"})
    private String pdCustName;

    @JsonAlias({"PHONE1", "phone1"})
    private String phone1;
}

@JsonAlias는 Jackson이 역직렬화할 때 여러 개의 후보 키를 동시에 받아들이도록 해주는 어노테이션입니다. 위 코드처럼 대문자, 소문자, 그리고 자바 카멜케이스(phoneMemo)까지 모두 한 필드로 매핑됩니다.

컨트롤러도 다음과 같이 정리했습니다.

@PostMapping("/api/data/insert")
public Response insert(@RequestBody DataInsertRequest request) {
    for (DataInsertRow row : request.getRows()) {
        // row.getPdMemo()로 안전하게 꺼낸다
    }
}

이 변경 이후 외부 시스템이 키를 어떤 케이스로 보내든 동일하게 동작하며, 필드 누락이 발생하면 컴파일이나 검증 단계에서 즉시 잡힙니다.


spring.jackson.property-naming-strategy로는 왜 부족한가

Spring Boot의 설정 한 줄로 해결될 것처럼 보이는 옵션이 있습니다.

spring.jackson.property-naming-strategy=SNAKE_CASE

이 설정은 자바의 카멜케이스 필드(phoneMemo)와 JSON의 스네이크케이스 키(phone_memo) 사이의 변환을 자동화합니다. 일반적인 외부 연동에서는 매우 유용한 옵션입니다.

그러나 이번 사례에는 부족했습니다. 이유는 다음과 같습니다.

SNAKE_CASE 전략은 어디까지나 소문자 스네이크케이스(phone_memo)를 기준으로 변환합니다. 반면 호출 측이 보낸 키는 전부 대문자 스네이크케이스(PHONE_MEMO)였습니다. Jackson은 두 케이스를 같은 키로 보지 않으며, 이 경우 매칭에 실패합니다.

호출 측 키SNAKE_CASE 단독@JsonAlias 적용
phone_memo매핑 성공매핑 성공
phoneMemo매핑 실패매핑 성공
PHONE_MEMO매핑 실패매핑 성공

요약하면 SNAKE_CASE 전략은 카멜케이스↔소문자 스네이크 변환에만 동작하며, 외부 시스템이 대문자 케이스를 사용하는 경우 별도 처리가 필요합니다. @JsonAlias는 이 한계를 가장 깔끔하게 보완하는 수단입니다.


운영 관점: 같은 장애가 반복되지 않게 하려면

코드 패치만으로 끝낼 사안이 아니었기에, 다음 항목을 함께 정리해 운영 체크리스트에 반영했습니다.

첫째, 외부 연동 API의 파라미터는 가능하면 Map 대신 DTO를 사용합니다. Map은 초기 개발 속도는 빠르지만, 키 규약이 미세하게 바뀌면 운영 중에 조용히 실패합니다.

둘째, 외부에서 들어오는 JSON 키 케이스를 사전에 정의합니다. 카멜케이스, 소문자 스네이크, 대문자 스네이크 중 무엇을 표준으로 둘지 명시하고, 양쪽 시스템이 동일한 규약을 공유해야 합니다.

셋째, 공통 로그 테이블의 컬럼 길이를 확인합니다. 이번 장애에서는 요청 본문 자체가 너무 커서(약 4.5MB) 로그 INSERT가 실패하면서 실제 원인 메시지가 가려졌습니다. 로그 컬럼이 VARCHAR 한정인 경우 본문은 별도 저장소(파일, CLOB 등)로 분리하거나, 길이 초과 시 잘라서 저장하는 방어 코드를 추가해야 합니다.

넷째, NOT NULL 컬럼 누락 시점의 로그를 비즈니스 로직에서 미리 캐치합니다. DB 단까지 내려가 실패하기 전에 컨트롤러/서비스 단에서 검증을 수행하면 원인 파악 시간이 크게 줄어듭니다.


자주 묻는 질문 (FAQ)

Q1. @JsonAlias와 @JsonProperty는 어떻게 다른가요?

@JsonProperty는 필드와 JSON 키를 1:1로 고정 매핑할 때 사용합니다. 반면 @JsonAlias여러 후보 키를 동시에 허용할 때 사용합니다. 외부 시스템이 보내는 키 케이스가 다양하거나 변경 가능성이 있다면 @JsonAlias가 더 유연합니다.

Q2. SNAKE_CASE 설정과 @JsonAlias를 함께 써도 되나요?

함께 사용해도 충돌하지 않습니다. SNAKE_CASE 전략으로 일반적인 변환을 자동화하고, 예외적인 대문자 케이스만 @JsonAlias로 보강하는 방식이 깔끔합니다. 단, 우선순위를 명확히 인지하고 단위 테스트로 검증하시기 바랍니다.

Q3. Map으로 받는 레거시 API를 한꺼번에 DTO로 바꿔야 할까요?

전수 전환이 이상적이지만 비용이 큽니다. 변경이 자주 일어나는 API, 외부 연동 API, 장애 이력이 있는 API부터 우선적으로 DTO로 전환하시기 바랍니다. 내부에서만 사용하는 단순 조회성 API는 후순위로 두어도 무방합니다.

Q4. MyBatis TypeException이 나오면 무조건 매퍼 XML 문제인가요?

아닙니다. 이번 사례처럼 상위 단계에서 null이 전달되어 발생하는 경우가 많습니다. 메시지의 “parameter #N”이 어떤 컬럼인지 매퍼 XML과 매칭한 뒤, 그 값이 어떻게 들어오는지 거꾸로 추적하는 것이 정공법입니다.

Q5. Altibase의 65534 바이트 제한은 우회할 수 없나요?

VARCHAR 자료형 자체의 한계이므로, 더 큰 데이터를 저장하려면 CLOB 같은 LOB 자료형을 사용하거나 데이터를 여러 행으로 분할 저장해야 합니다. 로그 저장 용도라면 데이터베이스 대신 파일 기반 로깅(예: 일별 로그 파일)을 권장합니다.


마무리

이번 장애의 표면 메시지는 MyBatis의 TypeException이었지만, 진짜 원인은 그보다 두 단계 위인 JSON 키 대소문자 미스매치였습니다. Map<String, Object>로 외부 본문을 받는 레거시 구조는 작은 규약 차이에도 운영 환경에서 조용히 실패하기 쉽습니다.

해결의 핵심은 두 가지였습니다. 파라미터를 DTO로 전환하여 컴파일 타임의 안전망을 확보하고, @JsonAlias로 대문자·소문자·카멜케이스를 모두 흡수해 외부 시스템의 키 규약 변경에 강한 구조로 바꾼 것입니다. 여기에 더해 공통 로그 컬럼 크기, NOT NULL 사전 검증 같은 운영 측면의 방어선을 함께 보강했습니다.

비슷한 외부 연동 API를 운영 중이라면, 다음번 장애가 발생하기 전에 한 번씩 점검해 보시기 바랍니다. 한 줄의 어노테이션이 야간 호출 한 통을 막아 줄 수 있습니다.


핵심 요약

  • Map<String, Object> 기반 파라미터는 키 대소문자 차이에 매우 취약합니다.
  • @JsonAlias({"PHONE_MEMO", "phone_memo"})로 여러 키 케이스를 한 필드에 동시에 매핑할 수 있습니다.
  • spring.jackson.property-naming-strategy=SNAKE_CASE 만으로는 대문자 스네이크케이스를 처리할 수 없습니다.
  • MyBatis TypeException은 결과 메시지일 뿐, 진짜 원인은 상위 단계의 null 전달인 경우가 많습니다.
  • 공통 로그 테이블의 컬럼 크기 초과는 진짜 장애 원인을 가리는 부수 오류로 이어질 수 있습니다.

댓글 남기기