본문 바로가기
Study for./My thoughts

4년 전 포인트 시스템이 나에게 남긴 것: Slack 장애 알림 주인공이 되지 않는 날이 되기까지

by harrykim 2026. 3. 6.

 

프롬프트: 먼 길을 가는 사람을 표현한 사진 만들어줘, 저작권에 걸리지 않게

 

 

내가 동시성 문제를 두 번 겪은 이야기 — 포인트 시스템의 트라우마가 이커머스 프로젝트에서 약이 된 건에 대하여

 

나는 동시성이라는 단어를 들으면 명치가 살짝 아프다. 비유가 아니다. 진짜 아프다. 진짜다

4년 전, 회사에서 포인트 관련 API를 처음부터 만들 일이 있었다. 이미 테이블 구조는 정해져 있었고, 여기저기 쿼리 형태로 흩어져 있던 포인트 관련 기능을 하나의 API로 모으면 되는 과제였다. 돌이켜 보면 그 과제의 무게를 나는 전혀 인식하지 못했다. 그때까지 기존 시스템에 기능을 얹는 작업만 해왔던 나에게는, 그냥 API 엔드포인트를 하나 만드는 감각이었다. 기존 기능을 목록으로 정리하고, 하나씩 구현하고, 운영에 올렸다. 동료들의 관심 속에서, 모든 것이 순조로워 보였다.

문제는 모니터링을 할 생각조차 하지 않았다는 거다.




그 시절의 포인트 시스템

 포인트 시스템의 구조는 이랬다. 구매 원장과 사용 이력 원장, 두 개의 테이블이 있었다. 구매 원장이 잔액과 구매 관련 내용을 관리하고, 사용 이력 원장이 해당 포인트에 대한 사용 처리를 기록했다. 포인트는 구매 단위별로 별도의 키 값으로 관리되었는데, 그 이유가 제휴나 이벤트 등 다양한 경로로 지급된 포인트의 성격을 구분해야 했기 때문이다. 그래서 같은 유저가, 같은 이유로, 같은 포인트에 동시에 접근하는 상황이 생겼다.

 유저가 클라이언트에서 버튼을 여러 번 누르는 경우가 있었다. 네트워크 지연으로 재시도된 요청이 동시에 커밋되는 경우가 있었다. 그렇게 Lost Update가 발생했다. Lost Update란, 두 개의 요청이 동시에 같은 데이터를 읽고, 각자 수정한 뒤 저장하면서 먼저 저장한 쪽의 변경이 사라지는 현상이다. 구 원장 테이블을 들여다보다가 알게 된 적도 있고, 로그를 분석하다가 발견한 적도 있었다. 결제 시스템의 로그를 통해 이중 출금 사실을 뒤늦게 확인한 적도 있었다. 분산 트랜잭션의 원자성이 보장되지 않아서 고객에게 보상 처리를 해드린 사안도 있었다.

 처음에는 `@Transactional`의 탓을 했다. `@Transactional`은 스프링 프레임워크에서 "이 메서드 안에서 일어나는 데이터베이스 작업을 하나의 묶음으로 처리하라"고 선언하는 어노테이션이다. 뒤에 `rollbackFor = Exception.class` 옵션을 붙이면 해결되는 줄 알고, 모든 서비스 클래스에 무분별하게 사용한 적도 있었다. 당연히 해결되지 않았다. 트랜잭션은 "하나의 묶음 안에서 전부 성공하거나 전부 실패하게 해주는 것"이지, "같은 데이터에 동시에 접근하는 여러 요청을 순서대로 처리해주는 것"이 아니기 때문이다.

 그때 팀의 한 분이 다른 시스템에서 쓰던 로그 관리 로직을 슬쩍 넣어두셨던 게 있었는데, 그 로그 덕분에 이슈를 추적할 수 있었다. 혼자서는 발견조차 못했을 문제들이었다.

 4년간 포인트 사용, 만료, 이전, 취소 등 모든 과정에서 트랜잭션에 대한 고민을 했다. 언제, 누가, 어떻게, 어디서를 정말 중요하게 생각하고 잘 로그로 남기고, 잘 추적할 수 있어야 했다. 단순 기능 구현보다 히스토리 관리와 실패 시 복구가 본체가 아닌가 싶을 정도로, 보상 트랜잭션이 정말 중요했다. 잘못 차감되거나 잘못 이전된 경우에도, 어떤 실패든 고객에게 유리하게 작동해야 한다는 것. 레디스나 카프카 같은 기술이 중요한 게 아니라, 어떻게 하면 고객이 불편함을 느끼지 않으면서 보상이 자동으로 이루어질지가 진짜 문제였다.

