컨트롤러에서 자바 객체를 반환하면 클라이언트에게 JSON이 도착합니다. 이 변환은 누가, 어떤 기준으로 하는 걸까요? 그리고 이 과정을 커스터마이징할 수 있을까요?

개념 정의

HttpMessageConverter 는 HTTP 요청 본문을 자바 객체로 변환(역직렬화)하거나, 자바 객체를 HTTP 응답 본문으로 변환(직렬화)하는 스프링의 인터페이스입니다. REST API에서 JSON ↔ 객체 변환의 핵심입니다.

왜 필요한가

HTTP는 텍스트 기반 프로토콜이고, 자바 애플리케이션은 객체를 다룹니다. 이 둘 사이의 변환을 자동으로 처리해줘야 개발자가 직렬화/역직렬화 코드를 반복 작성하지 않아도 됩니다.

JAVA
// HttpMessageConverter가 없다면...
@PostMapping("/users")
public void createUser(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String json = new BufferedReader(request.getReader()).lines().collect(Collectors.joining());
    ObjectMapper mapper = new ObjectMapper();
    CreateUserRequest dto = mapper.readValue(json, CreateUserRequest.class);

    User user = userService.create(dto);

    String responseJson = mapper.writeValueAsString(user);
    response.setContentType("application/json");
    response.getWriter().write(responseJson);
}

HttpMessageConverter가 있으면 이 모든 것이 자동입니다.

내부 동작

컨버터 선택 과정

요청 시 (역직렬화):

PLAINTEXT
1. @RequestBody 파라미터 감지
2. 요청의 Content-Type 헤더 확인 (예: application/json)
3. 등록된 HttpMessageConverter 목록을 순회
4. canRead(targetType, contentType) == true인 컨버터 선택
5. 선택된 컨버터의 read() 호출 → 자바 객체 반환

** 응답 시 (직렬화)**:

PLAINTEXT
1. @ResponseBody 또는 @RestController 감지
2. 클라이언트의 Accept 헤더 확인
3. 등록된 HttpMessageConverter 목록을 순회
4. canWrite(returnType, acceptType) == true인 컨버터 선택
5. 선택된 컨버터의 write() 호출 → HTTP 응답 본문 작성

기본 등록 컨버터 (순서대로)

PLAINTEXT
1. ByteArrayHttpMessageConverter        → byte[]
2. StringHttpMessageConverter            → String
3. ResourceHttpMessageConverter          → Resource
4. MappingJackson2HttpMessageConverter   → JSON ↔ Object
5. MappingJackson2XmlHttpMessageConverter → XML ↔ Object (Jackson XML 모듈)
6. FormHttpMessageConverter              → form data

Jackson이 클래스패스에 있으면 MappingJackson2HttpMessageConverter가 자동 등록됩니다.

코드 예제

ObjectMapper 커스터마이징

JAVA
@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            // 알 수 없는 필드 무시
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            // 빈 객체 직렬화 허용
            .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
            // 날짜를 타임스탬프 대신 ISO-8601로
            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
            // null 필드 제외
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            // Java 8 날짜 모듈 등록
            .registerModule(new JavaTimeModule())
            // 들여쓰기 (개발 환경)
            .enable(SerializationFeature.INDENT_OUTPUT);
    }
}

application.yml로 Jackson 설정

YAML
spring:
  jackson:
    default-property-inclusion: non_null      # null 필드 제외
    deserialization:
      fail-on-unknown-properties: false       # 알 수 없는 필드 무시
    serialization:
      write-dates-as-timestamps: false        # ISO-8601 날짜 형식
      indent-output: true                     # 들여쓰기 (개발용)
    date-format: yyyy-MM-dd HH:mm:ss         # 날짜 포맷
    time-zone: Asia/Seoul                     # 타임존

Jackson 어노테이션 활용

JAVA
public class UserResponse {

    private Long id;

    @JsonProperty("user_name")  // JSON 필드명 변경
    private String name;

    @JsonIgnore  // 직렬화/역직렬화에서 제외
    private String password;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")  // 날짜 포맷
    private LocalDateTime createdAt;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)  // 빈 값이면 제외
    private List<String> roles;
}

@JsonView — API마다 다른 필드 노출

JAVA
public class Views {
    public interface Summary {}
    public interface Detail extends Summary {}
}

public class UserResponse {

    @JsonView(Views.Summary.class)
    private Long id;

    @JsonView(Views.Summary.class)
    private String name;

    @JsonView(Views.Detail.class)  // Detail에서만 노출
    private String email;

이어서 @JsonView을 적용한 나머지 구현부입니다.

JAVA
    @JsonView(Views.Detail.class)
    private LocalDateTime createdAt;
}

