필드명 두 글자가 대문자면 Jackson이 JSON 키를 잘못 추론한다

분명 값을 보냈는데 400이 떴다. 처음엔 @NotBlank 문제인 줄 알고 어노테이션을 바꿔가며 한참을 헤맸다. 원인은 필드명이었다

문제 상황

프로젝트에서 데이터 매핑 오류를 경험했다. 분명 JSON으로 값을 보냈는데, Controller에서 @Valid 검증에 걸려 400 에러가 발생했다

// 클라이언트 요청
{
  "sId": "2024001",
  "studentName": "홍길동"
}
@Getter
@NoArgsConstructor
public class StudentRequest {
    @NotBlank(message = "학번은 필수입니다.")
    private String sId;

    @NotBlank(message = "이름은 필수입니다.")
    private String studentName;
}

@PostMapping("/student")
public ResponseEntity<?> saveStudent(@RequestBody @Valid StudentRequest request) {
    // request.getSId()가 계속 null로 들어옴
    // -> @Valid에 의해 400 Bad Request 발생
    return ResponseEntity.ok().build();
}

studentName은 정상 매핑됐다. sId만 계속 null이었다

삽질 과정

처음엔 Validation 어노테이션 문제라고 생각했다. @NotBlank, @NotNull, @NotEmpty를 바꿔가며 테스트했다. 소용없었다. 로그를 보니 값 자체가 null로 들어오고 있었다. 어노테이션이 아니라 바인딩 자체가 안 되는 거였다. 그래서 필드명을 바꿔봤다

private String sId;   // 매핑 실패 (null)
private String ssId;  // 매핑 성공

ssId로 바꾸니 정상 작동했다. 범인은 필드명이었다

원인 분석

Lombok이 생성한 getter

Lombok은 Java Bean 규약을 그대로 따른다. sId의 첫 글자 s를 대문자로 바꿔 getSId()를 만든다

private String sId;

// Lombok이 생성한 getter
public String getSId() {
    return this.sId;
}

Jackson의 역직렬화 과정

Jackson은 getter 메서드명에서 필드명을 추론할 때 java.beans.Introspector.decapitalize()를 사용한다

// Step 1: "get" 접두사 제거
getSId() → "SId"

// Step 2: decapitalize() 적용
// 핵심 로직
if (name.length() > 1 &&
    Character.isUpperCase(name.charAt(0)) &&
    Character.isUpperCase(name.charAt(1))) {
    return name; // 첫 두 글자가 모두 대문자면 그대로 유지
}
// 그 외에는 첫 글자를 소문자로

// "SId" 적용:
// - 첫 글자 'S' → 대문자
// - 두 번째 글자 'I' → 대문자
// 결과: "SId" 그대로 유지

결과적으로 Jackson이 찾는 JSON 키는 "SId"인데, 클라이언트가 보낸 키는 "sId"다. 매핑 실패 → null → @NotBlank 검증 실패

ssId가 작동한 이유

private String ssId;

// Lombok 생성
public String getSsId() { ... }

// Jackson 역직렬화
getSsId() → "SsId"
// 첫 글자 'S' 대문자, 두 번째 's' 소문자
// → 일반 규칙 적용: 첫 글자를 소문자로
// → "ssId"

Java Bean 규약 정리

decapitalize() 규칙은 단순하다. 첫 두 글자가 모두 대문자면 첫 글자를 유지한다

입력첫 두 글자출력이유
SId대문자 + 대문자SId첫 두 글자 모두 대문자면 유지
SsId대문자 + 소문자ssId일반 규칙 – 첫 글자 소문자로
URL대문자 + 대문자URL약어는 그대로 유지
userId소문자 + 소문자userId이미 소문자

Response에서 더 이상한 일이 일어났다

Request 문제를 해결하고 안심했는데, 이번엔 Response에서 예상 밖의 결과가 나왔다

@Getter
@AllArgsConstructor
public class StudentScoreResponse {
    private String sId;
    private String cId;
    private String studentName;
    private Integer score;
}

기대한 응답

{
  "sId": "2024001",
  "cId": "CS101",
  "studentName": "홍길동",
  "score": 95
}

실제 응답

{
  "sid": "2024001",
  "cid": "CS101",
  "studentName": "홍길동",
  "score": 95
}

프론트엔드 개발자가 sId, cId로 파싱하려다 데이터를 못 받았다고 했다

원인 — 솔직히 완벽하게 파악하지 못했다

ackson 설정, application.yml, 버전, 필터, 의존성까지 다 뒤졌다. 특이사항이 없었다. 정확한 원인을 찾지 못했다. 실무에서 원인을 완벽히 파악하지 못하는 상황이 생긴다. 그럴 때는 해결책을 찾는 게 먼저였다

해결 — @JsonGetter

이미 프론트엔드와 API 스펙을 sId, cId로 정했기 때문에 @JsonGetter로 JSON 키를 명시적으로 지정했다