그 안일함은, 내가 자주 하던 게임의 표현으로 풀스택 명치 원턴킬 30데미지였다. 사랑해요, 하스스톤




이번에는 다르게 해보자

 이 이야기를 왜 꺼내느냐. 지금 만들고 있는 이커머스 프로젝트에서 동시성 제어를 직접 구현해야 하는 순간이 왔기 때문이다. 그리고 이번에는, 4년 전의 실수를 반복하지 않으려고 많이 생각했다.

 이 프로젝트는 감성 이커머스 플랫폼이다. 회원가입을 하고, 브랜드와 상품을 둘러보고, 마음에 드는 상품에 좋아요를 누르고, 쿠폰을 받아서 주문하는 흐름이다. Round 3까지 회원, 브랜드, 상품, 좋아요, 주문 도메인의 모델과 서비스와 저장소가 구현되어 있었지만, 쿠폰 도메인은 아예 없었다. 컨트롤러 대부분도 미구현 상태였다. 동시성 제어라는 개념 자체가 적용되어 있지 않아서, 여러 사람이 동시에 같은 상품을 주문하면 재고가 음수로 떨어질 수 있었고, 같은 쿠폰을 동시에 쓰면 중복 사용이 가능했다.

이번 라운드의 목표는 세 가지였다.

1. 쿠폰 도메인을 처음부터 만든다.
2. 주문에 쿠폰 할인을 연결한다.
3. 재고, 쿠폰, 좋아요에 동시성 제어를 적용한다.




쿠폰이라는 새로운 도메인을 만들면서 느낀 것

 쿠폰을 만들면서 가장 먼저 한 일은 도메인 모델을 설계하는 것이었다. 쿠폰에는 두 가지 종류가 있다. 정액 할인과 정률 할인이다. "5000원 할인"이냐 "10% 할인"이냐의 차이다. 이걸 하나의 엔티티 안에서 타입으로 구분하도록 만들었다. `calculateDiscount`라는 메서드가 주문 금액을 받아서 실제 할인 금액을 계산하는데, 정액이면 쿠폰 금액과 주문 금액 중 작은 값을 돌려주고, 정률이면 주문 금액에 퍼센티지를 곱해서 돌려준다. 3000원짜리 상품에 5000원 할인 쿠폰을 쓰면 3000원만 할인되어야지, 마이너스 2000원이 되면 안 되니까.

 그리고 쿠폰 발급이라는 개념을 별도 엔티티로 분리했다. 쿠폰 자체는 "5000원 할인 쿠폰"이라는 템플릿이고, 그걸 특정 유저에게 발급하면 "발급된 쿠폰"이 된다. 발급된 쿠폰은 세 가지 상태를 가진다. 사용 가능, 사용 완료, 만료. 여기서 중요한 건, 하나의 쿠폰은 한 유저에게 한 번만 발급된다는 제약이다. 데이터베이스에 유니크 제약 조건을 걸었고, 코드에서도 중복 발급 여부를 검증한다.

 이 이중 검증이 회사에서의 경험에서 온 것이다. 코드 레벨에서만 검증하면, 동시 요청이 동시에 검증을 통과할 수 있다. 두 개의 요청이 동시에 "이 유저에게 이 쿠폰이 발급된 적 있는가?"를 질의하면, 둘 다 "없다"라는 답을 받을 수 있다. 하지만 둘 다 INSERT를 시도할 때, 유니크 제약 덕분에 하나는 반드시 실패한다. 그 실패를 `DataIntegrityViolationException`이라는 예외로 잡아서 "이미 발급된 쿠폰입니다"라고 알려주면 된다. 코드의 검증은 정상적인 경우를 걸러주는 첫 번째 관문이고, 데이터베이스의 유니크 제약은 동시성 상황에서의 마지막 방어선이다.




