실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 2: 테스트 코드로부터 피드백 받기

실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 2: 테스트 코드로부터 피드백 받기

시작하며

안녕하세요, 정산플랫폼팀의 윤입니다. 실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 1: 효율적인 Mock Test에서는 효율적으로 Mock 테스트를 진행하는 방법에 대해서 살펴봤습니다. Part 2에서는 테스트 코드로부터 어떤 피드백을 얻고, 실제로 실무에 어떻게 적용했는지 사례를 공유하고자 합니다.

테스트 코드를 작성하면 어떤 이점이 있을까요? 테스트 코드는 단순히 로직을 검증하는 용도뿐만 아니라, 우리가 작성한 구현 코드의 첫 사용자 역할을 하기도 합니다. 제품을 처음 사용하는 사용자로부터 피드백을 받듯이, 테스트 코드도 구현 코드의 첫 사용자로서 다양한 피드백을 전달해 주고 있습니다. 이러한 피드백을 귀 기울여 반영하면 구현 코드를 좋은 코드 디자인으로 자연스럽게 개선할 수 있습니다. 구현 코드를 개선함으로써 더 간결하고 검증하기 쉬운 테스트 코드를 작성할 수 있게 됩니다. 결국, 테스트 코드를 적극적으로 반영하는 과정에서 자연스럽게 더 나은 구현 코드를 작성하는 선순환 구조를 형성하는 셈입니다. 이렇듯 테스트 코드는 독립적으로 존재하는 코드가 아니라, 구현 코드와 긴밀하게 상호작용하며 소통하는 중요한 코드입니다.

발표 영상이 궁금한 분들은 아래 링크에서 확인 가능하며, 지난 글에서 내용이 이어집니다.

🔗 2023 Spring Camp 실무에서 적용하는 테스트 코드 작성 방법과 노하우

테스트 코드의 피드백을 통한 Mock Server 테스트의 필요성 확인

본 내용에 들어가기 앞서 “그러면 HTTP Mock 테스트는 필요 없나요?”라는 의문을 가질 수 있습니다. 실제로 너무 과도하게 Mocking 하는 것이 불편하기 때문이죠. 결론부터 말씀드리면 Mock Server 테스트는 필요합니다. 중요한 것은 해당 객체가 가지고 있는 책임입니다. 책임이 중요한 이유는 책임이 테스트해야 하는 주요 관심사로 이어지기 때문입니다. Mock Server 테스트 필요성을 살펴보기에 앞서 PartnerClient의 책임과 역할을 살펴보도록 하겠습니다.

PartnerClient 객체의 책임과 역할

PartnerClient 객체는 두 가지 주요 책임과 역할을 수행합니다. 첫 번째로, HTTP 통신을 담당합니다. 이는 외부 서비스와의 상호작용을 위한 통신을 처리하는 역할을 의미합니다. 두 번째로, 비즈니스 로직을 담당합니다. 이는 도메인 내에서 필요한 로직을 처리하며, 외부 서비스와의 통신 결과를 기반으로 실제 비즈니스 처리를 수행합니다.

PartnerClient 객체의 책임 분리

객체의 책임을 계층별로 나누어보면 위와 같이 Service Layer, RestTemplate, HTTP Client 3가지 구조로 나누어집니다.

  • Service Layer: 비즈니스 로직 처리
  • RestTemplate: 편리한 High-Level HTTP 통신 지원
  • HTTP Client: 실제 Low-Level HTTP 통신 수행

이러한 구조는 우리가 잘 알고 있는 형태로, Service와 Repository 간의 관계와 유사합니다.

  • Service Layer: 비즈니스 로직 처리
  • Repository: 데이터베이스 제어를 편리하게 하는 High-Level 기능 수행
  • JDBC: 실제 데이터베이스 제어의 Low-Level 기능 담당

우리는 서로 다른 책임으로 분리하는 것이 좋다는 것을 이미 경험적으로 알고 있습니다. 그렇다면 PartnerClient는 어떨까요?

PartnerClient 역시 비즈니스 로직을 담당하는 서비스 로직과 HTTP 통신을 담당하는 로직으로 책임을 분리하는 것이 더 좋습니다. 하지만 막상 구현에 집중하다 보면 이런 부분들을 쉽게 놓치곤 합니다. 테스트 코드는 코드에서 놓친 부분을 다시 찾아내는 데 도움을 줄 수 있습니다.

