예측 아카이브 버그 6개 연속 수정기 — 테이블이 2개였다

문제 해결 노트··30분 읽기·
예측 아카이브의 6개 연속 버그를 하나씩 추적하며 수정하는 과정

버그 1: 일치 수가 전부 "0" — span과 table-cell의 충돌

예측 아카이브 페이지를 열었는데, 모든 예측의 일치 수가 "0"으로 표시되고 있었습니다. 3개, 4개 맞은 예측도 있을 텐데 전부 0. 데스크탑에서만 그랬고, 모바일에서는 정상이었습니다.

Before — 초급 프롬프트:
아카이브에서 일치 수가 다 0으로 보여. 고쳐줘.
After — 중급 프롬프트:
당신은 React/Tailwind CSS 렌더링 디버깅 전문가입니다.

[문제]
- 예측 아카이브 테이블에서 "일치 수" 컬럼이 모든 행에서 0으로 표시
- 데스크탑에서만 발생, 모바일은 정상
- 실제 DB에는 정확한 일치 수가 저장되어 있음

[현재 코드 — virtualized-archive-table.tsx]
<TableCell className="hidden sm:table-cell text-center">
  <span className="px-2 py-1 rounded-full text-xs font-medium ...">
    {matchCount}개 일치
  </span>
</TableCell>

[요청]
1. 데스크탑에서만 0으로 보이는 이유 분석
2. span + table-cell display 충돌 가능성 확인
3. 수정 방법

원인이 재밌었습니다. 태그의 기본 display는 inline인데, Tailwind의 hidden sm:table-cell은 데스크탑에서 display: table-cell을 적용합니다. inline 요소가 table-cell 컨텍스트 안에 들어가면 값이 제대로 렌더링되지 않는 CSS 충돌이었어요.

모바일에서는 hidden(display: none)이라 아예 안 보이니까 문제가 없었고, 데스크탑에서만 table-cell로 전환될 때 터진 겁니다.

// Before — span + table-cell 충돌
<TableCell className="hidden sm:table-cell text-center">
  <span className="px-2 py-1 rounded-full text-xs font-medium ...">
    {matchCount}개 일치
  </span>
</TableCell>

// After — div + inline-block으로 충돌 해소
<TableCell className="hidden sm:table-cell text-center">
  <div className="inline-block px-2 py-1 rounded-full text-xs font-medium ...">
    {matchCount}개 일치
  </div>
</TableCell>
로 바꾸고 inline-block을 추가.
의 기본 display가 block이라 table-cell 컨텍스트와 호환되고, inline-block으로 인라인 배치도 가능한 구조가 됐습니다.

버그 2: 보라색 도트가 엉뚱한 번호에 표시

일치 수 문제를 고치고 보니, 맞은 번호에 보라색 도트를 표시하는 기능도 이상했습니다. 1211회차에서 도트가 아예 안 나오고, 1210회차에서는 엉뚱한 번호에 도트가 찍혀 있었어요.

당신은 React 데이터 흐름 디버깅 전문가입니다.

[문제]
- 아카이브 테이블에서 당첨 번호와 일치하는 번호에 보라색 도트 표시
- 1211회: 도트가 아예 안 나옴
- 1210회: 엉뚱한 번호에 도트가 찍힘

[현재 데이터 흐름]
- 도트 표시: latestDraws API에서 당첨 번호를 가져와서 비교
- 일치 개수: DB의 matching_numbers 필드 사용
→ 두 개의 다른 데이터 소스를 사용 중

[요청]
1. latestDraws API 캐시와 DB 데이터가 불일치하는 원인
2. 하나의 데이터 소스로 통일하는 방법

원인: 도트 표시는 latestDraws API(별도 캐시)에서 당첨번호를 가져오고, 일치 개수는 DB의 matching_numbers 필드를 사용하고 있었습니다. 두 개의 데이터 소스가 따로 놀고 있었던 거예요. 1211회차가 latestDraws 캐시에 아직 없으니 도트가 안 나오고, 1210회차는 캐시 데이터가 틀려서 엉뚱한 도트가 찍힌 겁니다.

AI가 수정한 방식: ArchiveItemSchemamatched_winning_numbers 필드를 추가하고, getMatchingInfoForItem() 헬퍼 함수를 새로 만들어서 DB의 matched_winning_numbers를 우선 사용하도록 변경. latestDraws는 보너스 번호 확인용 fallback으로만 남겼습니다.

