목차 › 6. 주요 기능 개발
주요 기능 개발
기획 필수 27기능은 2026. 09. 03.에 27/27 구현을 달성해 유지 중이고, 그 밖의 확장은 전부 ADR 로 편입해 배포했다 · 최종 갱신 2026. 09. 22.
아래 여섯 절은 목차의 소절 구성을 그대로 따른다. 실제로 만든 것은 이보다 갈래가 많아 기능 축과 소절이 1:1 이 아니다 — 채용 파이프라인 통합 관리는 1·2절, 이력서 이해와 자동 서류 심사는 3절, 도구 호출 에이전트 ‘아르’와 RAG 시맨틱 검색은 4절, 면접 일정 자동화 · 지원자 포털 · AI 면접은 5절, 대시보드 · 종합 평가 · 비용 관측은 6절에 나누어 넣었다. 웹·앱 두 클라이언트와 운영·모델 전략은 특정 절의 것이 아니라 여섯 절 전체에 걸쳐 있어, 해당 기능을 설명하는 자리에서 함께 적었다. 기능마다 완료 여부를 절 제목 옆 뱃지로 표시하고, 안 끝난 것은 안 끝났다고 적는다.
1.채용 공고 등록 및 관리 완료
공고는 단순한 CRUD 가 아니라 그 공고에 들어올 지원자의 심사 정책을 담는 그릇이다. 임계 점수 · 자동 심사 on/off · 기본 면접관 풀이 공고 행에 함께 저장된다(ADR-0034).
공고 CRUD(B1–B3)는 앱 오너 확정 전의 전환기 백엔드 분담분이라
민아 C 가 맡았고, 지원자 목록 · 상세 · 단계 이력(D1·D4·D5)과
담당자 직접 등록(D6)까지 한 묶음으로 2026. 08. 25.에 끝냈다(B2 #32 · D1 #48 · D6 #72).
마감일 자동 마감과 공개 지원 링크(B4·B6)는
우정 B 가 08. 26.에 붙였다(PR #108 · #109).
상태는 draft · open · closed 셋이고,
마감일을 과거 날짜로 넣으면 422 다. 공고 응답에는 deadline 과 계산값
d_day(남은 일수, 마감일이 없으면 null)가 함께 실려 화면이 D-12 로 표시한다.
마감과 만료는 크론이 아니라 조회 시점에 판정한다. 배치가 도는 시각과 사람이 보는 시각이 어긋나면 같은 공고가 두 화면에서 다르게 보이기 때문이다. 그래서 마감된 공고를 공개 경로로 조회하면 그 자리에서 410 Gone 이 나가고, 지원서 제출도 같은 410 으로 막힌다.
| 기능 | 경로 | 규칙 · 근거 |
|---|---|---|
| 공고 목록 · 생성 | GET /postingsPOST /postings |
목록에 공고별 지원자 수를 함께 내린다 — 목록에서 상세로 들어가지 않고 적체를 본다 |
| 수정 · 상태 변경 · 자동 심사 설정 | PATCH /postings/{id} |
pass_threshold(0~100, 기본 60) · screening_mode(auto/manual) ·
interviewer_ids(기본 면접관 풀, 보내면 통째로 교체). 심사 정책을 전역이 아니라
공고 단위로 둔 이유는 직무마다 합격선이 다르기 때문이다 (ADR-0034) |
| 공개 지원 링크 발급 · 재발급 | POST /postings/{id}/public-link |
재발급하면 이전 토큰이 즉시 무효가 된다 — 유출된 링크를 끊는 유일한 수단이라 무효화를 미루지 않는다 (B6) |
| 불합격 메일 일괄 발행 | POST /postings/{id}/send-rejections |
자동 판정은 메일을 즉시 보내지 않는다. 마감 전 번복 여지를 남기려고 담당자가 마감 뒤
일괄 발행한다. 이미 보낸 사람과 사람이 직접 불합격시킨 사람은 건너뛰고 {queued} 를 돌려준다 |
| 공개 조회 (지원 폼) | GET /public/postings/by-token/{token} |
로그인 없이 공고를 읽는 유일한 경로. 마감 410 · 없는 토큰이나 미공개는 404 |
화면 쪽에서는 09. 15.에 공고 마감 · 다시 열기 버튼을 넣었다 — 최종 결과가 난 뒤 담당자가 직접 닫는 동작이라 자동화하지 않고 사람의 클릭으로 남겼다. 앱에서는 공고 리스트가 첫 화면이다(PR #145). 지원자 목록에서 시작하면 지금 어느 공고를 보는 중인지가 화면에 남지 않아, 계층을 공고 → 그 공고의 지원자 → 상세로 세웠다. 공고 삭제는 되돌릴 수 없어 수정 화면 하단에 두고 확인 시트를 한 번 더 띄운다(PR #9).
2.지원자 통합 관리 완료
메인 화면은 지원자 칸반 보드다. 카드를 끌어 단계를 옮기면 이력이 남고 메일이 나간다. 단계 모델은 다섯, 그것을 지배하는 규칙은 여섯이다.
단계는 applied → screening → interview →
accepted / rejected 다섯이다. 단계를 DB enum 이 아니라 코드 상수 + 체크
제약으로 두고 전환 규칙은 서비스 레이어가 강제한다 — enum 을 쓰면 단계 하나를 늘리는 데
스키마 이행이 필요해진다. 칸반 보드 UI(D2·D3)와 공고별 지원자 화면 · 상세 패널 ·
일괄 단계 변경(D1·D4·D9)은 소연 D 가 만들었고,
이후 API 클라이언트 · 인증 배선과 함께 실연동을 붙이며 드래그 · 토스트 안내까지
넣었다(PR #131 · #132 · #134 · #135 · #140).
| 규칙 | 내용 | 그렇게 한 이유 |
|---|---|---|
| 전진은 한 칸씩 | 단계를 건너뛰는 전진은 허용하지 않는다 | 건너뛴 단계의 이력과 메일이 통째로 비어 나중에 설명할 수 없다 |
| 역행은 항상 허용 | 어느 단계에서든 되돌릴 수 있다 | 사람이 AI 판정을 되돌리는 것이 유일한 안전장치다 |
| 불합격은 어느 단계에서든 | 단 reason 이 없으면 422 |
사유 없는 이력은 나중에 아무도 설명할 수 없다 (D8) |
| 서류 단계는 AI 가 자동 판정 | 사람이 한 번 손대면 그 지원자는 이후 자동 판정에서 빠진다 | 수동이 항상 이긴다 — AI 가 사람의 결정을 덮으면 되돌릴 자리가 없다 (ADR-0034) |
| 일괄 변경은 전부 아니면 전무 | 한 번에 200명까지, 부분 실패는 전체 롤백(409) | 200명 중 30명만 바뀌면 담당자가 무엇이 됐는지 알 수 없다 (D9) |
| 사용자는 지우지 않는다 | 비활성화만 한다 | changed_by·evaluator_id 로 박힌 이력을 물리 삭제가 부순다 |
검색 — 10만 건에서 111ms → 7.8ms
더미 지원서 10만 건을 만들어 놓고 인덱스를 튜닝해 검색 응답을 111ms 에서 7.8ms 로 줄였다.
전 공고를 가로지르는 통합 검색(GET /applications)은 커서 페이지네이션을 쓰고,
with_total=false 를 주면 총 건수 집계를 건너뛰어 크게 빨라진다 — 커서로 넘기는
화면은 총 건수를 쓰지 않기 때문이다(H4·H5).
검색 범위는 이름 · 이메일로 확정했다. 자소서 본문 전문 검색은 한국어가 PostgreSQL 기본
FTS 에서 형태소 분석이 안 되는 데다 자소서 5천 자 × 10만 건이면 인덱스 용량과 쓰기 비용이
커져, pg_trgm GIN 인덱스 확장으로 미뤘다 — 스키마 변경이 아니라 인덱스 추가라
미루는 비용이 없다.
평가 · 면접관 · 메모
평가는 점수와 코멘트를 남기고 목록과 평균을 함께 낸다(E1·E2). 권한은 두 등급뿐이라
조회는 로그인만 하면 전부 허용하되, 평가 작성은 member 의 경우 배정된 건만
가능하고 미배정이면 403 이다(ADR-0017). 면접관 배정은 admin 전용이고 중복 배정은
멱등으로 무시한다. 배정 대상의 역할은 보지 않는다 — 누구나 면접관이 될 수 있다는 것이
ADR-0013 의 결론이다. 메모는 평가와 별도 엔드포인트다 — 점수가 없고, 지원자 한 명에
여러 사람이 각자 행을 쌓기 때문이다.
같은 API, 두 클라이언트
React 웹 25화면과 Flutter 앱 23화면이 같은 FastAPI 를 쓴다. 앱은 JWT 를
flutter_secure_storage(Android Keystore)에 넣고, 조회 · 단계 변경 ·
평가 작성 · 이력서 열람까지 웹과 같은 일을 한다. 앱의 단계 변경 시트는
backend/app/stages.py 의 전이 규칙을 앱으로 옮겨 갈 수 있는 단계만 보여주고,
메일 경고는 실제로 메일이 나가는 단계에만 띄우며, 불합격은 사유 없이 넘어가지 못하게 했다
(PR #146, 앱 테스트 36 → 42개). 서버 규칙을 화면이 다시 해석하지 않고 그대로 옮긴 것이라,
클라이언트가 하나 더 붙어도 서버가 달라지지 않는다.
3.이력서 파싱 및 자동 스크리닝 완료
접수 → 추출 → 3단 체인 요약 → 3축 채점 → 자동 단계 이동까지 한 줄로 이어져 있다. 최종 합불만 사람이고, 서류 단계는 아르가 옮긴다(ADR-0003 → 0034).
업로드 — 파일이 API 서버를 거치지 않는다
이력서는 presigned URL 로 브라우저에서 S3 로 직접 올라간다. 파일이 API 를 통과하지 않아
서버 부담과 유출 접점이 함께 줄어든다. 업로드 시점에는 아직 지원서 행이 없으므로,
제출 본문에 files[](s3_key·filename·size_bytes·
content_type·kind)를 함께 보내 그때 files 행을 만든다(F1 → C2).
사고 — 브라우저에서만 업로드가 조용히 막혔다
API 는 정상이고 서버 로그도 없는데 브라우저에서만 실패했다. 원인은 버킷 CORS 미설정이었다 — CORS 는 브라우저만 검사하므로 서버 간 PUT 은 통과해 드러나지 않았다. PUT · 프론트 출처 · 헤더를 허용하고, “프론트 주소가 늘면 여기에도”를 문서 규칙으로 남겼다.
추출 — 실패해도 요약은 죽지 않는다
app/agent/extractor.py 가 S3 에서 파일을 받아 PDF · DOCX · HWPX 본문을
뽑는다(구형 HWP 미지원). 어떤 실패도 None 폴백이라 추출이 안 돼도 요약 자체는
돌아간다. 로컬 처리라 LLM 비용이 들지 않고, 프롬프트 주입을 막으려 8천 자 상한을 걸었다.
이 경로를 붙이기 전에는 요약이 지원 폼 필드만 읽어서 이력서를 충실히 낸 지원자도
“제출물 부족”이 나왔다(PR #158).
폼에서 비운 학력 · 경력 · 기술스택은 이력서에서 추출해 채운다
(_fill_structured_fields, 소연 D PR #307).
경력 연수를 확인할 수 없으면 지어내지 않고 null 을 돌려준다. 그리고
AI 가 채운 값에는 career_years_source="ai" 표식을 남겨 화면에 「AI 추정」 배지로
보인다 — 표식이 없으면 지원자 신고값과 구별되지 않아 공정성 문제가 된다.
남은 것 — 요약을 재생성하면 「AI 추정」 표식이 사라진다
PR #311 로 후속 처리하기로 했으나 머지되지 않았다. 재생성을 거친 지원자는 추정값과 신고값이 화면에서 다시 구별되지 않는다.
3단 체인과 3축 채점
요약→평가→추천을 한 번의 프롬프트가 아니라 세 단계로 나눈다(ADR-0022).
1단계가 요지와 핵심 역량을 뽑고, 2단계가 자격요건 · 우대사항 · 인재상 3축으로
점수와 강점/우려를 매기고, 3단계가 추천과 확인 질문을 낸다. 근거가 부족하면 지어내는 대신
‘판단 불가’로 표시하고, 어떤 모델이 만들었는지 태그로 남긴다.
점수는 applications.doc_score 에 저장되고, 공고의 pass_threshold 이상이면
아르가 applied→screening→interview 로, 미만이면 rejected 로 옮긴다.
이때 이력의 changed_by 는 NULL(시스템 판정)이고 점수 사유가 함께 박힌다.
가중치는 실측으로 고쳤다
fit-check 지원자 24명의 판정을 사람이 전수 검토해 아슬한 세 케이스를 찾아냈다 — 요건이 우수한데 인재상 한 문구로 탈락 · 1점 차이 탈락 · 필수 요건을 통과했는데 우대 부족으로 탈락. 인수인계에 적혀 있던 “자동 불합격 3명”도 실제로는 8명이었다. 그 8명의 3축 점수를 각각 수치화한 리포트를 근거로 가중치를 고쳤다(PR #182).
| 축 | 개정 전 | 개정 후 | 이유 |
|---|---|---|---|
| 자격요건 | 50 | 60 | 직무를 할 수 있는가가 먼저다 — 강조 |
| 우대사항 | 20 | 10 | 있으면 좋은 것이지 없다고 떨어질 것이 아니다 — 축소 |
| 인재상 | 30 | 30 | 유지. 대신 요건 점수가 70 이상이면 인재상 하한 50 규칙을 새로 넣어, 요건 우수 지원자를 자소서 한 문구로 떨어뜨리지 않는다 |
아슬한 건을 위해 hold(보류) 상태를 두자는 안은 팀장 결정으로 채택하지 않고
자동 판정을 그대로 유지했다. 개정은 자동 CD 로 배포했고, 기존 회사 프로파일 두 키는
alembic 0020 으로 갱신했다.
자료가 없는 지원자
자소서가 1줄이거나 전부 미기재면 분석할 것 자체가 없다. 요약 결과가
insufficient=true 면 decide_document 가
applied→screening→rejected 로 옮겨 화면에 「서류 탈락」 라벨이 붙게 했다
(PR #349 · #351). applied→rejected 직행으로 두면 사유를 알 수 없는
「불합격」으로 보이기 때문이다. 실제 역량이 있는 지원자(insufficient=false)는
건드리지 않아 대량 탈락을 막았고, 대상 3명은 요약 재생성 없이 판정만 재실행해 $0 로 끝냈다.
사고 두 건 — 점수 수렴과 JSON 깨짐
전원 64~65점으로 수렴. 온프레미스에서 요약을 재생성하니 모든 지원자가 요건 70 · 우대 40/50 · 인재상 60 으로 나와 변별력을 잃었다. 원인은 프롬프트의 출력 예시 숫자 70/40/60 을 소형 모델이 그대로 베낀 것이었다 — Claude 는 베끼지 않아 드러나지 않았다. v3 프롬프트에서 예시 숫자를 지우고 채점 구간표(85~100 / 65~84 / …)와 절차를 명시하니 실측이 85 · 85 · 40 · 15 로 갈렸고 Claude 의 78 · 86 · 44 와 같은 방향이었다.
JSON 이 깨져 점수 NULL. 56명 재생성 중 2명의 서류 점수가 비었다. 소형 모델이 문자열 안에 이스케이프하지 않은 따옴표를 써서 파싱이 실패한 것으로, Ollama 의 format 스키마도 막지 못했다. 파서에 문자열 내부 따옴표 복구 폴백을 넣어 56/56 파싱 성공.
제출물 무결성 원장
이력서 · 자소서의 SHA-256 지문을 추가 전용 원장에 해시 사슬로 쌓고, DB 트리거가 UPDATE · DELETE · TRUNCATE 를 거부한다. 사슬 머리는 매일 이더리움 Sepolia 테스트넷에 앵커해 위·변조를 외부에서 검증할 수 있게 했다(ADR-0028). 서명 개인키는 서버에 두지 않고 GitHub Actions 에 둔다 — 서버가 뚫려도 서명은 못 한다. 화면에는 민아 C 가 상세 패널 첨부 아래에 무결성 배지를 붙여 (PR #46), 앵커 게시 이후 제출물이 바뀌었는지 한눈에 보인다. 운영 체인은 처음 Polygon Amoy 였으나 무료 가스 수도꼭지 6곳이 전부 막혀 Sepolia 로 전환했다(PR #29). 시연 데이터 정리를 위해 트리거를 임시 해제한 이력이 실제로 있었기 때문에, 트리거 상태 변경이 반드시 흔적을 남기도록 감사 트리거 · 이벤트 로그 · 문서화 규칙 세 갈래를 ADR-0028 §1.7 에 추가했다(PR #173). 원장은 잠긴 상태가 기본이고 우회는 마지막 수단이다.
4.지원자–공고 매칭 추천 완료
매칭은 세 갈래로 구현했다 — 공고 요건으로 점수를 매기는 3축 채점(3절), 의미로 찾아내는 RAG 시맨틱 검색, 자연어 한 문장을 작업으로 분해하는 도구 호출 에이전트 ‘아르’. 세 갈래 모두 추천까지이고 확정은 사람이 한다.
RAG 시맨틱 검색 (ADR-0021)
스킬 · 학력 · 경력을 ko-sroberta 로 임베딩해 pgvector 에 768차원으로
저장하고, “Python 경험자” 같은 의미 검색을 붙였다
(수택 E PR #151). 키워드 검색을 버리지 않고 폴백으로 병행하며,
응답의 search_mode 로 벡터가 실제로 돌았는지 화면에 노출한다 —
확장이 꺼져 있을 때 조용히 키워드로 떨어지면 사용자가 왜 결과가 달라졌는지 알 수 없다.
도입 직후 스킬 배열의 정확 일치를 시맨틱 랭킹 최상위로 올려 “Kubernetes” 같은
고유명사에서 의미가 비슷한 엉뚱한 지원자가 먼저 나오던 것을 원천 차단했다.
사고 — pgvector 확장 하나 때문에 API 전체가 안 떴다
재배포 직후 API 가 재시작 루프(exit=3 · restarts=10)에 빠지고
/health 가 502 였다. RAG 가 커넥션마다 CREATE EXTENSION vector 를 거는데
운영 DB 이미지(postgres:16-alpine)에 확장이 없어 lifespan 이 죽은 것이다.
fix-forward 로 — 확장을 못 켜면 경고만 남기고 시맨틱 검색만 꺼진 채 기동하게 바꾼 뒤,
DB 이미지를 pgvector/pgvector:pg16 으로 교체했다(alpine musl → debian glibc 전환이라
REINDEX 동반). 데이터는 무사했고, 로컬 compose 도 같은 이미지로 맞춰 재발을 막았다.
도구 호출 에이전트 ‘아르’ — 도구 12종
“김도현 찾아줘 → 면접 안내 이메일 만들어줘” 같은 자연어를 검색 · 조회 ·
단계 변경 · 면접관 배정 · 일정 제안 · 메일 초안 · 이력서 드롭 접수로
분해한다. 핵심은 실행 경계다 — 읽기 도구는 즉시 실행, 쓰기 도구는
pending_action 으로 반환되어 사람이 확인 카드를 승인해야
POST /agent/confirm 으로 실행된다. 메일 발송도 이 경로를 탄다. 되돌릴 수 없는 조작에
승인을 강제한 것이 이 기능의 설계 전부라고 해도 된다.
| 검증 | 내용 | 결과 · 실측 |
|---|---|---|
| 데모 4단계 E2E | 검색 → 조회 → 단계 변경 → 이메일 초안, 로컬 서버 실호출 | 4단계 전부 정상. 동명이인 2명 안내 · 후퇴 불가 안내 + 대안 · 초안은 pending_action 반환.
비용 $0.045 |
| 엣지 8종 | 검색 0건 · 모호한 요청 · 잘못된 단계명 · 범위 밖 삭제 요청 · 멀티턴 3단계 ID 추적 · 특수문자(O’Brien) 등 | 8종 전부 PASS, 비용 $0.083 |
| API 레벨 테스트 | 에이전트 4개 엔드포인트를 HTTP 로 — 기본 20 + 경계값 · SQL injection · XSS · 권한 없는 접근 · 빈 입력 21 | 41건 (PR #141). 에이전트 관련 테스트 포함 전체 185개 통과 시점 기록 |
| 회귀 하네스 | 실서버 /agent/chat 시나리오 — 기본 10 + 변형 30 |
40시나리오. 모델·프롬프트를 바꿀 때마다 돌린다 |
긴 대화에서 application_id 를 엉뚱한 값(id=1)으로 참조하는 문제가 E2E 에서 잡혀,
프롬프트에 규칙 두 줄을 넣었다 — 직전 도구 결과에서 확인된 ID 만 쓴다,
“이 지원자”는 최근 조회한 지원자를 가리킨다(PR #136).
면접관을 이름으로 찾지 못하던 문제는 search_users 읽기 도구로 풀고, 프롬프트에
user_role 을 주입해 member 가 admin 전용 도구(assign_interviewer)를
요청하면 도구를 부르기 전에 차단한다(PR #153).
무관한 질문과 비용
“안드레 카파시가 누구야” 같은 질문에 Claude 가 인물 설명을 다 생성한 뒤 거절 문구를
덧붙이던 문제가 있었다. 거절 규칙이 “정치 · 연예 · 주식”처럼 카테고리
열거식이라 인물 · 상식 유형은 모델이 확률적으로 판단한 것이다. 판단 기준을
“답의 근거가 우리 데이터냐”로 다시 쓰자, 외부 일반 지식은 내용 없이 한 문장으로
거절하고 이력서 · 자소서 속 인물 · 프로젝트는 원문을 조회해 정상 답변한다(PR #353).
비용 쪽은 “안녕”, “뭘 할 수 있어” 같은 정적 발화가 매번 LLM 을 타 호출당 약
$0.02 씩 나가던 것을, 규칙 의도 라우터의 캔드 응답으로 우회했다 — 정규식을
^…$ 로 묶어 실제 요청이 붙으면 다시 LLM 으로 넘긴다.
실측 아르 호출당 평균 $0.0075, 인사 · 능력 · 사용법 같은 기본 질문은 $0 다.
추천의 성격은 화면 문법으로도 못박았다 — 앰버 점선은 AI 제안, 실선은 사람 확정이다.
아르가 서류 단계를 옮겨도 accepted 는 사람만 만들 수 있고, 사람이 한 번 단계를
옮기면 decision_source=human 이 되어 그 뒤로 자동은 손대지 않는다.
5.면접 일정 관리 및 알림 완료 잔여 결함 1건
가용 시간 등록 → 후보 슬롯 제안 → 지원자 선택 → 확정 → 통보까지 전 구간이 2026. 08. 31.에 배포 서버 E2E(가용 시간→배정→제안→공개 조회→확정→메일 렌더)로 관통을 확인했다(ADR-0016).
면접관이 자기 가용 시간을 등록하면(본인 또는 admin), 배정된 면접관의 가용 시간에서 후보 슬롯이 만들어지고 제안 메일이 큐에 들어간다. 지원자는 메일 링크의 공개 페이지에서 슬롯을 고르고, 이미 확정 · 만료 · 취소된 제안이면 409 가 난다. 확정되면 통보 메일이 또 큐에 들어간다. 만료는 조회 시점에 판정하고, 이미 나간 제안의 슬롯은 스냅샷이라 면접관이 가용 시간을 지워도 영향받지 않는다 — 보낸 제안이 뒤에서 바뀌면 지원자가 본 화면과 서버가 갈린다. 아르에는 일정 도구 4개(조회 · 제안 · 확정 · 취소)를 붙여 자연어로도 같은 일을 한다 (수택 E PR #150 · #153 · #157).
지원자 쪽 — 24시간 접근
지원자 일정 페이지는 카드 + 정적 안내에서 아르와의 대화형으로 다시 만들었다(PR #157).
PC 는 아르 캐릭터 + 일정 카드/채팅 2단, 모바일은 상단 고정 일정 카드(접기·펴기) + 채팅이다.
아르 얼굴은 3D 뷰어 스냅숏 PNG 12KB 로 대체해 지원자 기기의 WebGL 부담을 없앴다.
FAQ 는 POST /public/schedule/{token}/faq 로, 토큰 인증 · stateless 1문1답이며
담당자용 아르와 완전히 별개 경로다 — 지원자가 담당자 도구에 닿을 길을 아예 만들지 않았다.
연봉 · 평가 · 다른 지원자 · 회사 내부 절차는 답하지 않고, 프롬프트 주입을
무시하는 것을 실호출로 확인했다. 실측 하네스 15 시나리오를 신설했고, 인사 · 연봉 ·
합격 가능성 · 감사 인사는 규칙 응답으로 $0 다.
메일 — 경로를 갈아엎고 재발송을 넣었다
문구는 코드 기본값 + DB 오버라이드다. 4종(applied·interview·
accepted·rejected)만 있고, 허용하지 않은 치환 변수를 쓰면 422 로 막는다.
발송 주체(email_logs.actor_kind)가 From 표시 이름 · 본문 서명 · 회신
주소 셋을 함께 정한다 — 셋이 어긋나면 지원자가 누구에게 연락해야 할지 헷갈린다.
발신 주소는 언제나 하나다. 담당자 개인 주소를 From 에 넣으면 외부 메일에서 DMARC 정렬이
깨져 스팸함으로 가기 때문이고, 개인 연락처 역할은 Reply-To 가 맡는다.
일괄 단계 변경에서도 메일은 건별로 큐에 넣는다 — 지원자마다 이름과 공고가 달라
한 통으로 묶을 수 없다.
| 시점 | 메일 경로 | 바꾼 이유 |
|---|---|---|
| 초기 | API → SQS 큐 → 메일 워커 컨테이너 → SES | — |
| 2026-09-07 | n8n 컨테이너 도입 (ADR-0030 1단계) | 발송을 코드가 아니라 워크플로 노드로 분리하면 노드 하나만 바꿔 공급자를 교체할 수 있다. 워크플로 JSON 은 저장소가 진실 — 화면에서 고쳤으면 내보내서 커밋한다 |
| 현재 | API → n8n 웹훅 → SMTP | SQS · SES 는 폐기(ADR-0031 · 0036). AWS 사용 서비스를 8종에서 3종(EC2·S3·IAM)으로 줄였다 |
사고 — n8n 이 웹훅을 받고 죽어 메일이 queued 로 남았다
경로가 하나뿐이라 n8n 이 멈추면 합격 · 불합격 통보가 통째로 멎는 단일 장애점이었다.
API 가 스스로 5분마다 밀린 메일을 SMTP 로 직접 재발송하게 했다(최대 3회, 초과는
failed 로 접어 사람이 확인). 우정 B 09-17 작업.
면접 세션 링크
면접 세션을 만들면 지원자에게 링크 메일이 나간다(notify 기본 true).
재발송은 새 세션이 아니라 같은 세션 · 같은 링크를 다시 보내는 별도 경로다 —
새 세션을 만들면 앱이 가장 먼저 만들어진 방으로 들어가 담당자와 갈린다.
만료는 410, 이미 끝난 면접은 409 다. 그리고 링크를 만들자마자 지원자가 시작해도 422 가 나지
않도록 기본 질문 3개를 세션과 같은 커밋에 먼저 넣고, 뒤에서 이력서 · 자소서 기반
맞춤 질문 최대 4개(09-12 에 10 → 4로 줄임)를 만들어 아직 시작 전이면 바꿔 넣는다.
남은 것 — 면접관 자동 배정이 0명으로 떨어진다
프리즈(09-20) 시점에 미해결로 남은 결함이다. 담당은 백엔드. 배정 자체는 수동으로 가능하므로 일정 조율 흐름 전체가 막히지는 않는다.
6.채용 현황 대시보드 및 통계 완료
대시보드는 목록이 아니라 현황판이다. 2026. 09. 07.에 지원자 목록을 여기서 뺐다 — 사람을 훑는 일은 ‘지원자’ 탭이 이미 하고, 대시보드는 “회사가 지금 어떻게 돌아가나”에만 답한다.
화면은 위에서 아래로 얼마나 → 어디가 → 언제 순이다. 부르는 API 는 셋뿐이고 전부 다른 화면을 위해 이미 열려 있던 것이라 백엔드 변경이 없었다.
| 블록 | 데이터 출처 | 설계 판단 |
|---|---|---|
| 전체 현황 · 공고별 적체 | GET /postings( status·d_day·stage_counts) |
막대는 심사 중 3단(접수·서류·면접)만 그린다. 합격·불합격은 한번 되면 영원히 쌓이는 누적값이라 같은 자를 쓰면 시간이 갈수록 앞 세 칸이 실오라기가 된다 — 견줄 대상이 아니다. 덤으로 두 색은 적록색약에서 ΔE 3.5 라 나란히 두면 경계가 보이지 않는다 |
| 이번 주 면접 | GET /schedules?from&to |
확정된 일정만 싣는다. 캘린더 화면과 같은 규칙으로 주 시작을 일요일로, 시간대를 Asia/Seoul 로 고정했다 — 여기서만 다르면 같은 주가 두 화면에서 다르게 보인다 |
| 지금 면접 중 | GET /interview-sessions/active |
in_progress 만 낸다. 지원자 이름과 공고 제목을 같이 내려
대시보드가 한 번에 들어간다 — 없으면 지원자 목록→상세→세션→링크 넷을
거쳐야 실시간 분석 화면에 닿는다. 이 블록은 실패해도 화면은 뜬다 |
표에 싣는 진행 중 공고는 6개까지이고 넘치면 잘라 “외 n개”를 단다 —
스크롤을 만들면 현황판이 다시 목록이 된다. 09. 15.에는 ‘내 배정 수’
(GET /interviewers/{me}/applications) 블록을 뺐다.
경로 순서에도 함정이 하나 있었다 — active 를 {id} 아래에 두면
active 가 id 로 읽혀 422 가 난다.
종합 평가 — 점수를 한 화면에서
GET /summary 가 공고(최신순)마다
{id, title, status, applicant_count, applicants[]} 를 주고, 지원자마다
doc_score · doc_decision ·
interview_ai_score(최신 면접) · final_score ·
grade(S/A/B/C) · ai_summary 와 최신 면접의 강점 · 우려
(각 최대 3개)를 싣는다. 페이지 없이 전 공고를 한 번에 준다 — 담당자가 여기서
최종 합격 · 불합격을 확정하므로 화면을 넘겨 가며 비교할 일이 없어야 한다.
최종 점수는 서류×가중치 + 면접×가중치이고, 가중치 7개 · 인재상 ·
등급 경계는 GET/PUT /settings/scoring 으로 admin 이 바꾼다(가중치 합이 100 이
아니어도 되며 합으로 나눈다). 화면 구성은 민아 C 가 대시보드 ·
평가 현황을 시안대로 재구성하면서 함께 정리했다(PR #27 · #28 · #41 · #43).
통계는 코드가, 재서술만 AI 가
인적성(사전 성향) 설문은 10문항 5점 척도이고, 카테고리 통계는 코드가 계산한다.
아르는 응답을 관찰 문장으로 재서술만 하며 성격 유형 판정 · 점수화 · 합불 의견을
만들지 않고, 미응답에 불이익도 없다(ADR-0027). 공고 단위 일괄 발송은 응답으로
{sent, skipped_already_sent, skipped_stage} 를 돌려줘 몇 건이 왜 빠졌는지 숫자로
남긴다.
면접 중 실시간 분석도 같은 원칙이다. stats.overall 과
stats.per_question[](판정 수 n · truth % ·
표정 라벨별 비율 · blink_per_sec · flags_pct · 음성)을 수치로 쌓고,
AI 가 그것을 한 문단으로 요약한다. 다만 요약 문장에 stats 에 없는 숫자가
나오거나 사람을 단정하는 단어가 나오면 그 문장을 버리고 고정 틀로 대체하며,
어느 쪽이었는지를 summary_source 로 구별한다. 표정(ViT + MediaPipe)과 음성 신호는
담당자 참고 지표일 뿐 점수 · 합불 판정에 쓰지 않는다(ADR-0029 · 0032).
비용도 지표다
채팅 · 요약 · STT 세 기능에 토큰 · 비용 로깅을 통일했다. 응답의
model 은 모델명이 아니라 backend:model 태그다 — 백엔드마다
토크나이저가 달라 토큰 수를 그대로 비교할 수 없으므로 어느 엔진이 낸 값인지 함께 남긴다.
로컬 백엔드는 프롬프트 캐싱 개념이 없어 캐시 토큰이 항상 0 인데, backend 필드가
있어야 ‘캐시 미적중’과 ‘캐시 개념 없음’이 구분된다.
실측은 아르 호출당 평균 $0.0075, 실시간 면접 세션당 약 $0.05(답변 10개 + 종료 시 1회),
STT 는 분당 $0.006 으로 면접당 약 $0.06 이다. 대량 더미 데이터에는 AI 호출을 금지했다.