이코에코(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 향후 개선 사항

  1. character-worker 캐시 검증: DB와 캐시 일관성 주기적 검증
  2. 모니터링 추가: 중복 저장 시도 감지 메트릭
  3. 테스트 추가: 캐시 불일치 상황 시뮬레이션 테스트

9. 관련 커밋

e67ab58a fix(my): use character_code for idempotency instead of character_id

변경 파일:

  • domains/my/models/user_character.py
  • domains/my/repositories/user_character_repository.py
  • domains/my/tasks/sync_character.py