여러 제휴사와 연동하는 신규 프로젝트 개발기 2편

여러 제휴사와 연동하는 신규 프로젝트 개발기 2편

요약: 카카오페이 자동차보험 비교 서비스 개발 과정에서 사용자 입력의 유효성 검증과 테스트 코드 작성을 강조합니다. 클라이언트와 서버에서 입력값을 검증하며, Kotlin Contracts를 활용하여 서버에서의 유효성 검증을 강화합니다. 또한, 커스텀 익셉션을 이용해 보다 명확한 에러 핸들링을 도모하고, 모든 입력을 철저히 검증하여 서비스 안정성을 높입니다. 테스트 코드는 코드의 신뢰성 향상, 리팩토링의 안정성 검증, 엣지 케이스 검출 및 기획 구현 검증 등 다양한 이점을 제공합니다. 또한, 단위 테스트와 통합 테스트로 서비스의 예외 상황 대처 능력을 검증합니다. 이 모든 과정은 프로젝트의 성공적인 실행과 유지보수에 큰 도움이 되었습니다.

시작하며

안녕하세요, 카카오페이 보험마켓파티의 카펀입니다. 카카오페이 자동차보험 비교 서비스를 개발하며 마주한 문제와, 이들에 대한 고민을 소개하고 있습니다. 1편에서는 여러 제휴사 관리를 위한 공통 구조, 여러 제휴사를 호출할 때의 병렬 및 비동기 처리에 대해 소개해 드렸습니다. 2편에서는 사용자 입력에 대한 검증과 테스트 코드를 작성하는 방법에 대해 다루어 보겠습니다.

🔗 여러 제휴사와 연동하는 신규 프로젝트 1편

사용자 입력과 유효성 검증은 어떻게 다루어야 할까?

유효성 검증을 하는 이유

서비스를 매우 단순히 생각해 보면, 사용자는 아래와 같은 흐름으로 사용하게 됩니다.

  • 사용자는 클라이언트에 필요한 값을 입력한다.
  • 클라이언트는 서버에 값을 전달한다.
  • 서버는 제휴사에 값을 전달하고, 응답을 받는다.
  • 서버는 클라이언트에 응답을 보낸다.
  • 클라이언트는 사용자에게 응답을 보여 준다.

이 중에서 눈여겨보고자 하는 단계는 ‘클라이언트는 서버에 값을 전달한다’ 단계입니다. 사람이 입력한 값을 시스템에 전달하는 과정이다 보니, 입력 가능한 값의 범위를 엄밀하게 정의해 두지 않으면 온갖 잘못된 값이 들어올 수 있습니다. 이렇게 잘못된 값이 들어오기 시작하면, 예상치 못한 동작의 원인이 되거나, 서버로부터 받은 응답을 신뢰할 수 없게 됩니다.

사용자의 입력을 검증하는 것 역시 크게 두 가지로 나눌 수 있습니다.

  • 사용자의 입력을 클라이언트에서 검증하고, 잘못된 입력에 대해서는 경고를 표시한다.
  • 클라이언트에서 전달하는 값을 서버에서 검증하고, 잘못된 입력에 대해서는 Client-Error로 응답한다.

이 중 어떤 방법을 선택하는 것이 좋을까요? 저는 둘 다 병행해야 한다고 생각합니다. 각기 검증의 역할과 목적이 다르기 때문인데요.

클라이언트의 경우에는, 잘못된 입력값에 대해 사용자에게 알려 주고, 올바른 값을 선택할 수 있도록 안내하기 위한 검증을 진행합니다. 동시에 잘못된 값이 서버로 넘어가는 것을 막아 주는 역할을 합니다.

서버의 경우에는, 입력값이 시스템으로 전달되기 전에 마지막으로 확인을 하는 역할입니다. 이 단계에서 검증을 통과하지 못하면 애플리케이션이 정상 진행될 수는 없지만, 상대적으로 더 엄격하고 확실하게 검증할 수 있습니다.

