패키지 구조 표준은 도메인 안을 네 겹으로 나누고 들어오는 칸을 api라고 불렀다. 그때는 컨트롤러가 한 종류라고 전제하고 있었다.

그런데 컨트롤러는 두 종류다. JSON을 내보내는 API 컨트롤러가 있고, 템플릿 엔진으로 만든 HTML 화면을 내보내는 Web 컨트롤러가 있다. 둘은 같은 자리에 살지만 적용되는 규칙이 정반대다.

// 안 A — 지금 구조. 둘이 같은 패키지에 산다
order/api/OrderApiController.java   // HttpApiResponse로 감싼다
order/api/OrderWebController.java   // 감쌀 수 없다. HTML이다

// 안 B — 출력 형식으로 자리를 가른다
order/api/OrderController.java
order/web/OrderWebController.java

걸려 있는 건 이름의 예쁨이 아니다. 이 저장소의 규칙은 applyTo 글로브로 폴더를 겨눠서 붙는다. 자리가 안 갈라지면 “응답은 HttpApiResponse로 감싼다”가 Web 컨트롤러에도 그대로 붙는다. 그리고 아무 신호도 안 난다.


먼저: 계층 이름이 어긋나는 자리는 두 표면에 있었다

이 글은 원래 다른 문제에서 출발했다. 「아직 정하지 않은 것」 장부의 2번 — 같은 자리를 도메인 드리븐은 api라 부르고 3계층은 Controller라 부른다는 모순이다.

인증 주체 표준의 원칙 1은 “LoginUser는 api에서 죽는다”인데, 이 글은 3계층 탭에도 올라가 있다. 3계층 프로젝트에는 api 폴더가 없다. 시키는 대로 하려는 순간 만들 폴더가 없어서 막힌다.

범위는 재놨었다. 양쪽에 걸치는 글 중 계층 이름을 쓰는 건 2편, 총 17군데. 그런데 그건 산문만 센 것이었다.

# coding-standards/.github/instructions/authenticated-user-standard.instructions.md
applyTo: "**/api/**/*.java, **/common/**/*.java"

한 줄씩 따라가기 — applyTo: 뒤의 문자열이 이 규칙을 적용할 파일을 고르는 패턴이다. **는 그 자리에 폴더가 몇 겹 있든 전부 건너뛴다는 뜻이고(a/api도, a/b/c/api도 모두 걸린다), *.java는 확장자가 .java인 파일만 고른다는 뜻이다. 그래서 **/api/**/*.java는 어디에 있든 이름이 api인 폴더 아래의 모든 자바 파일을 가리킨다. 쉼표로 이어 붙인 **/common/**/*.java는 common 폴더에도 같은 규칙을 적용하겠다는 뜻이다.

이 줄은 산문이 아니라 폴더를 찾는 패턴이다. 3계층 프로젝트에 api 폴더가 없으면 이 규칙은 아무 파일에도 안 붙는다. 에러도 경고도 없이 그냥 없는 것처럼 동작한다. layered를 단 표준 9편 중 4편이 이 상태였다.

