Spring에서 RequestBody 매핑

2AST_\·2024년 5월 12일

스프링

목록 보기
1/2
post-thumbnail

서버에 정보를 전달할 때, Path Variable / Request Param / Request Body 등의 방식들이 존재한다. 오늘은 그 중 RequestBody를 매핑하는 방법과 그 원리를 알아가볼까 한다. (Validation은 오늘 이야기에서 제외하겠다)

1. Map으로 매핑

@PostMapping("/test")
public ResponseEntity enroll(@RequestBody Map<String, String> requestBody) {
    return ResponseEntity.ok().body(requestBody.get("email"));
}

해당 경우는, 매핑할 필드가 적은 경우 사용될 수 있다. 왜냐하면 Map에서 어떤 키값들이 있을지 기술되어 있지 않기 때문이다. 따라서 이는 API 명세서에 의존할 수 밖에 없다.

2. 객체(DTO)로 매핑

// 객체
@Getter
public class TestRequestBody {
    private String email;
    private String password;
}

// Controller
@PostMapping("/test")
public ResponseEntity enroll(@RequestBody final TestRequestBody requestBody) {
    return ResponseEntity.ok()
              .body(requestBody.getEmail() +" "+ requestBody.getPassword());
}

해당 경우는 클래스를 정의하고 이를 매핑하는 방식이다. 각 필드를 통해서 JSON을 매핑해준다.

이 방식의 경우에는 키값을 미리 정의했다는 점이 좋다. 클린 코드 관점에서도 여러 개의 값을 묶어줌으로써 가독성, 사용성 측면에서 좋다. 또한 Getter만 있어 불변성을 보장할 수 있다.

3. Snake <-> Camel

Java에서는 CamelCase 사용을 지항하나, 다른 언어에서는 Snake 방식을 지향할 수 있다. 따라서 클라이언트와 서버 사이에서 사용되는 RequestBody도 Snake와 Camel 케이스 간의 변환이 필요할 수 있다.

@JsonProperty

@Getter
public class TestRequestBody {
    private String email;
    private String password;
    @JsonProperty("phone_number")
    private String phoneNumber;
}

이와 같이 @JsonProperty로 특정 필드에 다른 이름으로 매핑시킬 수 있다.

@JsonNaming

@Getter
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class TestRequestBody {
    private String email;
    private String password;
    private String phoneNumber;
}

또한 @JsonNaming으로 전체적으로 변환 전략을 사용할 수 있다. 여기서 주의할 점은 PropertyNamingStrategy.SnakeCaseStrategy.class은 deprecated 되었으므로 유의해서 써야 한다.
참고: Alternatives for PropertyNamingStrategy.SNAKE_CASE or PropertyNamingStrategy.SnakeCaseStrategy as it is deprecated now

Jackson

앞에 소개되었던 @JsonProperty, @JsonNaming, @RequestBody 모두 Jackson 라이브러리를 기반으로 한다. Jackson 라이브러리에 대해서는 아래의 블로그와 그 외의 문서들을 확인하면 되겠다. (사실 RequestBody의 동작(매핑)은 Jackson에 매우 많이 의존한다.)

[Spring] Jackson 라이브러리 이해하기.

4. @RequestBody 동작 원리

RequestBody는 HttpMessageConveter 이용한 역직렬화에 의해 동작된다. 여기서 직렬화 / 역직렬화에 대한 정의는 다음곽 같다.

  • 직렬화(Serialization): 객체 또는 데이터를 바이트 스트림 형태로 연속적인 데이터로 변환하는 포맷 기술
  • 역직렬화(Deserialization): 바이트로 변환된 데이터를 객체 또는 데이터로 변환하는 기술

HttpMessageConverter는 인터페이스이다. 스프링에서는 JSON을 역직렬화할 때, 디폴트로 HttpMesssageConverter의 구현체인 MappingJackson2HttpMessageConverter를 사용한다. 하지만 해당 클래스는 ObjectMapper를 초기화하고 파싱할 수 있는 포맷만 기술한다. 실질적인 파싱은 부모 클래스인 AbstractJackson2HttpMessageConverter에서 ObjectMapper를 활용해 파싱한다.

ObjectMapper와 리플렉션 그리고 private

