[Spring] 인터페이스/추상 클래스가 포함된 request dto를 controller에서 받아보자.

@melonturtle · October 02, 2023 · 4 min read

Entity 구조

현재 프로젝트의 entity 구조는 아래와 같다.

TopicStructure

ChoiceContent를 상속받아 현재는 ImageTextChoiceContent만 있지만, 요구사항 상 후에 ChoiceContent를 상속하는 다른 클래스들도 생길 가능성이 있어 그를 고려한 설계를 했다.

문제는, 이를 클라이언트의 요청을 어떻게 받을 지였다.

{
	"choices": [
		{ ??? }, { ??? }
	],
}

위 choices의 값으로 여러 타입이 올 수 있기 때문이다. 그 전에는 항상 정해진 타입의 고정 형태로만 요청을 받았었기 때문에, 새롭게 찾아서 적용했다.

Request DTO 구조

요청을 받기 위한 DTO 클래스를 만들어야 한다. 따라서 기존 Entity에 대응되게 아래와 같이 만들었다. 기존 Entity에서 ChoiceContent는 abstract class이다. 하지만 여기 DTO에서 ChoiceContent를 위한 DTO는 interface로 만들었다. 어차피 DTO와 Entity는 별개이기 때문에 서로 abstract이든 interface든 뭐든 간에 당연히 상관이 없다.

TopicRequestStructure

코드를 보는 게 더 이해가 쉬울 수 있어서 필요한 부분만 포함하여 코드를 추가한다.

TopicCreateRequest

public record TopicCreateRequest(
        List<ChoiceCreateRequest> choices,
) {
}

ChoiceCreateRequest

public record ChoiceCreateRequest (
        ChoiceContentCreateRequest choiceContentRequest
){
}

ImageTextChoiceContentCreateRequest

public record ImageTextChoiceContentCreateRequest(
	/* ... */
) implements ChoiceContentCreateRequest{
}

Interface/abstract class에 올 수 있는 타입들 표시하기

이제 인터페이스인 ChoiceContentCreateRequest에 올 수 있는 하위 타입들을 표시하면 요청으로 다양한 하위 타입을 받을 수 있다!

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = ImageTextChoiceContentCreateRequest.class, name = "IMAGE_TEXT")
})
public interface ChoiceContentCreateRequest {
}

현재 프로젝트의 경우에는 아직 하위 타입이 한개밖에 없으므로 ImageTextChoiceContentCreateRequest.class하나만 작성해줬다. 여기서 property = "type"name="IMAGE_TEXT"가 중요하다. 이 의미는 json에서 type이라는 property로 각 타입들을 구분할거고, 그 때 ImageText...Request 클래스의 경우엔 name = "IMAGE_TEXT"가 type의 값으로 온다는 의미이다.

즉, json에선 아래와 같이 구분한다는 의미이다.

{
  "choices": [
    {
      "choiceContentRequest": {
        "type": "IMAGE_TEXT"
      }
    },
    {
      "choiceContentRequest": {
        "type": "IMAGE_TEXT"
      }
    }
  ]
}

그리고 하위 타입에도 자신이 어떤 이름으로 구분되는지 표시해준다.

@JsonTypeName("IMAGE_TEXT")
public record ImageTextChoiceContentCreateRequest(
	//...
) implements ChoiceContentCreateRequest{
}

이제 문제 없이 다양한 하위 타입을 읽어올 수 있다!

좀 더 내부 구현을 설명하자면, 각 ChoiceContentCreateRequest 들을 쉽게 Entity로 전환하기 위해 인터페이스에 ChoiceContent toEntity()를 만들어놓았다.

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = ImageTextChoiceContentCreateRequest.class, name = "IMAGE_TEXT")
})
public interface ChoiceContentCreateRequest {
    ChoiceContent toEntity();
}

그리고 각 구체 클래스에서 toEntity()를 구현하여 타입에 따른 엔티티를 쉽게 만들 수 있게 했다.

public record ImageTextChoiceContentCreateRequest(
        // ...
) implements ChoiceContentCreateRequest{
    @Override
    public ChoiceContent toEntity() {
        return new ImageTextChoiceContent(imageUrl, text);
    }
}

이 방법이 instanceOf로 하나하나 값을 구분하여 엔티티를 만드는 것보다 객체지향적인 것 같기도 하고 무엇보다 간편하다.

내가 구현한 방법은 인터페이스를 이용할 때지만, 추상 클래스일 때도 문제 없이 가능하다.

참고

https://stackoverflow.com/questions/42965921/use-interface-in-spring-controller-method-argument

@melonturtle
공부하면서 남긴 기록입니다. 문제가 있다면 언제든지 알려주세요!