여기서 한 가지 더 문제가 나왔습니다. 빈 배열 [] 처리였어요:

// Before — 빈 배열을 "미처리"로 잘못 판단
const dbMatched = item.matched_winning_numbers;
if (dbMatched && Array.isArray(dbMatched) && dbMatched.length > 0) {
  // 처리...
}
// [] 빈 배열이면 여기를 건너뛰고 → fallback 실행 → 엉뚱한 도트

// After — 빈 배열도 "cron 처리 완료, 0개 일치"로 신뢰
if (Array.isArray(dbMatched)) {
  return { winningNumbers: dbMatched, bonusNumber };
}
dbMatched.length > 0 조건이 함정이었습니다. 빈 배열 []은 "0개 일치"라는 정상 결과인데, 이걸 "아직 cron이 처리 안 한 상태"로 착각해서 fallback이 실행됐거든요. Array.isArray()만 체크하면 빈 배열이든 [3, 15, 27]이든 모두 cron 처리 완료로 인식됩니다.

버그 3: 테이블이 2개였다

자동 저장을 고치려고 살펴봤더니, 근본적인 문제가 나왔습니다. DB에 테이블이 2개 있었어요:

| | prediction_archive | SavedPredictions |
|---|---|---|
| 목적 | 전체 예측 기록 | 사용자 저장 번호 |
| 조회 페이지 | /predict/archive | /my-lotto/saved |
| 번호 필드명 | numbers (JSONB) | predicted_numbers (INTEGER[]) |
| 알고리즘명 | STATISTICAL (대문자) | statistical (소문자) |

AI가 아카이브 기능과 저장 기능을 별도로 만들면서 각각 다른 테이블에 저장하도록 만든 겁니다. 통계 페이지에서 번호를 생성하면 prediction_archive에 저장되는데, "내 저장 번호" 페이지는 SavedPredictions에서 읽으니까 안 보이는 거였죠.

[피드백]
테이블이 2개 존재합니다.
- prediction_archive: 아카이브 페이지가 읽는 테이블 (numbers 필드, 대문자 알고리즘명)
- SavedPredictions: "내 저장 번호"가 읽는 테이블 (predicted_numbers 필드, 소문자 알고리즘명)

[요청]
1. 저장 API가 SavedPredictions 테이블을 사용하도록 수정
2. 필드명 매핑: numbers → predictedNumbers
3. 알고리즘명 매핑: STATISTICAL → statistical

수정 전후의 저장 API 호출:

// Before — prediction_archive 테이블로 저장
fetch('/api/predict/archive/save', {
  body: JSON.stringify({
    algorithm: 'STATISTICAL',
    numbers,
    round: nextRound,
    userId,
  }),
})

// After — SavedPredictions 테이블로 저장
fetch('/api/predict', {
  body: JSON.stringify({
    algorithm: 'statistical',
    predictedNumbers: numbers,
    confidence: 80,
    round: nextRound,
    analysis: {},
  }),
})

엔드포인트, 필드명, 알고리즘명 대소문자까지 3가지가 한꺼번에 바뀌었습니다.

버그 4: 같은 번호가 계속 저장

테이블 통합 후 테스트하니까 새로운 문제. 같은 예측을 여러 번 저장하면 중복으로 들어가고 있었습니다. 1190회 [3, 12, 24, 31, 38, 42]가 5번이나 저장됐어요.

당신은 데이터베이스 중복 방지 전문가입니다.

[문제]
- 같은 회차 + 같은 번호 조합이 중복 저장됨
- PostgreSQL 배열 타입이라 단순 문자열 비교 불가

[요청]
1. 저장 전 같은 회차 + 같은 번호 조합 존재 여부 확인
2. 주의: [3, 12, 24, 31, 38, 42]와 [42, 38, 31, 24, 12, 3]은 같은 조합
3. 중복 시 HTTP 409 Conflict 반환

"정렬 후 비교"를 명시한 게 핵심이었습니다. 이걸 안 쓰면 AI가 순서 그대로 비교해서 같은 조합을 못 잡거든요. AI가 만든 중복 검사 로직:

// 같은 회차의 기존 예측을 가져와서 클라이언트에서 배열 비교
const { data: duplicateData } = await supabase
  .from('prediction_archive')
  .select('id, numbers')
  .eq('round', round)
  .eq('user_id', userId)
  .limit(50);  // 쿼리 범위 제한