서버 단계 검증이 특히 중요한 이유는, 클라이언트에서 의도와 상관없이 잘못된 값을 넘겨줄 수도 있다는 점입니다. 여러 경우의 수가 겹치다 보면, 클라이언트의 검증이 완벽하지 않을 수 있습니다. 예상하지 못한 사용자의 입력이 가능한 경우에도, 서버에서 이를 최종 확인해서 막아야 합니다. 또한 클라이언트는 사용자에게 노출되어 있기 때문에, 나쁜 의도를 가지고 서버에 잘못된 값을 보내도록 조작하는 등의 리스크 역시 존재합니다. 서버 단계에서의 검증을 통해 이러한 리스크를 줄일 수 있습니다.

Kotlin Contracts를 이용한 필드 검증

Kotlin Contracts는 Kotlin 1.3에서 추가되었습니다. 주된 역할은, 코드 분석 시 컴파일러가 모르는 정보를 사용자가 알고 있을 때, 그 정보를 알려주는 것입니다.

이를 잘 이용한 것이 Kotlin의 require, check와 같이 조건을 검증하는 것입니다. require는 조건을 만족하지 않는 경우 IllegalArguentException을, check는 조건을 만족하지 않는 경우 IllegalStateException을 발생시킵니다.

val num: Int = 19
require(num < 12)	// IllegalArgumentException
check(num < 12)	// IllegalStateException

같은 방식으로, requireNotNullcheckNotNull도 존재합니다.

val userHistory: UserHistory? = somethingRepository.getUserHistory(userId)	// null

requireNotNull(userHistory) // IllegalArgumentException
checkNotNull(userHistory)	// IllegalStateException

이들의 구현체를 살펴보면, contract를 사용하고 있습니다. 구조는 거의 동일하므로, require의 경우만 예시로 보여 드리겠습니다.

@kotlin.internal.InlineOnly
public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        val message = lazyMessage()
        throw IllegalArgumentException(message.toString())
    }
}

@kotlin.internal.InlineOnly
public inline fun <T : Any> requireNotNull(value: T?, lazyMessage: () -> Any): T {
    contract {
        returns() implies (value != null)
    }

    if (value == null) {
        val message = lazyMessage()
        throw IllegalArgumentException(message.toString())
    } else {
        return value
    }
}

require의 경우에는, 조건이 true가 아닌 경우 예외를 발생시킵니다. 메서드가 정상적으로 호출된 후에는, contract를 통해 조건이 true임을 사용자가 컴파일러에게 알려 주는 역할을 합니다.

requireNotNull의 경우에는, 조건이 null인 경우 예외를 발생시킵니다. 메서드가 정상적으로 호출된 후에는, contract를 통해 주어진 값이 null이 아님을 사용자가 컴파일러에게 알려 주는 역할을 합니다.

커스텀 익셉션을 contract를 이용해서 사용해 보자!

공통 익셉션을 많이 사용하기도 하지만, 프로젝트에 따라 알맞은 커스텀 익셉션을 만들어 두고 사용하기도 합니다. 저희도 마찬가지였는데요. 예시로 KakaopayException, KakaopayClientException 두 개를 만들어 사용했다고 가정하겠습니다. KakaopayException은 HTTP Status 500을 응답하기 위한 예외이며, KakaopayClientException은 HTTP Status 400을 응답하기 위한 예외입니다.

위의 require, requireNotNull 구현체를 참고하여, 동일한 구조로 익셉션만 변경하여 사용할 수 있습니다. 아래와 같이 정의하여 사용했습니다.

// KakaopayClientException
@kotlin.internal.InlineOnly
public inline fun kakaopayClient(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        val message = lazyMessage()
        throw KakaopayClientException(message.toString())
    }
}

@kotlin.internal.InlineOnly
public inline fun <T : Any> kakaopayClientNotNull(value: T?, lazyMessage: () -> Any): T {
    contract {
        returns() implies (value != null)
    }

    if (value == null) {
        val message = lazyMessage()
        throw KakaopayClientException(message.toString())
    } else {
        return value
    }
}

// KakaopayException
@kotlin.internal.InlineOnly
public inline fun kakaopayServer(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        val message = lazyMessage()
        throw KakaopayClientException(message.toString())
    }
}

