Spring Framework

Spring 단일 Endpoint에 여러 요청 처리하기

Junuuu 2024. 2. 13. 00:04

문제 상황

/single-end-point라는 endpoint에 DtoA, DtoB 등의 서로 다른 request가 들어오는 상황에서는 어떻게 코드를 작성해야 할까요?

 

문제를 해결하기 위해 다양한 방법으로 접근을 시도해 보고 장, 단점을 비교해보고자 합니다.

 

 

Input Request

data class DtoA(
        val col1: String,
        val col2: String,
        val type: String,
)

data class DtoB(
        val col3: String,
        val col4: String,
        val col5: String,
        val type: String,
)

정의되어 있는 Request는 DtoA, DtoB라고 가정하겠습니다.

해당 Request들은 type으로 어떤 reqeust인지 구분할 수 있도록 값이 들어오고 요구사항에 따라 서로 다른 property field 이름을 가집니다.

 

이제 하나의 Controller endpoint를 통하여 동적인 Request를 처리하는 방법을 비교해 보겠습니다.

 

직관적인 해결방법 - Map 활용

@GetMapping("/v1/single-end-point")
fun singleEndpointV1(@RequestBody singleEndPointRequest: Map<String, String>): String{
	singleEndpointDispatcherV1.process(singleEndPointRequest)
	return "hello"
}

Map을 활용해 볼 수 있습니다.

Spring에서 Map을 활용하면 API Spec이 변경돼도 Controller의 코드 변경 없이 처리할 수 있게 됩니다.

이는 엄청난 장점이지만 반면에 어떤 값이 넘어오는지 알 수 없고, 직접 값을 꺼내서 처리해주어야 합니다.

 

SingleEndpointDispatcherV1

@Component
class SingleEndpointDispatcherV1(
        private val dtoAService: DtoAServiceV1,
        private val dtoBService: DtoBServiceV1,
){
    fun process(request: Map<String, String>){
        val requestType = request["type"] ?: throw IllegalArgumentException("type 값은 필수적으로 들어와야 합니다.")
        when(requestType){
            "DtoA" -> { dtoAService.process(ConverterV1.requestToDtoA(request))}
            "DtoB" -> { dtoBService.process(ConverterV1.requestToDtoB(request))}
            else -> throw IllegalArgumentException("지원하지 않는 type: $requestType")
        }
    }
}

object ConverterV1{
    fun requestToDtoA(request: Map<String,String>): DtoA {
        return DtoA(
                col1 = request["col1"]!!,
                col2 = request["col2"]!!,
        )
    }

    fun requestToDtoB(request: Map<String,String>): DtoB {
        return DtoB(
                col3 = request["col3"]!!,
                col4 = request["col4"]!!,
                col5 = request["col5"]!!,
        )
    }
}

 

SingleEndpointDispactherV1 코드를 보면 Service 코드를 직접 주입받고 request의 type에 따라서 어떤 서비스를 호출할지 어떤 Converter의 메서드를 호출할지를 결정하고 해당 Service를 호출하게 됩니다.

 

이 과정에서 col1, col2, col3, col4, col5 등의 파라미터를 직접 매핑해주어야 하고 매핑하는 과정에서 에러 핸들링도 모두 수행해주어야 합니다.

 

Service 구현

@Service
class DtoAServiceV1{
    fun process(dtoA: DtoA): String{
        return dtoA.col1
    }
}

Dispatcher 클래스에서 이미 객체를 생성해서 넘겨주기 때문에 Service에서는 바로 해당 Dto를 사용할 수 있습니다.

 

 

Map 활용 시 장, 단점 

  • 장점 1 - 코드가 직관적이기 때문에 변환이 어떻게 일어나는지 쉽게 파악할 수 있다.
  • 장점 2 - API Spec 변경이 발생하면 Converter 부분 수정이 필요하겠지만 Controller의 수정은 필요 없다.
  • 단점 1 - dto, domain 등으로 직접 변환하는 코드 및 에러 핸들링을 처리해야 한다.
  • 단점 2 - Controller만 보고 어떤 reqeust가 들어오는지 파악할 수 없다.
  • 단점 3 - Service가 추가될 때마다 Dispatcher에도 변경이 전파됩니다.

 

Interface + CustomJsonDeserializer + Composite 패턴

interface SingleEndPointRequestWithCustomDeserializer{
    val type: String
}

data class DtoAV2(
        val col1: String,
        val col2: String,
        override val type: String = "DtoA",
): SingleEndPointRequestWithCustomDeserializer

data class DtoBV2(
        val col3: String,
        val col4: String,
        val col5: String,
        override val type: String = "DtoB",
): SingleEndPointRequestWithCustomDeserializer