@Getter
@AllArgsConstructor
public class StudentScoreResponse {
    private String sId;

    @JsonGetter("sId")  // 직렬화 시 JSON 키를 "sId"로 강제 지정
    public String getSId() {
        return sId;
    }

    private String cId;

    @JsonGetter("cId")
    public String getCId() {
        return cId;
    }

    private String studentName;
    private Integer score;
}

@JsonGetter는 Jackson에게 “복잡한 규칙은 다 무시하고, 이 이름을 키로 써줘”라고 명시적으로 알려주는 것이다. decapitalize() 등 모든 추론 로직을 우회한다

snake_case ↔ camelCase 문제

문제는 여기서 끝이 아니었다. PHP 서버와 Java 서버가 공존하는 구조였다

클라이언트 → Java 서버 → PHP 서버 → Java 서버 → 클라이언트

PHP는 snake_case, Java는 camelCase를 쓴다

PHP 서버 응답

{
  "student_room": "활빈당",
  "student_name": "홍길동",
  "class_number": 3
}

이 응답을 ObjectMapper로 파싱하면 모든 필드가 null이었다

@Getter
@NoArgsConstructor
public class StudentRoomResponse {
    private String studentRoom;   // null
    private String studentName;   // null
    private Integer classNumber;  // null
}

첫 번째 시도 — @JsonGetter만으로는 반쪽

@JsonGetter를 붙였더니 PHP 응답 파싱은 됐는데, 클라이언트 응답이 snake_case로 나갔다

// 클라이언트가 받은 응답 (의도하지 않음)
{
  "student_room": "활빈당"
}

// 클라이언트가 기대한 응답
{
  "studentRoom": "활빈당"
}

입력과 출력을 동시에 제어해야 했다

해결 — @JsonProperty + @JsonAlias 조합

이전에 비슷한 버그를 겪었던 경험 덕분에 비교적 빠르게 찾았다

@Getter
@NoArgsConstructor
public class StudentRoomResponse {
    @JsonProperty("studentRoom")   // 직렬화(Java → JSON): "studentRoom" 사용
    @JsonAlias("student_room")     // 역직렬화(JSON → Java): "student_room"도 허용
    private String studentRoom;

    @JsonProperty("studentName")
    @JsonAlias("student_name")
    private String studentName;

    @JsonProperty("classNumber")
    @JsonAlias("class_number")
    private Integer classNumber;
}

최종 흐름

PHP 서버 응답       Java 파싱         클라이언트 응답
(snake_case)  →   (camelCase)  →   (camelCase)
student_room  →   studentRoom  →   studentRoom

@JsonAlias는 “이 이름도 받아주세요”, @JsonProperty는 “보낼 때는 이 이름으로 보낼게요”라는 의미다

어노테이션역직렬화 (JSON → Java)직렬화 (Java → JSON)
@JsonProperty해당 이름으로 매핑해당 이름으로 출력
@JsonAlias해당 이름도 허용영향 없음

@JsonAlias는 배열로 여러 별칭을 동시에 허용할 수도 있다

@JsonAlias({"student_name", "StudentName", "STUDENT_NAME"})
private String studentName;

레거시 시스템 통합이나 다양한 외부 API와 통신할 때 유용하다

상황별 해결 방법 정리

명확한 네이밍 (가장 추천)

// Bad
private String sId;
private String uId;

// Good
private String studentId;
private String userId;

규칙 충돌 자체를 막는다. 유지보수도 쉽다

@JsonProperty — Request 또는 양방

기존 API 스펙을 유지해야 할 때

@JsonProperty("sId")
@NotBlank(message = "학번은 필수입니다.")
private String sId;

@JsonGetter — Response 전용

원인 불명의 직렬화 이상 동작을 회피할 때

@JsonGetter("sId")
public String getSId() {
    return sId;
}

@JsonProperty + @JsonAlias — 네이밍 규칙이 다른 외부 시스템 연동

@JsonProperty("studentRoom")
@JsonAlias("student_room")
private String studentRoom;

Validation 어노테이션 정리

삽질 중에 헷갈렸던 부분이라 함께 정리한다

어노테이션null“” (빈 문자열)” ” (공백)대상 타입
@NotNull미허용허용허용모든 타입
@NotEmpty미허용미허용허용String, Collection, Map, Array
@NotBlank미허용미허용미허용String만

문자열 필드는 @NotBlank, 컬렉션·배열은 @NotEmpty, 객체 참조는 @NotNull을 쓰는 게 맞다

마무리

한 줄로 요약하면, 필드명 첫 두 글자가 모두 대문자면 Jackson이 JSON 키를 예상과 다르게 추론한다. Validation 검증이 이상하게 동작할 때는 어노테이션을 바꾸기 전에 먼저 데이터가 객체에 제대로 바인딩됐는지 확인하는 게 먼저다. 그리고 Request만 고치고 끝내지 말고 Response도 반드시 함께 확인해야 한다