Architecture
구인구직 시스템
보험설계사 구인구직 플랫폼입니다. 개발자 2명이 포탈과 어드민을 처음부터 설계했습니다. 바운디드 컨텍스트 15개를 헥사고날 레이어 모듈로 갈라 의존 방향을 Maven 이 컴파일 타임에 막게 만들었습니다. 프로세스는 일부러 나누지 않았습니다.
15
바운디드 컨텍스트
호스트 2개 위에 라이브러리로 올라감
2
배포 단위
portal-service, admin-service
12
기술적 의사결정
기각한 선택지까지 기록했음
Java 17(런타임 JRE 21) · Spring Boot + eGovFrame 부트스타터 · Maven 멀티모듈 · Spring Data JPA + Hibernate 6 · QueryDSL · MapStruct + Lombok · PostgreSQL 17 · Flyway · Spring Security + JWT · Jasypt · Caffeine · Local/FTP/NCP Object Storage · Apache PDFBox·POI · Jakarta Mail + Thymeleaf · Toss Payments · SpringDoc OpenAPI · Jenkins + Jib + Docker Swarm · JUnit5 + AssertJ + H2
이 플랫폼의 핵심 복잡성
이 플랫폼이 일반 구인, 구직 플랫폼과 다른 점은 두 가지입니다. 뒤의 모델링이 전부 여기서 나옵니다.
1. 한 회원이 구직자와 구인자를 겸합니다
구직자로서
talentResume · JobSeekerPosting(인재노출, OPEN/CLOSE) · JobApplication(공고에 지원)
구인자로서
recruitingAgency(대리점) · JobPosting(대리점이 소유)
보험업 특성상 구직자(설계사)가 경력을 쌓은 후 대리점을 개설해 구인자로 전환되는 경우가 빈번합니다. 동일한 회원이 이력서를 등록하면서 동시에 채용 공고를 올릴 수 있어야 합니다. 대리점을 등록하고 인증이 승인되면 그때 구인자 역할이 붙고, 공고의 소유자는 회원이 아니라 대리점입니다.
2. 지원 이후에도 서류 교환이 계속됩니다
일반 구인, 구직 플랫폼의 채용 흐름은 대체로 단방향입니다. 이 플랫폼은 시작점이 양쪽이고(구직자의 지원, 대리점의 면접 제안), 보험업 특성상 위촉 과정에서 요청과 제출과 보완이 여러 번 오갑니다.
일반 플랫폼
구직자 이력서 제출 → 구인자 검토 → 합격/불합격 (종료)이 플랫폼
구직자 이력서 제출
→ 구인자 검토 → 추가 서류 요청
→ 구직자 서류 제출
→ 구인자 검토 → 보완 요청
→ 구직자 보완 제출
→ 구인자 최종 승인 (종료)왕복하는 것이 핵심이라 요건 상태에 누구 차례인지를 같이 담았습니다. 제출과 보완은 후보자 차례, 심사와 승인과 반려는 대리점 차례입니다. 화면이 상태 이름을 훑어 분기하지 않고 이 값 하나만 보면 됩니다.
바운디드 컨텍스트 15개
경계를 가르는 기준은 하나가 아닙니다. 액터(누가 하는가), 생명주기(언제까지 사는가), 재사용 주체(누가 공유하는가) 세 축을 섞어 씁니다.
| 축 | 기준 | 적용 |
|---|---|---|
| 액터 | 누가 이 행위를 하는가 | 구직자의 행위는 talent, 구인자의 행위는 recruiting |
| 생명주기 | 언제 만들어져서 언제까지 사는가 | 커머스를 product / order / point / subscription 으로 |
| 재사용 주체 | 누가 이것을 공유하는가 | auth, file, code, notification 처럼 모든 BC 가 쓰는 것 |
액터 하나로 다 가르려고 하면 커머스가 안 갈립니다. 주문도 구독도 결국 회원이 산다라서 액터가 같습니다. 반대로 생명주기 하나로 가르면 이력서와 공고가 같은 칸에 들어갑니다. 둘 다 만들고 고치고 닫는 같은 주기입니다. 그래서 축을 섞어 쓰되, 어떤 축으로 갈랐는지를 BC 마다 기록해 뒀습니다.
액터 축은 사람이 아니라 역할을 가릅니다
- 회원 레코드는 하나입니다. 액터로 가른다는 것은 회원을 구직자용과 구인자용으로 나누는 것이 아니라 행위를 나누는 것입니다. 같은 사람이 이력서를 쓰면 talent 의 일이고, 대리점을 열어 공고를 올리면 recruiting 의 일입니다.
- 그래서 두 BC 는 서로의 데이터를 갖지 않고 회원 UUID 라는 같은 값을 각자 참조합니다. 한쪽이 상대의 역할을 확인해야 하는 일도 없습니다. 대리점이 있으면 구인자이고, 없으면 아닙니다.
- 이 축으로 갈린 것은 talent 와 recruiting 둘뿐입니다. 나머지 열셋은 생명주기나 재사용 주체로 갈렸습니다. 액터는 1차 기준이지 유일한 기준이 아닙니다.
- 행위자와 판정자가 어긋나는 자리에서는 판정자를 따릅니다. 위촉 요건을 수행하는 것은 후보자지만 승인과 반려는 대리점이 하고, 그래서 그 절차는 recruiting 이 소유합니다.
| BC | 서브도메인 | 소유 데이터 |
|---|---|---|
| 액터로 가른 것액터 | ||
구인핵심 recruiting | agency, jobposting, recruitment, appointment, interviewoffer, matching, banner, gacompany, attachment, support | 대리점, 공고, 채용 진행과 판정, 위촉 요건, 면접제안, 배너 |
인재핵심 talent | resume, jobseeker, jobapplication | 이력서 애그리거트, 인재노출, 지원(생명주기까지) |
| 신원재사용 주체 | ||
인증 auth | authorization, identity | Principal, PasswordAccount, EmailAccount, SocialAccount, Role, Permission, UrlAccess |
회원 customer | (단일) | 고객 프로필, PII, 동의, 활동로그 |
직원 employee | (단일) | 직원 |
| 생명주기로 가른 것생명주기 | ||
주문·결제 order | order, payment, refund | 주문, 결제, 환불, PG 연동 |
상품 product | (단일) | 상품 카탈로그, 환불정책 |
포인트 point | (단일) | 포인트 지갑, 거래내역 |
구독 subscription | subscriber, quota | 구독 계약, 사용량 쿼터 |
| 모든 BC 가 쓰는 것재사용 주체 | ||
커뮤니티 community | board, chat, serviceCenter | 게시판, 채팅, 문의 |
파일 file | (단일) | 파일 메타, 접근 인가, 다운로드 로그 |
알림 notification | (단일) | 알림, 발송 큐 |
메시지 message | (단일) | 메시지 발송 |
공통코드 code | (단일) | 공통코드, 분류코드, 화면코드 |
통계 statistics | customer, jobseeker, recruiter, commission, pageview, collector | 집계 결과 |
데이터베이스는 아직 하나지만 테이블의 주인은 나눠 뒀습니다.
도메인 간 관계
아래 세 절이 한 덩어리입니다. 구직자가 지원해서 시작하는 흐름, 그 역방향인 대리점 제안 흐름, 그리고 둘을 갈라 둔 근거입니다. 여기서 반복해서 쓴 판단은 셋입니다.
지원과 채용처리를 다른 BC 가 소유합니다
talent 의 JobApplication 과 recruiting 의 Recruitment 는 서로 참조하지 않고 자연 상관키(공고·회원·이력서 UUID)로만 대응합니다. 소유자가 둘이라 한 테이블에 담으면 지원자의 취소와 대리점의 판정을 독립적으로 표현할 수 없었습니다.
지원과 제안 두 경로가 Recruitment 로 수렴합니다
initiated_from 과 출처 컬럼(둘 중 하나만, DB CHECK 로 강제)으로 어디서 왔는지가 아니라 어느 것에서 왔는지까지 남깁니다.
BC 를 넘는 참조는 전부 하위가 UUID 값으로 보유합니다
JPA 연관관계 매핑을 쓰지 않습니다. 대신 조회를 두 번 하는 비용을 냅니다. 기각한 대안과 그 비용은 아키텍처 장의 단방향 값참조 절에 있습니다.
구직자 지원 흐름
회원에서 이력서, 지원으로 이어지고 대리점의 공고와 만나 채용 진행과 위촉 요건까지 가는 경로입니다. 실선은 참조를 보유한 쪽에서 참조 대상 방향이고, 점선은 참조가 없이 자연 상관키로만 대응하는 관계입니다.

