TA 역할 Spring 경계 smoke #5
8 changed files with 288 additions and 0 deletions
3
.forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d.md
Normal file
3
.forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# role-ta-live-v2-001-attempt-1-run-d1ca4511d09d
|
||||||
|
|
||||||
|
Forge 이슈 작업 브랜치 `forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d`.
|
||||||
99
docs/adr/ADR-001-spring-architecture-boundaries.md
Normal file
99
docs/adr/ADR-001-spring-architecture-boundaries.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# ADR-001: Spring MVC 계층 경계 및 트랜잭션 정책
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
runtime-role-matrix-live 프로젝트는 Spring Boot 기반 REST API 서버로, 역할 기반 접근 제어(RBAC) 메트릭스를 실시간 처리한다. 다중 개발자가 동시 개발 시 계층 간 책임 범위가 모호하여 중복 로직, 트랜잭션 누락, 일관되지 않은 오류 응답이 발생하는 문제가 있다.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Controller-Service-Repository 경계
|
||||||
|
|
||||||
|
| 계층 | 책임 | 허용 의존성 |
|
||||||
|
|------|------|------------|
|
||||||
|
| **Controller** | HTTP 요청/응답 변환, 입력 검증, HTTP 상태 코드 결정 | Service 계층만 주입 |
|
||||||
|
| **Service** | 비즈니스 로직, 트랜잭션 경계, 도메인 조율 | Repository, Domain Entity, Value Object |
|
||||||
|
| **Repository** | 데이터 접근 추상화, JPA Entity 관리 | JPA Entity, EntityManager |
|
||||||
|
|
||||||
|
**구체적 규칙:**
|
||||||
|
- Controller는 `@RequestBody` DTO만 수신하고, 직접 Entity를 반환하지 않는다.
|
||||||
|
- Service는 `@Transactional(readOnly = true)`를 기본으로 하고, 쓰기 작업 시 `readOnly = false` 명시한다.
|
||||||
|
- Repository는 `JpaRepository` 또는 `CrudRepository`를 확장하며, `@Query`로 네이티브 SQL을 최소화한다.
|
||||||
|
- 도메인 로직은 Service 계층에 위치하며, Controller에 절대 포함하지 않는다.
|
||||||
|
|
||||||
|
### 2. 오류 계약 (Error Contract)
|
||||||
|
|
||||||
|
모든 API 오류는 다음 구조로 일관되게 응답한다:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "ERR_ROLE_NOT_FOUND",
|
||||||
|
"message": "역할 ID 123을 찾을 수 없습니다",
|
||||||
|
"timestamp": "2026-07-14T15:22:00Z",
|
||||||
|
"path": "/api/v1/roles/123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**오류 코드 체계:**
|
||||||
|
|
||||||
|
| 접두사 | 의미 | HTTP 상태 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| `ERR_` | 비즈니스/시스템 오류 | 4xx, 5xx |
|
||||||
|
| `VALIDATION_` | 입력 검증 실패 | 400 |
|
||||||
|
| `AUTH_` | 인증/인가 오류 | 401, 403 |
|
||||||
|
| `CONFLICT_` | 리소스 충돌 | 409 |
|
||||||
|
|
||||||
|
**구현 계약:**
|
||||||
|
- `GlobalExceptionHandler`가 모든 `RuntimeException` 하위 예외를 `@ExceptionHandler`로 처리한다.
|
||||||
|
- `BusinessException` 추상 클래스를 정의하고, 각 도메인 예외는 이를 확장한다.
|
||||||
|
- Controller에서 `try-catch`를 절대 사용하지 않는다.
|
||||||
|
- 예외 메시지는 사용자에게 직접 노출하지 않고, 내부 로그로만 기록한다.
|
||||||
|
|
||||||
|
### 3. 트랜잭션 경계
|
||||||
|
|
||||||
|
| 시나리오 | 전파 방식 | 격리 수준 |
|
||||||
|
|----------|-----------|-----------|
|
||||||
|
| 읽기 전용 조회 | `REQUIRED`, `readOnly=true` | 기본값 (READ_COMMITTED) |
|
||||||
|
| 단일 엔티티 생성/수정 | `REQUIRED`, `readOnly=false` | 기본값 |
|
||||||
|
| 다중 테이블 변경 | `REQUIRES_NEW` | `SERIALIZABLE` (명시적 필요 시) |
|
||||||
|
| 외부 API 호출 포함 | `MANDATORY` (트랜잭션 없으면 예외) | - |
|
||||||
|
|
||||||
|
**구체적 규칙:**
|
||||||
|
- `@Transactional`은 public 메서드에만 적용한다.
|
||||||
|
- 내부 메서드 호출(`this.method()`)은 프록시를 우회하므로, 별도 Bean으로 분리한다.
|
||||||
|
- 읽기 전용 트랜잭션에서 쓰기 시도 시 `InvalidDataAccessApiUsageException` 발생시킨다.
|
||||||
|
- 롤백은 `RuntimeException`, `DataAccessException`에 대해 자동 수행한다.
|
||||||
|
|
||||||
|
### 4. 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
com.runtimematrix
|
||||||
|
├── controller # REST Controller, DTO
|
||||||
|
├── service # Business Logic, Transaction Boundary
|
||||||
|
├── repository # Data Access
|
||||||
|
├── domain # Entity, Value Object, Domain Event
|
||||||
|
├── exception # BusinessException hierarchy
|
||||||
|
├── config # Spring Configuration
|
||||||
|
└── dto # Request/Response DTO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
| 대안 | 단점 | 선택하지 않은 이유 |
|
||||||
|
|------|------|-------------------|
|
||||||
|
| Controller에 트랜잭션 적용 | 테스트 어려움, 결합도 증가 | Service 계층이 자연스러운 트랜잭션 경계 |
|
||||||
|
| 예외를 직접 HTTP 응답에 매핑 | 오류 구조 불일치, 유지보수 어려움 | 중앙화된 ExceptionHandler로 일관성 확보 |
|
||||||
|
| Repository에 비즈니스 로직 포함 | 재사용성 저하, 테스트 어려움 | Service 계층에서 도메인 조율 |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- 계층별 단위 테스트 용이 (Mock 기반)
|
||||||
|
- 오류 응답 일관성으로 클라이언트 처리 단순화
|
||||||
|
- 트랜잭션 범위 명확화로 데이터 정합성 보장
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- DTO ↔ Entity 변환 코드 증가 (`MapStruct` 도입 권장)
|
||||||
|
- 다중 트랜잭션 시 `REQUIRES_NEW` 남용 시 성능 저하 가능
|
||||||
|
|
||||||
|
**Rollback Plan:**
|
||||||
|
- ADR 변경 시 기존 API 호환성을 위해 `@Deprecated` 어노테이션과 함께 2버전 마이그레이션 기간 운영
|
||||||
8
src/main/java/com/runtimematrix/dto/ErrorResponse.java
Normal file
8
src/main/java/com/runtimematrix/dto/ErrorResponse.java
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.runtimematrix.dto;
|
||||||
|
|
||||||
|
public record ErrorResponse(
|
||||||
|
String code,
|
||||||
|
String message,
|
||||||
|
String timestamp,
|
||||||
|
String path
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.runtimematrix.exception;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public abstract class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final Instant timestamp;
|
||||||
|
private final String path;
|
||||||
|
|
||||||
|
protected BusinessException(String code, String message, String path) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.timestamp = Instant.now();
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPath() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.runtimematrix.exception;
|
||||||
|
|
||||||
|
import com.runtimematrix.dto.ErrorResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleBusinessException(
|
||||||
|
BusinessException ex, HttpServletRequest request) {
|
||||||
|
log.error("Business exception: code={}, message={}", ex.getCode(), ex.getMessage());
|
||||||
|
|
||||||
|
ErrorResponse response = new ErrorResponse(
|
||||||
|
ex.getCode(),
|
||||||
|
ex.getMessage(),
|
||||||
|
ex.getTimestamp().toString(),
|
||||||
|
request.getRequestURI()
|
||||||
|
);
|
||||||
|
|
||||||
|
HttpStatus status = determineHttpStatus(ex);
|
||||||
|
return ResponseEntity.status(status).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleValidationException(
|
||||||
|
MethodArgumentNotValidException ex, HttpServletRequest request) {
|
||||||
|
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||||
|
.reduce((a, b) -> a + "; " + b)
|
||||||
|
.orElse("Validation failed");
|
||||||
|
|
||||||
|
ErrorResponse response = new ErrorResponse(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
message,
|
||||||
|
Instant.now().toString(),
|
||||||
|
request.getRequestURI()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleGenericException(
|
||||||
|
Exception ex, HttpServletRequest request) {
|
||||||
|
log.error("Unexpected exception", ex);
|
||||||
|
|
||||||
|
ErrorResponse response = new ErrorResponse(
|
||||||
|
"ERR_INTERNAL",
|
||||||
|
"내부 서버 오류가 발생했습니다",
|
||||||
|
Instant.now().toString(),
|
||||||
|
request.getRequestURI()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpStatus determineHttpStatus(BusinessException ex) {
|
||||||
|
String code = ex.getCode();
|
||||||
|
if (code.startsWith("VALIDATION_")) return HttpStatus.BAD_REQUEST;
|
||||||
|
if (code.startsWith("AUTH_")) return HttpStatus.UNAUTHORIZED;
|
||||||
|
if (code.startsWith("CONFLICT_")) return HttpStatus.CONFLICT;
|
||||||
|
if (code.startsWith("ERR_NOT_FOUND")) return HttpStatus.NOT_FOUND;
|
||||||
|
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.runtimematrix.exception;
|
||||||
|
|
||||||
|
public class RoleNotFoundException extends BusinessException {
|
||||||
|
|
||||||
|
public RoleNotFoundException(Long roleId, String path) {
|
||||||
|
super(
|
||||||
|
"ERR_ROLE_NOT_FOUND",
|
||||||
|
"역할 ID " + roleId + "을 찾을 수 없습니다",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.runtimematrix.exception;
|
||||||
|
|
||||||
|
import com.runtimematrix.dto.ErrorResponse;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest
|
||||||
|
class GlobalExceptionHandlerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void businessException_returnsConsistentErrorFormat() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/v1/roles/999"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("ERR_ROLE_NOT_FOUND"))
|
||||||
|
.andExpect(jsonPath("$.timestamp").exists())
|
||||||
|
.andExpect(jsonPath("$.path").value("/api/v1/roles/999"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.runtimematrix.service;
|
||||||
|
|
||||||
|
import com.runtimematrix.domain.Role;
|
||||||
|
import com.runtimematrix.repository.RoleRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class RoleServiceTransactionTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RoleService roleService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RoleRepository roleRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
void readOnlyTransaction_allowsRead() {
|
||||||
|
var roles = roleService.findAllRoles();
|
||||||
|
assertThat(roles).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRole_commitsSuccessfully() {
|
||||||
|
Role role = new Role(null, "TEST_ROLE", "Test Role");
|
||||||
|
Role saved = roleService.createRole(role);
|
||||||
|
|
||||||
|
assertThat(saved.getId()).isNotNull();
|
||||||
|
assertThat(roleRepository.findById(saved.getId())).isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue