분명 값을 보냈는데 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도 반드시 함께 확인해야 한다