표준 applyTo 3계층에 있는 폴더인가
authenticated-user **/api/**, **/common/** 없다
swagger-documentation **/api/** 없다
service-assert **/application/** 없다
exception-taxonomy **/infra/**, **/common/** 없다

세는 방법을 한 번 틀렸다. 처음엔 본문 전체에서 layered를 grep해 15편이 나왔는데, 그건 layered-dto-naming-standard라는 링크 문자열까지 센 것이다. 소속은 front matter의 architectures:에만 있다. 실제로는 9편이고 그중 4편이었다.

산문은 얼버무릴 수 있지만 글로브는 못 얼버무린다. 실재하는 폴더 이름을 대야 한다. 그래서 이 모순은 “글을 어떻게 쓸 것인가”가 아니라 “3계층의 폴더 이름을 무엇으로 정할 것인가” 를 먼저 답해야 닫히는 문제였다.


원칙 1. 컨트롤러는 출력 형식으로 갈라 api와 web 형제 패키지에 둔다

3계층 폴더 이름을 정하려던 자리에서 컨트롤러가 두 종류라는 사실이 나왔고, 그게 api라는 이름 자체를 흔들었다. HTML 화면을 내보내는 컨트롤러도 api/ 안에 산다면 api는 자기 내용물을 틀리게 부르는 이름이다.

정해야 했던 건 이거였다 — 컨트롤러 두 종류를 패키지 구조의 어디서 가를 것인가.

안 A — api 안에서 한 겹 더 나눈다

order/api/rest/    OrderRestController, XxxRequest, XxxResponse
order/api/web/     OrderWebController

얻는 것은 기존 구조를 안 건드린다는 점이다. api라는 계층 이름이 그대로 살고 패키지 구조 표준의 “네 겹” 서술도 그대로다. 글로브는 **/api/rest/**로 좁히면 정확해진다.

버린 이유는 api/web이 자기모순이라서다. “API인데 웹”이 무슨 뜻인지 6개월 뒤에 안 읽힌다. api가 계층 이름과 종류 이름 두 역할을 겸하는 순간 생기는 문제고, 겹의 개수를 늘려도 그 겸직은 안 풀린다.

안 B — 계층 이름을 presentation으로 바꾸고 그 아래서 나눈다

order/presentation/api/
order/presentation/web/

얻는 것은 이름이 전부 맞는다는 것이다. 계층 이름(presentation)과 종류 이름(api/web)이 서로 겹치지 않는다.

버린 이유는 파급이 제일 큰데 얻는 게 이름의 정확함뿐이라서다. **/api/** 글로브를 쓰는 표준 5편, 패키지 구조 표준의 구조도와 mermaid, _data의 계층 id, api를 계층 이름으로 부르는 본문 전부가 바뀐다. 그리고 3계층이 노리는 게 단순함인데 order/presentation/api/는 도메인 아래 세 겹이다.

안 C — 형제 패키지 둘로 뽑는다

order/api/         JSON을 내보낸다
order/web/         HTML을 내보낸다
order/application/
order/domain/
order/infra/

얻는 것은 자리 이름이 곧 출력 형식이라는 점이다. 폴더를 보면 무엇이 나가는지 알고, 글로브도 **/api/** 하나로 정확히 겨눈다. 덤이 하나 더 있는데, 3계층도 들어오는 칸을 같은 이름으로 쓸 수 있다는 것이다.

order/api/          order/api/
order/web/          order/web/
order/application/  order/service/
order/domain/       order/repository/
order/infra/

들어오는 두 칸의 이름이 양쪽에서 같아진다.

안 D — 나누지 않고 클래스 이름으로만 구분한다

order/api/OrderApiController.java
order/api/OrderWebController.java

얻는 것은 구조 변경이 0이라는 점이다.

버린 이유는 글로브가 못 가른다는 것이다. applyTo: "**/api/**"는 두 클래스를 똑같이 집는다. Copilot이 Web 컨트롤러를 편집할 때 “응답은 HttpApiResponse로 감싼다”를 그대로 먹인다. 규칙이 틀린 자리에 붙는데 아무 신호가 없다 — 3계층에서 글로브가 조용히 안 붙는 고장과 정확히 대칭이다.

골랐다 — 안 C. 안 A와 안 B는 종류를 계층 안에 숨겨서 글로브가 한 겹 더 파고들게 만들고, 안 D는 글로브를 아예 못 쓰게 만든다. 안 C만 자리와 규칙 적용 대상이 1:1로 맞는다. 대신 패키지 구조 표준의 “네 겹”이 더 이상 모든 도메인에 맞지 않는 것을 감수한다.

여기서 가른 것은 형제 패키지의 개수이고, api 하나의 안을 더 나눌지는 따로 정했다. api는 controller·request·response로 나누고 web은 나누지 않는다 — 접미사 패키지 분할 표준에 있다. 글로브는 그래도 **/api/** 하나로 충분하다. 재귀 패턴이라 하위 폴더까지 그대로 집는다.


원칙 2. web은 새 계층이 아니라 들어오는 계층의 두 번째 패키지다

형제 패키지로 뽑기로 했으니 그 형제가 계층인지 패키지인지를 정해야 한다. 이걸 안 정하면 3계층의 “책임을 셋으로만 나눈다”가 깨지는지 아닌지부터 답이 안 나온다.

bulk와 batch를 뽑을 때의 근거를 먼저 확인했다. 그때는 “부를 수 있느냐” 가 기준이었다 — bulk를 infra에 두면 같은 패키지의 Repository 구현체가 벌크 Mapper를 주입받을 수 있고, Service는 못 부르는데 구현체는 부를 수 있는 구멍이 생긴다.

그 근거는 여기 안 온다. API 컨트롤러와 Web 컨트롤러는 서로 부를 일이 없다. API 컨트롤러는 application의 UseCase를 부르고, Web 컨트롤러는 템플릿 이름만 돌려주므로 아무것도 부르지 않는다(아래 원칙 6). web이 무언가를 부른다면 api와 같은 방향(application)뿐이다. 둘 사이에 새 화살표가 생기지 않으니 계층을 하나 더 세울 근거가 없다. 다른 건 나가는 것이 JSON이냐 HTML이냐뿐이다.

그래서 이렇게 정한다.

graph LR API["api
Controller, Request, Response"] --> APP["application
UseCase, Service"] WEB["web
WebController (템플릿 이름만)"] -.->|"부르지 않음"| APP APP --> DOM["domain
Domain, Repository 인터페이스"] INF["infra
Entity, Mapper, 구현체"] --> DOM style API fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style WEB fill:#2d3748,stroke:#9f7aea,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

다이어그램의 infra 칸에 있는 Entity는 DB 테이블 한 줄을 자바 클래스로 그대로 옮겨 그린 것이다 — domain의 Domain과 짝을 이루되, “DB에 어떻게 저장할지”만 맡는다.

계층은 그대로다. 들어오는 계층이 패키지를 둘 가질 수 있을 뿐이다. 그러면 셋으로 나눈 3계층도 여전히 셋이고, 도메인 드리븐도 여전히 넷이다.

패키지 개수는 bulk·batch 때와 같은 문장으로 처리한다 — 기본형은 넷이고, HTML 화면을 내보내는 도메인만 web을 더 갖는다. JSON만 내보내는 도메인에 빈 web/ 폴더를 만들지 않는다.


원칙 3. 3계층도 도메인을 먼저 나누고 그 안에 계층을 둔다

3계층의 폴더 구조는 이렇게 간다.

order/
├── api/          Controller (JSON)
├── web/          WebController (HTML)
├── service/      Service
└── repository/   Repository, DAO, Mapper

도메인 드리븐과 패키징 원칙이 같다 — 도메인을 먼저 나누고, 계층은 그 안에 둔다. 차이는 그 안이 몇 겹이냐뿐이다. 3계층에는 domain이 없고, 그래서 application/infra로 의존을 뒤집는 일도 없다. 호출 방향과 의존 방향이 그냥 같다.

여기서 선택지는 하나였다. 흔한 3계층은 최상위를 계층으로 먼저 나누지만(controller/order, service/order), 그 안은 검토하지 않았다. 도메인 드리븐에서 이미 도메인 먼저로 정했고, 두 구조를 오가는 사람이 매번 반대로 생각해야 할 이유를 못 찾았기 때문이다. 검토를 안 했다는 사실을 여기 적어둔다 — 나중에 3계층 프로젝트에서 이 순서가 걸리면 그때 다시 연다.

3계층에서 노리는 것은 단순함이다. 계층을 더 쪼개거나 인터페이스로 뒤집지 않는다. 그래서 domain을 전제하는 표준(Repository 설계 표준, Domain 영속화 표준)은 3계층에서 문장 자체가 성립하지 않는다. 이 둘은 이미 ddd만 달고 있어서 고칠 것이 없다 — 소속 기준이 폴더를 정하기 전부터 이미 걸러주고 있었다.

다만 그 기준이 글을 통째로 걸러낸다는 점은 남는다. Repository 설계 표준의 원칙 다섯 중 셋은 3계층에서도 그대로 통하는데, 나머지 둘이 domain을 쓰기 때문에 글 전체가 도메인 드리븐 전용이 된다. 이건 이 글에서 닫지 않고 열린 항목으로 남긴다.


원칙 4. 양쪽이 다르게 부르는 자리는 계층 이름 대신 클래스 이름으로 쓴다

원칙 1과 3을 정하고 나니 api가 들어간 자리는 저절로 닫혔다. 3계층에도 api 폴더가 생겼으므로 “LoginUser는 api에서 죽는다”가 양쪽에서 그대로 맞는 문장이 된다. 헤딩도 안 고친다.

남은 건 application 5회와 infra 2회, 총 7군데다. 이건 여전히 3계층에 없는 이름이다.

안 A — 중립어로 다시 쓴다. “application으로는” → “유스케이스를 조율하는 자리로는”. 양쪽에서 읽히지만 중립어가 사람마다 달라지면 헤딩과 본문이 곧 색인이라 grep이 깨진다.

안 B — application(Service)처럼 병기한다. 양쪽이 자기 이름을 보지만 서술이 두 벌 되고, 컨트롤러가 두 종류라는 걸 안 지금은 부정확한 이름을 두 벌로 늘리는 셈이다.

안 C — 계층어를 쓰는 글에서 layered를 뺀다. grep으로 자동 검사까지 되지만 3계층에서도 통하는 규칙을 3계층이 못 받게 된다. 인증 주체 표준은 3계층에서도 그대로 맞는 규칙이다.

안 D — 글을 안 고치고 대응표를 한 곳에 둔다. 수정이 0이지만 검색으로 글에 바로 들어온 사람은 그 표를 안 거친다. 이 저장소가 「소속은 글이 든다」로 이미 한 번 기각한 형태다.

안 E — 3계층 폴더도 application/infra로 부른다. api처럼 이름을 맞춰버리면 7군데를 안 고쳐도 된다. 버린 이유는 infra가 “domain의 반대편”이라는 뜻으로 지은 이름이라서다. 3계층에는 domain이 없다. 짝이 없는 이름을 가져오면 6개월 뒤에 “여기 domain은 어디 있지”를 찾게 된다. application도 같은 이유로 domain과 짝일 때만 의미가 산다.

골랐다 — 안 A. 다만 중립어를 지어내지 않는다. 그 자리에 사는 클래스의 접미사(Service·Repository)는 이미 양쪽이 같다. 아래 표처럼 Service는 클래스 이름까지 같고, Repository는 도메인 드리븐에서 XxxRepositoryImpl, 3계층에서 XxxRepository로 끝이 조금 다르지만 둘 다 「Repository 구현체」로 부르면 맞는다.

지금 도메인 드리븐 3계층 바꿔 쓸 말
application application/XxxService service/XxxService Service
infra infra/XxxRepositoryImpl repository/XxxRepository Repository 구현체
// 지금 — 3계층에 없는 폴더 이름이다
`LoginUser`는 `api`에서 죽는다. `application`으로는 `Long userId`만 간다

// 바꾸면 — 양쪽 다 맞는다
`LoginUser`는 `api`에서 죽는다. `Service`로는 `Long userId`만 간다

이게 안 A의 약점을 지운다. 중립어가 표류할 걱정이 없는 이유는 Service·Repository가 지어낸 말이 아니라 표준이 이미 못 박아둔 클래스 이름이기 때문이다. 대가는 계층을 가리키던 말이 클래스를 가리키게 되면서 “어느 패키지에 두라”는 정보가 그 문장에서 빠진다는 것이다. 그건 패키지 구조 표준이 답할 일이라 여기서 겹쳐 말하지 않는다.


원칙 5. applyTo 글로브는 두 아키텍처의 실제 폴더를 모두 적는다

3계층 폴더 이름이 정해졌으니 글로브가 겨눌 과녁이 생겼다. 남은 건 한 파일에서 양쪽을 어떻게 겨누느냐다.

안 A — 글로브를 **/*.java로 넓힌다. 어느 구조에서든 붙는다. 버린 이유는 그러면 규칙이 전혀 상관없는 파일에도 붙는다는 것이다. Repository 구현체를 고치는데 Controller 규칙이 따라온다. 지금 글로브를 좁게 쓰는 이유가 그거였다.

