카카오뱅크 투자/외환기술팀이 클라우드 네이티브 환경에서 펀드 시스템을 독립 구축하면서 탄생한 사내 Spring Boot Starter 라이브러리 ‘barcelona’을 소개합니다. 은행 도메인의 특수한 환경에서 핵심금융원장을 Spring Boot 기반으로 만들고, FixedLength 전문 처리, HTTP Client 추상화, 분산 트레이싱 등 공통적인 기능을 라이브러리화하여 개발 생산성을 높인 여정을 직접 확인해보세요!

들어가며

안녕하세요, 카카오뱅크 투자/외환기술팀에서 펀드 개발을 담당하고 있는 Angie입니다. 저는 지난 6월, 한국 스프링 사용자 모임(KSUG)이 주관한 Spring Camp 2025 컨퍼런스에서 <카카오뱅크 펀드시스템에 날개를 달아준, 사내 Spring Boot Starter 라이브러리 이야기>라는 주제로 발표한 경험이 있습니다. 이 글에서는 해당 발표 내용을 바탕으로, 팀의 개발 생산성을 높이기 위해 자체적으로 개발한 사내 Spring Boot Starter 라이브러리(이하 ‘스타터 라이브러리’)인 barcelona의 도입 과정과 주요 특징을 정리해 공유드리고자 합니다.

카카오뱅크는 2024년 1월부터 펀드 판매 서비스를 시작했습니다. ‘펀드’라는 상품 출시의 이면에는 단순한 금융상품의 추가를 넘어서는, 기술적으로도 의미 있는 도전이 있었습니다. 바로 펀드 원장을 기존 코어뱅킹(계정계) 시스템에 통합하지 않고, 클라우드 네이티브 환경에서 Spring Boot 기반의 독립 시스템으로 구축한 점입니다. 이 과정에서 저희 팀은 반복적인 설정 작업과 공통 요구사항을 보다 효율적으로 해결하기 위해 barcelona라는 사내 스타터 라이브러리를 개발하게 되었습니다.

Spring Camp 2024에서 많은 인기를 얻었던 <구해줘 홈즈! 은행에서 3천만 트래픽의 홈 서비스 새로 만들기> 발표 이야기와 추가적인 펀드 시스템 개발 이야기가 궁금하신 분은 <Airflow로 펀드 배치 시스템 완벽 구축하기>글도 함께 참고해주세요!

펀드 시스템 분리: 공통 기능 라이브러리의 필요성

카카오뱅크의 코어뱅킹 시스템(계정계)은 초기에는 단일 시스템으로 운영되었습니다. 서비스 규모가 작았던 시기에는 효율적이었지만, 서비스와 회사 규모가 커지면서 모놀리스 구조의 한계가 점차 드러나기 시작했습니다. 빌드·배포 속도의 저하, 유지보수의 어려움 등 전형적인 단일 시스템의 문제들이 발생한 것입니다. 이를 해결하기 위해 카카오뱅크는 전사적으로 점진적인 MSA(Micro Service Architecture) 구조로의 전환을 추진하고 있었습니다. 그 과정에서 새롭게 구축한 펀드 시스템은 코어뱅킹 시스템을 클라우드 네이티브 환경 위에 분리하여 독립적으로 만든 첫 번째 사례였습니다.

카카오뱅크는 이미 다수의 Spring 기반의 서비스 경험이 있었기 때문에, ‘펀드’라는 단일 도메인을 분리해 구축하는 과정에서 오픈소스 Spring Boot를 활용해 핵심금융원장 시스템을 클라우드 네이티브 환경에서 직접 구현하기로 결정했습니다.

오랜 기간 안정적으로 운영되어 온 온프렘 환경과 달리, 당시 클라우드 환경에서는 서버 애플리케이션 개발을 위한 기반이 이제 막 갖춰지기 시작하는 단계였습니다. 그래서 Spring Initializer로 생성한 프로젝트를 바탕으로 하나씩 환경을 직접 만들어가야 했습니다. 카카오뱅크의 시스템은 전통적으로 채널계(고객·앱 접점)와 계정계(자금 거래 처리)로 구분되는데, 펀드 시스템 역시 이 구조에 맞춰 채널계와 계정계로 분리하여 구축했습니다. 문제는 여러 신규 프로젝트를 만들면서 실제 핵심 로직 개발보다 초기 설정 작업에 더 많은 시간과 노력이 소요된다는 점이었습니다.