@kotlin.internal.InlineOnly
public inline fun <T : Any> kakaopayServerNotNull(value: T?, lazyMessage: () -> Any): T {
    contract {
        returns() implies (value != null)
    }

    if (value == null) {
        val message = lazyMessage()
        throw KakaopayClientException(message.toString())
    } else {
        return value
    }
}

유효성 검증 예시

앞서 유효성 검증을 하는 이유에 대해 소개해 드렸습니다. 이에 따라 저희 프로젝트에서는 클라이언트로부터 받는 입력을 위의 contracts를 이용해 진행했는데요.

이때 검증 범위는 어디까지 가져가야 할까요? 결론부터 소개해 드리면, 저희는 모든 입력을 검증하였습니다. 언급한 바와 같이 서버에서의 입력 검증은 시스템에서 사용할 데이터를 검증하는 최종 검증이기 때문에, 클라이언트로부터 받는 모든 값은 검증하기 전에는 신뢰할 수 없다는 마인드로 접근하였습니다.

예를 들어서 아래와 같은 request dto가 있다면,

data class SomethingRequestDto(
  val name: String,
  val startDate: LocalDate,
) {
    private val now: LocalDate = LocalDate.now()
}

이름은 한글만을 포함할 수 있고, 시작 날짜는 오늘로부터 10일 이내여야 하는 조건으로 검증해 보겠습니다. Kotlin에서는 init 생성자를 이용하면 확실하게 검증할 수 있습니다.

init {
  kakaopayClient(name.matches(koreanRegex)) { "이름의 형식이 잘못되었습니다." }
  kakaopayClient(startDate in now..now.plusDays(10)) { "시작 날짜의 범위가 잘못되었습니다." }
}

만약 아래와 같은 requestDto가 있고,

data class SomeBooleanRequestDto(
  val hasPhone: Boolean,
  val hasTablet: Boolean,
  val hasLaptop: Boolean,
  val hasDesktop: Boolean,
  val hasAll: Boolean
)

아래와 같은 제약조건이 존재한다면,

  • hasAll이 true이면 나머지는 전부 false이다.
  • hasAll을 제외한 필드 중 단 하나라도 true이면 hasAll은 false이다.

다음과 같이 검증할 수 있습니다.

init {
  val unitFields: Boolean = hasPhone || hasTablet || hasLaptop || hasDesktop
  if (hasAll) kakaopayClient(unitFields == false) { "hasAll이 true입니다." }
  if (unitFields) kakaopayClient(hasAll == false) { "개별 항목 중 true가 존재합니다." }
}

이와 유사한 방식으로 자동차 보험 비교 서비스를 개발하며 클라이언트에서 받은 모든 입력을 검증하였습니다.

유효성 검증은 서비스의 안정성을 크게 끌어올린다

예시로 보여드린 코드는 구조가 간단하지만, 실제 프로젝트에서 입력은 매우 많은 경우의 수가 존재했습니다.

자동차 보험 비교에서는 사용자가 다양한 정보를 입력하게 됩니다. 운전 대상, 각종 할인 특약 및 보장 범위, 자동차 정보 등, 잘못 입력될 경우 결과가 크게 달라질 수 있는 입력값들이며, 자칫 실제와 크게 다른 결과를 계산하여 사용자에게 보여주게 된다면 프로덕트의 신뢰도 하락으로 이어질 수 있습니다. 이렇기 때문에라도 입력 검증은 절대 건너뛸 수 없는 중요한 작업입니다.

실제로 이를 통해 아주 가끔씩 발생하는 클라이언트의 입력값 검증 오류를 검출하기도 하였습니다. 서비스 오픈 이후 지금까지, 잘못된 입력값이 서비스 내부로 들어온 적이 단 한 번도 없다는 점에서 특히 유효성 검증의 중요성과 위력이 체감되었습니다.

검증 로직을 추가하는 것은 엔지니어 입장에서는 약간의 수고로움을 추가하는 것이지만, 그 보답으로 시스템의 확실한 안정성과 예외에 대한 제어를 얻게 됩니다.

테스트는 어떻게 고민하고 작성해야 할까?

