뱅크샐러드가 게임을 만들 때 데이터 정합성을 유지하는 법 (feat. 낙관적 락)

뱅크샐러드가 게임을 만들 때 데이터 정합성을 유지하는 법 (feat. 낙관적 락)


안녕하세요 금융쇼핑 PA 백앤드 개발자 배지원입니다.

최근 뱅크샐러드는 단순한 자산 관리를 넘어, 유저에게 즐거운 금융 경험을 제공하기 위해 게이미피케이션 요소를 적극적으로 도입하고 있습니다. 오늘 소개해 드릴 일해라 김뱅샐은 뱅크샐러드 유저들이 캐릭터를 육성하며 즐겁게 자산을 관리할 수 있도록 돕는 대표적인 방치형 앱테크 게임입니다.

오늘은 금융 플랫폼 내에서 앱테크 서비스를 구현하며 마주했던 데이터 정합성 에 대한 고민, 그리고 이를 해결하기 위해 채택한 재시도 로직 없는 낙관적 락 설계에 대해 공유하고자 합니다.




일해라 김뱅샐: 금융 경험을 확장하는 앱테크 서비스


일해라 김뱅샐은 유저가 회사원 김뱅샐을 승진시키며 즐거움을 느끼고, 그 과정에서 실질적인 혜택을 얻는 서비스입니다. 유저는 에너지를 관리하며 책상의 쓰레기를 치우거나 스킬을 사용하여 게임 내 재화인 샐(SAL)을 획득합니다.

이때 획득하는 샐(SAL) 은 단순한 게임 재화가 아니라, 실제 원화(KRW)로 전환 가능한 실질적인 자산입니다. 따라서 일해라김뱅샐 팀은 게임이 주는 즐거움은 유지하면서도, 금융 서비스 도메인에 걸맞게 유저의 자산이 정확하게 계산될 수 있는 서비스 설계에 집중했습니다.


01
일해라 김뱅샐 메인 화면



02
일해라 김뱅샐의 서비스 사이클






우리는 왜 동시성 제어에 신경을 써야 했나


‘일해라 김뱅샐’ 의 핵심 데이터인 character_state(레벨, SAL, 에너지 등)는 다양한 경로를 통해 실시간으로 업데이트됩니다. 시스템을 설계하며 저희가 정의한 데이터 수정 트리거는 다음과 같습니다.

  • 유저의 직접적인 액션(API): 에너지충전, 레벨업, 쓰레기 치우기, 스킬 사용 등 유저 활동에 따른 즉각적인 상태 변경.
  • 운영 변수(Backoffice): CS 처리나 이벤트 보상 지급 등을 위해 운영자가 백오피스에서 유저의 잔액을 수동으로 조정하는 경우.
  • 예방적 설계: 이후에 생길 수 있는 배치처리, 비동기적인 이벤트에 따른 상태 업데이트 상황 등







데이터 정합성이 무너진다는 것의 의미


이런 레이스 컨디션(Race Condition)은 단순히 데이터가 꼬이는 문제를 넘어, 서비스 전반에 기술적 부채와 운영 리스크를 남깁니다. 내가 열심히 스킬을 쓰고 쓰레기를 치웠는데 정작 보상이 제대로 들어오지 않는다면, 유저는 금세 서비스에 대한 신뢰를 잃게 될 것입니다. 특히 현금성 재화가 오가는 시스템에서 데이터 정합성은 결코 타협할 수 없는 기본 중의 기본입니다. 결국 저희 팀에게 동시성 제어는 단순한 성능 최적화가 아니라, 유저의 소중한 활동 내역을 데이터로 온전하게 지켜내기 위한 필수적인 안전장치였습니다.





어떤 방식을 고려했고, 왜 ‘낙관적 락’이었나요?

우리는 뱅크샐러드의 MSA 환경에서 선택 가능한 세 가지 주요 잠금 방식을 검토했습니다.


후보 1: 비관적 락 (Pessimistic Lock)

DB 레벨에서 SELECT ... FOR UPDATE를 통해 행을 선점하는 방식입니다.

  • 특징: 트랜잭션이 끝날 때까지 타 프로세스의 접근을 원천 차단합니다.
  • 장점: 정합성이 매우 중요하고 충돌이 빈번할 때 확실한 대안입니다.
  • 단점: 잠금을 획득하기 위한 대기가 발생하여 대규모 트래픽 상황에서 DB 커넥션 병목이 생길 수 있습니다.