1-fund-system.jpg
[그림 1] 카카오뱅크 펀드 시스템 아키텍처

각 프로젝트마다 공통적으로 필요한 초기 설정으로는 특정 데이터 형식의 직렬화·역직렬화, API 통신 설정, 분산 트레이싱 구성, 보안 및 로깅 등이 있었습니다. 물론 프로젝트별로 개별적으로 구현할 수도 있지만, 그렇게 되면 동일한 기능을 여러 번 작성해야 하고, 버그가 발생할 경우 모든 프로젝트에서 각각 수정해야 하는 번거로움이 있습니다. 요구사항이 변경될 때도 모든 프로젝트를 일일이 수정해야 하는 불편함이 생기죠. 이러한 비효율을 줄이기 위해, 팀에서는 공통 기능을 한데 모아 제공하는 펀드 시스템 전용 스타터 라이브러리인 barcelona를 개발하게 되었습니다.

그렇다면 왜 스페인의 한 도시 이름을 따서 만들었을까요? 앞서 Oslo 프로젝트를 다룬 글에서도 소개한 것처럼, 카카오뱅크에는 프로젝트명을 도시나 섬 이름에서 따오는 재미있는 전통이 있습니다. barcelona 역시 이러한 전통을 따라, 라이브러리를 개발한 분이 가장 좋아하는 도시인 ‘바르셀로나’라는 이름을 붙였다고 합니다. 건축의 교과서라고 불리는 도시 바르셀로나처럼, barcelona 라이브러리도 Spring 기반 초기 프로젝트 구축에서 교과서 같은 역할을 해주고 있습니다.

Spring Boot 스타터 구조 이해하기

barcelona 라이브러리는 Spring의 다른 스타터들과 역할이 크게 다르지 않습니다. 예를 들어, Spring으로 웹 개발을 할 때 spring-boot-starter-web을 추가하면 다음과 같은 작업이 자동으로 가능해집니다.

  1. Tomcat 등 내장 서버가 자동으로 구성되고,
  2. Jackson 모듈을 통해 객체의 직렬화·역직렬화가 가능하며,
  3. @Controller 어노테이션만으로 API를 손쉽게 구현할 수 있습니다.

Spring으로 서버 개발을 해보셨다면, 내부 구현을 몰라도 이 기능들을 자연스럽게 활용하셨을 것입니다. 필요한 기능들이 내부적으로 잘 추상화되어 있어 개발자가 쉽게 사용할 수 있기 때문입니다. 이와 마찬가지로, 카카오뱅크 투자/외환기술팀에서는 은행 도메인 개발에 필요한 공통 기능을 추상화해 스타터로 제공하고 있습니다. 즉, 개발자는 의존성 한 줄만 추가하면 필요한 기능을 바로 사용할 수 있습니다.

혹시 Spring에서 기본적으로 제공하는 기능을 바로 사용할 수 있도록 추가해온 Spring Boot 스타터의 구조를 살펴본 적 있으신가요? 일반적으로 스타터는 3가지 모듈로 구성됩니다.

  1. starter 모듈: 실제 코드는 없고, 필요한 모듈들을 의존성으로 묶어주는 역할
  2. context 모듈: 라이브러리의 핵심 구현이 담긴 모듈
  3. autoconfigure 모듈: Context의 기능들을 특정 조건에 따라 Bean으로 자동 등록
    barcelona-spring-boot-starter/
      ├── barcelona-spring-boot-starter          # starter 모듈
      │   └── (의존성 관리만 담당, 실제 코드 없음)
      ├── barcelona-spring-boot-autoconfigure    # autoconfigure 모듈
      │   └── (자동 설정 및 Bean 등록)
      └── barcelona-context                      # context 모듈
          └── (핵심 구현 로직)

이 구조 덕분에 Spring Boot는 라이브러리의 핵심 구현(context)과 Boot 환경에서의 자동 설정(autoconfigure)을 분리해 서로 독립적으로 구현·배포할 수 있으며, starter가 필요한 라이브러리 묶음을 한 번에 가져와 의존성 관리도 단순화 되었습니다.

barcelona도 위와 같은 형식으로 모듈을 분리하였습니다. 단순한 코드 정리가 아닌, 공통 기능을 Spring Boot 생태계에 자연스럽게 녹아들도록 하기 위해서입니다. 덕분에 복잡한 설정을 반복할 필요 없이 한 줄의 의존성 추가만으로 필요한 기능을 바로 사용할 수 있게 되었고, 은행 고유의 공통 기능들도 일관된 방식으로 사용할 수 있게 되었습니다.

