diff --git a/.forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d.md b/.forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d.md new file mode 100644 index 0000000..2f5082c --- /dev/null +++ b/.forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d.md @@ -0,0 +1,3 @@ +# role-ta-live-v2-001-attempt-1-run-d1ca4511d09d + +Forge 이슈 작업 브랜치 `forge/role-ta-live-v2-001-attempt-1-run-d1ca4511d09d`. diff --git a/docs/adr/ADR-001-spring-architecture-boundaries.md b/docs/adr/ADR-001-spring-architecture-boundaries.md new file mode 100644 index 0000000..30275f9 --- /dev/null +++ b/docs/adr/ADR-001-spring-architecture-boundaries.md @@ -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버전 마이그레이션 기간 운영 diff --git a/src/main/java/com/runtimematrix/dto/ErrorResponse.java b/src/main/java/com/runtimematrix/dto/ErrorResponse.java new file mode 100644 index 0000000..a51512b --- /dev/null +++ b/src/main/java/com/runtimematrix/dto/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.runtimematrix.dto; + +public record ErrorResponse( + String code, + String message, + String timestamp, + String path +) {} diff --git a/src/main/java/com/runtimematrix/exception/BusinessException.java b/src/main/java/com/runtimematrix/exception/BusinessException.java new file mode 100644 index 0000000..9fd77eb --- /dev/null +++ b/src/main/java/com/runtimematrix/exception/BusinessException.java @@ -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; + } +} diff --git a/src/main/java/com/runtimematrix/exception/GlobalExceptionHandler.java b/src/main/java/com/runtimematrix/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..3dc965b --- /dev/null +++ b/src/main/java/com/runtimematrix/exception/GlobalExceptionHandler.java @@ -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 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 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 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; + } +} diff --git a/src/main/java/com/runtimematrix/exception/RoleNotFoundException.java b/src/main/java/com/runtimematrix/exception/RoleNotFoundException.java new file mode 100644 index 0000000..f3e2caf --- /dev/null +++ b/src/main/java/com/runtimematrix/exception/RoleNotFoundException.java @@ -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 + ); + } +} diff --git a/src/test/java/com/runtimematrix/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/runtimematrix/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..0da51bf --- /dev/null +++ b/src/test/java/com/runtimematrix/exception/GlobalExceptionHandlerTest.java @@ -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")); + } +} diff --git a/src/test/java/com/runtimematrix/service/RoleServiceTransactionTest.java b/src/test/java/com/runtimematrix/service/RoleServiceTransactionTest.java new file mode 100644 index 0000000..cd6b579 --- /dev/null +++ b/src/test/java/com/runtimematrix/service/RoleServiceTransactionTest.java @@ -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(); + } +}