계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔

11 minute read

Published:

목차

  1. 작성 이유
  2. 계정 집계(Account Aggregation) 아키텍처
  3. 나의 코드는..🤔
  4. 더 적절한 자료구조는 없을까?🤔
  5. 자료구조의 책임은 어디까지일까?🤔
  6. 금융결제원(KFTC)

N+1기

계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
N+1은 왜 JPA에서 발생할까?🤔(JPQL)
N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔


작성 이유

블로그 스터디를 진행하던 중 “계좌가 많아질 경우, 어떻게 설계하는 게 좋을까요?”라는 질문을 받았다.

이에 일단 다음 두 가지 경우를 생각해냈다. 예를 들어, 현재 DB에 총 100개의 계좌가 존재한다고 가정해보자.

한 명의 사용자가 100개의 계좌를 보유한 경우

  • 특정 사용자 기준으로 조회되는 데이터의 양이 많다
  • 계좌 목록 조회, 정렬, 페이징이 중요해진다
  • 한 객체에 데이터가 몰릴 수 있다
  • 더 나아가, 적은 유저 수 + 유저당 압도적인 계좌 수(법인 고객)

100명의 사용자가 각각 1개의 계좌를 보유한 경우

  • 사용자 수는 많지만, 사용자 단위 조회는 가볍다
  • 사용자 → 계좌 매핑을 빠르게 찾는 것이 중요하다
  • 전체 사용자 대상 조회나 집계 시 부담이 발생할 수 있다
  • 더 나아가, 압도적인 유저 수 + 유저당 소수의 계좌 수(일반 사용자)

물론 이는 단순화한 경우의 수이며, 실제 서비스에서는 이 두 상황이 섞여 더 다양한 패턴이 존재할 수 있다. 하지만 “계좌가 많아진다”는 말을 조금 더 구체적으로 바라보는 출발점으로는 충분하다고 느꼈다.

그러나 답변하며 상황을 더 구체적으로 나눠보니 교착상태에 빠지게 되었다. 고민이 시작됐다. 계좌 존재 여부 확인에는 HashSet이 좋아 보이고 사용자 기준 접근에는 HashMap이 더 적합해 보인다.

그렇다면, 이 두 가지를 하나의 자료구조로 해결할 수는 없을까? 아니면, 역할에 따라 자료구조를 분리하는 것이 맞을까? 하나의 책임에 하나의 구조를 두는 게 더 나은 선택일까? 이러한 고민이 이어지면서 점점 미궁으로 빠져들었다.


계정 집계(Account Aggregation) 아키텍처

유럽에서는 법적으로 Open Banking 규격이 도입되면서, 계좌 정보를 API를 통해 안전하게 공유하는 구조가 보편화되었다. 이 과정에서 자연스럽게 등장한 것이 바로 ‘계정 집계(Account Aggregation)’라는 아키텍처 패턴이다. 계정 집계 서비스는 여러 금융기관에 흩어져 있는 계좌 정보를 한 곳에서 조회·관리할 수 있도록 하며, 이를 위해 표준화된 인터페이스와 데이터 모델을 적극적으로 활용한다. 단순히 계좌를 많이 다루기 위한 구조라기보다는, 계좌 수가 늘어나도 복잡도가 급격히 증가하지 않도록 하기 위한 설계 선택에 가깝다.

다음과 같은 구조로 중앙 허브 기관이 없고 각 은행이 직접 API(Open Banking API)를 제공한다. 계정 집계는 핀테크 서비스가 직접 수행하여 책임과 복잡성이 각 서비스로 분산된다.

Open Banking APIs