const isDuplicate = duplicateData.some(record => {
  const recordNumbers = [...record.numbers].sort((a, b) => a - b);
  const currentNumbers = [...sortedNumbers].sort((a, b) => a - b);
  return JSON.stringify(recordNumbers) === JSON.stringify(currentNumbers);
});

if (isDuplicate) {
  return NextResponse.json(
    { success: false, isDuplicate: true }, { status: 409 }
  );
}
.limit(50)으로 쿼리 범위를 제한하고, select('id, numbers')로 필요한 필드만 가져오는 최적화도 들어갔습니다. PostgreSQL 배열은 DB 레벨에서 순서 무관 비교가 어려워서, 클라이언트에서 정렬 후 JSON 문자열로 비교하는 방식을 택했습니다.

버그 5: 자동 저장 토스트가 안 뜬다

중복 방지까지 넣고 나니, 사용자 피드백이 없는 문제가 남았습니다. 저장 성공이든 중복이든 비로그인이든, 아무 안내가 없었어요.

[피드백]
자동 저장 결과에 대한 사용자 피드백이 전혀 없습니다.

[요청]
저장 결과에 따라 토스트 메시지를 표시해주세요:
- 성공: "N세트가 자동 저장되었습니다!"
- 중복(409): "이미 저장된 번호입니다."
- 비로그인(401): "로그인하면 번호가 자동 저장됩니다."

AI가 생성한 여러 세트를 순회하면서 성공/중복 카운트를 세고, 결과에 따라 다른 토스트를 보여주는 로직을 추가했습니다.

버그 6: 알고리즘 이름이 섞여 있었다

마지막 버그. 아카이브에서 알고리즘별 필터링이 안 됐습니다. 원인: custom, CUSTOM, Custom이 다 다른 값으로 저장되어 있었거든요. virtualized-archive-table.tsx에서 알고리즘 아이콘 매핑도 대소문자별로 fallback을 넣어두고 있었습니다:

// 대소문자 혼용으로 인한 이중 매핑
const algorithmIcons: Record<string, any> = {
  'CUSTOM': Users,
  'custom': Users,     // fallback
  'LUCKY': Trophy,
  'lucky': Trophy,     // fallback
};
당신은 데이터 정규화 전문가입니다.

[문제]
- 알고리즘 이름 대소문자 혼용: 'custom'/'CUSTOM'/'Custom'
- 18건의 기존 레코드에 불일치
- 아이콘 매핑에서 대소문자별 fallback을 넣어둔 상태

[요청]
1. 모든 알고리즘명을 대문자로 통일하는 마이그레이션 SQL
2. 저장 시점에서 toUpperCase() 적용
3. fallback 매핑 제거

18건의 기존 레코드를 대문자로 마이그레이션하고, 저장 시점에서 toUpperCase()를 적용해서 이후 데이터는 항상 대문자로 들어가게 만들었습니다.

5개 커밋으로 순차 수정한 흐름

| 커밋 | 내용 | 파일 수정 |
|------|------|----------|
| bd2ecf4 | 보라색 도트 — DB 우선 사용 | virtualized-archive-table.tsx (94줄), archive.ts (5줄) |
| 536b9ab | 빈 배열 판단 오류 수정 | virtualized-archive-table.tsx |
| 9ef6912 | 테이블 2개 → SavedPredictions 통일 | predict/statistics/page.tsx |
| 22f2abd | 자동 저장 토스트 메시지 | predict/statistics/page.tsx |
| 0e2bec5 | 중복 저장 방지 (정렬 후 비교) | predict/archive/save/route.ts |

6개 버그를 잡으면서 깨달은 건, AI가 만든 코드에서 "기능 간 연결"이 가장 취약하다는 거였습니다. 예측 기능은 prediction_archive에 저장하고, 조회 기능은 SavedPredictions에서 읽고, 도트 표시는 latestDraws API에서 가져오고. 각 기능은 혼자서는 잘 돌아가는데, 연결되는 지점에서 테이블이 다르고, 필드명이 다르고, 대소문자가 다릅니다. "이 기능과 저 기능이 같은 데이터를 공유한다"는 걸 AI한테 처음부터 명시해야 이런 문제를 줄일 수 있었습니다.

공유

댓글