ObjectMapper는 Jackson에서 직렬화 / 역직렬화를 지원하기 위한 클래스이다. 이를 가능하게 만드는 핵심 기술은 바로 리플렉션이다.

다만 리플렉션을 활용해서 바로 필드에 접근하는 것은 아니다. 기본적으로 프로퍼티를 통해서 필드에 접근한다. 프로퍼티란 자바빈 개념에서 사용되는데, 자바빈과 프로퍼티의 개념은 다음과 같다. (Spring bean과는 다르다)

  • 자바빈: JavaBean 규약에 따라 작성된 재사용 가능한 자바 클래스, (조건들은 아래와 같다.)
    • 직렬화될 수 있어야 한다.
    • 기본 생성자를 가지고 있어야 한다.
    • 속성들은 get, set 혹은 표준 명명법을 따르는 메서드들을 사용해 접근할 수 있어야 한다. -> 프로퍼티

자바빈은 단지 표준 규칙이다. 그냥 컨벤션이라 생각하면 편하다. Jackson은 프로퍼티를 추출해서 이를 바탕으로 필드를 유추한다.

예를 들어 getName이라는 프로퍼티가 존재하면 get을 없애고, 첫 대문자를 소문자로 바꿔 필드를 name으로 유추한다.

여기서 궁금한 점은 리플렉션을 통해서 private 필드에 접근할 수 있는데 왜 뱅뱅 돌아서 프로퍼티로 접근할까? 라는 생각이 들게 되었다.

왜 프로퍼티를 사용하는가

여기서부터는 개인적인 생각이다. 옳지 않을 수 있다

private 필드에 바로 접근할 수 있다는 점은 자칫 캡슐화와 정보은닉의 장점이 깨진다는 것이다. private은 원래 사용자가 마음대로 사용할 수 없도록 나온 접근제어자이다. 하지만 getter / setter의 유무에 따라 private 필드의 의도가 달라진다.

만약 getter / setter가 없는 private 필드들을 생각해보자. 이는 클래스 내부에서 사용되길 기대하기 때문이다. 밖의 사용자는 알 필요가 없다.

그렇다면 getter / setter가 있는 private 필드들은 어떨까? 이는 setter의 존재 이유와도 같다. 직접 접근해서 오남용을 막기 위해서 쓴다.

public void setAmount(int amount) {
    if (amount < 1) throw new RuntimeException("수량은 1개 미만으로 설정될 수 없습니다.");
	this.amount = amount;
}

이와 같이 private 필드 + getter / setter 조합의 의미는 데이터의 원천 차단이 아니라 의도되게 데이터들을 사용되도록 구성된다는 것이다.

그렇기 때문에 Jackson 또한 프로퍼티들을 통해서 private 중에 접근이 가능한 필드만을 유추하여 매핑한다는 것이 내 추론이다. 물론 RequestBody에는 그런 것들은 없겠지만 ObjectMapper는 범용적으로 사용한다는 것을 생각하면 충분히 이해가 된다.

Tip

만약 JSON의 boolean필드가 isSuccess로 되어있으면 isSuccess()가 getter 메서드가 될 것이다. 해당 상황에서는 RequestBody를 날리면 매핑이 되지 않아 false가 설정된다.

왜냐하면 ObjectMapper에서 isSuccess() 프로퍼티를 통해서 필드를 success로 유추하기 때문이다. 이러한 동작 과정을 이해해야 문제를 인식할 수 있다.

개인적으로 다음과 포맷으로 RequestBody를 설정할 것을 추천한다.

@Getter
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class TestReq {
    private boolean success;
    private String name;
}

기본 생성자와 Getter는 매핑과 데이터 접근을 위해서, @AllArgsConstructor와 @Builder는 테스트 할 때 편하게 사용하기 위해서이다.

더욱 자세한 글은 아래의 참고한 블로그들을 확인하면 좋을 것 같다.

참고

Alternatives for PropertyNamingStrategy.SNAKE_CASE or PropertyNamingStrategy.SnakeCaseStrategy as it is deprecated now
[Spring] Jackson 라이브러리 이해하기.
@RequestBody 동작 이해하기
@RequestBody에 왜 기본 생성자는 필요하고, Setter는 필요 없을까? #3
Jackson ObjectMapper

0개의 댓글