비관적 락과 낙관적 락, 그 사이에서

 동시성 제어의 핵심은 "같은 데이터에 여러 요청이 동시에 접근할 때 데이터가 꼬이지 않게 하는 것"이다. 이걸 해결하는 방법은 크게 두 가지다.

 첫 번째는 비관적 락이다. 이름 그대로 "충돌이 날 거라고 비관적으로 가정하고, 아예 데이터를 잠가버리는 것"이다. 내가 이 데이터를 쓰고 있는 동안 다른 사람은 이 데이터를 건드리지 못한다. 확실하다. 대신 잠긴 동안 다른 요청은 줄을 서서 기다려야 하니까, 트래픽이 많아지면 병목이 될 수 있다. 식당에 빈 자리가 하나인데 줄이 길게 늘어선 상황과 비슷하다.

 두 번째는 낙관적 락이다. "충돌이 안 날 거라고 낙관적으로 가정하고, 일단 작업한 뒤에 충돌 여부를 확인하는 것"이다. 데이터를 읽을 때 버전 번호를 함께 읽고, 저장할 때 "내가 읽었던 버전이 아직 그대로인가?"를 확인한다. 그 사이에 다른 누군가가 데이터를 바꿔서 버전이 올라갔으면, 내 저장은 실패한다. 이 경우 데이터를 다시 읽어서 다시 시도하면 된다. 구글 문서에서 두 사람이 같은 줄을 동시에 편집했을 때, 나중에 저장한 사람에게 "누군가 수정했습니다, 다시 확인하세요"라고 알려주는 것과 비슷하다.

이 프로젝트에서는 둘 다 썼다. 모든 곳에 같은 방식을 적용하지 않고, 리소스의 특성에 따라 전략을 나눴다.

 상품 재고 차감과 쿠폰 사용에는 비관적 락을 적용했다. 이유는 간단하다. 재고가 10개 남은 상품에 15명이 동시에 주문하면, 10명만 성공해야 한다. 재고가 음수가 되면 안 된다. 쿠폰도 마찬가지로, 하나의 발급 쿠폰을 두 건의 주문에서 동시에 사용하면 안 된다. 이런 상황에서는 정확한 값 기반의 연산이 필수이므로, 데이터를 아예 잠가버리는 것이 맞다.

JPA에서는 이걸 `@Lock(LockModeType.PESSIMISTIC_WRITE)`라는 어노테이션으로 구현한다. 이 어노테이션이 붙은 조회 메서드를 호출하면 `SELECT ... FOR UPDATE`라는 SQL이 데이터베이스로 나가고, 해당 행이 잠긴다. 다른 트랜잭션이 같은 행을 조회하려고 하면, 첫 번째 트랜잭션이 커밋되거나 롤백될 때까지 대기한다.

 좋아요 수 변경에는 낙관적 락을 적용했다. 좋아요는 재고나 쿠폰과 성격이 다르다. 좋아요 수가 100인데 동시에 두 명이 좋아요를 누르면 102가 되어야 하지만, 아주 짧은 순간 101로 보인다고 해서 사업적으로 큰 문제가 되지는 않는다. 충돌 빈도도 상대적으로 낮다. 그래서 상품 엔티티에 `@Version` 필드를 추가하고, 좋아요 수를 변경할 때 버전 충돌이 발생하면 최대 3회까지 재시도하도록 만들었다.

이 결정은 회사에서 모든 리소스에 같은 방식의 락을 적용해본 경험 때문이었다. 모든 곳에 비관적 락을 걸면 단순하기는 하지만, 데이터베이스 커넥션 점유 시간이 길어진다. 커넥션이란 애플리케이션과 데이터베이스 사이의 통신 회선 같은 것인데, 한정된 수만큼만 열 수 있다. 비관적 락으로 행을 잠그면, 그 행을 잠근 트랜잭션이 끝날 때까지 커넥션을 잡고 있어야 한다. 트래픽이 몰리면 커넥션 풀이 고갈될 수 있다. 리소스의 특성에 따라 전략을 분리해야 한다는 걸 경험으로 배운 것이다.



주문에 쿠폰을 연결하면서 트랜잭션의 무게를 다시 느끼다

주문 생성 과정은 이렇게 흘러간다.