후보 2: 분산 락 (Distributed Lock)

Redis(Redlock)나 ZooKeeper 같은 외부 인프라를 활용하여 서버 인스턴스 간 자원 접근을 제어합니다.

  • 특징: DB 밖의 저장소에 ‘잠금 권한’을 기록합니다.
  • 장점: 여러 DB에 걸쳐 있거나 외부 API와 연동된 복잡한 동기화에 유리합니다.
  • 단점: Redis 통신 오버헤드와 별도 인프라 운영 비용이 발생합니다.

후보 3: 낙관적 락 (Optimistic Lock)

버전 번호(update_version)를 통해 수정 시점에 데이터가 변했는지 확인합니다.

  • 특징: 실제 업데이트 쿼리 시점에 WHERE 조건으로 버전 일치 여부를 검사합니다.
  • 장점: DB 잠금 시간이 거의 없어 성능이 최상이며, 데드락 위험이 없습니다.
  • 단점: 충돌 시 애플리케이션 레벨에서 에러를 처리해야 합니다.




우리의 최종 선택: “낙관적 락”

‘일해라 김뱅샐’ 팀은 수많은 방식 중 낙관적 락을 최종 선택했습니다. 그 이유는 서비스의 구체적인 ‘데이터 액세스 패턴’과 ‘도메인 특성’에 있었습니다.


1. 낮은 충돌 확률

  • ‘일해라 김뱅샐’ 의 캐릭터 데이터는 각 유저별로 격리된 구조입니다. 특정 유저의 데이터를 수많은 타인이 동시에 수정할 일이 없으며, 주로 유저 본인의 요청과 운영자의 간헐적인 수정이 겹치는 정도입니다. 이러한 환경에서 성능 오버헤드가 큰 비관적 락이나 분산 락은 오버 엔지니어링이라고 판단했습니다.

2. 인프라 복잡도 최소화

별도의 Redis 인프라를 구축하고 관리해야 하는 분산 락에 비해, 낙관적 락은 기존 DB 스키마에 update_version 컬럼 하나를 추가하는 것만으로 구현이 가능합니다. 시스템 복잡도를 낮게 유지하면서도 정합성을 챙길 수 있는 가장 효율적인 방법이었습니다.


3. 게임다운 ‘매끄러운 경험’을 위한 반응성 확보

게임에서 가장 중요한 것은 유저의 흐름이 끊기지 않는 것입니다. 수많은 유저가 동시에 접속하더라도, DB Lock 대기 때문에 화면이 멈칫하거나 반응이 늦어지는 일은 없어야 한다고 생각했습니다. 보상을 획득하는 즐거운 순간에 유저의 경험을 해치지 않도록, 낙관적 락을 통해 반응성을 유지하며 유저가 캐릭터의 성장에만 몰입할 수 있는 환경을 만들고 싶었습니다.


4. 복잡한 ‘재시도’ 로직이 필요 없는 서비스 구조

낙관적 락 도입을 망설이게 하는 가장 큰 요인은 보통 ‘충돌 시 어떻게 처리 할 것인가’ 에 대한 결정과 복잡도 관리입니다. 자칫하면 코드가 복잡해지고, 관리포인트가 늘어나기 때문이죠. 하지만 ‘일해라 김뱅샐’은 서비스 특성상 이러한 부담이 매우 낮았습니다.


1. 시간 차분(Time-delta)을 활용한 보상 이월 구조

유저가 벌어들인 수익 계산 로직은 개별 클릭 이벤트에 의존하는 ‘이벤트 적립’ 방식이 아니라, 마지막 업데이트 시점현재 시점 사이의 시간 간격(Time-delta)을 계산해 보상을 합산하는 상태 기반 정산 방식을 사용합니다.


💡 Reward=(TcurrentTlast_update)×Rateper_second\text{Reward} = (T_{\text{current}} - T_{\text{last\_update}}) \times \text{Rate}_{\text{per\_second}}



  • 보상의 자동 이월: 특정 요청이 동시성 충돌로 실패하더라도 유저의 수익은 누락되지 않습니다. DB의 업데이트 시점이 갱신되지 않았으므로, 다음 액션이 성공하는 시점에 실패했던 기간까지 포함하여 한꺼번에 정산되기 때문입니다.
  • 실패의 재정의: 여기서 실패는 데이터의 유실이 아니라, 단지 ‘보상 확정 시점의 일시적인 이월’ 을 의미합니다. 유저는 손해를 보지 않고, 서버는 복잡한 재시도 처리를 할 필요가 없어집니다.