여러분은 코드를 작성할 때 테스트 코드를 작성하고 계신가요? 저는 제가 작성한 코드에 대해 불신이 가득합니다. 컴퓨터는 거짓말과 실수를 하지 않지만, 사람은 실수를 하고, 코드는 사람이 작성한 것이니까요. 그러다 보니 코드에 대해 자신이 없을수록 테스트 코드를 더 꼼꼼히 작성하게 되곤 합니다.

테스트 코드를 작성하는 이유

테스트 코드를 작성하면서 얻게 되는 이점이 크게 체감되는데요. 대표적으로 아래와 같습니다.

  • 작성한 복잡한 로직에 대한 신뢰성 향상
  • 리팩토링 전후의 로직 동일성 검증
  • 엣지 케이스의 조기 검출 및 보완
  • 기획서에 대한 이해도 검증 및 코드 레벨로의 기획 구현

복잡한 로직은 코드를 꼼꼼히 읽어도 이해하기 어려울 때가 많습니다. 특히 기획상으로도 다양한 경우의 수가 있다면 더더욱 그런데요. 각 경우의 수를 테스트 코드로 작성해서 검증하다 보면, 작성한 코드에 대한 신뢰도를 확보할 수 있는 것은 물론, 기획 상의 오류 혹은 기획서에 대한 제 이해도를 높일 수 있습니다. 동료들에게 작성한 코드에 대해 소개할 때도 훨씬 간편해지고요.

게다가 리팩토링에도 강한 코드가 됩니다. 프로덕트를 만들고 유지보수하는 것은 오너십을 가진 엔지니어라면 누구나 경험하게 되는 과정인데요. 만약 테스트 코드가 없다면, 리팩토링 전후에 프로덕트가 동일하게 동작한다는 것을 확신할 수 있을까요? 물론 QA를 통해 확인할 수 있지만, QA 이전에 개발자 레벨에서의 검증을 거쳐야 QA의 수고로움을 덜 수 있습니다. 그렇다고 리팩토링을 안 할 수는 없으니, 테스트 코드는 필수라고 느끼고 있습니다.

물론 테스트 코드가 만능인 것은 아닙니다. 개발 일정에 테스트 코드 작성을 포함시키기 빠듯한 경우가 많고, 리팩토링을 진행할 때에도 수정해야 할 범위가 많게는 배로 늘어나기도 합니다. 그럼에도 당장 약간의 시간을 더 투자하여 테스트 코드를 작성한다면, 정량적으로 측정할 수는 없지만 미래의 수많은 시간들을 아낄 수 있게 된다고 믿고 테스트 코드를 작성하곤 합니다.

테스트 코드는 어느 범위까지 작성하는 것이 좋을까?

저는 작성하는 모든 코드에 대해 테스트를 작성하지는 않지만, 필요성에 따라 집중해서 작성하는 편입니다. 필요성을 판단하는 기준은

  • 비즈니스 로직을 포함하는가?
  • 조건에 따른 분기의 경우의 수가 많은가?
  • 실제 환경에서 테스트하기 어려운 조건인가?

그렇다 보니 테스트 커버리지가 높게 나오지는 않습니다 (jacoco 기준 약 30%). 하지만 테스트 커버리지가 의미 있는 지표냐고 생각해 본다면, 저는 부정적입니다. 코드의 복잡도는 상황에 따라 다른데요. 예를 들어서 아래와 같은 팩토리 메서드와 유효성 검증을 위한 메서드가 있다면,

object SomethingFactory {
  fun convert(dto: SomethingUserDto): SomethingUserResponse {
    return with(dto) {
      SomethingUserResponse(
        name,
        birthday,
        height
      )
    }
  }
}

data class SomethingUserResponse(
  val name: String,
  val birthday: Birthday,
  val height: Int
) {
  init {
    kakaopayClient(name.matches(koreanRegex)) { "이름의 형식이 잘못되었습니다." }
    kakaopayClient(birthday.age >= 20) { "나이를 확인해 주세요." }
    kakaopayClient(100 <= height && height <= 190) { "가입할 수 있는 키의 범위 밖입니다." }
  }
}

