본문으로 건너뛰기
버튼 하나 뒤에서

매일 쓰는 기능 뒤의 서버 이야기

버튼 하나 뒤에서 벌어지는 일

티켓팅, 로그인 유지, 클릭 기록, 포인트, 결제, 캐시, 서버 늘리기. 매일 쓰는 기능 뒤에서 서버가 실수하는 순간을 실제 서버와 데이터베이스에서 일부러 일으켜 보고, 고친 뒤 얼마나 나아졌는지 숫자로 확인하는 그림책 7편이에요.

1장 티켓팅부터 보기 →
예매서버데이터베이스

7편의 이야기

  1. 1장

    티켓팅 선착순에 몰린 500명

    표는 100장인데 500명이 동시에 누르면, 서버는 몇 장을 팔까요?

    400장 → 0장

    더 팔린 표 (고치기 전 → 후)

  2. 2장

    손목밴드 로그인이 유지되는 원리

    로그인이 만료된 순간 요청 5개가 동시에 나가면, 새 손목밴드를 몇 번 받으러 갈까요?

    5번 → 1번

    재발급 창구에 간 횟수 (동시 요청 5개, 실측)

  3. 3장

    묶음배송 클릭 기록이 저장되기까지

    클릭 기록 10,000개를 하나씩 보내는 것과 100개씩 묶어 보내는 것, 얼마나 다를까요?

    65배

    더 빨라요 (하나씩 18.7초 → 묶어서 0.29초)

  4. 4장

    포인트 통장 잔액이 저절로 틀어지는 이유

    포인트 적립·사용 200건이 동시에 몰리면, 잔액은 맞을까요?

    10,100P

    잔액 숫자만 고칠 때 사라진 포인트 (실측)

  5. 5장

    결제 버튼 두 번 두 번 눌러도 한 번만 결제

    결제 버튼을 연달아 누르고 앱이 알아서 다시 보내면, 돈은 몇 번 빠져나갈까요?

    4번 → 1번

    결제된 횟수 (번호표 없이 → 함께, 실측)

  6. 6장

    맛집 메뉴판 빠르게 보여주고, 제때 바꾸기

    손님 1000명이 같은 메뉴를 물어보면, 데이터베이스에 몇 번 가야 할까요?

    1000번 → 1번

    데이터베이스에 간 횟수 (매번 → 메뉴판 먼저, 실측)

  7. 7장

    계산대 하나 더 서버 두 대로 나눠 받기

    서버 한 대에 손님이 계속 몰리면, 같은 서버를 하나 더 켜는 것만으로 될까요?

    7~10초 → 1.9초

    손님 800명일 때 느린 쪽 5%가 기다린 시간 (API 1개 → 2개, 처리량은 그대로, 실측)

번외

배포 및 회고 처음 서버를 만져 본 프론트엔드 개발자의 기록

AWS 가입부터 끊기지 않는 배포, 매일 DB 백업까지. 친 명령 그대로와 막혔던 것, 배운 것을 적었어요.

실패한 요청 0번

배포 중 0.2초마다 찔러 본 결과 (배포 3번)

숫자로 본 결과

모두 이 저장소의 측정 기록에 있는 숫자예요. 노트북 한 대에서 잰 상대 비교라 실행마다 ±30% 정도 흔들려요.

400 → 0
초과 판매된 표
100장에 500명 동시 예매. 고치기 전 vs 세 가지 해결책 모두
약 4배
“한 번에 처리”가 “줄 세우기”보다 빠름
0.41초 vs 1.70초 (각 3회 중 중간값)
65배
묶어 보내기가 하나씩 보내기보다 빠름
클릭 기록 10,000개를 서버로: 18.7초 → 0.29초
58~72배
묶어 저장하기가 하나씩 저장하기보다 빠름
데이터베이스에 10,000줄: 70.4초 → 1.21초, 다시 재면 75.0초 → 1.04초
출처: ticketing-coupon.md · delivery-batching.md (맥북 M1 Pro, MySQL 8.4, 2026-10-19 측정)

7가지 선택

장마다 무엇을 고르고 무엇을 포기했는지, 재 보고 나서 알게 된 것까지 적었어요. 숫자는 모두 이 저장소의 측정 기록에 있는 값이에요.

1장 보기 →

선착순 표 100장: 줄 세우기 대신 "남았으면 팔기"를 한 번에

