diff --git a/.forge/role-ta-live-1522-001-attempt-3-run-12d03ada6afe.md b/.forge/role-ta-live-1522-001-attempt-3-run-12d03ada6afe.md deleted file mode 100644 index a96892b..0000000 --- a/.forge/role-ta-live-1522-001-attempt-3-run-12d03ada6afe.md +++ /dev/null @@ -1,3 +0,0 @@ -# role-ta-live-1522-001-attempt-3-run-12d03ada6afe - -Forge 이슈 작업 브랜치 `forge/role-ta-live-1522-001-attempt-3-run-12d03ada6afe`. diff --git a/docs/adr/ADR-001-spring-architecture-boundaries.md b/docs/adr/ADR-001-spring-architecture-boundaries.md deleted file mode 100644 index be26926..0000000 --- a/docs/adr/ADR-001-spring-architecture-boundaries.md +++ /dev/null @@ -1,131 +0,0 @@ -# ADR-001: Spring Architecture Boundaries - -## Status -Accepted - -## Context - -### 프로젝트 배경 -runtime-role-matrix-live-202607141522 프로젝트는 역할 기반 접근 제어(RBAC) 매트릭스를 실시간으로 관리하는 시스템이다. Spring Boot 기반으로 구축되며, 다중 클라이언트 환경에서 일관된 아키텍처 패턴이 필요하다. - -### 문제 정의 -1. **Controller-Service-Repository 경계 모호**: 각 계층의 책임이 명확하지 않아 코드의 응집도 감소 및 결합도 증가 -2. **오류 계약 부재**: 예외 처리 전략이 통일되지 않아 일관되지 않은 API 응답 생성 -3. **트랜잭션 경계 불명확**: 서비스 계층에서 트랜잭션 관리 방식이 표준화되지 않아 데이터 일관성 위험 - -### 기술 환경 -- Spring Boot 3.2.x / Java 17 / Jakarta EE 10 -- Spring Data JPA / Spring Web (REST API) - ---- - -## Decision - -### 1. Controller-Service-Repository 경계 정의 - -| 계층 | 책임 | 금지 사항 | -|------|------|----------| -| **Controller** | HTTP 요청/응답, 입력 검증(@Valid), ResponseEntity 반환 | 비즈니스 로직, DB 접근, @Transactional | -| **Service** | 비즈니스 로직, @Transactional 관리, 도메인 객체 조작 | HttpServletRequest/Response 접근, 응답 형식 직접 생성 | -| **Repository** | DB 접근, 쿼리 실행, Entity 관리 | 비즈니스 로직, Service 호출 | - -```java -// Controller 예시 -@RestController @RequiredArgsConstructor -public class RoleMatrixController { - private final RoleMatrixService service; - @PostMapping @Valid @RequestBody RoleCreateRequest req - public ResponseEntity> createRole(req) { - return ResponseEntity.status(CREATED).body(ApiResponse.success(service.createRole(req))); - } -} - -// Service 예시 -@Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class RoleMatrixService { - private final RoleRepository roleRepository; - @Transactional public RoleResponse createRole(RoleCreateRequest req) { - Role role = Role.create(req.getName(), req.getDescription()); - return RoleResponse.from(roleRepository.save(role)); - } -} - -// Repository 예시 -@Repository -public interface RoleRepository extends JpaRepository { - Optional findByName(String name); - @Query("SELECT r FROM Role r LEFT JOIN FETCH r.permissions WHERE r.id = :id") - Optional findByIdWithPermissions(@Param("id") Long id); -} -``` - -### 2. 오류 계약 정의 - -**예외 계층**: `BaseException` → `BusinessException` / `SystemException` - -**오류 응답 표준 형식**: -```json -{"success": false, "error": {"code": "ROLE_NOT_FOUND", "message": "요청한 역할을 찾을 수 없습니다."}, "timestamp": "..."} -``` - -| 예외 유형 | HTTP 상태 | 용도 | -|----------|-----------|------| -| BusinessException | 4xx | 클라이언트 오류 (not found, duplicate, denied) | -| SystemException | 5xx | 서버 오류 (database, external service) | - -```java -@RestControllerAdvice -public class GlobalExceptionHandler { - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusiness(BusinessException ex) { - return ResponseEntity.status(ex.getErrorCode().getHttpStatus()) - .body(ApiResponse.error(ex.getErrorCode())); - } - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGeneric(Exception ex) { - return ResponseEntity.status(INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); - } -} -``` - -### 3. 트랜잭션 경계 정의 - -| 상황 | 설정 | 설명 | -|------|------|------| -| 기본 읽기 | `@Transactional(readOnly = true)` | 성능 최적화, Dirty Checking 비활성화 | -| 쓰기 작업 | `@Transactional` | 변경 감지 활성화 | -| 전파 정책 | REQUIRED (기본) | 기존 트랜잭션 참여 또는 신규 생성 | -| 격리 수준 | READ_COMMITTED | 기본값 | -| 롤백 조건 | unchecked exception | RuntimeException 자동 롤백 | - -**규칙**: 트랜잭션 시작/종료점은 Service Layer public 메서드. 다중 Repository 호출 시同一 트랜잭션에서 실행. - ---- - -## Alternatives - -| 대안 | 장점 | 단점 | 미선택 이유 | -|------|------|------|------------| -| Transaction Script | 단순, 직관적 | 복잡도 증가 시 유지보수 어려움 | 확장성 부족 | -| DDD 패턴 | 복잡 도메인 캡슐화 | 학습 곡선 높음, 과도한 추상화 | 현재 규모에서 과도한 복잡성 | -| Checked Exception | 컴파일 타임 강제 | 코드 복잡도 증가, 트랜잭션 충돌 | Spring 예외 처리와 불일치 | -| Repository DTO 반환 | 즉시 변환 가능 | 계층 결합, 테스트 어려움 | 책임 분리 위반 | - ---- - -## Consequences - -### 긍정적 결과 -- **명확한 책임 분리**: 계층별 집중으로 가독성 향상 -- **일관된 오류 처리**: 표준화된 예외/응답으로 API 일관성 확보 -- **테스트 용이성**: 계층별 Mock 가능 -- **트랜잭션 안전성**: 명확한 경계로 데이터 일관성 보장 - -### 부정적 결과 -- **초기 설정 비용**: 예외 계층, DTO 등 부가 코드 증가 -- **추가 추상화**: 간단 CRUD도 Service 경유 필요 - -### 재검토 조건 -- 도메인 복잡도大幅 증가 시 DDD 패턴 도입 검토 -- 성능 병목 발생 시 쿼리 최적화 diff --git a/src/main/java/com/klaroworks/runtime/controller/RoleMatrixController.java b/src/main/java/com/klaroworks/runtime/controller/RoleMatrixController.java deleted file mode 100644 index c6580c3..0000000 --- a/src/main/java/com/klaroworks/runtime/controller/RoleMatrixController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.klaroworks.runtime.controller; - -import com.klaroworks.runtime.dto.ApiResponse; -import com.klaroworks.runtime.dto.RoleCreateRequest; -import com.klaroworks.runtime.dto.RoleResponse; -import com.klaroworks.runtime.service.RoleMatrixService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import java.util.List; - -@RestController -@RequestMapping("/api/v1/roles") -@RequiredArgsConstructor -public class RoleMatrixController { - - private final RoleMatrixService roleMatrixService; - - @PostMapping - public ResponseEntity> createRole( - @Valid @RequestBody RoleCreateRequest request) { - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success(roleMatrixService.createRole(request))); - } - - @GetMapping("/{id}") - public ResponseEntity> getRoleById(@PathVariable Long id) { - return ResponseEntity.ok(ApiResponse.success(roleMatrixService.getRoleById(id))); - } - - @GetMapping - public ResponseEntity>> getAllRoles() { - return ResponseEntity.ok(ApiResponse.success(roleMatrixService.getAllRoles())); - } - - @PutMapping("/{id}") - public ResponseEntity> updateRole( - @PathVariable Long id, - @Valid @RequestBody RoleCreateRequest request) { - return ResponseEntity.ok(ApiResponse.success(roleMatrixService.updateRole(id, request))); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteRole(@PathVariable Long id) { - roleMatrixService.deleteRole(id); - return ResponseEntity.noContent().build(); - } -} diff --git a/src/main/java/com/klaroworks/runtime/dto/ApiResponse.java b/src/main/java/com/klaroworks/runtime/dto/ApiResponse.java deleted file mode 100644 index e10bb94..0000000 --- a/src/main/java/com/klaroworks/runtime/dto/ApiResponse.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.klaroworks.runtime.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.klaroworks.runtime.exception.ErrorCode; -import lombok.*; -import java.time.Instant; - -@Data @Builder @NoArgsConstructor @AllArgsConstructor -@JsonInclude(JsonInclude.Include.NON_NULL) -public class ApiResponse { - private boolean success; - private T data; - private ErrorInfo error; - private String timestamp; - - @Data @Builder @NoArgsConstructor @AllArgsConstructor - public static class ErrorInfo { - private String code; - private String message; - private Object details; - } - - public static ApiResponse success(T data) { - return ApiResponse.builder().success(true).data(data) - .timestamp(Instant.now().toString()).build(); - } - - public static ApiResponse error(ErrorCode code) { - return ApiResponse.builder().success(false) - .error(ErrorInfo.builder().code(code.getCode()).message(code.getMessage()).build()) - .timestamp(Instant.now().toString()).build(); - } - - public static ApiResponse error(ErrorCode code, Object details) { - return ApiResponse.builder().success(false) - .error(ErrorInfo.builder().code(code.getCode()).message(code.getMessage()).details(details).build()) - .timestamp(Instant.now().toString()).build(); - } -} diff --git a/src/main/java/com/klaroworks/runtime/exception/BaseException.java b/src/main/java/com/klaroworks/runtime/exception/BaseException.java deleted file mode 100644 index f7202af..0000000 --- a/src/main/java/com/klaroworks/runtime/exception/BaseException.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.klaroworks.runtime.exception; - -public abstract class BaseException extends RuntimeException { - private final ErrorCode errorCode; - - protected BaseException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } - - protected BaseException(ErrorCode errorCode, String message) { - super(message); - this.errorCode = errorCode; - } - - public ErrorCode getErrorCode() { return errorCode; } -} - -class BusinessException extends BaseException { - public BusinessException(ErrorCode errorCode) { super(errorCode); } - public BusinessException(ErrorCode errorCode, String message) { super(errorCode, message); } -} - -class RoleNotFoundException extends BusinessException { - public RoleNotFoundException(Long roleId) { - super(ErrorCode.ROLE_NOT_FOUND, String.format("Role not found with id: %d", roleId)); - } -} - -class DuplicateResourceException extends BusinessException { - public DuplicateResourceException(String resourceType, String identifier) { - super(ErrorCode.DUPLICATE_ROLE_NAME, String.format("%s '%s' already exists", resourceType, identifier)); - } -} diff --git a/src/main/java/com/klaroworks/runtime/exception/ErrorCode.java b/src/main/java/com/klaroworks/runtime/exception/ErrorCode.java deleted file mode 100644 index 850b044..0000000 --- a/src/main/java/com/klaroworks/runtime/exception/ErrorCode.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.klaroworks.runtime.exception; - -import org.springframework.http.HttpStatus; - -public enum ErrorCode { - INTERNAL_SERVER_ERROR("COMMON_001", "내부 서버 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - INVALID_REQUEST("COMMON_002", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST), - ROLE_NOT_FOUND("ROLE_001", "요청한 역할을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - DUPLICATE_ROLE_NAME("ROLE_002", "이미 존재하는 역할 이름입니다.", HttpStatus.CONFLICT), - PERMISSION_NOT_FOUND("PERM_001", "요청한 권한을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - PERMISSION_DENIED("PERM_002", "해당 작업에 대한 권한이 없습니다.", HttpStatus.FORBIDDEN); - - private final String code; - private final String message; - private final HttpStatus httpStatus; - - ErrorCode(String code, String message, HttpStatus httpStatus) { - this.code = code; - this.message = message; - this.httpStatus = httpStatus; - } - - public String getCode() { return code; } - public String getMessage() { return message; } - public HttpStatus getHttpStatus() { return httpStatus; } -} diff --git a/src/main/java/com/klaroworks/runtime/exception/GlobalExceptionHandler.java b/src/main/java/com/klaroworks/runtime/exception/GlobalExceptionHandler.java deleted file mode 100644 index 7ac66ac..0000000 --- a/src/main/java/com/klaroworks/runtime/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.klaroworks.runtime.exception; - -import com.klaroworks.runtime.dto.ApiResponse; -import lombok.extern.slf4j.Slf4j; -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; - -@Slf4j -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusinessException(BusinessException ex) { - log.warn("Business exception: {}", ex.getErrorCode().getCode()); - return ResponseEntity.status(ex.getErrorCode().getHttpStatus()) - .body(ApiResponse.error(ex.getErrorCode())); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(java.util.stream.Collectors.toMap( - e -> e.getField(), e -> e.getDefaultMessage())); - log.warn("Validation failed: {}", errors); - return ResponseEntity.badRequest() - .body(ApiResponse.error(ErrorCode.INVALID_REQUEST, errors)); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGenericException(Exception ex) { - log.error("Unexpected error", ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); - } -} diff --git a/src/main/java/com/klaroworks/runtime/service/RoleMatrixService.java b/src/main/java/com/klaroworks/runtime/service/RoleMatrixService.java deleted file mode 100644 index e473322..0000000 --- a/src/main/java/com/klaroworks/runtime/service/RoleMatrixService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.klaroworks.runtime.service; - -import com.klaroworks.runtime.dto.RoleCreateRequest; -import com.klaroworks.runtime.dto.RoleResponse; -import com.klaroworks.runtime.entity.Role; -import com.klaroworks.runtime.exception.DuplicateResourceException; -import com.klaroworks.runtime.exception.RoleNotFoundException; -import com.klaroworks.runtime.repository.RoleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.List; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class RoleMatrixService { - - private final RoleRepository roleRepository; - - @Transactional - public RoleResponse createRole(RoleCreateRequest request) { - if (roleRepository.existsByName(request.getName())) { - throw new DuplicateResourceException("Role", request.getName()); - } - Role role = Role.create(request.getName(), request.getDescription()); - return RoleResponse.from(roleRepository.save(role)); - } - - public RoleResponse getRoleById(Long id) { - return roleRepository.findById(id) - .map(RoleResponse::from) - .orElseThrow(() -> new RoleNotFoundException(id)); - } - - public List getAllRoles() { - return roleRepository.findAll().stream().map(RoleResponse::from).toList(); - } - - @Transactional - public RoleResponse updateRole(Long id, RoleCreateRequest request) { - Role role = roleRepository.findById(id) - .orElseThrow(() -> new RoleNotFoundException(id)); - role.update(request.getName(), request.getDescription()); - return RoleResponse.from(role); - } - - @Transactional - public void deleteRole(Long id) { - if (!roleRepository.existsById(id)) throw new RoleNotFoundException(id); - roleRepository.deleteById(id); - } -} diff --git a/src/test/java/com/klaroworks/runtime/service/RoleMatrixServiceTest.java b/src/test/java/com/klaroworks/runtime/service/RoleMatrixServiceTest.java deleted file mode 100644 index 8d8ce49..0000000 --- a/src/test/java/com/klaroworks/runtime/service/RoleMatrixServiceTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.klaroworks.runtime.service; - -import com.klaroworks.runtime.dto.RoleCreateRequest; -import com.klaroworks.runtime.dto.RoleResponse; -import com.klaroworks.runtime.entity.Role; -import com.klaroworks.runtime.exception.RoleNotFoundException; -import com.klaroworks.runtime.repository.RoleRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; -import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class RoleMatrixServiceTest { - - @Mock private RoleRepository roleRepository; - @InjectMocks private RoleMatrixService service; - private Role testRole; - - @BeforeEach - void setUp() { - testRole = Role.builder().id(1L).name("ADMIN").description("Administrator").build(); - } - - @Test - @DisplayName("역할 생성 시 save 호출 및 응답 반환") - void createRole_callsSaveAndReturnsResponse() { - when(roleRepository.save(any(Role.class))).thenReturn(testRole); - RoleResponse response = service.createRole(new RoleCreateRequest("ADMIN", "Administrator")); - assertNotNull(response); - assertEquals("ADMIN", response.getName()); - verify(roleRepository, times(1)).save(any(Role.class)); - } - - @Test - @DisplayName("존재하지 않는 역할 조회 시 RoleNotFoundException 발생") - void getRoleById_whenNotFound_throwsException() { - when(roleRepository.findById(999L)).thenReturn(Optional.empty()); - assertThrows(RoleNotFoundException.class, () -> service.getRoleById(999L)); - } - - @Test - @DisplayName("모든 역할 조회 시 Role 목록 반환") - void getAllRoles_returnsListOfRoles() { - when(roleRepository.findAll()).thenReturn(List.of(testRole)); - List roles = service.getAllRoles(); - assertEquals(1, roles.size()); - assertEquals("ADMIN", roles.get(0).getName()); - } -}