다른 방법으로 추상화를 사용하고 CustomJsonDeserializer를 이용하여 동적인 request를 처리해보고자 합니다.

 

SingleEndPointRequestWithCustomDeserializer 인터페이스를 선언하고 각각의 request는 해당 인터페이스를 구현합니다.

해당 인터페이스는 type을 가지고 type을 통하여 동적인 request를 처리할 수 있습니다.

 

SingleEndpointDispatcherV2

interface SingleEndpointProcessorV2 {
    fun process(request: SingleEndPointRequestWithCustomDeserializer): String

    fun isSupport(type: String): Boolean
}

@Component
class SingleEndpointDispatcherV2(
        private val singleEndpointProcessors: List<SingleEndpointProcessorV2>,
): SingleEndpointProcessorV2 {
    override fun process(request: SingleEndPointRequestWithCustomDeserializer): String {
        val processor = singleEndpointProcessors.find { it.isSupport(request.type) } ?: throw IllegalArgumentException("지원되지 않는 타입")
        return processor.process(request)
    }

    override fun isSupport(type: String): Boolean {
        throw IllegalArgumentException("구현되지 않아야 함")
    }
}

 

이번에는 Dispatcher가 SingleEndpointProcessorV2 인터페이스를 구현합니다.

위에서 선언한 인터페이스로 request를 받아서 처리하기 위해 process 메서드를, type을 구분하기 위해 isSupport 메서드를 활용합니다.

 

Dispatcher는 ProcessorV2 인터페이스를 구현하고 있는 Service들의 Bean을 List로 의존성을 주입받습니다.

이후에 지원하는 type에 해당하는 Service를 찾고 해당 Service로 실행 책임을 위임합니다.

 

각각의 Service

@Service
class DtoAServiceV2: SingleEndpointProcessorV2 {
    override fun process(request: SingleEndPointRequestWithCustomDeserializer): String {
        val dtoA = request as DtoAV2
        println(dtoA)
        return dtoA.col1
    }

    override fun isSupport(type: String): Boolean {
        return type == "DtoA"
    }
}

각각의 Service에서는 SingleEndpointProcessorV2를 구현하며 해당 request를 변환하여 비즈니스로직을 처리하는 process 메서드를 구현하고, type을 식별할 수 있는 isSupport 메서드를 구현합니다.

 

Controller

@GetMapping("/v2/single-end-point")
fun singleEndpointV2(@RequestBody singleEndPointRequestV2: SingleEndPointRequestWithCustomDeserializer): String{
	singleEndpointDispatcherV2.process(singleEndPointRequestV2)
	return "hello"
}

이제 해당 인터페이스를 Controller를 통해 호출을 하면 어떤 일이 일어날까요?

Spring은 우리가 만든 Interface를 어떻게 다루어야 할지 모르기 때문에 에러가 발생합니다.

이 문제를 해결하기 위해서는 Spring에게 특정 Interface는 어떻게 바뀔 것이라는 걸 알려주어야 합니다.

 

CustomDeserializer 구현하기

class MyCustomDeserializer : JsonDeserializer<SingleEndPointRequestWithCustomDeserializer>() {
    override fun deserialize(jsonParser: JsonParser, deserializationContext: DeserializationContext): SingleEndPointRequestWithCustomDeserializer {

        val rootNode: JsonNode = jsonParser.codec.readTree(jsonParser)
        val typeNode = rootNode.get("type") ?: throw IllegalArgumentException("type이 존재하지 않습니다")
        val type = typeNode.asText()
        return when(type){
            "DtoA" -> DtoAV2(col1 = rootNode.get("col1").asText(), col2 = rootNode.get("col2").asText(), type = "DtoA")
            "DtoB" -> DtoBV2(col3 = rootNode.get("col3").asText(), col4 = rootNode.get("col4").asText(), col5  = rootNode.get("col5").asText(), type = "DtoB")
            else -> throw IllegalArgumentException("지원하지 않는 type: $type")
        }
    }
}

JsonDeserializer<T> Abstract Class를 구현하는 MyCustomDeserializer를 구현합니다.

입력값에 따라 JsonNode 클래스를 활용하여 type을 정의하고 해당 type으로부터 파싱을 수행해야 합니다.

 

이후 해당 Class를 Spring이 알 수 있도록 해야 하기 때문에 SimpleModule Bean에 Deserializer를 추가해 줍니다.

@Configuration
class JacksonModuleConfiguration {

    @Bean
    fun simpleModule(): SimpleModule{
        val module = SimpleModule()
        module.addDeserializer(SingleEndPointRequestWithCustomDeserializer::class.java, MyCustomDeserializer())
        return module
    }
}

이제 Spring이 해당 Interface를 어떻게 다루어야 할지 알기 때문에 요청을 정상적으로 처리할 수 있습니다.

 