"남았으면 1 늘리기"를 데이터베이스 한 문장에 맡겼어요. 500명이 동시에 눌러도 정확히 100장이 나갔고, 줄 세우기보다 약 4배 빨랐어요.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • 원자적 조건부 UPDATE: UPDATE Coupon SET issuedCount = issuedCount + 1 WHERE id = ? AND issuedCount < totalQuantity. 1행이 바뀌면 발급 행을 INSERT, 0행이면 품절.
  • 확인과 증가가 한 문장이라 그 사이에 다른 요청이 끼어들 틈이 없어요. 500명·100장, 3회 측정에서 0.46 / 0.39 / 0.41초, 초과 발급 0장. 네 방법 중 코드도 가장 짧았어요.
포기한 방법과 이유
  • 비관적 락(SELECT … FOR UPDATE): 정확히 100장이지만 1.68~1.73초. 트랜잭션이 커넥션을 쥔 채 줄을 서서, 줄이 길면 커넥션 풀이 마를 수 있어요.
  • 낙관적 락(version 비교 후 재시도): 한 행에 500명이 몰리자 재시도가 약 14,140번. 30번 한도를 넘긴 440명이 실패해서 100장 중 60장만 팔렸고 23~24초 걸렸어요.
  • 이 방법의 한계도 받아들였어요. "1인 1매" 같은 규칙은 한 문장에 못 담아서, 그땐 unique 제약을 더하거나 잠금으로 가야 해요.
배운 것
  • 비관적 락의 시간은 대부분 락이 아니라 커넥션 풀 대기였어요. 2026-10-19 기록 500건에서 중간값이 풀 대기 170.1ms, 행 락 대기 2.4ms. 전체 대기 시간의 96%가 풀 앞이었어요.
  • 낙관적 락은 충돌이 드문 데이터용이에요. 같은 행에 몰리면 재시도가 재시도를 부르는 폭풍이 돼요(다른 날 기록은 9,015번 만에 모두 성공해서, 실행마다 크게 흔들려요).
  • 아무 대책이 없으면 카운터는 1인데 실제 발급 행은 500개였어요. 카운터만 믿으면 안 되고 실제 행 수로 검증해야 한다는 걸 테스트(300명 vs 50장, 실제 MySQL)로 고정했어요.

2장 보기 →

로그인 유지: 손목밴드는 메모리에, 재발급 영수증은 스크립트가 못 여는 쿠키에

금방 만료되는 손목밴드는 메모리에만 두고, 재발급 영수증은 쓸 때마다 새것으로 바꿔요. 그래서 복사본은 처음 쓰이는 순간 들켜요.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • 액세스 토큰: JWT(HS256), 기본 20초, 자바스크립트 메모리에만. 리프레시 토큰: 랜덤 32바이트, 24시간, httpOnly 쿠키(Path=/auth, SameSite=Lax). DB에는 SHA-256 해시만 저장해서 DB가 새도 토큰으로 못 써요.
  • 리프레시할 때마다 토큰을 회전하고, 이미 쓴 토큰이 다시 오면 그 로그인(패밀리)의 토큰을 전부 폐기해요. active → used는 조건부 UPDATE라 같은 토큰이 동시에 와도 정확히 하나만 성공해요.
포기한 방법과 이유
  • localStorage: 실측에서 나쁜 스크립트가 3곳 중 localStorage만 열었어요(메모리, httpOnly 쿠키는 못 엶). 편하지만 XSS 한 번에 토큰이 통째로 새요.
  • 오래가는 액세스 토큰 하나: 서명만으로 검증해서 빠르지만 폐기할 방법이 없어요. 그래서 수명을 짧게 하고 폐기 가능한 리프레시 토큰을 따로 뒀어요.
  • 재사용 유예 기간(grace period): 실무에선 직전 토큰을 잠깐 봐주기도 하지만, 원리를 보이려고 엄격하게 처리했어요. 대가로 클라이언트가 동시 요청을 직접 묶어야 해요. 쿠키는 자동 전송되니 CSRF는 SameSite·Path·CORS로 줄였어요.
배운 것
  • 보안 규칙이 엄격할수록 클라이언트 동시성이 중요해졌어요. 만료 순간 요청 5개가 각자 refresh하면 1번만 회전하고 4번은 재사용으로 걸려 로그인이 통째로 취소됐어요. 진행 중인 refresh를 공유(single-flight)해서 5번 → 1번.
  • 개발 모드의 React StrictMode가 effect를 두 번 돌려서, 새로고침 복구가 refresh를 두 번 보내면 그것도 재사용으로 보여요. refresh를 모듈 단위 single-flight 하나로 공유해서 해결했어요.