2. 자기 수정(Self-correcting) 아키텍처를 통한 리소스 최적화

모든 요청을 성공시키기 위해 서버에서 복잡한 재시도 루프를 돌리는 것은 DB 커넥션과 CPU 자원 낭비일 수 있습니다. 저희는 이를 억지로 해결하기보다 시스템이 스스로 수렴하도록 설계했습니다.

  • 자연스러운 상태 수렴: 특정 업데이트가 실패하더라도, 이어지는 유저의 다음 액션이나 시스템 이벤트가 항상 최신 상태를 기준으로 다시 동작합니다.
  • 결과적 일관성: 별도의 보정 로직 없이도 시스템은 결국 최신 정산 상태로 자기 수정(Self-correcting) 하며 수렴하게 됩니다. 이는 분산 환경에서 시스템의 복잡도를 낮추면서도 높은 신뢰도를 유지하는 핵심 전략입니다.



결론

결국 저희는 복잡한 락 메커니즘을 도입하기보다, 도메인의 흐름을 이용해 기술적 문제를 우회한

“가장 단순하면서도 서비스 특성에 딱 맞는” 낙관적 락을 통해 성능과 정합성을 보장할 수 있도록했습니다.




어떻게 구현했나요?

우리는 DB 스키마에 update_version 이라는 필드를 추가하고, 애플리케이션 레이어에서 이를 검증하도록 구현했습니다.


1. Lock 의 범위를 최소화하는 스키마 설계

데이터베이스의 I/O 효율을 높이고 Lock 범위를 좁히기 위해 정적 데이터(character)와 동적 데이터(character_state)를 분리했습니다. 이는 단순히 테이블 수의 증가가 아니라, 인덱스 리프 노드의 경합을 줄이는 설계적 장치입니다.

