ENGINEERING NOTES
실서비스에서 부딪힌 문제와 해결
튜토리얼에는 없는 문제들입니다. 원인을 계층별로 격리하고, 근본 원인을 수정하고, 회귀 테스트로 방어한 기록입니다.
01
경험치 경제의 악용 가능성 — 게임 밸런스를 코드로 지키기
PROBLEMEXP가 무게×횟수×세트에 선형 비례하고 상한이 없어, 비정상 입력 1건으로 수백만 EXP 파밍(즉시 만렙)이 가능했다. 고반복 기록은 Epley 공식 특성상 강도 배율이 항상 최대치에 붙었다.
SOLUTION단건 EXP 캡 + 일일 EXP 캡(초과 시 숨기지 않고 capReached 플래그로 노출), Epley 입력 reps 12회 클램프, PR 퀘스트에 최소 +2.5% 갱신 조건. 악용 시나리오를 유닛 테스트로 고정.
// 일일 한도는 트랜잭션 내부에서 집계 — 동시 요청 우회 차단
const todayExp = await tx.workoutRecord.aggregate({
where: { userId, createdAt: { gte: dayStart } }, _sum: { expGained: true },
});
const granted = Math.min(calculatedExp, Math.max(0, DAILY_EXP_CAP - todayExp));
02
상대적 강도 EXP — "공정함"을 공식으로 만들기
PROBLEM절대 무게 기반 보상은 초보자에게 불공평하고, 가벼운 무게 고반복 도배가 최적 전략이 되는 왜곡을 낳는다.
SOLUTION유저별 공인 1RM 대비 강도 배율(0.3~1.05)로 보상을 정규화. 공인 1RM 초과 기록은 차단하고 영상 레벨테스트로만 갱신 — 보상 체계와 데이터 신뢰성을 같이 지켰다.
const set1RM = weight * (1 + Math.min(reps, 12) / 30); // Epley + 고반복 클램프
const intensity = Math.min(1.05, Math.max(0.3, set1RM / official1RM));
const totalExp = (baseExp + gradeBonus + volumeBonus + categoryBonus) * intensity;
03
서비스워커가 구버전 앱을 영구 서빙 — 배포가 도달하지 않는 사고
PROBLEM오프라인용 SW가 index.html을 Cache-First로 캐싱해, 배포 후에도 기존 유저 브라우저에 구버전 앱이 영구 표시. 하드 리프레시로도 해소 불가 — "고쳐도 반영이 안 되는" 유령 버그의 원인.
SOLUTION브라우저의 sw.js 자동 업데이트 감지를 역이용한 self-destroying SW 배포: 활성화 즉시 전체 캐시 삭제 → 자기 등록 해제 → 열린 탭 자동 새로고침. 유저 개입 없이 전원 자동 복구.
self.addEventListener('activate', (event) => event.waitUntil((async () => {
await Promise.all((await caches.keys()).map(k => caches.delete(k)));
await self.registration.unregister();
(await self.clients.matchAll()).forEach(c => c.navigate(c.url)); // 자동 새로고침
})()));
04
"화면 위가 잘려요" — 3개의 원인이 겹친 UI 버그 추적기
PROBLEM동일 증상에 원인이 3개 중첩: ① 라우트 이동 시 이전 페이지 스크롤 위치 유지 ② 조상의 overflow:hidden이 sticky 헤더 무력화 ③ 결과 모달이 화면보다 길 때 items-center 중앙정렬로 상단이 스크롤 불가 영역으로 밀려남.
SOLUTIONPlaywright로 유저의 실제 조작 시퀀스를 재현·캡처해 원인을 하나씩 격리. 전역 ScrollToTop, overflow-x-clip 전환(가로 넘침 차단 + sticky 보존), 모달은 items-center 대신 자식 margin:auto 패턴으로 교체.
/* 모달 상단 잘림의 표준 해법: 공간이 남으면 중앙, 넘치면 위부터 스크롤 */
/* container */ .overlay { display: flex; justify-content: center; overflow-y: auto; }
/* child */ .modal { margin-block: auto; height: fit-content; }
05
데이터 정합성 — 시드와 서비스 로직의 계약을 테스트로 강제
PROBLEM운동 코드 매핑 테이블의 키가 실제 DB 시드 코드와 어긋나며 추천 무게가 전부 기본값으로 추락(초보 여성에게 과중량 추천 위험). 맵 클리어 조건도 시드와 판정 로직의 타입 문자열이 달라 진행 불가 상태였다.
SOLUTIONshared 워크스페이스에 단일 타입 정의(ClearCondition)를 두고 시드·서비스가 공유. "시드의 모든 코드 ↔ 매핑 ↔ 표준 데이터" 3단 정합성을 검증하는 회귀 테스트를 추가해 같은 사고의 재발을 빌드 단계에서 차단.
it('seed의 모든 운동 code가 추천무게 매핑에 존재해야 한다', () => {
const missing = seedCodes.filter(code => !(code in EXERCISE_CODE_TO_NAME));
expect(missing).toHaveLength(0); // 누락 시 CI에서 즉시 실패
});
06
인증 상태 유실 — Redux Persist와 내비게이션의 레이스
PROBLEM로그인 직후 페이지 이동 시 간헐적으로 accessToken이 NULL — Redux 상태가 localStorage에 기록되기 전에 새 페이지가 로드되는 레이스 컨디션.
SOLUTIONpersistor.flush()로 저장 완료를 보장한 뒤에만 이동. 회원가입의 User + RefreshToken 생성도 prisma.$transaction으로 묶어 부분 저장(고아 레코드) 자체를 제거.
dispatch(setCredentials({ user, accessToken, refreshToken }));
await persistor.flush(); // localStorage 쓰기 완료 보장
window.location.href = '/home';
07
무료 인프라에서 운영하기 — Supabase × Fly.io 생존기
PROBLEMSupabase 무료 플랜은 7일 미사용 시 DB 자동 정지, Transaction Pooler는 Prisma와 circuit breaker 충돌, Fly.io auto-stop은 콜드 스타트 타임아웃 유발.
SOLUTIONGitHub Actions cron으로 매시간 헬스체크(DB 쿼리 포함), pooler 대신 직접 연결(:5432) 전환, 운영 배포는 prisma migrate deploy만 허용 + 실패 시 서버 기동 중단(exit 1)으로 데이터 정합성 보호.