//build.gradle
dependencies {
    implementation 'com.kakaobank.investment:barcelona-spring-boot-starter'
}

barcelona의 주요 기능: 은행 도메인에 특화된 추상화

barcelona가 제공하는 기능들은 직렬화·역직렬화, HTTP Client, 분산 트레이싱 등 일반적으로 Spring이나 오픈소스 라이브러리에서도 제공하는 기능들과 비슷합니다. 하지만 barcelona는 은행 업무에 특화된 요구사항을 반영해, 카카오뱅크에 적합한 방식으로 기능을 추상화하여 제공하고 있습니다. 이어서 세 가지 사례를 통해 금융 원장을 Spring으로 구현하면서 어떤 공통 기능이 필요했고, 이를 barcelona가 어떻게 해결했는지 살펴보겠습니다.

공통 기능 1. FixedLength 전문 직렬화/역직렬화

1-1. FixedLength 전문: JSON이 아닌 전통 통신 형식

대부분의 외부 시스템과 연동할 때 데이터 형식으로는 JSON이 많이 사용됩니다. JSON은 key-value 쌍으로 필드를 구분하며, 사람이 이해할 수 있는 익숙한 표현 방식입니다. JSON 형식을 사용하면 JSON 데이터와 객체 필드가 1:1로 대응되어 개발자가 데이터를 읽고 이해하기 쉽다는 장점이 있습니다.

{
  "name": "홍길동",
  "amount": 100000
}

하지만 펀드 시스템에서는 JSON이 아닌 다른 데이터 형식을 사용하는 경우도 있습니다. 고객이 펀드를 구매하면 해당 구매 대금은 카카오뱅크가 직접 보관하지 않고 외부 금융기관에 예치하게 됩니다. 이때 금융기관과의 연동에는 FixedLength 전문 메시지 형식이 주로 사용됩니다.

💡 FixedLength 전문이란?

  1. 구분자(Delimiter) 없이 위치(Offset)와 길이(Length)만으로 필드를 식별하는 방식
  2. 예: 앞 10자리는 이름, 다음 9자리는 금액처럼 고정된 자리수로 데이터 표현
홍길동       000100000
10자리       9자리

즉, FixedLength 전문은 JSON처럼 key-value 구조가 드러나지 않고, 각 필드의 위치와 길이가 미리 정해져 있습니다. 데이터는 이 정의된 길이에 맞춰 파싱(Parsing)해서 사용하게 되며, JSON과 달리 사람이 바로 의미를 파악하기 어렵습니다. 그렇다면 이런 형식의 데이터를 요청이나 응답으로 주고받을 때, 어떻게 의미 있는 객체로 변환할 수 있을까요?

1-2. 반복되는 파싱 로직의 문제

spring-boot-starter-web을 추가하면 Jackson 라이브러리를 통해 JSON 형식과 객체를 쉽게 직렬화·역직렬화할 수 있습니다. 덕분에 개발자는 객체를 JSON으로 변환하는 방법을 직접 고민할 필요 없이 비즈니스 로직 구현에 집중할 수 있습니다.

하지만 FixedLength 전문 형식은 이런 직렬화·역직렬화 기능이 기본적으로 제공되지 않습니다. 따라서 개발자는 비즈니스 로직뿐만 아니라 FixedLength 문자열에 담긴 데이터를 직접 이해하고, 의미 있는 객체로 변환하는 데 더 많은 시간을 들여야 합니다. 예를 들어, 아래처럼 FixedLength 문자열로 비즈니스 로직을 작성하려면, 방대한 데이터 정의서를 참고해 각 데이터 필드의 위치와 의미를 파악해야 하고, 이를 객체로 변환하는 파싱 로직도 직접 구현해야 합니다.

📌 FixedLength 문자열을 파싱하는 예시 코드

public DepositRequest parse(String fixedLengthString) {
    String name = fixedLengthString.substring(0, 10).trim();
    String amountStr = fixedLengthString.substring(10, 19).trim();
    BigDecimal amount = new BigDecimal(amountStr);

    return new DepositRequest(name, amount);
}