1. 사용자가 상품 목록과 수량, 그리고 사용할 쿠폰 정보를 보낸다.
2. 상품 데이터를 비관적 락으로 조회한다. 이 시점부터 해당 상품 행은 잠긴다.
3. 쿠폰이 있다면, 이 쿠폰이 이 사용자의 것인지, 아직 사용 가능한 상태인지, 만료되지 않았는지 확인한다.
4. 재고를 차감하고 주문을 생성한다.
5. 쿠폰을 비관적 락으로 다시 조회해서, 사용 처리하고, 할인 금액을 계산해서 주문에 반영한다.
6. 트랜잭션이 끝나면 모든 변경사항이 한꺼번에 데이터베이스에 반영된다.

 이 전체 과정이 하나의 `@Transactional` 안에서 일어난다. 중간에 어디서든 예외가 발생하면, 재고 차감도, 쿠폰 사용 처리도, 주문 생성도 모두 없던 일이 된다.

 이게 왜 중요한지를, 나는 회사에서 뼈저리게 배웠다. 주문은 성공했는데 쿠폰 사용 처리가 실패한 경우를 떠올려보면 된다. 고객 입장에서는 쿠폰을 썼는데 할인이 안 되어 있다. 아니면, 할인은 됐는데 쿠폰이 그대로 사용 가능 상태로 남아서 한 번 더 쓸 수 있다. 전자는 고객 불만이고, 후자는 회사 손해다. 어느 쪽이든 누군가 피해를 본다.

그래서 원자성이라는 개념이 필요하다. 원자성이란 "전부 성공하거나, 전부 실패하거나"라는 원칙이다. 중간 상태가 없어야 한다. 재고가 줄었는데 주문은 안 만들어진 상태, 주문은 만들어졌는데 쿠폰은 안 쓰인 상태, 이런 반쪽짜리 결과가 존재하면 안 된다. 이걸 보장해주는 것이 트랜잭션이다.




동시성 테스트를 처음 작성해보면서

 이번에는 코드를 작성하기 전에 "이 코드가 동시 요청 상황에서 올바르게 작동하는지 어떻게 증명할 것인가"를 먼저 생각했다. 4년 전에 이런 테스트를 먼저 작성했다면, 그 많은 보상 처리를 하지 않아도 됐을 것이다.

테스트를 네 개 시나리오로 만들었다.

 첫 번째, 좋아요 수 정합성. 하나의 상품에 10명이 동시에 좋아요를 누른다. 최종 좋아요 수가 정확히 10이어야 한다. 낙관적 락과 재시도 로직이 제대로 작동하는지 확인하는 테스트다. 만약 동시성 제어가 없으면, 10명이 동시에 좋아요를 눌러도 최종 수치가 3이나 5 같은 엉뚱한 숫자가 될 수 있다. 여러 스레드가 같은 값을 읽고, 같은 값에 1을 더해서 저장하니까.

 두 번째, 재고 동시 차감. 재고가 10개인 상품에 15명이 동시에 주문한다. 10명은 성공하고 5명은 실패해야 한다. 최종 재고는 정확히 0이어야 한다. 비관적 락이 재고를 지키는지 확인한다. 동시성 제어가 없으면, 15명 전부 재고 확인 시점에 "10개 있네"라고 읽고, 15건의 주문이 전부 성공해서 재고가 마이너스 5가 되는 참사가 발생한다.

 세 번째, 쿠폰 단일 사용. 하나의 발급 쿠폰을 5명이 동시에 주문에 사용하려고 한다. 1명만 성공하고 4명은 실패해야 한다. 쿠폰의 상태는 "사용 완료"여야 한다. 비관적 락이 쿠폰의 중복 사용을 막는지 확인한다.

 네 번째, 트랜잭션 롤백. 유효하지 않은 쿠폰(이미 사용된 쿠폰)으로 주문을 시도했을 때, 이미 차감된 재고가 롤백되어 원래 수량으로 돌아가는지 확인한다. 트랜잭션의 원자성이 제대로 작동하는지 검증하는 테스트다.

 테스트 코드에서는 자바의 `CountDownLatch`와 `ExecutorService`를 사용했다. `CountDownLatch`는 "모든 스레드가 준비될 때까지 기다렸다가, 동시에 출발시키는" 장치다. 육상 경기에서 선수들이 모두 출발선에 선 다음 총소리가 나야 뛰기 시작하는 것처럼, 15개의 스레드를 만들고, 모두 출발 신호를 기다리게 한 다음, 한꺼번에 보낸다. `ExecutorService`는 이 스레드들을 관리해주는 스레드 풀이다.

