init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
+140
View File
@@ -0,0 +1,140 @@
# common module 허용 기준
## 목적
`common` 모듈은 공유 편의 때문에 만드는 기본 모듈이 아니다.
기본값은 **만들지 않음** 이다.
이 문서의 목적은:
- “일단 common으로 보내자”를 막고
- 모듈 경계를 흐리는 공유를 줄이고
- 정말 필요한 공용 코드만 예외적으로 허용하는 것이다
## 공식/원전 기준 요약
- 모듈은 외부에 노출하는 API와 내부 구현을 구분해야 한다.
- 다른 모듈은 공개된 API에만 의존해야 하며, 내부 구현 의존은 막아야 한다.
- 헥사고날 아키텍처의 핵심도 경계와 역할 분리다.
- 겉보기 중복만 보고 조기 추상화를 만들면 이후 변경 비용이 커질 수 있다.
## 기본 규칙
### 1. `common`은 기본 금지
새 코드를 추가할 때 기본 선택지는 아래 순서다.
1. owning layer/module 내부에 둔다
2. 경계가 필요하면 해당 module의 API로 노출한다
3. 그래도 어느 곳에도 자연스럽게 속하지 않을 때만 `common`을 검토한다
즉 “어디 둘지 애매하면 common”을 금지한다.
### 2. `common`은 아래 조건을 모두 만족할 때만 허용
다음이 모두 참일 때만 `common` 도입을 검토한다.
- 3개 이상 모듈에서 실제로 재사용된다
- 변화 이유가 같다
- 특정 모듈이 소유한다고 보기 어렵다
- `common`으로 옮겨도 의존 방향이 더 나빠지지 않는다
- 모듈 API를 좁게 노출하는 방식으로 해결하기 어렵다
하나라도 애매하면 기존 owning module에 둔다.
### 3. “중복 존재”만으로는 common 근거가 아니다
코드가 비슷해 보여도 다음이면 `common`으로 보내지 않는다.
- 서로 다른 비즈니스 문맥에서 독립적으로 변할 가능성이 큼
- 지금은 비슷하지만 미래 요구가 갈라질 가능성이 큼
- common으로 모으면 이름과 책임이 모호해짐
즉 진짜 공통 원인(common cause)일 때만 허용한다.
### 4. 레이어 경계를 깨는 공유 금지
`common`이 아래를 섞는 것을 금지한다.
- presentation + domain
- domain + infrastructure
- application + web transport
- persistence 모델 + API 모델
- 외부 API payload + 내부 domain meaning
공유보다 경계 보존이 우선이다.
### 5. `common`에는 business policy를 두지 않는다
다음은 `common`에 두지 않는다.
- 도메인 규칙
- 상태 전이 규칙
- 권한 판정
- 에러 코드 정책
- 외부 연동별 특화 규칙
이런 것은 반드시 owning module 또는 boundary contract가 소유한다.
### 6. `common`에 둘 수 있는 것
예외적으로 허용 가능한 후보:
- 경량 value type
- 순수하고 작은 utility
- 여러 모듈이 같은 이유로 쓰는 매우 안정적인 helper
- 모듈 경계를 깨지 않는 공통 annotation / marker / tiny abstraction
- 명확한 소유자가 없는 순수한 language-level helper
단, 이것도 실제 재사용과 변화 이유가 검증되어야 한다.
### 7. `common`의 utility는 특히 좁게 제한
utility는 아래 조건을 만족할 때만 허용한다.
- side effect 없음
- framework/business/persistence 의존 없음
- 이름만 보고 역할이 분명함
- 단순 문자열/시간/컬렉션 helper라도 owning type 안에 둘 수 없는 이유가 있음
`StringUtils`, `DateUtils`, `CommonUtils`, `AppUtils` 같은 잡동사니 묶음은 금지한다.
### 8. 모듈 API 노출이 common보다 우선
Spring Modulith의 방향처럼, 공용화가 필요해 보일 때 먼저 검토할 것은:
- 해당 모듈의 공개 API로 노출할 수 있는가
- named interface처럼 노출 범위를 좁게 지정할 수 있는가
- explicit dependency로 필요한 부분만 허용할 수 있는가
즉 “common으로 이동”보다 “모듈 API 설계 개선”을 먼저 본다.
### 9. 공용 타입은 더 보수적으로 관리
`common`으로 이동한 타입은 사실상 여러 모듈이 기대는 기반이 된다.
기본:
- 변경에 더 보수적이어야 한다
- naming을 더 명확히 해야 한다
- Javadoc/문서가 더 중요하다
- examples/tests가 함께 있어야 한다
### 10. `common`은 dump zone이 아니다
다음 징후가 보이면 잘못된 `common`이다.
- 이름이 `Common*`, `Util*`, `Base*`, `Helper*` 위주
- business/domain/web/persistence 코드가 섞여 있음
- 모듈 간 순환 의존을 가리기 위해 common을 사용
- “일단 여기 두자”가 반복됨
이 경우 common을 늘리는 대신 다시 소유 모듈로 분해한다.
### 11. common 도입은 문서화한다
새로운 `common` 타입/패키지를 추가할 때는 최소한 아래를 설명한다.
- 왜 owning module에 둘 수 없는가
- 어떤 모듈들이 실제로 재사용하는가
- 같은 이유로 어떻게 함께 바뀌는가
- 어떤 경계를 깨지 않는가
설명할 수 없으면 common으로 보내지 않는다.
## 프로젝트 기준 요약
- `common` 기본 금지
- 재사용만으로는 부족하고 “같은 이유로 함께 변함”이 필요
- 모듈 API 설계 개선이 common보다 우선
- 경계/레이어를 섞는 공용화 금지
- business policy는 common 금지
- utility/common dump zone 금지
- 예외적 허용 시에도 좁고 순수하고 안정적인 타입만
@@ -0,0 +1,173 @@
# DTO / Domain / Entity separation 기준
## 목적
DTO, Domain, Entity는 이름만 다른 비슷한 객체가 아니라 **서로 다른 경계와 책임을 가진 모델**이다.
형태가 비슷하더라도 역할이 다르면 분리한다.
## 공식 의미
- DTO는 프로세스/경계 사이에서 데이터를 운반하는 객체다.
- Entity는 persistence provider가 관리하는 영속 모델이다.
- Spring request binding은 강력하지만, 외부 입력을 객체 그래프에 직접 바인딩하는 것은 보안상 주의가 필요하다.
- 따라서 web 입력 모델, 비즈니스 모델, persistence 모델을 하나로 섞지 않는 것이 안전하다.
## 기본 규칙
### 1. DTO는 경계 데이터 모델이다
DTO는 다음 경계에서만 사용한다.
- HTTP request / response
- external API request / response
- message payload
- batch/file I/O payload
DTO의 책임:
- 데이터 운반
- 직렬화/역직렬화 경계 표현
- validation annotation 수용 가능
- API 계약 표현
DTO의 책임이 아닌 것:
- 비즈니스 규칙
- 도메인 불변식 유지
- 영속성 상태 관리
- repository/service 호출
### 2. Domain은 비즈니스 의미 모델이다
Domain은 다음을 표현한다.
- entity
- value object
- domain service
- domain policy
- 불변식
- 상태 전이 의미
Domain의 책임:
- 비즈니스 의미
- 유효한 상태 보장
- 행위와 규칙 표현
Domain의 책임이 아닌 것:
- JSON 구조
- HTTP 요청/응답 형식
- JPA annotation/persistence lifecycle
- 외부 API payload 구조
### 3. Entity는 persistence 모델이다
Entity는 DB와 persistence provider가 다루는 모델이다.
Entity의 책임:
- 테이블/컬럼/관계 매핑
- persistence lifecycle 대응
- 저장 가능한 상태 표현
Entity의 책임이 아닌 것:
- API 응답 계약
- 비즈니스 정책 결정
- 외부 연동 payload 표현
### 4. 하나의 타입으로 세 역할을 겸하지 않는다
다음은 기본 금지한다.
- request DTO를 그대로 domain으로 사용
- JPA entity를 그대로 response DTO로 반환
- domain object에 JPA/JSON/validation annotation을 한꺼번에 섞기
형태가 비슷해도 책임이 다르면 타입을 분리한다.
### 5. 경계마다 변환을 명시한다
기본 흐름:
- request DTO -> command / domain input
- entity -> domain
- domain -> response DTO
- external payload -> internal model
변환은 mapper/assembler/factory 같은 명시적 경계에서 수행한다.
### 6. DTO에는 validation을 둘 수 있지만, 최종 의미 검증은 domain이 맡는다
DTO에 둘 수 있는 것:
- `@NotBlank`
- `@Size`
- 형식 검증
- 웹 입력 범위 검증
하지만 다음은 domain 책임이다.
- 불변식
- 상태 전이 타당성
- 정책 기반 허용 여부
- aggregate 일관성
즉 DTO validation이 domain validation을 대체하지 않는다.
### 7. Entity의 nullable/관계/지연로딩 특성을 domain에 그대로 전파하지 않는다
DB 제약 때문에 entity가 nullable/관계 중심일 수는 있다.
하지만 domain은 비즈니스 invariant 기준으로 더 엄격할 수 있다.
기본:
- entity -> domain 변환 시 검증/정규화
- domain은 persistence 편의보다 비즈니스 의미 우선
### 8. Response DTO는 domain을 그대로 노출하지 않는다
response는 외부 계약이다.
기본적으로 domain object를 그대로 JSON으로 노출하지 않는다.
이유:
- 내부 구조 변경이 외부 계약에 새어 나간다
- 민감정보/불필요 필드 노출 위험
- 직렬화 shape가 domain 설계를 오염시킨다
### 9. Request DTO를 entity에 직접 바인딩하지 않는다
Spring DataBinder/WebDataBinder는 강력하지만 보안상 주의가 필요하다.
따라서 외부 입력을 entity나 깊은 도메인 객체에 직접 바인딩하지 않는다.
기본:
- request DTO에만 바인딩
- 이후 명시적 변환을 거쳐 command/domain으로 이동
### 10. Entity를 domain과 1:1로 맞추려 하지 않는다
entity와 domain은 비슷할 수 있지만 항상 같아야 할 필요는 없다.
예:
- entity는 FK/nullable/지연 로딩 중심
- domain은 value object/invariant/행위 중심
“필드가 같아 보이니 하나로 합친다”를 금지한다.
### 11. DTO는 record/단순 데이터 구조를 우선 검토
DTO는 경계 데이터 운반이 목적이므로, 불변/단순 구조를 우선 검토한다.
단, framework binding/serialization 요구사항이 있으면 그 제약을 따른다.
### 12. Domain은 DTO naming을 따라가지 않는다
도메인 타입 이름은 API 필드명/JSON 필드명보다 비즈니스 의미를 우선한다.
금지 예:
- `UserResponseName`
- `ProviderRequestCode`
도메인은 business language를 사용한다.
### 13. Entity는 persistence 편의 메서드를 가질 수 있지만 도메인 규칙 중심 타입이 되지 않게 한다
entity에 persistence 편의 메서드가 있을 수는 있다.
하지만 핵심 비즈니스 규칙을 entity/JPA lifecycle에 과도하게 묶지 않는다.
### 14. Mapping 비용보다 경계 명확성이 더 중요하다
DTO/Domain/Entity 분리는 변환 코드가 늘 수 있다.
그러나 그 비용보다:
- 경계 명확성
- 보안성
- 변경 범위 제한
- 직렬화/영속성 오염 방지
의 이익이 더 크다.
## 프로젝트 기준 요약
- DTO = 경계 데이터
- Domain = 비즈니스 의미
- Entity = persistence 모델
- 하나의 타입으로 세 역할 겸용 금지
- request는 DTO에만 바인딩
- DTO validation과 domain invariant를 분리
- entity nullable/관계를 domain에 그대로 전파하지 않음
- response DTO로 외부 계약을 명시
+163
View File
@@ -0,0 +1,163 @@
# interface 생성 기준
## 목적
인터페이스는 “있으면 좋아 보이는 추상화”가 아니라, **경계와 계약을 안정적으로 표현해야 할 때만** 만든다.
기본값은 “인터페이스를 무조건 만들지 않는다”이다.
## 공식 의미
- 인터페이스는 구현과 분리된 계약(contract)을 표현하는 타입이다.
- 구현체는 인터페이스가 정한 메서드 계약을 따른다.
- 인터페이스는 추상 메서드, default method, static method, 상수를 가질 수 있다.
- Spring DI는 의존 객체를 생성자/팩토리 메서드/세터를 통해 주입하며, 인터페이스나 추상 베이스 타입에 의존할 때 결합도가 낮아지고 테스트 대역 사용이 쉬워진다.
- Spring AOP는 대상이 인터페이스를 구현하면 JDK dynamic proxy를 기본으로 사용한다.
## 기본 규칙
### 1. 기본값은 “필요할 때만 만든다”
다음 중 하나가 아니라면 인터페이스를 만들지 않는다.
- 모듈/레이어 경계를 표현해야 한다
- 교체 가능한 구현이 실제로 존재하거나 가까운 미래에 예상된다
- 테스트에서 대역(stub/mock/fake)으로 치환하는 가치가 크다
- 프레임워크/프록시/AOP/플러그인 구조상 계약 타입이 분명히 필요하다
- 라이브러리/외부 모듈에 공개할 안정된 API 계약이 필요하다
### 2. “구현체 1개” 자체는 금지 근거가 아니지만, “이유 없는 인터페이스”는 금지
구현체가 1개여도 아래 중 하나면 인터페이스를 둘 수 있다.
- application port
- 외부 연동 client contract
- repository-like boundary
- 인증/토큰/암호화 같은 교체 가능한 정책
반대로 구현체가 1개이고 아래도 아니면 인터페이스를 만들지 않는다.
- 내부 helper/service
- 단순 orchestration class
- 프레임워크가 요구하지 않는 내부 컴포넌트
### 3. 레이어 경계는 인터페이스를 우선 검토
특히 다음 경계는 인터페이스를 우선 검토한다.
- application `port/in`
- application `port/out`
- infrastructure adapter가 구현하는 계약
- 외부 시스템 client contract
- 교체 가능한 정책 객체
즉 “안쪽이 바깥 구현을 모르면 좋은 곳”은 인터페이스 후보가 된다.
### 4. 내부 구현 디테일에는 기본적으로 인터페이스를 만들지 않는다
같은 모듈 내부에서만 쓰이고, 교체 가능성도 낮고, 테스트 seam 가치도 낮은 클래스는 concrete class 그대로 둔다.
금지 예:
- `UserService` + `UserServiceImpl`
- `EmailNormalizer` + `EmailNormalizerImpl`
- `AuthFacade` + `AuthFacadeImpl`
단, 정말 계약 타입이 먼저이고 구현이 뒤따르는 구조면 예외다.
### 5. 인터페이스는 “역할”을 표현해야 한다
인터페이스 이름은 구현 방식이 아니라 역할/능력을 드러내야 한다.
좋은 방향:
- `UserReader`
- `PasswordHasher`
- `JwtSigner`
- `TokenIssuer`
- `UserRepository`
- `OAuthClient`
지양:
- `DefaultUserService`
- `CommonManager`
- `BaseHandler`
### 6. 인터페이스는 작고 응집도 있게 유지
인터페이스는 하나의 역할/계약에 집중해야 한다.
금지:
- unrelated method를 한 인터페이스에 몰아넣기
- “편해서” 여러 책임을 한 계약에 합치기
- consumer마다 일부만 필요한 fat interface
### 7. 인터페이스는 구현 세부보다 호출 계약을 고정
인터페이스는 아래를 고정해야 한다.
- 어떤 입력을 받는가
- 어떤 결과를 돌려주는가
- 어떤 예외/실패 의미가 가능한가
- 어떤 side effect/보장이 있는가
반대로 아래는 인터페이스에 새지 않게 한다.
- HttpClient/WebClient/JPA/Redis/Jackson 등 기술 세부
- 프레임워크 구체 타입
- 구현체 내부 최적화 방식
### 8. default method는 “계약 핵심”보다 “하위 호환/작은 공통 동작”에만 제한
Oracle 문서상 default method는 기존 구현과의 binary compatibility를 유지하면서 기능을 추가할 수 있다.
프로젝트에서는 아래일 때만 제한적으로 허용한다.
- 라이브러리/공용 계약의 하위 호환이 중요하다
- 매우 작은 convenience 동작이다
- 구현체 대부분에 동일하게 자연스럽다
기본값은 추상 메서드다.
비즈니스 핵심 로직을 default method로 밀어 넣지 않는다.
### 9. 인터페이스 static method는 그 계약에만 밀접한 helper일 때만
Oracle 문서상 인터페이스는 static method를 가질 수 있다.
프로젝트에서는 그 helper가 해당 인터페이스 계약과 아주 밀접할 때만 허용한다.
그 외 일반 helper는 별도 타입/유틸로 분리한다.
### 10. 인터페이스 상수 남용 금지
인터페이스는 상수 묶음 용도로 만들지 않는다.
상수는 계약의 본질이 아닐 경우 별도 적절한 소유 타입에 둔다.
### 11. Spring 프록시/AOP 때문에 인터페이스를 만들 수는 있지만, 그 이유를 과장하지 않는다
Spring은 인터페이스가 있으면 JDK dynamic proxy를 기본으로 사용한다.
하지만 “프록시 가능”만으로 모든 클래스 앞에 인터페이스를 두지 않는다.
기본 판단 순서:
1. 이 타입이 경계/계약인가?
2. 교체/테스트/AOP 가치가 있는가?
3. concrete class로 두는 것이 더 단순한가?
### 12. 테스트를 위해서만 인터페이스를 남발하지 않는다
Spring DI 문서는 인터페이스/추상 베이스 타입이 테스트 대역 사용을 쉽게 한다고 설명한다.
하지만 “테스트가 쉬워 보인다”는 이유만으로 의미 없는 인터페이스를 만들지 않는다.
다음도 대안이 될 수 있다.
- package-private concrete class 테스트
- 더 작은 collaborator 분리
- test fixture/fake 구현
- 포트 레벨에서만 seam 만들기
### 13. public API / multi-module contract는 인터페이스 우선 검토
다른 모듈/패키지/팀이 사용할 public contract면 인터페이스를 우선 검토한다.
이 경우 호출자와 구현체를 느슨하게 분리할 가치가 크다.
### 14. 조기 추상화 금지
겉보기 유사성만 보고 인터페이스를 먼저 만들지 않는다.
다음 질문 중 “예”가 충분히 쌓일 때 만든다.
- 정말 다른 구현이 필요한가?
- 호출자가 구현이 아니라 계약에 의존해야 하는가?
- 이 추상화가 6개월 뒤에도 자연스러운가?
- 이 인터페이스가 테스트/교체/경계 보호에 실제 도움 되는가?
## 프로젝트 기준 요약
- 인터페이스 기본값은 “필요할 때만”
- application port / 외부 경계 / 교체 정책은 인터페이스 우선
- 내부 helper/orchestration에는 기본적으로 인터페이스 금지
- `XService` + `XServiceImpl` 자동 생성 금지
- 역할 중심 이름 사용
- 기술 세부를 계약에 노출하지 않음
- default/static method는 제한적으로만 허용
- 프록시 가능성만으로 인터페이스를 만들지 않음
- 조기 추상화 금지
+165
View File
@@ -0,0 +1,165 @@
# mapper separation 기준
## 목적
매퍼는 **한 모델의 데이터를 다른 모델로 번역하는 역할**만 맡는다.
비즈니스 규칙, 영속성 접근, 외부 호출, 부수효과를 매퍼에 섞지 않는다.
## 공식 의미
- DTO는 프로세스/경계 사이에서 데이터를 옮기는 객체다.
- DTO와 domain object 사이를 옮기는 assembler/mapper를 둘 수 있다.
- Spring DataBinder는 입력 바인딩이 강력하지만, 바인딩 대상 설계를 신중히 해야 하며 보안상 주의가 필요하다.
- MapStruct 같은 매퍼 도구는 type-safe bean mapping 코드를 생성한다.
- 기존 target 객체 갱신은 `@MappingTarget` 같은 명시적 update mapping으로 표현할 수 있다.
## 기본 규칙
### 1. 매퍼는 “번역”만 한다
매퍼의 책임은 아래 중 하나다.
- request DTO -> command
- domain -> response DTO
- persistence entity -> domain
- domain -> persistence entity
- external DTO -> internal model
- internal model -> external DTO
즉 “형태를 바꾸는 일”까지만 한다.
### 2. 비즈니스 규칙은 매퍼에 넣지 않는다
다음은 매퍼 책임이 아니다.
- 상태 전이 결정
- 권한 판정
- 에러 코드 결정
- 정책 선택
- 유효성 최종 판정
- 도메인 invariant 강제의 주 책임
단, 단순한 정규화/포맷 수준의 보조 변환은 허용될 수 있다.
### 3. 매퍼에서 repository / external API / service 호출 금지
매퍼는 pure mapping에 가깝게 유지한다.
금지:
- DB 조회
- 외부 API 호출
- 다른 aggregate 탐색을 위한 repository 호출
- 메시지 발행
- 파일/네트워크 접근
매핑에 필요한 부가 데이터가 있으면 호출자가 먼저 준비해서 매퍼에 전달한다.
### 4. 경계마다 매퍼를 분리한다
다음 경계를 하나의 매퍼로 섞지 않는다.
- web request/response 변환
- persistence entity 변환
- external integration 변환
예:
- `UserWebMapper`
- `UserPersistenceMapper`
- `VaultApiMapper`
처럼 경계별로 분리한다.
### 5. DTO / Domain / Entity를 직접 섞지 않는다
하나의 매퍼가 다음을 동시에 다루며 의미를 섞지 않게 한다.
- request DTO
- domain
- JPA entity
- external API payload
필요하면 경계별로 mapper를 여러 개 둔다.
### 6. 매퍼는 기술 세부보다 구조적 대응 관계를 표현
좋은 매퍼는 아래를 명확하게 보여야 한다.
- 어떤 source를 어떤 target으로 바꾸는가
- 어떤 필드가 대응되는가
- 어떤 값이 누락되거나 기본값 처리되는가
반대로 아래는 매퍼에 새기지 않는다.
- HTTP 상태 코드
- DB 트랜잭션
- serialization 포맷 정책
- retry/fallback 정책
### 7. update mapping은 명시적으로만
기존 target을 수정하는 매핑은 “새로 생성하는 매핑”과 구분한다.
기본:
- create mapping
- update mapping
을 별도 메서드로 둔다.
기존 객체 갱신은 side effect가 있으므로 이름과 계약을 분명히 한다.
### 8. null / empty / default 처리 정책을 숨기지 않는다
매퍼는 다음을 명확히 해야 한다.
- null source를 허용하는가
- null field를 무시하는가
- null이면 target을 덮어쓰는가
- empty collection을 그대로 넣는가
- 기본값을 넣는가
정책이 중요하면 호출자/standard 문서에서 먼저 정하고 매퍼에 일관되게 반영한다.
### 9. mapper는 가능한 한 결정적이고 테스트 가능해야 한다
같은 입력이면 같은 결과가 나와야 한다.
지양:
- 현재 시각 생성
- 랜덤 값 생성
- 환경값 조회
- thread-local/MDC 접근
정말 필요하면 호출자가 값을 주입한다.
### 10. request binding과 도메인 생성은 구분한다
Spring DataBinder/Web binding이 request를 객체로 바꿔 주더라도,
그 객체를 domain으로 승격하는 과정은 별도 매퍼/assembler 또는 factory에서 통제한다.
즉:
- web binding = 입력 수집
- mapper = 구조 변환
- domain factory/value object = 의미/불변식 부여
### 11. persistence mapper는 DB nullable/오염 상태를 domain으로 직접 흘리지 않는다
entity -> domain 매핑에서는:
- nullable column
- legacy 값
- 잘못된 저장 데이터
를 명시적으로 처리한다.
필요하면 예외를 던지거나 복원 규칙을 적용하지만, 조용히 의미를 바꾸지 않는다.
### 12. external integration mapper는 wire format을 코어에 새기지 않는다
외부 API JSON/XML/HTTP payload 구조는 integration 전용 mapper에서 소화한다.
core/application/domain은 외부 wire format 세부를 몰라야 한다.
### 13. 자동 매핑 도구를 써도 책임은 그대로
MapStruct 같은 도구를 써도 아래 원칙은 변하지 않는다.
- 비즈니스 로직을 매퍼에 넣지 않는다
- update/create를 구분한다
- 경계별 매퍼를 분리한다
- null/default 정책을 숨기지 않는다
도구는 구현 보조일 뿐 설계 기준을 대체하지 않는다.
## 프로젝트 기준 요약
- 매퍼는 번역만 한다
- 비즈니스 정책/DB 조회/외부 호출 금지
- web / persistence / integration 매퍼 분리
- create mapping과 update mapping 분리
- null/default 정책 명시
- 결정적이고 테스트 가능하게 유지
- domain 의미 부여와 매핑을 혼동하지 않음
+162
View File
@@ -0,0 +1,162 @@
# port abstraction 기준
## 목적
포트는 “인터페이스를 많이 만들기 위한 수단”이 아니라,
애플리케이션 코어가 외부 기술과 직접 결합되지 않도록 **경계를 고정하는 계약**이다.
## 공식 의미
- 포트는 애플리케이션과 외부 세계 사이의 purposeful conversation을 식별하는 계약이다.
- 어댑터는 기술별 입력/출력을 포트 계약에 맞게 번역한다.
- 헥사고날 아키텍처에서는 코어가 외부 컴포넌트에 의존하지 않고, 외부 컴포넌트가 코어가 정의한 포트에 맞춘다.
- 포트는 inbound(입력) / outbound(출력) 성격으로 나눠 볼 수 있다.
## 기본 규칙
### 1. 포트는 “경계”에만 만든다
다음 중 하나가 아니면 포트를 만들지 않는다.
- application use case 진입 계약
- persistence / external API / message broker / file system 같은 외부 의존 경계
- 교체 가능한 보안/토큰/암호화/알림 정책 경계
- 테스트에서 fake/stub로 바꿔 끼울 가치가 큰 경계
같은 모듈 내부 helper 호출에는 포트를 만들지 않는다.
### 2. 코어가 소유하는 포트만 허용
포트는 코어(application/domain)가 필요로 하는 계약이어야 한다.
기본:
- inbound port: 코어가 외부 호출자에게 제공하는 use case 계약
- outbound port: 코어가 외부 시스템에 요구하는 계약
금지:
- infrastructure 기술 구조에 맞춘 인터페이스를 코어에 올리기
- adapter 편의를 위해 포트 모양을 결정하기
### 3. 포트는 비즈니스 의미로 말하고, 기술 세부를 숨긴다
포트 메서드는 “무엇을 원하는가”를 표현해야 한다.
좋은 방향:
- `findUserByEmail`
- `saveUser`
- `signToken`
- `publishUserCreated`
지양:
- `callHttp`
- `executeQuery`
- `postJson`
- `sendKafkaRecord`
기술 세부는 adapter 구현으로 내린다.
### 4. 포트 시그니처에 기술 타입을 노출하지 않는다
포트 계약에는 가능하면 아래 타입을 직접 노출하지 않는다.
- `ResponseEntity`
- `HttpClient`, `WebClient`
- `ResultSet`
- `JpaRepository`
- `JsonNode`
- 프레임워크 request/response 타입
포트는 domain/application에 더 가까운 command/result/value type으로 표현한다.
### 5. 포트는 작고 응집도 있게 유지
포트 하나는 하나의 역할/대화에 집중해야 한다.
금지:
- unrelated use case를 한 inbound port에 몰아넣기
- 여러 외부 시스템 책임을 한 outbound port에 섞기
- 너무 범용적인 `CommonPort`, `IntegrationPort`
### 6. inbound port는 use case 단위로 생각
입력 포트는 보통 “사용자가 시스템에 요구하는 행위” 단위로 설계한다.
예:
- 회원 가입
- 로그인
- 토큰 발급
- 공개키 조회
즉 controller나 scheduler가 직접 서비스 구현을 알기보다, use case 계약을 호출하는 방향을 우선 검토한다.
### 7. outbound port는 코어가 정말 필요한 능력만 노출
출력 포트는 adapter가 할 수 있는 모든 기능이 아니라, 코어가 실제로 필요한 능력만 드러낸다.
예:
- `UserReader` / `UserAppender`
- `VaultSigner`
- `TokenPublisher`
금지:
- adapter의 내부 옵션/기술 선택지를 포트에 그대로 노출
- “혹시 나중에 필요할지도” 모드를 미리 넣기
### 8. 포트 이름은 역할 중심으로 짓는다
좋은 방향:
- `UserReader`
- `UserSaver`
- `TokenSigner`
- `AuthLoginUseCase`
- `PublicKeyQuery`
지양:
- `UserPort`
- `CommonPort`
- `InfraPort`
- `ExternalApiPort`
이름만 보고 어떤 대화를 하는지 보여야 한다.
### 9. 포트 반환값은 없음/실패 의미를 명확히 표현
- 단건 조회 없음 -> `Optional<T>` 검토
- 다건 조회 없음 -> empty collection
- 실패는 예외 또는 명시적 result type으로 표현
- null 반환 금지
### 10. 포트는 transaction/transport를 직접 소유하지 않는다
포트 자체는 transaction, HTTP, serialization 정책을 직접 설명하지 않는다.
기본:
- transaction boundary는 application service/use case 쪽에서 결정
- transport 형식은 controller/adapter에서 결정
- serialization은 adapter에서 처리
### 11. adapter는 포트를 “구현”하거나 “호출”하면서 번역 책임을 진다
- inbound adapter: HTTP, scheduler, message consumer, CLI 등에서 입력을 받아 포트 호출
- outbound adapter: DB, external API, queue, cache 등에 맞게 포트를 구현
adapter는 기술 번역을 담당하지만 business meaning을 새로 만들지 않는다.
### 12. 테스트 seam이 실제 가치가 있을 때 포트를 둔다
포트는 테스트를 쉽게 만들 수 있지만, 테스트 때문에 모든 내부 호출을 포트로 만들지는 않는다.
기본:
- 외부 경계 seam은 포트 우선
- 내부 구현 detail seam은 concrete class 유지 가능
### 13. 포트와 adapter는 1:1일 필요가 없다
하나의 포트에 여러 adapter가 붙을 수 있다.
예:
- mock repository / real repository
- REST adapter / batch adapter / test harness
즉 포트는 기술 구현 수가 아니라 “대화 계약” 기준으로 잡는다.
### 14. 포트는 framework proxy/AOP 이유만으로 만들지 않는다
Spring이 인터페이스 기반 프록시를 잘 지원하더라도, 프록시 가능성만으로 포트를 만들지 않는다.
먼저 경계/계약 의미가 있는지 확인한다.
## 프로젝트 기준 요약
- 포트는 경계에만 만든다
- 포트는 코어가 소유한다
- 포트는 비즈니스 의미로 말하고 기술 세부를 숨긴다
- inbound는 use case 중심
- outbound는 코어가 필요한 능력만
- 포트는 작고 응집도 있게
- adapter가 기술 번역을 담당
- 모든 내부 호출을 포트로 만들지 않는다
+183
View File
@@ -0,0 +1,183 @@
# value object 기준
## 목적
Value Object는 **식별자보다 값 자체가 본질인 도메인 개념**을 표현한다.
문자열, 숫자, primitive 조합으로 흩뿌려진 의미를 작은 타입으로 끌어올려,
- 의미를 드러내고
- 불변식을 한 곳에 모으고
- 잘못된 조합을 줄이는 것이 목적이다.
## 공식 의미
- Value Object는 개념적 identity가 없다.
- Value Object는 생성 후 immutable하게 다루는 것이 기본이다.
- 값이 같으면 서로 interchangeable 하다.
- 값 기반 객체는 identity-sensitive 연산(`==`, identity hash, synchronization)에 의존하지 않는다.
- `equals` / `hashCode`는 identity가 아니라 상태값 기준이어야 한다.
## 기본 규칙
### 1. identity가 아니라 값이 본질이면 Value Object를 우선 검토
다음은 Value Object 후보다.
- 이메일
- 사용자 이름
- 금액
- 통화
- 기간
- 주소
- 토큰 문자열
- 공개키 식별자
- provider code
- 정규화된 path/host/url 일부
- 비즈니스 규칙이 붙은 ID wrapper
질문:
- “무엇인가”보다 “어떤 값인가”가 본질인가?
- 같은 값이면 같은 것으로 취급해야 하는가?
- 생성 시점에 검증/정규화 규칙을 묶고 싶은가?
### 2. Value Object는 기본적으로 immutable
Value Object는 생성 후 상태가 바뀌지 않게 설계한다.
기본:
- final field
- setter 없음
- 변경이 필요하면 새 인스턴스 반환
변경 가능한 컬렉션/객체를 내부에 들고 있으면 defensive copy 또는 immutable snapshot을 사용한다.
### 3. equality는 값 기준
Value Object의 동등성은 값으로 판단한다.
기본:
- `equals` / `hashCode` 구현
- record를 쓸 수 있으면 record 우선 검토
- `==` 비교 금지
- identity-based lock/synchronization 금지
### 4. 생성 시점에 불변식을 강제
Value Object는 가능한 한 생성 시점에 유효한 상태만 허용한다.
예:
- `UserEmail.from(...)` 에서 trim/lowercase/형식 검증
- `Money.of(...)` 에서 음수 금지/scale 정리
- `UserName.from(...)` 에서 길이/문자 규칙 검증
“일단 넣고 나중에 확인”을 금지한다.
### 5. primitive obsession을 줄이는 방향으로 도입
다음과 같은 경우 Value Object 도입을 우선 검토한다.
- 같은 `String`이지만 의미가 여러 개라 실수 가능성이 큼
- 여러 곳에서 같은 검증/정규화가 반복됨
- 메서드 시그니처에서 의미가 안 드러남
- 잘못된 값 조합을 타입 수준에서 줄이고 싶음
### 6. 너무 사소한 래퍼는 만들지 않는다
다음은 도입을 보류할 수 있다.
- 검증/정규화/행위가 전혀 없음
- 의미가 너무 자명하고 혼동 위험이 낮음
- 래퍼 비용이 실제 이득보다 큼
즉 모든 primitive를 기계적으로 감싸지 않는다.
### 7. Value Object는 도메인 언어를 사용
이름은 기술 표현이 아니라 business meaning을 드러내야 한다.
좋은 방향:
- `UserEmail`
- `Money`
- `AuthProviderCode`
- `DisplayName`
- `TokenTtl`
지양:
- `StringWrapper`
- `ValueHolder`
- `CommonValue`
### 8. Value Object는 nullable 대신 명시적 의미를 우선
가능하면 Value Object 자체는 non-null로 다룬다.
부재 표현이 필요하면:
- Optional 반환
- nullable boundary 입력
- 별도 상태 타입
- empty/unknown 값을 실제 business state로 둘지 신중히 검토
null을 Value Object 의미의 일부처럼 쓰지 않는다.
### 9. Value Object는 DTO/Entity와 분리
Value Object는 domain 의미 타입이다.
기본:
- request DTO field를 그대로 Value Object로 바인딩하지 않음
- entity field를 그대로 Value Object로 대체하지 않고 매핑 전략을 명시
- DTO <-> domain, entity <-> domain 변환에서 Value Object를 생성/복원
### 10. 컬렉션을 포함하는 Value Object는 특히 신중
컬렉션이 들어가는 Value Object는 아래를 만족해야 한다.
- 컬렉션 자체가 immutable/unmodifiable
- 원소도 가능한 한 immutable
- equality/hashCode 의미가 분명함
- 순서 중요 여부가 명확함
### 11. 행위가 있어도 된다. 단, 값 의미와 관련된 행위여야 한다
Value Object는 단순 data carrier일 필요는 없다.
허용 예:
- 정규화
- 포맷 변환
- 비교
- 계산
- 조합
- 규칙 기반 convenience method
금지 예:
- repository 호출
- 외부 API 호출
- 전역 상태 의존
- 객체 그래프 조립의 중심이 되는 orchestration
### 12. persistence는 domain 의미를 우선하되 별도 매핑으로 해결
JPA entity는 persistence 제약을 받으므로 Value Object와 1:1로 같아야 할 필요는 없다.
기본:
- entity <-> domain mapper에서 Value Object 생성/복원
- 가능하면 embeddable/owned type 등 적절한 persistence 모델 사용 검토
- persistence 편의 때문에 domain Value Object를 포기하지 않음
### 13. record는 좋은 기본 선택지일 수 있다
Java record는 값 중심 타입 표현에 잘 맞을 수 있다.
단, 아래를 만족할 때 사용한다.
- 불변 구조가 자연스럽다
- 값 기반 equality가 맞다
- 생성 시 검증/정규화를 canonical constructor/factory로 명확히 표현할 수 있다
단, record를 쓴다고 자동으로 좋은 Value Object가 되는 것은 아니다.
### 14. Value Object는 작은 타입이지만 경계 비용을 줄여야 한다
도입 후 얻는 이득:
- 의미가 타입에 드러남
- 검증 중복 감소
- 잘못된 조합 감소
- 테스트 용이성 증가
단, 의미 없는 래퍼 남발은 금지한다.
## 프로젝트 기준 요약
- identity보다 값이 본질이면 Value Object 우선 검토
- 기본은 immutable
- equality는 값 기준
- 생성 시점에 불변식 강제
- primitive obsession 줄이기
- 너무 사소한 래퍼는 지양
- DTO/Entity와 분리
- persistence는 mapper/별도 매핑 전략으로 해결
- record는 좋은 선택지일 수 있으나 자동 정답은 아님