책임은 변화에 관한 것

이러한 책임 분리의 이유는, 결국 책임이 변화에 관한 것이기 때문입니다. 예를 들어, API가 XML에서 JSON으로 변경되어야 한다면 HTTP 통신 로직과 비즈니스 로직이 함께 있는 경우 변경이 어려워집니다. 그러나 객체의 책임이 분리되어 있다면, HTTP 통신만 담당하는 PartnerClient 객체만 변경하여 변화에 대응하는 것이 상대적으로 쉽습니다. 마찬가지로 비즈니스 로직의 변경이 필요한 경우에도 PartnerClientService 객체만 수정하여 대응할 수 있습니다.

미래의 수정 사항을 예측하기 어렵기 때문에 변경이 발생했을 때의 영향을 최소화하기 위해, 우리는 역할과 책임을 적절하게 분할하여 변경에 대비하여야 합니다. 이런 작업은 테스트 코드를 통해서 원활히 할 수 있습니다.

책임이 분리되지 않은 구현 코드는 주요 관심사를 테스트하기 어렵다.

우리는 책임 분리의 중요성을 경험적으로 알고 있으며 이러한 패턴에 익숙합니다. 그럼에도 불구하고 실제 구현 코드를 작성할 때 이러한 사실을 잊어버리고 코드를 작성하는 경우가 있습니다. 하지만 테스트 코드를 통해 해당 구현이 적절하지 않다는 피드백을 받을 수 있습니다. 구체적으로 어떤 피드백을 주고 있는지 살펴보겠습니다.

@Test
fun `Mock Server 기반 테스트 2xx가 아닌 경우 IllegalArgumentException 발생`() {
    //given
    val brn = "000-00-0000"
    val name = "주식회사 XXX"
    mockServer
        .expect(
            requestTo("http://localhost:8787/api/v1/partner/${brn}")
        )
        .andExpect(method(HttpMethod.GET))
        .andRespond(
            withStatus(HttpStatus.INTERNAL_SERVER_ERROR)
                .contentType(MediaType.APPLICATION_JSON)
                .body(
                    """
                        {
                          "brn": "$brn",
                          "name": "$name"
                        }
                    """.trimIndent()
                )
        )

    //when & then
    thenThrownBy {
        partnerClient.getPartnerBy(brn)
    }
        .isInstanceOf(IllegalArgumentException::class.java)
}

예를 들어, 2xx 응답이 아닌 경우 특정 예외가 발생하는 상황을 가정하면, 이 테스트 코드의 주요 관심사는 특정 시나리오에서 특정 예외가 제대로 발생하는지 확인하는 것입니다. 하지만 HTTP 응답 본문이나 HTTP 메서드와 같은 요소는 주요 관심사가 아닌데도, 불필요하게 관련 코드 양이 많아져서 주요 관심사를 가리게 되는 상황이 발생합니다. 테스트 코드가 주요 관심사가 아닌 부분에 많이 관여하고 있는 경우에는 실제 구현 코드가 잘못될 수 있다는 피드백을 제공하는 것일 수 있습니다. 이렇듯 테스트 코드를 유심히 살펴보는 것만으로도 충분한 인사이트를 얻을 수 있습니다. 또 다른 피드백을 살펴보겠습니다.

@Test
fun `Mock 기반테스트 PartnerResponse의 JSON을 Deserialize 테스트`() {
    //given
    val brn = "000-00-0000"
    val name = "주식회사 XXX"
    val response = PartnerResponse(brn, name)

    given(partnerClient.getPartnerByResponse(brn))
        .willReturn(ResponseEntity(response, HttpStatus.OK))

    //when
    partnerClientService.getPartnerBy(brn)
}

2xx 응답의 경우 JSON을 특정 객체로 역직렬화하는 것이 테스트의 주요 관심사입니다. 그러나 객체의 행위 자체를 Mocking으로 검증하는 것만으로는 JSON의 역직렬화 검증이 어렵습니다. 이처럼 테스트하고자 하는 주요 관심사를 테스트하기 어렵다는 것도 테스트 코드가 주는 피드백입니다.

