Coding Standards
HTML 요청부터 데이터베이스까지, 프로젝트의 전체 흐름과 계층별 개발 규칙을 확인합니다.
표준은 아키텍처마다 따로 둔다.
도메인 드리븐에서 정한 규칙이 3계층에서도 통한다는 보장이 없기 때문이다 —
Domain이 없는 구조에 「애그리거트 경계」를 들이밀 수는 없다.
아래 탭에서 아키텍처를 고르면 그 구조의 흐름과 표준만 보인다.
대상은 Spring Boot로 만드는 JSON API다. 서버가 HTML을 그리지 않기 때문에 화면 렌더링은 클라이언트가 맡고, 서버 쪽 표준은 진입점부터 시작한다. 각 탭의 표준은 서로 이어져 있고, 정한 순서대로 나열했다 — 뒤의 글은 앞의 글에서 정한 것을 전제로 삼는다.
깔려 있는 조건이 둘 더 있다. 여기서 코드를 주로 쓰는 것은 사람이 아니다 — Claude와 Codex와 Copilot이 같은 저장소에 파일을 직접 쓴다. 규칙을 도구 수만큼 배포하는 것도, 포맷을 도구로 강제하는 것도, 마이그레이션 파일명을 타임스탬프로 짓는 것도 여기서 갈렸다. 그리고 서비스는 하나고 인스턴스도 하나다. 조건이 다르면 그 자리에서 판단이 갈리므로, 갈리는 표준은 「먼저」 절에 그 조건과 조건이 뒤집혔을 때 어떻게 되는지를 적어 둔다. 갈리지 않는 표준에는 적지 않는다.
도메인 드리븐 아키텍처
최상위를 계층이 아니라 도메인으로 나누고, 도메인 하나가 api · application · domain · infra 네 겹을 갖는다
구조를 우리가 정할 수 있는 프로젝트에서 쓴다.
요청 한 사이클 — 그래프
사용자가 버튼을 누른 순간부터 데이터가 화면으로 돌아올 때까지. 계층을 누르면 아래 목록이 그 계층 표준만 남긴다.
- Client HTTP Request
- api · web XxxCommand / XxxQuery
- application XxxDomain
- domain XxxRepository 호출
- infra SQL
- Database
호출 방향과 의존 방향은 다르다. 요청은 위 그림대로 api에서 infra 쪽으로 흐르지만, 컴파일 의존은 infra가 domain을 향하는 반대 방향이다. application은 domain에 있는 Repository 인터페이스만 부르고, 그 구현이 infra에 있다는 사실을 모른다. 그 방향을 아래 계층 지도가 보여준다.
계층 지도 — 의존 방향
바깥 겹이 안쪽 겹을 부르고, 안쪽은 바깥을 모른다. 가운데가 domain이다.
화살표는 늘 바깥에서 안쪽이다. 안쪽 겹은 바깥 겹의 이름을 모른다.
domain 표준 12
비즈니스 규칙을 담는다. 영속성도 웹도 모르는 POJO다. Repository 인터페이스를 여기서 정의한다.
application 표준 13
유스케이스의 실행 순서를 조율하고 트랜잭션 경계를 잡는다. 판단은 Domain에 맡긴다.
api · web · infra 표준 22 · 1 · 13
바깥 세계와 붙는 자리. 여기만 프레임워크를 안다. infra는 바깥에 있어도 domain의 인터페이스를 구현한다.
common 표준 27
어떤 계층도 아니다. 모든 패키지가 참조하되, domain이 쓰는 타입은 프레임워크에 직접·간접으로 닿지 않게 두어 의존 방향이 깨지지 않는다. 패키지가 아니라 타입 단위로 지킨다.
계층별 표준 — 목록형
번호는 참조 순서다. 한 줄에 제목과 요지, 계층만 둔다.
- 팩토리 메소드로 VO 생성하기 DTO에서 VO로의 변환을 팩토리 메소드에 맡겨 Service의 변환 코드를 걷어낸다 application
- Null-free 객체 설계와 생성 철학 생성자가 객체의 완전성을 보장한다. 정적 팩토리, 불변성, 그리고 엔티티에서의 타협점 common
- Assert로 프로그래머의 실수를 잡아내기 내부 검증과 외부 검증의 경계를 나누고, 그 안쪽을 assert가 맡는다 common
- 상속 vs 합성 DTO 공통 필드는 1레벨 상속으로 관리하고, 그 밖은 합성으로 간다 common
- 애플리케이션 시간 사건 시각은 Service가 UTC Instant로 공급하고 날짜와 지역 시각은 의미에 맞는 타입으로 남긴다 api+4
- DB 시간 저장 사건 시점은 UTC 세션의 timestamptz(6)로 왕복하고, updatedAt은 DB 트리거가 아니라 애플리케이션이 갱신한다 application+2
- 스키마 마이그레이션 스키마는 Flyway 한 벌이 소유하고 Hibernate와 테스트는 그걸 검증만 한다 infraDatabase
- 계층별 DTO 네이밍 표준 Controller, Service, JPA, MyBatis 사이의 의존성을 끊는 DTO 설계 원칙 api+3
- Swagger 문서화 표준 @Schema와 Bean Validation의 역할을 나누고, 공통 응답은 전역에서 관리한다 api
- 계층별 검증 표준 무엇을 어디서 검증할지 가르는 기준은 "도메인 지식이 필요한가"이다 api+2
- 경계 DTO의 null 표기 Java 호출 계약에는 orNull을 남기고 JSON 계약에는 키와 null을 함께 남긴다 api+2
- Service 계층의 assert 메서드의 전제와 결과를 코드로 선언하고, 깨지는 순간 멈추게 하는 방법 application
- DTO는 생성자로만 만든다 setter와 기본 생성자를 없애면 객체가 불완전한 상태로 존재할 수 없다 common
- 예외 처리 표준 분류가 끝난 실패만 ErrorCodeException으로 운반하고 상태 코드, 메시지, 로그는 ErrorCode에서 통제한다 commonapi
- 성공 응답 상태 처리 결과가 200·201·202를 가르고 실제 상태와 본문 status, 코드 앞 세 자리를 맞춘다. 봉투가 면제되는 응답은 304 하나뿐이다 apicommon
- 예외의 종류 예외는 발생 경계에서 번역하고 ErrorCode는 식별 가능한 업무 도메인까지 세분화한다 common+2
- 패키지 구조 도메인별로 나누고 package-private으로 막으면 계층 위반이 컴파일 에러가 된다 api+4
- 컨트롤러 패키지 분리 JSON은 api, HTML은 web으로 가른다. 두 아키텍처가 들어오는 칸을 같은 이름으로 부른다 apiweb
- 접미사 패키지 분할 api·application은 나누면 질서가 생기고 infra는 나누면 깨진다. 나누는 김에 패키지 밖에서 안 쓰이는 것을 닫는다 apiapplication
- 애그리거트 경계 Domain은 지연 로딩이 없다. 애그리거트 크기가 곧 매 조회의 비용이다 domain
- Repository 설계 표준 저장과 단건 조회는 JPA로, 조건이 조합되는 검색은 MyBatis로, 대량 쓰기는 Repository 밖으로 뺀다 domain+2
- UseCase 인터페이스 Service를 package-private으로 감추면 Controller가 쓸 수 있는 것이 컴파일러로 정해진다 apiapplication
- 벌크와 배치 판단이 없으면 벌크, 있으면 배치. Domain을 거치는지는 결과이지 기준이 아니다 application+2
- Domain 영속화 POJO를 그대로 save하면 Domain이 모르는 컬럼이 조용히 null로 덮인다 infraDatabase
- 조회 전용 타입 들어가는 쪽에 SearchCondition이 있듯, 나가는 쪽에 View가 있다 domaininfra
- 페이징 방식 타입이 곧 페이징 방식이다. 둘로 갈라 커서 응답에 totalCount를 실을 방법 자체를 없앤다 api+2
- 삭제 지울 수 있는 애그리거트에만 delete를 둔다. 나머지는 컴파일러가 막는다 domain+2
- 인증 주체 LoginUser는 api에서 죽고, application으로는 userId만 간다 api+2
- 목록 조회 요청 계약 들어오는 쪽 세 파라미터가 전부 인덱스 비용으로 번역된다. 그래서 허용 목록은 인덱스와 같은 층이 소유한다 api+2
- ErrorCode 계약 숫자에 도메인이 박혀 있어서 소유가 바뀌면 계약이 깨진다. 그래서 발급 자리를 먼저 정하고, 폐기는 지우지 않는 방식으로만 한다 api+2
- URL 라우팅 네이밍 모든 경로는 `/api/v1`으로 시작하고 리소스는 복수 명사로 짓되 도메인 이름은 넣지 않는다 — 깨는 변경이 생기면 옛 주소를 살린 채 `/api/v2`를 옆에 연다 api
- 아키텍처 소속 소속은 글이 들고 안 맞는 원칙에만 표시를 단다. 세어보니 142개 중 3개였고, 그 수가 글을 쪼개는 안을 지웠다 common
- API 테스트 계약표 안심이 안 되는 건 테스트가 적어서가 아니라 무엇을 안 봤는지 몰라서다. 계약을 표로 적으면 빈칸이 보이고, 색칠은 사람이 아니라 기계가 한다 apicommon
- 라우트-계약 정합성 계약 enum이 아무리 촘촘해도 컨트롤러가 실제로 받는 라우트와 다른 얘기를 하면 소용없다. RequestMappingHandlerMapping에서 직접 읽어 양방향으로 대조하고, 어긋나면 빌드를 막는다 apicommon
- 계약 커버리지 게이트 노랑은 두 종류였다 — 잊어서 안 짠 것과 지금 하네스로 못 짜는 것. 정적 스캔으로 다시 세고, 이유를 적은 유예만 빼주고, 나머지는 빌드를 막는다 apicommon
- 테스트 층위 선택 스텁은 외래키도 인증도 소프트 삭제도 통과시킨다. 기본을 실제 스택으로 두고 단위는 그 층에서 재현할 수 없는 것에만 쓴다 api+3
- 테스트 냄새 어휘 「느리다」는 불평이고 「Too Many Tests 로 인한 Slow Tests」는 고칠 수 있는 것이다. 이름에 원인 목록이 딸려 온다 common
- 코드 포맷터 포맷은 도구가 강제한다. 값은 취향으로 정하고 항목별 근거를 적지 않기로 정한 것 자체를 남긴다 common
- 커밋 대기 커밋은 에이전트가 자기 일을 드러내야 하는 유일한 자리다. 거기서 안 멈추면 보고할 계기가 없다 common
- 표준 동기화 어긋남은 세 축이다 — 도구 사이, 배포 시점 사이, 사본과 원본 사이 common
- 배포 자동화 밀어내는 일만 자동으로 옮겨가고 그 앞을 지키던 검사는 남는다 common
- 규칙 전달 경로 코딩 표준은 경로가 걸고(rules), 작업 절차는 할 일이 건다(skills). 새 파일만 훅이 막는다 common
- 에이전트 운영 이슈를 넷으로 나눈 건 에이전트를 넷으로 나눈 부산물이었다. 문맥은 세션이 들고, 트래커에는 번호 붙은 계약과 사람용 댓글만 남긴다 common
- 멀티 에이전트 위상 층이 셋인 이유는 역할이 셋이기 때문이다. 마스터는 아무것도 기억하지 않고 매번 orca에 묻는다 common
- 일머리 정확도는 저울에 올리는 것이 아니라 전제다. 그 전제를 고정한 채 가장 빠른 길을 찾는다 common
- 실패 알림 화면은 지켜지는데 터졌다는 사실이 아무 데도 안 간다. 알림은 통제되지 않은 곳이라 값을 싣지 않고 링크만 준다 apicommon
찾는 표준이 없다. 비어 있는 자리는 저장소의 GitHub 이슈에 모아둔다.
3계층 아키텍처
최상위는 도메인으로 나누고, 도메인 하나가 요청 처리 · 비즈니스 로직 · 데이터 접근 세 겹을 갖는다
도메인 드리븐을 쓸 수 없는 자리. 외주처럼 구조를 우리가 정할 수 없는 일감에서는 네 겹으로 나누고 package-private으로 막는 방식을 강제할 수 없다.
요청 한 사이클 — 그래프
브라우저 요청이 Controller와 Service를 거쳐 Repository에 닿고, 결과는 반대로 돌아온다.
- Browser HTTP Request
- api · web Request DTO
- service 조회 조건 · 저장 객체
- repository SQL Query
- Database
패키징은 도메인 드리븐과 같다 — 최상위를 도메인으로 나누고 계층은 그 안에 둔다(order/api, order/service, order/repository). 다른 것은 그 안이 몇 겹이냐다. Repository, DAO, MyBatis Mapper는 모두 데이터 접근 책임을 수행한다. 기술 스택에 따라 이름과 구현 방식은 달라질 수 있어서, 이 셋을 서로 다른 계층으로 나누지 않고 하나로 묶었다. domain이 없으므로 Repository 인터페이스로 의존을 뒤집지 않고, 호출 방향과 의존 방향이 같다. 첫 칸의 api와 web은 계층이 둘이라는 뜻이 아니다 — 같은 Presentation 계층의 패키지 둘이고, 나가는 것이 JSON이냐 HTML이냐만 다르다.
계층 쌓임 — 위에서 아래로만
domain이 없어 의존을 뒤집지 않는다. 호출 방향과 의존 방향이 같다.
Browser
Presentation Layer
Business Layer
Data Access Layer
Database
화살표는 위에서 아래로만 간다. 부르는 방향이 곧 의존 방향이다.
Presentation Layerapi
HTTP 요청을 받아 입력값을 확인하고, 적절한 Service를 호출한 뒤 JSON 응답을 생성한다.
Business Layerservice
비즈니스 처리 순서를 조율하고, 트랜잭션 범위 안에서 Repository 또는 Mapper를 호출한다.
Data Access Layerrepository
Database에 접근하여 데이터를 조회하고 저장하며, 데이터 접근 구현을 상위 계층에서 분리한다.
이 구조의 계층별 책임, 요청 예시, 의존 규칙은 3계층 아키텍처 가이드에 따로 정리했다.
계층별 표준 — 목록형
번호는 참조 순서다. 3계층에서 통하는 표준만 남았다.
- 팩토리 메소드로 VO 생성하기 DTO에서 VO로의 변환을 팩토리 메소드에 맡겨 Service의 변환 코드를 걷어낸다 service
- Null-free 객체 설계와 생성 철학 생성자가 객체의 완전성을 보장한다. 정적 팩토리, 불변성, 그리고 엔티티에서의 타협점 Common
- Assert로 프로그래머의 실수를 잡아내기 내부 검증과 외부 검증의 경계를 나누고, 그 안쪽을 assert가 맡는다 Common
- 상속 vs 합성 DTO 공통 필드는 1레벨 상속으로 관리하고, 그 밖은 합성으로 간다 Common
- 애플리케이션 시간 사건 시각은 Service가 UTC Instant로 공급하고 날짜와 지역 시각은 의미에 맞는 타입으로 남긴다 api+3
- DB 시간 저장 사건 시점은 UTC 세션의 timestamptz(6)로 왕복하고, updatedAt은 DB 트리거가 아니라 애플리케이션이 갱신한다 service+2
- 스키마 마이그레이션 스키마는 Flyway 한 벌이 소유하고 Hibernate와 테스트는 그걸 검증만 한다 repositoryDatabase
- Swagger 문서화 표준 @Schema와 Bean Validation의 역할을 나누고, 공통 응답은 전역에서 관리한다 api
- Service 계층의 assert 메서드의 전제와 결과를 코드로 선언하고, 깨지는 순간 멈추게 하는 방법 service
- DTO는 생성자로만 만든다 setter와 기본 생성자를 없애면 객체가 불완전한 상태로 존재할 수 없다 Common
- 예외 처리 표준 분류가 끝난 실패만 ErrorCodeException으로 운반하고 상태 코드, 메시지, 로그는 ErrorCode에서 통제한다 apiCommon
- 성공 응답 상태 처리 결과가 200·201·202를 가르고 실제 상태와 본문 status, 코드 앞 세 자리를 맞춘다. 봉투가 면제되는 응답은 304 하나뿐이다 apiCommon
- 예외의 종류 예외는 발생 경계에서 번역하고 ErrorCode는 식별 가능한 업무 도메인까지 세분화한다 api+2
- 컨트롤러 패키지 분리 JSON은 api, HTML은 web으로 가른다. 두 아키텍처가 들어오는 칸을 같은 이름으로 부른다 apiweb
- 인증 주체 LoginUser는 api에서 죽고, application으로는 userId만 간다 apiservice
- 목록 조회 요청 계약 들어오는 쪽 세 파라미터가 전부 인덱스 비용으로 번역된다. 그래서 허용 목록은 인덱스와 같은 층이 소유한다 apirepository
- ErrorCode 계약 숫자에 도메인이 박혀 있어서 소유가 바뀌면 계약이 깨진다. 그래서 발급 자리를 먼저 정하고, 폐기는 지우지 않는 방식으로만 한다 api+2
- URL 라우팅 네이밍 모든 경로는 `/api/v1`으로 시작하고 리소스는 복수 명사로 짓되 도메인 이름은 넣지 않는다 — 깨는 변경이 생기면 옛 주소를 살린 채 `/api/v2`를 옆에 연다 api
- 아키텍처 소속 소속은 글이 들고 안 맞는 원칙에만 표시를 단다. 세어보니 142개 중 3개였고, 그 수가 글을 쪼개는 안을 지웠다 Common
- API 테스트 계약표 안심이 안 되는 건 테스트가 적어서가 아니라 무엇을 안 봤는지 몰라서다. 계약을 표로 적으면 빈칸이 보이고, 색칠은 사람이 아니라 기계가 한다 apiCommon
- 라우트-계약 정합성 계약 enum이 아무리 촘촘해도 컨트롤러가 실제로 받는 라우트와 다른 얘기를 하면 소용없다. RequestMappingHandlerMapping에서 직접 읽어 양방향으로 대조하고, 어긋나면 빌드를 막는다 apiCommon
- 계약 커버리지 게이트 노랑은 두 종류였다 — 잊어서 안 짠 것과 지금 하네스로 못 짜는 것. 정적 스캔으로 다시 세고, 이유를 적은 유예만 빼주고, 나머지는 빌드를 막는다 apiCommon
- 테스트 층위 선택 스텁은 외래키도 인증도 소프트 삭제도 통과시킨다. 기본을 실제 스택으로 두고 단위는 그 층에서 재현할 수 없는 것에만 쓴다 api+3
- 테스트 냄새 어휘 「느리다」는 불평이고 「Too Many Tests 로 인한 Slow Tests」는 고칠 수 있는 것이다. 이름에 원인 목록이 딸려 온다 Common
- 코드 포맷터 포맷은 도구가 강제한다. 값은 취향으로 정하고 항목별 근거를 적지 않기로 정한 것 자체를 남긴다 Common
- 커밋 대기 커밋은 에이전트가 자기 일을 드러내야 하는 유일한 자리다. 거기서 안 멈추면 보고할 계기가 없다 Common
- 표준 동기화 어긋남은 세 축이다 — 도구 사이, 배포 시점 사이, 사본과 원본 사이 Common
- 배포 자동화 밀어내는 일만 자동으로 옮겨가고 그 앞을 지키던 검사는 남는다 Common
- 규칙 전달 경로 코딩 표준은 경로가 걸고(rules), 작업 절차는 할 일이 건다(skills). 새 파일만 훅이 막는다 Common
- 에이전트 운영 이슈를 넷으로 나눈 건 에이전트를 넷으로 나눈 부산물이었다. 문맥은 세션이 들고, 트래커에는 번호 붙은 계약과 사람용 댓글만 남긴다 Common
- 멀티 에이전트 위상 층이 셋인 이유는 역할이 셋이기 때문이다. 마스터는 아무것도 기억하지 않고 매번 orca에 묻는다 Common
- 일머리 정확도는 저울에 올리는 것이 아니라 전제다. 그 전제를 고정한 채 가장 빠른 길을 찾는다 Common
- 실패 알림 화면은 지켜지는데 터졌다는 사실이 아무 데도 안 간다. 알림은 통제되지 않은 곳이라 값을 싣지 않고 링크만 준다 apiCommon
찾는 표준이 없다. 비어 있는 자리는 저장소의 GitHub 이슈에 모아둔다.
프론트엔드 아키텍처
앱이 둘이다. 보이는 값은 한 벌로 나누고, 만드는 것은 각자 만든다
Next.js 로 화면을 만드는 자리. 지금은 공개 사이트(client)와 관리자(admin) 둘이 돌고, 둘은 Docker 빌드 컨텍스트가 갈려 있어 서로의 파일을 못 읽는다.
아직 계층이 없다
폴더는 app · components · lib · styles로 나뉘어 있지만, 무엇이 무엇을 부를 수 있는지는 정한 적이 없다.
계층이 없다. app · components · lib · styles 로 폴더는 나뉘어 있지만 그것이 계층인지, 무엇이 무엇을 부를 수 있는지, 컴포넌트와 레이아웃을 무엇으로 가르는지가 정해진 적이 없다. 실제로 Header 는 components/ 루트에 있고 Footer 는 components/sections/ 안에 있다 — 같은 레이아웃 조각인데 사는 곳이 다르다.
정하지 않은 것을 그려두면 다음에 읽을 때 정한 것으로 착각한다. 그래서 계층이 정해질 때까지 흐름도 자리는 비워둔다. 빈틈은 저장소의 GitHub 이슈에 올려뒀다.
표준끼리의 관계 — 그래프
계층 대신, 먼저 정한 표준들이 서로를 어떻게 전제로 삼는지 본다. 화살표는 링크를 건 글 → 받은 글.
글 하나를 누르면 그 글에 기댄 표준만 남는다.
표준 — 목록형
계층을 정하기 전에도 성립하는 것들이다.
계층이 없어서 계층 필터도 없다 — 이름으로 찾는다
- 프론트엔드 토큰 공유 겹치는 컴포넌트는 0개였고 겹치는 것은 토큰 121줄이었다. 보이는 값만 한 벌로 두고 컴포넌트는 각자 만든다
- 컴포넌트 자리 폴더가 갈린 건 원인이 아니라 결과였다. 성격이 아니라 누가 그리는가로 가른다
- CSS 자리 같은 클래스가 다섯 파일에 다른 값으로 있고 마지막 로드가 이겼다. 이름 규칙 대신 도구가 막게 한다
- 만들 것인가 가져다 쓸 것인가 50개 중 3개만 쓰고 직접 만들다 팝업의 focus trap 을 빠뜨렸다. 다시 만들어야 하는 것으로 가른다
- 화면별 대응 같은 요금표를 표와 카드로 두 벌 그리고 있었다. 크기가 아니라 내용이 같은지를 먼저 묻는다
- 태그 선택 div 202개에 ul 2개였다. div 는 붙일 이름이 없을 때 쓰는 마지막 칸이다
- 컴포넌트 추출 빼는 이유는 둘뿐이다. 길이로 빼면 div 하나짜리 파일이 수십 개 생겨 읽기가 더 어려워진다
- 프론트엔드 API 계층 새로 정할 것이 거의 없었다. 전송·도메인·화면 세 겹이 이미 서 있었고 말로 꺼내진 적만 없었다
- 실패 화면 error.js 가 하나도 없어 렌더 중 버그가 터지면 화면이 사라진다. 예상한 실패와 예상 못 한 예외는 장치가 다르다
- 폼 제출 쓰기 폼 일곱 개가 두 번 누르면 두 번 나간다. 방어를 글로 적으면 또 잊는다 — 훅으로 만들어야 지켜진다
- 실패 알림 화면은 지켜지는데 터졌다는 사실이 아무 데도 안 간다. 알림은 통제되지 않은 곳이라 값을 싣지 않고 링크만 준다
- 디자인 도구의 이름 공식 연결 경로가 플랜에 막혀 있다. 도구가 코드로 실어 보내는 여섯 자리에 코드에 있는 이름을 그대로 적는다
- Figma 리뷰 댓글 처리 「반영했다」와 「요청대로 됐다」는 다르다. 에이전트는 답글과 이력까지, 핀을 닫는 것은 사람이 한다
찾는 표준이 없다. 비어 있는 자리는 저장소의 GitHub 이슈에 모아둔다.