테스트 커버리지를 높이기 위해서라면 두 메서드 모두에 대한 테스트 코드를 한 개씩 작성하면 됩니다. 즉 테스트 커버리지 100%를 달성할 수 있습니다. 하지만 이것이 좋은 테스트 코드라고 할 수 있을까요?

단순한 팩토리 메서드에 대해서는 테스트할 내용이 그리 많지 않습니다. 심지어 위와 같이 필드 수가 적은 경우라면 매우 자명해서, 테스트를 작성하지 않기도 합니다. 반면 SomethingResponse의 검증 내용은 테스트할 수 있는 경우의 수가 매우 많습니다.

  • 이름 검증만을 통과하지 못할 경우 발생하는 예외와 예외 메시지
  • 이름 검증과 생일 검증을 통과하지 못할 경우 어떤 예외가 우선해서 발생하는지
  • 모든 검증을 통과할 경우 아무 예외도 발생하지 않는지
  • 등등…

수많은 경우의 수를 고려하여 한 메서드에 대해 테스트 코드를 작성한다면, 비록 테스트 커버리지는 낮을 수 있지만 프로덕트에 대한 신뢰도는 매우 훌륭하게 확보할 수 있습니다. 이에 따라 저는 테스트 코드에 대해 일종의 선택과 집중을 하고 있습니다.

테스트 영역

많은 경우 저는 통합 테스트보다는 단위 테스트를 작성하곤 합니다. 실제로 단위 테스트는 아래와 같은 장점이 있습니다.

  • Spring을 시작하지 않아도 되니 테스트 속도가 빠릅니다.
  • 특정 기능을 독립적으로, 다양하게 테스트할 수 있습니다.
  • 행위 (mock)와 상태(stub) 검증에 용이합니다.

실제로 서비스를 개발하며 집중적으로 진행한 테스트 범위에 대해 소개해 보겠습니다.

유효성 검증 단위 테스트

앞서 소개한 유효성 검증은 굉장히 그 수가 많으며, 검증하는 경우의 수도 많습니다. 그 검증 로직 중 어딘가가 잘못되어서, 실제로는 유효한 값을 통과시키지 않고 있다면? 혹은 실제로는 유효하지 않은 값을 통과시키고 있다면? 더 큰 문제의 시작점이 될 수 있습니다. 그만큼 유효성 검증 로직은 확실해야 합니다.

이에 대한 확신을 얻기 유리한 방법이 바로 단위 테스트입니다. 앞서 소개했던 SomethingUserResponse의 검증 로직에 대해, 예시 코드로 바로 확인해 보겠습니다.

val successResponse = SomethingUserResponse(
  name = "김카펀",
  birthday = Birthday.from(LocalDate.of(2000,1,1)),
  height = (100..190).random()
)

@Test
fun `이름에 숫자가 들어갈 경우 KakaopayClientException이 발생한다`() {
  // given
  val invalidName = "김카펀1"

  // when
  val exception = assertThrows<KakaopayClientException> { SomethingUserResponse.copy(name = invalidName) }

  // then
  assertThat(exception.message).isEqualTo("이름의 형식이 잘못되었습니다.")
}

유효한 입력 예시를 하나 만들어 두고, 각 테스트 코드마다 유효하지 않은 값을 지정해 주는 식으로 테스트를 작성하였습니다. 순수한 Kotlin 코드이기 때문에 (테스트 라이브러리 외에) 어떤 라이브러리에도 의존하지 않고, 빠르게 예외가 발생함을 검증할 수 있습니다.

위의 테스트 외에도, 유사한 구조로 각 필드의 다양한 조건에 대해 검증 조건을 테스트할 수 있습니다.

유효한 입력에 대해서는 아래와 같이 테스트할 수 있습니다.

@Test
fun `모든 필드가 유효할 경우 아무런 예외도 발생하지 않는다`() {
    // given
    // when
    // then
    assertDoesNotThrow {
        SomethingUserResponse(
            name = "김카펀",
            birthday = Birthday.from(LocalDate.of(2000, 1, 1)),
            height = (100..190).random()
        )
    }
}

