Spring에서 sId가 필드가 매핑되지 않았던 이유

사소해 보이는 필드명 하나가 어떻게 수 십분의 디버깅을 요구했는지, 그리고 그 과정에서 배운 것들

문제 상황

프로젝트에서 데이터 매핑 오류를 경험했다. 분명 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로 바꾸니 정상 작동했다. 범인은 필드명이었다

기술적 원인 분석

내가 처음에 잘못 이해한 것
  • “Java Bean 규약은 두 번째 글자가 대문자면 첫 글자를 소문자로 유지한다(sId → getsId()). 하지만 Lombok은 이 규칙을 무시하고 getSId()를 만든다.”

이건 틀렸다. 코드를 직접 확인하고 여러가지 정보를 통해 정확한 원인을 파악했다

정확한 원인

Lombok이 생성한 getter
private String sId;

// Lombok이 생성한 Getter (Java Bean 규약 준수)
public String getSId() {
    return this.sId;
}

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

Jackson의 역직렬화 과정

Jackson이 getSId() 메서드를 발견하고 필드명을 추론하는 과정

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

// Step 2: java.beans.Introspector.decapitalize() 적용
// 이 메서드의 핵심 로직
if (name.length() > 1 && 
    Character.isUpperCase(name.charAt(0)) &&
    Character.isUpperCase(name.charAt(1))) {
    return name;  // 첫 두 글자가 모두 대문자면 그대로 유지
}
// 그 외에는 첫 글자를 소문자로
chars[0] = Character.toLowerCase(chars[0]);

// "SId" 적용: 
// - 첫 글자 'S' → 대문자
// - 두 번째 글자 'I' → 대문자
// 결과: "SId" 그대로 유지
매핑 실패
  • Jackson이 찾는 JSON 키: “SId”
  • 클라이언트가 보낸 키: “sId”

매핑 실패 → null 할당 → @NotBlank 검증 실패

ssId가 작동한 이유
private String ssId;

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

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

// JSON의 "ssId"와 정확히 매칭!

java Bean 규약 정리

java.beans.Introspector.decapitalize() 규칙

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

핵심: 첫 두 글자가 모두 대문자일 때문 첫 글자를 유지한다

그런데 Response에서는 더 이상한 일이 일어났다

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

Response 문제 상황

@Getter
@AllArgsConstructor
public class StudentScoreResponse {
    @Schema(description = "학생 학번")
    private String sId;
    
    @Schema(description = "과목 코드")
    private String cId;
    
    @Schema(description = "학생 이름")
    private String studentName;
    
    @Schema(description = "점수")
    private Integer score;
}
기대한 응답
{
  "sId": "2024001",
  "cId": "CS101",
  "studentName": "홍길동",
  "score": 95
}
실제 응답
{
  "sid": "2024001",     // 소문자 i로 시작
  "cid": "CS101",       // 소문자 i로 시작
  "studentName": "홍길동",  // 정상
  "score": 95           // 정상
}
  • 프론트엔드 개발자가 sId, cId로 파싱하려다 데이터를 못 받았다고 한다

Response 문제 원인 – 잘 모르겠음

항상 사용하던 Getter라서 생각하는 방향은 단순했다

// 예상되는 동작
@Getter
private String sId;
getsId() → "sId" → decapitalize → 결과: {"sId": "2024001"}

그런데 실제로는 “sid”가 나왔다

다양한 원인을 찾아봤다

# 1. 글로벌 Jackson 설정 확인
@Configuration 파일들 확인 → 특별한 설정 없음

# 2. application.yml 확인
spring.jackson.property-naming-strategy 확인 → 설정 없음

# 3. Jackson 버전 확인
Jackson 버전, Spring Boot 버전 확인 → 최신 안정 버전

# 4. 필터나 인터셉터 확인
응답을 변환하는 필터 확인 → 없음

# 5. 다른 라이브러리 충돌 확인
의존성 검토 → 특이사항 없음

정확한 원인을 찾지 못했다. 하지만 실무에서는 원인을 완벽히 파악하지 못하더라도 해결책을 찾는 것이 중요했다

Response 해결 방법 – @JsonGetter

원인은 몰라도 해결책은 명확했다. 이미 프론트엔드와 API 스펙을 sId, cId로 정했기 때문에 @JsonGetter를 사용하기도 했다.

