ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Eventual Consistency 트러블슈팅: Character Rewards INSERT 멱등성 미보장 버그 픽스
    이코에코(Eco²)/Troubleshooting 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

    댓글

ABOUT ME

🎓 부산대학교 정보컴퓨터공학과 학사: 2017.03 - 2023.08
☁️ Rakuten Symphony Jr. Cloud Engineer: 2024.12.09 - 2025.08.31
🏆 2025 AI 새싹톤 우수상 수상: 2025.10.30 - 2025.12.02
🌏 이코에코(Eco²) 백엔드/인프라 고도화 중: 2025.12 - Present

Designed by Mango