HTTP 통신 책임만 수행하도록 리팩토링

fun getPartnerBy(brn: String): PartnerResponse {
    return restTemplate
        .getForObject(
            "/api/v1/partner/${brn}",
            PartnerResponse::class.java
        )!!
}

해당 피드백을 받고 HTTP 통신만 담당하도록 PartnerClient 객체를 다시 디자인했습니다. 기존 예외 처리 및 비즈니스 로직에 대한 부분은 다른 객체에 위임하고 온전히 HTTP 통신의 책임만을 갖게 합니다. 이로써 해당 객체의 주요 관심사는 HTTP 통신이고, 테스트 코드의 주요 관심사도 자연스럽게 HTTP 통신에 관한 것으로 귀결됩니다.

HTTP 통신 주요 관심사 테스트

@Test
fun `getPartnerBy test`() {
    //given
    val brn = "000-00-0000"
    val name = "주식회사 XXX"
    mockServer
        .expect(
            requestTo("http://localhost:8787/api/v1/partner/${brn}")
        )
        .andExpect(method(HttpMethod.GET))
        .andRespond(
            withStatus(HttpStatus.OK)
                .contentType(MediaType.APPLICATION_JSON)
                .body(
                    """
                        {
                          "brn": "$brn",
                          "name": "$name"
                        }
                    """.trimIndent()
                )
        )

    //when & then
    val response = partnerClient.getPartnerBy(brn)
    then(response.brn).isEqualTo(brn)
    then(response.name).isEqualTo(name)
}

주요 관심사를 테스트하기 위해 Mock Server 테스트를 진행합니다. 어떤 종류의 테스트를 진행하는지 자세히 알아보겠습니다.

URI 테스트

Request URI
Expected : http://localhost:8787/api/v1/partner/123
Actual : http://localhost:8787/api/v1/partner/000-00-0000

실제 요청되는 URI와 테스트를 통한 Mocking의 URI가 일치하는지를 검증합니다.

HTTP Method 테스트

Unexpected HttpMethod
Expected : POST
Actual : GET

실제 요청되는 HTTP Method와 테스트를 통한 Mocking의 HTTP Method가 일치하는지를 검증합니다.

Deserialize 테스트

Expected : "김밥천국"
Actual : "주식회사 XXX"

실제 받은 응답의 JSON과 해당 JSON을 역직렬화한 객체의 값이 일치하는지를 검증합니다.

HTTP Mock Server 테스트는 주요 관심사를 테스트하기 위해 필요하다.

객체의 책임과 역할을 명확하게 분리하면, HTTP 통신을 책임지는 객체가 자연스럽게 분리되며, 해당 객체의 주요 관심사를 테스트하기 위해서는 자연스럽게 Mock Server를 활용한 테스트가 필요합니다. Mock Server 테스트에서는 주로 요청 본문이 의도대로 직렬화되었는지, 응답 본문이 의도대로 역직렬화되었는지, 필수 헤더 정보 등이 올바르게 전송되었는지를 검증을 진행합니다.

비즈니스 로직 책임만 수행하도록 리팩토링

이번에는 비즈니스 로직에 대한 책임을 담당하는 PartnerClientService로 리팩토링을 진행해 보겠습니다.

class PartnerClientService(
    private val partnerClient: PartnerClient
) {

    /**
     * 2xx 응답이 아닌 경우 Business Logic에 맞게 설정
     */
    fun getPartnerBy(brn: String): PartnerResponse {
        val response = partnerClient.getPartnerByResponse(brn)
        if (response.statusCode.is2xxSuccessful.not()) {
            throw IllegalArgumentException("....")
        }
        return response.body!!
    }
}

주요 관심사인 비즈니스 로직인 객체를 분리하여 HTTP 통신 이후의 예외 처리와 비즈니스 로직을 다루는 코드를 구현했습니다. 해당 코드에서는 간단하게 예외 처리만 진행했습니다.

비즈니스 로직 주요 관심사 테스트

@Test
fun `getPartnerBy 200 응답 케이스`() {
    //given
    val brn = "000-00-0000"
    val name = "주식회사 XXX"
    val response = PartnerResponse(brn, name)

    given(partnerClient.getPartnerByResponse(brn))
        .willReturn(ResponseEntity(response, HttpStatus.OK))

    //when
    val result = partnerClientService.getPartnerBy(brn)

    //then
    then(result.brn).isEqualTo(brn)
    then(result.name).isEqualTo(name)
}