3장 보기 →

클릭 기록: 바로 저장 대신 모아서 한 번에

클릭 기록을 하나씩 보내지 않고 모았다가 한 번에 보내요. 10,000개를 보내는 데 18.7초 걸리던 게 0.29초가 됐어요.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • 직접 만든 SDK가 큐에 모았다가 개수나 시간이 차면 POST /events/batch로 보내고(데모 화면은 10개 또는 10초), 서버는 createMany 한 문장으로 저장해요.
  • 페이지를 떠날 땐 sendBeacon(text/plain이라 CORS preflight 없음), 실패한 묶음은 localStorage에 보관했다가 다시 보내요. 차트는 원본 대신 5분마다 KST 날짜별로 정리한 집계표(EventDailyStat)를 읽고, 입구는 IP당 10초 200요청으로 제한해요.
포기한 방법과 이유
  • 하나씩 바로 보내기: 가장 실시간이지만 같은 10,000개에 요청 10,000번, 18.7초(동시 10).
  • 차트를 볼 때마다 원본 세기: 늘 정확하지만 원본이 쌓일수록 느려져요. 최대 5분 늦는 집계를 택했어요.
  • 묶음은 한 문장이라 일부만 실패할 수 없어요. 하나가 문제면 100개가 함께 실패하는 걸 받아들이고, SDK가 묶음 단위로 재시도해요.
배운 것
  • 하나씩 보내기의 병목은 HTTP보다 DB 커밋 횟수였어요. DB에 직접 create 10,000번(순차) 70.4초 vs createMany 100개씩 1.21초, 다시 재면 75.0초 vs 1.04초(58~72배). MySQL 기본값(innodb_flush_log_at_trx_commit=1)이라 커밋마다 디스크에 써요.
  • HTTP 단건(동시 10) 18.7초가 DB 단건(동시 10) 20.6초·15.2초와 비슷했어요. 요청을 줄이는 것보다 커밋을 줄이는 게 본질이었어요.
  • 집계는 날짜 경계가 중요했어요. 한국 서비스라 KST 자정 기준으로 고정했어요.

4장 보기 →

포인트: 잔액 숫자만 고치지 않고, 통장처럼 내역을 남기기

적립·사용 200건이 동시에 몰리자 잔액 숫자만 고치는 방식에선 10,100P가 사라졌어요. 내역을 남기고 잔액과 맞춰 보는 방식은 0P.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • 원장(PointEntry)은 추가만 하고 고치지 않아요. 한 트랜잭션에서 SELECT balance … FOR UPDATE → 0 미만이면 거절 → 내역 INSERT → balance = balance + amount.
  • 잔액 칸과 내역 합계(SUM)를 비교하는 대사로 어긋남을 바로 잡아내요. 측정: 시작 5,000P, +100P × 150건, −100P × 50건 동시 → 잔액 15,000P = 내역 합계 15,000P. 15,100P를 쓰려는 요청은 거절.
포기한 방법과 이유
  • 잔액 덮어쓰기(읽고 → 더해서 → 덮어쓰기): 0.11초로 더 빠르지만 잔액이 4,900P로 끝나 10,100P가 사라졌어요.
  • 통장 방식은 0.26초로 느려요. 지갑 한 행을 잠그니 한 사람에게 요청이 몰리면 줄이 길어져요. 실무라면 잔액을 SUM으로만 계산하거나, 잔액 칸은 캐시로 두고 대사 작업으로 검증하는 방법도 있어요.
배운 것
  • 사라진 양이 실행마다 달랐어요(다른 실행에선 9,900P). 동시성 버그는 재현이 들쭉날쭉해서, "내역 합계와 비교" 같은 검증 장치가 있어야 잡혀요.
  • 잔액은 결과이고 내역이 근거예요. 덮어쓰기 방식도 내역은 남겼기 때문에 "합계 15,000P인데 잔액 4,900P"라고 사라진 양을 정확히 셀 수 있었어요. 내역이 없었다면 사라진 줄도 몰랐을 거예요.

5장 보기 →

결제: 버튼을 잠그는 대신 결제 시도마다 번호표(Idempotency-Key)

