더블유클럽
경력근거: 현재 서비스 구조와 쌓여 있는 결과물을 파악하고 실서비스 반영 순서 정리
※ 실제 산출물이 아닌 AI 예측 시안입니다.
공고는 대표와 PO 가 AI 로 작동하는 기능을 매일 만들어 내고, 이 자리가 그 결과물을 무엇을, 어떤 순서로, 어떻게 안전하게 올릴지 판단한다고 씁니다. 입사 후 90일 예시의 첫 줄이 쌓여 있는 결과물을 파악하고 실서비스 반영 순서를 정리하는 일이고, 자격요건은 운영 DB 를 서비스 중단 없이 변경해 본 경험과 기술 판단의 이유를 비개발자에게 쉽게 설명하는 능력을 요구합니다. W클럽은 만남보장 정책으로 약속부터 만남까지 책임진다고 소개하는 앱이고 누적 다운로드가 30만 회를 넘어서, 약속 응답을 잘못 바꾸면 업데이트하지 않은 회원의 약속 화면이 열리지 않고 그 약속이 깨집니다. 먼저 올라가야 할 PR 판정은 반영 순서 정리에, DB 변경 분리는 무중단 DB 변경에, 옛 앱 판정은 스토어 배포 관리에 닿고, 기다리는 이유를 쉬운 말로 적는 칸은 비개발자 설명 요구에 닿습니다. 이 포트폴리오는 PR 사이 선후, 무중단 DB 변경, 옛 앱 호환을 patch 와 호출 기록으로 판정하는 구현을 보여줍니다. W클럽 저장소의 실제 구조나 배포 이력은 알지 못하며, 매칭 서비스 운영 전문성은 주장하지 않습니다.
공고는 대표와 PO 가 AI 로 작동하는 기능을 매일 만들어 내고, 이 자리가 그 결과물을 무엇을, 어떤 순서로, 어떻게 안전하게 올릴지 판단한다고 적습니다. 입사 후 90일 예시의 첫 줄도 쌓여 있는 결과물을 파악하고 실서비스 반영 순서를 정리하는 일이라, PR 마다 오늘 올릴지와 무엇을 먼저 올릴지를 정하는 화면이 그 첫 일과 같은 모양입니다.
주요업무에 운영 DB 안전 변경과 되돌리기 기준 운영이 있고, 자격요건은 운영 DB 를 서비스 중단 없이 변경해 본 경험을 따로 적습니다. 필드 이름을 바꾸는 마이그레이션과 새 이름을 읽는 코드가 한 PR 에 섞이면 배포가 도는 몇 분 동안 옛 서버와 새 서버가 서로 없는 필드를 읽게 되므로, 이 편은 그런 PR 을 골라 DB 변경부터 나누게 합니다.
W클럽은 애플·구글 누적 다운로드 30만 회 이상의 앱이고, 주요업무에 React Native 앱 업데이트와 스토어 배포 관리가 있습니다. 스토어에 새 버전을 올려도 업데이트하지 않은 회원은 옛 버전으로 서버를 계속 부르므로 응답 형식을 바꾸는 PR 은 그 호출 비율을 보고 올려야 하고, 자격요건대로 그 판단의 이유를 대표와 PO 가 읽을 말로 풀어야 합니다.
W클럽은 대표와 PO 가 Claude Code 로 기능을 만들어 매일 PR 을 쌓고, 리드 엔지니어가 그 PR 을 운영 서버에 올릴지 정합니다. 이 관리자 페이지는 열린 PR 의 patch 와 앱 호출 기록을 읽어 PR 마다 세 가지를 따집니다. 새로 읽는 필드나 함수를 아직 합쳐지지 않은 다른 PR 이 처음 만드는지, migrations/ 의 필드 이름 바꾸기와 그 새 이름을 읽는 코드가 한 PR 에 같이 들었는지, openapi.json 에서 지우거나 형식을 바꾼 응답을 최근 14일 동안 최신보다 낮은 앱 버전이 5% 이상 불렀는지입니다. 셋 다 아니면 PR 은 오늘 올릴 순서를 받고, 걸린 PR 에는 무엇을 먼저 올려야 하는지가 대표와 PO 가 읽는 말로 적힙니다.
매일 Claude Code 로 기능을 만들어 PR을 쌓는 대표·PO와, 그 PR을 검토해 운영 서버 반영을 정하는 리드 엔지니어. 매일 오전 같은 관리자 화면에서 오늘 올릴 PR과 기다릴 PR을 보고, 기다릴 PR은 무엇이 먼저 나가야 하는지 확인한다
쌓인 PR 의 차례가 정해지지 않는 것은 PR 하나하나를 못 읽어서가 아닙니다. GitHub 는 PR 을 하나씩 따로 보여주는데, Claude Code 세션은 서로의 변경을 모른 채 만들어져 한 PR 이 다른 PR 이 만드는 필드를 읽거나 이름 바꾸기와 새 이름 읽기를 한 번에 담습니다. 응답을 바꾸는 PR 이 위험한지는 코드만 봐서는 모르고, 아직 옛 앱을 쓰는 회원이 그 경로를 얼마나 부르는지에 달려 있습니다. PR 사이의 이름 관계, 마이그레이션, 응답 변경을 호출 기록에 비춰 한 화면에서 판정하면 리드 엔지니어는 순서를 정하려고 diff 를 전부 열지 않아도 되고, 대표와 PO 는 자기 PR 이 왜 기다리는지 직접 읽습니다.
제 NestJS 저장소에 Claude Code 와 Codex 로 쌓은 PR 목록·파일별 patch·앱 호출 기록을 스크립트로 내려받아 넣으면, 관리자 페이지가 PR 마다 판정과 오늘 올릴 순서를 띄우고 기다릴 PR 옆에 먼저 나가야 할 PR 번호와 이유를 적습니다. 대표·PO 는 그 이유를 읽고, 리드 엔지니어는 근거가 된 patch 줄을 펼쳐 봅니다. GitHub 에서 PR 을 바로 읽는 연동은 3주차 몫입니다.
기준값 바로 안쪽과 바깥쪽을 짝으로 둔 판정 테스트 40건이 GitHub Actions 에서 매번 돕니다. 먼저 올라가야 할 PR 이 있다고 본 PR 은 main 에 그 PR 만 얹은 빌드에서 tsc 가 같은 이름으로 멈추는지, DB 변경 판정을 받은 PR 은 MongoDB 테스트 인스턴스에서 마이그레이션 뒤 옛 코드가 빈 값을 읽는지 실제로 돌려 그 결과를 판정 옆에 남깁니다.
PR 이 열리거나 바뀔 때마다 판정과 쉬운 말 이유가 PR 댓글로 달리고, 지금 올려도 되는 PR 은 오늘 순서대로 개발 서버와 스테이징을 거쳐 운영 반영 대기열에 오릅니다. 호출 기록은 Redis 에 날짜·경로·버전별 카운터로 쌓아, 14일 비율을 기록 전체를 다시 훑지 않고 냅니다.
대표나 PO 가 아침에 관리자 페이지를 열면 쌓인 PR 9개가 네 판정으로 나뉘고, 기다릴 PR 마다 먼저 올라가야 할 PR 번호와 이유가 쉬운 말로 적힙니다. 리드 엔지니어는 같은 줄을 펼쳐 근거가 된 patch 줄과 옛 버전 호출 비율을 읽습니다.
버전 문자열 2.10.0 과 2.9.4 는 숫자로 견주고, 14일 창 시작 시각에 정확히 찍힌 호출은 넣고 1초 앞선 호출은 빼며, 이미 합쳐진 PR 이 만든 이름을 읽는 PR 은 기다리게 하지 않습니다. patch 가 null 인 이미지 파일은 이름을 읽지 않고 건너뛴 파일로 남습니다.
hunk 머리가 깨진 patch, 전체 맥락이 아닌 openapi.json diff, app_version 에 dev 같은 글자가 든 호출 기록 30종을 넣어도 화면이 멈추지 않고, 읽지 못한 PR 은 판정 목록 대신 화면 위 확인 칸에 이유를 달고 뜹니다.
기준값 바로 안쪽과 바깥쪽을 짝으로 둔 판정 테스트 40건으로 판정 일치를 냅니다. 단, 정답과 데이터를 모두 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
위 결과는 더미데이터 자가채점이며, 이 순서대로 올렸을 때 장애와 되돌리기가 실제로 줄었는지는 W클럽의 실제 PR 과 운영 호출 기록이 있어야 측정됩니다. 이 포트폴리오는 그 측정을 바로 돌릴 수 있는 상태로 만듭니다.
판정 테스트 40건의 정답은 제 저장소에서 Claude Code·Codex 로 만든 PR 에 제가 직접 매긴 것이라, 통과율은 성능 증명이 아니라 규칙과 구현이 서로 맞는다는 확인입니다. W클럽에 쌓인 PR 을 이 순서로 올렸을 때 장애와 되돌리기가 줄어드는지는 W클럽의 실제 PR 과 운영 호출 기록을 넣어 봐야 압니다.
더미의 PR 은 서로 엮이도록 골랐습니다. merged 를 보지 않으면 이미 합쳐진 #405 의 함수를 읽는 #413 이 기다리게 되고, 템플릿 문자열을 통째로 지우면 #417 이 #415 보다 먼저 나가며, 최신 버전을 문자열로 고르면 2.9.4 가 최신이 되어 #422 가 옛 앱 판정으로 뒤집힙니다. 14일 창을 한국 날짜로 자르면 9월 24일 08:59:59(한국 시각)의 2.9.4 호출이 들어와 #422 가 같은 쪽으로 뒤집히고, app_version 이 빈 호출을 옛 버전으로 세면 약속 목록 비율이 33.3% 에서 42.9% 로 오릅니다.
이름은 patch 의 + 줄에서 정규식으로 뽑습니다. obj[key] 같은 동적 접근이나 스프레드로 넘긴 필드는 읽는 이름으로 잡히지 않아, 실제로는 기다려야 할 PR 이 지금 올려도 됨으로 나올 수 있습니다. 2주차의 단독 빌드 확인이 이 빈틈을 잡는 자리이고, 빌드가 깨졌는데 판정이 지금 올려도 됨이었던 PR 은 따로 모아 규칙을 고칩니다.
필드 이름을 바꾸는 마이그레이션만 담고 새 이름을 읽는 코드는 없는 PR 은 세 판정 어디에도 걸리지 않습니다. 운영 중인 서버가 옛 이름을 아직 읽는지는 patch 만으로 다 보이지 않으므로, migrations/ 파일이 든 PR 은 판정과 상관없이 리드 엔지니어가 직접 열어 봅니다.
옛 앱 판정은 가장 최신 버전이 바뀐 응답을 이미 읽도록 출시됐다고 보고 그보다 낮은 버전만 셉니다. 최신 앱도 옛 응답만 읽는다면 비율이 0% 여도 기다려야 하므로 응답을 바꾸는 PR 은 앱 출시 일정과 맞춰 보고, 14일과 5% 는 W클럽 회원이 앱을 얼마나 빨리 업데이트하는지 실제 기록으로 다시 정해야 하는 도입 단계 과제로 둡니다.
판정 규칙과 관리자 화면 구현까지가 이 편의 범위입니다. W클럽 저장소의 실제 구조와 배포 이력, 매칭 서비스 운영 경험은 주장하지 않습니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
제 NestJS 저장소에 Claude Code 와 Codex 로 만든 PR 목록입니다. GitHub API 의 pulls 응답에서 번호, 제목, 작성자, 연 시각, 합쳐졌는지를 내려받으며, 이미 합쳐진 PR 이 선언한 이름은 운영 코드에 있는 것으로 셉니다.
| pr_no | title | author | opened_at | merged | merged_at |
|---|---|---|---|---|---|
| 405 | 만남 후기 작성 기한 계산 함수 추가 | noah-lead | 2026-09-29T14:10:00+09:00 | true | 2026-10-01T18:20:00+09:00 |
| 412 | 만남 후기 알림 문구 수정 | ella-po | 2026-10-05T10:02:00+09:00 | false | — |
| 413 | 기한 지난 만남 후기 작성 막기 | mason-ceo | 2026-10-05T16:45:00+09:00 | false | — |
| 415 | 회원 직업 필드 이름 정리 | mason-ceo | 2026-10-06T09:20:00+09:00 | false | — |
| 416 | 만남 평가에 늦게 온 이유 칸 추가 | ella-po | 2026-10-06T10:41:00+09:00 | false | — |
아래 JSON Schema 에 맞는 PR 행을 더 만들어 주세요. {"type":"object","properties":{"pr_no":{"type":"integer"},"title":{"type":"string"},"author":{"type":"string"},"opened_at":{"type":"string","format":"date-time"},"merged":{"type":"boolean"},"merged_at":{"type":["string","null"],"format":"date-time"}},"required":["pr_no","title","author","opened_at","merged","merged_at"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) merged 가 true 이고 merged_at 이 있는 PR(그 PR 이 선언한 이름은 운영 코드로 셉니다), (2) 같은 필드나 함수를 선언하는 열린 PR 두 개를 opened_at 만 다르게, (3) 다른 열린 PR 이 처음 만드는 이름을 읽는 PR, (4) migrations/ 파일과 그 새 이름을 읽는 코드를 한 PR 에 담은 PR. author 는 대표·PO·리드 엔지니어 세 사람 가운데 하나로 두고 pr_patches 행을 같은 번호로 함께 만드세요. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"pr_no":{"type":"integer"},"title":{"type":"string"},"author":{"type":"string"},"opened_at":{"type":"string","format":"date-time"},"merged":{"type":"boolean"},"merged_at":{"type":["string","null"],"format":"date-time"}},"required":["pr_no","title","author","opened_at","merged","merged_at"]}PR 마다 바뀐 파일과 그 patch 입니다. 코드 파일은 git diff 기본 맥락 세 줄로, openapi.json 은 바꾸기 전후 문서를 되살릴 수 있게 전체 맥락(-U10000)으로 뜹니다. 이미지처럼 글자로 된 변경이 없는 파일은 patch 를 null 로 둡니다.
| pr_no | file_path | status | patch |
|---|---|---|---|
| 405 | src/reviews/reviews.service.ts | modified | @@ -9,6 +9,10 @@ import { MeetReview }… |
| 412 | src/notifications/templates/review-rem… | modified | @@ -1,4 +1,4 @@ export const reviewRe… |
| 413 | src/reviews/reviews.service.ts | modified | @@ -14,6 +14,9 @@ export class Reviews… |
| 415 | migrations/20261006091500-rename-job-t… | added | @@ -0,0 +1,15 @@ +module.exports = { +… |
| 415 | src/members/members.service.ts | modified | @@ -9,6 +9,6 @@ export class MembersSe… |
아래 JSON Schema 에 맞는 파일별 patch 행을 더 만들어 주세요. {"type":"object","properties":{"pr_no":{"type":"integer"},"file_path":{"type":"string"},"status":{"type":"string","enum":["added","modified","removed","renamed"]},"patch":{"type":["string","null"]}},"required":["pr_no","file_path","status","patch"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) patch 가 null 인 이미지·폰트 같은 바이너리 파일, (2) up() 에는 $rename 이 있고 down() 에는 거꾸로 된 $rename 이 있는 migrations/ 파일, (3) review?.lateReason 처럼 옵셔널 체이닝으로 읽는 이름과 따옴표 안에만 나오는 이름, (4) openapi.json 을 -U10000 전체 맥락으로 뜬 patch 에서 $ref 로 여러 경로가 같이 바뀌는 응답 변경. patch 는 실제 git diff 의 hunk 형식(@@ -a,b +c,d @@)을 지키고, 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도(hunk 머리가 깨진 patch 등)는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"pr_no":{"type":"integer"},"file_path":{"type":"string"},"status":{"type":"string","enum":["added","modified","removed","renamed"]},"patch":{"type":["string","null"]}},"required":["pr_no","file_path","status","patch"]}제 NestJS 서버의 인터셉터가 요청마다 남긴 호출 기록입니다. path 는 Express 경로 틀(:id) 그대로이고, app_version 은 React Native 앱이 X-App-Version 헤더로 보낸 값이라 헤더를 보내지 않은 호출은 null 입니다.
| requested_at | method | path | app_version | status |
|---|---|---|---|---|
| 2026-10-07T23:41:10Z | GET | /appointments | 2.10.0 | 200 |
| 2026-10-07T23:41:12Z | GET | /appointments/:id | 2.10.0 | 200 |
| 2026-10-07T21:15:04Z | GET | /me/badges | 2.10.0 | 200 |
| 2026-10-07T12:05:44Z | GET | /appointments | 2.9.4 | 200 |
| 2026-10-06T11:02:13Z | GET | /appointments | — | 200 |
아래 JSON Schema 에 맞는 호출 기록 행을 더 만들어 주세요. {"type":"object","properties":{"requested_at":{"type":"string","format":"date-time"},"method":{"type":"string","enum":["GET","POST","PATCH","DELETE"]},"path":{"type":"string"},"app_version":{"type":["string","null"]},"status":{"type":"integer"}},"required":["requested_at","method","path","app_version","status"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) app_version 이 null 인 호출, (2) 2.10.0 과 2.9.4 처럼 문자열로 견주면 순서가 뒤집히는 버전, (3) 14일 창 시작 시각에 정확히 찍힌 호출과 1초 앞선 호출, (4) path 가 /appointments/:id 처럼 경로 틀로 남은 호출, (5) 304 처럼 본문 없이 끝난 응답. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도(app_version 에 dev 같은 글자)는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"requested_at":{"type":"string","format":"date-time"},"method":{"type":"string","enum":["GET","POST","PATCH","DELETE"]},"path":{"type":"string"},"app_version":{"type":["string","null"]},"status":{"type":"integer"}},"required":["requested_at","method","path","app_version","status"]}| TypeScript | 판정 엔진 전체의 언어입니다. patch 를 hunk 와 줄 단위로 읽어 + 줄의 선언 이름과 읽는 이름을 뽑고, PR 사이 선후를 그래프로 세워 오늘 순서를 매깁니다. |
| Node.js | openapi.json 을 바꾸기 전과 뒤 두 문서로 되살려 $ref 를 풀고, 경로마다 응답 속성이 지워졌거나 type·format 이 바뀐 곳을 찾는 스크립트가 돕니다. |
| NestJS | 판정 API 서버이자 입력을 만드는 제 저장소입니다. @nestjs/swagger 가 openapi.json 을 쓰고, 인터셉터가 요청마다 경로 틀과 X-App-Version 헤더를 호출 기록으로 남깁니다. |
| MongoDB | PR·patch·호출 기록·판정을 담고, migrate-mongo 마이그레이션 up() 의 $rename 을 읽어 DB 변경 판정을 냅니다. 2주차에는 테스트 인스턴스에서 옛 코드가 새 이름을 못 읽는지 실제로 돌립니다. |
| Redis | 3주차에 호출 기록을 날짜·경로·버전별 카운터로 쌓아 14일 옛 버전 비율을 냅니다. |
| Next.js | 대표·PO·리드 엔지니어가 아침에 여는 관리자 페이지입니다. |
| React | 판정 묶음, 펼침 근거, 오늘 순서 목록을 그리는 화면 컴포넌트입니다. |
| React Native | 앱이 요청마다 X-App-Version 헤더를 보내게 하는 부분입니다. 스토어 업데이트 뒤에도 옛 버전이 남는 구조가 옛 앱 판정의 전제입니다. |
| Claude | Claude Code 로 입력이 될 PR 을 제 저장소에 만들고, Claude Sonnet 4.5 가 기다리는 이유를 대표·PO 가 쓰는 화면 말로 옮깁니다. |
| ChatGPT | ChatGPT Codex 로 같은 저장소에 PR 을 따로 만들어, 두 에이전트가 서로의 변경을 모른 채 쌓은 PR 묶음을 입력으로 씁니다. |
| CI/CD | 판정 테스트 40건과 단독 빌드 확인을 GitHub Actions 에서 돌리고, 3주차에 PR 댓글과 개발 서버·스테이징 배포를 잇습니다. |
✓ pass → PR 9개의 오늘 순서를 이 화면이 정해 주면 1주차로 완결입니다. 더 키우려면 판정 테스트 40건을 GitHub Actions 에 묶고, 기다릴 PR 마다 그 PR 만 얹은 빌드가 실제로 멈추는지 확인하는 단계로 갑니다.
✓ pass → 여기까지도 완결입니다. 더 키우려면 PR 이 열릴 때마다 판정을 PR 댓글로 달고, 지금 올려도 되는 PR 을 순서대로 스테이징에 올리는 단계로 갑니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · pr_no + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
지금 올려도 됨지금 올림먼저 올라가야 할 PR이 있음앞 PR 기다림DB 변경을 먼저 따로 올려야 함DB 변경 분리옛 앱 버전이 아직 쓰는 응답을 바꿈옛 앱 응답 변경열린 PR 하나가 오늘 운영 서버에 올라갈 수 있는지, 아니면 무엇을 기다리는지 남기는 형식입니다. 판정과 순서는 규칙이 정하고, summary 의 두 문장만 Claude Sonnet 4.5 가 씁니다.
{"type":"object","properties":{"pr_no":{"type":"integer","description":"판정한 PR 번호입니다. 화면의 줄 키입니다."},"title":{"type":"string"},"author":{"type":"string"},"as_of":{"type":"string","description":"판정 시각입니다. 호출 기록 14일 창의 끝(미포함)입니다."},"verdict":{"type":"string","enum":["지금 올려도 됨","먼저 올라가야 할 PR이 있음","DB 변경을 먼저 따로 올려야 함","옛 앱 버전이 아직 쓰는 응답을 바꿈"]},"also_hits":{"type":"array","items":{"type":"string","enum":["먼저 올라가야 할 PR이 있음","DB 변경을 먼저 따로 올려야 함","옛 앱 버전이 아직 쓰는 응답을 바꿈"]},"description":"verdict 뒤 차례의 판정 가운데 함께 성립한 것입니다."},"release_rank":{"type":["integer","null"],"minimum":1,"description":"지금 올려도 됨 PR 의 오늘 순서입니다. waiting_prs 가 많은 순, 같으면 opened_at 이 이른 순으로 1부터 매깁니다. 다른 판정은 null 입니다."},"blocked_by":{"type":"array","items":{"type":"integer"},"description":"먼저 올라가야 할 PR 번호입니다. needed_names 의 이름을 + 줄에서 선언한 merged 가 false 인 다른 PR 가운데 opened_at 이 가장 이른 PR 입니다."},"waiting_prs":{"type":"array","items":{"type":"integer"},"description":"blocked_by 에 이 PR 을 둔 다른 PR 번호입니다."},"needed_names":{"type":"array","items":{"type":"string"},"description":"이 PR 이 + 줄에서 새로 읽는데 운영 코드에 없는 필드·함수 이름입니다."},"db_renames":{"type":"array","items":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"migration_file":{"type":"string"},"read_in":{"type":"string"}},"required":["from","to","migration_file","read_in"]},"description":"migrations/ 파일 up() 의 $rename 짝 가운데 새 이름을 같은 PR 의 다른 코드 파일이 읽는 것입니다."},"changed_responses":{"type":"array","items":{"type":"object","properties":{"method":{"type":"string"},"path":{"type":"string"},"field":{"type":"string","description":"지워졌거나 형식이 바뀐 응답 속성 이름입니다. 배열 응답이면 항목의 속성 이름입니다."},"change":{"type":"string","enum":["field_removed","format_changed"]},"old_version_share_pct":{"type":["number","null"],"description":"as_of 앞 14일 그 method·path 호출 가운데 latest_app_version 보다 낮은 app_version 호출 수 / app_version 이 적힌 호출 수 * 100 을 소수 둘째 자리에서 반올림(0.05 는 올림)한 값입니다. app_version 이 적힌 호출이 0건이면 null 입니다."}},"required":["method","path","field","change","old_version_share_pct"]}},"latest_app_version":{"type":["string","null"],"description":"as_of 앞 14일 전체 호출의 app_version 을 semver 숫자 셋으로 읽은 최댓값입니다. changed_responses 가 비면 null 입니다."},"skipped_files":{"type":"array","items":{"type":"string"},"description":"patch 가 null 이라 이름을 읽지 않은 파일입니다."},"summary":{"type":"string","description":"대표와 PO 가 읽는 두 문장입니다. Claude Sonnet 4.5 가 쓰고, 버려지면 규칙이 만든 기본 문장이 들어갑니다."},"evidence":{"type":"array","items":{"type":"object","properties":{"dataset":{"type":"string","enum":["pull_requests","pr_patches","api_access_logs"]},"field":{"type":"string","description":"근거가 된 입력 컬럼 이름입니다."},"pr_no":{"type":["integer","null"]},"file_path":{"type":["string","null"]},"quote":{"type":["string","null"],"description":"patch 원문에서 그대로 옮긴 한 줄 조각입니다."},"value":{"description":"agg 가 없으면 수록된 행의 그 컬럼 값 그대로이고, agg 가 있으면 여러 행에서 계산한 값입니다."},"agg":{"type":"string","enum":["count","sum","avg","min","max"]},"where":{"type":"string","description":"agg 를 계산한 행의 조건입니다."}},"required":["dataset","field"]}}},"required":["pr_no","title","author","as_of","verdict","also_hits","release_rank","blocked_by","waiting_prs","needed_names","db_renames","changed_responses","latest_app_version","skipped_files","summary","evidence"]}[
{
"pr_no": 416,
"title": "만남 평가에 늦게 온 이유 칸 추가",
"author": "ella-po",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "지금 올려도 됨",
"also_hits": [],
"release_rank": 1,
"blocked_by": [],
"waiting_prs": [
418
],
"needed_names": [],
"db_renames": [],
"changed_responses": [],
"latest_app_version": null,
"skipped_files": [],
"summary": "#418 이 기다리는 '늦게 옴' 항목을 처음 만드는 PR 이라 오늘 가장 먼저 올립니다. 다른 PR 이 만드는 이름을 읽지 않고 DB 작업도 없습니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 416,
"file_path": "src/reviews/schemas/meet-review.schema.ts",
"quote": "+ lateReason?: string;"
},
{
"dataset": "pull_requests",
"field": "merged",
"pr_no": 416,
"value": false
}
]
},
{
"pr_no": 413,
"title": "기한 지난 만남 후기 작성 막기",
"author": "mason-ceo",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "지금 올려도 됨",
"also_hits": [],
"release_rank": 3,
"blocked_by": [],
"waiting_prs": [],
"needed_names": [],
"db_renames": [],
"changed_responses": [],
"latest_app_version": null,
"skipped_files": [],
"summary": "이 PR 이 쓰는 후기 작성 기한 계산은 10월 1일에 합쳐진 #405 가 이미 운영 서버에 넣었습니다. 기다릴 PR 이 없어 연 시각 순서대로 세 번째로 올립니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 413,
"file_path": "src/reviews/reviews.service.ts",
"quote": "this.reviewDeadlineAt(appointment.meetAt)"
},
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 405,
"file_path": "src/reviews/reviews.service.ts",
"quote": "+ reviewDeadlineAt(meetAt: Date): Date {"
},
{
"dataset": "pull_requests",
"field": "merged",
"pr_no": 405,
"value": true
}
]
},
{
"pr_no": 418,
"title": "늦게 온 회원에게 다음 약속 전 안내",
"author": "mason-ceo",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "먼저 올라가야 할 PR이 있음",
"also_hits": [],
"release_rank": null,
"blocked_by": [
416
],
"waiting_prs": [],
"needed_names": [
"lateReason"
],
"db_renames": [],
"changed_responses": [],
"latest_app_version": null,
"skipped_files": [],
"summary": "이 PR 은 만남 평가의 '늦게 옴' 항목을 읽는데, 그 항목은 아직 올라가지 않은 #416 이 처음 만듭니다. #416 없이 이 PR 만 올리면 서버 빌드가 그 항목 이름에서 멈추므로 #416 을 먼저 올립니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 418,
"file_path": "src/appointments/appointments.service.ts",
"quote": "if (!review?.lateReason) return null;"
},
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 416,
"file_path": "src/reviews/schemas/meet-review.schema.ts",
"quote": "+ lateReason?: string;"
},
{
"dataset": "pull_requests",
"field": "merged",
"pr_no": 416,
"value": false
},
{
"dataset": "pull_requests",
"field": "opened_at",
"pr_no": 416,
"value": "2026-10-06T10:41:00+09:00"
}
]
},
{
"pr_no": 417,
"title": "약속 확정 알림에 상대 직업 표시",
"author": "mason-ceo",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "먼저 올라가야 할 PR이 있음",
"also_hits": [],
"release_rank": null,
"blocked_by": [
415
],
"waiting_prs": [],
"needed_names": [
"occupation"
],
"db_renames": [],
"changed_responses": [],
"latest_app_version": null,
"skipped_files": [],
"summary": "이 PR 은 회원 직업 칸(occupation)을 읽는데, 그 칸은 아직 올라가지 않은 #415 가 처음 만듭니다. #415 는 DB 변경부터 따로 나눠야 하는 PR 이라, 나눈 DB 변경이 올라간 뒤에 이 PR 을 올립니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 417,
"file_path": "src/notifications/appointment-confirmed.ts",
"quote": "partner.occupation"
},
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 415,
"file_path": "src/members/schemas/member.schema.ts",
"quote": "+ occupation?: string;"
},
{
"dataset": "pull_requests",
"field": "merged",
"pr_no": 415,
"value": false
}
]
},
{
"pr_no": 415,
"title": "회원 직업 필드 이름 정리",
"author": "mason-ceo",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "DB 변경을 먼저 따로 올려야 함",
"also_hits": [],
"release_rank": null,
"blocked_by": [],
"waiting_prs": [
417
],
"needed_names": [],
"db_renames": [
{
"from": "jobTitle",
"to": "occupation",
"migration_file": "migrations/20261006091500-rename-job-title.js",
"read_in": "src/members/members.service.ts"
}
],
"changed_responses": [],
"latest_app_version": null,
"skipped_files": [],
"summary": "회원 직업 칸의 이름을 jobTitle 에서 occupation 으로 바꾸는 DB 작업과, 새 이름을 읽는 프로필 코드가 한 PR 에 들어 있습니다. 배포가 도는 몇 분 동안 아직 바뀌지 않은 서버가 사라진 jobTitle 을 읽게 되므로, occupation 을 새로 만들고 값을 옮기는 DB 작업만 먼저 따로 올리고 jobTitle 지우기는 마지막에 올립니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 415,
"file_path": "migrations/20261006091500-rename-job-title.js",
"quote": "{ $rename: { jobTitle: 'occupation' } },"
},
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 415,
"file_path": "src/members/members.service.ts",
"quote": "occupation: member.occupation"
}
]
},
{
"pr_no": 421,
"title": "약속 응답의 장소를 묶음으로 변경",
"author": "ella-po",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "옛 앱 버전이 아직 쓰는 응답을 바꿈",
"also_hits": [],
"release_rank": null,
"blocked_by": [],
"waiting_prs": [],
"needed_names": [],
"db_renames": [],
"changed_responses": [
{
"method": "GET",
"path": "/appointments",
"field": "placeName",
"change": "field_removed",
"old_version_share_pct": 33.3
},
{
"method": "GET",
"path": "/appointments/{id}",
"field": "placeName",
"change": "field_removed",
"old_version_share_pct": 50
}
],
"latest_app_version": "2.10.0",
"skipped_files": [],
"summary": "약속 목록과 약속 상세 응답에서 장소 이름(placeName)이 빠지고 장소 묶음(place)으로 바뀝니다. 최근 14일 약속 목록 호출의 33.3%, 약속 상세 호출의 50.0% 가 아직 2.10.0 보다 낮은 앱에서 왔고, 그 앱은 placeName 을 읽도록 만들어져 있어 약속 화면의 장소 칸이 비거나 화면이 열리지 않습니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 421,
"file_path": "src/appointments/dto/appointment.dto.ts",
"quote": "- placeName: string;"
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "max",
"where": "as_of 앞 14일 전체 호출",
"value": "2.10.0"
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "count",
"where": "GET /appointments · 2.10.0 미만",
"value": 2
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "count",
"where": "GET /appointments · app_version 있음",
"value": 6
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "count",
"where": "GET /appointments/{id} · 2.10.0 미만",
"value": 1
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "count",
"where": "GET /appointments/{id} · app_version 있음",
"value": 2
},
{
"dataset": "api_access_logs",
"field": "requested_at",
"value": "2026-10-06T11:02:13Z",
"where": "app_version 이 null 이라 비율에서 뺀 호출"
}
]
},
{
"pr_no": 422,
"title": "배지 획득 시각을 초 단위로",
"author": "ella-po",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "지금 올려도 됨",
"also_hits": [],
"release_rank": 4,
"blocked_by": [],
"waiting_prs": [],
"needed_names": [],
"db_renames": [],
"changed_responses": [
{
"method": "GET",
"path": "/me/badges",
"field": "earnedAt",
"change": "format_changed",
"old_version_share_pct": 0
}
],
"latest_app_version": "2.10.0",
"skipped_files": [],
"summary": "배지 목록의 획득 시각 형식이 날짜에서 날짜와 시각으로 바뀌지만, 최근 14일 배지 목록 호출 3건은 모두 2.10.0 에서 왔습니다. 2.9.4 의 마지막 호출은 9월 24일 오전 8시 59분 59초로 14일 창 시작 1초 전이라 비율에 들지 않고, 이 PR 은 네 번째로 올립니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 422,
"file_path": "src/badges/dto/badge.dto.ts",
"quote": "+ @ApiProperty({ format: 'date-time' })"
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "count",
"where": "GET /me/badges · 2.10.0 미만",
"value": 0
},
{
"dataset": "api_access_logs",
"field": "app_version",
"agg": "count",
"where": "GET /me/badges · app_version 있음",
"value": 3
},
{
"dataset": "api_access_logs",
"field": "requested_at",
"value": "2026-09-23T23:59:59Z",
"where": "2.9.4 호출 · 창 시작 1초 전이라 뺌"
}
]
},
{
"pr_no": 423,
"title": "사진 인증 배지 이미지 교체",
"author": "mason-ceo",
"as_of": "2026-10-08T09:00:00+09:00",
"verdict": "지금 올려도 됨",
"also_hits": [],
"release_rank": 5,
"blocked_by": [],
"waiting_prs": [],
"needed_names": [],
"db_renames": [],
"changed_responses": [],
"latest_app_version": null,
"skipped_files": [
"assets/badges/photo-verified-v2@3x.png"
],
"summary": "사진 인증 배지 이미지의 경로만 바꾸는 PR 입니다. 새 이미지 파일은 글자로 된 변경이 없어 이름을 읽지 않고 건너뛰었고, 다른 PR 과 얽힌 이름이 없어 다섯 번째로 올립니다.",
"evidence": [
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 423,
"file_path": "src/badges/badge-images.ts",
"quote": "photo-verified-v2@3x.png"
},
{
"dataset": "pr_patches",
"field": "patch",
"pr_no": 423,
"file_path": "assets/badges/photo-verified-v2@3x.png",
"value": null
}
]
}
][자동 검증 · 코드가 판정합니다] 1. verdict 가 지금 올려도 됨·먼저 올라가야 할 PR이 있음·DB 변경을 먼저 따로 올려야 함·옛 앱 버전이 아직 쓰는 응답을 바꿈 네 값 중 하나인지, also_hits 의 값이 뒤 세 값 안에 있는지, evidence 의 agg 가 count·sum·avg·min·max 안에 있는지 확인합니다. 2. pr_no·title·author·as_of·verdict·also_hits·release_rank·blocked_by·waiting_prs·needed_names·db_renames·changed_responses·latest_app_version·skipped_files·summary·evidence 필수 필드가 모두 존재하는지 확인합니다. 3. 먼저 올라가야 할 PR 판정을 patch 에서 다시 계산합니다. 이 PR 의 코드 파일 + 줄에서 작은따옴표·큰따옴표 안 글자를 지우고, 백틱 템플릿 문자열은 글자만 지워 ${…} 안의 코드를 남긴 뒤 점 뒤 이름(review.lateReason·review?.lateReason·${partner.occupation})을 읽는 이름으로 모으고, 같은 PR 이 선언한 이름과 운영 코드에 있는 이름을 뺍니다. 운영 코드에 있는 이름은 merged 가 true 인 PR 의 + 줄에서 선언된 이름과, 어느 patch 든 맥락 줄·삭제 줄에 나온 이름입니다. 남은 이름을 merged 가 false 인 다른 PR 이 + 줄에서 선언했으면(속성·메서드·클래스 선언, migrations/ up() 의 $rename 새 이름) needed_names 에 그 이름이, blocked_by 에 그 PR 들 가운데 opened_at 이 가장 이른 PR 이 있어야 합니다. 4. DB 변경 판정을 다시 계산합니다. migrations/ 아래 파일의 up() 안 + 줄에서 $rename 의 옛 이름과 새 이름을 읽고(down() 은 읽지 않습니다), 같은 PR 의 다른 코드 파일 + 줄이 그 새 이름을 점 뒤 이름으로 읽으면 db_renames 에 그 짝이 있고 verdict 는 DB 변경을 먼저 따로 올려야 함이거나 also_hits 에 그 값이 있습니다. 5. changed_responses 의 old_version_share_pct 를 다시 계산합니다. openapi.json patch 의 맥락·삭제 줄로 바꾸기 전 문서를, 맥락·추가 줄로 바꾼 뒤 문서를 되살려 $ref 를 풀고, 200 응답의 속성이 지워졌거나 type·format 이 바뀐 method·path 마다 as_of 앞 14일(2026-09-24T00:00:00Z 포함, as_of 미포함) 호출 가운데 method 가 같고 path 의 :id 를 {id} 로 바꾼 값이 같은 호출을 셉니다. app_version 을 semver 숫자 셋으로 읽어 창 전체 최댓값을 latest_app_version 으로 두고, 그보다 낮은 호출 수 / app_version 이 적힌 호출 수 * 100 을 소수 둘째 자리에서 반올림(0.05 는 올림)합니다. app_version 이 null 인 호출은 분모와 분자에서 모두 뺍니다. 6. verdict 가 옛 앱 버전이 아직 쓰는 응답을 바꿈 이면 changed_responses 가운데 old_version_share_pct 가 5.0 이상인 항목이 하나 이상 있습니다. 모든 항목이 5.0 미만이거나 null 이면 이 판정은 verdict 에도 also_hits 에도 나오지 않습니다. 7. verdict 가 지금 올려도 됨 이면 blocked_by 는 빈 배열이고 release_rank 는 1 이상입니다. 다른 판정이면 release_rank 는 null 입니다. release_rank 는 지금 올려도 됨 PR 을 waiting_prs 가 많은 순, 같으면 opened_at 이 이른 순으로 1부터 매긴 값과 같습니다. 8. evidence 의 field 이름이 세 데이터셋에 실재하는 컬럼인지 대조합니다. 9. evidence 의 quote 가 수록된 patch 원문의 substring 인지, agg 가 없는 value 가 수록된 행의 원문 값과 같은지 확인합니다. 10. summary 가 존댓말 문장으로 끝나고, 그 안의 PR 번호가 pr_no·blocked_by·waiting_prs 에 있는 번호인지, 비율이 changed_responses 의 값과 같은지 확인합니다. 하나라도 어긋나면 그 문장을 버리고 규칙이 만든 기본 문장을 넣습니다. [수동 검증 · 사람이 봅니다] 1. 기다릴 PR 을 어떻게 고칠지(마이그레이션 나누기, 응답을 앱 버전에 따라 나누기, 앱 출시 기다리기)는 리드 엔지니어가 정합니다. 2. summary 가 대표와 PO 에게 쉬운 말인지 두 사람이 한 주 동안 읽고 고칠 문장을 표시합니다. [판정 차례 · 둘 이상 걸릴 때] PR 하나에는 판정을 하나만 띄우고 같이 걸린 판정은 also_hits 에 둡니다. ① 먼저 올라가야 할 PR이 있음 → ② DB 변경을 먼저 따로 올려야 함 → ③ 옛 앱 버전이 아직 쓰는 응답을 바꿈 → ④ 지금 올려도 됨. 다른 PR 없이는 빌드조차 되지 않는 PR 이 맨 앞이고, DB 변경은 PR 자체를 둘로 나눠야 하므로 두 번째이며, 옛 앱 판정은 앱 출시와 업데이트 속도에 따라 풀리므로 세 번째입니다. [오늘 순서 · release_rank] 지금 올려도 됨 PR 가운데 다른 PR 이 기다리는 PR 을 앞에 두고, 같으면 opened_at 이 이른 PR 부터 매깁니다. 이 더미에서는 #418 이 기다리는 #416 이 1번이고, #412·#413·#422·#423 이 연 시각 순서대로 2~5번입니다. 기다릴 PR 은 순서 대신 blocked_by 나 사유를 달고, 그 PR 이 올라가면 다음 판정에서 다시 매깁니다. [하드 케이스 · 판정이 갈리는 이유] #413 은 reviewDeadlineAt 을 읽지만 그 이름은 10월 1일에 합쳐진 #405 의 + 줄에서 선언돼 운영 코드에 있으므로 기다리지 않습니다. #415 의 마이그레이션 down() 에는 거꾸로 된 $rename(occupation → jobTitle)이 있지만 up() 만 읽어 db_renames 에는 jobTitle → occupation 한 짝만 남습니다. 이 더미에서는 down() 을 함께 읽어도 판정은 그대로라 판정을 가르는 사례는 아닙니다. #417 은 #415 가 만드는 occupation 을 백틱 템플릿 문자열의 ${partner.occupation ?? '비공개'} 안에서만 읽습니다. 템플릿 문자열을 통째로 지우면 이 이름을 놓쳐 #417 이 지금 올려도 됨으로 나오고 #415 보다 먼저 나갑니다. 제대로 읽으면 #417 은 #415 를 기다리고, #415 가 DB 변경 판정이라 #417 은 나눈 DB 변경이 올라간 뒤에 풀립니다. #421 의 AppointmentDto 변경은 $ref 를 풀면 GET /appointments 와 GET /appointments/{id} 두 경로에 같이 걸립니다. 2026-10-06T11:02:13Z 호출은 app_version 이 null 이라 비율에서 빠지며, 이 호출을 옛 버전으로 세면 약속 목록 비율이 33.3% 에서 42.9% 로 오릅니다. 최신 버전을 문자열로 고르면 2.9.4 가 2.10.0 보다 커 보여 #422 의 배지 목록 비율이 100.0% 가 되고, 지금 올려도 됨이 옛 앱 판정으로 뒤집힙니다. 2026-09-24T00:00:00Z 호출은 창 시작 시각과 같아 들어가고, 2026-09-23T23:59:59Z 호출은 1초 앞서 빠집니다. #423 의 PNG 파일은 patch 가 null 이라 이름을 읽지 않고 skipped_files 에 남깁니다. [확정된 판정식과 다른 점] 세 판정식은 그대로 씁니다. 판정식이 정하지 않은 것을 여섯 가지 정했습니다. 지금 올려도 됨은 세 조건 어디에도 걸리지 않은 PR 입니다. 운영 코드에 있다는 것은 merged 가 true 인 PR 의 + 줄에서 선언됐거나, 어느 patch 든 맥락 줄·삭제 줄에 그 이름이 나온 것으로 읽습니다(patch 는 main 과의 차이라 그 줄은 이미 운영 코드입니다). 같은 이름을 열린 PR 둘 이상이 선언하면 opened_at 이 가장 이른 PR 을 처음 추가한 PR 로 봅니다. 가장 최신 앱 버전은 14일 창 전체 호출의 semver 최댓값이고, app_version 이 null 인 호출은 비율의 분모와 분자에서 모두 뺍니다. 호출 기록의 :id 와 openapi.json 의 {id} 는 같은 경로로 봅니다. 마이그레이션은 up() 만 읽습니다. [출처 · 공고에 없는 낱말] 공고에는 AI 로 만든 기능과 PR 검토, 운영 서버 반영, 운영 DB 안전 변경, React Native 앱 업데이트와 스토어 배포, NestJS·MongoDB·Redis 우대까지 나옵니다. openapi.json, migrate-mongo, X-App-Version 헤더, 호출 기록 인터셉터는 이 편이 지원자 저장소에 두는 구성이고 W클럽 저장소의 실제 모습이 아닙니다. [평가 러너 · 2주차] 기준값 바로 안쪽과 바깥쪽을 짝으로 둔 판정 테스트 40건(옛 버전 비율 4.9%·5.0%, 창 시작 시각과 1초 전, 2.10.0 과 2.9.10 같은 버전 문자열, 합쳐진 PR 과 열린 PR 이 선언한 이름, 같은 이름을 선언한 열린 PR 둘, down() 에만 있는 $rename, patch 가 null 인 파일)을 GitHub Actions 에서 매번 돌려 판정과 also_hits 가 기대값과 같은지 봅니다. 먼저 올라가야 할 PR이 있음 판정은 main 에 그 PR 만 얹은 빌드에서 tsc 가 needed_names 의 이름으로 TS2339 를 내는지, DB 변경 판정은 MongoDB 테스트 인스턴스에서 마이그레이션 뒤 옛 코드가 jobTitle 을 빈 값으로 읽는지 실제로 돌려 맞춰 봅니다.