안 B — 아키텍처별로 파일을 두 벌 만든다. 각자 자기 경로만 겨눈다. 버린 이유는 같은 규칙이 두 파일이 되면서 한쪽만 고치는 순간 어긋난다는 것이다. 이 저장소가 글·SKILL.md·instructions.md를 항상 같은 커밋에 넣는 이유와 같은 문제를 하나 더 만든다.

골랐다 — 양쪽 경로를 한 줄에 나열한다. applyTo는 쉼표로 여러 패턴을 받으므로 파일을 늘리지 않고 양쪽을 겨눌 수 있다.

# 지금 — 3계층에서 아무 파일에도 안 붙는다
applyTo: "**/application/**/*.java"

# 바꾸면 — 양쪽에서 붙는다
applyTo: "**/application/**/*.java, **/service/**/*.java"

대응은 이렇게 된다.

도메인 드리븐 3계층
**/api/** **/api/** (같다)
**/web/** **/web/** (같다)
**/application/** **/service/**
**/infra/** **/repository/**
**/domain/** 없다

**/domain/**만 겨누는 표준은 3계층에 붙을 자리가 없다. 그건 글로브로 해결할 문제가 아니라 소속(architectures)을 확인하라는 신호다. Repository 설계 표준과 Domain 영속화 표준이 그 경우인데, 둘 다 이미 ddd만 달고 있어서 경로를 늘릴 일이 없었다. 글로브와 소속이 어긋나면 둘 중 하나가 틀린 것이고, 먼저 보는 쪽은 소속이다.

이 규칙으로 실제로 고친 것은 service-assert와 exception-taxonomy 둘이다. swagger-documentation과 authenticated-user는 **/api/**만 겨누고 있어서 원칙 1이 3계층에 api 폴더를 만든 것만으로 저절로 맞았다.

본문 한 벌과 글로브 양쪽 경로는 짝이다. 파일을 두 벌로 가르면 각 파일이 자기 아키텍처 이름을 쓰면 되니 원칙 4가 할 일이 없어지고, 계층 호칭 문제가 같이 되살아난다. 하나만 되돌리는 게 아니라 둘이 함께 무너진다.

대가는 글로브 줄이 길어지고, 아키텍처를 새로 열 때마다 몇 편이 아니라 전 편의 applyTo를 다시 훑어야 한다는 것이다. 아키텍처가 둘인 지금은 감당되는 비용이다. 셋째가 생기는 시점이 파일을 가를지 다시 판단할 자리다 — 그때는 본문 계층 호칭도 함께 봐야 한다.


원칙 6. web 컨트롤러는 템플릿 이름만 돌려주고, 데이터는 화면 JS가 api로 가져간다

원칙 1~5로 web/이라는 자리는 생겼는데 그 안에서 무엇을 하라는 규칙이 하나도 없었다. 이 원칙은 그 빈틈을 2026-10-21에 닫은 것이다.

빈틈의 모양은 이랬다. 지금까지의 응답 규칙은 전부 JSON 전제다. 공통 봉투는 HTML을 감쌀 수 없고, 실패를 모아 응답으로 바꾸는 @RestControllerAdvice는 화면(View)을 돌려주지 못한다. 그래서 web 컨트롤러가 실패하면 어떤 모양으로 답하는지, 화면에 넘길 데이터는 어떤 타입으로 만드는지가 통째로 비어 있었다. 처음 이 빈틈을 찾았을 때는 서버가 데이터를 채운 화면(SSR)을 짠 프로젝트가 없어서 미뤘다 — 규칙을 세워도 맞는지 돌려 볼 곳이 없었다.

미룬 것을 다시 열면서 실제 코드를 먼저 셌다(2026-10-21). farm-predict의 kamis-api-batch에 관리자 화면용 web 컨트롤러가 7개(254줄) 있었다. 컨트롤러 하나가 메뉴 한 묶음을 맡고 화면 하나마다 메서드가 하나씩이라, 7개에 메서드가 16개다. 16개 전부가 템플릿 이름 하나만 돌려주고 있었다. 화면에 데이터를 넘기는 Model(아래 안 A에서 본다)을 쓴 곳은 0건이었다. 화면의 입력 칸(폼)을 채워 보내면 브라우저가 그 값을 POST 요청으로 서버에 보내는데, 그 요청을 받아 처리하는 web 컨트롤러 메서드(핸들러)도 0건이었다. 다른 한 프로젝트에는 web 컨트롤러가 아예 없었다.

@Controller
public class DashboardAdminWebController {

    @GetMapping({"", "/", "/dashboard"})
    public String dashboard() {
        return "admin/dashboard";
    }
}

한 줄씩 따라가기. 이 코드는 farm-predict의 실제 컨트롤러에서 활동 기록용 애노테이션만 뺀 것이다. @Controller는 JSON이 아니라 화면을 돌려주는 컨트롤러라는 표시다. @GetMapping은 괄호 안 주소로 GET 요청이 오면 이 메서드를 부르라는 뜻이다. 메서드가 돌려주는 문자열 "admin/dashboard"는 데이터가 아니라 템플릿 파일의 이름이다. 스프링은 templates/admin/dashboard.html을 찾아 HTML로 만들어 브라우저에 보낸다.

넘길 값이 하나도 없는데 템플릿 엔진(Thymeleaf)이 하는 일은 조각을 합치는 것이다. farm-predict의 화면 파일은 본문만 들고 있고, 첫 줄에서 공통 레이아웃 admin/layout/main.html을 가리킨다. 엔진은 그 레이아웃의 머리말·메뉴·꼬리말 조각에 화면 본문을 끼워 HTML 한 장으로 만든다. 화면 제목처럼 화면마다 다른 글자도 컨트롤러가 넘기지 않고 화면 파일에 직접 적혀 있다(th:with="pageTitle='메뉴 설정'").

화면에 들어갈 숫자나 목록은 여기서 하나도 넘기지 않는다. 화면 파일 끝에 <script th:src="@{/js/kamis/settings/menu.js}"> 같은 줄이 있어서, 브라우저가 서버에 정적 파일로 놓인 자바스크립트를 따로 받아 실행한다. 그 코드가 api 컨트롤러(/api/v1/admin/menu)를 불러 JSON으로 받아 와 빈자리를 채운다. 관리자 화면 템플릿 안에 자바스크립트를 직접 적은 곳은 없었다.

정해야 했던 건 이거였다 — web 컨트롤러가 화면에 데이터를 실어 보낼 것인가.

안 A — 서버가 데이터를 채운 화면(SSR) 규칙을 세운다. 컨트롤러가 UseCase를 불러 결과를 Model에 싣고, 템플릿이 그 데이터를 채워 완성된 HTML을 만든다. Model은 컨트롤러 메서드의 인자로 받아 화면에 넘길 값을 담는 그릇이다.

// 안 A — 이렇게 하지 않는다
@GetMapping("/kamis/settings/menu")
public String menu(Model model) {
    model.addAttribute("menus", menuUseCase.findAll());
    return "admin/kamis/settings/menu";
}

Model model은 스프링이 메서드를 부를 때 빈 그릇을 넣어 준다. addAttribute("menus", ...)는 UseCase가 돌려준 메뉴 목록을 menus라는 이름으로 그릇에 담는다. 템플릿은 그 이름으로 목록을 꺼내 HTML에 채운다. 얻는 것은 서버가 데이터까지 채운 화면을 한 번에 보낸다는 것이다. 이 안을 고르면 화면에 넘기는 데이터의 타입, 실패했을 때 보여 줄 에러 화면, 폼 입력 검증이 틀렸을 때 다시 그리는 방법까지 web 전용 규칙이 한 벌 더 필요하다. 버린 이유는 그렇게 짠 프로젝트가 없어서 그 규칙이 맞는지 확인할 곳이 없다는 것이다. 지금 세우면 한 번도 돌려 보지 않은 규칙을 지어내는 일이 된다.

안 B — 빈 화면(셸) 페이지. web 컨트롤러는 템플릿 이름만 돌려준다. 서버가 보내는 HTML은 데이터가 빠진 빈 화면이고, 데이터는 화면 JS가 api 컨트롤러를 불러 가져간다. 화면을 브라우저가 채우는 셈이라 CSR에 가깝다.

골랐다 — 안 B. 안 A는 확인할 곳 없는 규칙을 한 벌 새로 만들어야 하지만, 안 B에서는 web 컨트롤러가 실패할 일 자체가 거의 없다. 남는 것은 템플릿 파일 이름을 잘못 적었거나 파일이 없는 경우 정도이고, 그건 스프링 부트의 기본 오류 화면(/error)이 맡는다. 데이터를 가져오다 실패하는 것도, 입력이 틀려 거절되는 것도 전부 화면 JS가 따로 부른 api 요청에서 일어난다. 그래서 실패 처리·공통 봉투·입력 검증은 그 api 요청을 받는 api 규칙이 맡는다. web 응답 자체는 HTML이라 봉투가 없다 — 도입에서 “감쌀 수 없다”고 한 그대로다. 그리고 실제로 있는 web 컨트롤러 7개가 모두 이미 이 모양이라 지금 코드를 하나도 안 바꾼다. 대신 서버가 데이터를 채운 HTML을 보내야 하는 화면은 이 규칙으로는 못 만든다. 그런 화면이 필요한 프로젝트가 생기면 그때 안 A를 다시 연다.

원칙 2의 그림에서 web 칸에 적혀 있던 “View 모델”도 이 원칙으로 지웠다. Model에 업무 데이터를 싣지 않으니 화면에 넘길 타입이 생기지 않는다. 같은 이유로 web에서 application으로 가던 화살표도 점선(부르지 않음)으로 바꿨다.


판단 기준 정리

질문 답 결론
JSON 컨트롤러는 어디에? {도메인}/api 양쪽 아키텍처 공통
HTML 화면 컨트롤러는 어디에? {도메인}/web 형제 패키지. 쓰는 도메인만 만든다
web 컨트롤러는 무엇을 돌려주나? 템플릿 이름만 (빈 화면 페이지) Model에 업무 데이터를 싣지 않는다. 데이터는 화면 JS가 api로 가져간다
실패 처리·봉투·검증은 누가 맡나? 화면 JS가 부른 api 요청의 api 규칙 web 응답에는 봉투가 없다. web 전용 규칙을 만들지 않는다
web은 새 계층인가? 아니다 application을 부르지 않고, 부른다면 api와 같은 방향뿐이다. 계층 수는 안 변한다
3계층의 폴더는? api web service repository 도메인 먼저, 계층은 그 안
글에서 application이라고 써도 되나? 안 된다 Service로 쓴다
글에서 infra라고 써도 되나? 안 된다 Repository 구현체로 쓴다
글에서 api라고 써도 되나? 된다 양쪽에 같은 이름의 폴더가 있다
applyTo에 무엇을 적나? 양쪽 경로를 쉼표로 나열 파일을 두 벌 만들지 않는다
**/domain/**만 겨누는 표준은? 소속에서 layered를 뺀다 글로브로 못 메운다

이 표준을 정하기까지

시작은 못 고르고 멈춰 있던 장부 2번이었다. 같은 자리를 도메인 드리븐은 api, 3계층은 Controller라 부르는 모순이었고, 안을 넷까지 뽑아놓고 그중 하나를 고르지 못한 채로 남아 있었다. 이번에 다시 열었다.

첫 발견은 범위를 잘못 쟀다는 것이었다. 장부에는 “17군데”라고 적혀 있었는데 그건 산문만 센 것이다. instructions.md의 applyTo 글로브가 별개의 표면이었고, 거기 DDD 패키지 경로가 박혀 있었다. 배지가 어긋나는 것보다 나쁜 고장이다 — 배지는 사람이 보고 이상하다고 느끼지만, 글로브는 아무 파일에도 안 붙고 아무 신호도 안 낸다.

그리고 그 범위도 한 번 더 틀렸다. 처음 센 “15편 중 7편”은 본문 전체에서 layered를 grep한 결과였는데, 거기에 layered-dto-naming-standard라는 링크 문자열이 섞여 있었다. front matter만 세니 9편 중 4편이었다. 범위를 잘못 재서 시작한 문제를 다시 잘못 쟀다는 게 이 대화에서 제일 뼈아픈 대목이다. 그래서 지금은 소속을 셀 때 본문이 아니라 architectures: 블록만 본다.

그리고 그 글로브는 안 A~D 중 무엇을 골라도 안 닫힌다는 걸 알았다. 산문은 중립어로 얼버무릴 수 있지만 글로브는 실재하는 폴더 이름을 대야 한다. 그런데 3계층의 폴더 이름을 정한 적이 없었다. 모순을 닫으려면 먼저 폴더를 정해야 했다. 문제가 한 칸 뒤로 밀렸다.

폴더를 정하려다 컨트롤러가 두 종류라는 사실이 나왔다. JSON을 내보내는 API 컨트롤러와 템플릿 엔진으로 HTML을 내보내는 Web 컨트롤러다. 그리고 이 구분은 3계층만의 것이 아니라 도메인 드리븐에도 똑같이 있다. 그 순간 api는 “양쪽이 다르게 부르는 같은 자리”가 아니라 한쪽에서도 정확하지 않은 이름이 됐다. 병기(안 B)가 여기서 죽었다 — 부정확한 이름을 두 벌로 늘리는 일이 되기 때문이다.

자리를 나눌 근거를 기존 결정에서 찾아봤는데 안 맞았다. bulk를 형제로 뽑은 근거는 “같은 패키지면 부를 수 있다”였다. API 컨트롤러와 Web 컨트롤러는 서로 부를 일이 없으니 그 근거는 안 온다. 대신 다른 근거가 있었다 — 적용되는 규칙이 갈린다. 공통 봉투·ErrorCode·조건부 GET은 전부 JSON 쪽 규칙이고, 이 저장소에서 규칙은 글로브로 폴더를 겨눠 붙는다. 자리가 안 갈라지면 글로브가 못 겨눈다. 안 D가 여기서 죽었다.

형제 패키지(안 C)를 고르자 처음 문제의 절반이 저절로 닫혔다. 3계층에도 api 폴더가 생기니 “LoginUser는 api에서 죽는다”가 양쪽에서 맞는 문장이 됐다. 모순을 풀려고 고른 게 아니라 컨트롤러를 가르려고 고른 것인데 모순이 따라 닫혔다. 이건 예상 못 한 수확이었다.

남은 7군데의 중립어는 지어낼 필요가 없었다. application과 infra를 무슨 말로 바꿀지 고민하다가, 그 자리에 사는 클래스의 접미사가 이미 양쪽에서 같다는 걸 알았다 — Service와 Repository다(구현체 이름은 도메인 드리븐이 XxxRepositoryImpl, 3계층이 XxxRepository다). 안 A의 유일한 약점이 “중립어가 표류하면 grep이 깨진다”였는데, 표준이 이미 못 박아둔 클래스 이름을 쓰면 표류할 데가 없다.

3계층 폴더를 application/infra로 부르는 길(안 E)도 잠깐 열렸다. 이름을 맞춰버리면 7군데를 안 고쳐도 된다. 버린 이유는 infra가 “domain의 반대편”이라는 뜻으로 지은 이름이라서다. 3계층에는 domain이 없다. 짝 없는 이름을 가져오면 없는 짝을 찾게 된다.

마지막에 이 결정이 만든 빈틈을 확인했다. web/이라는 자리는 생겼는데 그 안에서 무엇을 하라는 규칙이 하나도 없다. SSR·템플릿 엔진은 표준 24개와 SKILL.md 20개 전체에서 0건이다. 공통 봉투는 HTML을 못 감싸고 @RestControllerAdvice는 View를 못 돌려준다. 이건 여기서 닫을 수 없어서 열린 항목으로 남긴다.

두 달 반 뒤(2026-10-21), 그 빈틈을 실제 코드를 세어서 닫았다. SSR 프로젝트가 생기길 기다리는 대신 이미 있는 web 컨트롤러를 먼저 봤다. farm-predict의 7개가 전부 템플릿 이름만 돌려주고 있었고, Model도 폼 POST도 0건이었다. web에 규칙이 없어도 괜찮았던 이유가 거기 있었다 — web이 아무 데이터도 다루지 않으니 실패할 자리가 거의 없었다. 남는 것은 템플릿 파일이 없는 경우 정도이고 스프링 부트의 기본 오류 화면이 맡는다. 그 모양을 빈 화면(셸) 페이지 규칙으로 올렸다(원칙 6). 서버가 데이터를 채운 화면(SSR)의 규칙은 짠 프로젝트가 없어 확인할 곳이 없으므로 세우지 않았고, 필요한 프로젝트가 생기면 다시 연다.


정리

  • 컨트롤러는 출력 형식으로 가른다. JSON은 {도메인}/api, HTML은 {도메인}/web
  • web은 새 계층이 아니다. application을 부르지 않고, 부른다면 api와 같은 방향뿐인 형제 패키지다. 쓰는 도메인만 만든다
  • web 컨트롤러는 템플릿 이름만 돌려준다(빈 화면 페이지). 데이터는 화면 JS가 api로 가져가므로 실패·봉투·검증은 그 api 요청에서 일어나 api 규칙이 맡는다. web 응답 자체에는 봉투가 없다
  • 3계층도 도메인을 먼저 나눈다. api web service repository. domain이 없으므로 의존을 뒤집지 않는다
  • 글에서는 application·infra를 쓰지 않는다. Service·Repository 구현체로 쓴다. 클래스 접미사는 양쪽이 이미 같다
  • api는 그대로 쓴다. 양쪽에 같은 이름의 폴더가 생겼다
  • applyTo는 양쪽 경로를 쉼표로 나열한다. 파일을 두 벌 만들지 않는다
  • **/domain/**만 겨누는 표준은 소속이 틀린 것이다. 글로브로 못 메운다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기