Arda 개발 보고서
2026. 09. 29. github.com/Seuk-Team/Arda

목차 › 4. 시스템 설계

시스템 설계

Arda (Eval-ATS) · 헥사고날 부분 적용 4 컨텍스트 · 28 테이블 · 107 라우트 · 최종 갱신 2026. 09. 22.

컨텍스트 4ADR-0035
테이블 28ERD v2.8
alembic 리비전 260001~0026
HTTP 라우트 107+ WS 1
라우터 파일 24main.py 등록
디자인 토큰 87tokens.css

1. 시스템 아키텍처

설계의 기준 문서는 ADR-0035(헥사고날 부분 적용 · Bounded Context, 2026. 09. 12. 확정)다. 아래 수치는 저장소 backend/app/ 를 직접 센 값이고, 문서와 코드가 어긋나는 지점은 어긋난 대로 적었다.

재편 전 backend/app/ 는 레이어 분리가 없었다. 라우터 22개 · SQLAlchemy 엔티티 23개 (models.py 1,137줄) · 도메인 로직 · 인프라 어댑터가 한 패키지에 평평하게 놓여 있었고, 그 상태가 네 가지 통증을 낳았다 — app.models 가 71개 파일에서 89회 import 되어 스키마 변경 파급을 볼 수 없었고, 큰 라우터가 500~900줄 (interviews 885 · agent 698 · schedules 569)까지 자라 유스케이스를 라우터가 직접 조립했으며, "단계 전이" 하나가 screening.py · stages.py · stage_service.py 세 파일에 흩어졌고, LLM 백엔드 스위치가 환경변수로만 갈려 인터페이스로 승격돼 있지 않았다.

해법으로 Full DDD 를 도입하지 않았다. 팀 4명 · 발표 임박 · main 머지 2분 후 자동 배포라는 조건에서 Aggregate Root · Domain Events · DTO 이중 레이어의 유지비가 이득을 넘기 때문이다. 대신 Ports & Adapters + Repository + Bounded Context 셋만 취했다. 컨텍스트를 4개로 나눈 이유는 Arda 에 자연 허브가 하나가 아니라 넷이기 때문이다 — 하나로 몰면 나머지 셋이 위성으로 왜곡된다.

4개 Bounded Context

