오늘의하루

[JPA] Json 다형성을 활용한 DTO 설계와 확장 본문

Spring/JPA

[JPA] Json 다형성을 활용한 DTO 설계와 확장

오늘의하루_master 2024. 11. 26. 12:59

API를 설계하다 보면 서로 다른 요청 데이터 타입에 공통 필드가 반복적으로 존재하는 경우가 많습니다.
이 상황에서 새로운 공통 필드가 추가되거나 변경될 때 모든(1억개) DTO를 수정해야 하는 번거로움이 발생합니다.

 

DTO(Data Transfer Object)를 쓰는 이유?

"Controller에서는 DTO로 요청 정보를 받는 게 좋다."라는 말을 개발 공부를 시작했을 무렵 접하게 되었습니다.하지만 이유를 정확히 알지 못한 상태에서 사용해 오다가 이번에 JPA 공부를 하면서 그

jangto.tistory.com

1. 문제사항

처음 API를 설계할 때 공통 내용을 요청받는 DTO를 각각 따로 만들었다고 가정합니다.
예를 들어 BookRequest, AlbumRequest, MovieRequest라는 요청 DTO가 아래와 같이 있습니다.

@Getter @Setter
@NoArgsConstructor
public class BookRequest {
    private String name;
    private int price;
    private int stockQuantity;
}

@Getter @Setter
@NoArgsConstructor
public class AlbumRequest {
    private String name;
    private int price;
    private int stockQuantity;
    private String artist;
}

@Getter @Setter
@NoArgsConstructor
public class MovieRequest {
    private String name;
    private int price;
    private int stockQuantity;
    private String director;
}

현재 DTO를 보면 name, price, stockQuantity가 중복 정의 되어있습니다.

만약 공통 필드가 추가되거나 변경된다면 이를 사용하는 모든 DTO를 수정해야 하는 문제가 발생하게 되고 이는 유지 보수 비용이 증가합니다.

2. 해결 방법: @JsonTypeInfo와 @JsonSubTypes를 이용한 공통 DTO 설계

이 문제를 해결하기 위해 공통 상위 DTO를 정의하고 이를 Jackson의 다형성을 활용하여 확장하는 방식으로 설계를 변경하게되면 @JsonTypeInfo와 @JsonSubTypes를 사용하여 하위 DTO로 자동으로 매핑할 수 있습니다.

※ Jackson은 역직렬화 시 Reflection을 활용하기 때문에 기본 생성자는 필수 사항입니다.
@JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.PROPERTY,
        property = "type"
)
@JsonSubTypes({
        @JsonSubTypes.Type(value = BookRequest.class, name = "book"),
        @JsonSubTypes.Type(value = AlbumRequest.class, name = "album"),
        @JsonSubTypes.Type(value = MovieRequest.class, name = "movie")
})
@Getter @Setter
@NoArgsConstructor
public class ItemRequest {
    private String name;
    private int price;
    private int stockQuantity;
}

위와 같이 공통 필드를 담고 있는 상위 DTO를 만들고 하위 DTO는 이를 상속하여 만들어 줍니다.

 

@Getter @Setter
@NoArgsConstructor
public class BookRequest extends ItemRequest {
    private String genre;
}

@Getter @Setter
@NoArgsConstructor
public class AlbumRequest extends ItemRequest {
    private String artist;
}

@Getter @Setter
@NoArgsConstructor
public class MovieRequest extends ItemRequest {
    private String director;
}

3. 요청 데이터 예시

Jackson은 상위 DTO에서 정의한 type 필드를 사용해 요청 데이터를 하위 DTO로 매핑해 줍니다.

BookRequest JSON 예시

{
  "type": "book",
  "name": "실전! 스프링 부트와 JPA 활용1",
  "price": 88000,
  "stockQuantity": 1,
  "genre": "Programming"
}

AlbumRequest JSON 예시

{
  "type": "album",
  "name": "가까운 듯 먼 그대여",
  "price": 18000,
  "stockQuantity": 10,
  "artist": "카더가든"
}

MovieRequest JSON 예시

{
  "type": "movie",
  "name": "베테랑2",
  "price": 23000,
  "stockQuantity": 15,
  "director": "류승완"
}

4. Controller 구현

이제 공통 상위 DTO인 ItemRequest를 통해 요청 데이터를 처리할 수 있기 때문에 유연성과 확장성을 높일 수 있습니다.

특히 DTO의 개수가 많을 수록 유지보수에 좋습니다.

이러한 확장 구조에서 개인적으로 GET 방식을 사용하게 되면 이점이 사라진다고 느껴지기 때문에 POST 방식을 선호합니다.
@RestController
@RequiredArgsConstructor
public class ItemController {

    private final ItemService itemService;
  
    @PostMapping("/api/v1/item")
    public ResponseEntity<String> createItem(@RequestBody ItemRequest request) {
        String result = itemService.createItem(request);
        return ResponseEntity.ok().body(result);
    }
  
}

5. 결과

이러한 설계로 인해 만약 공통 필드인 stockQuantity를 quantity로 변경하더라도 코드 변경점이 N개에서 1개로 감소하게 되며 특히나 이 방식은 특히 Entity 상속 구조와 함께 사용할 경우 더욱 강력한 효과를 주는 설계가 됩니다.

6. 예외 처리

예제 상태에서 type필드에 잘못된 값이 들어왔을 때 HttpMessageNotReadableException이 발생하게 되는데 이를 독립적으로 처리하기 위해서는 아래와 같이 예외 핸들러를 만든 후 상위 DTO에서 상속해서 독립적으로 해결할 수 있습니다.

예외 핸들러 구현

public class ItemRequestJsonException {
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<String> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) {
        if (ex.getCause() instanceof InvalidTypeIdException typeIdException) {
            String invalidType = typeIdException.getTypeId();
            return ResponseEntity.badRequest().body(String.format("유효하지 않은 'type' 값입니다: '%s'", invalidType));
        }
        return ResponseEntity.badRequest().body("잘못된 요청입니다.");
    }
}

상위 DTO 독립적인 예외처리 

@JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.PROPERTY,
        property = "type"
)
@JsonSubTypes({
        @JsonSubTypes.Type(value = BookRequest.class, name = "book"),
        @JsonSubTypes.Type(value = AlbumRequest.class, name = "album"),
        @JsonSubTypes.Type(value = MovieRequest.class, name = "movie")
})
@Getter @Setter
@NoArgsConstructor
public class ItemRequest extends ItemRequestJsonException {
    private String name;
    private int price;
    private int stockQuantity;
}

7. 고려 사항

Jackson을 사용한 역직렬화는 코드의 유연성과 확장성을 높이는 데 유리하지만 Reflection을 사용하기 때문에 성능에 영향을 미칠 수 있기 때문에 요구 사항에 맞게 적절히 최적화하고 성능을 모니터링하여 필요한 경우 개선 작업을 진행하는 것이 중요하다고 생각합니다.

Comments