Design Decisions
각 결정을 상황 → 선택 → 이유·결과 순서로 기록했습니다. 되돌린 결정은 왜 되돌렸는지도 적었습니다. 포트폴리오의 프로젝트에서 링크로 연결되며, 공개용으로 정제한 요약입니다.
상태: ✅ 유효 · ↩️ 롤백 · 🔁 여러 번 바뀜
시기별 목차
| 시기 | 결정 |
|---|---|
| 2023 Q4 | 파사드 단 입력 검사 |
| 2024 Q1 | 확인 대신 선점 · 검증은 목록 조회로 · dev 선행 배포 |
| 2024 Q2~Q3 | 레이어·컨슈머 정리 · 북마크 멱등성 · proto 모듈화 |
| 2024 Q4 | 유니크 키 승격 · 비정상 이벤트는 DLQ · 카운트는 이벤트로 |
| 2025 Q1~Q2 | 무한 스크롤 · 동시 적재 정합성 · 서비스 밖으로 적재 |
| 2025 Q3 | 벤더 응답 분기 · Lua 카운터 · 통합 알림 · 무효화 대신 TTL |
| 2025 Q4 | 네이밍 교훈 · 롤백 토픽 → 결과 토픽 |
| 2026 Q1 | 정책 전용 서비스 · 서비스 신설은 배포 세트까지 · jitter 와 timer |
| 2026 Q2~Q3 | 정책은 enum 으로 · 롤백에서 배운 것 · 잠금은 컬럼으로 |
| 2026 Q4 | 수신자 스트리밍 |
파사드 단에서 입력을 검사한다 ✅
2023 Q4 · 온체인 동기화
- 상황 — 게임 서버의 요청이 Saga 를 시작하는데, 잘못된 입력이 중간까지 들어가면 이미 진행된 단계의 보상 트랜잭션까지 번집니다.
- 선택 — 게임 서버가 호출하는 파사드에서 먼저 입력을 검사합니다. 요청 변환 로직은 단위 테스트와 함께 만들고, 내부 서비스의 오류는 gRPC 에러로 감싸서 돌려줍니다.
- 이유·결과 — 실패를 가능한 한 앞에서 끊는 것이 되돌리는 것보다 쌉니다. 이후 도메인별 유효성 검사를 추가할 때 따르는 기준이 되었습니다.
확인 대신 선점한다 ✅
2024 Q1 · 캐릭터 민트 닉네임
- 상황 — “닉네임이 있는지 확인하고, 없으면 만든다”는 방식은 확인과 생성 사이에 다른 요청이 끼어들 수 있어서, 같은 닉네임이 두 번 만들어졌습니다.
- 선택 — 존재 확인을 없애고, 먼저 Redis 락으로 닉네임을 잡아 둡니다(선점). 선점은 작업 메시지를 처리하는 시점에 하고, 기존 닉네임의 선점은 민트가 성공했을 때만 풀어 줍니다. 처음에는 “실패하면 해제”로 만들었다가 이 동작으로 고쳤습니다. 캐릭터 생성에도 같은 락을 적용했습니다.
- 이유·결과 — “확인 후 실행” 대신 “먼저 잡고 실행”하면 끼어들 틈이 없습니다. 이 원칙은 이후 Redis 중복 차단, 일일 횟수 카운터에도 이어졌습니다.
검증은 건마다가 아니라 목록 조회로 ✅
2024 Q1
- 상황 — NFT 아이템 등록/해제를 검증할 때, 아이템 하나마다 상태 서비스를 호출했습니다.
- 선택 — 목록을 한 번에 조회해서 검증합니다. 검증 오류의 종류도 하나로 통일해, 호출하는 쪽이 오류별로 분기하기 쉽게 했습니다.
- 이유·결과 — 호출 수가 아이템 수에 비례해 늘어나던 것이 1회로 줄었습니다.
test/qa 승격 전에 dev 에 먼저 배포한다 ✅
2024 Q1 · Helm 운영
- 상황 — DE 서비스를 배포할 때, 새 버전이 올라가는 환경이 바로 test/qa 였습니다.
- 선택 — test/qa 로 올리기 전에 dev 대역에 먼저 올려 확인합니다. 장애를 재현하려고 임시로 올렸다 내린 배포도 커밋으로 남깁니다.
- 이유·결과 — “언제 무엇을 올렸다가 내렸는지”가 이력으로 남아, 문제가 생겼을 때 원인을 따라가기 쉽습니다.
레이어를 나누고 컨슈머를 일반화한다 ✅
2024 Q2 · 트랜잭션 이력
- 상황 — Kafka 컨슈머가 거래 결과만 처리하도록 만들어져 있어서 다른 종류의 이벤트를 추가하기 어려웠습니다.
- 선택 — 컨슈머를 범용 이벤트 컨슈머로 일반화하고, 레이어를 다시 정리하고, 쓰지 않는 기능을 제거하고, 메시지 형식을 v2 로 바꿨습니다.
- 이유·결과 — 이후 민팅·전송·Swap & Warp·USDT 이벤트를 구조 변경 없이 차례로 붙일 수 있었습니다.
북마크는 멱등이 기본 ✅
2024 Q3
- 상황 — 사용자가 버튼을 두 번 누르거나 요청이 재시도되는 일이 흔합니다.
- 선택 — 멱등을 기본으로 합니다. 이미 북마크한 것을 또 요청하면 성공으로 응답하고, 해제는 없는 북마크일 때 실패로 분명히 알립니다. 북마크 수는 DB 저장이 성공했을 때만 올립니다. 수를 담은 캐시는 0 이하면 키를 지워, 어긋나면 다시 계산하는 쪽으로 갑니다.
- 이유·결과 — 재시도가 와도 중복이 쌓이지 않고, 숫자가 틀어져도 복구가 쉽습니다.
proto 저장소는 도메인별 모듈로 ✅
2024 Q3
- 상황 — 전사가 하나의 proto 패키지를 공유해서, 한 도메인만 바뀌어도 모든 서비스를 다시 빌드해야 했습니다.
- 선택 — 도메인별로 독립 모듈과 태그를 두어 재구성했습니다. 코드 생성은 한 번만 하고, 게이트웨이가 쓰는 정의 파일은 CI 가 자동으로 반영합니다.
- 이유·결과 — 폴더 구조를 바꾸면 이를 쓰는 모든 서비스가 깨지므로 한 번에 끝내야 합니다. 나눠서 바꿨다가 되돌리기를 반복한 끝에 알게 된 점입니다.
트랜잭션 해시를 유니크 키로 ✅
2024 Q4
- 상황 — 기존 복합 키로는 같은 트랜잭션인지 구분하지 못해 중복 저장이 생겼습니다.
- 선택 — 트랜잭션 해시 컬럼을 추가하고 유니크로 걸었습니다. 소모성 아이템은 판매자 주문 해시가 서로 겹쳐서, 구매자 주문 해시를 저장하도록 했습니다.
- 이유·결과 — 같은 건을 DB 가 직접 막아 줍니다. 다만 크로스체인 도메인에서는 이 값이 계속 바뀌어서 안정적인 키가 아니라는 것이 드러났고, 그쪽은 별도의 연결용 키를 두었습니다.
비정상 이벤트는 DB 가 아니라 DLQ ✅
2024 Q4
- 상황 — 처음에는 비정상 이벤트도 “저장하되 조회에서 제외”했습니다.
- 선택 — 3주 뒤 방식을 바꿔, DLQ 로 보내고 DB 에는 쓰지 않습니다.
- 이유·결과 — 비정상 데이터가 DB 에 남아 있으면 조회 필터와 집계 모두 신경 써야 합니다. DLQ 에 모아 두면 다시 처리하거나 원인을 추적하기도 쉽습니다.
카운트는 요청 경로가 아니라 이벤트로 ✅
2024 Q4 ~ 2025 Q1
- 상황 — 조회수·북마크 수를 요청이 들어올 때 바로 갱신했습니다.
- 선택 — Redis 캐시 → Kafka 이벤트 발행 → 이벤트를 받아 집계 → 집계 전용 컨슈머 분리 순서로 옮겼습니다. 어긋났을 때 바로잡는 리셋 기능도 두었습니다.
- 이유·결과 — 요청 안에서 동기로 갱신하면 응답도 느려지고 숫자도 틀어지기 쉽습니다. 틀어졌을 때는 코드를 고치기 전에 리셋부터 하면 됩니다.
페이지네이션은 커서로 🔁
2024 Q2 → 2025 Q1
- 상황 — 처음에는 공통 파라미터와 전체 건수를 함께 내려 주었습니다. 테이블이 커지자 전체 건수를 세는 비용이 문제가 되었습니다.
- 선택 — 커서 기반 무한 스크롤로 바꿨고, 첫 페이지만 캐시합니다.
- 이유·결과 — 전체 건수를 한 번 내려 주면 프론트가 그 값에 의존해서 빼기 어려워집니다. 처음부터 커서였어야 했다는 교훈이 남았습니다.
동시 적재 정합성 — 이벤트 종류별 방어 ✅
2025 Q2~Q3 · 크로스체인 전송
- 상황 — Kafka 메시지는 재시도되고 순서도 뒤바뀔 수 있어서, 같은 건이 동시에 들어오거나 “요청”보다 “결과”가 먼저 도착해 중복·누락이 생겼습니다.
- 선택 — 이벤트 종류에 따라 방어를 달리했습니다.
- 거래·자산·Swap 처럼 한 번에 끝나는 이벤트: Redis SET NX 로 먼저 잡은 쪽만 처리합니다.
- 요청과 결과가 뒤집혀 올 수 있는 Warp: DB 의 advisory lock 안에서 먼저 조회해 없으면 생성하고 있으면 수정합니다. 먼저 온 쪽이 먼저 저장됩니다.
- 이유·결과 — 락의 목적은 “요청과 결과가 동시에 쓰는 것을 막는 것”이지 “결과가 두 번 들어오는 것을 막는 것”이 아니라는 점을 나중에 명확히 정리했습니다.
서비스는 데이터 파이프라인에 직접 쓰지 않는다 ✅
2025 Q2 → 2026 Q1
- 상황 — 분석용 데이터를 서비스가 요청 처리 중에 직접 적재했습니다. 이를 취소하고 배치로 분리했다가(2025 Q2), Kafka 로 발행해 적재하는 방식으로 바꿨습니다(2025 Q3). 그런데 DB 에 저장한 뒤 발행이 실패하면 데이터가 유실되었습니다.
- 선택 — CDC 로 전환했습니다(2026 Q1). 상태가 바뀌는 테이블과 이력이 쌓이는 테이블을 구분해 다루고, 변경이 뜸한 테이블의 로그가 쌓이지 않게 하는 배치와 데이터가 있을 때만 도는 적재 작업까지 함께 두었습니다.
- 이유·결과 — CDC 에서는 DB 커밋이 곧 전달이어서, “저장은 됐는데 발행은 안 됨”이 생기지 않습니다.
벤더 응답은 200+outcome 과 에러로 분기 ✅
2025 Q3 · 신용카드 인증
- 상황 — 결제 벤더가 실패를 4xx 와 5xx 에 섞어서 돌려주었습니다.
- 선택 — 4xx 는 대부분 사용자가 고칠 수 있는 실패라서, 200 응답에 실패 사유 코드(outcome)를 담아 프론트가 안내를 달리 할 수 있게 했습니다. 5xx 는 벤더 장애로 보고 에러로 응답해 재시도를 유도합니다. 같은 결제수단 조합으로 짧은 시간에 시도가 몰리면 일정 시간 차단하는 장치도 추가했습니다(2025 Q4, 이후 정책 서비스로 이관).
- 이유·결과 — 사용자 실수와 시스템 장애를 구분해 응답하도록 했고, 이 분기는 리뷰를 거쳐 정했습니다.
일일 횟수 카운터는 Lua 로 🔁
2025 Q3
- 상황 — 하루 시도 횟수를 Redis 로 세는데, “횟수를 올리고 만료 시각을 거는” 두 동작이 따로 실행되면 그 사이에 문제가 생겨 만료 없는 키가 남을 수 있습니다.
- 선택 — “횟수 증가 + 첫 증가 시 만료 설정”을 Lua 스크립트 한 번으로 묶어 실행합니다. 날짜를 키에 넣지 않고 만료 시각(UTC 자정)으로 하루를 구분합니다. 대역별로 횟수를 다르게 두는 설정은 곧 되돌렸고, 2026 Q4 에 시간 정책만 설정으로 뺐습니다.
- 이유·결과 — 두 동작 사이의 틈이 사라졌습니다.
회고. 한도를 넘었는지 확인하는 조회는 Lua 밖에서 먼저 했고 증가는 따로 했습니다. 동시 요청 두 개가 같은 값을 읽으면 한도를 넘길 수 있어서, 확인·증가·만료를 모두 Lua 한 번에 묶어야 완전합니다.
통합 알림 — 3가지 결정 ✅
2025 Q3~Q4
- “모든 사용자에게 알림” 기능 제거 — 전체 사용자에게 한꺼번에 보내는 것은 알림 서비스가 감당하지 못합니다. 기획과 합의해 없앴습니다.
- URL·제목·본문을 백엔드에서 생성 — 프론트마다 따로 조립하던 중복을 없앴습니다. 알림 종류별 데이터 구조 확장과 URL 생성을 맡았습니다.
- JWT 검증을 알림 서비스가 직접 처리 — SSE 연결을 맺을 때마다 계정 서비스를 호출하던 비용과 의존을 없앴습니다.
캐시 무효화 대신 짧은 TTL ✅
2025 Q3 · 거래소 BFF
- 상황 — 북마크가 바뀌면 캐시를 지워야 했습니다. 처음에는 바로 지웠고, 이어서 비동기로 지웠는데 요청이 끝나면서 취소되어 지우기에 실패했습니다. 대기 시간을 10초, 30초, 1분으로 늘려도 마찬가지였습니다.
- 선택 — 캐시를 지우지 않고 3초 TTL 로 두었습니다.
- 이유·결과 — 지우는 동작이 요청 처리에 묶이면 실패하는 경우가 늘어납니다. 최대 3초 동안 옛 값이 보이는 것은 사용자 경험상 허용할 수 있었습니다.
컬럼 이름에 용도를 넣지 않는다 🔁
2025 Q4 ~ 2026 Q2
- 상황 — 해시 컬럼 이름을 기획 변경에 맞춰 세 번 바꿨고(검사 자체가 없어지기도 했습니다), 정책 테이블과 필드 이름도 여러 번 바꿨습니다.
- 선택 — 이름에 용도나 토큰 이름을 넣지 않고 중립적으로 짓습니다.
- 이유·결과 — 이름이 바뀔 때마다 DBA 검토, 마이그레이션, 코드 리뷰가 다시 필요합니다. 처음부터 중립적으로 지었다면 줄일 수 있었던 비용입니다.
롤백 토픽 대신 결과 토픽 ✅
2025 Q4 · 스테이블 코인 선차감
- 상황 — 출금 한도를 선차감한 뒤 실패하면 되돌려야 합니다. 되돌리기 전용 롤백 토픽을 따로 두면 순서와 중복을 처리해야 하는 토픽이 둘로 늘어납니다.
- 선택 — 트랜잭션 결과 토픽 하나에서 성공·실패·드랍을 받아, 실패일 때만 선차감을 되돌립니다. 성공이면 선차감이 곧 확정입니다. 토큰 종류는 enum 으로 받아 토큰을 추가해도 테이블을 바꾸지 않게 했습니다.
- 이유·결과 — 순서·중복을 신경 쓸 곳이 하나로 줄었습니다.
정책 전용 서비스로 분리 ✅
2026 Q1
- 상황 — 호출 제한, 접근 제어 같은 제어 로직이 공통 라이브러리, 계정 서비스, 블록체인 서비스에 흩어져 있었습니다.
- 선택 — 1단계로 계정 서비스와 공통 라이브러리의 제어 로직을 포털 서비스로 모았으나 콘텐츠 도메인과 섞여서, 정책만 맡는 서비스를 새로 만들었습니다. 각 서비스가 정책 전체를 메모리에 올려 두던 방식은 정책 서비스가 한 곳에서 들고 있는 구조로 바꿨습니다(메모리 문제 해결). 이후 블록체인 서비스의 출금 예외 목록까지 옮겨 통합을 마쳤습니다.
- 이유·결과 — 정책이 바뀔 때 고칠 곳이 한 곳이 되었습니다.
서비스 신설은 배포 설정까지 한 세트 ✅
2026 Q1 · api-sentinel
- 상황 — 코드만 먼저 끝내고 배포 설정을 뒤로 미루면, dev 에서만 도는 서비스가 되기 쉽습니다.
- 선택 — Helm 템플릿, 4개 환경 매니페스트, 게이트웨이 route, CI 배포 job 을 코드와 같은 주에 한 세트로 넣었습니다.
- 이유·결과 — dev 선행 배포 원칙과 짝을 이룹니다. 서비스가 만들어지는 즉시 모든 환경에 올릴 수 있는 상태가 됩니다.
jitter 와 timer — 정책 갱신을 흩뿌리는 방법 ✅
2026 Q1
- 상황 — 서비스 인스턴스들이 정책을 새로 읽는 시각이 같은 순간에 몰려 부하가 치솟았습니다.
- 선택 — 고정 주기(ticker)로 돌려 보았지만 모든 인스턴스가 같은 박자로 움직여 분산되지 않았습니다. 그래서 매 회차 갱신 간격에 무작위 지연(최대 ±5초, jitter)을 더하는 timer 로 확정했습니다.
- 이유·결과 — 갱신 시각이 서로 어긋나 부하가 한 번에 몰리지 않습니다. 고정 윈도우(fixed window) 방식은 한 번 없앴다가 sliding 과 함께 다시 둘 다 지원하게 되었습니다.
정책은 코드 분기가 아니라 enum/설정으로 ✅
2026 Q2~Q3
- 상황 — 출금 검사에서 “OTP 로 할지, 잠금 여부로 할지”가 기획에 따라 여러 번 오갔습니다. 그때마다 코드를 바꿔야 했습니다.
- 선택 — API 가 어떤 검사를 할지를 enum 으로 받게 했습니다. 같은 이유로 토큰 종류와 시간 정책도 설정이나 enum 으로 둡니다.
- 이유·결과 — 정책은 바뀌는 것이므로, 바뀔 때 코드 수정이 아니라 값 변경으로 끝나게 했습니다.
롤백에서 배운 것 ↩️
2026 Q3 · 출금 지연 피처
- 상황 — 서비스 7개에 걸쳐 구현한 출금 지연 피처가 기획 변경으로 전체 롤백되었습니다.
- 선택 — 구현 중에 기존 코드의 결함도 함께 고쳤습니다. DB 오류를 삼켜서 “commit unexpectedly resulted in rollback” 으로 끝나던 경로, 알림이 DB 커밋보다 먼저 나가던 경로, 갱신 조건이 식별자가 아니던 문제입니다. 그런데 이 수정들이 피처 커밋과 섞여 있어 롤백과 함께 사라졌습니다.
- 이유·결과 — 결함 수정은 피처와 분리해서 먼저 머지해야 롤백에서 살아남습니다. 큰 피처는 브랜치와 메모를 남겨 두어야 다시 시작하는 비용이 줄어듭니다.
잠금은 Redis 가 아니라 컬럼으로 ✅
2026 Q3 · 이메일 변경 후 보안 설정 잠금
- 상황 — 이메일을 바꾸면 일정 시간 동안 보안 설정을 바꿀 수 없게 막아야 합니다. Redis TTL 로 구현하면 기간을 바꿔도 이미 만들어진 키에는 반영되지 않고, 데이터가 영속적이지도 않습니다.
- 선택 — 이메일을 바꾼 시각을 DB 컬럼에 저장하고, 그 시각에서 지금까지의 경과로 잠금을 판단합니다. 잠금 확인용 API 를 따로 두면 프론트 호출이 하나 늘어서 기존 응답의 필드 하나로 합쳤습니다. 복제 지연 때문에 잠금이 늦게 보일 수 있는 구간은 Primary DB 를 직접 조회해 없앴고, 잠금 시간 설정이 0 이면 조회를 생략합니다.
- 이유·결과 — 기간을 바꾸면 바로 반영되고, 서버가 재시작되어도 잠금이 유지됩니다.
대량 수신자는 스트리밍으로 나눠 받는다 ✅
2026 Q4 · 백오피스 알림
- 상황 — 수만 건의 수신자 목록이 상세 조회 응답에 한꺼번에 실려 있었습니다.
- 선택 — 상세 응답에서 수신자를 빼고, 500건씩 나눠 보내는 스트리밍 RPC 로 분리했습니다. 상한을 명시하고, 받는 쪽 규칙(수신자는 chunk 형태로만 오고 다른 곳에 섞여 와도 무시)도 코드에 적었습니다. 수정할 때 빈 목록이 오면 기존 목록을 덮어쓰지 않아서, 실수로 대상 전체가 사라지는 사고를 막았습니다.
- 이유·결과 — 응답 크기가 일정해졌고, 큰 데이터를 쪼개는 것보다 받는 쪽이 어떻게 조립하는지를 정해 두는 것이 핵심이었습니다.