비즈니스 로직 단위 테스트

팩토리 메서드 데이터 변환 단위 테스트

앞서 테스트의 선택과 집중에서는 팩토리 메서드의 테스트 중요도를 상대적으로 낮게 소개하였습니다. 하지만 데이터가 여러 번의 팩토리 메서드를 통해 변환된다면? 팩토리 메서드를 통해 생성되는 필드가 수십 개라면? 각 제휴사마다 다른 부분이 조금씩 존재한다면? 중간 어디선가 의도와 다르게 변환될 가능성이 있습니다.

자동차 보험 비교 서비스가 딱 그런 케이스인데요. 앞서 소개드린 구조대로,

  • 클라이언트 -> 서버 request DTO -> DB 저장 -> 제휴사 공통 request DTO -> 제휴사 개별 request DTO
  • 제휴사 개별 response DTO -> 제휴사 공통 response DTO -> DB 저장 -> 서버 response DTO -> 클라이언트

이런 과정을 겪는 동안 여러 입력값과 결괏값이 팩토리 메서드를 거쳐 변환됩니다. 이들 중 어디선가 변환이 잘못되고 있다면, 탐지하기 매우 어려운 오류로 진행될 가능성이 있습니다. 특히 실제 프로젝트에서는 각 DTO마다 필드가 수십 개이며, 여러 제휴사에 대해 변환이 이루어지고 있다 보니, 자칫 잘못 변환되고 있다면 이를 찾아내기 어렵습니다. 이를 테스트 코드를 통해 한 번 검증함으로써 데이터가 올바르게 변환되고 있다는 확신을 얻을 수 있습니다.

예시를 통해 소개해 보겠습니다. 아래와 같은 공통 request DTO가 있고,

data class SomethingRequestDto(
    val requestUuid: String,
    val userPersonalInformation: String,
    val userNotPersonalInformation: String,
    val partnerUserId: String,

    val condition: String,
    val currentStatus: CurrentStatus,
    val isStatusChanged: Boolean,

    val startDate: LocalDate,
    val endDate: LocalDate,
    val discountCode: DiscountCode,
    val contractCode: ContractCode,
    val promotionCode: PromotionCode
)

이를 제휴사 Alpha의 request DTO에 대해 변환하도록 팩토리 메서드를 작성합니다.

data class AlphaApiRequestRaw(
    val bizCode: String,
    val requestUuid: String,
    val userPersonalInformation: String,
    val userNotPersonalInformation: String,
    val partnerUserId: String,

    val condition: String,
    val status: String,
    val changed: Boolean,

    val startAt: LocalDate,
    val endAt: LocalDate,
    val discount: String?,
    val contract: String?,
    val promotion: String?
) {
    companion object {
        fun from(request: SomethingRequestDto): AlphaApiRequestRaw {
            val bizCompanyCode = BizCompanyCode.ALPHA
            return with(request) {
                AlphaApiRequestRaw(
                    bizCode = bizCompanyCode.code, // 검증 필요
                    requestUuid = requestUuid,
                    userPersonalInformation = userPersonalInformation,
                    userNotPersonalInformation = userNotPersonalInformation,
                    partnerUserId = partnerUserId,
                    condition = condition,
                    status = currentStatus.code, // 검증 필요
                    changed = isStatusChanged,
                    startAt = startDate,
                    endAt = endDate,
                    discount = discountCode.convert(bizCompanyCode), // 검증 필요
                    contract = contractCode.convert(bizCompanyCode), // 검증 필요
                    promotion = promotionCode.convert(bizCompanyCode)	// 검증 필요
                )
            }
        }
    }
}

필드가 많아질수록 잘못 변환되고 있는 점을 찾아내기 어렵다는 의미가 이해가 되시나요?

위 팩토리 메서드 AlphaApiRequestRaw.from 중 특히 검증이 필요한 부분은 주석으로 표시해 두었습니다. status의 경우에는 enum 엔트리의 알맞은 값이 전달되고 있는지 확인해야 하며, discount, contract, promotion의 경우, 주어진 상태값이 각 제휴사에 걸맞은 값, 혹은 사용하지 않는 경우 null로 변환되는지 검증해야 한다고 가정하였습니다.