@RestController
public class UserController {

    @GetMapping("/users")
    @JsonView(Views.Summary.class)  // id, name만 반환
    public List<UserResponse> list() { ... }

    @GetMapping("/users/{id}")
    @JsonView(Views.Detail.class)  // 모든 필드 반환
    public UserResponse detail(@PathVariable Long id) { ... }
}

커스텀 HttpMessageConverter

CSV 형식을 처리하는 커스텀 컨버터 예시입니다.

JAVA
public class CsvHttpMessageConverter extends AbstractHttpMessageConverter<List<String[]>> {

    public CsvHttpMessageConverter() {
        super(new MediaType("text", "csv"));
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        return List.class.isAssignableFrom(clazz);
    }

이어서 나머지 어노테이션 기반 구현부입니다.

JAVA
    @Override
    protected List<String[]> readInternal(Class<? extends List<String[]>> clazz,
                                           HttpInputMessage inputMessage) throws IOException {
        // CSV 파싱 로직
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(inputMessage.getBody()))) {
            return reader.lines()
                .map(line -> line.split(","))
                .collect(Collectors.toList());
        }
    }

이어서 @Override을 적용한 나머지 구현부입니다.

JAVA
    @Override
    protected void writeInternal(List<String[]> data,
                                  HttpOutputMessage outputMessage) throws IOException {
        // CSV 쓰기 로직
        try (OutputStreamWriter writer = new OutputStreamWriter(outputMessage.getBody())) {
            for (String[] row : data) {
                writer.write(String.join(",", row) + "\n");
            }
        }
    }
}

// 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(new CsvHttpMessageConverter());
    }
}

컨버터 추가 vs 교체

JAVA
@Configuration
public class WebConfig implements WebMvcConfigurer {

    // 기존 컨버터에 추가 (기본 컨버터 유지)
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(new CsvHttpMessageConverter());
    }

    // 컨버터 목록 완전 교체 (기본 컨버터 제거됨 — 주의!)
    // @Override
    // public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    //     converters.add(new MappingJackson2HttpMessageConverter());
    //     converters.add(new CsvHttpMessageConverter());
    // }
}

configureMessageConverters를 사용하면 기본 컨버터가 모두 사라지므로 주의해야 합니다. 대부분의 경우 extendMessageConverters를 사용합니다.

직렬화/역직렬화 디버깅

YAML
logging:
  level:
    org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor: DEBUG
    org.springframework.http.converter: DEBUG

주의할 점

1. 기본 생성자가 없는 DTO는 Jackson 역직렬화에 실패한다

Jackson은 기본적으로 기본 생성자(no-arg constructor)로 객체를 생성한 뒤 setter나 리플렉션으로 값을 채웁니다. @AllArgsConstructor만 있고 기본 생성자가 없으면 InvalidDefinitionException이 발생합니다. @NoArgsConstructor를 추가하거나 @JsonCreator로 생성자를 지정해야 합니다.

2. 순환 참조가 있는 엔티티를 직접 반환하면 StackOverflowError가 발생한다

JPA 엔티티에서 OrderUser를 참조하고 UserOrder 목록을 참조하면, Jackson이 직렬화할 때 무한 재귀가 발생하여 StackOverflowError가 터집니다. 엔티티를 직접 반환하지 말고 DTO로 변환하거나, @JsonIgnore/@JsonManagedReference로 순환을 끊어야 합니다.

3. Content-Type 헤더가 맞지 않으면 415 Unsupported Media Type이 발생한다

클라이언트가 Content-Type: text/plain으로 JSON을 보내면 MappingJackson2HttpMessageConverter가 매칭되지 않아 415 Unsupported Media Type이 반환됩니다. API 클라이언트에서 Content-Type: application/json 헤더를 빠뜨리는 실수가 잦으며, 에러 메시지만 봐서는 원인을 파악하기 어렵습니다.

정리

  • HttpMessageConverter 는 HTTP 본문 ↔ 자바 객체 변환을 담당합니다
  • JSON 변환은 기본적으로 Jackson(ObjectMapper) 이 처리합니다
  • application.yml이나 @Bean ObjectMapper로 Jackson 동작을 커스터마이징할 수 있습니다
  • @JsonView로 같은 DTO에서 API마다 다른 필드를 노출할 수 있습니다
  • 커스텀 미디어 타입이 필요하면 AbstractHttpMessageConverter를 구현합니다
  • 컨버터 추가 시 extendMessageConverters를 사용해 기본 컨버터를 유지합니다
댓글 로딩 중...