이 네 개의 테스트가 모두 통과하는 걸 보면서 느낀 게 있다. 테스트는 배포 후에 문제를 발견하는 도구가 아니라, 배포 전에 문제를 방지하는 도구다. 이걸 머리로는 알고 있었는데, 가슴으로 느끼는 데 4년이 걸렸다.




4년 전의 경험이 가르쳐준 것, 그리고 이번에 달라진 것

 회사에서 포인트 시스템을 운영하면서, 단순히 기술적인 문제만 겪은 게 아니었다. `@Transactional` 어노테이션에 대한 고민, 테이블 구조(스키마)에 대한 고민, 전자금융법과 관련된 재무적 고민, 한 번도 생각해보지 못했던 고객 경험 관점(CX)에서의 고민까지. 기깔 나고 예술적인 코드보다, `System.out.println`을 찍더라도 어떤 객체의 어떤 값이 어떤 시점에 어떻게 바뀌었는지를 남기는 것이 더 중요했다. 데이터베이스 입출력을 줄이기 위한 캐시, 조회 속도를 올리기 위한 인덱싱, 주기적으로 돌아가는 배치 작업이 실시간 서비스에 영향을 주지 않도록 하는 처리 — 이런 것들이 필요하다는 걸 뼈저리게 느꼈다.

이번 프로젝트에서 달라진 것은 이런 점이다.

 동시성 전략을 리소스별로 다르게 가져갔다. 모든 곳에 같은 락을 걸지 않았다. 재고와 쿠폰처럼 정확성이 생명인 곳에는 비관적 락을, 좋아요처럼 약간의 일시적 불일치가 허용되는 곳에는 낙관적 락을 적용했다.

 트랜잭션 범위를 의식적으로 설계했다. 주문 생성이라는 하나의 유스케이스에 필요한 모든 작업을 하나의 트랜잭션으로 묶되, 불필요하게 넓히지 않으려고 했다. 4년 전에는 트랜잭션이 어디서 시작되고 어디서 끝나는지도 제대로 파악하지 못한 채 코드를 작성했다.

 테스트를 먼저 작성했다. 동시성 문제는 운영 환경에서 재현하기 어렵다. 하지만 `CountDownLatch`와 스레드 풀로 시뮬레이션하면, 배포 전에 확인할 수 있다. 재고가 음수가 되는지, 쿠폰이 두 번 사용되는지, 롤백이 제대로 작동하는지. 이런 것을 코드로 증명할 수 있다는 건, 생각보다 마음이 놓이는 일이다.




남은 이야기

 아직 부족한 부분이 많다. 비관적 락은 트래픽이 높아지면 커넥션 풀 고갈 위험이 있다. 재고를 별도 테이블로 분리해서 `SET stock = stock - 1 WHERE stock >= 1` 같은 원자적 UPDATE 쿼리로 전환하는 방안도 생각하고 있고, Redis를 사용한 분산 락 도입도 고려 중이다. 낙관적 락의 재시도 횟수가 3회면 충분한지, 재시도 사이에 지연 시간을 넣어야 하는지도 아직 정답을 모른다.

 그래도 하나는 분명하다. 4년 전에 포인트 시스템에서 겪었던 그 명치 데미지가, 이번에는 방패가 됐다. 경험이라는 건 그런 거다. 아플 때는 정말 아프지만, 지나고 나면 같은 곳을 다시 맞지 않게 해준다.

 52개의 파일을 고치거나 새로 만들었고, 57개의 단위 테스트와 4개의 동시성 통합 테스트를 작성했다. 쿠폰 도메인을 처음부터 만들었고, 미완성이었던 브랜드, 상품, 좋아요, 주문의 API 엔드포인트를 전부 완성했다. 숫자로 보면 그냥 코드를 많이 쳤다는 이야기인데, 그 코드 한 줄 한 줄에 4년간의 삽질이 녹아 있다는 걸, 쓰고 보니 새삼 느끼게 된다.