website: 모집 관리 어드민 화면과 공개 상태 조회 API 구현
관리자가 모집을 켜고 끌 수 있는 어드민 화면을 붙이고, 공개 사이트(랜딩·`/recruit`)가 그 상태를 실제로 반영할 수 있게 만든 PR이다.
모집 켜기/끄기와 안내메일 발송 로직 자체는 #124(PR #126)에서 이미 구현돼 있었다. 이번 작업은 그 위에 화면만 새로 붙이는 정도로 시작했는데, 막상 붙여보니 기존 관리자 API는 전부 인증이 걸려 있어서 방문자용 화면(랜딩, /recruit)에서는 쓸 수가 없었다. 그래서 인증이 필요 없는 공개 상태 조회 엔드포인트 GET /api/recruitment/status를 새로 만들었다. 이 엔드포인트는 {open}만 반환하도록 했다 — 구독자 수는 관리자만 봐야 할 정보라 응답에서 아예 뺐다.
어드민 화면(RecruitmentManagement.tsx)에는 현재 상태와 구독자 수를 보여주고, 모집을 켜기 전에 window.confirm으로 "구독자 N명에게 발송됩니다" 확인을 거치게 했다. 실수로 안내메일이 대량 발송되는 사고를 막기 위한 최소한의 장치다. 켰을 때 화면이 어떻게 보이는지 미리 확인할 수 있도록 /recruit?preview=1로 여는 미리보기 버튼도 같이 넣었다.
지원폼(#125/#152)이 아직 없다는 게 문제였다. 모집중 상태가 됐을 때 방문자에게 뭘 보여줘야 할지가 정해지지 않은 상태였다. 이 부분은 이슈 #154로 PM 확인을 요청했고, 답이 오기 전까지는 팀 추천안(임시 안내 문구를 띄우고 #152가 끝나면 그 자리를 실제 폼으로 교체)을 기본값으로 구현해뒀다. PM 결정이 달라져도 frontend/src/app/recruit/page.tsx 하나만 바꾸면 되게 격리해둔 것도 그래서다.
#154 논의 결과는 B안으로 나왔다 — 지원폼이 완성되기 전까지는 운영 환경에서 모집 열기 자체를 막고, 스테이지에서만 검증하자는 방향이었다. 이걸 반영하려고 RecruitmentManagementService.open()에 app.recruitment.application-form-ready라는 스위치를 추가했다. 기본값은 false라서 운영(prod)에서는 열기가 항상 막혀 있고, 테스트와 스테이지 설정에서만 true로 열어 관리자 화면과 메일 발송을 미리 확인할 수 있게 했다. 반대로 닫기(close())는 이 스위치와 무관하게 항상 동작하도록 분리했다. 실수로 모집을 열었을 때 되돌릴 방법이 막혀버리는 상황은 만들고 싶지 않았다.
이 부분 테스트는 별도 클래스(RecruitmentManagementControllerProductionHoldTest)로 뺐다. 기존 상태 전이 테스트는 기본 설정(검증 환경 가정, true)을 그대로 쓰고, 운영 보류 케이스만 @TestPropertySource로 false를 오버라이드해서 확인하는 구조다. 하나의 테스트 클래스에서 프로퍼티를 이랬다저랬다 바꾸는 것보다 이 편이 읽기에도 명확했다.
과정이 순탄하지만은 않았다. dev 브랜치를 여러 번 병합하면서 코드가 잘려나가는 사고가 한 번 있었다 — adminApi.ts의 updateRecruitmentStatus 함수와 AdminDashboard.tsx의 멤버관리 버튼 블록 앞부분이 병합 과정에서 날아가 CI의 frontend 잡이 lint 파싱 에러로 실패했다. dev와 PR 브랜치 원본을 나란히 대조해서 누락된 줄을 다시 채워 넣었다.
리뷰 반영 과정에서는 랜딩(Recruit.tsx)과 /recruit 페이지가 각자 따로 구현하고 있던 공개 상태 조회 로직을 발견했다. 두 곳 모두 fetch로 상태를 가져오는 코드였는데, 한쪽에서 .catch()가 빠져 있어서 실패 시 unhandled rejection이 날 수 있는 상태였다. 이걸 useRecruitmentStatus 훅 하나로 합쳐서 두 화면이 같은 로직을 쓰게 정리했다. 조회가 실패하면 평소(모집 알림) 모드로 안전하게 유지되도록 만들었고, skip 옵션을 둬서 /recruit?preview=1처럼 조회 자체를 건너뛰고 즉시 모집중으로 보여줘야 하는 경우도 처리했다.
이 김에 백엔드 DTO 이름(RecruitmentPublicStatusResponse)도 shared/types/recruitment.ts 쪽 타입명(PublicRecruitmentStatusResponse)과 맞췄다. 그리고 공개 상태 조회가 "없으면 생성" 분기를 타지 않도록, recruitment_status 싱글턴 행을 마이그레이션으로 미리 시딩해뒀다 — 매 요청마다 findOrCreate가 도는 걸 피하고 싶었다.
마지막에는 마이그레이션 버전 번호 충돌이 있었다. dev에 먼저 머지된 V7__add_staff_activities.sql과 이 PR의 V7__seed_recruitment_status.sql이 같은 버전 번호를 잡고 있어서 migration-guard CI가 걸렸다. 이미 머지된 파일은 컨벤션상 건드리지 않는 게 맞아서, 이 PR 쪽 파일만 타임스탬프 기반 버전(V<yyyyMMddHHmmss>)으로 리네임해서 해결했다.
생성일이 7/22, 병합일이 7/28로 일주일 정도 걸렸는데 대부분은 dev 병합 충돌 정리와 PM 결정 대기, 리뷰 반영에 쓰인 시간이다. 코드 자체의 규모는 크지 않았지만, 다른 팀원 작업(스태프 활동 마이그레이션, 어드민 대시보드 변경)과 계속 부딪히면서 병합하는 과정이 실제로는 더 손이 갔다.