HTTP 통신은 주된 관심사가 아니므로 행위 기반 Mock 테스트를 사용하여 2xx 응답을 전제로 하고, 실제 주요 관심사에 집중하여 도메인 로직을 검증합니다. 이 테스트는 간단한 바인딩 여부 확인만을 검증하며, 복잡한 로직을 포함한다고 가정할 때는 상황이 달라집니다. 해당 복잡한 로직이 실제로 주요 관심사가 되며 그 관심사에 대해서 테스트를 진행하게 됩니다.

@Test
fun `getPartnerBy 400 케이스`() {
    //given
    val brn = "000-00-0000"
    val name = "주식회사 XXX"
    val response = PartnerResponse(brn, name)

    given(partnerClient.getPartnerByResponse(brn))
        .willReturn(ResponseEntity(response, HttpStatus.BAD_REQUEST))

    //when
    thenThrownBy {
        partnerClientService.getPartnerBy(brn)
    }
        .isInstanceOf(IllegalArgumentException::class.java)
}

4xx 응답으로 가정한 HTTP 통신 이후, 우리 도메인 로직과 관련된 주요 관심사를 테스트합니다. 이와 같이 객체의 책임을 명확하게 구분하고 할당하면, 각 객체는 자신의 주요 관심사에 집중한 테스트 코드를 간단하고 명료하게 작성할 수 있습니다.

객체가 본인의 책임을 다하지 않으면 그 책임은 다른 객체로 넘어간다.

컨테이너 벨트에서 앞선 작업자가 자신의 작업을 완료하지 않고 다음 작업자로 넘기면, 해당 작업은 다음 작업자가 처리해야 합니다. 컨테이너 벨트와 유사한 원리가 객체 간 협력 관계에서도 적용됩니다. 객체가 자신의 책임을 충실히 수행하지 않으면 어떤 일이 벌어질까요? 해당 책임은 다른 객체로 전이되고 다른 객체가 해당 책임까지 수행하게 되며, 전체 시스템에 부정적인 영향을 주게 됩니다. 이때 테스트 코드가 주는 피드백을 활용하면 객체에 적절한 책임을 할당하고, 자신의 주요 관심사에 집중하도록 할 수 있습니다. 이것이 테스트 코드의 중요한 가치 중 하나입니다.

테스트 코드의 피드백을 통한 외부 의존성 전파 문제 개선

HTTP 통신을 담당하는 HTTP Client 모듈 특성상 외부 다른 모듈에서 자주 사용하기 되기 때문에 다음과 같은 의존성 전파 문제가 발생할 수 있습니다. HTTP Client 모듈에서 RestTemplate처럼 특정 외부 라이브러리의 객체를 직접적으로 리턴하면 외부 라이브러리의 의존도가 모듈 전체로 퍼지게 되며, 외부 라이브러리 변경 시 해당 모듈을 의존하고 있는 모듈에게도 영향을 주게 됩니다. 테스트 코드로부터 어떻게 피드백을 받아 이를 개선하는지 살펴보겠습니다.

파트너 조회 코드 변경

운영자가 사업자 번호를 입력하면 파트너 서비스에 가맹점명을 질의하여 저장하는 Flow에서, 파트너 서비스에서 원하는 응답을 조회하지 못하는 경우는 국세청 서버에 질의하여 저장하는 Flow로 변경됐다고 가정해 보겠습니다.

/**
 * 기존 코드
 * 2xx 응답이 아닌 경우 Business Logic에 맞게 설정
 */
fun getPartnerBy(brn: String): PartnerResponse {
    val response = partnerClient.getPartnerByResponse(brn)
    if (response.statusCode.is2xxSuccessful.not()) {
        throw IllegalArgumentException("....")
    }
    return response.body!!
}

/**
 * 변경 코드
 * 2xx 응답이 아닌 경우 호출하는 곳에서 제어하게 변경
 * 리턴 타입은 spring-web 의존성의 ResponseEntity<T> 객체
 */
fun getPartnerEntityBy(brn: String): ResponseEntity<PartnerResponse?> {
    return partnerClient.getPartnerByResponse(brn)
}