단위 테스트로 검증한다면,

@Test
fun `AlphaApiRequestRaw 팩토리 메서드 테스트`() {
    // given
    val request = SomethingRequestDto(
        // ...
        currentStatus = CurrentStatus.NORMAL,
        // ...
        discountCode = DiscountCode.TEN,
        contractCode = ContractCode.NONE,
        promotionCode = PromotionCode.EARLY_BIRD
    )

    // when
    val result = AlphaApiRequestRaw.from(request)

    // then
    assertThat(result.bizCode).isEqualTo(BizCompanyCode.ALPHA)
    assertThat(result.discount).isEqualTo("01")
    assertThat(result.contract).isEqualTo("00")
    assertThat(result.promotion).isNull()
}

특히 확인이 필요한 필드들을 중심으로 검증을 진행할 수 있습니다. 물론 전체 필드에 대해 검증할 수도 있습니다.

API 호출 관련 통합 테스트

외부 API를 호출하는 경우에 대해 테스트하고 싶은 경우가 있습니다. 제휴사 API들은 전부 외부에 있고, 어느 제휴사의 API 응답이 잘못되어도 우리의 서비스에는 아무런 영향을 전파하지 않아야 합니다. 따라서 API 호출이 실패할 경우의 fallback, 장애 전파를 막는 circuit breaker에 대한 설정을 테스트하였습니다.

이 경우에는 통합 테스트로 진행하였는데요. 실제 제휴사의 API를 호출하는 것이 아닌, mocking을 통해 원하는 응답값을 설정한 후 테스트를 진행하였으며, 이를 위해 wiremock이라는 라이브러리를 활용하였습니다. wiremock에 대한 자세한 소개는 생략하고, 몇 가지 예시를 소개하겠습니다.

Biz라는 제휴사에 대해, timeout이 발생할 경우 fallback 값을 리턴하는 테스트입니다.

    @Test
    fun `readTimout 발생 시 fallback값을 리턴한다`() {
        wireMockServer.stubFor(
            WireMock.post(WireMock.urlPathMatching("/gateway/compare"))
                .willReturn(
                    WireMock.aResponse()
                        .withFixedDelay(3000)	// 3초의 딜레이 설정
                        .withStatus(HttpStatus.OK.value())
                        .withHeader("Content-Type", "application/json")
                        .withBody(
                            objectMapper.writeValueAsString(
                                CarrotCarAgreeResponse(
                                    payload = ApiCarAgreeResponseRaw(
                                        "200",
                                        "정상처리",
                                        "20231119205311"
                                    )	// 정상 응답
                                )
                            )
                        )
                )
        )

        // given
        val options = Request.Options(
            10000,
            TimeUnit.MILLISECONDS,
            1,	// 1ms를 초과할 경우 readTimeout 발생
            TimeUnit.MILLISECONDS,
            false
        )

        // when
        val fallback = bizApiFetcher.agree(testAgreeRequest, "token", options)

        // then
        assertThat(fallback.payload.responseCode).isEqualTo(ResponseCode.FALLBACK.code)
    }

API가 3초 후에 응답하도록 설정 후, readTimeout 값을 1ms로 설정한 테스트입니다. 테스트 코드대로 실행된다면 readTimeout이 발생할 것이며, 이때 예외를 발생시키는 대신 fallback값을 리턴하는지 여부를 검증합니다.

또는 circuitbreaker에 대한 테스트도 작성할 수 있습니다.

    @Test
    fun `타임아웃 에러 응답이 허용 숫자를 초과하면 CircuitBreaker 상태가 OPEN이다`() {
        // given
      	// 위와 같음

        // when
        for (i in 1..20) {
            bizApiFetcher.agree(testAgreeRequest, "token", options)
        }

        // then
        assertThat(circuitBreakerRegistry.circuitBreaker(CIRCUIT_BREAKER_NAME).state).isEqualTo(
            CircuitBreaker.State.OPEN
        )
    }