버그가 발생할 수 있는 지점이 여러 군데 있습니다. 예를 들어, substring의 범위를 잘못 지정하면 잘못된 값이 들어가고, 패딩 처리에 오류가 나면 파싱이 제대로 되지 않을 수 있습니다. 이런 문제들 때문에 핵심 비즈니스 로직보다 파싱 로직의 검증과 디버깅에 더 많은 시간을 쓰게 됩니다.

1-3. 해결 방법: FixedLength 전문도 JSON처럼

barcelona는 FixedLength 전문도 JSON처럼 객체로 쉽게 직렬화·역직렬화할 수 있는 기능을 제공합니다. 개발자는 아래와 같이 객체에 @FixedLengthCharacter 어노테이션만 추가하면 됩니다.

public class DepositRequest {
    @FixedLengthCharacter(length = 10)
    private String customerName;

    @FixedLengthCharacter(length = 9)
    private BigDecimal amount;
}

이렇게 어노테이션을 붙이면 변환 로직을 직접 작성할 필요 없이 데이터가 자동으로 변환됩니다.

// 자동 변환 예시
String fixedString = "홍길동       000100000";
DepositRequest request = objectMapper.readValue(fixedString, DepositRequest.class);

이 방식은 JSON을 객체로 변환하는 과정과 매우 유사합니다. 문자열을 직접 자르고 붙이는 반복적인 코드를 작성할 필요 없이, JSON을 다루듯 직관적으로 객체 변환을 할 수 있습니다.

1-4. Jackson Module 확장으로 구현하기

이 기능은 Jackson의 Module이라는 확장 지점을 활용해 구현했습니다. Jackson은 개발자가 직접 규칙을 정의해 확장할 수 있는 구조를 가지고 있습니다. 기본 타입이 아닌 커스텀 타입에 대한 직렬화와 역직렬화 로직을 제어하고 싶을 때, ObjectMapper에 Module을 만들어 등록할 수 있습니다.

FixedLength 전문을 처리하기 위해 아래와 같은 확장 기능을 활용했습니다.

  1. JsonSerializer: 객체를 문자열로 바꾸는 코드 작성
  2. JsonDeserializer: 문자열을 객체로 바꾸는 코드 작성
  3. Module: 위 JsonSerializer, JsonDeserializer들을 묶어 ObjectMapper에 등록

위 클래스들을 확장해 구현하여 Module에 등록해두면, 이후에는 JSON을 다루듯이 FixedLength 전문도 객체로 자동 변환할 수 있습니다.

📌 구현 예시 코드

// 1. Serializer 구현
public class FixedLengthSerializer extends JsonSerializer<Object> {
    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        // 객체를 FixedLength 문자열로 변환하는 로직
    }
}

// 2. Deserializer 구현
public class FixedLengthDeserializer extends JsonDeserializer<Object> {
    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) {
        // FixedLength 문자열을 객체로 변환하는 로직
    }
}

// 3. Module에 등록
public class FixedLengthModule extends SimpleModule {
    public FixedLengthModule() {
        addSerializer(Object.class, new FixedLengthSerializer());
        addDeserializer(Object.class, new FixedLengthDeserializer());
    }
}

// 4. ObjectMapper에 등록
objectMapper.registerModule(new FixedLengthModule());

FixedLength 전문 처리 Module은 barcelona에 포함되어 있어 개발자가 직접 ObjectMapper에 등록해 쓸 수 있습니다. 또한, FixedLength 전문을 이용해 통신하는 외부 API Client를 라이브러리 차원에서 추상화해 제공하기 때문에 별도 설정 없이도 자동 변환이 이뤄집니다. 또한 Spring Boot의 AutoConfiguration을 활용해, 매번 Module을 수동으로 등록하지 않아도 되도록 구성했습니다.

만약 이 기능을 공통화하지 않았다면, 개발자는 문자열을 직접 자르고 붙이며 객체와 FixedLength 전문 변환 로직을 매번 작성해야 했을 것입니다. 또, 오류를 막기 위해 테스트 코드도 일일이 작성해 핵심 로직 작성보다 부가적인 코드를 작성하는 데에 시간이 많이 소요되었을 겁니다. 하지만 barcelona가 FixedLength 전문 변환을 추상화 해준 덕분에, FixedLength 전문을 객체처럼 자연스럽게 다루며 코드를 작성할 수 있게 되었습니다.

공통 기능 2. 사내 API 통신을 위한 HTTP Client 추상화

2-1. 문제 상황: 반복되는 HTTP Client 설정