기존 코드는 HTTP 응답이 2xx 범위에 속하지 않는 경우 Exception을 발생하고 있어 국세청 서버에 질의하기 위해서는 코드를 변경해야 합니다. 리턴 객체를 spring-web 의존성의 ResponseEntity<T> 객체로 변경함으로써 호출하는 곳에서 HTTP Status Code를 직접 제어할 수 있도록 수정합니다. 구현 코드가 변경됐으니 테스트 코드도 수정합니다.

@Test
fun `getPartner ResponseEntity 응답`() {
    //given
    val brn = "000-00-0000"

    given(mockPartnerClient.getPartnerEntityBy(brn))
        .willReturn(ResponseEntity(null, HttpStatus.BAD_REQUEST))

    //when
    val response = partnerClientService.getPartner(brn)

    //then
    then(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST)
}

400 응답했을 경우 ResponseEntity<T> 객체의 T가 null이라고 가정하고 테스트를 진행합니다. HTTP 모듈에서는 외부 라이브러리의 HTTP Status Code를 직접적으로 다루는 것이 큰 문제라고 생각이 들지 않을 수 있습니다. 이제 해당 모듈을 사용하는 서비스 코드도 변경 작업을 진행합니다.

가맹점 저장

fun register(
    brn: String,
): Shop {
    val partnerResponse = partnerClientService.getPartner(brn)
    val shop = when {
        partnerResponse.statusCode.is2xxSuccessful.not() -> {
            // 조회 실패시 추가 질의 로직...
            Shop(
                brn = brn,
                name = "국세청에서 응답받은 가맹점명..."
            )
        }
        else -> Shop(
            brn = brn,
            name = partnerResponse.body!!.name
        )
    }
    return shopRepository.save(shop)
}

파트너 서버에서 가맹점 정보를 가져오지 못하는 경우, 추가로 국세청 질의를 하는 코드를 작성합니다. 이 작업을 간소화하기 위해 편의상 국세청 질의는 실제로 진행하지 않고 하드코딩했습니다.

가맹점 저장 테스트 코드의 피드백

@Test
fun `register partner client에서 파트너 정보를 가져오지 못하는 경우 test`() {
    //given
    val brn = "000-00-0000"
    val name = "(주)한글"
    given(mockPartnerClient.getPartnerEntityBy(brn))
        .willReturn(
            ResponseEntity(
                PartnerResponse(brn, name),
                HttpStatus.BAD_REQUEST
            )
        )

    //when
    val shop = shopRegistrationService.register(brn)

    //then
    then(shop.brn).isEqualTo(brn)
    then(shop.name).isEqualTo("국세청에서 응답받은 가맹점명...")
}

서비스 모듈에서 추가로 국세청에 질의하는 테스트 코드를 작성하면 spring-web 의존성의 ResponseEntity<T> 객체를 다루게 됩니다. 만약 HTTP Client의 라이브러리가 변경된다면 HTTP Client를 사용하는 곳에서 직접적인 변경이 발생할 수 있기 때문에 사용하는 모듈에서 외부 의존성을 직접적으로 다루는 것은 적절하지 않다는 피드백을 받을 수 있습니다.

ResponseEntity<T> 객체를 리턴하게 됨으로써, 외부 모듈에게 외부 라이브러리의 의존성을 직접적으로 전달하여 자연스럽게 모듈 간의 결합도가 높아지는 문제가 발생합니다. 이러한 문제는 HTTP Client 모듈에서 외부 영향을 최소화할 수 있게 본인의 모듈에서 해결해야 합니다. 따라서 테스트 코드를 통한 이런 피드백을 반영하여 구현 코드를 리팩토링해 보겠습니다.

가맹점 저장 테스트 코드의 피드백 반영

/**
 * 2xx 응답이 아닌 경우 호출하는 곳에서 제어하게 변경
 */
fun getPartnerEntityBy(brn: String): ResponseEntity<PartnerResponse?> {
    return partnerClient.getPartnerByResponse(brn)
}

기존 코드는 spring-web 라이브러리에 의존성을 지닌 ResponseEntity<T> 객체를 반환하는 문제가 있습니다.

/**
 * Pair<Int, PartnerResponse?> 리턴
 */
