ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JPA] Json 다형성을 활용한 DTO 설계와 확장
    Spring/JPA 2024. 11. 26. 12:59
    728x90
    반응형

    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을 사용하기 때문에 성능에 영향을 미칠 수 있기 때문에 요구 사항에 맞게 적절히 최적화하고 성능을 모니터링하여 필요한 경우 개선 작업을 진행하는 것이 중요하다고 생각합니다.

    728x90
    반응형
Designed by Tistory.