이 패턴의 핵심은 다음과 같다.

  • API 우선 설계
    • 계좌를 내부 객체가 아닌 외부에서도 접근 가능한 리소스로 정의하고, 조회 단위와 책임을 명확히 한다.
  • 표준 데이터 모델 채택
    • ISO 20022와 같은 국제 표준을 사용해 계좌 구조를 통일함으로써, 계좌 수나 금융기관이 늘어나도 동일한 방식으로 처리할 수 있다.
  • 사용자 동의 기반 접근 제어
    • 모든 계좌 접근은 사용자 동의를 전제로 하며, 권한 범위를 명확히 제한한다.

이러한 접근 덕분에 유럽의 금융 시스템에서는 계좌 수의 증가를 ‘자료구조 문제’가 아닌 ‘설계와 책임 분리의 문제’로 다룬다. 결과적으로 계좌가 많아질수록 복잡해지는 것이 아니라, 오히려 확장에 유리한 구조를 유지할 수 있다.

은행 A ─┐
은행 B ─┼→ Account Aggregator (핀테크) → 사용자
은행 C ─┘

유럽(Open Banking, PSD2)의 핵심 철학

다음과 같은 이유로 계정 집계는 개념만 표준화되고, 구현은 분산되었다.

경쟁과 혁신 중심

특정 기관에 데이터가 집중되는 것을 피한다. 핀테크가 은행과 직접 연결해 차별화된 서비스를 만들 수 있도록 허용한다.

국가·은행 구조의 다양성

EU는 다국가·다은행이므로 환경 하나의 중앙 허브를 만들고 운영하기가 현실적으로 어렵다.

규제는 하되, 구현은 자율

규제 당국은 “API를 열어라”까지만 강제하고 누가 집계할지는 시장에 맡긴다.

유럽형 서드파티

유럽(Open Banking, PSD2)에서는 서드파티가 반드시 존재한다.

  • TPP (Third Party Provider)
  • AISP (Account Information Service Provider)
  • PISP (Payment Initiation Service Provider)

서드파티 역할

  • 은행과 직접 API로 통신
  • 계좌 데이터를 자신이 직접 집계
  • 중앙 허브에 의존하지 않음
  • 집계 책임을 서비스가 감당

기술적 팁

해시맵이나 해시셋과 같은 자료구조는 메모리 기반에서 매우 빠르지만, 계좌 정보처럼 영속적으로 저장되어야 하고 지속적으로 조회되는 데이터를 다루기에는 한계가 있다. 계좌 수와 트래픽이 증가하는 상황을 고려한다면, 결국 RDBMS를 중심으로 인덱스와 캐시 계층을 함께 설계하는 구조가 필수적이다.

이러한 구조에서는 도메인 주도 설계(DDD)가 특히 효과적이다. User, Account, Transaction과 같은 핵심 개념의 경계를 명확히 나누고, 각 도메인 서비스가 자신의 책임에만 집중하도록 하면, 계좌 수가 늘어나더라도 도메인 모델이 불필요하게 비대해지는 것을 막을 수 있다. 또한 계좌 정보를 외부에 제공하거나 내부 서비스 간에 조회해야 한다면, API 설계 역시 표준화된 방식을 따르는 것이 중요하다.

/users/{userId}/accounts/{accountId}와 같은 RESTful한 리소스 경로를 기준으로, 페이지네이션, 필터링, 버저닝을 함께 고려하면 계좌 수가 증가해도 안정적인 조회와 확장이 가능해진다.

확장성 높은 기본 설계 구조

요구사항구조적 대응
적은 유저 × 많은 계좌사용자당 계좌 리스트 → 분산/파티셔닝 고려
많은 유저 × 계좌 소수유저-계좌 인덱스 기반 접근
계좌 정보 API 제공RESTful + Pagination + 필터링
표준 준수 필요ISO 20022 / Open Banking API 적용

나의 코드는..🤔

엔티티 간 연관관계(Entity Association)

Account는 User를 참조하는 단방향 연관관계를 가진다. Account 내부에 User 필드가 존재하며, User는 Account를 전혀 알지 못한다. 또한 여러 Account가 하나의 User를 참조할 수 있으므로, 이 관계는 교과서적인 단방향 Many-to-One 연관관계에 해당한다.