fun getPartner(brn: String): Pair<Int, PartnerResponse?> {
    val partnerByResponse = partnerClient.getPartnerByResponse(brn)
    return Pair(
        first = partnerByResponse.statusCode.value(),
        second = partnerByResponse.body
    )
}

피드백이 반영된 코드에서는 Pair<Int, T?> 타입으로 리턴합니다. 만약 단순한 Int 형태로는 HTTP Status Code를 충분히 표현하기 어렵다고 판단된다면, 내부 모듈에서 사용할 HTTP Status Code를 직접 정의하여 사용할 수도 있습니다.

외부 라이브러리의 의존성을 직접적으로 전파하지 않음으로써 HTTP 클라이언트 라이브러리가 변경되더라도 모듈을 사용하는 측에서는 변경이 발생하지 않게 됩니다.

테스트 코드의 피드백을 통한 다양하고 복잡한 케이스 커버 개선

구매에 필요한 다양한 정보들은 여러 인프라스트럭처에 저장되어 있습니다. 예를 들어, 상품 정보는 Elasticsearch에, 환율 정보는 Redis에, 쿠폰 정보는 MySQL에, 그리고 가맹점 정보는 MongoDB에 저장되어 있다고 가정하겠습니다.

복잡한 상품 주문

@Service
class OrderService(
    private val productQueryService: ProductQueryService,
    private val exchangeRateClientImpl: ExchangeRateClientImpl,
    private val couponQueryService: CouponQueryService,
    private val shopQueryService: ShopQueryService
) {

    fun order(
        productId: Long,
        orderDate: LocalDate,
        orderAmount: BigDecimal,
        shopId: Long,
        couponCode: String?
    ): String {
        // 상품 정보는 Elasticsearch에서 조회
        val product = productQueryService.findById(productId)
        // 환율 정보는 Redis에서 조회
        val exchangeRateResponse = exchangeRateClientImpl.getExchangeRate(orderDate, "USD", "KRW")
        // 쿠폰 정보는 MySql에서 조회
        val coupon = couponQueryService.findByCode(couponCode)
        // 가맹점 정보는 MySql에서 조회
        val shop = shopQueryService.findById(shopId)

        /**
         * 복잡한 로직...
         * 1. 상품 정보 조회 하여 금액 및 상품 재고 확인, 재고가 없는 경우 예외 처리 등등
         * 2. 환율 정보 조회 하여 특정 국가 환율로 계산
         * 3. 쿠폰 정보 조회하여 적용 가능한 상품인지 확인, 가맹점과 할인 금액 부담 비율 등등 계산
         * 4. 가맹점 정보 조회하여 수수료 정보등 조회
         */
        val order = save(order)

        return order.orderNumber
    }
}

OrderService 객체는 다양한 인프라스트럭처에 저장된 정보를 수집하여 주문을 처리해야 합니다. 이 과정에서 주문은 다양한 관심사와 복잡한 로직으로 이루어져 있다고 가정합니다.

상품 주문 테스트 케이스

DataCase
상품 정보만약 상품에 재고가 없는 경우
환율 정보환율 정보를 가져오지 못하는 경우
환율 정보환율 정보를 기반으로 한 최종 결제 금액 계산
쿠폰 정보쿠폰을 특정 가맹점에 적용하지 못하는 경우 예외 처리
쿠폰 정보만료된 쿠폰의 경우 예외 처리
가맹점폐업 처리된 가맹점인 경우 예외
가맹점가맹점 필수 정보를 가져오지 못한 경우 예외 처리

이와 같은 주문 시나리오에 대한 테스트 케이스가 있고, 이를 반영하는 테스트 코드를 작성해야 한다고 가정하겠습니다.

상품 주문 테스트 코드 작성이 어려운 이유

테스트 코드 작성이 매우 어렵습니다. 다양한 인프라스트럭처에 데이터를 각 테스트 케이스에 맞게 설정해야 하며, 정상적인 케이스뿐만 아니라 비정상적인 케이스도 테스트해야 하기 때문에 Given절 설정이 더욱 어렵습니다.

