본문 바로가기
Java & Kotlin/Spring

[Spring with Kotlin] sealed class로 다양하게 요청 받기

by devson 2022. 1. 28.

예제 코드는 여기에서 확인할 수 있다.

 


 

서버 API를 만들다보면 동일한 리소스에 대해 다른 요청 파라미터를 받아야할 때가 있다.

기본적인 파라미터는 동일하지만 리소스의 타입에 따라 다른 파라미터를 추가적으로 받아야할 때

sealed class를 활용하여 하나의 리소스에 대한 다양한 요청 DTO를 관리할 수 있다.

 

예를 들어 유저를 등록하는 API가 있다고 하자.

기본적으로 서비스에서 가입 시 유저로부터 이름, (로그인 ID로써)이메일, 비밀번호를 받아야한다면

요청 파라미터는 다음과 같은 data class로 나타낼 수 있을 것이다.

 

data class UserSignUpRequest(
    @field:NotBlank
    val name: String,
    @field:Email
    val email: String,
    @field:Size(min = 8, max = 100)
    val password: String,
)

 

그런데 이 서비스는 커머스 서비스로서 유저가 추가적으로 일반 고객으로서 유저물품을 판매하는 유저로 타입이 나뉜다.

그리고 각 타입 별로 회원가입 시 받아야할 추가 정보가 달라진다.

 

- 공통 정보: 이름, (로그인 ID로써)이메일, 비밀번호

- 일반 유저 추가 정보: 배송을 받을 주소

- 판매자 유저 추가 정보: 회사명, 사업자 등록 번호

 

이 경우에 API를 단순화하기 위해 일반 유저 가입 API, 판매자 유저 가입 API 이렇게 2개의 API로 분리할 수 있을 것이다.

API를 분리한다면 각 API가 받아야할 파라미터의 차이가 있다보니 요청에 대한 DTO class도 2개가 될 것이다.

그러면 아래와 같이 사용자 요청에 대한 data class를 만드는 방식이 가장 일반적일 것이다.

 

// 일반 유저
data class CustomerUserSignUpRequest(
    @field:NotBlank
    val name: String,
    @field:Email
    val email: String,
    @field:Size(min = 8, max = 100)
    val password: String,
    
    @field:NotBlank
    val address: String,
)

// 판매자 유저
data class SellerUserSignUpRequest(
    @field:NotBlank
    val name: String,
    @field:Email
    val email: String,
    @field:Size(min = 8, max = 100)
    val password: String,

    @field:NotBlank
    val companyName: String, // 회사명
    @field:Pattern(regexp = """^\d{3}-\d{2}-\d{4}$""")
    val businessRegistrationNumber: String, // 사업자 등록 번호
)

 

하지만 위와 같이 완전히 분리된 class를 사용하면 문제가 될 만한 것이 있다.

 

1. validation 규칙이 변경될 경우 두 class 모두 변경해야한다.

예를 들어 비밀번호 길이에 대한 규칙이 변경되면 두 class의 password 프로퍼티에 걸린 어노테이션을 수정해야한다.

(custom validator를 만들어 사용하면 해결되는 부분이긴하다)

 

2. 공통적으로 받아야할 데이터가 새로 생겼을 때 두 class 모두 변경해야한다.

서비스를 운영하다보니 정책이 변경되어 회원 가입시 휴대폰 번호를 추가로 받아야하는 경우

두 class에 phoneNumber와 같은 프로퍼티를 추가해줘야한다.

 

굳이 따로 설명했지만 결국 회원가입과 관련된 정보가 두 개의 class에 중복되어 관리되기 때문에 생기는 문제들이다.

이 문제를 sealed class를 사용하여 풀어보도록 하자.

 

먼저 회원가입 시 받아야할 공통 정보에 대한 데이터를 담을 수 있도록 아래와 같이 sealed class를 만든다.

sealed class UserSignUpRequest(
    @field:NotBlank
    open val name: String,
    @field:Email
    open val email: String,
    @field:Size(min = 8, max = 100)
    open val password: String,
)

 

그리고 일반 유저에 대해 추가적인 데이터를 받도록 이 sealed class를 상속받는 data class를 만들도록 한다.

sealed class UserSignUpRequest(
    @field:NotBlank
    open val name: String,
    @field:Email
    open val email: String,
    @field:Size(min = 8, max = 100)
    open val password: String,
) {
    data class CustomerUser(
        override val email: String,
        override val name: String,
        override val password: String,

        @field:NotBlank
        val address: String,
    ) : UserSignUpRequest(
        name = name,
        email = email,
        password = password,
    )
}

 

그리고 판매자 유저에 대해서도 동일하게 data class를 추가해준다.

sealed class UserSignUpRequest(
    @field:NotBlank
    open val name: String,
    @field:Email
    open val email: String,
    @field:Size(min = 8, max = 100)
    open val password: String,
) {
    data class CustomerUser(
        // ...
    )

    data class SellerUser(
        override val email: String,
        override val name: String,
        override val password: String,

        @field:NotBlank
        val companyName: String, // 회사명

        @field:Pattern(regexp = """^\d{3}-\d{2}-\d{4}$""")
        val businessRegistrationNumber: String, // 사업자 등록 번호
    ) : UserSignUpRequest(
        name = name,
        email = email,
        password = password,
    )
}

이런 식으로 공통적인 요청 정보에 대해서는 sealed class에 담고,

각 타입별로 달라지는 추가 정보는 그 구현체에 담는 식으로 정보의 중복을 없앨 수 있다.

 

그리고 이 요청 DTO를 Controller에서 사용하는 것은 기존과 다를바가 없다.

@RestController
class SealedClassController {
    @PostMapping("/sealed/customer")
    fun customerUserRequest(
        @Validated @Valid @RequestBody request: UserSignUpRequest.CustomerUser,
        result: BindingResult
    ) {
        // ...
    }

    @PostMapping("/sealed/seller")
    fun sellerUserRequest(
        @Validated @Valid @RequestBody request: UserSignUpRequest.SellerUser,
        result: BindingResult
    ) {
        // ...
    }
}

 

요청에 대한 data class가 class를 상속받는 구조다보니 어쩔 수 없이 open, override 등 조금 더 코드가 추가된 부분이 있고 코드 자체가 지저분해 보일 수도 있다.

하지만 개인적으로 이런 식으로라도 정보의 중복을 없애는 것은 나쁘지 않다고 본다.

(코드도 그렇게 어렵진 않다고 생각한다)

또한 요청을 sealed class를 상속받아 만들었기 때문에 SignUpRequest의 구현체는 정해진 특정 클래스만 존재하는 것을 강제할 수 있다.

댓글