Entity Association

User가 여러 Account를 가지니까 One-to-Many 아닌가?🤔

User가 여러 Account를 가지니까 One-to-Many 아닌가란 생각이 들 수도 있다. 이는 개념적으로 보면 맞는 말이다. 하지만 JPA와 객체 모델에서 연관관계를 정의할 때의 기준은 다르다.

객체 연관관계는 항상 다음 두 가지를 기준으로 판단한다.

  • 누가 누구를 참조하고 있는가
  • 어디에 참조 필드가 존재하는가 이 기준으로 다시 보면, User는 Account를 전혀 참조하지 않고 Account만이 User를 참조하고 있다. 따라서 이 관계는 User 기준의 One-to-Many가 아니라, Account 기준의 Many-to-One 연관관계가 된다.

User와 Account의 존재 여부 검증

계좌 생성 흐름을 보면, 가장 먼저 다음 두 가지를 검증한다.

  • User가 존재하는지
  • 계좌 번호가 이미 존재하는지
// User가 DB에 있는지 검증
User user = userRepository.findById(userId).orElseThrow(
        () -> new CustomApiException("유저를 찾을 수 없습니다.")
);

// 해당 계좌가 DB에 있는 중복 여부를 체크
Optional<Account> accountOptional = accountRepository.findByNumber(accountSaveReqDto.getNumber());
if (accountOptional.isPresent()) {
  throw new CustomApiException("해당 계좌가 이미 존재합니다.");
}

이 검증은 모두 Repository를 통해 DB 조회를 기반으로 수행된다. 즉, ‘존재 여부’는 컬렉션의 문제라기보다는 영속성 계층의 책임이다. 계좌 존재 여부를 빠르게 확인하기 위해 굳이 메모리 자료구조(HashSet)를 도입하지 않아도 DB와 인덱스가 이미 그 역할을 수행하고 있다.

Account의 계좌의 행위와 검증

계좌의 주인인지, 비밀번호가 맞는지, 잔액이 충분한지와 같은 검증은 모두 Account 내부에서 수행된다.

public void checkOwner(Long userId) {
  if (user.getId().longValue() != userId.longValue()) {
    throw new CustomApiException("계좌 소유자가 아닙니다.");
  }
}

public void checkSamePassword(Long password) {
  if (this.password.longValue() != password.longValue()) {
    throw new CustomApiException("계좌 비밀번호 검증에 실패했습니다.");
  }
}

public void checkBalance(Long amount) {
  if (this.balance < amount) {
    throw new CustomApiException("계좌 잔액이 부족합니다.");
  }
}

계좌의 상태(balance, owner, password)를 가장 잘 아는 객체는 Account이다. 외부에서 if 문으로 검증하는 대신, 행위와 검증을 함께 둔다. 결과적으로 도메인 로직이 흩어지지 않는다. 즉, “이 계좌로 이 행위를 해도 되는가?”라는 질문의 책임은 항상 Account 자신에게 있다.

User의 계좌 목록

사용자의 계좌 목록을 조회하는 로직은 다음과 같다. 여기서 계좌는 List 형태로 반환된다.

public AccountListRespDTO findAccountsByUser(Long userId) {
  // User가 DB에 있는지 검증
  User user = userRepository.findById(userId).orElseThrow(
          () -> new CustomApiException("유저를 찾을 수 없습니다.")
  );

  // 유저의 모든 계좌목록
  List<Account> accountList = accountRepository.findByUserId(userId);

  return new AccountListRespDTO(user, accountList);
}

사용자 계좌는 목록 조회 대상이다. 순서, 개수, 반복 처리, 페이징이 중요하며 이미 DB에서 중복 여부를 판단하기에 이 시점에서 중복 여부를 판단할 필요는 없다. 따라서 계좌 컬렉션은 ‘존재 여부 검증용’이 아니라 ‘표현과 조회용’이다.