외부 API 연동에는 보통 RestTemplate, WebClient, Feign 등 다양한 HTTP Client 라이브러리를 사용합니다. 덕분에 소켓을 직접 열거나 프로토콜을 깊이 이해하지 않아도, 라이브러리 호출만으로 안전하게 통신할 수 있습니다.

2-external-api-connection.jpg
[그림 2] 펀드 시스템과 외부 API 간 통신 방법

저희 팀 역시 사내 API를 호출할 때 HTTP Client를 사용하는데, 여러 시스템에서 동일한 Endpoint를 가진 API를 호출하다 보니 같은 설정을 반복해야 한다는 문제가 있었습니다.

만약 한 시스템에서만 쓰이는 API라면, 별도의 라이브러리로 추상화하지 않고 직접 설정해도 충분합니다. 하지만 여러 프로젝트에서 공통으로 사용하는 주요 사내 API의 경우, 매번 반복되는 중복 설정을 피하기 위해 라이브러리 차원에서 HTTP Client를 추상화해 제공하고 있습니다.

2-2. 해결 방법: 통일된 방식으로 제공하기

사내 API를 호출할 때는 공통적으로 맞춰야 하는 요소들이 있습니다. 우선 통신 규격을 통일해야 합니다. 예를 들어, 앞서 설명한 FixedLength 형식처럼 각 API는 정해진 규격을 갖고 있어, 개발자가 모든 복잡한 규격을 알지 못하더라도 필요한 정보만 입력하면 API를 호출할 수 있도록 만들었습니다.

또한, HTTP Client 종류도 통일했습니다. 프로젝트마다 자유롭게 Client를 선택하게 두면, 같은 요청과 응답 형식이라도 라이브러리마다 파싱 방식이 달라질 수 있습니다. 이 경우 코드의 일관성이 깨지고, 팀 전체가 사용된 기술을 모두 이해해야 하므로 운영 부담과 학습 비용이 커집니다. 따라서 스타터 라이브러리에서는 공통의 Client를 제공했습니다.

결과적으로 barcelona를 통해, 개발자는 사내 API의 요청 형식이나 HTTP 통신 기술을 잘 알지 못해도, 스타터 라이브러리 호출만으로 안전하고 일관된 방식의 통신을 할 수 있습니다.

다만, 단순한 공통 설정만으로는 스타터 라이브러리를 사용하는 프로젝트의 모든 요구사항을 충족하지 못할 때도 있습니다. 각 시스템 요구사항 별 요청의 출처나 목적에 대한 감사 로그 기록, 권한 검증, 거래 증적 저장과 같은 부가 기능이 필요한 경우도 있기 때문입니다.

2-3. 확장 포인트: Chain of Responsibility(책임 연쇄) 패턴

펀드코어(펀드의 계정계) 시스템이 코어뱅킹 시스템과 통신하는 상황을 예로 들어보겠습니다. 펀드를 사려면 펀드 시스템이 아닌 코어뱅킹 시스템에 존재하는 입출금 통장에서 돈을 출금해야 합니다. 또한 고객이 모바일로 직접 거래하지 못하는 경우, 고객센터 직원이 대신 거래를 처리하기도 합니다.

이 과정에서 코어뱅킹 시스템의 출금 API인 coreBankingClient를 호출하기 전후로, 펀드 시스템에서는 여러 검증과 부가 처리가 필요합니다.

  1. 거래 가능한 부서인지 확인합니다. 이때 개발 부서에서 고객 계좌로 거래를 일으켜서는 안 됩니다.
  2. 거래 가능한 직원인지 검증합니다. 이는 회계 처리 권한이 있는 직원만 거래를 수행할 수 있도록 하기 위함입니다.
  3. 거래 증적을 기록하고 출금 요청을 전송합니다.
  4. 응답을 받은 후에도 완료 증적을 남깁니다.

이런 부가 로직을 가장 단순하게 구현하면, 출금 요청 API 호출 앞뒤에 검증과 기록 코드를 순차적으로 작성하는 방식이 됩니다.

📌 사용자 출금 요청 구현 예시 코드

public void withdrawForFund(WithdrawRequest request) {
    // 요청 전 검증
    validateDepartment(request.getDepartmentId());
    validateEmployee(request.getEmployeeId());
    recordPreTransaction(request);

    // API 호출
    WithdrawResponse response = coreBankingClient.withdraw(request);

    // 응답 후 기록
    recordPostTransaction(response);
}

