TypeORM 목록 API에서 관계 필드를 정렬 키로 쓴 뒤 운영 목록 조회가 500으로 실패했어요. 운영 데이터와 생성 SQL, PR의 케이스 표를 바탕으로 원인을 짚고 세 번의 수정을 따라가 봤어요. 장애 지속 시간과 영향받은 요청 수, 변경 전후 쿼리 실행 시간은 확인하지 않았어요.
limit에 따라 달라진 응답
첫 PR에서는 프로젝트 목록을 기수 순으로 정렬하려고 cohorts.recruitStartAt을 정렬 키로 잡았어요. 로컬에서는 멀쩡했지만 운영에서는 목록 조회가 500으로 실패했어요. 같은 엔드포인트도 limit에 따라 결과가 달랐어요.
| limit | 결과 |
|---|---|
| 1, 5, 9 | 500 |
| 20, 100 | 200 |
당시 운영 프로젝트는 열 건 정도였어요. limit이 전체 건수보다 크면 hasNext가 false가 되어 다음 페이지 커서를 만드는 코드로 들어가지 않아요. 20이나 100으로 요청하면 오류가 나는 코드에 닿지 않아 200이 나왔지만 프론트는 한 페이지에 10건 미만을 요청해 500을 맞았어요. 상세 조회가 멀쩡해 보인 것도 커서를 만들지 않아서였어요.
soft-delete된 기수가 정렬과 커서를 흔들었다
운영 projects를 들여다보니 대부분이 이미 soft-delete된 기수를 참조하고 있었어요.
id=9 cohortId=4 cohortName=null Fridge Link
id=8 cohortId=2 cohortName=null 명언제과점
...
id=10 cohortId=14 cohortName='14기' 오구오구 ← 유일하게 기수가 살아있음
cohortId에는 값이 있는데 cohortName만 null이었어요. FK는 끊기지 않았지만 조인 결과는 비어 있었죠. TypeORM이 조인한 관계에도 soft-delete 조건을 붙이면서 project.cohort가 통째로 null이 됐고 다음 페이지 커서를 만들 때 last.cohort.recruitStartAt을 읽다가 터졌어요.
정렬 순서도 이미 틀려 있었어요. Postgres에서 ORDER BY ... DESC의 기본은 NULLS FIRST라 기수가 없는 프로젝트 대부분이 맨 앞으로 몰렸거든요. 500이 나지 않는 limit=20 응답도 정렬은 잘못돼 있었어요.
처음에는 withDeleted()가 해결해 줄 거라 기대했어요. 하지만 정렬 교정 PR에서 생성 SQL을 찍어 보니 관계 조인(leftJoinAndSelect)에도, 엔티티 직접 조인(leftJoin)에도 AND ("cohort"."deletedAt" IS NULL)이 그대로 남아 있었어요.
첫 수정: 관계가 null이어도 목록을 반환한다
복구 PR에서는 기수가 없어도 목록을 반환하도록 커서를 만들 때 null인 관계를 읽지 않게 했고 정렬에서도 null이 앞으로 몰리지 않게 했어요. 다만 이때는 정렬 키가 실제 기수 순서를 제대로 나타내는지 판단하지 않았어요. 운영 데이터를 보기 전에 다른 키로 바꾸면 순서가 또 틀릴 수 있었기 때문이에요.
두 번째 수정: cohortId는 기수 번호가 아니었다
500을 멈춘 뒤 운영 cohorts를 살펴보니 폴백으로 쓰려던 cohortId와 기수 번호가 서로 맞지 않았어요.
| id | name |
|---|---|
| 5 | 8기 |
| 6 | 10기 |
| 2 | 9기 |
| 3 | 11기 |
| 4 | 13기 |
| 14 | 14기 (유일하게 살아있음) |
id로 정렬하면 2(9기) → 3(11기) → 4(13기) → 5(8기) → 6(10기) 순서가 돼요. 당시 8기와 10기에는 프로젝트가 한 건도 없어서 잘못된 순서가 결과에 드러나지 않았어요. 그 두 기수 중 하나에만 프로젝트를 추가해도 순서가 틀어질 상태였죠.
정렬 키를 기수 이름에 들어 있는 숫자로 바꾸고, 이름을 읽지 못할 때 쓸 폴백도 뒀어요.
COALESCE(NULLIF(substring(cohort.name from '\d+'), '')::int, project."cohortId")
NULLIF(..., '')는 이름에 숫자가 없을 때 나오는 빈 문자열을 null로 바꿔 ::int 캐스팅 실패를 피하게 해요. COALESCE는 이때 cohortId를 쓰게 해요. 이 폴백은 이름이 이상할 때뿐 아니라 기수가 soft-delete되어 cohort 자체가 null일 때도 필요했어요. 생성 SQL에서 봤듯 withDeleted()를 써도 조인의 soft-delete 조건은 빠지지 않았거든요.
기수 이름은 어드민에서 자유롭게 입력해요. 표시용 문자열을 정렬 기준으로 삼는 데는 한계가 있어서 제대로 하려면 cohorts에 정렬용 정수 컬럼을 두고 값을 채워야 해요. 운영 데이터를 보정하고 어드민 입력 경로도 바꿔야 하는 작업이라 장애 수습에는 넣지 않았어요. 이건 제 판단이고 리뷰에서 합의한 결론은 아니에요. 같은 PR에는 프로젝트가 붙은 기수를 삭제하지 못하게 하는 가드도 넣었어요.
세 번째 수정: 값 범위와 커서 입력을 방어한다
첫 PR과 복구 PR, 정렬 교정 PR을 리뷰받은 뒤 방어 PR을 추가했어요. 리뷰에서 목록을 다시 실패하게 만들 수 있는 경로가 드러났거든요.
먼저 \d+는 자릿수에 제한이 없어요. 기수 이름은 자유 입력이라 '99999999999기'도 들어올 수 있어요. 이때 substring(...)::int가 int4 범위를 넘으면 22003(numeric value out of range)이 나면서 목록 전체가 실패해요.
Postgres의 \d는 로케일에 따라 전각 숫자까지 잡지만 JS의 \d는 ASCII 전용이에요. 정렬 값은 SQL이 계산하고 커서는 JS가 만들기 때문에 두 구현이 다른 값을 내면 페이지가 겹치거나 빠질 수 있어요. 이런 오류는 500처럼 바로 눈에 띄지도 않아요.
양쪽 정규식을 [0-9]{1,9}로 맞췄어요. 9자리로 제한하면 int4 범위를 넘지 않고 문자 클래스를 명시하면 로케일과 구현에 따른 해석 차이도 없앨 수 있어요. 이름별 결과도 확인했어요.
| 이름 | 이전 | 이후 |
|---|---|---|
'13기' | 13 | 13 |
'DDD 13기' | 13 | 13 |
'007기' | 7 | 7 |
'기수' | cohortId 폴백 | cohortId 폴백 |
'99999999999기' | 22003 오류 | 999999999 |
기존 정상 케이스의 해석은 그대로였고 마지막 행에서만 오류가 값으로 바뀌었어요. decodeCursor가 입력값이 정수인지 확인하지 않던 경로도 함께 막았어요. 클라이언트가 돌려보내는 커서는 외부 입력이라 서버가 만든 형태일 거라고 가정하고 처리할 수 없었어요.
정렬 교정 PR에 넣었던 기수 삭제 가드는 실 DB에서 직접 호출해 확인했어요. 앞서 withDeleted()의 동작을 기대만으로 판단했던 만큼, 이번에는 생성 SQL과 실제 호출 결과를 근거로 삼았어요. 별도 PR에 있던 가드 개선도 이 PR로 가져오고 별도 PR은 닫았어요.
정렬용 정수 컬럼을 두려면 운영 데이터 보정과 어드민 입력 경로 변경이 아직 남아 있어요. 이번 수습에서는 거기까지 진행하지 않았어요.