HashSet + HashMap은 적절한가?🤔

질문을 받았을 당시에는 다음과 같은 고민을 했다. 계좌 수가 많아질수록 존재 여부 확인과 사용자별 접근을 빠르게 처리해야 한다고 생각했기 때문이다.

  • 계좌 존재 여부 → HashSet
  • 사용자별 계좌 접근 → HashMap<User, Accounts>

하지만 코드를 다시 살펴보며 생각이 정리되었다. 이 시스템에서 계좌의 존재 여부와 사용자–계좌 매핑 관계는 이미 DB와 Repository가 책임지고 있었다. 그리고 도메인 계층에서는 역할이 비교적 명확하게 나뉘어 있었다. Account는 자신의 상태와 행위를 책임지고 Service는 전체 흐름을 조율하며 List는 단순히 “조회 결과”를 표현한다. 즉, 현재 프로젝트의 설계 초점은 자료구조의 선택이 아니라 책임을 어디에 둘 것인가에 맞춰져 있었다.

계좌가 많아질 경우를 고민하며 자료구조부터 떠올렸지만, 정작 코드 안에는 이미 어느 정도의 답이 정리되어 있었다.

  • 존재 여부 → DB / Repository
  • 소유자/비밀번호/잔액 검증 → Account
  • 사용자 계좌 목록 → List

결국 중요한 것은 어떤 자료구조를 쓰느냐보다, 역할과 책임의 경계를 명확히 나누는 일일지도 모른다.


더 적절한 자료구조는 없을까?🤔

앞선 구현에서는 계좌 존재 여부는 DB 조회로 검증하고 사용자 계좌 목록은 List로 조회하며 계좌의 행위와 검증은 Account 도메인에 위임했다. 그렇다고 해서 "이 설계에서는 자료구조를 전혀 고민할 필요가 없다"는 뜻은 아니다. 다른 자료구조를 생각해보자.

Map<UserId, List> 구조

가장 먼저 떠올릴 수 있는 대안은 사용자 기준 접근을 명확히 드러내는 구조다.

Map<String, List<Account>> userAccounts;

장점

  • 특정 사용자 계좌 조회가 빠르다​
  • [사용자 → 계좌들] 관계가 명확하다
  • 캐시 계층(Redis 등)과 결합하기 좋다

단점

  • 계좌 추가/삭제 시 동기화 비용 발생
  • Account 단위 조회(계좌 번호 기준)가 불편
  • 결국 DB 상태와 이중 관리 문제가 생길 수 있다

그러나 이 구조는 메인 저장소라기보다는 캐시나 읽기 전용 뷰에 가까운 역할에 더 적합해 보인다.

Set + Map 조합 구조

초기에 떠올렸던 고민으로 다시 생각해보자.

  • 계좌 존재 여부 → Set
  • 사용자 접근 → Map
Set<String> accountNumbers;
Map<Long, List<Account>> userAccounts;

이 구조는 기능적으로는 가능하다. 하지만 곧 문제가 드러난다.

  • 데이터 중복 관리
  • 동기화 실패 가능성
  • 계좌 삭제 시 두 군데 모두 수정 필요

즉, 자료구조를 합친 것이 아니라 복잡성을 늘린 것에 가깝다.


자료구조의 책임은 어디까지일까?🤔

여기까지 탐색하며 점점 명확해진 사실은 이것이다. 계좌 수가 많아질 때의 문제는 메모리 자료구조의 문제가 아니라 조회 패턴과 책임 분리의 문제에 가깝다.

  • 존재 여부 → DB + Unique Index
  • 사용자별 조회 → Repository 쿼리
  • 도메인 규칙 → Account

이 구조는 데이터가 많아질수록 오히려 더 안정적으로 동작한다.

그럼에도 불구하고 자료구조를 바꿔야 하는 순간은 언제일까?