하지만 위 방식대로 구현하게 되면, 프로젝트마다 구현이 필요하므로 로직에 대한 파악과 유지보수가 어렵습니다. 팀에 새로운 개발자가 합류해 유지보수를 해야한다면 API 호출 전후에 어떤 부가 처리가 붙어 있는지 확인하기 힘들고, 정책이 바뀌었을 때는 흩어져 있는 검증 코드를 일일이 찾아야 합니다.

저희는 이 문제를 해결하기 위해 Client 호출 과정을 ClientHandler 인터페이스로 추상화하고, Chain of Responsibility 패턴을 적용했습니다.

📌 Chain of Responsibility 패턴을 반영한 출금 요청 구현 예시 코드

public interface ClientHandler {
    Response handle(Request request, ClientHandlerChain chain);
    int getOrder(); // 실행 순서
}

ClientHandler를 활용해 각 검증과 부가 처리에 해당하는 클래스를 구현할 수 있습니다.

// 1. 부서 검증 Handler
public class DepartmentValidationHandler implements ClientHandler {
    @Override
    public Response handle(Request request, ClientHandlerChain chain) {
        // 거래 가능한 부서인지 검증
        validateDepartment(request.getDepartmentId());

        // 다음 Handler 호출
        return chain.next(request);
    }

    @Override
    public int getOrder() {
        return 1;
    }
}

// 2. 직원 권한 검증 Handler
public class EmployeeAuthHandler implements ClientHandler {
    @Override
    public Response handle(Request request, ClientHandlerChain chain) {
        // 회계처리 권한 검증
        validateEmployeeAuth(request.getEmployeeId());

        return chain.next(request);
    }

    @Override
    public int getOrder() {
        return 2;
    }
}

// 3. 거래 증적 기록 Handler
public class TransactionRecordHandler implements ClientHandler {
    @Override
    public Response handle(Request request, ClientHandlerChain chain) {
        // 요청 전 증적 기록
        recordPreTransaction(request);

        Response response = chain.next(request);

        // 응답 후 증적 기록
        recordPostTransaction(response);
        return response;
    }

    @Override
    public int getOrder() {
        return 3;
    }
}

// 4. 실제 API 호출 Handler (라이브러리 내부)
public class CoreBankingClientHandler implements ClientHandler {
    @Override
    public Response handle(Request request, ClientHandlerChain chain) {
        // 실제 HTTP 호출
        return httpClient.call(request);
    }

    @Override
    public int getOrder() {
        return 100; // 가장 마지막에 실행
    }
}

각 Handler들은 getOrder()에 따라 체인처럼 자동 연결됩니다.

3-Chain-of-Responsibility.jpg
[그림 3] Chain of Responsibility 패턴으로 적용한 Handler 구조

이 방식의 장점은 분명합니다. 각 Handler는 한 가지 역할만을 담당하므로, 새로운 기능이 필요할 때 해당 역할에 맞는 Handler만 추가하면 됩니다. Handler의 등록은 코드 내 명시적으로 관리되어 일관성이 보장되고, 어떤 Handler가 등록되어 있는지 쉽게 파악할 수 있어 유지보수와 추적이 용이합니다.

이 구조 덕분에 라이브러리를 사용하는 프로젝트에서 공통적으로 필요한 핵심 로직과 프로젝트별 요구사항에 따른 부가 로직을 명확히 분리할 수 있었습니다. 또한 개발자는 라이브러리에서 제공하는 간단한 인터페이스만 구현하면 되기 때문에, Client 동작 방식이나 요청·응답 포맷을 낱낱이 뜯어보지 않고도 바로 통신 기능을 사용할 수 있습니다. 이로 인해 유지보수 시에도 각 Handler만 독립적으로 수정할 수 있어 개발 효율성이 크게 향상되고, 관리가 훨씬 수월해졌습니다.

공통 기능 3. 분산 트레이싱과 Baggage 활용

3-1. 문제 상황: 분산 시스템에서의 추적 문제

기존 코어뱅킹 시스템과 펀드 시스템이 분리되면서 하나의 고객 요청이 여러 시스템을 거쳐 처리되기 시작했습니다. 모바일 앱에서 시작된 요청이 펀드 채널계를 거쳐 펀드 계정계로 가고, 다시 코어뱅킹 시스템까지 이동합니다. 만약 이런 분산 환경에서 어느 한 곳에 문제가 생긴다면 어떻게 파악할까요? 분산된 시스템에서는 문제를 파악하기 위해 각 프로그램의 로그를 일일이 확인하는 것이 거의 불가능합니다. 로그만으로는 인과관계를 파악하기 어렵고, 실시간 요청이 많아지면 대량의 로그에서 의미 있는 정보를 찾기도 어렵습니다.