@Getter
@AllArgsConstructor
public class StudentScoreResponse {
    @Schema(description = "학생 학번")
    private String sId;
    
    @JsonGetter("sId")  // 직렬화 시 JSON 키를 "sId"로 강제 지정
    public String getSId() {
        return sId;
    }
    
    @Schema(description = "과목 코드")
    private String cId;
    
    @JsonGetter("cId")  // 직렬화 시 JSON 키를 "cId"로 강제 지정
    public String getCId() {
        return cId;
    }
    
    @Schema(description = "학생 이름")
    private String studentName;
    
    @Schema(description = "점수")
    private Integer score;
}
결과
{
  "sId": "2024001",     // 의도한 대로
  "cId": "CS101",       // 의도한 대로
  "studentName": "홍길동",
  "score": 95
}

프론트엔드에서 정상적으로 데이터를 받을 수 있게 되었다

@JsonGetter가 작동하는 원리

// Jackson 직렬화 과정

// 1. @JsonGetter 어노테이션이 있는 메서드 발견
@JsonGetter("sId")
public String getSId() { ... }

// 2. 어노테이션에 명시된 값을 JSON 키로 직접 사용
JSON 키 = "sId"  
// decapitalize 과정과 기타 모든 추론 로직을 완전히 우회!

// 3. 메서드 실행 결과를 값으로 사용
JSON 값 = getSId() 반환값 = "2024001"

// 최종 JSON
{ "sId": "2024001" }  // 의도한 대로
핵심

@JsonGetter는 Jackson에게 “복잡한 규칙은 다 무시하고, 이 메서드로 JSON을 만들 때 내가 지정한 이 이름을 키로 써줘”라고 명시적으로 알려주는 것이다

왜 studentName과 score는 문제가 없었나?

private String studentName;  // 첫 글자 소문자
private Integer score;       // 첫 글자 소문자

// Lombok 생성
public String getStudentName() { ... }
public Integer getScore() { ... }

// Jackson 직렬화
getStudentName() → "StudentName" 
→ 첫 글자 'S' 대문자, 두 번째 't' 소문자
→ 일반 규칙 적용: 첫 글자를 소문자로
→ "studentName"

getScore() → "Score"
→ 일반 규칙 적용: 첫 글자를 소문자로
→ "score"

일반적인 camelCase 패턴은 문제가 없다

실무에서의 교훈

완벽하게 원인을 파악하지 못한 것은 찝찝했지만, 실무에서 중요한 것

  • 문제를 해결하는 것: 원인을 모르더라도 해결책이 있다면 일단 OK
  • 재발을 방지하는 것: @JsonGetter로 명시적으로 제어
  • 팀에 공유하는 것: 같은 패턴을 피하도록 가이드 작성

Request vs Response 정리

구분Request (역직렬화)Response (직렬화)
데이터 흐름JSON → 객체Java 객체 → JSON
문제 증상필드가 null로 들어롬JSON 키가 의도와 다름
원인Jackson이 SId 키를 찾음정확한 원인 미상(아마 Jackson 동작)
해결 어노테이션@JsonProperty(“sId”)@JsonGetter(“sId”)

실무에서 마주친 또 다른 케이스

snake_case와 camelCase

문제는 여기서 끝이 아니었다. 실무 프로젝트에서 PHP 서버와 Java 서버가 공존하는 구조였다

상황 설명

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

  • 클라이언트가 Java 서버에 요청
  • Java 서버가 PHP 서버에 API 호출
  • PHP 서버가 응답(snake_case)
  • Java 서버가 클라이언트 응답 (camelCase로 변환)

문제 발생

PHP 서버는 snake_case를 사용하고, Java는 camelCase를 사용했다

PHP 서버 응답
{
  "success": true,
  "data": {
    "student_room": "활빈당",
    "student_name": "홍길동",
    "class_number": 3
  },
  "message": ""
}

이 응답을 ObjectMapper로 파싱한 후 클라이언트에 전달하려고 했는데

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

모든 필드가 null로 나왔다. PHP의 student_room을 Java의 studentRoom으로 매핑하지 못했다

첫 번째 시도 – @JsonGetter만 사용

이전에 @JsonGetter를 사용해서 해결했으니 사용해보았다

@Getter
@NoArgsConstructor
public class StudentRoomResponse {
    private String studentRoom;

    @JsonGetter("student_room")
    public String getStudentRood() {
        return studentRoom
    }