앞과 동일하게, 응답에 3초가 걸리는 API와 1ms 후에 readTimeout을 발생시키는 wiremock 설정입니다. API를 짧은 시간에 20번 호출하게 되면, 설정에 따라 CircuitBreaker의 상태가 CLOSED에서 OPEN으로 바뀌는지 여부를 확인할 수 있습니다.

위와 같은 테스트들의 장점은, 실제로 제휴사에 요청을 보내지 않으면서도, 제휴사의 응답이 잘못되었을 경우에 대한 테스트를 진행할 수 있다는 점입니다. 서비스 자체의 정상 동작 여부도 중요하지만, 그만큼 예외 상황에 대한 대처 능력도 테스트를 통해 검증해야 합니다.

테스트는 코드에 대한 이해도를 높여 준다

이렇듯 다양한 관점에서 단위, 통합 테스트를 작성하였는데요. 앞서 테스트 코드를 작성하며 기대한 이점과 같이, 실제로 기획 사항 변경 및 코드 리팩토링 시에 큰 도움이 되었습니다.

다양한 제휴사에 대해 각각 구현 내용이 조금 바뀌거나, 프로덕트를 개선하며 기존 로직을 명확히 이해해야 하는 경우가 있었습니다. 허용하는 사용자 키의 최댓값을 190에서 180으로 줄인다면? 한글만 허용하던 이름 필드에 숫자를 허용한다면? 동일한 사용자 입력에 대해 특정 제휴사에는 다른 값으로 전달하도록 변경된다면? 실제로 위와 같은 개선 작업들이 진행되었고, 그때마다 변경된 요건에 맞게 테스트 코드를 수정하고, 프로덕트 코드를 수정하며 기능 변경에 유연하게 대응하였습니다.

리팩토링 시에도 큰 도움이 되었습니다. 특정 기능의 저장 로직을 개선할 때, 조건에 따라 저장 횟수가 분기하는 로직이 있었는데요. 기존에 작동하던 테스트 코드들이 리팩토링 후에도 정상 동작하는 것을 통해 리팩토링에 대한 신뢰성을 확보하였습니다. 또는 서킷브레이커의 횟수 카운트에 특정 조건을 추가하는 리팩토링을 진행하였는데, 기존에는 모든 경우에 열리던 서킷이 리팩토링 후에는 특정 조건에서만 열리는 것 역시 테스트 코드를 통해 확신을 가질 수 있었습니다.

맺음말

카카오페이 자동차보험비교 서비스를 개발하며 경험한 고민들을 추상화하여 소개해 드렸습니다. 엔지니어가 어떤 문제를 해결하고자 할 때는, 시도하고자 하는 방법을 통해 어떤 점을 얻을 수 있고, 어떤 점이 리스크로 존재하며, 이를 종합적으로 고려해야 합니다. 또한, 시도한 방법과 그 결과에 대해, 의도한 바를 달성하였는지, 의도치 않은 점은 없었는지 돌아봐야 합니다.

이 글은 이러한 목적으로, 프로젝트 진행 중 저희가 마주한 고민들과 그에 대해 시도한 해결법에 대해 작성해 보았습니다. 올바르지 않은 입력을 받는 것을 문제로 정의하고, 서버에서 모든 입력을 검증하도록 시도하였습니다. 프로젝트가 의도대로 동작한다는 확신을 얻기 위해 테스트 코드를 작성하였고, 이후 리팩토링 등에 크게 도움을 받았습니다. 이런 요소가 모두 시스템의 안정성 향상이라는 결실로 이어졌으며, 이를 위해 거친 과정은 이후 다른 프로젝트를 진행할 때에도 큰 도움이 되고 있습니다. 이러한 저희의 경험이 여러분이 겪게 되는 문제를 해결하는데 도움이 되었으면 좋겠습니다.

참고한 내용들

함께 읽기

🔗 여러 제휴사와 연동하는 신규 프로젝트 1편

katfun.joy
katfun.joy

카카오페이 서버 개발자 카펀입니다. 어려움과 불편함을 기술을 통해 해소하는 것을 매우 좋아합니다. 한 줄의 코드마다 근거를 가지고 작성하고자 노력합니다.