// 업데이트가 빈번한 데이터들을 분리
CREATE TABLE `character_state` (
  `character_state_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '캐릭터 상태 ID',
  `character_id` BIGINT NOT NULL,
  `current_sal` BIGINT NOT NULL COMMENT '경험치이자 자산인 SAL',
  `current_energy` BIGINT NOT NULL COMMENT '캐릭터가 돈을 벌어들일 수 있는 에너지(초 단위)',
  `update_version` BIGINT NOT NULL COMMENT '동시성 제어를 위한 버전 필드',
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`character_state_id`),
  UNIQUE KEY `idx_character_u1` (`character_id`)
) ENGINE = InnoDB;

2. 원자적 CAS(Compare-And-Swap) 구현

func (u *stateUpdater) UpdateState(ctx context.Context, exec boil.ContextExecutor, characterID int64, updateVersion int64, state *State) (int64, error) {
    newUpdateVersion := updateVersion + 1
    // ... 업데이트 필드 설정 ...

    numUpdated, err := mysql.CharacterStates(
        mysql.CharacterStateWhere.CharacterID.EQ(characterID),
        mysql.CharacterStateWhere.UpdateVersion.EQ(updateVersion), // 버전 체크!
    ).UpdateAll(ctx, exec, m)

    if numUpdated == 0 {
        return 0, ErrConcurrentStateUpdate // 충돌 감지
    }
    return newUpdateVersion, nil
}

🧐 [Tip] updated_at을 버전 필드로 쓰면 안 될까? 실무에서는 반드시 정수형 버전 필드를 따로 써야 합니다.

시간 정밀도의 한계: DB 타임스탬프가 밀리초(ms) 단위일 경우, 동일 밀리초 내 두 번의 업데이트가 발생하면 충돌을 감지하지 못합니다.

클럭 스큐(Clock Skew): 분산 환경의 서버 간 미세한 시간 차이는 논리적 선후 관계를 보장하기 어렵게 만듭니다.




실전 운영 가이드

낙관적 락은 구현이 간단해 보이지만, 실제 분산 환경에서 운영하다 보면 예상치 못한 데이터 정합성 이슈나 성능 저하를 마주하게 됩니다. ‘일해라 김뱅샐’ 서비스의 안정성을 담보하기 위해 반드시 고려해야 할 실무 포인트들을 정리했습니다.


1) 복제 지연(Replication Lag)과 Stale Read 대응

분산 DB 아키텍처에서 Master(Primary)의 데이터 업데이트가 Replica(Secondary)로 전파되는 데에는 불가피한 시차가 발생합니다. 만약 낡은 데이터(Stale Data)를 읽어 수정을 시도하면 버전 충돌률이 급격히 높아집니다.

  • Consistent Read 강제

    자산 업데이트나 상태 변경처럼 정합성이 크리티컬한 경로에서는 반드시 Primary DB 조회를 강제하여 최신 버전을 읽어야 합니다.

  • 이벤트 대조 핸들러

    이벤트 기반 아키텍처라면 메시지에 포함된 버전과 DB 현재 버전을 비교하고, DB가 아직 업데이트 전이라면 전파가 완료될 때까지 짧은 대기 후 다시 시도하는 핸들러 설계가 필요합니다.



2) 재시도 전략: 충돌은 ‘에러’가 아닌 ‘제어 흐름’

낙관적 락에서 충돌(Conflict)은 시스템 장애가 아닌 정상적인 제어 흐름의 일부입니다. 따라서 모든 충돌에 무조건적인 재시도를 수행하기보다, 비즈니스 성격에 맞는 정교한 재시도 전략이 필요합니다.

💡 재시도 구현 시 검토해야 할 4원칙

  • Fresh Read의 원칙 (최신 데이터 재조회)

    재시도 루프 내부에서는 반드시 Primary DB에서 최신 버전 정보를 다시 읽어와야 합니다. 메모리에 로드되어 있던 기존 객체를 그대로 들고 재시도하는 것은 무의미한 연쇄 충돌을 야기하고 CPU 자원을 낭비할 뿐입니다.

  • 지수 백오프와 지터

    충돌 직후 즉시 재시도하면 또다시 충돌할 확률이 매우 높습니다. 재시도 간격을 2n2^n 형태로 늘리는 지수 백오프를 적용하고, 여기에 무작위 값인 지터(Jitter)를 섞어 요청 시점을 분산시켜야 합니다. 이는 수많은 요청이 한꺼번에 몰리는 현상을 방지하여 DB의 병목을 막아주는 핵심 기술입니다.

  • 멱등성(Idempotency) 보장

    서버 내부의 재시도가 중복 처리되거나, 네트워크 이슈로 응답을 못 받은 클라이언트가 재요청을 보낼 위험이 있습니다. 클라이언트가 생성한 request_id를 기반으로 멱등성 테이블을 운용하여, 동일 요청에 대해서는 실제 DB 쓰기 없이 이전의 성공 결과를 반환하는 안전장치가 병행되어야 합니다.

  • 최대 재시도 횟수 제한

    낙관적 락의 재시도는 무한정 이루어져서는 안 됩니다. 일정 횟수 이상 충돌이 반복된다면 이는 시스템의 심각한 경합 상황으로 판단해야 합니다. 이때는 사용자에게 명확한 에러를 반환하거나, 비즈니스 로직에 따라 비관적 락 등으로의 전환을 검토해야 하는 신호입니다.





마치며

지금까지 일해라 김뱅샐 팀이 데이터 무결성을 위해 낙관적 락을 도입한 배경과 실무적인 구현 과정을 살펴보았습니다.

실제 서비스 운영 이후, 다양한 환경의 유저들로부터 수많은 요청이 발생했지만 데이터 정합성 이슈는 단 0건이었습니다. 유저들은 게임의 재미에 몰입하면서도 자신의 활동에 따른 보상을 1원(SAL)의 오차도 없이 정확하게 받아보고있습니다.

비즈니스 요구사항은 언제나 변합니다. 저희 팀은 일해라 김뱅샐 의 성장 가능성과 범위가 무한하다는 것을 알기에 항상 ‘그때는 맞고, 지금은 틀리다’ 라는 마음가짐으로 지금 기획에 가장 최적화된 구현을 하지만 나중에 수정된 요구사항에도 유연하게 대응할 수 있는 구조를 고민하고있습니다.




보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기

Featured Posts

post preview
post preview
post preview
post preview
post preview

Related Posts