다음과 같은 경우라면, 지금의 선택을 다시 고민해볼 수 있다.

  • DB 조회가 병목이 되는 경우
  • 계좌 조회 트래픽이 폭증하는 경우
  • 읽기 전용 데이터가 압도적으로 많은 경우

이때는

  • In-memory Cach
  • Read Model (CQRS)
  • Redis Hash / Set 같은 보조 자료구조가 의미를 갖기 시작한다.

금융결제원(KFTC)

계정 집계(Account Aggregation) 서비스는 유럽뿐만 아니라 우리나라에서도 이미 사용되고 있는 구조다. 한국에서는 이 역할을 “금융결제원(KFTC, Korea Financial Telecommunications and Clearing Institute)”이 수행하고 있다.

KFTC 대한민국 금융위원회의 오픈뱅킹(Open Banking) 정의는 은행의 금융결제 시스템을 핀테크 기업에 개방하는 것을 의미한다. 1986년에 설립된 금융결제원(KFTC)은 국내 소액결제망을 운영하며 오픈뱅킹 모델 설계를 담당했다. 이 모델은 금융결제원이 핀테크 기업과 은행을 위한 데이터 집중기관(Data Aggregator) 역할을 수행하는 통합 플랫폼으로 구성된다.

한국형 오픈뱅킹 구조

한국의 오픈뱅킹 모델은 금융결제원을 중심으로 한 통합 플랫폼 구조로 구성된다. 금융결제원은 모든 은행의 계좌 정보를 한데 모아 표준화된 API로 제공하는 중앙 허브(Hub) 역할을 수행한다. 핀테크 기업은 개별 은행과 각각 연동할 필요 없이, 금융결제원 한 곳에만 연결하면 모든 은행의 계좌 데이터에 접근할 수 있다. 즉, 금융결제원은 한국형 오픈뱅킹에서 중추적인 데이터 집합소(Centralized Account Aggregator)로 기능한다.

KFTC 등장 이유

일반적인 계정 집계 모델과 달리, 한국에서는 금융결제원이 중앙에 위치한 구조를 선택했다. 이는 다음과 같은 장점 때문이다.

  • 표준화
    • 모든 은행이 동일한 데이터 양식(ISO 20022 기반 등)으로 정보를 제공한다.
    • 그 결과, 계좌 수나 은행 수가 늘어나더라도 데이터 구조의 복잡도가 증가하지 않는다.
  • 보안 및 통제
    • 핀테크 기업이 개별 은행에 직접 접근하는 구조보다, 공신력 있는 기관인 금융결제원을 경유하는 방식이 보안과 리스크 관리 측면에서 훨씬 안정적이다.
  • 효율성
    • 수많은 은행 계좌를 각각 연동·관리하는 비용을 줄이고, 계좌 집계에 필요한 중복 작업을 획기적으로 감소시킨다.

이 구조는 계좌 집계를 단순한 기능이 아니라, 국가 단위 금융 인프라 문제로 다루었기 때문에 가능한 선택이다.

인프라 vs 서비스

이 개념을 이해할 때 한 가지 구분해야 할 점이 있다.

금융결제원(인프라형 어그리게이터)

  • 모든 은행 데이터를 표준화된 API로 묶는 기술적 허브이다.
  • 소비자의 눈에는 보이지 않는다.
  • 금융 시스템의 안정성과 확장성을 책임진다.

핀테크 앱(서비스형 어그리게이터)

  • 금융결제원이라는 ‘중앙 허브’에서 데이터를 받아와서 사용자에게 보여준다.
  • e.g. 토스, 카카오페이, 뱅크샐러드처럼 소비자가 실제로 사용하는 앱들

참고

Gemini
ChatGPT
RESTful 웹 API 디자인에 대한 모범 사례
Open Banking API: How It Works, Use Cases & Selection Guide
Open Banking APIs: All you need to know
API standards for data-sharing (account aggregator)