3-Layer Architecture
3계층 아키텍처
Controller, Service, Data Access 계층으로 이어지는 요청과 응답 흐름을 확인합니다.
3계층 아키텍처는 요청 처리, 비즈니스 로직, 데이터 접근의 책임을 각각 분리하는 구조입니다. 브라우저의 요청은 Controller와 Service를 거쳐 Repository 또는 MyBatis Mapper에 전달되고, 처리 결과는 반대 방향으로 반환됩니다.
Controller Service Repository MyBatis Mapper Database
도메인 드리븐을 쓸 수 없는 자리. 외주처럼 구조를 우리가 정할 수 없는 일감에서는 네 겹으로 나누고 package-private으로 막는 방식을 강제할 수 없다.
Request Flow
브라우저의 HTTP 요청이 Controller와 Service를 거쳐 Data Access Layer에 닿고, 결과가 반대 방향으로 돌아온다. 계층 카드를 누르면 아래 표준 목록이 그 계층 것만 남긴다.
- 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이냐만 다르다.
Layer Responsibilities
세 계층이 각각 무엇을 맡고, 무엇을 맡지 않는지다. 계층이 흐려지는 자리는 대부분 「하지 않는다」 쪽에 있다.
-
Presentation Layer
api
HTTP 요청을 받아 입력값을 확인하고, 적절한 Service를 호출한 뒤 JSON 응답을 생성한다.
대표 경로
{도메인}/api여기서 한다
- Route 연결
- Request Parameter 수신
- Request DTO 변환
- 입력 형식 검증
- 인증 정보 수신
- Service 호출
- Response DTO 반환
- HTTP Status Code 지정
여기서 하지 않는다
- 핵심 비즈니스 로직
- 직접 SQL 실행
- Mapper 직접 호출
- 복잡한 데이터 가공
- 장시간 트랜잭션 처리
- 여러 업무 로직을 직접 조율
-
Business Layer
service
비즈니스 처리 순서를 조율하고, 트랜잭션 범위 안에서 Repository 또는 Mapper를 호출한다.
대표 경로
{도메인}/service여기서 한다
- 업무 흐름 조율
- 비즈니스 조건 검사
- 트랜잭션 관리
- 여러 Repository 또는 Mapper 호출
- 데이터 생성 및 변경
- 외부 API 호출 조율
- 업무 예외 발생
- 결과 데이터 구성
여기서 하지 않는다
- HTTP Request 직접 의존
- HTTP Response 직접 생성
- View 객체 직접 생성
- SQL 문자열 직접 작성
- Controller 역할 수행
- 프레젠테이션 전용 포맷에 강하게 의존
-
Data Access Layer
repository
Database에 접근하여 데이터를 조회하고 저장하며, 데이터 접근 구현을 상위 계층에서 분리한다.
대표 경로
{도메인}/repository여기서 한다
- SQL 실행
- 데이터 조회 · 저장 · 수정 · 삭제
- 검색 조건 적용
- Paging 처리
- Database Record 변환
- 영속성 기술 처리
여기서 하지 않는다
- HTTP 처리
- 사용자 메시지 결정
- 업무 흐름 조율
- Controller 호출
- Service 역할 수행
- 화면 전용 데이터 생성
- 복잡한 업무 정책 판단
Data Access Layer의 구현 방식
Repository, DAO, MyBatis Mapper는 모두 데이터 접근 책임을 수행합니다. 프로젝트의 기술 스택과 규칙에 따라 이름과 구현 방식은 달라질 수 있습니다. 셋을 서로 다른 계층으로 나누지 않는다 — 같은 자리의 다른 구현이다.
-
MyBatis · XML
ControllerServiceMapper InterfaceMapper XMLDatabase -
MyBatis · Annotation
ControllerServiceMapper InterfaceSQL AnnotationDatabase -
JPA · ORM
ControllerServiceRepositoryORMDatabase
Request Examples
실제 요청 하나가 어느 클래스를 어떤 순서로 지나가는지다. 예시를 고르면 위 다이어그램의 계층에 그 요청에서의 순서 번호가 붙는다.
GET /users/10
MyBatis Mapper
-
GET /users/10Request -
UserController.getUser()Request -
UserService.getUser()Request -
UserMapper.selectUserById()Request -
users 테이블 조회Request -
UserResponse 반환Response
POST /posts
MyBatis Mapper
-
POST /postsRequest -
PostController.createPost()Request -
PostService.createPost()Request -
PostMapper.insertPost()Request -
posts 테이블 저장Request -
생성 결과 반환Response
PATCH /orders/100/status
JPA Repository
-
PATCH /orders/100/statusRequest -
OrderController.updateStatus()Request -
OrderService.updateStatus()Request -
OrderRepository.updateStatus()Request -
orders 테이블 수정Request -
변경 결과 반환Response
Coding Standards
여기 실린 33개는 계층 구조를 전제하지 않아 이 구조에서도 그대로 성립하는 표준이다. 3계층만의 표준은 아직 정한 것이 없다 — 비어 있는 자리는 저장소의 GitHub 이슈에 적어뒀다.
계층을 가리지 않는 규칙
객체 생성, 예외, assert처럼 어느 계층에서 쓰든 같은 모양이어야 하는 것들이다. 위 다이어그램에는 자리가 없지만 세 계층 모두에 걸린다.
전체 표준 33개
- 팩토리 메소드로 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 이슈에 모아둔다.
Dependency Rules
호출은 Controller에서 Data Access 쪽으로만 흐른다.
거꾸로 부르는 순간 계층이 아니라 그물이 된다.
허용 이 방향으로만 부른다
-
Controller호출 가능Service -
Service호출 가능Repository / Mapper
금지 거꾸로 부르지 않는다
-
Service호출 금지Controller -
Repository / Mapper호출 금지Service -
Repository / Mapper호출 금지Controller
- Controller가 Repository 또는 Mapper를 직접 호출하지 않는다.
- Controller에 핵심 비즈니스 로직을 작성하지 않는다.
- Service에 HTTP 전용 처리 로직을 작성하지 않는다.
- Repository 또는 Mapper에 업무 흐름을 작성하지 않는다.
- SQL은 Data Access Layer에서 관리한다.
- 트랜잭션 범위는 일반적으로 Service에서 관리한다.
- 계층 간 순환 참조를 만들지 않는다.
프로젝트에 조회 전용 구조나 별도의 Query Service 규칙이 이미 있다면 그 프로젝트의 코딩 스탠다드가 위 규칙보다 앞선다. 여기 적은 것은 아무 규칙도 없을 때의 기본값이다.
Related Architecture
더 엄격하게 의존성을 분리한 구조가 필요하다면 도메인 드리븐 아키텍처를 확인하세요.
3계층 아키텍처
- Controller, Service, Data Access 중심
- 구조가 단순하고 이해하기 쉬움
- 일반적인 업무 시스템에 적용하기 쉬움
- 빠른 개발과 유지보수에 적합
- 계층별 책임 분리가 핵심
도메인 드리븐 아키텍처
- Domain을 중심으로 의존성 방향을 엄격히 제어
- UseCase, Adapter, Infrastructure 등을 세분화
- 외부 기술로부터 핵심 비즈니스 규칙을 보호
- 구조와 추상화가 상대적으로 복잡함
모든 프로젝트에 복잡한 아키텍처가 필요한 것은 아닙니다. Controller, Service, Data Access의 책임이 명확히 분리되어 있다면 3계층 아키텍처도 충분히 안정적이고 유지보수하기 좋은 구조가 될 수 있습니다.
둘 중 하나가 더 낫다는 이야기가 아니다. 프로젝트 규모, 업무 복잡도, 팀의 숙련도에 따라 고르는 구조다. 이 저장소가 도메인 드리븐을 먼저 정한 것도 우열 때문이 아니라, 구조를 우리가 정할 수 있는 프로젝트가 먼저 있었기 때문이다.