    // 아래와 같이 해보기도 하고 여러가지를 테스트 해보았다
    @JsonGetter("student_room")
    public String getStudent_rood() {
        return studentRoom
    }    
}

반쪽뿐인 해결

  • PHP 응답 파싱: student_room → studentRoom 정상 매핑
  • 클라이언트 응답: JSON키가 student_room으로 나감 (camelCase로 변환이 안됨)
// 클라이언트가 받은 응답 (의도하지 않음)
{
  "student_room": "활빈당",     // snake_case로 나감
  "student_name": "홍길동",
  "class_number": 3
}

// 클라이언트가 기대한 응답
{
  "studentRoom": "활빈당",      // camelCase로 받고 싶음
  "studentName": "홍길동",
  "classNumber": 3
}

해결 – @JsonProperty + @JsonAilas 조합

이전에 비슷한 버그를 겪었던 경험 덕분에 비교적 빠르게 해결할 수 있었다

@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 응답 파싱 (역직렬화)
// 클라이언트에게 보내는 데이터
{
  "studentRoom": "활빈당"
}
// @JsonProperty("studentRoom") 덕분에 camelCase로 변환!

@JsonAlias vs @JsonProperty

어노테이션역직렬화(JSON → Java)직렬화(Java → JSON)사용 시나리오
@JsonProperty해당 이름으로 매핑해당 이름으로 출력양방향 모두 제어
@JsonAlias해당 이름도 허용영향 없음여러 이름 수용(입력 전용)

동작 원리 상세

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

// 역직렬화 (JSON → Java)
// 다음 JSON 키들을 모두 허용:
// 1. "studentRoom" (@JsonProperty)
// 2. "student_room" (@JsonAlias)

{"studentRoom": "활빈당"}  // 매핑 성공
{"student_room": "활빈당"} // 매핑 성공

// 직렬화 (Java → JSON)
// @JsonProperty만 사용:
{"studentRoom": "활빈당"}  // camelCase로 출력
핵심

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

최종 흐름

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

여러 별칭을 허용하는 @JsonAlias

@JsonAlias는 배열로 여러 별칭을 지정할 수 있다

@JsonProperty("studentName")
@JsonAlias({"student_name", "StudentName", "STUDENT_NAME"})  // 모두 허용!
private String studentName;

// 다음 JSON들이 모두 매핑됨:
{"studentName": "홍길동"}     
{"student_name": "홍길동"}    
{"StudentName": "홍길동"}     
{"STUDENT_NAME": "홍길동"}    

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

해결 방법

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

// Bad - 축약어로 인한 혼란
private String sId;
private String uId;

// Good - 명확한 camelCase
private String studentId;  // 추천
private String userId;
장점
  • 규칙 충돌 원천 차단
  • 코드 가독성 향상
  • 팀원 누구나 이해 가능

@JsonProperty 명시 – Request용 또는 양방향

기존 API 스펙을 유지해야 하는 경우

@JsonProperty("sId")  // JSON 키를 명시적으로 지정
@NotBlank(message = "학번은 필수입니다.")
private String sId;
장점
  • 레거시 코드 호환성 유지
  • 외부 API 규격 준수
  • Request/Response 양방향 모두 적용 가능
단점
  • 어노테이션 추가 필요
  • 유지보수 포인트 증가

@JsonGetter 명시 – Response 전용

Response에서 예측 불가능한 동작이 발생하는 경우

@Schema(description = "학생 학번")
private String sId;

@JsonGetter("sId")  // Response JSON 키를 명시적으로 지정
public String getSId() {
    return sId;
}
장점
  • Response만 선택적으로 제어 가능
  • 원인 모름 Jackson 동작을 우회 가능
  • Getter 로직도 함께 커스터마이징 가능
단점
  • Lombok @Getter와 공존 시 수동 Getter 작성 필요
  • Request는 별도로 처리해야 함
사용 케이스
  • Response에서 예상과 다른 JSON 키가 나올 때
  • 원인을 정확히 파악하지 못했지만 해결이 필요할 때
  • 특정 환경에서만 발생하는 이상 동작 회피

@JsonProperty + @JsonAlias 조합 – snake_case ↔ camelCase

외부 시스템과 네이밍 규칙이 다른 경우