4-fund-system-error.jpg
[그림 4] 분산 환경에서 일어날 수 있는 문제

3-2. 해결 방법: 분산 트레이싱 라이브러리의 활용

이런 문제를 해결하기 위해 저희는 시중의 분산 트레이싱 라이브러리인 OpenTelemetry, Zipkin, Jaeger 등을 사용합니다. 이런 라이브러리들은 한 요청 전체를 식별하는 Trace ID와 각 호출 단위를 식별하는 Span ID를 부여해, 요청이 여러 시스템을 거쳐도 추적할 수 있게 해줍니다. 이런 정보들이 HTTP Header, gRPC metadata, Message Queue message headers 등의 transport를 통해 자동으로 전파되고, 개발자는 별다른 코드 없이도 시스템에서 일어나는 일들을 한눈에 파악할 수 있습니다. 분산 트레이싱에 대한 개념은 이번 글에서 다루지 않으니, AWS에서 발행한 분산 추적 관련 글을 참고해보시길 바랍니다.

3-3. 한 단계 더: Baggage를 활용한 추가 정보 전파

barcelona는 분산 트레이싱을 단순히 추적 용도로만 사용하지 않고, Baggage라는 개념을 활용해 분산된 프로그램 전역에서 필요한 정보를 편리하게 사용할 수 있도록 추상화 했습니다.

💡 Baggage란?

  1. 라이브러리가 자동 계측하는 정보 외에 요청과 함께 전달되어야 하는 추가 데이터
  2. HTTP Header, gRPC metadata, Message Queue message headers 등을 통해 시스템 간 전파되는 데이터

3-4. 사용 사례: 직원 정보 전파

앞서 직원이 고객 대신 펀드를 사는 거래를 할 때, 직원번호와 부서 정보를 검증해야 했습니다. 이러한 정보를 어떻게 받아올 수 있을까요? 일반적인 방법은 한 시스템에서 다른 시스템을 호출할 때마다 RequestResponse에 정보를 직접 포함하는 것입니다.

// 각 시스템마다 Request/Response에 정보를 직접 포함
public class FundRequest {
    private String employeeId;
    private String departmentId;
    private FundData fundData;
}

이렇게 하면 모든 계층의 객체에 비즈니스 로직과 관계없는 정보가 포함되어야 합니다. 이 문제를 해결하기 위해 저희는 분산 트레이싱 라이브러리의 Baggage를 활용했습니다. Baggage를 사용하면 요청과 함께 전달되어야 하는 추가 데이터가 자동으로 전파됩니다.

// 1. 맨 앞단(운영업무툴)에서 Baggage에 직원 정보 저장
Baggage.current()
    .toBuilder()
    .put("employeeId", "EMP001")
    .put("departmentId", "DEPT100")
    .build()
    .makeCurrent();

// 2. HTTP 요청 시 자동으로 Header에 포함되어 전파
// baggage: employeeId=EMP001,departmentId=DEPT100

// 3. 펀드 채널계, 계정계 등 어느 시스템에서든 꺼내서 사용
String employeeId = Baggage.current().get("employeeId");

하지만 저희는 여기서 한 단계 더 나아가, 개발자가 Baggage나 분산 트레이싱 같은 개념을 몰라도 쉽게 사용할 수 있도록 barcelona에서 추상화된 객체를 제공합니다.

// 라이브러리에서 제공하는 추상화된 객체
@Component
public class TracingContext {
    public String getEmployeeId() {
        return Baggage.current().get("employeeId");
    }

    public String getDepartmentId() {
        return Baggage.current().get("departmentId");
    }
}

// 사용하는 곳에서는 Baggage를 몰라도 됨
@Service
public class FundService {
    private final TracingContext tracingContext;

    public void buyFund() {
        String employeeId = tracingContext.getEmployeeId();
        // Baggage라는 개념조차 모르고 사용 가능
    }
}

이렇게 추상화하면 분산 트레이싱이나 Baggage 개념을 깊이 알지 못해도 손쉽게 활용할 수 있습니다. 프로그램 전역에서 동일한 방식으로 정보를 조회할 수 있으며, Baggage의 구현 방식이 변경되더라도 이를 사용하는 코드는 수정할 필요가 없습니다.

