6.4 KiB
ADR-001: Spring 계층 경계, 오류 계약 및 트랜잭션 경계 정의
Context
runtime-role-matrix-live-202607141522-v3 프로젝트에서 TA(Tech Architect) 역할의 역할 기반 접근 제어(RBAC)가 Spring Boot 기반으로 구현된다. Controller, Service, Repository 계층 간 책임 범위, 오류 처리 규약, 트랜잭션 전파 전략을 명확히 정의하지 않으면 다음과 같은 문제가 발생한다.
- 책임 혼재: Controller에서 비즈니스 로직 수행 또는 Repository 직접 호출로 결합도 증가
- 일관성 없는 오류 처리: 각 계층마다 다른 예외 타입/메시지 반환으로 클라이언트 혼란
- 트랜잭션 경계 불명확: 읽기 전용 쿼리에 불필요한 트랜잭션 오버헤드 또는 데이터 정합성 손실
- 테스트 어려움: 계층 간 경계 모호 시 단위 테스트 mocking 전략 수립 곤란
Decision
1. Controller-Service-Repository 경계
| 계층 | 책임 | 금지 사항 |
|---|---|---|
| Controller | HTTP 요청/응답 변환, 입력 검증(Bean Validation), 라우팅, 예외 매핑 | 비즈니스 로직 직접 구현, Repository 직접 호출, @Transactional 선언 |
| Service | 비즈니스 로직 수행, 도메인 객체 조합, 트랜잭션 경계 설정, 오류 변환 | HTTP 관련 코드(HttpServletRequest 등), 직접 JDBC/ORM 쿼리 실행 |
| Repository | 영속성 작업(DB CRUD), 쿼리 실행, JPA Entity 관리 | 비즈니스 로직, 다른 Repository 직접 호출, @Transactional 선언(상위 계층 위임) |
2. 오류 계약 (Error Contract)
모든 계층에서 발생하는 오류는 RuntimeException 계층 구조로 표준화한다.
BaseException (RuntimeException)
├── BusinessException → 사용자에게 의미 있는 메시지, HTTP 4xx 매핑
│ ├── RoleNotFoundException
│ ├── DuplicateRoleException
│ └── InsufficientPermissionException
└── SystemException → 내부 오류, HTTP 5xx 매핑
├── DatabaseException
└── ExternalServiceException
오류 응답 형식 (RFC 7807 Problem Details):
{
"type": "https://api.example.com/errors/role-not-found",
"title": "Role Not Found",
"status": 404,
"detail": "ID가 'admin'인 역할이 존재하지 않습니다.",
"instance": "/api/v1/roles/admin",
"timestamp": "2026-07-14T15:22:00Z",
"traceId": "abc123"
}
@ControllerAdvice 예외 매핑 규칙:
| 예외 타입 | HTTP 상태 코드 | 로깅 레벨 |
|---|---|---|
| BusinessException | 4xx (설정값) | WARN |
| SystemException | 500 | ERROR |
| ValidationException | 400 | WARN |
| 기타 예외 | 500 | ERROR |
3. 트랜잭션 경계
| 작업 유형 | @Transactional 설정 | 전파 방식 |
|---|---|---|
| 읽기 전용 조회 | readOnly = true |
REQUIRED |
| 단일 쓰기 작업 | readOnly = false (기본) |
REQUIRED |
| 복합 쓰기 작업 | readOnly = false |
REQUIRED |
| 네스티드 읽기 | readOnly = true |
NESTED (Savepoint) |
| 독립 읽기 | readOnly = true |
REQUIRES_NEW |
트랜잭션 경계 위치: Service 계층의 public 메서드에 선언한다. Controller에서 @Transactional 사용을 금지한다.
격리 수준 (Isolation Level):
| 시나리오 | 격리 수준 | 선택 이유 |
|---|---|---|
| 역할 목록 조회 | READ_COMMITTED | 기본값, 동시성 성능 |
| 역할 할당/해제 | REPEATABLE_READ | 데이터 정합성 보장 |
| 설정 변경 | SERIALIZABLE | 극단적 정합성 필요 시 |
롤백 규칙: Checked Exception은 기본적으로 롤백되지 않으므로, 명시적 롤백이 필요한 BusinessException에는 @Transactional(rollbackFor = BusinessException.class)를 적용한다.
4. 패키지 구조
com.example.runtimematrix
├── controller # REST API 엔드포인트
│ └── RoleController.java
├── service # 비즈니스 로직 + 트랜잭션
│ ├── RoleService.java
│ └── impl/
├── repository # 영속성 접근
│ ├── RoleRepository.java
│ └── custom/
├── domain # 엔티티, 밸류 오브젝트
│ ├── entity/
│ └── vo/
├── exception # 예외 계층 구조
│ ├── BaseException.java
│ ├── BusinessException.java
│ └── SystemException.java
├── dto # 요청/응답 DTO
│ ├── request/
│ └── response/
└── config # 설정 클래스
Alternatives
대안 1: Controller-Service-Repository 외에 DTO 변환 계층 추가
선택하지 않은 이유: 소규모 프로젝트에서 과도한 추상화로 복잡성 증가. DTO 변환은 Mapper 라이브러리(MapStruct) 또는 수동 변환으로 Service 내에서 처리한다.
대안 2: 모든 예외를 RuntimeException으로 통일
선택하지 않은 이유: 예외 유형 구분 없이는 @ControllerAdvice에서 HTTP 상태 코드 매핑이 어려우며, 클라이언트에게 의미 있는 오류 피드백 제공 곤란.
대안 3: 트랜잭션을 Controller에 선언
선택하지 않은 이유: HTTP 요청/응답 처리와 트랜잭션 관점 분리 필요. AOP 프록시 기반 트랜잭션은 public 메서드에만 적용되므로 Controller의 메서드 레벨 제어가 불완전하다.
Consequences
긍정적 영향
- 단위 테스트 용이성: Service를 MockRepository로 교체하여 독립 테스트 가능
- 일관된 오류 처리: 클라이언트가 예측 가능한 오류 응답 형식 수신
- 트랜잭션 최적화: 읽기 전용 쿼리에서 readOnly=true로 불필요한 쓰기 잠금 해제
- 유지보수성: 계층별 책임 명확화로 개발자 간 협업 효율 향상
부정적 영향
- 추가 클래스 생성: 예외 계층 구조로 인한 다수의 예외 클래스 필요
- DTO 변환 코드: Service 계층에서 Entity ↔ DTO 변환 로직 추가
- 트랜잭션 경계 설계 필요: 각 Service 메서드별 트랜잭션 설정 의사결정 필요
모니터링 및 검증
- 트랜잭션 경계 위반 시
TransactionException발생으로 조기 감지 - @ControllerAdvice 전역 예외 처리 로깅으로 오류 패턴 분석 가능
- Integration Test에서 @Transactional(readOnly = true) 조회 후 수정 시도 시 예외 검증