대리점 제안 흐름
대리점이 공개된 인재를 보고 먼저 면접을 제안하는 흐름입니다. 지원 흐름의 역방향이고, 두 흐름이 같은 채용 진행으로 수렴합니다.

지원과 채용 진행
두 흐름이 만나는 자리가 채용 진행입니다. 그런데 지원과 채용 진행은 한 테이블이 아닙니다. 소유자가 둘이라 지원자가 정하는 값과 대리점이 정하는 값을 한 레코드에 얹을 수 없었습니다.
| 지원 (구직) | 채용 진행 (구인) | |
|---|---|---|
| 소유 | 구직자(회원 UUID) | 대리점(대리점 UUID) |
| 담는 것 | 지원했다는 사실, 그 시점 이력서 스냅샷 | 절차와 판정이 매달리는 그릇 |
| 상태 | 생명주기(유효 / 취소). 지원자가 정함 | 판정(판정전 / 위촉 / 탈락). 대리점이 정함 |
| 없어도 되나 | 면접제의로 시작하면 없음 | 아무도 처리 안 하면 없음 |
| 서빙 | 지원 목록. 공고별 지원자 목록에는 이 값을 실어 보냄 | 채용 진행 목록. 공고별 지원자 목록도 여기가 서빙 |
배포와 코드 구조
무엇이 배포되고, 그 안에서 코드가 어떤 축으로 나뉘는지입니다. 경계는 MSA 기준으로 긋되 배포는 모놀리식으로 뒀고, 그 축에 얹을 수 없는 관심사는 어댑터로 밀어냈습니다.
배포 단위
배포되는 것은 호스트 2개입니다. BC 는 독립 프로세스가 아니라 호스트가 의존하는 라이브러리로 올라갑니다. 경계는 MSA 기준으로 긋되 배포는 모놀리식으로 뒀습니다.
portal-service
일반 사용자 웹
화면(Thymeleaf)과 시큐리티 게이트웨이
BC 13개. employee 와 statistics 를 모른다
admin-service
운영자 백오피스
대부분 어드민 자체 도메인(메뉴, 접근로그)
BC 15개 전부. 어드민 자체 도메인은 여기에만 있다
BC 를 통째로 거는 일은 없습니다. BC 집계 pom 은 packaging=pom 이라 jar 자체가 없고, 호스트는 필요한 레이어 모듈만 골라 겁니다. 통째로 걸면 남의 PII 컨트롤러까지 classpath 로 딸려옵니다.
| 빠뜨린 것 | 언제 아나 | 무슨 일이 생기나 |
|---|---|---|
| -dataaccess · -integration | 기동 실패 | 서비스가 out 포트를 생성자로 받는데 구현 빈이 없습니다. 컨텍스트가 뜨지 않아 배포 단계에서 걸립니다 |
| -web | 그 URL 만 404 | 기동은 정상입니다. 컨트롤러가 없다는 것은 그 경로를 부를 때 압니다 |
| -messaging (리스너) | 아무 말 없음 | 발행 측은 성공합니다. 가입 리스너를 빠뜨리면 가입은 200 으로 끝나고 회원 프로필만 안 생깁니다 |
모듈 구조
모듈도 패키지도 축을 하나로 맞췄습니다. 레이어 퍼스트, 그다음이 서브도메인입니다. domain 모듈은 아무것도 의존하지 않아, JPA 를 import 하면 그 자리에서 빌드가 깨집니다.
레이어를 Maven 모듈로 가른다
BC 하나는 아래 모듈로 나뉩니다. 전부 두는 것이 아니라 필요한 것만 둡니다.
| 모듈 | 역할 | 쓰는 BC |
|---|---|---|
| -domain | 순수 도메인. 아무것도 의존하지 않는다 | 15 / 15 |
| -application | 유스케이스, in·out 포트, 서비스, DTO | 15 / 15 |
| -dataaccess | 영속 어댑터. DB 접근 전용 | 15 / 15 |
| -web | HTTP 어댑터. 컨트롤러와 채널별 DTO | 14 / 15 |
| -integration | 다른 BC 를 부르는 아웃바운드 어댑터 | 8 / 15 |
| -messaging | 이벤트 발행과 소비 어댑터 | 9 / 15 |
| -schedule | 시간 트리거 전용. 로직 없이 in 포트만 부른다 | 1 / 15 |
| -storage | 파일 저장소와 암복호화 어댑터 | 1 / 15 |
레이어 모듈은 필요할 때만 만듭니다. -schedule 이 따로 있는 이유는 트리거이기 때문입니다. @Scheduled 폴러를 -application 에 두면 스스로 in 포트를 부르는 어댑터가 도메인 로직과 같은 모듈에 앉습니다. 컨트롤러가 HTTP 로 트리거하는 것과 같은 자리라 모듈로 뗐습니다.
소스가 깨끗한 것과 규칙이 강제되는 것은 다릅니다. domain 에 JPA import 가 하나도 없어도 pom 에 의존이 남아 있으면, 다음 사람이 @Entity 를 붙였을 때 빌드가 통과합니다. 그래서 domain 의 pom 은 의존이 비어 있습니다.
패키지도 같은 축
com.{company}.system.{bc}.service.{layer}.{subdomain}.{role}
domain {sub}.{entity | valueobject | exception}
application {sub}.{service | port.in | port.out | dto.command | dto.query | dto.result | event | mapper}
dataaccess {sub}.{entity | repository | adapter | mapper}
web {sub}.{admin | client}
messaging {publisher | listener}.local.{sub}repo/ 모노레포. 루트 pom 이 모듈 22개를 등록한다├── portal-service/ 배포 단위. 사용자 화면과 시큐리티만├── admin-service/ 배포 단위. 백오피스와 어드민 도메인├── talent-service/ 핵심 BC. 프로세스가 아니라 라이브러리다│ ├── talent-domain/ 이 아래를 다음 트리에서 편다│ ├── talent-application/│ ├── talent-dataaccess/│ ├── talent-web/│ └── talent-messaging/├── recruiting-service/ 이하 같은 모양의 BC. 모두 15개├── …├── message-service/├── common/ 응답 봉투와 공통 예외. 도메인을 모른다└── infrastructure/ Flyway, 로컬 도커 구성, 크로스도메인 문서
talent-service/ packaging=pom 인 집계자. 빌드 순서만 잡고 jar 를 만들지 않는다├── talent-domain/ 아무것도 의존하지 않는다. 그래서 JPA 를 import 하면 컴파일이 깨진다│ ├── resume/entity/ 애그리거트 루트와 자식 7종│ ├── resume/valueobject/ 식별자도 값객체다. 섞어 넘기면 컴파일 에러│ └── resume/exception/ 비즈니스 규칙 위반을 표현한다. 여기도 아무것도 의존하지 않는다├── talent-application/ 유스케이스가 사는 곳. HTTP 도 JPA 도 모른다│ ├── {sub}/port/in/ 바깥이 이 BC 에 요청할 수 있는 일. 컨트롤러는 이것만 안다│ ├── {sub}/port/out/ 이 BC 가 바깥에 요구하는 것. 인터페이스를 안쪽이 소유한다│ ├── {sub}/service/ in 포트를 구현하고 out 포트로 요구한다│ ├── {sub}/dto/ command · query · result 로 갈린다│ └── {sub}/mapper/ 도메인 모델 ↔ dto 변환. MapStruct 가 구현체를 생성한다├── talent-dataaccess/ out 포트 구현. 어댑터는 매핑만 하고 쿼리를 짜지 않는다│ ├── {sub}/entity/ 영속 전용. 도메인 모델과 다른 클래스다│ ├── {sub}/repository/ 쓰기는 JPA, 조회는 QueryDSL│ ├── {sub}/adapter/ out 포트 구현체. mapper 로 바꾼 값을 repository 에 넘긴다│ └── {sub}/mapper/ entity ↔ 도메인 모델 변환├── talent-web/ HTTP 어댑터. 소비자별로 갈라 둔 자리│ ├── {sub}/client/ 대상이 세션 신원인 쪽│ └── {sub}/admin/ 대상을 param 으로 받는 쪽. DTO 를 공유하지 않는다└── talent-messaging/ 발행과 소비 어댑터. 여기만 브로커로 갈아끼우면 된다├── publisher/local/{sub}/ 도메인 이벤트 발행. local 은 지금 전송 방식(인프로세스)└── listener/local/{sub}/ 다른 BC 이벤트 구독. 서브도메인이 마지막이라 role 이 먼저 갈린다
단일 서브도메인 BC(customer, employee, point 등)는 서브도메인 세그먼트를 생략합니다. 축이 레이어라 막히는 것도 레이어뿐입니다. 같은 talent-application 안에서 jobseeker 가 resume 내부를 import 해도 컴파일은 통과합니다.
계층 간 흐름과 DTO 경계
레이어를 넘을 때마다 값의 이름이 바뀝니다. 컨트롤러가 받은 Request 는 안으로 들어가지 않고, 도메인 모델은 밖으로 나가지 않습니다. 경계마다 DTO 를 새로 만든 대가로 서비스가 HTTP 를 모릅니다.
[ 들어가는 길 ]
Request web/{admin|client}/request/ 채널별 입력 DTO
Controller Request 를 Command·Query 로 바꿔 UseCase 를 부른다
UseCase application/port/in/ 컨트롤러가 아는 것은 여기까지
Service application/service/ HTTP 도 JPA 도 모른다
도메인 모델 domain/entity/ 규칙이 사는 자리
Port application/port/out/ 저장, 조회, 발행의 추상
PersistenceAdapter dataaccess/adapter/ 매핑만 하고 쿼리를 짜지 않는다
JpaEntity dataaccess/entity/ 영속 전용
[ 나오는 길 ]
Result application/dto/result/ 유스케이스가 돌려주는 값
Response.from web/{admin|client}/response/ 화면에 맞는 필드 조합
CommonResponse common-application 공통 응답 봉투| 레이어 | DTO | 소유하는 것 |
|---|---|---|
| web | request · response | 채널별 입출력 형태. admin 과 client 가 서로 공유하지 않는다 |
| application | command · query · result | 쓰기 의도, 조회 조건, 유스케이스 결과 |
| domain | entity · valueobject | 도메인 모델. 영속과 무관하다 |
| dataaccess | entity | JPA 영속 엔티티. 도메인 모델과 1:1 이 아닐 수 있다 |
레이어를 넘을 때마다 DTO 를 새로 만드는 것은 비용입니다. 그 대신 서비스가 HTTP 를 모릅니다. 화면 요구가 바뀌면 web 모듈만 고치고, 같은 서비스를 스케줄러나 리스너에서 그대로 부릅니다. 도메인 모델과 영속 엔티티를 따로 둔 것도 같은 이유입니다.
경계를 잇는 법
위에서 가른 BC 를 무엇으로 잇는지입니다. 경계를 넘는 참조는 값 하나뿐이고, 남의 데이터가 필요하면 그 BC 의 in 포트로 물어봅니다. 동기로 부르든 이벤트로 흘리든 끝은 같은 문입니다.
BC 의존 그래프
BC 15개가 평평하게 놓여 있지 않습니다. 각 BC 의 -integration 모듈이 거는 상대를 모으면 아래 그래프가 나옵니다. 거는 상대가 없는 BC 는 표에 없습니다.
| BC | 거는 상대 | |
|---|---|---|
| recruiting | auth · customer · file · notification · talent | 가장 붐빈다. 구인 쪽이 거의 모두를 본다 |
| community | customer · recruiting · talent | |
| statistics | customer · recruiting · talent | |
| order | point · product · subscription | 커머스 넷이 한 덩어리로 묶인다 |
| notification | message | |
| message | file | |
| product | file |
BC 그래프에는 순환이 없습니다. 빌드가 아니라 소유 규칙으로 막습니다
레이어를 모듈로 가르면 한 BC 안의 순환은 빌드가 막습니다. BC 사이는 다릅니다. 상대를 부르는 어댑터가 -integration 에 있어, 두 BC 가 서로를 불러도 Maven 그래프에는 고리가 생기지 않습니다.
a-integration ──▶ a-application (자기 out 포트)
──▶ b-application (상대 in 포트)
b-integration ──▶ b-application
──▶ a-application
a-application ✕ b-application (서로 모른다 → Maven 통과)그래서 방향은 규칙으로 지킵니다. 한 사실의 소유자를 하나로 정하고, 필요한 쪽이 소유자를 부릅니다. 반대편이 값 하나 때문에 되부르면 두 BC 다 혼자 떼어낼 수 없습니다.
이 규칙이 가장 빡빡한 자리가 지원(talent)과 채용 진행(recruiting)입니다. 항목마다 소유를 정해 둔 결과, talent 이 import 하는 BC 는 code, customer, file 셋입니다. recruiting → talent 한 방향만 남습니다.
단방향 값참조
경계를 넘는 참조는 전부 값 하나입니다. BC 경계에서는 JPA 의 @ManyToOne, @OneToMany 연관관계 매핑을 쓰지 않습니다.
| 선택지 | 기각 이유 |
|---|---|
| BC 를 넘는 JPA 연관관계 매핑 | 기각. 엔티티 그래프가 BC 를 가로질러 이어집니다. resume.getCustomer().getName() 이 되는 순간 talent 가 customer 의 스키마에 묶이고, MSA 로 쪼갤 때 그 체인을 전부 끊어야 합니다 |
| DB FK 제약만 걸고 매핑은 안 함 | 기각. 코드 결합은 막지만 DB 를 쪼갤 때 제약이 깨집니다. 그래서 BC 를 넘는 자리에는 FK 를 걸지 않았습니다. recruitment.job_application_uuid 가 그 예로, talent 소유라 컬럼만 두고 제약은 없습니다 |
값참조는 대신 조회를 두 번 하게 만듭니다. 지원 목록에 후보자 이름을 붙이려면 회원 UUID 를 모아 CustomerLookupUseCase 를 한 번 더 불러야 합니다. 조인 한 번이면 될 것을 왕복 두 번으로 바꾸는 것이고, 그 비용을 내는 대신 경계를 얻었습니다.
값으로만 잇는다는 것은 남의 데이터가 필요할 때 물어볼 문을 따로 둔다는 뜻입니다. 그 문이 in 포트이고, 다음 절이 그것입니다.
동기 호출과 이벤트 발행
기본은 상대 BC 의 in 포트를 직접 부르는 것이고 이벤트는 예외입니다. 가르는 기준은 상대가 죽었을 때 내 요청도 같이 실패해야 하는가입니다. 두 경로 모두 전송을 application 밖으로 뺐습니다.
지금 바로 답이 필요할 때
integration다른 BC 의 데이터가 당장 필요하면 그 BC 의 in 포트를 부릅니다. 남의 엔티티나 리포지토리는 건드리지 않습니다.
- application부르는 BC
자기 out 포트를 정의한다
상대가 누구인지, 어떻게 부르는지는 모른다
- integrationHTTP 로 갈릴 자리
그 포트를 구현한 어댑터
대상 BC 의 in 포트(UseCase)를 호출한다
- application대상 BC
in 포트가 받아 처리한다
누가 불렀는지 모른 채 같은 시그니처로 답한다
나중에 알아도 될 때
messaging지금은 스프링 애플리케이션 이벤트로 돕니다. 브로커는 아직 없지만, 발행하는 서비스는 그게 인프로세스로 가는지 카프카로 가는지 모릅니다.
- application발행 BC
out 포트로만 발행한다
ApplicationEventPublisher 를 직접 부르지 않는다
- messagingpublisher · local카프카로 갈릴 자리
그 포트를 구현한 로컬 어댑터
여기서만 스프링 이벤트를 발행한다
- messaginglistener · local카프카로 갈릴 자리
AFTER_COMMIT 리스너가 받는다
REQUIRES_NEW 로 독립 트랜잭션을 연다
- application소비 BC
대상 BC 의 in 포트를 부른다
여기서 두 경로가 같은 문으로 만난다
order-application OrderService
↓ (out 포트)
ProductCatalogPort
△ implements
order-integration ProductCatalogAdapter
↓ 지금은 in-process, 나중엔 HTTP
product-application ProductLookupUseCase (in 포트)이벤트로 푸는 조건
아래 셋 중 둘 이상일 때만 이벤트로 풉니다.
- 1발행 측이 결과를 몰라도 되는가 (로그인은 최종접속 갱신이 실패해도 성공해야 합니다)
- 2소비자가 나중에 늘어날 수 있는가
- 3트랜잭션을 갈라야 하는가
다른 BC 의 데이터가 필요하면 그 BC 의 in 포트를 부릅니다. 엔티티, 리포지토리, 영속 모델에 직접 접근하지 않고 테이블을 조인하지도 않습니다. 조인 대신 uuid 목록을 모아 한 번에 조회합니다.
어떤 일이 일어나면 웬만하면 이벤트는 쓰지 않습니다. 그렇게 하면 무엇이 무엇을 부르는지 추적이 안 되고(스택트레이스가 발행 지점에서 끊깁니다), 이벤트로 연결했지만 실제로는 강결합인데 컴파일러가 잡아주지 않습니다.
동기로 왔든 이벤트로 왔든 끝은 대상 BC 의 in 포트입니다. 그래서 전송 방식을 바꿔도 도메인 코드는 그대로입니다.
대신 로컬 이벤트는 유실을 감안합니다. 리스너가 실패해도 재시도도 큐도 없습니다. 최종접속일시가 한 번 안 갱신되는 것은 그래도 되는 일이라 이 경로에 얹습니다.
감안할 수 없는 자리는 얹지 않습니다. 알림처럼 발송이 감사 근거가 되는 일은 원래 트랜잭션에 큐 테이블 행을 함께 넣고, 발송은 커밋 뒤 워커가 합니다.
채널과 소유 모듈
도메인 REST 는 호스트가 아니라 각 BC 의 -web 모듈이 소유합니다. 두 호스트는 이 컨트롤러들을 컴포넌트 스캔으로 올리고, 자기 시큐리티로 어느 경로를 열지 막을지만 정합니다.
URL 은 소비자를 /api 다음에 박습니다. 1차 주체가 API 이고 그다음이 소비자, 그다음이 도메인입니다. 1차 소비자는 사용자라 세그먼트를 생략합니다.
| 채널 | 경로 | 모듈 | 규칙 |
|---|---|---|---|
| 사용자 | /api/{도메인} | web/client | 대상은 세션 신원. customerUuid 를 파라미터로 받지 않음 |
| 어드민 | /api/admin/{도메인} | web/admin | param 으로 아무 고객이나 대상. 조회 위주 |
| 화면 전용 조립 | /api/admin/pages/... | 호스트 (BFF) | 여러 도메인을 모아 화면 모양으로 깎음. in 포트만 호출하고 규칙은 갖지 않음 |
| 화면 | /page/... · /admin/... | 호스트 | 도메인 REST 는 BC 의 -web 이 소유하고 호스트는 스캔만 함 |
컨트롤러 폴더만 나눈 것이 아니라 요청과 응답 DTO 도 공유하지 않습니다. 한 번 공유하면 어드민 전용 필드가 사용자 응답으로 새는 것은 시간 문제입니다. 나중에 한 채널을 걷어낼 때 다른 채널이 깨지지 않게 하려는 것이기도 합니다.
화면이 여러 도메인을 필요로 할 때, 그 조합의 소유자는 도메인이 아니라 화면입니다. 도메인 서비스는 남의 도메인을 모릅니다. 조각의 조회 API 가 그 채널에 이미 열려 있으면 브라우저에서 합치고(어드민 고객 상세, 마이페이지), 아니면 호스트가 in 포트를 모아 합칩니다(개제요청자 선택 팝업). 페이징이 있어도 마찬가지입니다. 검색 조건은 uuid 집합으로 해석해 도메인에 내리고, 이름 붙이기는 돌아온 한 페이지에만 겁니다. 남의 필드로 정렬해야 할 때만 예외인데, 그때도 도메인에 조립을 밀어 넣지 않고 스냅샷을 떠서 자기 필드로 만듭니다.
REST 를 어디까지 지켰나
아래는 /api 로 시작하는 엔드포인트 전체를 훑어 본 결과입니다. 지켜진 것, 근거를 갖고 어긴 것, 아직 미달인 것으로 갈랐습니다.
리처드슨 성숙도 모델로는 레벨 2 입니다. 자원을 URI 로 식별하고, HTTP 메서드로 행위를 구분하고, 상태코드를 의미대로 씁니다. 레벨 3(HATEOAS)은 의도적으로 하지 않았습니다.
메서드로 행위를 가릅니다
| 메서드 | 쓰임 |
|---|---|
| GET | 조회. 부수효과 없음 |
| POST | 생성, 그리고 자원으로 표현할 수 없는 상태 전이 |
| DELETE | 삭제 |
| PUT | 전체 교체 |
| PATCH | 부분 변경, 상태 전이 |
경로에 행위를 담지 않습니다. 예외는 둘입니다. 상태 전이를 하위 자원으로 표현한 자리와, 검색을 POST 로 받는 두 자리입니다.
PUT /api/resumes/{resumeUuid} 이력서 전체를 통째로 교체
PUT /api/job-postings/me/{jobPostingUuid} 공고 전체를 통째로 교체
PUT /api/applicant-processes/{uuid}/outcome 판정을 그 값으로 확정. 두 번 보내도 같다
PATCH /api/admin/products/{productId}/activate 판매중으로 전이
PATCH /api/admin/appointments/{uuid}/stages 요건 하나의 상태만 변경
PATCH /api/admin/subscribers/{uuid}/cancel 구독 해지로 전이이력서를 예로 들면 경력을 하나만 고쳐도 PUT 으로 전체를 받습니다. 애그리거트 루트가 자식 리스트를 통째로 교체하는 쓰기 모델과 PUT 의 의미가 맞아떨어지는 자리입니다. 자식만 고치는 PATCH 를 열면 루트를 우회하는 경로가 생깁니다.
멱등성이 메서드 의미와 맞습니다. PUT 을 두 번 보내면 같은 결과이고, PATCH 의 전이는 두 번째 호출이 도메인에서 막힙니다. 이미 답한 제안에는 다시 답할 수 없고, 이미 판정한 채용은 되돌리기 전에는 다시 판정할 수 없습니다. 두 번 눌러도 안전한가를 프론트의 버튼 비활성화가 아니라 도메인이 보장합니다.
상태코드를 봉투가 아니라 HTTP 로 내려보냅니다
| 상태 | 언제 |
|---|---|
| 400 | 잘못된 입력, 검증 실패, 도메인 규칙 위반 |
| 401 | 신원 없음 |
| 403 | 남의 자원 |
| 404 | 없거나 내 것이 아님 |
| 409 | 선행조건 미충족(이미 판정함, 이미 답함) |
상태코드를 봉투가 아니라 HTTP 로 내려보냅니다. 예외 번역 advice 가 전부 ResponseEntity.status(...) 로 상태를 실어 보냅니다. 봉투의 success 는 상태코드를 대신하는 것이 아니라 같은 사실을 프론트가 읽기 쉬운 자리에 한 번 더 적어 둔 것입니다.
403 과 404 를 가르는 기준이 하나입니다
| 사유 | 상태 | 예 |
|---|---|---|
| 그 행위 자체를 할 권한이 없음 | 403 | URL 인가 게이트웨이, 파일 카테고리 권한 |
| 자원이 있지만 내 것이 아님 | 404 | 남의 이력서, 남의 공고, 다른 고객의 메모 |
상태코드만 맞추면 안 됩니다. errorCode 와 메시지도 not-found 와 같게 맞춥니다. 404 를 주면서 errorCode 에 ACCESS_DENIED 를 실으면 감춘 것이 아닙니다. 대신 예외 타입은 서버 로그에 남겨, 응답이 같아도 운영자는 없어서인지 남의 것이라서인지 구분합니다.
권한 부족은 감출 것이 없습니다. 어느 URL 에 어떤 권한이 필요한지는 이미 인가 규칙으로 드러난 사실이고, 403 을 받아야 클라이언트가 재시도해도 소용없다는 것을 압니다. 코스 인가(URL)는 게이트웨이, 파인 인가(자원 소유권)는 도메인이라는 분담과 그대로 겹칩니다.
이 규칙에서 반복해서 새는 곳은 advice 누락입니다. 예외가 상태코드를 제대로 들고 있어도 그 패키지에 @RestControllerAdvice 가 없으면 호스트의 catch-all 이 전부 500 으로 뭉갭니다. 채용 진행에서 한 번(판정 대상이 아직 열리지 않았을 때), 인재노출에서 한 번(남의 이력서를 노출하려 할 때) 그렇게 됐습니다. advice 는 basePackages 로 한정돼 있어 새 컨트롤러 패키지를 만든다고 따라오지 않습니다.
URL 규약
컬렉션은 복수 명사
엔드포인트 대부분이 복수 컬렉션 아래에 있습니다.
소비자를 경로에 드러내되 자원 계층은 흐트러뜨리지 않음
같은 자원을 두 소비자가 보되 경로가 갈립니다. /me 는 내 것이라는 하위 컬렉션으로 씁니다. 세션 신원을 쿼리 파라미터로 받지 않는 것이 규칙입니다. 받는 순간 값을 바꿔 남의 것을 조회하는 경로가 열립니다.
중첩은 소유 관계까지만
경로 깊이는 대부분 세 단계에서 다섯 단계 사이입니다. 이 공고의 지원자처럼 종속이 있을 때만 중첩하고, 그렇지 않으면 최상위 컬렉션에 쿼리 파라미터로 답니다.
필터, 정렬, 페이징은 쿼리 파라미터
정렬 키는 latest, deadline 처럼 도메인 어휘로 받습니다. sort=createdAt,desc 로 받으면 그 순간 DB 컬럼명이 공개 계약이 됩니다.
의도적으로 어긴 것
원리주의로 보면 위반이지만 근거를 갖고 어긴 것들입니다.
- 01
공통 응답 봉투를 씌웁니다
순수 REST 라면 GET /api/resumes/{uuid} 는 이력서 표현 자체를 돌려줘야 합니다. 여기서는 success, message, data, errorCode 로 감쌉니다. 봉투는 REST 가 아니라 클라이언트 편의를 위한 선택입니다. 프론트가 서비스마다 다른 모양을 파싱하지 않도록 하나로 통일했고, 실패 사유를 사람이 읽을 문구(message)와 코드가 분기할 값(errorCode)으로 갈라 담았습니다. 봉투 타입은 common 에 하나만 두고 서비스마다 변형하지 않습니다. 도메인 타입은 서비스마다 복제하는 것이 원칙인데 이것만 공유가 정답인 케이스입니다. 이 선택이 성립하는 조건은 응답을 받는 쪽이 브라우저 하나라는 것입니다. 포털은 도메인 서비스를 HTTP 로 부르지 않고 각 BC 의 web 모듈을 같은 프로세스에 올리며, 화면이 /api 를 직접 부릅니다. 서비스 간 호출이 생기면 그 구간은 봉투 없이 내리고 봉투는 조합하는 호스트가 한 번만 씌우는 것이 맞습니다.
치른 대가: 표준 클라이언트와의 직결성을 잃습니다. 캐시 프록시나 범용 도구가 data 를 벗겨 낼 줄 모릅니다.
- 02
HATEOAS 를 하지 않았습니다
응답에 다음에 할 수 있는 행위의 링크를 싣지 않습니다. 이 API 의 클라이언트가 프론트엔드 하나뿐이고, 그 프론트는 어차피 화면 흐름을 코드로 알고 있습니다. 링크를 실으면 비용은 지금 들고 얻는 것은 클라이언트가 URL 을 몰라도 되는 유연성인데, 클라이언트가 하나면 그 유연성을 쓸 데가 없습니다.
치른 대가: 공개 API 로 열거나 클라이언트가 여럿이 되면 그때 붙일 자리입니다.
- 03
상태 전이를 하위 자원으로 표현합니다
POST /api/interview-offers/{uuid}/accept 는 경로에 동사가 있어 위반입니다. 정석은 PATCH /api/interview-offers/{uuid} 에 상태값을 보내는 것입니다. 그렇게 하지 않은 이유는 인가와 불변식이 전이마다 다르기 때문입니다. 수락은 인재만, 거절도 인재만, 취소는 대리점만 할 수 있고 각각 검사하는 것이 다릅니다. 상태 필드 하나로 받으면 컨트롤러가 값을 보고 분기해야 하고, 그 분기가 곧 인가 분기가 됩니다. 전이를 각자 엔드포인트로 두면 시큐리티가 경로로 가를 수 있습니다.
치른 대가: URL 인가 계층과 맞물리는 선택이고, 널리 쓰이는 절충이라 판단했습니다.
- 04
인증 엔드포인트는 자원이 아닙니다
POST /api/auth/login, /logout, /token/refresh 는 명백히 RPC 입니다. 로그인을 세션 자원의 생성으로 모델링하는 방법도 있지만 이 자리에서 얻는 것이 없어 관례를 따랐습니다.
기술적 의사결정 여섯 개
이 프로젝트에서 남길 가치가 있는 것은 결과 구조가 아니라 무엇을 놓고 고민했고 무엇을 버렸는가입니다. 코드는 최종 선택만 보여주고 기각된 선택지는 남기지 않습니다. 결정마다 상황 → 선택 → 이유 → 대가 순으로 적습니다.
- 01
도메인 소유를 호스트가 아니라 BC 에 둡니다
portal(사용자용)과 admin(백오피스) 두 앱이 같은 도메인을 씁니다. 이력서 로직을 누가 소유하고 다른 쪽은 그걸 어떻게 쓰는가.
대신 버렸다
호스트가 도메인 소유 · admin 을 없애고 한 앱으로 · 지금 바로 MSA
골랐다
도메인 소유를 호스트에서 BC 로
MSA 로 가야 할 이유(경계, 독립 진화)와 지금 가면 안 되는 이유(운영 비용)를 동시에 만족하는 지점입니다. 경계는 MSA 기준으로 긋되 배포는 모놀리식으로 둡니다. 나중에 MSA 로 갈 때 바뀌는 것이 integration 모듈의 어댑터 구현뿐이 되도록 형태를 잡았습니다. 호스트가 여러 도메인의 조회를 모아 화면 모양으로 깎는 일은 오히려 호스트에 둡니다. 그 조합은 도메인이 아니라 화면의 관심사이기 때문입니다.
치른 대가: admin 과 portal 이 같은 JVM 에서 같은 코드를 공유하므로 장애 격리가 없습니다. 공유 BC 의 컨트롤러가 양쪽 컨텍스트에 뜨는 문제도 생겼습니다(경로 분리와 default deny 로 대응). 격리가 필요해지는 시점이 서비스 분리의 트리거입니다.
- 02
헥사고날 의존 방향을 Maven 모듈로 강제하고, 레이어 퍼스트로 자릅니다
의존이 바깥에서 안으로만 흐르게 하려면 두 가지를 정해야 합니다. 무엇을 경계로 자를 것인가, 그 경계를 무엇이 막을 것인가.
대신 버렸다
도메인(서브도메인) 퍼스트 · 패키지 + ArchUnit · Java Module System(JPMS)
골랐다
레이어 퍼스트 · Maven 멀티모듈
규칙을 열거할 필요가 없기 때문입니다. 의존 그래프가 문서가 아니라 pom 에 있습니다. 대가로 모듈 수가 BC 수의 몇 배로 불어나고 IDE 로딩과 풀빌드가 느려지지만, 그 마찰 자체가 '경계를 넘고 있다'는 신호라서 없애지 않았습니다.
치른 대가: 레이어를 축으로 골랐으므로 막히는 것도 레이어뿐입니다. 같은 talent-application 안에서 jobseeker 가 resume 내부를 import 해도 컴파일은 통과합니다. 승급 기준은 '떼어내는 단위가 서브도메인이 될 때'로 잡아 뒀고 아직 아닙니다. 그때까지 이 축은 ArchUnit 이 후보입니다.
- 03
애그리거트 루트 단일 쓰기, 읽기는 포트를 쪼갭니다
이력서는 자식이 7종입니다. 자식마다 리포지토리를 둘 것인가, 루트로만 쓸 것인가. 자식 리포지토리를 두면 편하지만, 그 순간 루트를 우회하는 쓰기 경로가 생깁니다. '메인 이력서는 하나뿐' 같은 불변식은 루트가 자식 전체를 봐야 검사할 수 있습니다.
대신 버렸다
자식 리포지토리 유지 + 규칙으로 금지 · 자식 리포지토리를 package-private 으로
골랐다
자식 리포지토리 삭제, 루트로만 쓰기
쓰기는 ResumeRepositoryPort 하나로 모으고, 읽기는 화면 단위로 쪼갰습니다(ResumeChildReadPort, ResumeSectionReadPort, ResumeDetailReadPort, ResumeStatisticsReadPort). 이 프로젝트에서 CQRS 가 사는 곳이 여기입니다. 서비스 클래스를 둘로 나누는 것이 아니라 out 포트 층에서 갈립니다. 읽기 포트를 하나로 두면 잡화점이 됩니다(공고 조회 오퍼레이션이 13개까지 늘어난 적이 있습니다). 그래서 화면 하나에 조회 리포지토리 하나로 나눕니다. 쓰기의 경계가 '함께 바뀌는 것'이라면 읽기의 경계는 '함께 보이는 것'입니다.
치른 대가: 부분 수정(경력 한 건만 바꾸기)도 루트 전체를 다시 저장합니다. 이력서 크기에서는 문제가 아니지만 자식이 수백 건인 애그리거트라면 이 방식이 맞지 않습니다. ResumeDetailReadPort 는 JPA 엔티티가 없는 레거시 테이블을 raw SQL 로 조합해 순수하지 않지만, 읽기 전용이고 쓰기는 여전히 루트로만 가므로 불변식은 지켜집니다.
- 04
크로스도메인 표시 조합은 화면이 소유하고, 도메인은 남을 모릅니다
한 화면이 여러 BC 의 데이터를 필요로 하는데 BC 는 남의 테이블을 조인하지 않기로 했습니다. 이 조합을 누가, 어디서 하는가. 소유자는 항상 화면입니다. 진짜 질문은 '어디서 실행하나'이고, 그 답을 가르는 것은 남의 필드를 표시하는지, 검색하는지, 정렬하는지입니다.
대신 버렸다
DB VIEW + @Immutable 엔티티 · QueryDSL 로 BC 를 넘는 JOIN · 1차 목록을 가진 BC 가 out 포트로 조립
골랐다
호스트가 in 포트를 모아 조립 (BFF) · 브라우저가 도메인별 호출을 합침 · 스냅샷 (쓰기 시점에 값을 박아 둠)
표시와 검색은 늘 밖으로 뺄 수 있습니다. 검색 조건을 uuid 집합으로 해석해 1차 쿼리에 IN 으로 내리고, 표시는 돌아온 한 페이지에만 이름을 붙입니다. 정렬만 못 뺍니다. 전체 순서를 알아야 하므로 스냅샷을 떠서 자기 필드로 만듭니다. 호스트가 하는 것은 읽기 전용 이어붙이기와 같은 프로세스의 in 포트 호출이라, 쓰기 소유권을 BC 에 둔다는 원칙과 부딪히지 않습니다.
치른 대가: BFF 는 방치하면 계층으로 자랍니다. 가드레일이 컴파일러가 아니라 코드 주석이라 리뷰에서 봐야 합니다. 왕복이 늘고(검색 한 번에 도메인 호출 두세 번), 브라우저 조립은 부분 실패와 인가를 화면마다 따로 처리해야 합니다. 스냅샷은 소급되지 않습니다. 그래도 경계가 성능보다 되돌리기 어렵다고 봤습니다. subscription 은 어떤 도메인도 모르는 채로 이 3단계가 실제로 통한다는 증거입니다.
- 05
커머스를 액터가 아니라 생명주기로 갈랐습니다
주문, 결제, 상품, 포인트, 구독을 몇 개의 BC 로 가를 것인가. 액터로 가르면 다 '회원이 산다'라서 갈리지 않습니다. 실제로 다른 것은 변경 주기였습니다. 카탈로그는 운영자가 매주 바꾸고, 주문은 한번 만들어지면 절대 바뀌면 안 됩니다.
대신 버렸다
커머스 단일 BC · payment 를 독립 BC 로
골랐다
생명주기로 4분할, payment 는 order 서브도메인으로
떼면 트랜잭션을 갈라야 하는가가 기준이었습니다. 갈라도 되는 것(상품 지급)은 뗐고, 갈라선 안 되는 것(결제 승인과 주문 확정)은 붙여 뒀습니다. 결제가 확정되면 같은 트랜잭션에서 OrderPaymentPort.markPaid 로 주문이 PAID 가 됩니다. 반대로 구독 발급과 포인트 지급은 뗐습니다. 실패해도 결제는 유효하고 재시도로 복구되는 성질이라 OrderPaidEvent 로 끊어 반응합니다. 주문 생성 시 상품 단가, 환불정책을 그 시점 값으로 스냅샷 복사합니다. 환불정책이 나중에 바뀌었다고 지난 주문에 새 정책을 적용하면 분쟁이 됩니다.
치른 대가: 결제 승인과 주문 확정을 한 트랜잭션에 묶은 만큼 결제 상태와 기록이 order 안에 남습니다. payment 를 따로 떼야 할 때가 오면 그때 갈라야 합니다. 스냅샷은 같은 값을 두 곳에 두는 선택이라, 주문이 상품 단가와 유형, 환불정책, 프로비저닝 스펙을 복사해 안고 갑니다. 구독 발급과 포인트 지급은 라인마다 독립 트랜잭션이라 하나가 실패해도 나머지는 진행되지만, 실패한 라인은 미지급으로 남고 그것을 다시 집는 배치가 없어 재처리가 아직 사람 손입니다. 부분 취소와 대사 배치도 없어 전체 취소만 됩니다.
- 06
파일 접근 인가를 게이트웨이가 아니라 file BC 가 소유합니다
이력서, 증빙, 위촉서류처럼 민감한 파일을 다룹니다. 접근 허용은 operation 권한과 그 파일 카테고리의 도메인 권한, 두 축의 곱입니다. 첫 축은 URL 로 표현돼 게이트웨이가 볼 수 있지만, 둘째 축은 파일 메타를 읽어야 압니다. URL 만 보고는 그 UUID 가 이력서인지 공지 첨부인지 모릅니다.
대신 버렸다
호스트 시큐리티에서 URL 인가로만 · 파일을 소유한 각 도메인이 판단
골랐다
file BC 가 판단, 호스트는 신원만 브리지
판단을 file BC(FileAccessDecisionUseCase, FileAccessGuard)로 옮기고 호스트는 SecurityContext 의 신원을 인자로 넘기는 역할만 합니다. 미리보기와 다운로드도 다른 권한(FILE_PREVIEW/FILE_DOWNLOAD)으로 갈랐습니다. 화면에서 훑는 것과 파일이 조직 밖으로 나가는 것은 신뢰 수준이 다릅니다. 카테고리별 권한 매핑을 코드가 아니라 file_category_permission 테이블에 둬서, 운영 중 정책 변경마다 배포하지 않아도 되게 했습니다.
치른 대가: 판정 엔진이 매칭되는 모든 규칙을 OR 로 평가하고 우선순위가 없습니다. 넓은 패턴이 남아 있으면 세분 규칙을 아무리 더해도 넓은 규칙이 계속 열어버립니다. 세분화하려면 넓은 규칙을 반드시 지워야 합니다.
- 되돌리기 비용이 큰 쪽을 먼저 지킵니다. 성능은 나중에 고칠 수 있지만 경계는 한번 새면 되돌리는 데 재작성이 듭니다.
- 강제할 수 없는 규칙은 규칙이 아닙니다. 문서로 지키자고 한 것은 지켜지지 않았고, 컴파일러가 막은 것만 지켜졌습니다.
- 떼어내는 단위로 생각합니다. '이걸 단독 프로세스로 떼면 무엇이 어디에 있어야 하나'를 물으면 대부분의 배치 문제가 풀렸습니다.