이코에코(Eco²) Knowledge Base/Troubleshooting
Eventual Consistency 트러블슈팅: Character Rewards INSERT 멱등성 미보장 버그 픽스
mango_fr
2025. 12. 30. 14:32

날짜: 2025-12-30
영향 범위:my.user_characters테이블, 캐릭터 인벤토리 조회
심각도: Medium (데이터 중복, UX 영향, 해결 전 표기 13/13)
0. 부하테스트 수행 기록
이 버그는 12월 28-29일간 집중 부하테스트 과정에서 발견.
총 13회의 k6 부하테스트를 수행하며 Scan SSE 파이프라인의 성능을 검증했고, 그 과정에서 캐릭터 보상 중복 저장 문제가 발생.
부하테스트 요약
| # | VUs | Duration | Scan Total | Completion Rate | Success Rate |
|---|---|---|---|---|---|
| 1 | 100 | 2m | 1,150 | 96.0% | 100% |
| 2 | 100 | 2m | 1,020 | 98.5% | 100% |
| 3 | 200 | 2m | 1,831 | 99.8% | 100% |
| 4 | 250 | 2m | 2,131 | 99.9% | 100% |
| 5 | 300 | 2m | 2,538 | 99.9% | 100% |
| 6 | 400 | 2m | 3,385 | 98.2% | 100% |
| 7 | 500 | 2m | 4,062 | 94.0% | 100% |
| 8 | 600 | 2m | 2,117 | 33.2% | 100% |
| 9 | 200 | 3m | 2,832 | 99.2% | 100% |
| 10 | 250 | 3m | 3,524 | 99.8% | 100% |
| 11 | 300 | 3m | 4,231 | 98.7% | 100% |
| 12 | 400 | 3m | 5,647 | 96.5% | 100% |
| 13 | 500 | 3m | 6,892 | 91.3% | 100% |
테스트 조건
- 테스트 기간: 2025-12-28 ~ 2025-12-29 (약 2일)
- 단일 테스트 사용자: user_id
8b8ec006-2d95-45aa-bdef-... - 총 Scan 요청: 약 38,000회
- 총 캐릭터 보상 시도: 약 38,000회 (동일 유저, 동일 캐릭터 반복)
문제 발생 조건
동일 유저가 동일 캐릭터를 ~38,000회 반복 획득 시도
↓
character-worker 캐시에서 character_id 불일치 발생 (2건)
↓
ON CONFLICT (user_id, character_id) 통과 → 중복 INSERT
↓
user_characters에 동일 character_code 2개 레코드 존재
1. 문제 발견
1.1 증상
사용자가 /api/v1/user/me/characters API를 호출했을 때, 동일한 캐릭터가 두 번 표시되는 문제 발생.
[
{
"id": "a68c313e-3fd3-41b6-ae90-...",
"code": "char-paepy",
"name": "페이피",
"type": "",
"dialog": "테이프와 스테이플은 떼고 깨끗하게 접어요!",
"acquired_at": "2025-12-24T12:10:19.825556Z"
},
// ... 중간 생략 ...
{
"id": "c7df88f5-66af-4d75-bcd4-...",
"code": "char-paepy",
"name": "페이피",
"type": "골판지류, 신문지, 과자상자, 백판지, 책자",
"dialog": "테이프와 스테이플은 떼고 깨끗하게 접어요!",
"acquired_at": "2025-12-02T23:20:20.199493Z"
}
]
관찰된 중복 (
char-petty(페티): 2개char-paepy(페이피): 2개
2. 시스템 아키텍처 (문제 발생 지점)
2.1 캐릭터 보상 저장 흐름
┌─────────────────────────────────────────────────────────────┐
│ Character Reward Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ scan-worker (scan.reward) │
│ │ │
│ ├── 1. character.match 호출 (동기) │
│ │ └── character-worker의 로컬 캐시에서 매칭 │
│ │ └── 응답 반환: { character_id, ... } │
│ │ │
│ └── 2. DB 저장 task 발행 (Fire & Forget) │
│ │ │
│ ├── character.save_ownership │
│ │ └── character.character_ownerships │
│ │ └── ON CONFLICT (user_id, character_id)│
│ │ │
│ └── my.save_character ← 🐛 문제 발생 지점 │
│ └── user_profile.user_characters │
│ └── ON CONFLICT (user_id, character_id)│
│ │
└─────────────────────────────────────────────────────────────┘
- DB 저장은 비즈니스 로직에서 분리(Fire & Forget), 별도의 Persistence Queue에 쌓은 뒤 일괄 배치 처리 수행
- UNIQUE INSERT(user.id, character.id)로 멱등성을 보장하도록 유도
- 일정 시간이 지나면 데이터의 정합성이 맞춰지는 구조 (Eventual Consistency)
2.2 관련 테이블 스키마
-- user_profile.user_characters (문제 발생 테이블)
CREATE TABLE user_profile.user_characters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
character_id UUID NOT NULL, -- ← 문제의 원인
character_code VARCHAR(64) NOT NULL,
...
acquired_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT uq_user_character UNIQUE (user_id, character_id) -- ← 문제의 제약
);
3. 데이터 분석
3.1 중복 데이터 조회
SELECT user_id, character_code, COUNT(*) as cnt
FROM user_profile.user_characters
GROUP BY user_id, character_code
HAVING COUNT(*) > 1;
결과:
user_id | character_code | cnt
--------------------------------------+----------------+-----
8b8ec006-2d95-45aa-bdef-... | char-petty | 2
8b8ec006-2d95-45aa-bdef-... | char-paepy | 2
3.2 상세 데이터 분석
SELECT id, user_id, character_id, character_code, character_name, acquired_at
FROM user_profile.user_characters
WHERE character_code IN ('char-petty', 'char-paepy')
AND user_id = '8b8ec006-2d95-45aa-bdef-e08201f1bb82'
ORDER BY character_code, acquired_at DESC;
결과:
id | user_id | character_id | character_code | acquired_at
--------------------------------------+--------------------------------------+--------------------------------------+----------------+-------------------------------
de0922d7-c95a-4022-... | 8b8ec006-... | a68c313e-3fd3-41b6-ae90-... | char-paepy | 2025-12-24 12:10:19 ← 새로운 ID
48d11f4e-66f2-4b5b-... | 8b8ec006-... | c7df88f5-66af-4d75-bcd4-... | char-paepy | 2025-12-02 23:20:20 ← 원래 ID
6ec7120b-da70-4374-... | 8b8ec006-... | f2c21422-c57a-4012-818e-... | char-petty | 2025-12-24 03:13:22 ← 새로운 ID
24d1af0a-a1be-490f-... | 8b8ec006-... | 44490ae4-e02b-451b-8bed-... | char-petty | 2025-12-21 06:45:20 ← 원래 ID
3.3 character 테이블 검증
SELECT id, code, name FROM character.characters
WHERE code IN ('char-petty', 'char-paepy');
결과:
id | code | name
--------------------------------------+------------+--------
c7df88f5-66af-4d75-bcd4-... | char-paepy | 페이피 ← 올바른 ID
44490ae4-e02b-451b-8bed-... | char-petty | 페티 ← 올바른 ID
3.4 존재하지 않는 character_id 확인
SELECT id, code, name FROM character.characters
WHERE id IN ('a68c313e-3fd3-41b6-ae90-...', 'f2c21422-c57a-4012-818e-...');
결과:
id | code | name
----+------+------
(0 rows)
핵심 발견: user_characters에 저장된 character_id 중 일부가 character.characters 테이블에 존재하지 않음!
4. 근본 원인 분석
4.1 문제의 핵심
┌─────────────────────────────────────────────────────────────┐
│ Eventual Consistency 문제 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. character 테이블의 ID가 변경됨 │
│ - 원인: DB 마이그레이션, 테이블 재생성, 또는 데이터 복구 │
│ - 결과: 기존 ID와 새 ID가 공존 │
│ │
│ 2. character-worker의 로컬 캐시가 잘못된 ID를 가짐 │
│ - 캐시 워밍업 시점에 임시 데이터 로드 │
│ - 또는 캐시 동기화 이벤트 누락 │
│ │
│ 3. UNIQUE 제약이 character_id 기준 │
│ - 같은 character_code도 다른 character_id면 저장 가능 │
│ - 멱등성 보장 실패 │
│ │
└─────────────────────────────────────────────────────────────┘
4.2 타임라인 추정
2025-12-02: char-paepy 최초 획득 (character_id: c7df88f5...)
↓
2025-12-21: char-petty 최초 획득 (character_id: 44490ae4...)
↓
[2025-12-23 ~ 24 사이 어딘가]
- character 테이블 재생성 또는 데이터 마이그레이션
- character-worker 캐시에 임시 ID가 로드됨
↓
2025-12-28 ~ 2025-12-29: char-petty 부하테스트로 38,000 호출
- UNIQUE(user_id, character_id) 통과 → 중복 저장 1회 발생
4.3 코드 분석
문제의 코드 (my/tasks/sync_character.py):
sql = text(
f"""
INSERT INTO user_profile.user_characters
(user_id, character_id, character_code, ...)
VALUES {", ".join(values)}
ON CONFLICT (user_id, character_id) DO NOTHING -- ← 문제!
"""
)
문제점:
character_id가 캐시에서 잘못 전달되면 다른 레코드로 인식- 동일한 캐릭터(character_code)가 다른 ID로 중복 저장
5. 해결 방안
5.1 설계 원칙
┌─────────────────────────────────────────────────────────────┐
│ 멱등성 기준 변경 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 기존: UNIQUE(user_id, character_id) │
│ - 캐시 불일치 시 중복 허용 │
│ - character_id는 변할 수 있음 (DB 마이그레이션 등) │
│ │
│ 변경: UNIQUE(user_id, character_code) │
│ - character_code는 불변 (캐릭터마다 고유) │
│ - 캐시 불일치에도 중복 방지 │
│ - Self-healing: 기존 레코드의 character_id를 최신으로 갱신 │
│ │
└─────────────────────────────────────────────────────────────┘
5.2 수정된 코드
1. my/models/user_character.py:
class UserCharacter(Base):
__tablename__ = "user_characters"
__table_args__ = (
# Bug fix (2025-12-30): character_id → character_code 기준으로 변경
UniqueConstraint("user_id", "character_code", name="uq_user_character_code"),
{"schema": "user_profile"},
)
2. my/tasks/sync_character.py:
sql = text(
f"""
INSERT INTO user_profile.user_characters
(user_id, character_id, character_code, ...)
VALUES {", ".join(values)}
ON CONFLICT (user_id, character_code) DO UPDATE SET
character_id = EXCLUDED.character_id, -- Self-healing
character_name = EXCLUDED.character_name,
character_type = COALESCE(EXCLUDED.character_type, user_profile.user_characters.character_type),
character_dialog = COALESCE(EXCLUDED.character_dialog, user_profile.user_characters.character_dialog),
updated_at = NOW()
"""
)
3. my/repositories/user_character_repository.py:
stmt = (
insert(UserCharacter)
.values(...)
.on_conflict_do_update(
constraint="uq_user_character_code", # Bug fix
set_={
"character_id": character_id, # Self-healing
"character_name": character_name,
...
},
)
.returning(UserCharacter)
)
6. 마이그레이션 실행
6.1 중복 데이터 정리
-- Step 1: 중복 확인
SELECT user_id, character_code, COUNT(*) as cnt
FROM user_profile.user_characters
GROUP BY user_id, character_code
HAVING COUNT(*) > 1;
-- Step 2: 중복 삭제 (가장 오래된 것만 유지)
DELETE FROM user_profile.user_characters
WHERE id IN (
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY user_id, character_code
ORDER BY acquired_at ASC -- 가장 오래된 것 유지
) as rn
FROM user_profile.user_characters
) ranked
WHERE rn > 1
);
-- 결과: DELETE 2
6.2 제약조건 변경
-- Step 3: 기존 제약 삭제
ALTER TABLE user_profile.user_characters
DROP CONSTRAINT IF EXISTS uq_user_character;
-- Step 4: 새 제약 생성
ALTER TABLE user_profile.user_characters
ADD CONSTRAINT uq_user_character_code UNIQUE (user_id, character_code);
6.3 검증
\d user_profile.user_characters
-- 결과:
-- Indexes:
-- "user_characters_pkey" PRIMARY KEY, btree (id)
-- "ix_user_characters_character_id" btree (character_id)
-- "ix_user_characters_user_id" btree (user_id)
-- "uq_user_character_code" UNIQUE CONSTRAINT, btree (user_id, character_code)
7. 검증
7.1 중복 저장 테스트
# 같은 캐릭터를 다른 character_id로 저장 시도
await repo.grant_character(
user_id=user_id,
character_id=UUID("a68c313e-3fd3-41b6-ae90-72640c6f0aba"), # 잘못된 ID
character_code="char-petty",
character_name="페티",
...
)
# 결과: 기존 레코드의 character_id가 새 값으로 갱신됨 (UPSERT)
# 중복 레코드 생성 안됨 ✅
7.2 API 응답 확인
curl -H "Authorization: Bearer $TOKEN" \
https://api.dev.growbin.app/api/v1/user/me/characters | jq '.[] | .code' | sort | uniq -c
# 결과: 모든 캐릭터가 1개씩만 존재 ✅
8. 교훈 및 권장사항
8.1 Eventual Consistency 환경에서의 멱등성
┌─────────────────────────────────────────────────────────────┐
│ Best Practices │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. UNIQUE 제약 기준 선택 │
│ - 변할 수 있는 값: character_id, session_id 등 ❌ │
│ - 불변 값: character_code, user_id + business_key ✅ │
│ │
│ 2. Self-Healing UPSERT │
│ - ON CONFLICT DO NOTHING → 문제 숨김 ❌ │
│ - ON CONFLICT DO UPDATE → 자동 복구 ✅ │
│ │
│ 3. 캐시 일관성 │
│ - 캐시 데이터는 언제든 변할 수 있음을 가정 │
│ - DB가 진실의 원천(Source of Truth) │
│ - 캐시 불일치에도 DB 무결성 보장 필요 │
│ │
└─────────────────────────────────────────────────────────────┘
8.2 향후 개선 사항
- character-worker 캐시 검증: DB와 캐시 일관성 주기적 검증
- 모니터링 추가: 중복 저장 시도 감지 메트릭
- 테스트 추가: 캐시 불일치 상황 시뮬레이션 테스트
9. 관련 커밋
e67ab58a fix(my): use character_code for idempotency instead of character_id
변경 파일:
domains/my/models/user_character.pydomains/my/repositories/user_character_repository.pydomains/my/tasks/sync_character.py