안녕하세요, 카카오뱅크 홈서비스 개발팀에서 홈과 이체 서비스를 개발하고 있는 Tigger입니다.
저는 지난 5월, 한국 스프링 사용자 모임(KSUG)에서 주관한 Spring Camp 2024 컨퍼런스에서 ‘카카오뱅크의 홈 서비스를 안정적으로 분리하고 이관한 경험’을 주제로 발표했습니다. 해당 발표는 유튜브 영상으로도 공개되어 있으나, 발표를 더욱 자세하게 정리하여 기술블로그를 통해 공유드립니다.
저희 홈서비스개발팀은 카카오뱅크 앱의 첫 화면인 ‘홈 서비스’를 기존의 레거시 모노리식 서버에서 마이크로 서비스로 분리했습니다. 좀 더 자세히 설명드리자면, 이 프로젝트는 기술 부채 해결을 위해 ‘헥사고날 아키텍처(Hexagonal Architecture)‘와 ‘코루틴(Kotlin Coroutine)‘을 사용했습니다. 그리고 운영 서비스 이관 전략으로는 응답 비교, 표본 검사, A/B 비율 조정, Fallback 전략을 사용했습니다. 사실 현업에 계신 분들에게는 모두 익숙한 개념일 수 있지만, 이 글에서는 실제 운영 서비스를 다루는 과정에서의 고민과 결정, 발생한 이슈, 느낀 점을 더 중점적으로 담았습니다. 특히 카카오뱅크 서버 개발자들이 겪은 실제 경험을 녹여 생동감을 더했습니다.
이 글은 프로그래밍을 경험해 본 분들을 대상으로 작성되어, 기술 용어나 배경 설명을 최소화했습니다. 따라서 실제 사례 부분은 헥사고날 아키텍처나 코루틴을 적용해 본 경험이 없다면 이해하기 어려울 수 있습니다. 그럼에도 불구하고 전체적인 흐름을 이해하는 데에는 큰 어려움이 없도록 구성했습니다. 그럼 이어서 왜 잘 운영 중이었던 서비스를 분리하게 되었는지 설명드리겠습니다.
‘때’를 맞이한 홈 서비스 😇
오래된 서비스가 갖는 숙명처럼 카카오뱅크도 거대한 레거시 서비스를 가지고 있습니다. 빠른 성장과 서비스 딜리버리에 집중하다보니 시스템이 커지고 코드상의 부채가 많이 쌓였습니다. 시스템의 성장에 따라 빌드 시간도 크게 증가했습니다. 심지어 많은 사람들이 하나의 레포지토리(Repository)에서 작업하다보니, 깃 충돌이 크게 나면 해결하기 어려워졌습니다. 때마침 ‘캠프(Camp)‘라는 이름의 목적 조직으로 조직개편도 일어났습니다. 변경된 조직 구조에 적합한 협업 방법을 고민한 끝에, 기존의 커다란 한 덩어리였던 레거시 서비스로부터 홈 서비스를 별도의 조직 단위로 분리하기로 결정했습니다.
서비스를 분리하면 조직 간의 업무 결합도는 낮아지고 대규모 깃 충돌 문제도 해결되겠지만, 여전히 기술 부채는 남습니다. 그래서 아래와 같이 주요 기술부채 2가지를 선정하고, 서비스 분리와 동시에 해결할 방안을 고민했습니다.
- 코드 노후화에 의한 구조적 문제
- 환경 변화에 의한 성능적 문제
저희는 구조적 문제와 성능적 문제를 해결하면서도 안정성을 확보하려 했습니다. 은행 서비스에 문제가 생기면 고객들이 받는 영향이 매우 크고 심각합니다. 특히 카카오뱅크는 휴대폰 앱이 고객들과의 주요 접점인 ‘인터넷 은행’이어서 더욱 신중할 수밖에 없었습니다. 생산성을 위해 서비스를 분리하고 기존의 기술 부채를 해결하기 위해 시작한 서비스 이관 작업이지만 이 모든 과정과 결과는 안정적이어야 합니다. 혁신과 안정을 동시에 추구하는 것은 어쩌면 다소 상충된 목표처럼 보일 수도 있겠네요.
자, 이 문제를 해결하기 위해 저희가 어떻게 했는지 하나씩 이야기해보겠습니다. 먼저 기술 부채 이야기부터 시작하겠습니다.
기술 부채 1. 구조적 문제 해결하기 (feat. 헥사고날 아키텍처)
저희가 가지고 있던 첫 번째 문제는 코드의 구조적 문제였습니다. 코드가 노후화되면서 계층 간 의존성이 꼬이고, 외부 의존성과 도메인 정책이 섞여 있는 상태였는데요. 이 문제의 배경을 더 자세히 설명드리기 위해, 우선 은행의 개발 환경을 간략히 설명하겠습니다.
대부분 은행의 백엔드 개발 환경은 크게 ‘계정계’와 ‘채널계’로 나뉩니다. 먼저 계정계는 은행의 핵심 데이터를 관리하며, 은행의 본연의 역할을 수행하는 중요한 시스템입니다. 대표적으로, 계정계는 고객의 계좌, 거래 내역, 입출금, 대출, 이자 등 각종 금융 데이터를 처리하고 관리합니다.
그리고, 채널계는 서비스 데이터를 관리하고 계정계 시스템을 활용해 고객에게 필요한 금융 서비스를 제공합니다. 예를 들어, 카카오뱅크 한달적금 상품은 계정계가 제공하는 ‘일일적금’이라는 기능을 기반으로, 채널계에서 ‘매일 춘식이가 혜택을 배달한다’는 서비스적 개념을 추가한 것입니다. 즉, 채널계에서는 은행의 핵심 기능을 제공하는 계정계를 기반으로, 고객이 사용하는 실제 앱 서비스를 만든다고 할 수 있겠습니다.
여기서 중요한 것은 채널계와 계정계라는 시스템 계층이 존재하며, 채널계가 이 계정계에 상당 부분 의존하고 있다는 점입니다. 저희는 원래 레이어 아키텍처(Layer Architecture)를 적용해 개발하고 있었는데요. 앞서 말씀드렸듯이 계정계에 의존한 개발이 이루어지다 보니, 계정계를 중심으로 서비스가 개발되는 경우가 많았습니다. 여기까지는 어쩌면 은행으로서 당연한 상황이니 문제가 없어 보입니다.
하지만 이 상황이 지속되면서 계정계의 의존성이 지나치게 커지기 시작했습니다. 우선 서비스 도메인 계층에서 계정계의 모델을 그대로 사용하게 되었습니다. 이렇게 되면 계정계라는 외부 계층과 도메인 계층 사이에 강한 결합이 생깁니다. 계정계 모델을 비즈니스 모델처럼 사용함으로써, 계정계의 제약에 따른 필드 제약을 함께 관리하게 될 뿐만 아니라, 외부 변경에 의해 도메인 계층이 영향을 받게 됩니다. 그리고 때로는 컨트롤러(Controller)에서 바로 계정계에 접근하는 경우도 있었습니다. 이렇게 되면 유즈케이스(Usecase)가 확장될수록 도메인 로직이 웹 계층에 추가되면서 책임이 혼합되고 핵심 로직이 퍼져서 응집도가 떨어지게 됩니다.
심지어 서비스가 확장됨에 따라 은행 본연의 기능 외 다른 기능을 제공하는 경우들이 점점 많아지고 있었습니다. 즉, 계정계뿐만 아니라 다른 시스템들도 의존해야 하는 경우들이 더욱 많이 생기고 있었는데요. 새로운 시스템을 추가할 때마다 도메인 여기저기에 녹아 있는 과도한 계정계 의존성이 변경을 어렵게 만들었습니다.
이런 상황 속에서 저희의 선택지는 헥사고날 아키텍처(Hexagonal Architecture)였습니다. 헥사고날 아키텍처는 도메인을 보호하기 위한 클린 아키텍처(Clean Architecture) 중 하나로, 계층형 아키텍처와 달리 의존성의 방향이 고수준에서 저수준으로 향합니다. 따라서 도메인 계층이 영속성이나 외부 서비스에 의존하지 않고, 반대로 외부 서비스와 영속성 계층이 도메인 계층을 의존합니다.
모듈 구조와 Gradle 의존성을 통해 이러한 의존성의 역전을 강제할 수 있기 때문에 헥사고날 아키텍처는 도메인의 보호를 어느 정도 강제해주는 특징이 있습니다. 또한, Port라는 인터페이스를 먼저 정의하고 시작하기 때문에 서로 다른 계층을 개발할 때 동시 작업이 쉬워지는 등의 부수적인 효과도 있습니다. 반면 익숙한 레이어 아키텍처의 구조에 비해 러닝 커브가 있고, 보일러플레이트(Boilerplate) 코드가 많이 생겨 개발 속도를 늦추는 단점도 있을 수 있습니다.
저희는 조직과 서비스의 규모가 상당히 커지고 성숙해진 단계이기 때문에 유지보수 측면의 장점과 강제되는 구조가 주는 안정감이 더 크게 다가왔습니다. 또한, 코드를 새로 이관하는 프로젝트이기 때문에 중간에 잘못된 선택이라고 느껴지더라도 빠르게 되돌아오는 것이 가능하다고 생각했습니다. 그래서 저희는 헥사고날 아키텍처를 도입해보기로 결정했습니다.
먼저, 의존성을 역전시켜 의존성의 방향을 고수준에서 저수준으로 향하게 했습니다. 이를 강제하기 위해 3개의 모듈로 분리하여 저수준에서 고수준을 의존할 수 없도록 했습니다. 이제는 도메인에서 계정계 모델을 직접 참조할 수 없습니다. 따라서 도메인과 외부 의존성은 느슨하게 결합됩니다.
이처럼 더 좋은 아키텍처는 우리가 더 쉽게 질 좋고 지속 가능한 코드를 만들 수 있도록 도와줍니다. 그러나 아키텍처를 바꾼 것만으로 구조적인 문제가 자동으로 해결되지는 않습니다. 결국 코드를 작성하는 것은 개발자이기 때문입니다.
저는 처음 헥사고날 아키텍처를 적용 할 때 많은 고민을 했습니다. ‘이건 도메인일까, 아닐까?’ 하는 도메인에 대한 근본적인 고민부터 ‘이 코드는 어떤 모듈의 어떤 계층에 있어야 할까?’와 같은 고민까지 다양했습니다. 스스로 그동안 얼마나 도메인에 대해 얕게 고민하고 개발했는지 돌아보는 계기가 되기도 했습니다. 그 중에서 제가 가장 많이 했던 고민 중 하나는 ‘특정 로직이 애플리케이션 레이어(Application)에 있어야 하는지’, 아니면 ‘어댑터 레이어(Out-Adapter)에 있어야 하는지’ 였는데요. 이를 몇 가지 사례와 함께 설명드리겠습니다.
🤔 고민 Point 1. 캐시(Cache)
첫 번째 사례는 응답 캐시(Response Cache)입니다. 응답을 캐싱해야 할 때, 캐시 로직은 어디에 있어야 할까요? 캐시 조회와 캐시 갱신 등의 로직이 애플리케이션 서비스에 있을 수도 있습니다. 아니면 어댑터가 캐시 조회와 갱신을 모두 책임지고, 애플리케이션 서비스는 단순히 조회 기능만을 수행할 수도 있죠. 아래 예시는 캐시 로직이 애플리케이션에 있는 경우를 나타낸 코틀린 코드 입니다.
@Service
class GetAccountBalanceService(
private val loadAccountPort: LoadAccountPort,
private val loadAccountCachePort: LoadAccountCachePort,
private val updateAccountCachePort: UpdateAccountCachePort,
) : GetAccountBalanceUseCase {
@Override
fun getAccountBalance(query: GetAccountBalanceQuery): Money {
val cachedAccount = loadAccountCachePort.loadAccount(query.accountId) // 캐시 조회
val account = cachedAccount
?: loadAccountPort.loadAccount(query.accountId) // 캐시가 없으면 직접 조회
.also { updateAccountCachePort.updateCache(query.accountId, it) } // 캐시 갱신
return account.calculateBalance()
}
}
캐시 로직이 어댑터에 있는 경우, 아래와 같이 구현되겠죠.
@Service
class GetAccountBalanceService(
private val loadAccountPort: LoadAccountPort,
) : GetAccountBalanceUseCase {
@Override
fun getAccountBalance(query: GetAccountBalanceQuery): Money {
val account = loadAccountPort.loadAccount(query.accountId) // 캐시는 out adapter 에서 알아서 처리
return account.calculateBalance()
}
}
이 경우 중요한 포인트는 크게 두 가지로 나눌 수 있습니다.
우선 캐시가 중요한 비즈니스 정책인지 생각해봐야 합니다. 대부분의 경우, 애플리케이션은 단순히 데이터를 조회하고 싶을 뿐이고, 그 조회 요청에 대해 어떻게 응답하는지는 어댑터의 책임입니다. 어댑터는 캐시를 조회할 수도 있고, 영속성 데이터를 조회할 수도 있으며, 외부 호출을 할 수도 있습니다. 그리고 항상 캐시가 필요한지 여부도 하나의 기준이 됩니다. 캐시가 항상 필요한 경우, 조회 서비스와 캐시 서비스가 긴밀하게 결합됩니다. 캐시를 통한 조회가 매번 필요한 경우, 만약 이를 분리하면 다른 서비스를 새로 개발할 때 캐시 레이어의 존재를 모르게 되어 캐시를 사용하지 않을 수 있습니다.
일반적으로 캐시는 비즈니스보다는 기술적인 관심사에 가깝지만, 반대로 캐시가 중요한 비즈니스 정책일 수도 있습니다. 이를 잘 보여주는 예로, 오픈뱅킹 잔액 조회 사례를 살펴보겠습니다.
오픈뱅킹은 고객이 다른 금융기관에 등록한 데이터를 공유하는 서비스로, 고객이 보유한 다른 은행 계좌의 잔액을 보여줍니다. 특정 화면에서는 오픈뱅킹 잔액을 짧게 캐시하여 응답합니다. 이는 오픈뱅킹에서의 데이터 조회에 비용이 발생하기 때문입니다. 비용을 감수하더라도 정확성이 중요한 화면에서는 캐시 없이 응답하고, 일부 정확성을 포기하더라도 빠른 응답이 필요한 화면에서는 캐시를 사용하여 응답합니다. 이처럼 캐시 사용이 중요한 비즈니스 정책에 해당할 경우, 애플리케이션 레이어에서 관리하는 것이 좋습니다.
🤔 고민 Point 2. 애그리게이션(Aggregation)
두 번째 사례는 외부 서비스 응답 애그리게이션(Response Aggregation) 사례입니다.
계좌와 관련된 정보를 얻기 위해서 우리는 계좌 도메인을 조회해야 하는데, 계좌는 ¹잔액, ²거래내역, ³제휴정보, ⁴별칭 이렇게 네 가지 속성을 가지고 있다고 생각해봅시다. 그러면 계좌 도메인 객체를 만들고, 이를 조회하는 Port를 만들면 해결될 것 같습니다. 아래는 계좌 도메인을 조회하기 위한 Out Port를 만들어 외부에서 계좌 정보를 가져오기 위한 인터페이스를 구현해본 것입니다.
data class Account(
val balance: Balance, // 잔액
val activities: List<Activity>, // 거래내역
val partnershipInfo: PartnershipInfo, // 제휴 정보
val alias: String, // 별칭(계좌의 이름)
)
interface LoadAccountPort {
fun loadAccount(accountId: AccountId): Account // 계좌를 조회
}
하지만 현실은 항상 그렇게 간단하지 않습니다. 😅
만약 네 가지 속성을 조회하는 방식이 각각 다르다면 어떨까요? 잔액과 거래내역은 계정계에서, 제휴정보와 별칭은 각각 외부 서비스와 데이터베이스에서 조회해야 한다고 가정해봅시다. 이제 고민이 시작됩니다. 앞서 봤던 코드처럼 LoadAccountPort
하나만 만들고 어댑터 안에서 모두 조합해서 응답해야 할까요? 아니면 네 가지 속성을 조회하는 Port를 각각 하나씩 만들고, 애플리케이션 서비스에서 조합해야 할까요? 아래 예시는 네 개의 Out Port를 각각 만드는 경우를 구현한 결과입니다.
@Service
class GetAccountService(
private val loadBalancePort: LoadBalancePort,
private val loadActivitiesPort: LoadActivitiesPort,
private val loadPartnershipInfoPort: LoadPartnershipInfoPort,
private val loadAccountAliasPort: LoadAccountAliasPort,
): GetAccountUseCase {
@Override
fun getAccount(accountId: AccountId) {
val balance = loadBalancePort.loadBalance(accountId) // 잔액 조회 (계정계1)
val activities = loadActivitiesPort.loadActivities(accountId) // 거래내역 조회 (계정계2)
val partnershipInfo = loadPartnershipInfoPort.loadPartnershipInfo(accountId) // 제휴 정보 조회 (외부 서비스)
val alias = loadAccountAliasPort.loadAccountAlias(accountId) // 계좌 별칭 조회 (데이터베이스)
// application layer(Service) 에서 out port 의 응답을 조립해서 Account 를 완성
return Account(
balance = balance,
activities = activities,
partnershipInfo = partnershipInfo,
alias = alias,
)
}
}
결국 중요한 것은 ‘도메인’을 어떻게 정의하느냐입니다. 잔액, 거래내역, 제휴정보, 별칭을 묶은 계좌라는 애그리거트(Aggregate, 도메인 객체의 묶음 단위)가 있다고 생각할 수 있습니다. 그렇다면 애그리거트 단위로 한 번에 조회가 이루어지는 것이 좋겠죠? 하지만 제휴정보는 별개의 도메인이고 계좌의 애그리거트에 포함되지 않는다고 생각할 수도 있습니다. 그렇다면 계좌 도메인을 바꿔서 제휴정보를 분리하고 제휴정보를 따로 조회할 수도 있습니다.
물론 도메인의 경계를 어떻게 나누느냐는 상황과 판단에 따라 다를 수 있지만, 대부분의 경우 어댑터는 도메인을 완성해서 응답해야 합니다. 트랜잭션을 고려했을 때 외부 의존성에 따라 분리하는 것도 매력적일 수 있지만, 도메인의 응집도를 넘을 정도의 이점은 아니라고 생각합니다. 결국 헥사고날 아키텍처는 도메인을 잘 보호하기 위해 만들어진 아키텍처입니다. 따라서 경계를 구분할 때도 도메인을 중심으로 생각해야 합니다.
알고 나면 당연한 이야기인데, 저는 처음에는 이를 이해하지 못해 조금 돌아가며 고생했던 것 같습니다. 혹시 헥사고날 아키텍처를 처음 도입하시는 분이라면 저와 같은 실수를 반복하지 않기를 바랍니다. 😂
기술 부채 2. 성능적 문제 해결하기 (feat. 코루틴)
이번에는 두 번째 기술 부채인 성능 문제에 대해 이야기해보겠습니다. 저희가 해결하고자 했던 두 번째 기술적 문제는 외부 서비스 호출에 따른 성능상의 우려였습니다. 이 문제의 배경을 이해하기 위해서는 홈 화면 서비스의 특징을 알아야 합니다.
카카오뱅크의 홈 화면은 여러 계좌의 목록을 보여주는 것은 물론 그 외에도 다양한 서비스 정보들이 노출됩니다. 여러 가지 유형의 계좌와 서비스들을 모두 보여줘야 하다 보니 조회해야 하는 정보도 다양해집니다. 다양한 정보를 모두 보여주기 위해 내부에서는 수많은 서비스를 호출해서 정보를 조합하게 되는데요. 기존에는 이 서비스들이 대부분 레거시 서비스 위에 있었기 때문에 한 서비스에서 내부 호출을 통해 대부분의 정보를 조회할 수 있었습니다.
하지만 회사 내부적으로 MSA 전환이 활발히 진행되면서 많은 서비스가 별도의 도메인을 가지게 되었습니다. 이제는 홈 화면에서 여러 서비스를 각각 호출해서 데이터를 조합해야 하는 상황이 되었습니다. 호출해야 하는 서비스가 많아질수록 네트워크 구간이 늘어나 응답 속도가 더 지연되므로, 더 늦기 전에 대책을 강구할 필요가 있었습니다.
다른 해결 방법도 있지만, 저희는 동시성을 통해 간단하게 해결하려고 했습니다. 동시성을 활용하면 서비스를 호출할 때 발생하는 네트워크 대기 시간을 더 효율적으로 사용할 수 있기 때문입니다.
그러면 어떻게 동시성을 도입할 수 있을까요? 저희가 고민했던 옵션은 세 가지였습니다. Spring의 Async, Webflux 그리고 Kotlin의 코루틴(Coroutine)입니다. 참고로 당시에는 Java 21 버전의 Virtual Thread가 아직 출시되기 전이라 선택 옵션에 포함되지 않았습니다.
사실, Webflux는 초창기에 다른 프로젝트에 도입해 본 적이 있었지만 아쉽게도 저희에게는 좋은 경험이 아니었습니다. 우선, 코드에 Mono
, Flux
등 동시성 관련 요소들이 계속 침투하는 것이 불편했고, 러닝 커브도 상당했습니다. 또한, 깊은 이해 없이 사용하는 경우 예상치 못한 문제들을 많이 만나게 되며, 이러한 문제가 발생했을 때 원인을 찾거나 해결하는 것도 쉽지 않았습니다. Webflux의 버그로 인해 심각한 장애가 발생한 적도 있었는데, 이 경험 때문에 내부적으로 신뢰도와 선호도가 높지 않았습니다.
결국 Webflux는 선택지에서 배제했습니다. 이제 남은 선택지 중 Kotlin의 코루틴에 관심이 생기기 시작했습니다. 코루틴은 Kotlin 언어에서 제공하는 비동기식 비차단(Non-blocking) 프로그래밍 라이브러리입니다. 대표적으로 동시성을 다루는 멀티 스레드 방식과 달리, 경량 스레드(Lightweight Thread)라는 개념을 차용해 작업에 스레드 대신 객체를 할당합니다. 이렇게 하면 컨텍스트 스위칭 비용을 대폭 줄일 수 있는 강점이 있습니다.
코루틴은 동시성을 매우 간결하게 활용할 수 있다는 장점이 있습니다. Webflux는 말할 것도 없고, Spring의 Async와 비교해 봐도 메서드 자체에 동시성을 부여하는 경우를 고려하면, 훨씬 간결하게 코드를 작성할 수 있다는 것을 알 수 있습니다. 게다가 Future 객체와 같은 불필요한 동시성 객체가 사용되지 않는다는 점도 더욱 좋았습니다. 프록시 패턴의 한계(private method 불가능, 같은 클래스 내 호출 불가능)를 의식할 필요가 없다는 것도 추가적인 장점이었습니다.
- ex) Spring Async
@Async
fun getSomethingAsync(): CompletableFuture<Res> {
return CompletableFuture.completedFuture(Res())
}
fun useCase() { // 같은 클래스에 있으면 안 됨
val res = getSomethingAsync()
res.thenAccept { println(it) }
}
- ex) Kotlin Coroutine
suspend fun getSomethingSuspend(): Res { // suspend
return Res()
}
suspend fun useCase() { // suspend
val res = getSomethingSuspend()
println(res)
}
Kotlin은 언어 수준에서 코루틴을 지원하기에 이미 Kotlin을 사용하고 있던 저희 입장에서는 도입의 허들도 낮았습니다. 그리고 같은 회사의 안드로이드 개발자들이 이미 코루틴을 잘 사용하고 있었기 때문에, 참고 자료도 충분하고 기술적 도움도 받을 수 있어서 매력적이었습니다. 여기에 새로운 기술을 배우고 도입해보고 싶은 개발자로서의 욕심도 있었습니다. 그래서 Kotlin 코루틴을 최종적으로 선택했습니다.
하지만 앞서 헥사고날 아키텍처와 마찬가지로, 새로운 것을 도입하는 것이 쉽지만은 않았습니다. 다소 간단해 보이는 코루틴도 막상 도입하려니 고민되는 부분이 많았습니다. 이번에는 코루틴을 도입하면서 했던 주된 고민을 하나 공유하고자 합니다. 제 고민은 ‘코루틴을 어떻게 적용할 것인가?’ 였습니다. 코틀린 적용 방법으로 어떤 방법들을 생각해 보았는지 3가지를 소개하겠습니다.
🤓 코루틴 적용법 1. 컨트롤러(Controller)부터 suspend
선언하기
코루틴은 어떻게 적용해볼 수 있을까요?
@RestController
@RequestMapping("/api/")
class GetAccountsController(
private val getAccountsQuery: GetAccountsQuery,
) {
@GetMapping("/v1/accounts")
suspend fun getAccounts(): Response<List<Account>> { // suspend!
return Response.ok(getAccountsQuery.getAccounts())
}
}
저희가 처음에 고려한 옵션은 컨트롤러(Controller)부터 suspend
를 선언하는 것이었습니다. 이렇게 하면 별다른 빌더 없이 호출되는 모든 하위 컴포넌트에서 중단 함수를 사용할 수 있게 됩니다. 중단 함수가 컨트롤러부터 시작되기 때문에 코루틴에 의해 적절히 최적화되어 애플리케이션이 전체적으로 더 효율적으로 동작할 수 있습니다. 다만 이후 동시성을 활용하려면 모든 서비스나 어댑터 메서드 등에 suspend
를 선언해야 하는 제약이 생깁니다. 하지만 모든 메서드에 suspend
를 선언하는 것은 일관성이 있는 규칙이므로 쉽게 깨지지 않는다고 생각했습니다. 이 규칙은 특별한 러닝 커브가 없기 때문에 좋은 선택지처럼 보였습니다.
하지만 문제를 발견했습니다. 스레드 로컬(Thread Local)이 전파되지 않는 것이었습니다. 저희는 로그를 위한 MDC(Mapped Diagnostic Context)나 다른 서비스 호출 시 기본 헤더 설정 등을 위해 스레드 로컬을 사용하고 있습니다. 그런데 컨트롤러에서부터 suspend
를 사용하는 경우 스레드 로컬이 전파되지 않는 것을 확인했습니다. 왜 세팅되지 않는지 확인하기 위해 디버그 모드를 켜고 Dispatcher Servlet
부터 흐름을 따라가 보았습니다. 그러자 곧 suspend
함수가 호출되는 메서드를 찾을 수 있었습니다.
class InvocableHandlerMethod {
protected Object doInvoke(Object... args) throws Exception {
...
if(KotlinDetector.isKotlinReflectPresent()) {
if(KotlinDetector.isSuspendingFunction(method)) { // suspend function 이면
return invokeSuspendingFunction(method, getBean(), args);
}
...
}
class CoroutineUtils {
public static Publisher<?> invokeSuspendingFunction( ... ) {
invokeSuspendingFunction(Dispatchers.getUnconfined, ... ); // Context 전파 없이 Unconfined Dispatcher 로 실행
}
}
abstract class CoWebFilter: WebFilter { // web filter
final override fun filter( ... ): Mono<Void> {
return mono(Dispatchers.Unconfined) { // Context 전파 없이 Unconfined Dispatcher 로 실행
...
}
이 메서드를 따라가 보면, 기본적으로 Unconfined
디스패처를 사용하고 있는 것을 볼 수 있었습니다. 코루틴을 실행시켜주는 CoWebFilter
를 확인해 봐도 상황은 동일했습니다. 스레드 로컬을 전파해주는 코드도 없고, 저희가 끼어들어 코루틴 컨텍스트를 넣어줄 틈도 없었기 때문에, 스레드 로컬을 필요로 하는 저희 입장에서는 이 선택지는 배제될 수밖에 없었습니다. 참고로, 저희가 선택한 시점에는 불가능했지만 현재는 직접 컨텍스트를 넣어줄 수 있도록 수정되었습니다.
🤓 코루틴 적용법 2. 필요한 곳부터 suspend
사용하기
컨트롤러부터 suspend
를 사용하는 것이 불가능해지자, 저희는 필요한 곳에서부터 suspend
를 사용하는 방법을 시도해봤습니다. 처음부터 적용할 수 없다면 필요한 곳에만 적용해야 한다고 생각했는데요. 저희에게 동시성이 필요한 구간은 외부 서비스를 호출하는 Out Port 구간이었습니다. 그래서 이 구간을 suspend
로 만들어 동시성을 적용해 보았습니다. 아래 코드는 필요한 Out Port 구간에만 suspend를 적용해본 예시입니다.
interface Outport {
suspend fun getOpenBankiing(): OpenBanking // suspend!
suspend fun getPartnership(): Partnership // suspend!
}
fun getOuterInfo( ... ): OuterInfo {
return runBlocking() {
val openBanking = outport.getOpenBankiing()
val partnership = outport.getPartnership()
OuterInfo(partnership, openBanking)
}
}
이 방법은 필요한 곳에만 간결하게 동시성을 적용하고, 코루틴의 최적화도 충분히 활용할 수 있어 좋은 선택지처럼 보였습니다. 또한, 컨텍스트도 자동으로 전파되기 때문에, 스레드 로컬 걱정 없이 깔끔하게 사용할 수 있었습니다.
하지만, 이번에는 suspend
가 전파되는 것이 우려되었습니다. 예를 들어, 홈 화면을 조회할 때는 여러 다양한 서비스를 호출해야 하기에 동시성이 필요하지만, 상세 정보를 보여주는 화면에서는 동시성이 필요하지 않습니다. 그러나 특정 Out Port를 suspend로 만드는 경우, 이 Port를 호출하는 모든 서비스는 동시성을 제어해야 합니다. 이는 suspend
가 붙은 중단 함수가 코루틴 스코프 안에서 동작해야 하기 때문입니다. 결국 다른 유즈케이스에서 이 Port를 사용하려면 빌더를 만들거나, suspend
를 붙여야 하죠.
🤓 코루틴 적용법 3. async/await 패턴 적용하기
동시성 적용이 다른 호출부에 영향을 주지 않는 방법을 찾기 위해 고민하던 중, 조사해 보니 async/await 패턴을 통해 코루틴을 적용할 수도 있다는 것을 발견했습니다. 이 패턴을 사용하면 suspend
를 사용할 때와 반대로 컨텍스트를 별도로 관리해야 하며, 중단 함수를 사용하지 않기 때문에 코루틴을 100% 활용할 수는 없습니다. 그러나 동시성이 다른 호출부로 전파되지 않고 호출부에서만 관리된다는 장점이 있었습니다.
interface Outport {
fun getOpenBankiing(): OpenBanking // suspend X
fun getPartnership(): Partnership // suspend X
}
fun getOuterInfo( ... ): OuterInfo {
return runBlocking() { // 딱 필요한 곳에서만 동시성 제어
val openBanking = async { outport.getOpenBankiing() }
val partnership = async { outport.getPartnership() }
OuterInfo(partnership.await(), openBanking.await())
}
}
suspend
와 async/await, 두 가지 선택지는 각각 장단점이 있습니다.
먼저 suspend
선택지는 중단 함수를 사용하기 때문에 코루틴을 더 효율적으로 최적화하여 사용할 수 있고 별도의 컨텍스트 관리가 필요 없다는 장점이 있습니다. 그러나 비동기 선택지가 호출부까지 전파된다는 단점이 있습니다. 이렇게 되면 비동기 요구가 사라질 때 변경해야 하는 곳이 늘어나고, 같은 메서드를 다른 곳에서 재활용하고 싶은 경우 코루틴 빌더 사용이 강제됩니다. 반면에 async/await 패턴은 상반된 특성을 가지고 있습니다. 컨텍스트를 관리해줘야 하고 스레드는 비효율적으로 사용되지만, 동시성을 오직 호출부에서만 알고 있다는 장점이 있습니다.
어떤 선택지가 더 좋을지 고민하기 시작하면서, 저희는 상황과 목적을 다시 한 번 생각해 보기로 했습니다. 카카오뱅크 홈 화면의 로직은 크게 세 구간으로 나뉩니다. ¹코어 로직 구간, ²외부 서비스를 호출하는 구간, ³조회한 외부 정보를 조합하여 응답을 생성하는 구간입니다. 이 중 동시성이 필요한 구간은 ‘외부 서비스를 호출하는 구간’뿐입니다. 왜냐하면, 코어 로직 구간과 외부 정보 조합 구간은 순차적인 작업을 필요로 하므로 동시성을 활용하는 의미가 없기 때문입니다. 즉, 저희는 제한적으로 동시성을 활용하고 싶었습니다. 그렇다면 약간의 성능적 이득보다는 suspend
가 필요하지 않은 곳들에까지 침투하는 것이 장기적으로 더 큰 비용을 만들어낼 것이라고 판단했습니다.
둘 다 적용해서 성능 비교도 해봤습니다. suspend
를 사용할 때 스레드를 더 효율적으로 사용하는 것은 분명했지만, 응답 시간에는 별 차이가 없다는 것을 확인할 수 있었습니다.
🙂 최종 선택: async/await 패턴
그래서 저희는 최종적으로 async/await 패턴을 사용해 홈 화면을 구현하기로 결정했습니다. 저희의 상황에 더 적절한 선택을 고민한 결과, 다소 일반적이지 않은 방법을 선택한 것 같습니다. 저희의 선택이 정답이라고 생각하지는 않습니다. 앞으로 기능이나 상황이 달라진다면, 선택을 번복할 수도 있습니다. 하지만 결국 중요한 것은 각자의 상황에 맞는 최적의 선택을 하는 것이라고 생각합니다.
Architecture is the stuff you can’t Google. There are no right or wrong answer in architecture, only trade-offs.
-Mark Richards, Neal Ford, Fundamentals of Software Architecture
서비스 분리/이관 전략 : ‘안정성’을 최우선으로! 💪
이제부터는 안정적인 분리/이관 전략에 대해 말씀드리겠습니다. 이 세상에 장애가 발생해도 괜찮은 시스템은 없을 것입니다. 다만, 영향도의 차이는 있을 수 있습니다. 카카오뱅크는 금융 서비스이고, 은행은 신뢰가 무엇보다도 중요하기에 안정성은 더욱 중요합니다. 또한, 은행의 장애 상황은 사용자들에게 치명적인 영향을 줄 수 있으므로, 은행의 개발자는 무엇보다도 장애 상황에 철저하게 대비하고 시스템의 안정성에 민감할 수 밖에 없습니다.
홈 화면은 첫 화면이기에 매우 높은 영향도를 가지고 있습니다. 이는 계좌 및 잔액 조회와 이체 단계로 넘어갈 수 있는 징검다리이기 때문에 은행의 핵심 업무라고 할 수 있습니다. 또한 카카오뱅크 전체에서 가장 트래픽이 많은 서비스이기도 하므로, 수많은 고객들을 위해서라도 장애가 발생하는 상황은 절대로 피하고 싶었습니다.
하지만 기술 부채 해결을 위한 코드의 대규모 변경이 있었기에 사실상 서비스를 새로 만든 수준이었습니다. 이 작업은 채널 서버 개발자들끼리 수행한 서비스 분리 작업으로, 코드를 새로 작성했지만 외부와의 인터페이스는 변경되면 안 되는 상황이었습니다. 즉, 입출력 인터페이스는 기존과 동일해야 했습니다.
🫡 전략 1. 응답 비교 서비스
대규모 변경으로 인해 응답이 달라질까 불안했기에, 저희는 먼저 ‘응답 비교 서비스’를 만들어 안정성을 확보하려고 했습니다. 기존 서비스에서 비동기로 응답 비교 서비스를 호출하고, 응답 비교 서비스는 신규 서비스를 호출하여 두 응답을 비교합니다. 그리고 응답에 이상이 있으면 알람을 발생시켜, 응답의 어느 부분이 달라졌는지 알려주도록 만들었습니다. 이는 홈 서비스가 조회성 기능만을 가지고 있기에 가능한 방법이었습니다. 해당 비교 서비스를 통해 운영환경에서도 대고객 영향을 주지 않고 실제 응답의 정확성을 비교할 수 있었으며, 잘못된 부분을 빠르게 발견할 수 있었습니다.
하지만 모든 것이 순탄했던 것만은 아니었습니다. 응답이 응답 비교 서비스로 전달되고, 응답 비교 서비스가 신규 서비스를 호출하는 사이에 데이터 원천이 바뀌어 잘못된 알람이 자주 발생했는데, 그 중 ‘잔액 데이터’가 바뀌는 경우가 많았습니다. 이러한 문제는 하나씩 원인을 확인하여 대처했습니다. 특히 잔액처럼 변동이 잦아 False 알람이 많이 발생하는 항목에 대해서는 알림 빈도를 줄이거나, 알림을 무시하는 방식으로 처리했습니다.
🫡 전략 2. 표본 검사
비교 서비스를 통해 응답의 정합성을 보장받을 수 있게 되었지만, 이로 인해 발생하는 문제도 있었습니다. 운영 환경에서 실제 응답으로 비교를 시도하다 보니 기존 시스템과 다른 시스템들에 영향을 미칠 수밖에 없었습니다.
먼저, 기존 시스템에서 응답 비교 서비스를 호출하면서 기존 시스템의 쓰레드 사용이 증가할 수 있습니다. 비록 비동기 방식으로 호출하기 때문에 그 영향도는 작겠지만, 결코 무시할 수는 없습니다. 또한, 홈 화면은 앱 진입하면서 고객이 마주하는 가장 첫번째 화면이기 때문에 다른 어떤 시스템보다도 압도적인 트래픽을 자랑합니다. 여기서 응답 비교 서비스를 호출한다면, 이 트래픽이 신규 서비스를 통해 호출받는 시스템으로 다시 전달되면서 트래픽이 두 배로 증가하는 효과가 발생합니다.
이 때문에 저희는 전수 조사가 아닌 표본 검사를 진행했습니다. 트래픽이 높기 때문에 적은 비율을 조사하는 것만으로도 상당히 많은 요청 유형을 검토할 수 있었습니다. 예를 들어, 홈 화면은 하루 평균 약 3,000만 번 정도 사용자 호출이 있기 때문에, 1%만 확인해도 약 30만 개의 요청을 검증할 수 있습니다.
이때, 저희는 검증 비율을 동적으로 설정할 수 있도록 설계했습니다. 처음에는 매우 작은 0.1%의 비율로 시작했고, 이후 지표를 모니터링하면서 0.1%에서 1%, 5%, 10%로 조금씩 비율을 높여가며 검증을 진행했습니다.
🫡 전략 3. A/B 비율 조정을 통한 트래픽 전환
실제 운영 전환 작업을 준비하면서, 타 시스템에 미치는 영향을 최소화기 위해 앞서 말씀드린 응답 비교 서비스를 사용해 충분히 시간을 가지고 서비스를 확인했습니다. 하지만 운영 전환 시점이 다가올수록 여전히 불안하고 긴장이 되는 건 어쩔 수 없었습니다. 그래서 조금 더 안정적인 이관이 될 수 있도록 추가 장치를 마련했습니다.
우선, 운영 전환을 단번에 진행하지 않고, A/B 테스트와 유사하게 일부 비율만 전환했습니다. 이는 초기에는 아주 작은 비율로 시작하여, 점차 비율을 높여가는 방식이었습니다. 또한 문제가 생기면 즉시 비율을 다시 조정할 수 있도록 검증 비율을 동적으로 설정했습니다.
이 때도 이슈가 하나 있었습니다. 혹시 하이럼의 법칙(Hyrum’s Law)을 아시나요? API의 사용자가 충분히 많아지면, 반드시 누군가는 명세에 나와있지 않은 숨겨진 동작에 의존한다는 법칙입니다. 이렇게 되면 암묵적인 기능이 생긴 것이나 다름 없기 때문에, 향후 API를 수정할 때 제약을 받게 됩니다.
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviours of your system will be depended on by somebody.
-Titus Winters and others, Software Engineering at Google
저희도 홈 화면 서비스 분리 과정에서 하이럼 법칙의 사례를 경험했습니다. 기존 홈 화면 서비스에는 계좌의 별칭(이름) 데이터가 존재하지 않으면 별칭을 보정(새로 저장)해주는 기능이 있었습니다. 만약의 상황을 대비해 만든 기능이었습니다. 그런데 이번에 새로 서비스를 분리하면서, 이 기능이 새로운 홈 화면의 역할과 책임에 맞지 않는다고 판단했습니다. 기록을 살펴보니 우려했던 만약의 상황이 발생한 적도 없어 보였습니다. API 명세에도 없는 기능이고 사용되지 않는 기능이라고 생각해서 신규 분리한 서비스에서는 이 기능을 제거했습니다.
그런데 알고 보니 이 별칭 보정 기능을 의존하는 서비스가 있었습니다. 특정 상황에서 해당 보정 기능을 사용해 계좌 별칭을 만들어내고 있었던 것입니다. 그 특정 상황의 빈도가 높지 않아 최근 호출된 기록이 없어서 이를 발견하지 못했던 것이죠. 다행히 운영 전환 비율을 조절할 수 있었기에 이 문제를 발견하고 바로 운영 전환 비율을 0%로 조절했습니다. 그리고 문제를 조치한 후에야 다시 비율을 천천히 올릴 수 있었습니다. 이 사건을 통해 명세에 없는 기능이라도 존재하는 기능을 제거하는 것이 정말 쉽지 않은 것임을 다시 한 번 느끼게 되었습니다.
🫡 전략 4. Fallback 기능
운영 전환 비율 조정과는 별개로, 만일의 상황을 대비해 Fallback 기능도 추가해 두었습니다. 이는 신규 서비스에서 에러 응답이 발생하면 기존 서비스가 대신 응답하도록 하는 기능으로, 저희가 미처 검증하지 못한 예외적인 케이스에 대비한 장치였습니다.
이관 작업 이후, 운영 환경에서 해당 기능이 도움이 된 적이 있었습니다. 한 번은 신규 서비스를 배포하던 중에 실수가 있어 nginx가 내려간 적이 있었습니다. 이로 인해 신규 서비스로의 연결이 순간적으로 모두 끊어졌습니다. 그러나 다행히 운영 서비스에는 거의 영향이 없었는데, 이는 Fallback 기능이 동작하여 기존 서비스로 응답이 이루어졌기 때문입니다. 이후 운영 전환 비율을 조정하여 트래픽을 기존 서비스로 돌리고, nginx를 복구한 후 다시 신규 서비스로 전환할 수 있었습니다. 예상하지 못했던 상황에서도 Fallback 기능이 유효하게 동작해 준 덕분에 큰 사고를 피할 수 있었습니다.
회고 ☕️
지금까지 설명드린 내용과 개발 과정을 간략하게 회고를 하며 정리해보겠습니다. 카카오뱅크는 레거시 시스템을 사용하는 개발자들의 전반적인 개발 생산성 향상을 위해 서비스 분리를 계획했습니다. 그러나 단순히 ‘분리’만으로는 모든 문제를 해결할 수 없었기 때문에, 서비스 분리와 함께 기술 부채도 해결하고자 했습니다. 해결하려던 주요 기술 부채는 다음과 같습니다.
성능적 문제: 외부 서비스 호출 증가에 따른 성능 우려
저희는 헥사고날 아키텍처를 도입해 의존성 문제를 해결했고, 코루틴을 도입해 성능 우려를 해결했습니다. 그 결과, 유지보수가 훨씬 편해졌습니다. 우선 서비스 분리가 가져온 개발 생산성의 향상이 매우 크게 체감됩니다. 헥사고날 아키텍처와 코루틴 도입도 내부적으로 긍정적인 평가를 받고 있습니다. 특히 헥사고날 아키텍처는 여러 개발자가 동시에 다른 계층을 구현하며 충돌 없이 작업할 수 있다는 점이 큰 장점으로 느껴집니다. 다만, 간단한 기능을 개발할 때에도 불필요한 보일러플레이트 코드가 많이 생기는 건 단점입니다. 코루틴의 경우, 성능 향상보다도 코드를 간결하게 작성할 수 있다는 점이 더 매력적으로 느껴지며, 간결한 코드로 동시성을 쉽게 적용할 수 있었습니다.
기술 부채를 해결하는 과정에서 불안감이 있었지만, 안정적인 이관을 위해 네 가지 전략을 사용했습니다.
2. 다른 서비스 영향도를 줄이기 위한 표본 검사
3. 장애 최소화 및 복구를 위한 A/B 비율 조정 트래픽 전환
4. 예외 케이스를 대비한 Fallback 기능
그 결과, 큰 문제 없이 운영 전환이 순탄하게 진행되었습니다. 이는 오랜 기간 동안 철저한 검증을 진행하며, 만약의 상황을 대비해 다양한 장치를 마련해 둔 덕분이었습니다.
이로써 은행에서 새로운 홈 서비스를 만든 여정이 마무리되었습니다. ‘카카오뱅크 앱의 얼굴’이라고 할 수 있는 홈 서비스를 레거시로부터 분리하고 이관하는 프로젝트를 맡았을 당시 최악의 상황에 대한 두려움과 부담감이 있었지만, 돌이켜보니 그래서 더 기억에 남는 프로젝트였던 것 같습니다. 새롭게 탄생한 홈 서비스가 앞으로도 지속 가능하고 오래도록 건강하게 운영되기를 바라며, 여기서 마무리하겠습니다. 긴 글 읽어주셔서 감사합니다. 🙌