@JsonProperty("studentRoom")      // 출력: camelCase
@JsonAlias("student_room")        // 입력: snake_case도 허용
private String studentRoom;
장점
  • 입력과 출력에 다른 네이밍 규칙 적용 가능
  • 레거시 시스템 통합에 최적
  • 여러 별칭 동시 허용 가능
단점
  • 두 개의 어노테이션 관리 필요
사용 케이스
  • snake_case API 시스템과 통신
  • 여러 버전의 API 지원
  • 레거시 마이그레이션 과정

Validation 어노테이션 정리

문제 해결 중 헷갈렸던 검증 어노테이션 차이점 정리

어노테이션null“” (빈 문자열)” ” (공백)대상 타입
@NotNull미허용허용허용모든 타입
@NotEmpty미허용미허용허용String, Collection, Map, Array
@NotBlank미허용미허용미허용String만
실무 가이드
  • 문자열 필드 (이름, 아이디 등): @NotBlank 사용 권장
  • 컬렉션 / 배열: @NotEmpty 사용
  • 객체 참조: @NotNull 사용
public class UserRequest {
    @NotBlank  // 공백 입력 방지
    private String username;
    
    @NotEmpty  // 빈 리스트 방지
    private List<String> roles;
    
    @NotNull   // null 객체 방지
    @Valid
    private Address address;
}

회고와 교훈

  • 문서를 읽자: Java Bean 규약은 명확히 문서화되어 있다
  • 디버깅의 중요성: 실제 생성된 메서드를 확인했으면 더 빨리 해결 가능
  • 네이밍의 힘: 명확한 이름은 버그를 예방한다
  • 겸손함: 처음 분석이 틀렸다는 걸 인정하기 다시 공부
  • 양방향 테스트: Request만 고치고 끝이 아니다. Response도 반드시 확인해야 한다
  • 완벽한 이해가 불가능 할 때도 있다: 원인을 못 찾아도 해결책은 있다
  • 경험의 축적: 비슷한 버그를 한 번 겪어보니 다음번엔 빠르게 해결할 수 있었다
  • 실용주의: 이론적 완벽함보다 실무적 해결이 우선일 때가 있다

팀 가이드라인 예시

네이밍 규칙

  • 3글자 이상의 명확한 camelCase 사용
  • 단일 문자 + 대문자 조합 절대 지양 (sId, cId, uId 등)
    • 이유: Java Bean 규약과 Jackson의 복잡한 상호작용으로 예측 불가능한 동작 발생
  • 축약어보다는 풀네임 사용 (id → identifier, num → number)

어노테이션 사용 전략

  • 내부 시스템: 가급적 어노테이션 없이 명확한 네이밍으로 해결
  • 레거시/외부 API 연동 시
    • Request: @JsonProperty 명시
    • Response(예측 불가능한 동작 발생): @JsonGetter 명시
    • 양방향: @JsonProperty 사용
  • snake_case ↔ camelCase 변환
    • @JsonProperty + @JsonAlias 조합 사용
    • 입력은 유연하게, 출력은 일관되게

마무리하며

@NotNull, @NotEmpty, @NotBlank에 대해서 별 생각없이 사용하였는데 이번 삽질을 통해서 배운 것이 있다. 프레임워크나 라이브러리(Lombok, Jackson)가 모든 것을 알아서 해줄 것이다 믿기보다, 그 근간이 되는 Java 표준 규약(Java Bean)을 이해하는 것이 중요하다는 점이다. 특히 검증 어노테이션이 작동하지 않을 때는, 어노테이션 자체의 문제보다 데이터가 객체에 제대로 바인딩 되었는지(Naming Rule)를 먼저 확인해보는 것도 필요하다

프레임워크는 편리하지만, 기본 규칙을 이해하지 못하면 작은 네이밍 하나가 시스템을 멈추게 한다. 결국 기본기를 이해하는 사람이 문제를 가장 빠르게 해결한다. 그리고 기본기로도 해결 안 되는 문제는 경험과 실용주의로 극복한다

한편으로는 막연하게 검증 어노테이션이 이상하다고 생각하였고, 별다른 생각 없이 상황을 제대로 파악하지 못하고 무의미한 테스트만 반복하였다. 감과 반복적인 테스트만 하다가 뒤늦게 문제 지점을 발견했고 해결할 수 있었지만, 결국 자바 기초가 부족해서 발생한 원인이므로 이런 문제를 줄이려면 결국 기본기를 반복해서 다지는 수밖에 없고 생각된 날이였다