이전 글에서 다룬 PaymentGateway 인터페이스 설계에서 한 걸음 더 들어가, Gradle 모듈 분리, 테스트 전략, 반환 타입 설계 원칙을 기록한다.
들어가며
이전 글(커머스 서비스 결제 구현 시 알아야 할 5가지 핵심 개념)에서 멱등성, 상태 머신, IDOR 등과 함께 PaymentGateway 인터페이스를 이용한 PG 추상화 아키텍처를 소개했다. 이번 글에서는 그 아키텍처를 실제 프로젝트에 적용하면서 마주한 세부 설계 결정들을 다룬다.
구체적으로 세 가지를 이야기한다.
- 인터페이스만으로는 왜 부족한가 — Gradle 모듈로 물리적 경계를 만든 이유
- PG 응답을 그대로 노출하면 안 되는 이유 — 반환 타입과 에러 설계 원칙
- 서비스 테스트와 구현체 테스트를 왜 나눠야 하는가 — 테스트 분리 전략
1. 서비스에 PG 코드가 직접 들어가면 벌어지는 일
추상화 없이 서비스 레이어에 PG API 호출 코드를 넣으면 어떤 일이 벌어지는지 먼저 보자.
// ❌ 서비스에 PG 로직이 직접 들어간 경우
@Service
class OrderConfirmService(
private val restClient: RestClient,
) {
fun confirmPayment(orderId: UUID, paymentKey: String, amount: Long) {
val secretKey = Base64.getEncoder().encodeToString("sk_test_xxx:".toByteArray())
val response = restClient.post()
.uri("https://api.tosspayments.com/v1/payments/confirm")
.header("Authorization", "Basic $secretKey")
.body(mapOf("paymentKey" to paymentKey, "orderId" to orderId, "amount" to amount))
.retrieve()
.body(String::class.java)
// 주문 상태 변경 로직...
}
}
이 코드의 문제는 세 가지다.
- PG 교체 = 서비스 코드 수정 — 비즈니스 로직과 인프라 코드가 같은 파일에 있다
- 테스트 비용 폭발 — 결제 승인을 단위 테스트하려면
RestClient전체의 메서드 체인을 모킹해야 한다 - 컴파일 의존성 전파 — PG 라이브러리를 변경하면 서비스 모듈 전체가 재컴파일된다
이전 글에서 소개한 PaymentGateway 인터페이스는 첫 번째 문제를 해결한다. 하지만 인터페이스만으로는 두 번째, 세 번째 문제를 완전히 막을 수 없다. 개발자가 인터페이스를 무시하고 구현체를 직접 import하는 실수를 컴파일러가 잡아주지 못하기 때문이다.
2. Gradle 모듈로 물리적 경계 강제하기
논리적 분리 vs 물리적 분리
인터페이스에 의존하는 것은 논리적 분리다. 코드 리뷰에서 "구현체를 직접 쓰지 마세요"라고 말할 수 있지만, 컴파일러가 강제하지는 않는다.
// 같은 모듈 안이라면 이런 코드가 컴파일된다
import kr.co.lawfit.lawget.support.payment.tosspayments.TossPaymentsGateway
@Service
class OrderConfirmService(
// PaymentGateway 대신 구현체를 직접 주입 — 컴파일 에러 없음!
private val tossPaymentsGateway: TossPaymentsGateway,
)
Gradle 모듈로 분리하면 이야기가 달라진다. core-api는 support:payment 모듈의 공개된 API만 접근할 수 있고, 모듈 내부의 구현 상세는 감춰진다.
모듈 의존 관계
// settings.gradle.kts
include(
"core:core-api", // 비즈니스 로직 — PaymentGateway 인터페이스 사용
"core:core-admin", // 관리자 서버 — PaymentGateway 인터페이스 사용
"support:payment", // 결제 모듈 — Port 정의 + Adapter 구현
"storage:db-core", // DB 레이어
)
// core/core-api/build.gradle.kts
dependencies {
implementation(project(":support:payment"))
implementation(project(":storage:db-core"))
}
core-api는 support:payment가 공개한 PaymentGateway 인터페이스와 PaymentConfirmResult 등의 타입만 사용한다. TossPaymentsGateway라는 구현체의 존재를 알 필요가 없다.
support:payment 모듈의 내부 구조
support/payment/
├── src/main/kotlin/
│ └── kr/co/lawfit/lawget/support/payment/
│ ├── PaymentGateway.kt ← Port (인터페이스)
│ ├── PaymentConfirmResult.kt ← 비즈니스 반환 타입
│ ├── PaymentCancelResult.kt
│ ├── PaymentException.kt ← 추상화된 예외
│ └── tosspayments/ ← Adapter (구현체)
│ ├── TossPaymentsGateway.kt
│ ├── TossPaymentsProperties.kt
│ └── TossPaymentsErrorResponse.kt ← PG 전용 DTO
핵심은 tosspayments/ 패키지의 내용이 모듈 밖으로 노출되지 않는다는 점이다. Spring Boot의 자동 설정(@Component)이 런타임에 TossPaymentsGateway를 PaymentGateway 빈으로 등록해줄 뿐, 비즈니스 코드에서는 인터페이스만 보인다.
물리적 분리가 주는 부가 이점
| 이점 | 설명 |
|---|---|
| 증분 빌드 | PG 구현체 수정 시 support:payment만 재컴파일. core-api는 빌드 스킵 |
| 독립 테스트 | ./gradlew :support:payment:test로 PG 연동 테스트만 실행 가능 |
| 의존성 격리 | 토스 전용 HTTP 설정(RestClient 빈, ObjectMapper 커스텀)이 다른 모듈에 영향 없음 |
| 교체 안전성 | 새 PG 어댑터를 추가할 때 기존 모듈의 코드를 건드리지 않음 (OCP) |
"모듈을 나누는 비용이 크지 않나?"라고 생각할 수 있다. 실제로
build.gradle.kts파일 하나와settings.gradle.kts에 한 줄 추가하는 것이 전부다. 반면, 모듈을 나누지 않고 "규칙으로만" 관리하면, 팀원이 늘어날수록 규칙 위반을 잡아내는 비용이 기하급수적으로 증가한다.
3. 반환 타입과 에러 설계 — PG 중립적인 계약
PaymentGateway 인터페이스를 설계할 때 가장 많이 고민한 부분은 반환 타입이었다. 토스페이먼츠의 결제 승인 응답에는 40개 이상의 필드가 있다. 이 중 우리 서비스가 실제로 사용하는 필드는 6개에 불과하다.
안티패턴: PG 응답을 그대로 노출
// ❌ PG 응답 DTO를 그대로 반환하면?
interface PaymentGateway {
fun confirm(...): TossPaymentResponse // 40+ 필드, 토스 전용 구조
}
이렇게 하면 인터페이스를 만든 의미가 없어진다. 서비스 레이어가 TossPaymentResponse의 필드 이름과 구조에 의존하게 되고, PG를 교체하면 서비스 코드도 전부 수정해야 한다.
설계 원칙: 비즈니스가 필요한 것만 노출
// ✅ 비즈니스 관점의 반환 타입
data class PaymentConfirmResult(
val paymentKey: String,
val orderId: String,
val totalAmount: Long,
val status: String,
val method: String?, // 카드, 가상계좌, 간편결제
val approvedAt: String?,
)
6개 필드만 담았다. 토스의 card.issuerCode, receipt.url, checkout.url 같은 PG 전용 필드는 구현체 내부에서만 사용하고 외부로 노출하지 않는다. 나중에 나이스페이먼츠로 교체해도 이 6개 필드는 동일하게 채울 수 있다.
에러도 같은 원칙
토스페이먼츠는 REJECT_CARD_PAYMENT, INVALID_CARD_EXPIRATION 같은 에러 코드를 반환한다. 나이스페이먼츠는 3001, 3002 같은 숫자 코드를 사용한다. 서비스 레이어가 이런 PG별 에러 코드를 알아야 할까?
// PG 중립적인 예외
class PaymentException(
val errorCode: String, // PG의 원본 에러 코드 (디버깅용)
override val message: String,
) : RuntimeException(message)
// 구현체에서 PG 에러를 변환
private fun executePost(url: String, body: Any): String {
try {
return restClient.post()
.uri(url)
.header("Authorization", "Basic ${encodeSecretKey()}")
.body(body)
.retrieve()
.body(String::class.java)
?: throw PaymentException("EMPTY_RESPONSE", "결제 응답이 비어있습니다.")
} catch (e: RestClientResponseException) {
// 토스 에러 → PaymentException으로 변환
val error = objectMapper.readValue<TossPaymentsErrorResponse>(e.responseBodyAsString)
throw PaymentException(error.code, error.message)
}
}
서비스 레이어에서는 PaymentException만 처리하면 된다. PG사별 에러 코드는 errorCode 필드에 보존되어 로깅과 디버깅에 사용되지만, 비즈니스 로직에서는 참조하지 않는다.
// 서비스 레이어 — PG 에러 코드를 모름
try {
paymentGateway.confirm(paymentKey, order.orderNo, amount)
} catch (e: PaymentException) {
// "어떤 PG의 에러인지"가 아니라 "결제 실패"라는 사실만 알면 된다
throw CoreException(ErrorType.PAYMENT_FAILED, e.message)
}
설계 기준: "이 반환 타입/에러 타입이 PG를 교체해도 변하지 않는가?"를 자문한다. 답이 No라면 추상화 수준이 낮은 것이다. 인터페이스의 계약(contract)은 비즈니스 도메인의 언어로 정의해야 한다.
4. 테스트 분리 전략 — 관심사별 테스트 경계
헥사고날 아키텍처의 가장 실용적인 이점은 테스트 용이성이다. 인터페이스를 경계로 테스트를 둘로 나눌 수 있다.
| 테스트 대상 | 모킹 대상 | 검증 관심사 |
|---|---|---|
| 서비스 (OrderConfirmService) | PaymentGateway 인터페이스 | 비즈니스 로직 — 소유권, 만료, 금액 검증, 상태 전이 |
| 구현체 (TossPaymentsGateway) | RestClient (HTTP 클라이언트) | PG 연동 — JSON 파싱, 인증 헤더, 에러 변환 |
서비스 테스트: "결제가 성공했을 때 비즈니스 로직이 올바른가?"
class OrderConfirmServiceTest {
private val paymentGateway: PaymentGateway = mockk() // 인터페이스 모킹
private val orderRepository: OrderRepository = mockk()
private val deliveryRequestRepository: DeliveryRequestRepository = mockk()
private val sut = OrderConfirmService(
orderRepository, deliveryRequestRepository, paymentGateway
)
@Test
fun `결제 승인 성공 시 주문 상태가 PAID로 변경된다`() {
// PG의 구체적 동작은 관심 밖 — "성공 응답"만 설정
every { paymentGateway.confirm(any(), any(), any()) } returns PaymentConfirmResult(
paymentKey = "pk_test", orderId = "ORD-001",
totalAmount = 30000, status = "DONE",
method = "카드", approvedAt = "2026-03-30T10:00:00+09:00",
)
// ...
val result = sut.confirmPayment(orderId, "pk_test", 30000, userId)
assertThat(order.orderStatus).isEqualTo(OrderStatus.PAID)
}
@Test
fun `PG 에러 시 CoreException으로 변환된다`() {
every { paymentGateway.confirm(any(), any(), any()) } throws
PaymentException("REJECT_CARD_PAYMENT", "잔액부족")
assertThatThrownBy { sut.confirmPayment(orderId, "pk_test", 30000, userId) }
.isInstanceOf(CoreException::class.java)
.hasMessageContaining("잔액부족")
}
@Test
fun `다른 사용자의 주문에 결제하면 ACCESS_DENIED`() {
val otherUserId = UUID.randomUUID()
// paymentGateway.confirm()은 호출되지 않아야 한다
assertThatThrownBy { sut.confirmPayment(orderId, "pk_test", 30000, otherUserId) }
.isInstanceOf(CoreException::class.java)
verify(exactly = 0) { paymentGateway.confirm(any(), any(), any()) }
}
}
서비스 테스트에서는 PG API를 전혀 호출하지 않는다. PaymentGateway가 인터페이스이기 때문에 MockK 한 줄로 모킹이 끝난다. RestClient의 빌더 체인(.post().uri().header().body().retrieve().body())을 일일이 모킹할 필요가 없다.
구현체 테스트: "토스 API 응답을 제대로 파싱하는가?"
class TossPaymentsGatewayTest {
private val restClient: RestClient = mockk()
private val properties = TossPaymentsProperties(
secretKey = "test_sk_xxx",
baseUrl = "https://api.tosspayments.com",
)
private val gateway = TossPaymentsGateway(restClient, properties)
@Test
fun `결제 승인 성공 — JSON 응답이 PaymentConfirmResult로 변환된다`() {
// RestClient가 반환할 JSON 설정
every { responseSpec.body(String::class.java) } returns """
{"paymentKey":"pk_test","orderId":"ORD-001","totalAmount":30000,
"status":"DONE","method":"카드","approvedAt":"2026-03-30T10:00:00+09:00"}
""".trimIndent()
val result = gateway.confirm("pk_test", "ORD-001", 30000)
assertThat(result.paymentKey).isEqualTo("pk_test")
assertThat(result.status).isEqualTo("DONE")
assertThat(result.totalAmount).isEqualTo(30000)
}
@Test
fun `토스 에러 응답 시 PaymentException으로 변환된다`() {
every { responseSpec.body(String::class.java) } throws
RestClientResponseException("", 400, "", null, """
{"code":"REJECT_CARD_PAYMENT","message":"잔액이 부족합니다"}
""".trimIndent().toByteArray(), null)
assertThatThrownBy { gateway.confirm("pk_test", "ORD-001", 30000) }
.isInstanceOf(PaymentException::class.java)
.hasMessageContaining("잔액이 부족합니다")
}
}
구현체 테스트의 관심사는 오직 PG 연동이다. "JSON 응답을 PaymentConfirmResult로 올바르게 변환하는가?", "토스의 에러 응답을 PaymentException으로 변환하는가?" — 비즈니스 로직(소유권 검증, 상태 전이)은 테스트하지 않는다.
이 분리가 PG 교체 시 빛을 발한다
나이스페이먼츠로 교체한다고 가정하자.
| 테스트 | 수정 필요? | 이유 |
|---|---|---|
OrderConfirmServiceTest |
X | 인터페이스를 모킹하므로 구현체와 무관 |
TossPaymentsGatewayTest |
삭제 | 더 이상 사용하지 않는 구현체 |
NicePaymentsGatewayTest (신규) |
O — 새로 작성 | 나이스 API 응답 파싱 검증 |
비즈니스 로직 테스트는 한 줄도 수정하지 않는다. PG 교체의 영향이 구현체 테스트에만 격리된다. 이것이 "테스트 가능한 아키텍처"가 실제로 의미하는 바다.
만약 서비스 테스트에서
RestClient를 직접 모킹했다면? PG를 교체할 때 서비스 테스트의 모킹 설정도 전부 바꿔야 한다. "무엇을 모킹하느냐"가 테스트의 유지보수 비용을 결정한다.
5. ConfigurationProperties로 환경별 설정 분리
PG 구현체에 필요한 설정(Secret Key, Base URL)은 @ConfigurationProperties로 외부화했다. 코드에 하드코딩하지 않으면 테스트/스테이징/운영 환경별로 다른 키를 주입할 수 있다.
@ConfigurationProperties(prefix = "payment.tosspayments")
data class TossPaymentsProperties(
val secretKey: String,
val baseUrl: String = "https://api.tosspayments.com",
)
# payment.yml (core-api, core-admin에서 import)
payment:
tosspayments:
secret-key: ${TOSSPAYMENTS_SECRET_KEY:test_sk_xxx}
base-url: https://api.tosspayments.com
이 설정 파일은 core-api와 core-admin 양쪽 모듈에서 import한다. 사용자 결제 승인(core-api)과 관리자 결제 취소(core-admin) 모두 같은 PG 설정을 사용하기 때문이다.
PG를 교체하면 이 YAML 파일의 prefix와 값만 바꾸면 된다. 비즈니스 코드는 PaymentGateway 인터페이스를 통해 주입되므로 설정 변경의 영향을 받지 않는다.
마치며
헥사고날 아키텍처의 "Port와 Adapter" 개념 자체는 간단하다. 하지만 실제 프로젝트에 적용할 때는 세부적인 설계 결정이 품질을 좌우한다. 이번 글에서 다룬 핵심을 정리하면:
- 인터페이스(논리적 분리) + Gradle 모듈(물리적 분리) — 규칙은 어기지만 컴파일 에러는 어길 수 없다. 물리적 경계는 실수를 구조적으로 방지한다
- 반환 타입은 비즈니스 언어로 — PG 응답을 그대로 노출하면 인터페이스가 PG에 종속된다. "서비스가 실제로 쓰는 필드"만 담아야 교체 가능한 계약이 된다
- 에러도 추상화의 대상 — PG별 에러 코드를 구현체 안에서 변환하면, 서비스 레이어는 "결제 실패"라는 사실만 알면 된다
- 테스트 경계 = 아키텍처 경계 — 서비스 테스트는 인터페이스를, 구현체 테스트는 HTTP 클라이언트를 모킹한다. PG 교체 시 서비스 테스트는 수정 없이 통과한다
"인터페이스 하나 만드는 건 쉬운데, 모듈까지 나눌 필요가 있나?" — 혼자 개발할 때는 과하게 느껴질 수 있다. 하지만 팀이 커지거나 PG 교체가 실제로 발생했을 때, 컴파일러가 지켜주는 경계의 가치는 코드 리뷰 100번보다 크다.
참고
- Alistair Cockburn — Hexagonal Architecture
- Get Your Hands Dirty on Clean Architecture (Tom Hombergs) — 헥사고날 아키텍처와 Gradle 모듈 분리 실전 가이드
- Gradle Multi-Project Builds — 멀티모듈 프로젝트 공식 문서