패키지 구조 표준은 도메인 안을 api·application·domain·infra 네 겹으로 나눴다. 컨트롤러 패키지 분리 표준이 거기에 web을 형제로 붙였다. 그 겹 안을 더 나눌지는 어느 쪽도 정하지 않았다.
그래서 지금 계층 폴더 하나를 열면 이렇게 생겼다.
// 안 A — 지금 구조. 한 폴더 안에 종류가 섞여 있다
socialprovider/api/ 19개
├── SocialProviderController.java
├── SocialProviderCreateRequest.java
├── SocialProviderResponse.java
├── CompetitorController.java
├── CompetitorSearchRequest.java
└── ...
// 안 B — 접미사로 한 겹 더 판다
socialprovider/api/controller/
socialprovider/api/request/
socialprovider/api/response/
걸려 있는 것은 가독성만이 아니다. 자바에서 폴더를 한 겹 더 파면 접근 제어가 그 자리에서 끊긴다. api/SocialProviderCreateRequest가 api/controller/SocialProviderController에서 안 보이게 되므로, 나누는 순간 무엇을 public으로 열지를 함께 정해야 한다. 나누느냐 마느냐가 곧 무엇을 열어젖히느냐의 문제다.
이 글의 모든 숫자는 origin/dev의 프로덕션 자바 552개(테스트 109개 별도)를 실제로 센 값이다.
먼저: 자바에서 패키지는 계층이 아니다
이 글의 모든 판단을 좌우한 제약이다. a.b는 a의 “안”이 아니라 a와 남남이다. 이름이 점으로 이어져 있을 뿐 접근 제어에서는 아무 관계가 없다.
실제로 확인했다. a에 package-private 클래스를 두고 a.b에서 참조하면 이렇게 된다.
error: Base is not public in a; cannot be accessed from outside package
폴더를 한 겹 더 파는 순간 지금 닫혀 있는 것이 안 보인다. 그래서 접미사 분할은 순수한 정리 작업이 아니라 접근 제어를 건드리는 작업이다.
우회할 제어자가 있는지도 확인했고, 없었다.
package-private와package-protected는 같은 말이다. 아무것도 안 붙였을 때의 기본 접근 수준을 부르는 두 이름일 뿐이고, 별도 키워드가 아니다. 검색하다 두 단어가 따로 나와서 두 수준이 있는 줄 알았는데 아니었다protected는 실재하지만 최상위 클래스에 못 붙는다.error: modifier protected not allowed here. 게다가protected는 상속 축이라 형제 패키지에는 애초에 무의미하다- JPMS도 안 통한다. 모듈 간 공개 범위를 정하는 장치라 모듈 하나 안에서는 아무것도 막지 않는다. 이 프로젝트는 Java 21 단일 Gradle 모듈이다
중간 단계가 없다. 하위 패키지에만 보여주는 방법은 없고, 보여주려면 public, 즉 프로젝트 전체에 여는 것뿐이다.
컴파일러 밖에서 막는 수단은 ArchUnit 하나뿐인데 이 프로젝트에 없다. 그러니 지금 public을 하나 붙이면 그걸 되돌리라고 말해줄 장치가 아무 데도 없다.
먼저: 계층마다 실측 결과가 정반대였다
“접미사로 나눈다”를 전 계층에 똑같이 적용할 수 있는지부터 쟀다. 네 계층 전부를 세어봤고, 하나의 규칙으로 덮을 수 없다는 결론이 여기서 나왔다.
| 계층 | 파일 | public | package-private | 이미 개념별로 묶여 정렬된 비율 | 접미사 매칭 |
|---|---|---|---|---|---|
| api | 109 | 103 | 6 | 20% | 105/109 (96%) |
| application | 120 | 87 | 33 | 21% | 98/120 (82%) |
| domain | 115 | 115 | 0 | 0% | 미분류 다수 |
| infra | 136 | 0 | 136 (100%) | 65% | 거의 전부 |
다섯 번째 칸이 판단을 갈랐다. 파일 이름이 알파벳순으로 늘어설 때 같은 업무 개념끼리 이미 붙어 있는 비율이다.
api·application은 20~21%다. 폴더를 열면 개념이 흩어져 있다. 나누면 질서가 생긴다infra는 65%다. 폴더를 열면 이미 개념끼리 붙어 있다. 나누면 그 질서가 깨진다domain은 0%인데 접미사 자체가 없다. 나눌 축이 없다
네 번째 칸도 한 방향을 가리킨다. infra는 136개 전부가 package-private이다. 이 저장소에서 접근 제어가 실제로 서 있는 유일한 자리이고, 분할이 접근 제어를 건드리는 작업인 이상 잃을 것이 제일 많은 자리이기도 하다. 반대로 domain은 115개 전부가 public이라 잃을 것이 없다.
같은 조작이 네 계층에서 각각 다른 결과를 낸다. 그래서 이 표준은 계층별로 답이 다르다.
원칙 1. 접미사 분할은 api와 application에만 한다
정해야 했던 건 이거였다 — 접미사 분할을 어느 계층까지 적용할 것인가.
안 A — 전 계층에 일괄 적용한다
socialprovider/api/controller/ request/ response/
socialprovider/application/usecase/ command/ query/ service/
socialprovider/domain/model/ repository/ vo/
socialprovider/infra/entity/ mapper/ repository/ result/
얻는 것은 규칙이 하나라는 것이다. “계층 안은 접미사로 나눈다” 한 줄이면 끝이고, 계층마다 다른 판단이 필요 없다. 새 파일을 놓을 자리도 이름만 보면 정해진다.
버린 이유는 실제로 돌려보니 패키지가 74개에서 196개로 늘고 그중 60%가 파일 1~2개짜리라는 것이다. 폴더를 열면 파일 하나가 들어 있고, 그 폴더를 찾으려고 부모를 다시 여는 왕복만 는다. 큰 폴더를 쪼개려다 작은 폴더를 백 개 넘게 만드는 안이다.
골랐다 — api와 application 둘만 나눈다.
<도메인>/api/ controller/ request/ response/
<도메인>/application/ usecase/ command/ query/ service/
<도메인>/domain/ (나누지 않는다)
<도메인>/infra/ (나누지 않는다)
controller · request · response"] --> APP["application
usecase · command · query · service"] APP --> DOM["domain
나누지 않는다"] INF["infra
나누지 않는다"] --> DOM style API fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style APP fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style DOM fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0 style INF fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0
접미사 분포가 이 둘에서 실제로 갈린다.
api response 50 · request 37 · controller 18 · 루트 4 (105/109 매칭)
application usecase 31 · command 29 · service 27 · 루트 22 · query 11 (98/120 매칭)
안 A가 못 지켜주는 것은 분할이 실제로 질서를 만드는 자리에서만 분할한다는 것이다. 그리고 그 이득은 재봤다 — 파일 10개가 넘는 패키지가 api는 4개에서 1개로, application은 5개에서 0개로 준다.
| 폴더 | 지금 | 분할 후 |
|---|---|---|
auth/application |
18 | 7 |
review/api |
14 | 6 |
socialprovider/api |
19 | 15 |
마지막 줄이 이 표준의 한계다. socialprovider/api는 Response가 15개라 나눠도 거의 안 준다. 접미사 분할로는 더 못 푸는 자리가 있다는 뜻이고, 그게 도메인 분해를 후속으로 남기는 이유다(「후속으로 남긴 것」).
대신 계층마다 규칙이 다른 것을 감수한다. “패키지 안은 접미사로 나눈다”는 한 줄로 못 적고, 어느 계층인지를 먼저 봐야 한다. 이어지는 세 섹션이 나머지 두 계층을 왜 뺐는지다.
infra를 뺀 첫째 이유 — 접미사 정렬이 이미 개념별로 묶어준다
접미사는 이름의 끝에 있다. 그래서 파일 이름을 알파벳으로 정렬하면 앞쪽의 업무 개념이 정렬 키가 되고, 같은 개념이 저절로 붙어서 보인다.
socialprovider/infra/
CompetitorEntity.java
CompetitorEntityMapper.java
CompetitorJpaEntityRepository.java
CompetitorJpaRepository.java ← Competitor 4개가 붙어 있다
WorkforceEntity.java
WorkforceJpaEntityRepository.java ← Workforce 3쌍이 붙어 있다
...
여기에 접미사 분할을 걸면 Competitor 4개가 entity/·jpa/·repository/·result/ 네 폴더로 흩어진다. 한 개념을 고치려고 폴더 넷을 오간다.
즉 infra에서 분할은 질서를 만드는 게 아니라 이미 있는 질서를 깨는 조작이다. 65%라는 숫자가 그걸 말한다. api·application에서 분할이 옳은 이유와 정확히 같은 근거가, infra에서는 반대 방향을 가리킨다.
infra를 뺀 둘째 이유 — 나누면 89개가 열리고 그 절반이 DB 스키마다
infra는 136개 전부가 package-private이다. 패키지 구조 표준 원칙 3이 “infra는 전부 package-private으로 막는다”고 정해둔 그 상태가 100% 지켜지고 있다.
여기서 폴더를 한 겹 더 파면 89개를 public으로 열어야 한다. entity/의 타입을 repository/의 구현체가 봐야 하는데, 자바에는 그 사이만 여는 중간 단계가 없기 때문이다(「먼저: 자바에서 패키지는 계층이 아니다」).
열리는 89개의 내역이 문제다.
| 무엇 | 개수 | |
|---|---|---|
Entity |
29 | DB 스키마 |
JpaEntityRepository |
18 | DB 스키마 |
Mapper |
20 | |
MapperResult |
18 | |
MapperParam |
4 |
절반에 가까운 47개가 DB 스키마 그 자체다. Entity 29개와 그것을 다루는 Spring Data 인터페이스 18개가 프로젝트 전체에 열린다. 지금은 infra 밖에서 그 타입 이름조차 쓸 수 없다.
이건 나중에 붙인 해석이 아니다. 실제 코드에 주석으로 적혀 있다.
package-private이다. 이 타입은 물론 이 타입을 다루는 Spring Data 인터페이스와 Repository 구현체도 패키지 밖으로 나가지 않는다.
정리를 위해 접근 제어를 무르는 거래인데, 첫째 이유에서 봤듯이 그 정리는 애초에 마이너스다. 그리고 되돌릴 장치도 없다 — ArchUnit이 없으니 한 번 열면 다시 닫으라고 말해줄 것이 없다.
infra를 다른 축으로 나누는 안도 열림에서 걸린다
접미사가 아닌 축으로 infra를 나누는 안도 봤다. 어느 축을 골라도 「무엇이 열리는가」가 같은 모양으로 걸린다.
안 B — 기술 축으로 나눈다. infra/jpa/와 infra/mybatis/로 가른다. 얻는 것은 두 영속성 기술이 섞여 있는 지금 상태가 갈린다는 것이다. 버린 이유는 같은 테이블을 다루는 JPA 쪽과 MyBatis 쪽이 서로를 못 보게 된다는 것이다. 둘은 같은 Entity와 같은 결과 타입을 공유하므로 결국 그걸 열어야 하고, 열리는 것이 다시 Entity다.
안 C — 업무 개념 폴더로 나눈다. infra/competitor/, infra/workforce/처럼 개념별로 판다. 얻는 것은 정렬이 만들어주던 묶음을 폴더로 굳힌다는 것이다 — 65%를 100%로 만든다. 버린 이유는 어디를 잘라도 샌다는 것이다. 하위 테이블 Entity가 메인 Entity를 FK로 양방향 참조하고 있어서, 개념 경계를 어디에 그어도 그 선을 가로지르는 참조가 남는다. 열림이 제일 적은 안인데도 구조적으로 안 닫힌다.
안 D — 하위 도메인으로 승격한다. socialprovider.competitor.infra처럼 도메인을 한 겹 더 판다. 얻는 것은 infra만이 아니라 네 계층이 함께 작아진다는 것이다. 버린 이유는 대부분의 개념에 자기 유스케이스가 없다는 것이다. application이 0인 개념을 도메인으로 승격하면 빈 api·application 폴더만 생긴다. 유스케이스가 없으면 도메인이 아니라 테이블이다.
셋의 실패 지점이 서로 다른데 결론은 같다 — infra에서 손대야 할 것은 폴더가 아니다. 안 D가 가리킨 방향만 살려서 후속으로 넘긴다.
domain을 뺀 이유 — 여기엔 접미사가 아예 없다
domain은 나눌지 말지를 고르는 문제가 아니었다. 나눌 축이 없었다.
// domain 계층의 실제 이름들 — 접미사가 없다
SocialProviderSatisfaction
ContentSourceType
OriginUrl
StoredFile
도메인 모델과 값 객체는 업무 이름을 그대로 갖는 것이 정상이다. XxxController처럼 자기 역할을 이름 끝에 달고 다니지 않는다. 그래서 115개 중 상당수가 접미사로 분류되지 않고, 개념별로 묶여 정렬된 비율은 0%다.
두 숫자가 같은 방향을 가리킨다. 접미사로도 안 갈리고 개념으로도 안 묶여 있다는 것은, 여기가 어지러운 이유가 「분류가 안 돼 있어서」가 아니라 「한 폴더에 서로 상관없는 개념이 너무 많아서」라는 뜻이다.
처방이 다르다. 접미사 분할은 같은 개념의 것들을 역할별로 가르는 조작인데, 여기서 필요한 것은 서로 다른 개념을 도메인 단위로 떼어내는 조작이다. 그건 이 표준이 아니라 도메인 분해가 할 일이다.
원칙 2. 접미사 목록에 없으면 계층 루트에 남긴다
api와 application을 나누기로 했으니 목록에 안 걸리는 파일을 어디에 둘지를 정해야 한다. 적은 수가 아니다 — application 루트에 22개, api 루트에 4개가 남는다.
// 이렇게 하지 않는다 — 잡동사니 폴더를 만든다
auth/application/misc/
auth/application/common/
auth/application/support/
// 이렇게 한다 — 계층 루트에 그냥 둔다
auth/application/usecase/
auth/application/command/
auth/application/service/
auth/application/AdminAuthenticator.java ← 여기 남는다
auth/application/AuthSecurityBeanConfig.java ← 여기 남는다
정해야 했던 건 이거였다 — 접미사가 없는 파일을 위한 폴더를 만들 것인가.
안 A — misc·common·support 같은 수용 폴더를 만든다. 얻는 것은 계층 루트가 비어서 폴더만 남는다는 것, 즉 겉보기 일관성이다. 버린 이유는 그 폴더가 무엇이 들어 있는 곳인지 이름으로 답하지 못한다는 것이다. 이름이 「분류되지 않은 것」이므로 자라기만 하고 절대 줄지 않는다. 그리고 새 파일을 만들 때마다 “이건 접미사가 애매하니 일단 misc에”가 성립해서, 접미사 폴더를 비우는 방향으로 압력이 걸린다.
골랐다 — 계층 루트에 남긴다. 안 A가 못 지켜주는 것은 모든 폴더가 자기 이름으로 내용물을 설명한다는 것이다. controller/에는 컨트롤러만 있고, 그 규칙에 안 맞는 것은 폴더에 안 들어간다.
루트 22개가 한 폴더에 쌓이는 것도 아니다. 도메인별로 흩어져 있어서, 분할 후 파일 10개를 넘는 application 패키지는 루트를 포함해 0개다. 안 A가 지우려던 문제가 애초에 그 규모로 존재하지 않는다.
대신 계층 루트에 파일과 폴더가 섞여 보이는 것을 감수한다. 그 낱개를 쓰는 폴더로 내려보내는 안은 원칙 4에서 다시 나오고, 거기서도 버린다.
원칙 3. 크기와 무관하게 항상 나눈다
“파일이 몇 개 이상이면 나눈다”는 임계값을 두지 않는다. api와 application이면 파일이 셋이어도 나눈다.
정해야 했던 건 이거였다 — 작은 도메인도 나눌 것인가.
안 A — 임계값을 둔다. “한 계층에 파일 10개를 넘으면 나눈다” 같은 규칙이다. 얻는 것은 작은 도메인에서 폴더 왕복이 없다는 것이다. 실제로 지금 10개를 넘는 패키지는 api 4개, application 5개뿐이라, 임계값을 두면 손댈 곳이 아홉 군데로 줄어든다.
버린 이유는 임계값을 넘는 날 구조가 바뀐다는 것이다. 파일 11번째를 추가하는 커밋이 갑자기 기존 열 개를 세 폴더로 옮기는 커밋이 된다. 그 커밋은 리뷰에서 「무엇이 이 기능의 변경인가」를 못 가른다. 그리고 그 이동은 접근 제어를 건드리므로(「먼저: 자바에서 패키지는 계층이 아니다」) public 판단까지 그 커밋에 딸려 들어온다. 파일 하나 늘리는 일이 아니게 된다.
여기에 더해, 임계값은 매번 사람이 재야 한다. 10개인지 12개인지를 셀 사람이 필요하고, 그 판단이 도메인마다 갈리면 같은 프로젝트 안에서 두 모양이 공존한다. 이 저장소가 반복해서 버린 형태가 「사람이 계속 기억해야만 지켜지는 규칙」이고, 여기엔 그걸 잡아줄 ArchUnit도 없다.
골랐다 — 임계값 없음. 안 A가 못 지켜주는 것은 구조가 시간에 따라 변하지 않는다는 것이다. 처음 만들 때 나눠두면 자라도 그대로다.
대신 작은 도메인에서 폴더당 파일 하나인 구간을 감수한다. 안 A가 지적한 낭비가 실재한다는 것은 인정한다. 다만 그 낭비는 파일이 늘면 저절로 사라지고, 임계값이 만드는 「구조가 바뀌는 커밋」은 사라지지 않는다.
원칙 4. 나눈 뒤 패키지 밖에서 참조되지 않으면 public을 뗀다
접미사 분할이 접근 제어를 건드린다면, 건드리는 김에 지금 틀려 있는 것을 바로잡는다.
지금 api는 109개 중 6개만 닫혀 있다. Controller 18개와 Request 37개가 전부 public인데, 열려 있어야 할 이유가 있어서 열린 게 아니다. 자바에서 클래스를 만들면 public을 붙이는 관습 때문이다.
반대쪽을 보면 그게 관습이었다는 게 분명해진다. Service는 27개 중 26개가 닫혀 있다. UseCase 인터페이스 표준이 “구현체는 package-private”이라고 못 박아둔 자리라서다. infra는 136개 전부가 닫혀 있다.
이 저장소의 실제 규칙은 「패키지 경계를 넘는 것만
public」이고,api가 그걸 안 지키고 있었을 뿐이다.
그래서 원칙은 하나다. 분할 후 자기 패키지 밖에서 참조되지 않으면 public을 뗀다.
「지금 안 쓰인다」가 아니라 「분할 후의 패키지 밖」이 기준이라는 점이 중요하다. 나누고 나면 controller/가 request/를 참조하는 것이 패키지 경계를 넘는 일이 되므로, 지금 닫을 수 있어 보이는 것 상당수가 열린 채로 남는다.
| 총계 | 분할 후 닫힘 | |
|---|---|---|
Controller |
18 | 18 |
Service |
27 | 26 |
Response |
50 | 16 |
Request |
37 | 6 |
| 루트 | 26 | 1 |
확실히 닫히는 것은 행위다. Controller 18개 전부와 Service 26개가 닫힌다. 반면 Request는 37개 중 6개, Response는 50개 중 16개만 닫힌다 — 나머지는 controller/가 다른 패키지에서 이름을 부르므로 열려 있어야 한다.
합계는 이렇게 된다.
지금 39개 닫힘 (api 6 + application 33)
분할·닫기 후 67개 닫힘 (api 41 + application 26) 순증 +28
여는 것은 13개다. 그리고 그 13개가 어디서 나오는지가 이 조작의 성격을 보여준다.
- api 6개 — 지금
api에서 닫혀 있는 6개가 전부 열린다. 값 객체AdminAccountPasswordPolicy·ServiceUsedYearMonth와 응답 4개(BirthStatisticsItemResponse·BirthStatisticsMonthlyPivotResponse·BirthStatisticsSyncResultResponse·RegionResponse)다 - application 7개 —
AuthSecurityBeanConfig·AdminRememberMeServices·AdminAuthenticator·InquiryAccessTokenSigner·InquirySearchRateLimiter·ReviewViewCountLimiter·SocialProviderCollectStore. 전부 계층 루트에 남는 협력자인데,service/로 내려간Service가 이들을 주입받으므로 경계를 넘게 된다
원칙 2와 원칙 4가 여기서 만난다. 루트에 남기기로 한 것들이 하위 패키지에서 참조되면 열어야 한다. 그 대가가 7개다.
application이 33에서 26으로 줄어드는 것도 같은 이유다. 남는 26개는 원래부터 닫혀 있던 Service들이고, 이 계층은 분할로 새로 닫는 것 없이 7개를 내준다. 반대로 api는 6개를 내주고 41개를 닫는다.
낱개를 쓰는 폴더로 내리는 안(방법 B)도 봤다. 새로 여는 13개 중 일부를 그것을 쓰는 하위 패키지 안으로 옮기면 열림이 더 준다. 얻는 것은 열림 개수가 제일 적다는 것이다. 버린 이유는 폴더가 다시 혼합된다는 것이다. service/ 안에 Service가 아닌 AdminAuthenticator가 들어앉는 순간, 애초에 나눈 목적인 「폴더 이름이 내용물을 설명한다」가 깨진다. 원칙 2에서 잡동사니 폴더를 거부한 것과 같은 이유로, 여기서도 혼합을 거부한다.
분할의 대가 — 커밋 하나가 건드리는 폴더가 2.3에서 6.1로 는다
고른 안의 비용을 실측으로 적어둔다. git 히스토리를 훑어 한 커밋이 건드리는 폴더 수를 세봤다.
지금 평균 2.3 개 (최대 6)
분할 후 평균 6.1 개 (최대 15)
그리고 이 숫자가 더 세다. 파일 2개 이상을 건드린 커밋 27건 중 27건, 즉 100%가 접미사를 가로지른다.
기능 하나를 고치면 컨트롤러와 요청 DTO와 서비스를 함께 고치는 게 정상이라 당연한 결과다. 접미사 분할은 「함께 바뀌는 것」이 아니라 「같은 종류인 것」을 묶는 조작이므로, 함께 바뀌는 것끼리는 반드시 흩어진다. 예외가 한 건도 없었다는 게 그걸 그대로 보여준다.
그럼에도 분할을 택한 이유는 두 비용이 걸리는 빈도가 아니라 대상이 다르다는 판단이다.
- 커밋을 쓰는 사람은 어차피 고칠 파일 셋을 이미 알고 연다. 폴더가 늘어도 IDE에서 파일을 찾는 방식은 안 바뀐다
- 폴더를 열어 파일을 찾는 사람은 19개짜리 혼합 폴더를 열 때마다 매번 걸린다. 그리고 그건 코드를 처음 보는 사람이 제일 많이 하는 동작이다
전자는 이미 알고 있는 사람의 비용이고 후자는 모르는 사람의 비용이다. 후자를 택했다.
이 판단이 뒤집힐 조건도 적어둔다. 100%라는 숫자가 나온 표본은 커밋 27건이다. 표본이 커졌을 때 평균 폴더 수가 6.1보다 더 벌어지거나, 접미사를 가로지르는 커밋이 리뷰에서 실제로 문제를 일으키기 시작하면 다시 연다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
api를 나누나 |
나눈다 | 접미사 매칭 96%인데 개념 묶임은 20%뿐이다 |
application을 나누나 |
나눈다 | 매칭 82%, 개념 묶임 21%. 10개 넘는 패키지가 0이 된다 |
infra를 나누나 |
안 나눈다 | 이미 65%가 묶여 있다. 나누면 89개가 열리고 47개가 DB 스키마다 |
domain을 나누나 |
안 나눈다 | 접미사가 아예 없다. 처방은 도메인 분해다 |
| 접미사 목록에 없는 파일은 | 계층 루트에 남긴다 | misc 폴더는 이름으로 내용물을 설명하지 못한다 |
| 파일이 셋뿐인 도메인은 | 그래도 나눈다 | 임계값을 넘는 커밋이 구조 변경 커밋이 된다 |
나눈 뒤 public은 |
패키지 밖에서 참조되지 않으면 뗀다 | 지금 열린 것 대부분은 설계가 아니라 자바 관습이다 |
Request·Response는 닫히나 |
대부분 안 닫힌다 | controller/가 다른 패키지에서 부른다. 6/37과 16/50만 닫힌다 |
| 열어야 하는 낱개는 | 연다. 폴더로 내리지 않는다 | 내리면 폴더가 다시 혼합돼 분할 목적이 깨진다 |
| 하위 패키지에만 열 수 있나 | 없다 | a.b는 a의 밖이다. protected도 JPMS도 안 통하고 ArchUnit도 없다 |
이 표준을 정하기까지
시작은 socialprovider/api를 열었을 때 파일 19개가 한눈에 안 들어온다는 것이었다. 처음 세운 안은 단순했다 — 접미사로 폴더를 파자. 계층 넷 전부에 똑같이 적용하면 규칙이 한 줄로 끝난다고 봤다.
첫 발견은 그 안을 실제로 돌려본 숫자였다. 패키지가 74개에서 196개가 되는데 그중 60%가 파일 1~2개짜리였다. 큰 폴더 몇 개를 쪼개려다 작은 폴더를 백 개 넘게 만드는 안이었다. 여기서 「전 계층 일괄」이 죽었다.
그다음에 계층별로 다시 셌고, 거기서 방향이 꺾였다. 접미사로 갈리는 비율만 보면 infra가 제일 높아서 나누기 제일 좋은 자리로 보였다. 그런데 「이미 개념별로 묶여 정렬된 비율」을 같이 세니 infra가 65%였다. api·application은 20~21%인데.
그 65%의 이유를 언어로 꺼낸 순간이 이 대화의 전환점이었다. 접미사가 이름의 끝에 있으니 알파벳 정렬의 키는 앞쪽의 업무 개념이 된다. 즉 접미사 명명 규칙 자체가 이미 개념별 묶음을 만들어주고 있었다. Competitor 4개와 Workforce 3쌍이 붙어서 보이는 게 우연이 아니었다. 그러면 infra에서 접미사 분할은 질서를 만드는 게 아니라 있는 질서를 깨는 조작이다. 같은 근거가 계층에 따라 반대 방향을 가리킨다는 걸 여기서 알았다.
접근 제어 쪽은 별개 문제인 줄 알았는데 아니었다. infra를 나누면 89개를 열어야 하고 그중 Entity 29개와 JpaEntityRepository 18개, 즉 절반이 DB 스키마 그 자체였다. infra는 지금 136개 전부가 package-private이라 이 저장소에서 접근 제어가 실제로 서 있는 유일한 자리이기도 하다. 정리를 위해 그걸 무르는 거래인데, 첫째 이유에서 이미 그 정리가 마이너스라는 게 나와 있었다. 두 이유가 각각 독립적으로 infra를 지웠다.
「하위 패키지에만 열 수는 없나」를 여기서 진지하게 팠고, 없다는 걸 확인했다. a.b가 a의 안이 아니라는 건 알고 있다고 생각했는데, 막상 물으니 근거를 못 댔다. 그래서 실제로 컴파일해봤다 — error: Base is not public in a; cannot be accessed from outside package. 검색 중에 package-protected라는 말이 나와서 package-private과 다른 수준인 줄 알았는데 같은 것의 다른 이름이었다. protected는 실재하지만 최상위 클래스에 못 붙고(error: modifier protected not allowed here) 상속 축이라 형제 패키지엔 무의미하다. JPMS는 모듈 간에만 작동하는데 이 프로젝트는 단일 모듈이다. 남는 건 ArchUnit인데 미도입이다. 중간 단계가 없고 되돌릴 장치도 없다는 게 확정되고 나서야 「열림 개수」가 안을 고르는 기준으로 승격했다.
infra를 다른 축으로 나누는 안 셋을 그 기준으로 줄줄이 떨어뜨렸다. 기술 축은 같은 테이블을 다루는 두 쪽이 서로를 못 보게 되고, 개념 폴더는 하위 테이블이 메인 Entity를 FK로 양방향 참조해서 어디를 잘라도 샌다. 하위 도메인 승격에서는 다른 발견이 하나 나왔다 — 개념 대부분이 application에 유스케이스가 0이었다. 유스케이스가 없으면 도메인이 아니라 테이블이다. 이 문장은 도메인 분해를 후속으로 미루는 근거가 됐다.
domain은 다른 이유로 빠졌고, 그게 이 글의 범위를 정했다. 접미사가 아예 없다 — SocialProviderSatisfaction, OriginUrl, StoredFile. 도메인 모델이 업무 이름을 갖는 건 정상이라 이게 문제가 아니다. 접미사로도 안 갈리고(미분류 다수) 개념으로도 안 묶인다(0%)는 두 숫자가 겹치는 자리는 「분류가 안 된 곳」이 아니라 「개념이 너무 많은 곳」이다. 처방이 다르니 이 표준이 답할 수 없다.
닫기(원칙 4)는 원래 이 표준의 일부가 아니었다. 분할 후 public 개수를 세다가 api 109개 중 6개만 닫혀 있다는 걸 봤다. 그런데 Service는 27개 중 26개, infra는 136개 전부가 닫혀 있었다. 같은 저장소에서 자리마다 정반대라면 하나는 설계고 하나는 관습이다. 이 저장소의 실제 규칙은 「패키지 경계를 넘는 것만 public」이었고 api만 그걸 안 지키고 있었다. 어차피 분할이 접근 제어를 건드리니 같이 바로잡기로 했다.
여기서 규칙 문안을 한 번 틀릴 뻔했다. 「지금 패키지 밖에서 안 쓰이면 닫는다」로 적으면 Request 37개가 전부 닫히는 것처럼 읽힌다. 그런데 그 37개가 안 쓰인다는 건 분할 전의 평평한 api/ 기준이고, 나누고 나면 controller/가 request/를 부르는 것이 경계를 넘는 일이 된다. 실측하니 Request는 6개, Response는 16개만 닫혔다. 기준은 「지금」이 아니라 「분할 후의 패키지」다. 이 한 단어 차이로 규칙이 거짓말을 할 뻔했다.
마지막에 낱개를 쓰는 폴더로 내리는 안(방법 B)이 나왔고, 열림 개수만 보면 이 안이 이겼다. 그런데 그러려면 service/ 안에 Service가 아닌 AdminAuthenticator가 들어가야 한다. 열림 개수를 기준으로 삼다 보니 애초에 왜 나누기로 했는지를 놓칠 뻔했다 — 목적은 가독성이었고, 폴더가 다시 혼합되면 그 목적이 없어진다. 기준이 목적을 밀어낸 순간을 알아채고 되돌린 자리다.
비용은 마지막에 실측했고, 예상보다 훨씬 컸다. 커밋 하나가 건드리는 폴더가 2.3에서 6.1로 늘고 최대는 6에서 15가 된다. 그리고 파일 2개 이상 건드린 커밋 27건이 한 건도 빠짐없이 접미사를 가로질렀다. 100%다. 그래도 분할을 택한 이유는 그 비용을 무는 쪽이 이미 어디를 고칠지 아는 사람이고, 혼합 폴더의 비용을 무는 쪽은 코드를 처음 보는 사람이라서다.
그리고 이 표준이 다 못 푸는 자리를 확인하고 끝냈다. socialprovider/api는 나눠도 19에서 15로만 준다. Response가 15개라서다. 접미사로 더 못 쪼개는 큰 폴더가 남는다는 뜻이고, 그게 도메인 분해를 여는 신호다.
정리
- 접미사 분할은
api와application에만 한다.api는controller·request·response,application은usecase·command·query·service infra는 나누지 않는다. 접미사가 이름 끝에 있어 알파벳 정렬이 이미 65%를 개념별로 묶어준다. 나누면 89개가 열리고 그중 47개가 DB 스키마다domain도 나누지 않는다. 접미사 자체가 없고 개념 묶임이 0%다. 여기 필요한 것은 도메인 분해다- 접미사 목록에 없으면 계층 루트에 남긴다.
misc같은 잡동사니 폴더를 만들지 않는다 - 크기와 무관하게 항상 나눈다. 임계값을 두면 그 선을 넘는 커밋이 구조 변경 커밋이 된다
- 나눈 뒤 패키지 밖에서 참조되지 않으면
public을 뗀다. 기준은 「지금」이 아니라 「분할 후의 패키지」다 - 확실히 닫히는 것은 행위다.
Controller18/18과Service26/27.Request·Response는 대부분 열린 채 남는다 - 자바에서
a.b는a의 밖이다. 하위 패키지에만 여는 중간 단계가 없고,protected·JPMS·ArchUnit 어느 쪽도 대안이 아니다 - 대가는 커밋 하나가 건드리는 폴더가 2.3에서 6.1로 느는 것이다. 아는 사람의 비용을 늘려 모르는 사람의 비용을 줄이는 거래다
후속으로 남긴 것
이 표준은 infra와 domain을 나누지 않기로 했는데, 그 두 계층의 큰 폴더가 작아지는 건 아니다. 처방이 다를 뿐이다.
그리고 나누기로 한 쪽에도 안 풀리는 자리가 남았다. socialprovider/api는 분할 후에도 15개다. api에서 파일 10개를 넘는 패키지가 4개에서 1개로 주는데, 그 1개가 이것이다.
남은 것은 도메인 분해다. socialprovider가 111개로 저장소의 20%, content가 75개로 13%다. 판정 기준의 후보 하나는 이미 나왔다 — 자기 유스케이스가 있는가. application이 0인 개념은 도메인이 아니라 테이블이다.
여기서 닫지 않고 열린 항목으로 남긴다. 이 표준을 적용한 뒤에 다시 재야 판단이 선다.
AI 코드 어시스턴트에 바로 적용하기
coding-standards/.claude/skills/suffix-package-split-standard/SKILL.md
---
name: suffix-package-split-standard
description: 계층 패키지 안을 접미사로 한 겹 더 나누는 규칙과 그때의 접근 제어. api·application에 새 클래스를 만들거나 옮길 때, public을 붙일지 정할 때, infra·domain을 나누자는 제안을 검토할 때 적용한다.
---
# 접미사 패키지 분할 표준
접미사 분할은 `api`와 `application`에만 한다. `infra`와 `domain`은 나누지 않는다.
## 무엇을 나누나
- `{도메인}/api`는 `controller`, `request`, `response` 세 하위 패키지로 나눈다.
- `{도메인}/application`은 `usecase`, `command`, `query`, `service` 네 하위 패키지로 나눈다.
- `{도메인}/infra`는 나누지 않는다. 접미사가 이름 끝에 있어 알파벳 정렬이 이미 업무 개념별로 묶어준다.
- `{도메인}/domain`은 나누지 않는다. 도메인 모델과 값 객체에는 접미사가 없다.
- `{도메인}/web`, `{도메인}/bulk`, `{도메인}/batch`, `common`도 나누지 않는다.
- 파일 개수로 나눌지 정하지 않는다. `api`·`application`이면 파일이 셋이어도 나눈다.
## 접미사 목록에 없는 파일
- 계층 루트에 그대로 둔다.
- `misc`, `common`, `support`, `util` 같은 수용 폴더를 만들지 않는다.
- 접미사가 애매하다는 이유로 기존 하위 패키지에 밀어 넣지 않는다. 폴더 이름과 내용물이 어긋난다.
## 나눈 뒤의 접근 제어
- 하위 패키지를 만들면 상위 패키지의 package-private 타입이 안 보인다. 자바에서 `a.b`는 `a`의 밖이다.
- `protected`로 우회할 수 없다. 최상위 클래스에 못 붙고, 붙어도 상속 축이라 형제 패키지에 무의미하다.
- **자기 패키지 밖에서 참조되지 않는 타입만** `public`을 떼고 package-private으로 둔다.
- 판정 기준은 분할 후의 패키지다. 분할 전에 안 쓰였다는 이유로 닫지 않는다.
- `Controller`는 package-private으로 둔다. 하위 패키지 밖에서 부르는 코드가 없다.
- `Service` 구현체는 package-private을 유지한다. usecase-interface-standard를 따른다.
- `Request`·`Response`는 대부분 `public`으로 남는다. `controller`가 다른 하위 패키지에서 이름을 부르기 때문이다. 일괄로 닫지 않는다.
- 계층 루트에 남긴 협력자를 하위 패키지의 클래스가 주입받으면 그 협력자는 `public`으로 연다.
- 열어야 하는 낱개 타입을 쓰는 쪽 하위 패키지로 옮겨 열림을 줄이지 않는다. 폴더가 혼합돼 분할 목적이 깨진다.
## 나누자는 제안이 왔을 때
- `infra`를 기술 축(`jpa`/`mybatis`)이나 업무 개념 폴더로 나누지 않는다. `Entity`와 Spring Data 인터페이스를 `public`으로 열게 된다.
- `infra`의 `Entity`·`JpaEntityRepository`·Mapper·Repository 구현체는 패키지 밖으로 내보내지 않는다. package-structure-standard를 따른다.
- 계층 폴더가 크다는 이유로 하위 도메인을 만들지 않는다. 자기 `application` 유스케이스가 없는 개념은 도메인이 아니라 테이블이다.
- `domain`이 크면 접미사가 아니라 도메인 분해로 푼다.
coding-standards/.github/instructions/suffix-package-split-standard.instructions.md
---
description: 계층 패키지 안을 접미사로 나누는 범위와 그때의 접근 제어
applyTo: "**/api/**/*.java, **/application/**/*.java"
---
# 접미사 패키지 분할 표준
- `{도메인}/api`는 `controller`, `request`, `response`로 나눈다.
- `{도메인}/application`은 `usecase`, `command`, `query`, `service`로 나눈다.
- `infra`와 `domain`은 나누지 않는다. `web`·`bulk`·`batch`·`common`도 나누지 않는다.
- 파일 개수로 나눌지 정하지 않는다. 임계값이 없다.
- 접미사 목록에 없는 파일은 계층 루트에 남긴다. `misc`·`support`·`util` 폴더를 만들지 않는다.
- 하위 패키지를 만들면 상위 패키지의 package-private 타입이 안 보인다. `protected`로 우회하지 않는다.
- 자기 패키지 밖에서 참조되지 않는 타입만 `public`을 떼고 package-private으로 둔다.
- 판정 기준은 분할 후의 패키지다. 분할 전에 안 쓰였다는 이유로 닫지 않는다.
- `Controller`는 package-private으로 둔다. `Service` 구현체도 package-private을 유지한다.
- `Request`·`Response`는 `controller`가 다른 하위 패키지에서 참조하므로 대부분 `public`으로 남는다. 일괄로 닫지 않는다.
- 계층 루트에 남긴 협력자를 하위 패키지의 클래스가 주입받으면 그 협력자는 `public`으로 연다.
- 열어야 하는 타입을 쓰는 쪽 하위 패키지로 옮기지 않는다. 폴더가 혼합된다.
- `infra`를 기술 축이나 업무 개념 폴더로 나누자는 제안을 받아들이지 않는다.
- 계층 폴더가 크다는 이유로 하위 도메인을 만들지 않는다. `application` 유스케이스가 없는 개념은 도메인이 아니다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기