버튼 세 번에 앱의 자동 재시도까지 요청 4개가 나가면, 번호표가 없을 땐 4번 결제됐어요. 같은 번호표를 붙이니 1번.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • 앱이 결제 시도마다 UUID를 만들어 Idempotency-Key 헤더로 보내고, 재시도에도 같은 값을 써요.
  • 서버는 조회보다 INSERT Payment(status=processing)를 먼저 시도해요. idempotencyKey가 UNIQUE라 동시에 와도 한 행만 성공하고(P2002), 진 요청은 50ms마다 조회해 paid가 되면 같은 결제를 돌려줘요(Idempotent-Replayed: true). 같은 키에 다른 주문·금액이면 422.
포기한 방법과 이유
  • 버튼 잠금만: 한 번만 눌러도 앱의 자동 재시도 때문에 2번 결제됐어요.
  • "먼저 조회해 보고 없으면 저장": 동시 요청이 둘 다 "없음"을 봐서 막지 못해요. 그래서 DB의 UNIQUE 제약에 맡겼어요.
배운 것
  • 중복 결제는 사용자 실수보다 타임아웃 재시도에서 생겨요. 데모에서 앱은 0.6초 안에 답이 없으면 다시 보내는데, 결제사 승인은 0.9초 걸려요.
  • 통합 테스트로 같은 키 5개를 동시에 보내 행 1개, 응답 5개 모두 같은 결제 id인 걸 확인했어요. 결제사 호출 중 서버가 죽으면 그 키가 processing으로 남는데, 매분 도는 정리 작업이 5분 넘은 processing을 실패(failed)로 닫아요. 데모의 가짜 결제사는 승인 기록이 없으니 "돈이 안 나갔다"로 보고, 진짜 결제사라면 여기서 승인 여부를 다시 물어봐야 해요.

6장 보기 →

맛집 메뉴판: 캐시는 빠르게, 바뀐 메뉴는 제때

손님 1000명이 같은 메뉴를 물어도 데이터베이스엔 1번만 가요. 대신 메뉴가 바뀌면 최대 5초 늦어서, 고칠 때 메뉴판을 지워요.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • cache-aside: Redis GET → 없으면 MySQL SELECT → SET EX 5. 1000명 읽기에서 DB 1000번(63ms) → 1번(23ms).
  • 메뉴를 고칠 때 DEL로 무효화해요. 새 메뉴가 보이기까지 5.2초(남은 TTL) → 0초.
  • 메뉴판이 비는 순간 1000명이 몰리는 스탬피드엔 Redis 잠금(SET … NX PX 2000)으로 잡은 한 명만 DB에 가요. 내 토큰일 때만 DEL(Lua), 나머지는 20ms마다 GET, 1초 넘으면 DB에서 직접 읽어요.
포기한 방법과 이유
  • 대책 없음: 비는 순간 1000명 전부 DB로(1000번, 96ms).
  • 프로세스 안 single-flight: 서버 한 대에선 가장 빨라요(10ms). 하지만 서버 3대를 흉내 내니 DB가 3번, 서버 수만큼 늘어나요.
  • Redis 잠금의 대가: 기다리는 손님이 다시 물어서 GET이 1999번으로 늘고 45ms. DB는 서버 수와 상관없이 1번이라 이걸 택했어요.
배운 것
  • Prisma findUnique는 같은 틱의 호출을 한 쿼리로 묶어요. "DB 1000번"이 실제로는 1번이라 측정이 거짓말을 하고 있었어요. findFirst로 바꾸고 MySQL Com_select 증가량으로 약 1,000개를 확인했어요.
  • 빈 DB에서 첫 손님 1000명이 동시에 메뉴 행을 만들다 unique 충돌(P2002)로 500 오류가 났어요. 만들다 충돌하면 다시 읽도록 고치고 회귀 테스트를 더했어요.
  • CI에선 Turborepo가 허용 목록에 없는 환경 변수를 테스트에 넘기지 않아 REDIS_URL이 빠졌어요(globalPassThroughEnv에 추가). 로컬에선 통과하고 CI에서만 깨져서, 빌드 도구의 환경 변수 규칙까지 봐야 했어요.
  • CloudFront에선 캐시 적중률을 올리는 게 목표였다면, 여기선 "메뉴판이 비는 순간"과 "메뉴가 바뀐 순간"을 다루는 게 핵심이었어요.

7장 보기 →

계산대 하나 더: 같은 API를 두 개 켜서 나눠 받기

