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 하나뿐인데 폴더를 미리 파면 빈 껍데기가 규약처럼 남는다.
점수 계산은 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. 에 고쳤다.
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 는 "무엇이 있는가"만 유지한다 — 같은 것을 두 곳에 적으면 반드시 갈린다.
| 메서드 | 개수 | 성격 |
|---|---|---|
POST | 51 | 생성 + 동사형 행위(발송·확정·앵커·게시·재전사) |
GET | 41 | 조회. 만료 판정이 여기 얹힌다 |
PATCH | 6 | 부분 수정(단계·공고·평가·메모·역할) |
DELETE | 6 | 세션·메모·배정 해제·템플릿 복귀 등 되돌릴 수 있는 것만 |
PUT | 3 | 통째 교체(질문 목록·메일 템플릿·채점 설정) |
WS | 1 | /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개에서 나온다.
토큰에 없는 값은 쓰지 않고 토큰으로 추가한다는 규칙이 다크 전환을 한 블록으로 끝내 준 장치다.