이로써 저희는 시중의 분산 트레이싱 라이브러리를 그대로 사용하는 데 그치지 않고, 한 단계 더 추상화된 인터페이스를 제공했습니다. 이를 통해 개발자는 복잡한 트레이싱 개념이나 설정에 신경 쓰지 않고 핵심 로직 구현에 집중할 수 있게 되었습니다. 또한, 부가적인 요청 정보가 필요할 때 추상화된 라이브러리에서 손쉽게 활용할 수 있도록 다양한 편의 기능도 제공했습니다.

3-5. barcelona의 다른 기능들

앞서 소개한 세 가지 기능 외에도 barcelona는 다양한 기능을 갖고 있습니다. 사내 규칙에 따라 개인정보를 자동으로 마스킹하는 기능, 종단 간 암호화를 지원하는 E2E 암호화 기능, 오류 탐지 및 알림 기능 등이 있습니다. 또한 코어뱅킹뿐만 아니라 여러 사내 API와 통신할 수 있는 Client 설정도 포함되어 있으며, 모두 일관된 방식으로 추상화되어 제공됩니다.

결과와 배운 점

barcelona를 도입한 후 프로젝트 초기 설정에 소요되는 시간이 크게 줄어 개발 생산성이 눈에 띄게 향상되었습니다. 예전에는 새 프로젝트를 구축할 때 공통 설정에만 적지 않은 시간을 써야 했는데, 이제는 비즈니스 로직 구현에 바로 집중할 수 있게 되었습니다.

// 이것만으로 모든 공통 기능 사용 가능
dependencies {
    implementation 'com.kakaobank:barcelona-spring-boot-starter'
}

실제로 펀드뿐만 아니라 투자 등 다양한 영역에서도 프로젝트 초기 구축 시 barcelona를 적극 사용하며 시간을 절약하고 있습니다.

개발자의 학습 곡선도 완화되었습니다. 내부 구현을 자세히 알지 못해도 FixedLength 전문을 객체로 처리할 수 있고, 사내 API와 통신할 수 있습니다. 또한 분산 트레이싱이라는 기술을 상세히 몰라도 개발에 필요한 context를 가져올 수 있습니다.

또한 코드베이스의 일관성이 확보되어, 모든 프로젝트가 동일한 방식으로 공통 기능을 사용하게 되었습니다. 그 결과 코드 리뷰와 유지보수가 훨씬 수월해졌고, 팀원 간 소통 비용도 줄었습니다. 누가 작성했든 코드가 비슷한 패턴을 따르기 때문에 이해하기도 훨씬 쉬워졌습니다.

이러한 변화는 저희 팀뿐만 아니라 카카오뱅크의 많은 서버 개발자들에게도 긍정적인 영향을 미쳤습니다. barcelona의 개발 편의성을 바탕으로, 금융 도메인의 요구사항을 분석하고 비즈니스 로직을 개발하는 데에 더욱 집중할 수 있게 되었습니다.

마치며

오픈소스 Spring을 활용해 펀드라는 핵심 금융원장 시스템을 새롭게 구축하는 일은 쉽지 않은 도전이었습니다. 하지만 공통 기능을 체계적으로 추상화한 barcelona 덕분에 복잡한 금융 도메인 로직에 더욱 집중할 수 있었고, 금융 개발도 일반 IT 개발처럼 한층 쉽고 재미있게 진행할 수 있었습니다. 저도 카카오뱅크에서 첫 커리어를 시작한 주니어 개발자로서, 빠르게 온보딩하여 실제 프로젝트에 기여할 수 있었습니다.

아마 팀 내에서 공통 양식이나 개발 표준을 도입하려는 분, 혹은 여러 프로젝트에서 반복되는 기능을 묶어 라이브러리로 관리하고자 고민하시는 분들이 많으실 것 같습니다. 이러한 분들께서는 공통 기능을 어떻게 효과적으로 추상화할지, 레거시 시스템을 어떻게 현대화할지, 그리고 도메인 특화 요구사항을 라이브러리에 어떻게 녹여낼지에 대해 많은 고민을 하실 텐데요.
카카오뱅크 투자/외환기술팀의 경험이 이러한 고민을 풀어가는 과정에서 작은 참고가 되었으면 합니다.