부하 테스트에서 Node 프로세스 하나가 CPU 코어 하나를 꽉 채워 초당 약 620건에서 멈췄어요. 코어가 2개라 API를 2개로 늘리고 Caddy가 나눠 보내게 했어요.

개발자용 자세히고른 것 · 포기한 것 · 배운 것
선택한 방법과 이유
  • 색(blue/green)마다 API를 2개씩 정의하고, Caddy reverse_proxy에 둘 다 적어 least_conn(진행 중 요청이 적은 쪽)으로 보내요. 30초마다 /health를 확인해 죽은 쪽은 빼요.
  • 무중단 배포는 그대로예요. 쉬던 색 2개를 함께 켜고, 둘 다 200일 때만 Caddy를 바꿔요.
  • 응답 헤더 x-instance-id(환경 변수 INSTANCE_NAME)로 어느 API가 받았는지 화면에서 바로 보여요.
포기한 방법과 이유
  • Node cluster로 컨테이너 하나에서 코어 2개 쓰기: 컨테이너를 나누면 한쪽이 죽어도 Caddy가 빼고 다른 쪽이 받아서 이쪽을 골랐어요.
  • 초당 요청 수 같은 넉넉한 한도는 API마다 따로 세는 채로 뒀어요(사실상 2배). 요청마다 Redis를 한 번 더 다녀오는 비용보다 작다고 봤어요.
배운 것
  • "나 혼자 돈다"고 믿은 코드가 처음엔 세 군데인 줄 알았는데 실제로는 네 군데였어요. 5분 집계 cron은 5분 칸마다 Redis SET NX PX를 잡은 쪽만, 쿠폰·포인트 실험의 "한 번에 하나"는 메모리에서 Redis 잠금으로 옮겼어요. 3장 SSE 알림은 프로세스 안 Subject라 다른 API로 간 배치를 못 들어서, Redis PUBLISH/SUBSCRIBE(bv:events:ingested)로 바꿨어요. 결제 정리 cron은 조건부 UPDATE라 두 번 돌아도 한 번만 바뀌어서 그대로 뒀어요.
  • 마이그레이션은 _1만 하고 _2는 RUN_MIGRATIONS=false로 건너뛰어요. expand → contract 규칙이라 _2가 먼저 떠도 괜찮아요.
  • 같은 조건(800 VU)으로 다시 재 보니 처리량은 초당 약 620건 → 640건으로 거의 그대로였고, 800 VU의 p95가 7~10초 → 1.9초, 시간 초과가 0.89% → 0%로 줄었어요. 이번엔 서버 CPU 전체가 꽉 찼어요(컨테이너 합 약 180% / 200%). API 둘이 각각 50~65%, Caddy 35~50%, MySQL 25~40%를 썼어요. 그래서 다음 병목은 API 개수가 아니라 Caddy · DB와 나눠 쓰는 서버 CPU예요.

같은 내용을 저장소의 docs/decisions.md에도 적어 뒀어요.

About me

임철순 증명사진임철순서비스 10개를 운영하는 회사에서 프론트엔드를 맡아 비용과 속도를 숫자로 줄여 왔고, 이 사이트에서는 서버와 데이터베이스까지 직접 만들고 쟀어요.2022.06 블록체인(Solidity) 개발 → 2022.09~ 캠퍼스 B2C 모바일 앱 회사 프론트엔드왜 백엔드로, 경력, 글 · 활동, 연락처 보기 →

어떻게 만들었나

  1. 브라우저Next.js 15 · React 19Vercel
  2. API 서버NestJS 11 · Prisma 6Docker · EC2 · Caddy(HTTPS)
  3. 데이터베이스 · 캐시MySQL 8 · Redis 7Docker · 같은 EC2
  • 자동 테스트 72개 — API 51개(실제 MySQL·Redis로 동시성 검증 포함), 화면 19개, 이벤트 SDK 2개
  • GitHub Actions: 커밋마다 포맷 → 타입 검사 → 린트 → 테스트 → 빌드 → 운영 모드 API 스모크 테스트, Docker 이미지 빌드
  • 남용 방지: IP당 요청 제한, 시뮬레이션 동시 실행 1개 + 쿨다운, 관리자 API 키
  • 배포 구성은 저장소의 docs/deploy.md에 있어요 (pnpm + Turborepo 모노레포). 한 일과 회고는 배포 및 회고