CustomJsonDeserializer  활용 시 장, 단점 

  • 장점 1 - 또 다른 타입이 추가되더라도 Dispatcher에는 변경사항이 없어집니다. (새로운 request를 만들고, 해당 request가 어떻게 직렬화될지 Service에는 어떻게 처리될지만 정의하면 됩니다)
  • 단점 1 - 여전히 dto, domain 등으로 직접 변환하는 코드 및 에러 핸들링을 처리해야 합니다.
  • 단점 2 - 알아야 하는 지식들이 많아집니다 (Module 등록, CustomDeserializer, 디자인 패턴)
  • 단점 3 - Service가 인터페이스를 변환하는 역할까지 수행하게 됩니다.

 

Interface + 제네릭 + Json 어노테이션 + Composite 패턴

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes(
    JsonSubTypes.Type(value = DtoA::class, name = "DtoA"),
    JsonSubTypes.Type(value = DtoB::class, name = "DtoB")
)
interface SingleEndPointRequest {
    val type: String
}

data class DtoA(
        val col1: String,
        val col2: String,
        override val type: String = "DtoA",
): SingleEndPointRequest

data class DtoB(
        val col3: String,
        val col4: String,
        val col5: String,
        override val type: String = "DtoB",
): SingleEndPointRequest

@JsonTypeInfo, @JsonSubTypes 어노테이션을 활용하여 해당 인터페이스가 type이 DtoA, DtoB인지 따라 어떤 클래스로 변환될지를 미리 정의해 둡니다.

 

그리고 DtoA, DtoB 클래스는 SingleEndPointRequest 인터페이스를 구현하고 있습니다.

 

SingleEndPointDispatcherV3

interface SingleEndpointProcessor<out T: SingleEndPointRequest> {
    fun process(request: @UnsafeVariance T): String
}

@Component
class SingleEndpointDispatcherV3(
        private val singleEndpointProcessors: Map<String, SingleEndpointProcessor<SingleEndPointRequest>>,
): SingleEndpointProcessor<SingleEndPointRequest>{
    override fun process(request: SingleEndPointRequest): String {
        val processor = singleEndpointProcessors[request.type] ?: throw IllegalArgumentException("지원하지 않는 type 입니다")
        return processor.process(request)
    }
}

이번에는 SingleEndPointDispatcher가 제네릭을 활용하여 request의 타입을 SingleEndPointRequest으로 제한합니다.

이후 Map을 활용하여 SingleEndPointRequest 타입의 SingleEndpointProcessor 클래스를 Map으로 의존성 주입받고 process 메서드 호출을 통해 위임합니다.

 

일반적으로 제네릭의 경우 특정 타입으로 정의하면 특정 타입으로만 동작하기 때문에 Spring Bean은 SingleEndPointRequest 타입을 가져올 수 없습니다.

SingleEndPointRequest는 Interface이며 실제 Service는 실제 객체에 의해 구현되어 있기 때문입니다.

 

따라서 제네릭의 out을 활용하여 하위타입도 불러올 수 있도록 사용하고 @UnsafeVariance 어노테이션을 같이 활용해주어야 합니다.

 

Service

@Service("DtoA")
class DtoAServiceV3: SingleEndpointProcessor<DtoA> {
    override fun process(dtoA: DtoA): String {
        return dtoA.col1
    }
}

Service의 경우에는 이제 Interface 타입이 아닌 DtoA 클래스를 직접 인자로 받아 사용할 수 있습니다.

Bean 이름의 경우에는 DtoA (타입이름)과 동일하게 주어 Spring Bean이 Map으로 의존성을 주입받았을 때 타입이름으로 Service Bean을 찾을 수 있도록 구현하였습니다.

 

제네릭 + 어노테이션 활용 시 장, 단점

  • 장점 1 - 또 다른 타입이 추가되더라도 Dispatcher에는 변경사항이 없어집니다. (새로운 request를 만들고 어노테이션을 통해 해당 request가 어떤 클래스로 변환될지 Service에는 어떻게 처리될지만 정의하면 됩니다)
  • 장점 2 - Service에서 인터페이스 형변환을 수행하지 않아도 됩니다.
  • 단점 1 - 알아야 하는 지식들이 많아집니다 (제네릭, Json 어노테이션, 디자인 패턴)

 

 

마무리

3가지 방법으로 Dynamic Request를 구현하는 방법을 알아보았습니다.

각각의 방법들에는 trade off가 있을 것이고 팀원분들과 논의하여 현재에서 최선의 방법을 선택하는 것이 좋을 것 같습니다.

이외에도 더 좋은 방법, 다른 방법들이 있다면 알려주시면 감사하겠습니다.

 

코드는 Github에서 자세히 볼 수  있습니다.