이렇게 테스트 코드 작성이 어렵다고 느껴지면 테스트 코드를 무작정 작성하는 것이 아니라 객체 디자인을 다시 천천히 고려해 봐야 합니다. 어떤 문제점 때문에 테스트 코드 작성이 어려운지 살펴보도록 하겠습니다. 현재 테스트 코드의 작성이 어려운 원인은 다양한 인프라스트럭처에 저장되어 있는 데이터 셋업 그리고 복잡하고 다양한 케이스에 대한 비즈니스 로직이 OrderService 하나의 객체 안에 책임과 역할이 집중되어 있기 때문입니다.

특정 객체의 책임이 과중하다면 새로운 협력 객체를 참여 시켜 책임을 분산해야 한다.

만약 컨테이너 벨트에서 모두가 각자의 작업을 다하고 있는데도 작업이 힘들다면, 새로운 작업자를 추가하여 작업을 분산 시켜야 합니다. 이것은 객체 간의 협력 관계에서도 동일합니다. 협력 관계에 있는 객체들이 이미 각자의 책임과 역할을 충분히 수행하고 있지만, 특정 객체에 책임이 과하게 집중되어 있다면, 협력 관계에 참여할 새로운 객체를 추가하여 책임을 분산 시켜야 합니다.

OrderService의 과중한 책임 분산

OrderService는 두 가지 주요 책임을 갖고 있습니다. 첫 번째는 다양한 인프라스트럭처에서 데이터를 조회하는 것이며, 두 번째는 조회된 데이터를 기반으로 복잡한 주문 처리 비즈니스 로직을 처리하는 것입니다. OrderService에서는 각기 다른 인프라스트럭처의 데이터 조회, OrderServiceSupport에서는 복잡한 주문 비즈니스 로직 처리로 책임을 분산하는 작업을 진행합니다.

신규 협력 객체로 책임 분산

/**
 * Spring Bean Context와 인프라스트럭처의 관련 코드가 없는 순수한 POJO
 */
class OrderServiceSupport {

    /**
     * 각각의 인프라의 조회 책임을 위임 하여 복잡한 로직 작성... 에 대한 관심사만 갖는다.
     */
    fun order(
        product: Product,
        orderDate: LocalDate,
        orderAmount: BigDecimal,
        exchangeRateResponse: ExchangeRateResponse,
        shop: Shop,
        coupon: Coupon?,
    ): Order {

        /**
         * 복잡한 로직...
         * 1. 상품 정보 조회 하여 금액 및 상품 재고 확인, 재고가 없는 경우 예외 처리 등등
         * 2. 환율 정보 조회 하여 특정 국가 환율로 계산
         * 3. 쿠폰 정보 조회하여 적용 가능한 상품인지 확인, 가맹점과 할인 금액 부담 비율 등등 계산
         * 4. 가맹점 정보 조회하여 수수료 정보등 조회
         */
        return Order()
    }
}

OrderServiceSupport는 OrderService를 지원하는 객체로, 가장 주요한 특징으로 Spring Bean Context와 인프라스트럭처 관련 코드 없이 순수한 POJO 구조를 갖추고 있습니다. 이 객체는 주로 복잡한 로직 처리를 담당합니다.

간단해진 주문 로직 테스트

internal class OrderServiceSupportTest {

    @Test
    internal fun `쿠폰 적용 없는 주문 생성`() {
        //given
        val product = Product(
            productId = ...,
            amount = ...,
            currency = ...,
        )
        val orderDate = LocalDate.of(2022, 2, 2)
        val orderAmount = 100.toBigDecimal()
        val exchangeRateResponse = ExchangeRateResponse(
            1222.12.toBigDecimal()
        )
        val shop = Shop(
            feeRate = 0.023.toBigDecimal()
        )

        //when
        val order = OrderServiceSupport().order(
            product = product,
            orderDate = orderDate,
            orderAmount = orderAmount,
            exchangeRateResponse = exchangeRateResponse,
            shop = shop,
            coupon = null,
        )

        //then
        // 복잡한 로직..에 대한 검증
    }
}

그렇기에 OrderServiceSupport의 테스트 코드는 외부 의존성이 필요 없어 간단하게 작성 가능합니다.