컨텍스트 자연 허브 폴더 모델 라우트 무엇을 쥐고 있나
Application 가장 큰 스타 Application app/application/ 10 38 접수 → 심사 → 단계 이동 → 결정. 단계 이력 · 채점 · 메일 · 메모 · 통합 검색 · 아르 채팅
Interview InterviewSession app/interview/ 7 32 면접관 배정 · 가용 시간 · 일정 제안 · 세션 실행 · 채점 · 실시간 소켓
Hiring JobPosting app/hiring/ 5 7 공고 CRUD · 마감일 · 공개 링크 · 회사 프로필 · 심사 가중치 제공(계산은 안 한다)
Talent User app/talent/ 3 11 담당자 인증 · 계정 · 권한, 그리고 지원자 로그인(별도 토큰 종류)
shared 컨텍스트 아님 — app/shared/ 3 20 파일 · 무결성 원장 · /internal/* · 메일 · S3 · 해시 사슬 계산

합계 모델 28개 · 라우트 108개(HTTP 107 + WebSocket 1)다. 에이전트 코드(app/agent/)는 컨텍스트 폴더 밖에 남겼다 — 채팅 엔드포인트와 오케스트레이션은 Application 에 있고(api/agent.py · agent_service.py), 프롬프트 · 도구 · 추출기 · 임베더 · STT 는 여기 있다. 소유 도메인이 다르기 때문이다. 소유는 도메인 오너제를 그대로 따른다: 우정 B backend/(agent 제외) · 소연 D backend/app/agent/ + application/api/agent.py — 폴더가 갈려도 오너는 따라간다 · 민아 C frontend/ · mobile/ · 수택 E infra/ · .github/.

포트와 어댑터

인바운드 — 밖에서 들어오는 길

FastAPI 라우터 24개(*/api/*.py) + WebSocket 1개. adapter/inbound/ 는 만들지 않았다 — 흡수할 것이 REST 하나뿐인데 폴더를 미리 파면 빈 껍데기가 규약처럼 남는다.

도메인 — 컨텍스트 4개

점수 계산은 application/screening.py 안에서 DB 를 만지지 않는 순수 함수 (doc_score_from · interview_score_from · final_score · grade)로 뽑혀 가중치를 인자로 받는다 — 분리가 끝난 것은 아니다. 같은 파일에 DB 를 쓰는 판정·배정 (decide_document · pick_interviewer)이 함께 있다. 단계 전이는 stage_service.apply_stage_change 하나만이 진실이다 — REST 와 에이전트 도구(change_stage)가 같은 함수를 통과한다. 이 모듈을 뺀 이유가 사고다(#148) — 두 경로가 각자 구현하던 때 에이전트 쪽은 email_logs 행만 만들고 큐로 발행하지 않아 메일이 영영 안 나가는데 응답은 성공이었다. 2026. 08. 31. 에 고쳤다.

출력 포트 — 도메인이 요구하는 밖의 계약 (6개, ABC)

ports/output/ 에 Repository 4종(application · hiring · interview · talent) + llm_port + mail_dispatcher_port.

아웃바운드 어댑터 — 구현

adapter/outbound/pg/ 에 Pg*Repository 4종, adapter/outbound/mail/ 에 디스패처 3종(n8n · smtp · sqs), adapter/outbound/llm/ 는 지금은 re-export 껍데기다 — 실체가 아직 app/agent/backends/(Anthropic · Ollama)에 있고, 옛 import 경로를 깨뜨리지 않으려고 이름만 먼저 옮겼다.

채택한 것과 버린 것

채택

  • Ports & Adapters — agent/backends/base.py 의 Protocol 패턴을 전 도메인으로 확장
  • Repository — 71개 파일이 models.py 를 직접 참조하던 결합을 끊는다
  • Bounded Context 4개 — 스타를 넷으로 인정
  • SQLAlchemy 2.0 모델을 그대로 도메인으로 쓴다 — 컨텍스트는 폴더 이름으로만 표기

거부

  • Full DDD 파편(Aggregate Root · Domain Events · Event Sourcing) — CRUD + 워크플로 성격에 과하다
  • DTO ↔ Pydantic 이중 레이어 — 스키마를 고칠 때마다 두 곳을 고쳐야 하고 어긋나면 런타임에만 드러난다
  • domain/entities/ 순수 dataclass 분리 — 매핑 코드가 배로 늘고 얻는 것은 순수성뿐
  • grpc/ · scheduler/ 폴더 선제 생성

아직 안 된 것

Phase 2(대형 라우터 3개를 UseCase 로 분리)는 실행하지 않았다 — app/use_cases/ 디렉터리가 저장소에 없다. 다만 셋 중 둘은 UseCase 가 아니라 서비스 파일을 빼는 것으로 얇아졌다 — agent.py 698→324줄(agent_service.py 384), schedules.py 569→444줄(schedule_service.py). interviews.py 만 885→915줄로 되레 늘었다 — session_service.py(436)를 뺐는데도 그 사이 붙은 기능이 더 많았다. shared/api/internal.py 가 interview/api/interview_rtc.py 를 참조하는 역방향 의존이 3곳 남아 있고(interview/README.md 는 4곳으로 적어 뒀다 — 문서가 코드보다 한 걸음 늦다. app.interview 전체로 넓히면 5곳이다), 실시간 소켓은 모듈 레벨 _ROOMS dict 로 상태를 들고 있어 프로세스를 늘리면 방이 갈린다 — 컨텍스트 README 에 후속 1순위로 적어 뒀다. 각 Phase 는 스스로 CI 초록 · main 배포 가능해야 한다는 규칙 때문에 여기서 끊었고, 끊긴 자리를 문서에 남겨 두는 쪽을 택했다.

물리 배치

프론트(React/Vite)는 Vercel, 나머지는 EC2 한 대의 docker compose 다섯 컨테이너(db · api · caddy · lie-detection · n8n)다. 443 앞의 Caddy 가 경로로 갈라 FastAPI · /ai/* · /n8n/* 로 보낸다. 이력서는 presigned URL 로 브라우저에서 S3 로 직행해 API 서버를 지나가지 않고, 실시간 면접 영상도 WebRTC 라 서버는 연결을 맺는 쪽지(SDP · ICE)만 나른다. 무거운 바이트를 API 가 들지 않는 것이 이 배치의 골자다. AWS 사용 서비스는 8종에서 3종(EC2 · S3 · IAM)으로 줄였다 (ADR-0031 · 0036).

인스턴스는 t3.micro → t3.small → t3.medium(09. 07.) → t3.large(09. 09.)로 올라왔고, 마지막 이관은 오진이었다. 전사 컨테이너가 죽던 진짜 원인은 코드 기본값이 CPU 에서 float32 로 잡히던 것이었다 — int8 로 바꾸자 로드 시 RSS 가 3,835MB 에서 891MB 로 떨어졌고, 그 사실을 알기까지 메모리 상한 상향 5번과 인스턴스 이관 1번을 썼다. 규모를 키워 덮는 것이 원인을 가린 사례로 문서에 남겼다.

2. 데이터베이스 설계 (ERD)

전체 정의는 저장소 docs/00_overview/01-erd.md(v2.8 · 2026. 09. 17.)가 진실이다. 여기서는 핵심 엔티티와 관계, 그리고 왜 그렇게 나눴는가만 추린다.

지원서(applications)가 축이다. 지원자는 로그인 없이 지원하므로 인물 테이블을 따로 두지 않고 지원서가 사람 정보를 안는다 — 인재풀 기능을 하게 되면 그때 분리한다. 테이블은 28개이고 스키마 이행은 alembic 리비전 26개(0001~0026)로 관리한다. 09. 12. 에 한 파일이던 models.py 를 컨텍스트별 파일 (application 10 · interview 7 · hiring 5 · shared 3 · talent 3)로 쪼갰다.

핵심 관계

관계카디널리티왜 이 모양인가
job_postings → applications 1 : N 지원서 1건은 공고 1건에 묶인다. 그래서 AI 요약이 공고 요건에 종속돼도 별도 테이블 없이 applications 에 직접 둘 수 있다
applications → stage_history 1 : N 단계는 덮어쓰지 않고 쌓는다. changed_by 가 NULL 이면 시스템·자동 판정이고, 값이 있으면 사람이다 — 이 한 칸이 자동 심사의 개입 여부를 가른다
applications → evaluations / application_notes 1 : N (둘 다) 일부러 두 표로 갈랐다. 평가는 1~5점이 필수인 행이라, 점수 없는 서술형이 섞이면 평가 목록과 평균이 오염된다
applications → files → document_anchors 1 : N : 0..1 파일 1건에 앵커 1건. 앵커에는 원본이 없고 지문(SHA-256)만 있어 이 표가 유출돼도 이력서 내용은 새지 않는다
document_anchors → chain_publications 1 : N (FK 없음) 각 고리가 앞 고리를 재료로 쓰므로 머리 하나가 앞 전부를 덮는다 — 머클 트리도 고리별 증명도 필요 없다. FK 대신 chain_hash + covered_through_seq 로 잇고, 같은 머리라도 네트워크가 다르면 각각 한 행이다((network, chain_hash) 부분 유일 인덱스)
applications → interview_sessions → interview_turns 1 : N : N 재발송이 새 행이라 옛 링크가 죽지 않는다. 회차마다 질문·음성·전사·응답 시각이 붙는다
interview_sessions → interview_findings 1 : N 서류 주장 ↔ 면접 발언 대조. 점수 컬럼이 없다 — 값은 일치·불일치·확인필요 셋뿐이고 판단은 사람이 한다
applications → application_embeddings 1 : 0..1 768차원 pgvector 1개. 파생 데이터라 지우고 백필로 다시 만들 수 있다
users ↔ applications N : M interviewer_assignments 가 잇는다. 역할이 아니라 "그 건의 면접관"이라는 관계라 배정 대상의 role 은 검사하지 않는다
company_profile → integration_clients → applications 1 : N : N 회사 시스템이 서버 대 서버로 밀어 넣는 경로. external_id 가 멱등 키다

단계(stage)

applied → screening → interview → accepted / rejected 5종 고정이다. DB enum 이 아니라 체크 제약 + 코드 상수로 관리한다 — enum 타입을 쓰면 값 하나 늘릴 때마다 마이그레이션이 붙기 때문이다. rejected 는 어느 단계에서든 진입할 수 있고, 그 외 전진은 순서대로만 간다. 규칙 강제는 DB 가 아니라 백엔드 서비스 레이어가 한다.

설계 규칙

원장은 고칠 수 있는 칸이 하나도 없다

document_anchors 는 DB 트리거가 UPDATE · DELETE · TRUNCATE 를 거부한다 (0006 · 0008). 0005 가 2단계 타임스탬프용으로 비워 두고 0006 의 트리거가 유일한 예외로 열어 뒀던 ots_status · ots_proof 두 칸은 공개 체인 기록을 chain_publications 로 빼면서 0008 에서 없앴다 — 열린 칸 하나가 곧 원장의 유일한 구멍이기 때문이다. TRUNCATE 는 행 트리거를 안 타므로 문장 트리거로 따로 막았다.

인덱스는 비용을 적고 넣는다

최신순 목록·커서 페이지네이션용 (created_at DESC, id DESC) 는 3.2MB · 쓰기 +19% 를 대가로 적어 두고 넣었다. 그 밖에 (job_posting_id, current_stage)(칸반·단계 필터) · email(앱 로그인) · ix_..._hnsw(vector_cosine_ops). 더미 10만 건 기준 검색은 111ms → 7.8ms 로 줄었다.

중복은 제약 하나로 막는다

복합 UNIQUE 가 코드보다 먼저 막는다 — applications(job_posting_id, email)(중복 지원) · applications(integration_client_id, external_id)(통합 멱등) · interviewer_assignments(application_id, interviewer_id). 자기소개 앵커는 Postgres 가 NULL 을 서로 다른 값으로 보기 때문에 UNIQUE 하나로 안 되어 부분 인덱스로 막았다.

해시로 저장하는 것과 평문으로 두는 것

일정·인적성·면접 조회 링크 토큰은 평문이다 — 새어 봐야 "내 지원 현황이 보인다"다. 반면 applicant_password_tokens.token_hash 와 integration_clients.api_key_hash 는 bcrypt 해시만 남긴다 — 이 링크가 새면 계정이 통째로 넘어간다.

행을 지우는 대신 표시를 남긴다

users 는 삭제가 없고 is_active 로 끈다 — users.id 가 created_by · evaluator_id · changed_by 로 도처에 박혀 물리 삭제가 이력을 부순다. 회수한 API key 도, 이미 쓴 비밀번호 링크도 행은 남긴다.

문서 · 코드 · 이행은 같은 커밋

스키마를 바꾸면 01-erd.md 갱신 + alembic 리비전이 한 묶음이다. 0023 번호가 두 번 쓰여 운영 DB 에 표가 안 생긴 사고 뒤, 번호 중복은 tests/test_alembic_revisions.py 가 CI 에서 막는다. 그 사고가 CI 초록인 채로 난 이유는 테스트가 create_all 로 표를 만들어 이행을 안 타기 때문이었다. 그래서 CI 에 alembic upgrade head 를 따로 넣어 이행 자체의 회귀를 잡는다. 다만 모델과 이행 결과를 대조하지는 않는다 — 모델에만 넣고 리비전을 빠뜨린 것은 지금도 사람이 막는다.

3. API 설계

접두사 /api/v1 · 인증 JWT Bearer. 요청·응답의 진실은 Swagger(/docs)이고 02-api.md 는 "무엇이 있는가"만 유지한다 — 같은 것을 두 곳에 적으면 반드시 갈린다.

메서드개수성격
POST51생성 + 동사형 행위(발송·확정·앵커·게시·재전사)
GET41조회. 만료 판정이 여기 얹힌다
PATCH6부분 수정(단계·공고·평가·메모·역할)
DELETE6세션·메모·배정 해제·템플릿 복귀 등 되돌릴 수 있는 것만
PUT3통째 교체(질문 목록·메일 템플릿·채점 설정)
WS1/ws/interview/{token}/rtc — 실시간 면접 시그널링

인증은 네 종류다

주체자격수명왜 따로 두나
담당자 JWT Bearer (sub=user_id) 12시간 비활성 계정은 로그인만이 아니라 이미 발급된 토큰도 401 이다 — 로그인만 막으면 만료까지 그대로 쓴다
지원자 JWT (typ 이 다름) 2시간 같은 비밀키로 서명하므로 서명 검증이 이걸 못 막는다 — 양쪽 의존성에서 종류를 확인해 서로의 경로를 못 타게 한다
비로그인 지원자 URL 안 일회성 토큰 링크마다 공고 공개 링크·일정 제안·인적성·AI 면접이 같은 패턴을 쓴다. 지원자에게 계정을 강요하지 않기 위해서다
우리 서비스끼리 X-Service-Token / 회사 API key — /internal/* 는 환경변수가 없으면 전부 401(기본 닫힘). 회사 통합은 bcrypt 대조 + 분당 상한

권한은 admin · member 2종이고 위계가 아니다(ADR-0017). 조회는 로그인만 하면 전부 열려 있고, admin 에게만 남은 것은 넷뿐이다 — 면접관 배정·해제 · 계정 생성 · 메일 템플릿 · 남의 가용 시간. member 의 유일한 제한은 평가 작성을 배정된 건에만 할 수 있다는 것이다. "면접관은 배정된 지원자만 조회"라는 옛 규칙(A3)은 폐지했다.

전역 규약

스케줄러를 두지 않는다 — 조회 시점 판정

마감 · 링크 만료 · 제안 만료를 읽는 순간에 판정한다. 공고를 조회할 때 deadline < 오늘 이고 open 이면 그 자리에서 closed 로 바꿔 저장한다. 크론 하나가 죽어 전 기능이 조용히 멈추는 상태를 만들지 않으려는 것이다. 대가도 적어 둔다 — 아무도 안 열면 기한이 지나도 상태가 안 바뀌어, 지원자 현황 목록은 상태가 아니라 expires_at 으로 거른다.

일괄 변경은 전부 성공하거나 전부 실패한다

POST /applications/bulk-stage 는 한 번에 200명까지고, 한 건이라도 전환 규칙에 걸리면 전체 롤백 + 409 에 실패 id 목록을 실어 준다. 30명만 바뀌고 끝나면 담당자가 무엇이 됐는지 알 수 없다. 이미 그 단계인 건은 실패가 아니라 skipped 로 센다.

없는 것도 있는 것과 똑같이 답한다

지원 이력이 없는 이메일로 비밀번호 링크를 요청해도 202 다. 만료된 링크와 이미 쓴 링크는 둘 다 410, 문구까지 같다. 다르게 답하면 그것만으로 "이 사람이 여기 지원했는가"를 확인하는 도구가 된다 — 이직 준비 중인 사람에게는 지원 사실 자체가 알려지면 안 되는 정보다.

되돌릴 수 없는 조작은 사람의 확인을 통과한다

에이전트의 쓰기 도구는 실행되지 않고 pending_action 으로 돌아온다. 사용자가 확인 카드를 승인해 POST /agent/confirm 을 부를 때 비로소 실행된다 — 메일 발송도 이 경로를 탄다.

무거운 바이트는 API 를 지나가지 않는다

이력서는 presigned URL 로 브라우저 → S3 직행이고 서버는 키 발급과 레코드만 만든다. 면접 답변 음성은 이력서 업로드 경로를 쓰지 않는다 — 그쪽은 토큰 없이 누구나 부를 수 있어서, 허용 목록에 .webm 을 넣으면 아무나 버킷에 미디어를 올리고 이력서 자리에 음성이 박힌다. 그래서 면접 토큰이 필요한 별도 경로를 뒀다 (음성 50MB / 이력서 10MB 로 상한도 따로).

큰 목록은 커서로 넘긴다

통합 검색은 limit(≤200) + cursor 이고, with_total=false 를 주면 total 이 null 이 되는 대신 검색이 크게 빨라진다 — 10만 건에서 전체 개수를 세는 것이 목록을 뽑는 것보다 비싸다.

CORS 는 2026. 09. 01. 에야 들어왔다. 그 전에는 vercel.json 의 rewrite 로 /api 를 우회시켰는데, 그 구조에서는 지원자 자소서를 포함한 모든 요청이 제3자 서버를 통과했다. 지금은 브라우저가 API 를 직접 부르고, 쿠키를 안 쓰므로 allow_credentials 는 꺼져 있으며 토큰은 Authorization 헤더로만 간다.

4. 화면 설계 (UI/UX)

기준 문서는 docs/00_overview/05-design.md다. 색·간격·폰트 크기·radius· 그림자·모션 시간·z-index를 전부 토큰(CSS 변수)으로 두고, 모든 색에 선택 이유를 붙인다 — 이유를 설명할 수 없는 색은 쓰지 않는다.

화면 지도 — 테이블이 메인, 칸반은 보조

1,000명 이상 규모를 전제로 테이블을 주 뷰로 놓았다. 근거는 경쟁 제품의 실제 구조다 — Greenhouse 는 목록 + 단계 요약, Airtable · Jira 는 같은 데이터의 뷰 전환이고, 칸반은 카드 수십 장까지만 유효하다. Lever 만 칸반 네이티브인데 그마저 대량 구간은 큐로 처리한다. 그래서 칸반은 토글로 여는 보조 뷰이고, 드래그는 공고의 지원자 화면에서만 살아 있다.

메뉴무대설계 결정
대시보드 진입점 숫자 카드 + 지원자 현황(리스트 ↔ 칸반 토글, 여기 칸반은 보기 전용) + 공고별 퍼널 레일 + 이번 주 면접 축소판. 통계 차트는 범위 밖
공고의 지원자 본 작업 화면 테이블 메인(검색·정렬·페이지네이션·체크박스 일괄 변경) + 칸반 토글. 퍼널 레일이 곧 단계 필터. 상세는 페이지 이동 없는 우측 패널과 "다음 지원자 →" 연속 심사
지원자 전 공고 통합 검색 10만 건 복합 필터 무대. 칸반 없음. 행을 누르면 같은 상세 패널이 옆에서 열린다 — 검색 맥락을 잃지 않게 페이지를 옮기지 않는다
캘린더 월 그리드 확정된 면접만. 셀은 3건에서 끊고 나머지는 +N건, 날짜를 누르면 우측 패널에 그날 목록. 등록·수정·삭제가 없는 조회 전용이다
종합 평가 확정 화면 공고별 지원자 수·평가 완료·평균 종합 점수(서류 + 면접 · 등급). 최종 합불을 사람이 확정하는 자리
설정 계정·운영 내 계정·면접 가능 시간은 전원, 사용자·권한·메일 템플릿은 admin. 프로필 사진은 없다 — users 에 사진 컬럼이 없어 이니셜 아바타로 대신한다

오른쪽 패널은 한 자리다

아르(에이전트) · 캘린더의 그날 일정 · 지원자 상세 · 평가 넷이 화면 오른쪽 끝의 같은 자리에 서고 한 번에 하나만 열린다(나중에 연 쪽이 이긴다). 폭은 --right-panel-w 360px 하나로 합쳤다 — 아르 360 / 상세·평가 420 으로 갈려 있으면 사이드바 216 + 420 = 636 이라 PC 최소 1280 에서 좌우 여백(48×2)을 빼고 548px 밖에 안 남아 메인인 테이블이 눌린다. 360 이면 608px 이 남는다. 여닫는 모션은 없다 — 폭은 레이아웃 값이라 애니메이션하면 매 프레임 본문이 다시 흐르고 테이블 컬럼까지 재배치된다. 내용 페이드만 남겼다.

토큰 — 87개, :root 한 블록

2026. 09. 04. 에 라이트 "새싹"에서 다크 "딥 네트워크"로 뒤집었다. 마크의 의미·색온도· 재질 셋이 화면의 나머지(데이터망·쿨 네온)와 어긋났기 때문이다. 전환은 tokens.css 의 :root 한 블록 교체로 끝났다 — 토큰이 시맨틱 네이밍이라 그 시점 26개 CSS 모듈 중 색을 직접 박아 둔 곳이 하나도 없었다. 바뀐 것은 색만이 아니라 재질이다: 면이 불투명한 흰 판에서 뒤가 비치는 유리가 됐다.

토큰값이유
--bg #070B14 바탕. 위에 오로라 4겹이 깔린다 — 빈 공간이 그냥 검으면 "우주"가 아니라 어두운 화면이다
--bg-elev rgba(13,19,34,.62) 카드·패널의 얼굴. 불투명하면 패널이 화면의 90%를 덮어 배경이 여백에만 토막으로 남는다
--accent #22D3EE 시안 — 면·채움·레일·선택. 배경 노드망의 팔레트를 그대로 UI 로 가져온다
--accent-text #7DD3FC 같은 시안이 글자로는 탁하다 — 글자·테두리는 한 톤 밝게
--accent-fill #FFFFFF 주 동작 버튼은 흰 판 + 어두운 글자. 네온이 이미 배경에 깔려 있어 버튼까지 빛나면 무엇이 동작인지 안 읽힌다
--neutral #7D8CA8 판단 전(접수·서류·면접) 공용 — "색 없음"이 곧 의미다
--ok / --danger #34D399 #F0A38F 합격에만 연두, 불합격에만 살구. 라이트의 벽돌색은 어두운 바탕에 먹혀 안 읽혀 살구로 올렸다
--ai #FCD34D 앰버 점선 = AI 제안 / 시안 실선 = 사람 확정. 앰버는 승인을 기다리는 것에만 쓴다 — 읽기만 하는 요약문에 쓰면 "뭘 눌러야 하나"로 읽힌다

퍼널 램프 --stage-1~--stage-4 는 2026. 09. 07. 에 밝기 간격을 벌렸다. 이전 값은 인접 색 사이 ΔE(OKLab×100)가 9.6 이라 정상 시야에서도 세 칸이 다 회색으로 보였다 — 구분 기준이 15 다. 지금은 15.9 다. 무채 규칙이 있어 손댈 수 있는 것은 밝기뿐이었다.

타이포 · 간격 · 반응형

스케일 7단 외 크기 금지

Material Design 3 타입 스케일 기준 — display 26 / h1 22 / h2 18 / body 16 / sm 14 / caption 12 / num 14px. 간격은 --sp-1~8 = 4·8·12·16·24·32·40·48px 4px 배수만. 통일감은 같은 토큰을 쓰는 것으로 달성한다.

숫자 폰트를 따로 들인 이유

Pretendard Variable(본문) + JetBrains Mono(숫자) 2종. 표의 숫자는 자리 폭이 고정돼야 값이 갱신될 때 흔들리지 않는데 tabular-nums 만으로는 열이 미세하게 어긋났다. 대신 한글이 섞인 자리엔 안 쓴다 — 공백·괄호·가운뎃점을 mono 가 그려 "09.04 (금) 14:00" 칩이 18px 부풀었고 카드가 26px 넘쳤다(실측).

브레이크포인트는 셋뿐

1100px 상세 패널이 나란히 → 덮는 오버레이 · 768px 여기부터 모바일 (사이드바 접기 · 칸반 대신 단계 탭 + 리스트 · 테이블은 카드형) · 1280px PC 최소 검증 기준선. 이 셋 외 임의 미디어 쿼리 금지. 모바일에서 칸반을 가로 스크롤로 밀어 넣지 않는다.

"완성"의 정의를 못 박았다

인터랙티브 컴포넌트는 default / hover / press / focus / disabled 전부, 데이터 화면은 loading(스켈레톤) / empty / error 전부 정의해야 완성이다. 드래그는 원본 자리 표시 + 고스트 + 드롭 영역까지, 실패 롤백은 토스트로 — 조용히 지나가면 안 된다. 터치 타깃 최소 44×44px(HIG), 대비 WCAG AA.

등장 애니메이션은 한 곳에만

--dur-fast 120 / --dur-base 200 / --dur-slow 320ms. 시그니처 모션은 퍼널 레일 하나뿐이고, 로그인 다이브만 예외 2종 (--dur-dive 1600ms · --dur-land 280ms)을 쓴다 — 로그인 응답을 기다리는 동안 60%까지 진행하다 응답이 오면 착지하는 구조라 연출이 로딩 표시를 대신한다. prefers-reduced-motion 대응 필수.

금지 목록을 문서에 박았다

보라→파랑 그라데이션 기본값 · 생성물 그대로 출고 · 전부 가운데 정렬 · 의미 없는 01/02/03 · 이모지 남발 · 주제 무관 패럴랙스 · 개발 중임을 드러내는 문구. 의미 없는 장식을 멋대로 붙이지 않는다 — 필요하다고 느끼면 먼저 제안한다.

구현 규모 — 프론트는 페이지 25개 · 공통 컴포넌트 18개 · CSS 모듈 38개이고 색·간격·모션 값은 전부 tokens.css 의 토큰 87개에서 나온다. 토큰에 없는 값은 쓰지 않고 토큰으로 추가한다는 규칙이 다크 전환을 한 블록으로 끝내 준 장치다.

← 목차로