
Adapter 패턴은 Kotlin에서도 꽤 자주 만납니다. 특히 새 서비스 코드는 깔끔한 인터페이스를 기대하는데, 실제로 손에 쥔 것은 오래된 SDK나 외부 API 클라이언트뿐일 때 그렇습니다. 이럴 때 기존 코드를 전부 뜯어고치기보다 중간에 번역 계층 하나를 두는 편이 더 안전합니다.
이번 글은 정의만 반복하지 않겠습니다. 어떤 상황이 정말 Adapter 문제인지, extension function으로 끝낼 수 있는 경우는 언제인지, 외부 SDK를 감싸는 object adapter는 어떤 감각으로 이해하면 되는지 실무 기준으로 정리해보겠습니다.
Adapter 패턴이 필요한 순간
Adapter는 보통 기능이 없어서 쓰는 패턴이 아닙니다. 기능은 이미 있는데, 인터페이스가 안 맞아서 쓰는 패턴입니다.
예를 들어 우리 서비스는 아래 PaymentGateway 인터페이스를 기준으로 결제를 호출한다고 해보겠습니다.
interface PaymentGateway {
fun charge(request: PaymentRequest): PaymentResult
}
data class PaymentRequest(
val orderId: String,
val amount: Long,
val currency: String,
)
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failure(val reason: String) : PaymentResult
}그런데 실제로는 팀이 오래전부터 써 온 SDK가 이렇게 생겼을 수 있습니다.
class LegacyPaySdk {
fun pay(
userKey: String,
price: Int,
moneyCode: String,
): LegacyPayResponse {
TODO("외부 SDK 호출")
}
}
data class LegacyPayResponse(
val code: String,
val approvalNo: String?,
val message: String?,
)문제는 결제 기능이 없는 게 아닙니다. 이미 있습니다. 문제는 호출 방식도 다르고, 파라미터 의미도 조금 다르고, 성공과 실패 표현도 다르다는 점입니다. 이런 상황이 전형적인 Adapter 문제입니다.
Adapter의 핵심은 새 기능 추가가 아니라, 이미 있는 기능을 현재 코드가 이해할 수 있는 말로 번역하는 데 있습니다.
object adapter를 가장 먼저 떠올리면 된다
Kotlin 실무에서는 우선 object adapter 감각만 잡아도 충분합니다. 클라이언트가 기대하는 인터페이스를 하나 만들고, 어댑터가 그 인터페이스를 구현한 뒤 실제 SDK 객체를 감싸서 메서드 호출, 입력값, 결과값, 예외를 중간에서 번역하면 됩니다.
class LegacyPayAdapter(
private val sdk: LegacyPaySdk,
private val userKeyProvider: () -> String,
) : PaymentGateway {
override fun charge(request: PaymentRequest): PaymentResult {
return try {
val response = sdk.pay(
userKey = userKeyProvider(),
price = request.amount.toInt(),
moneyCode = request.currency,
)
if (response.code == "0000" && response.approvalNo != null) {
PaymentResult.Success(transactionId = response.approvalNo)
} else {
PaymentResult.Failure(reason = response.message ?: "결제 실패")
}
} catch (e: IllegalArgumentException) {
PaymentResult.Failure(reason = "요청값이 올바르지 않습니다: ${e.message}")
} catch (e: Exception) {
PaymentResult.Failure(reason = "외부 결제 SDK 호출 중 오류가 발생했습니다")
}
}
}이제 서비스 코드는 더 이상 LegacyPaySdk를 직접 알 필요가 없습니다. 오직 PaymentGateway만 보면 됩니다.
class CheckoutService(
private val paymentGateway: PaymentGateway,
) {
fun checkout(orderId: String, amount: Long): PaymentResult {
return paymentGateway.charge(
PaymentRequest(
orderId = orderId,
amount = amount,
currency = "KRW",
)
)
}
}이 구조의 장점은 꽤 현실적입니다. 외부 SDK가 바뀌어도 CheckoutService를 덜 건드리게 됩니다. 테스트에서는 PaymentGateway의 fake 구현을 바로 넣을 수도 있습니다.
Adapter가 실제로 하는 일
처음 보면 어댑터는 그냥 메서드 이름만 바꾸는 래퍼처럼 보일 수 있습니다. 그런데 실무에서는 보통 여기서 끝나지 않습니다.
입력값 변환
Long 금액을 Int로 바꾸거나, 분리된 필드를 SDK 형식으로 합치거나, 프로젝트 표준 통화 코드를 주입해야 할 수 있습니다.
결과값 변환
SDK는 code == "0000"로 성공을 표현할 수 있고, 우리 서비스는 sealed interface나 Result 타입을 원할 수 있습니다. 이 차이를 호출 지점마다 반복 처리하면 금방 지저분해집니다.
예외 번역
외부 SDK 예외를 서비스 전역에서 그대로 받기 시작하면 바깥 코드가 SDK 세부사항에 묶입니다. 어댑터에서 도메인 친화적인 실패 표현으로 바꿔주는 편이 안전합니다.
의존성 차단
어댑터가 없으면 프로젝트 여러 군데에서 LegacyPaySdk 타입을 직접 참조하게 됩니다. 그 순간부터 교체 비용이 급격히 올라갑니다. Adapter는 외부 타입의 침투를 막는 경계선이기도 합니다.
extension function으로 충분한 경우
굳이 클래스를 하나 더 만들지 말고 extension function으로 가볍게 끝내는 편이 더 좋은 경우도 많습니다. 오히려 그쪽이 자연스러울 때가 많습니다.
data class LegacyUserDto(
val id: Long,
val fullName: String,
)
data class UserSummary(
val id: Long,
val name: String,
)
fun LegacyUserDto.toUserSummary(): UserSummary {
return UserSummary(
id = id,
name = fullName,
)
}이 경우에는 새 인터페이스 계약이 필요한 게 아닙니다. 그냥 변환 함수를 읽기 좋게 붙인 정도입니다. 이런 장면까지 무조건 Adapter 클래스를 만들면 오히려 코드가 무거워집니다.
- 입력 타입 하나를 출력 타입 하나로 바꾸는 단순 변환
- 예외 번역이나 상태 관리가 거의 없는 경우
- 외부 의존성을 주입하거나 교체할 필요가 없는 경우
- 다형적 계약보다 헬퍼 성격이 강한 경우
즉 변환 함수가 필요할 뿐이라면 extension function이 더 간단합니다. Kotlin 공식 문서가 설명하듯 extension은 기존 타입에 실제 멤버를 추가하는 것이 아니라, 같은 문법으로 새 함수를 호출 가능하게 만드는 도구에 가깝습니다.
extension function만으로 부족한 경우
호출 계약 자체가 다를 때
단순 DTO 변환이 아니라 charge()처럼 서비스가 기대하는 행위 자체가 있을 때입니다. 이때는 함수 하나보다 인터페이스가 더 중요합니다.
예외와 실패 규칙을 숨겨야 할 때
외부 SDK의 예외 타입, 에러 코드, 재시도 규칙을 호출부마다 알게 두면 결합이 커집니다. 이건 변환 함수보다 경계 계층 문제에 가깝습니다.
테스트 대역이 필요할 때
서비스가 PaymentGateway 인터페이스에만 의존하면 테스트에서 fake 구현을 쉽게 넣을 수 있습니다. 반대로 코드가 LegacyPaySdk extension function에 직접 기대기 시작하면 테스트 설계가 덜 유연해질 수 있습니다.
외부 타입 노출을 막고 싶을 때
프로젝트 도메인에 LegacyPayResponse 같은 타입이 퍼지기 시작하면 나중에 SDK를 바꿀 때 손댈 곳이 많아집니다. Adapter는 이 확산을 막는 데 유용합니다.
extension function은 타입 위에 편의 기능을 얹는 도구에 가깝고, Adapter는 계약 자체를 바꾸는 경계 계층에 가깝습니다.
또 하나 중요한 점이 있습니다. Kotlin 공식 문서에 따르면 extension function은 정적으로 해석됩니다. 그래서 런타임 다형성으로 계약을 갈아끼우는 문제를 extension만으로 다 해결하려고 하면 한계가 분명합니다.
Kotlin의 delegation은 어떻게 연결될까
Kotlin의 by 위임 문법은 object adapter와 감각이 잘 맞습니다. 이미 맞는 기능은 그대로 넘기고, 몇 개만 선택적으로 바꾸고 싶을 때 보일러플레이트를 줄이는 데 도움이 됩니다.
interface NotificationSender {
fun send(message: String)
fun healthCheck(): Boolean
}
class SlackSdkClient {
fun post(text: String) {
println("send to slack: $text")
}
fun ping(): Boolean = true
}
class SlackAdapter(
private val client: SlackSdkClient,
) : NotificationSender {
override fun send(message: String) {
client.post(message)
}
override fun healthCheck(): Boolean {
return client.ping()
}
}다만 변환 규칙이 많은 어댑터는 결국 명시적으로 구현하는 편이 읽기 좋을 때가 많습니다. by는 설계 판단을 대신해주지 않고, 단순 전달 코드를 줄여주는 도구에 가깝습니다.
Adapter를 쓰면 좋은 기준
- 이미 쓸 만한 기존 코드가 있는가
- 문제의 본질이 기능 부족이 아니라 인터페이스 불일치인가
- 외부 타입이나 레거시 규칙을 프로젝트 안쪽으로 퍼뜨리고 싶지 않은가
- 변환 규칙이 여러 호출부에 반복될 조짐이 있는가
여기에 예가 많으면 Adapter 쪽으로 기울 가능성이 큽니다. 반대로 단순 DTO 변환 한두 개면 끝나고, 전역 계약을 세울 정도가 아니라면 extension function이나 작은 mapper 함수가 더 좋은 선택일 수 있습니다.
남용하면 생기는 문제
아무 래퍼나 다 Adapter라고 부르는 문제
메서드 하나 감싼 클래스라고 다 Adapter는 아닙니다. 핵심은 인터페이스 불일치를 해결하느냐입니다. 그냥 로깅만 붙였다면 Decorator에 더 가깝습니다.
어댑터가 도메인 로직까지 먹어버리는 문제
어댑터 안에서 환불 정책, 할인 계산, 권한 검증까지 다 해버리면 경계 계층이 비대해집니다. Adapter는 번역 계층이지 도메인 서비스 대체재가 아닙니다.
너무 이른 추상화
외부 SDK를 딱 한 군데에서만 쓰고 앞으로도 교체 가능성이 거의 없고 변환 규칙도 단순하다면 인터페이스와 어댑터를 먼저 만드는 게 과할 수 있습니다. 이때는 얇은 함수 하나로 시작해도 됩니다.
Decorator, Facade와 헷갈리지 않기
비슷해 보여도 질문이 다릅니다. Adapter는 기존 객체를 다른 인터페이스로 보이게 만들고, Decorator는 같은 인터페이스를 유지하면서 기능을 덧붙이고, Facade는 복잡한 서브시스템 앞에 단순한 진입점을 둡니다.
결제 SDK 하나를 PaymentGateway에 맞게 바꾸는 건 Adapter에 가깝습니다. 결제 전후 로깅이나 메트릭을 붙이는 건 Decorator에 더 가깝고, 여러 결제·정산·영수증 API를 한 번에 묶어 단순 진입점을 주는 건 Facade 쪽에 가깝습니다.
코틀린에서 Adapter 패턴을 볼 때 기억할 점
코틀린은 extension function, data class, delegation 덕분에 자바보다 어댑터 주변 코드가 더 짧아질 수 있습니다. 그래서 Adapter 자체가 예전보다 덜 거창하게 느껴집니다.
하지만 패턴의 본질은 그대로입니다. 호출부가 기대하는 계약을 분명히 세우고, 기존 코드와의 차이를 중간에서 번역하고, 외부 세부사항이 안쪽 설계를 오염시키지 않게 막는 것. 이 세 가지가 필요하면 Kotlin에서도 Adapter는 여전히 실전적인 패턴입니다.
마무리
Adapter 패턴을 너무 무겁게 생각할 필요는 없습니다. 기존 코드를 버리지 않고 새 인터페이스에 맞춰 쓰기 위한 번역 계층이라고 보면 됩니다.
중요한 건 패턴 이름보다 판단 기준입니다. 단순한 변환이면 extension function으로 끝내고, 계약·예외·상태·테스트 경계까지 다뤄야 하면 Adapter를 두는 쪽이 더 낫습니다.
이전 글인 코틀린 디자인 패턴(5) – Prototype 패턴과 data class copy 정리를 읽었다면 이번 글은 구조 패턴으로 넘어가는 좋은 연결점이 됩니다. Prototype이 이미 있는 상태를 어떻게 복제할지의 문제였다면, Adapter는 이미 있는 코드를 어떻게 현재 설계에 맞게 연결할지의 문제이기 때문입니다.
공식 참고 자료로는 Kotlin Extensions 문서, Kotlin Delegation 문서, Kotlin Interfaces 문서를 함께 보면 좋습니다. Adapter 일반 개념은 Refactoring.Guru의 Adapter 설명도 빠르게 감을 잡는 데 도움이 됩니다.