간단하게 작성이 가능한 이유는 OrderServiceSupport가 스프링에도 인프라스트럭처에도 의존하지 않는 순수한 POJO 객체이기 때문입니다. 테스트도 동일하게 POJO로 작성할 수 있어 외부 의존성 없이 테스트 코드를 간단하게 작성할 수 있습니다. 이렇게 관련 로직을 한 번 객체로 내린 결과로, 우리는 다양한 케이스에 대해서 테스트 코드를 더 쉽게 작성할 수 있게 되었습니다.

또한, 다양한 인프라스트럭처를 사용하다 보면 다른 인프라스트럭처로 대체되는 일도 빈번하게 발생합니다. 중요한 로직을 POJO로 작성하게 되면, 인프라스트럭처가 변경되더라도 해당 로직은 변경의 영향을 상대적으로 덜 받게 됩니다.

복잡한 로직을 위임 이후 OrderService

fun order(
    productId: Long,
    orderDate: LocalDate,
    orderAmount: BigDecimal,
    shopId: Long,
    couponCode: String?
): String {
    // 상품 정보는 Elasticsearch에서 조회
    val product = productQueryService.findById(productId)
    // 환율 정보는 Redis에서 조회
    val exchangeRateResponse = exchangeRateClientImpl.getExchangeRate(orderDate, "USD", "KRW")
    // 쿠폰 정보는 MySql에서 조회
    val coupon = couponQueryService.findByCode(couponCode)
    // 가맹점 정보는 MySql에서 조회
    val shop = shopQueryService.findById(shopId)

    // 복잡한 로직... OrderServiceSupport 객체로 위임
    val order = OrderServiceSupport().order(
        product = product,
        orderDate = orderDate,
        orderAmount = orderAmount,
        exchangeRateResponse = exchangeRateResponse,
        shop = shop,
        coupon = coupon
    )
    val order = save(order)
    return order.orderNumber
}

OrderServiceSupport 클래스의 테스트 케이스가 철저히 작성되었다면, OrderService 클래스의 테스트 케이스는 상대적으로 간단하게 작성해도 될 것으로 보입니다. 각각의 인프라 조회 테스트 또한 각 조회 서비스의 테스트 코드에서 작성되어야 하므로, 직접적인 조회에 대한 테스트는 진행하지 않아도 충분합니다. 주문이 의도한 값으로 정상적으로 영속화됐는지를 중점으로 간단한 테스트만 작성해도 괜찮습니다.

마치며

객체 및 모듈 간의 의존성을 분리하고 객체의 책임과 역할을 적절하게 할당하는 것이 좋은 코드 설계라는 것을 이미 알고 있었습니다. 하지만 알고 있는 것과 실제 적용하는 것은 또 다를 수 있습니다. 저도 이런 학습을 했지만 실제 위의 개선 사항들은 실제 테스트 코드로 받은 피드백 기반으로 리팩토링을 진행하면서 개선을 했습니다. 또, 테스트 코드의 필요성을 언급하는 것이 무의미해질 정도로 테스트 코드의 필요에 대한 인식은 자리 잡았습니다. 다만 주로 안전성, 문서화, 리팩토링 시 안전성 등의 장점들로만 테스트 코드의 가치를 강조하는 부분이 아쉬웠습니다. 물론 이런 가치를 부정하는 것은 아닙니다. 다만 이런 가치만큼 테스트 코드가 주는 피드백의 가치 또한 크고 이 부분에 대해서 많은 분들과 논의하고 싶었습니다.

테스트 코드가 주는 피드백으로 구현 코드를 개선하고, 개선한 결과를 다시 테스트 코드로 확인하는 반복적인 사이클을 통해 지속적인 개선이 이루어질 수 있으며, 테스트 코드가 주요 관심사를 명확하게 다루고 있는지, 주요 관심사를 유지하면서 책임과 역할의 단위를 적절하게 나누고 있는지, 그 밖에 다양한 부분들에 대해서 피드백을 받을 수 있는 가치가 테스트 코드의 주요 가치 중 하나라고 생각합니다.

혹시 테스트 코드 작성이 불편하고 어렵나요? 그렇다면 이는 구현 코드의 품질과 구조에 대한 피드백일 수 있습니다. 그 피드백을 반영해 주세요.

yun.cheese
yun.cheese

카카오페이 정산플랫폼팀 윤입니다. 서버 개발자로서 좋은 코드 설계와 아키텍처에 관심이 많습니다.

태그