diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index fd55f05f..e3349be5 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -78,8 +78,10 @@ jobs: run: | ./gradlew unitTest ./gradlew integrationTest + ./gradlew javadoc if [ "${{ github.base_ref }}" = "main" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then ./gradlew e2eTest + ./gradlew openapi3 fi working-directory: apps/user-service @@ -161,6 +163,12 @@ jobs: - set-image-tag if: startsWith(github.ref, 'refs/tags/user-service-v') + permissions: + contents: read # 리포지토리 읽기 + pages: write # GitHub Pages 쓰기 + id-token: write # OIDC 토큰 + actions: read # Actions 읽기 + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -192,12 +200,13 @@ jobs: with: output: user-service-swagger-ui-${{ needs.set-image-tag.outputs.image-tag }} spec-file: openapi-spec/openapi3.yaml + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Deploy to GitHub Pages if: steps.check-openapi.outputs.openapi_exists == 'true' uses: peaceiris/actions-gh-pages@v3 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + personal_token: ${{ secrets.PERSONAL_TOKEN }} publish_dir: ./user-service-swagger-ui-${{ needs.set-image-tag.outputs.image-tag }} destination_dir: user-service/${{ needs.set-image-tag.outputs.image-tag }} diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile index ebc6e465..6ecb09c8 100644 --- a/apps/pre-processing-service/Dockerfile +++ b/apps/pre-processing-service/Dockerfile @@ -64,4 +64,4 @@ COPY . . ENV MECAB_PATH=/usr/lib/mecab/dic/ipadic # (권장 대안) 코드에서 uvicorn import 안 하고 프로세스 매니저로 실행하려면: -ENTRYPOINT ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", "-b", "0.0.0.0:8000"] \ No newline at end of file +ENTRYPOINT ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app.main:app", "-b", "0.0.0.0:8000", "--timeout", "120"] \ No newline at end of file diff --git a/apps/pre-processing-service/app/core/config.py b/apps/pre-processing-service/app/core/config.py index a710faba..ad7005ea 100644 --- a/apps/pre-processing-service/app/core/config.py +++ b/apps/pre-processing-service/app/core/config.py @@ -104,7 +104,7 @@ class BaseSettingsConfig(BaseSettings): MAX_IMAGE_SIZE_MB: int = 10 # 테스트/추가용 필드 - openai_api_key: Optional[str] = None # << 이 부분 추가 + OPENAI_API_KEY: Optional[str] = None # << 이 부분 추가 def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/apps/pre-processing-service/app/service/blog/blog_create_service.py b/apps/pre-processing-service/app/service/blog/blog_create_service.py index 29ce12b7..a66fa609 100644 --- a/apps/pre-processing-service/app/service/blog/blog_create_service.py +++ b/apps/pre-processing-service/app/service/blog/blog_create_service.py @@ -1,32 +1,27 @@ import json import logging -import os +from loguru import logger from datetime import datetime from typing import Dict, List, Optional, Any from openai import OpenAI -from dotenv import load_dotenv - +from app.core.config import settings from app.model.schemas import RequestBlogCreate from app.errors.BlogPostingException import * -# 환경변수 로드 -load_dotenv(".env.dev") - class BlogContentService: """RAG를 사용한 블로그 콘텐츠 생성 전용 서비스""" def __init__(self): # OpenAI API 키 설정 - self.openai_api_key = os.getenv("OPENAI_API_KEY") + self.openai_api_key = settings.OPENAI_API_KEY if not self.openai_api_key: raise ValueError("OPENAI_API_KEY가 .env.dev 파일에 설정되지 않았습니다.") # 인스턴스 레벨에서 클라이언트 생성 self.client = OpenAI(api_key=self.openai_api_key) logging.basicConfig(level=logging.INFO) - self.logger = logging.getLogger(__name__) def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: """ @@ -39,20 +34,26 @@ def generate_blog_content(self, request: RequestBlogCreate) -> Dict[str, Any]: Dict: {"title": str, "content": str, "tags": List[str]} 형태의 결과 """ try: - # 1. 콘텐츠 정보 정리 + logger.debug("[STEP1] 콘텐츠 컨텍스트 준비 시작") content_context = self._prepare_content_context(request) + logger.debug(f"[STEP1 완료] context length={len(content_context)}") - # 2. 프롬프트 생성 + logger.debug("[STEP2] 프롬프트 생성 시작") prompt = self._create_content_prompt(content_context, request) + logger.debug(f"[STEP2 완료] prompt length={len(prompt)}") - # 3. GPT를 통한 콘텐츠 생성 + logger.debug("[STEP3] OpenAI API 호출 시작") generated_content = self._generate_with_openai(prompt) + logger.debug(f"[STEP3 완료] generated length={len(generated_content)}") + + logger.debug("[STEP4] 콘텐츠 파싱 시작") + result = self._parse_generated_content(generated_content, request) + logger.debug("[STEP4 완료]") - # 4. 콘텐츠 파싱 및 구조화 - return self._parse_generated_content(generated_content, request) + return result except Exception as e: - self.logger.error(f"콘텐츠 생성 실패: {e}") + logger.error(f"콘텐츠 생성 실패: {e}") return self._create_fallback_content(request) def _prepare_content_context(self, request: RequestBlogCreate) -> str: diff --git a/apps/pre-processing-service/app/utils/llm_extractor.py b/apps/pre-processing-service/app/utils/llm_extractor.py index 3fb200a5..7140b184 100644 --- a/apps/pre-processing-service/app/utils/llm_extractor.py +++ b/apps/pre-processing-service/app/utils/llm_extractor.py @@ -1,8 +1,7 @@ import os from openai import OpenAI from dotenv import load_dotenv - -load_dotenv() +from app.core.config import settings class LLMExtractor: @@ -13,7 +12,7 @@ def __init__(self, model="gpt-4o"): :param model: 사용할 LLM 모델 이름 """ - self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + self.client = OpenAI(api_key=settings.OPENAI_API_KEY) self.model = model def login_extraction_prompt(self, target_description: str, html: str): diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 7d4d7e0a..94750654 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'site.icebang' -version = '0.0.1-alpha-SNAPSHOT' +version = '0.0.1-beta-STABLE' description = 'Ice bang - fast campus team4' java { @@ -21,7 +21,6 @@ configurations { compileOnly { extendsFrom annotationProcessor } - // Spring Boot의 기본 로깅(Logback) 제외 all { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } @@ -50,6 +49,7 @@ dependencies { // Retry implementation 'org.springframework.retry:spring-retry' + // logging implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml' implementation 'org.apache.logging.log4j:log4j-layout-template-json' @@ -57,11 +57,8 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'org.apache.httpcomponents:httpcore:4.4.16' - // 비동기 로깅 -// implementation 'com.lmax:disruptor:3.4.4' -// implementation 'org.apache.commons:commons-dbcp2' -// implementation 'org.apache.commons:commons-pool2' + // micrometer & actuator implementation "io.micrometer:micrometer-tracing-bridge-brave" implementation "io.micrometer:micrometer-tracing" implementation 'io.micrometer:micrometer-registry-prometheus' diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java index 0f99e59b..8a986b4d 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java @@ -4,15 +4,72 @@ import lombok.Data; +/** + * 공통 APi 응답 DTO 클래스입니다. + * + *

REST API의 응답 형식을 표준화하기 위해 사용됩니다. 모든 응답은 성공 여부({@link #success}), 응답 데이터({@link #data}), 응답 + * 메시지({@link #message}), 그리고 HTTP 상태 코드({@link #status})를 포함합니다. + * + *

사용 예시: + * + *

{@code
+ * // 성공 응답 생성
+ * ApiResponse response = ApiResponse.success(userDto);
+ *
+ * // 메시지를 포함한 성공 응답
+ * ApiResponse response = ApiResponse.success(userDto, "회원 조회 성공");
+ *
+ * // 오류 응답 생성
+ * ApiResponse errorResponse = ApiResponse.error("잘못된 요청입니다.", HttpStatus.BAD_REQUEST);
+ * }
+ * + * @param 응답 데이터의 타입 + * @author jys01012@gmail.com + * @since v0.0.1-alpha + * @see HttpStatus + * @see lombok.Data + */ @Data public class ApiResponse { + /** + * 요청 처리 성공 여부. + * + *

true: 요청이 정상적으로 처리됨 false: 요청 처리 중 오류 발생 + */ private boolean success; + + /** + * 실제 응답 데이터(payload). + * + *

요청이 성공적으로 처리되었을 경우 반환되는 데이터이며, 실패 시에는 {@code null}일 수 있습니다. + */ private T data; + + /** + * 응답 메세지. + * + *

성공 또는 오류 상황을 설명하는 메시지를 담습니다. 클라이언트에서 사용자에게 직접 표시할 수도 있습니다. + */ private String message; + + /** + * HTTP 상태 코드. + * + *

Spring의 {@link HttpStatus} 열거형을 사용합니다. + */ private HttpStatus status; // HttpStatus로 변경 + /** 기본 생성자입니다. 모든 필드가 기본값으로 초기화됩니다. */ public ApiResponse() {} + /** + * 모든 필드를 초기화하는 생성자. + * + * @param success 요청 성공 여부 + * @param data 응답 데이터 + * @param message 응답 메시지 + * @param status HTTP 상태 코드 + */ public ApiResponse(boolean success, T data, String message, HttpStatus status) { this.success = success; this.data = data; @@ -20,18 +77,50 @@ public ApiResponse(boolean success, T data, String message, HttpStatus status) { this.status = status; } + /** + * 성공 응답을 생성합니다. (기본 메시지: "OK", 상태: 200 OK) + * + * @param data 응답 데이터 + * @param 데이터 타입 + * @return 성공 응답 객체 + */ public static ApiResponse success(T data) { return new ApiResponse<>(true, data, "OK", HttpStatus.OK); } + /** + * 성공 응답을 생성합니다. (상태: 200 OK) + * + * @param data 응답 데이터 + * @param message 사용자 정의 메시지 + * @param 데이터 타입 + * @return 성공 응답 객체 + */ public static ApiResponse success(T data, String message) { return new ApiResponse<>(true, data, message, HttpStatus.OK); } + /** + * 성공 응답을 생성합니다. + * + * @param data 응답 데이터 + * @param message 사용자 정의 메시지 + * @param status 사용자 정의 상태 코드 + * @param 데이터 타입 + * @return 성공 응답 객체 + */ public static ApiResponse success(T data, String message, HttpStatus status) { return new ApiResponse<>(true, data, message, status); } + /** + * 오류 응답을 생성합니다. + * + * @param message 오류 메시지 + * @param status HTTP 상태 코드 + * @param 데이터 타입 + * @return 오류 응답 객체 + */ public static ApiResponse error(String message, HttpStatus status) { return new ApiResponse<>(false, null, message, status); } diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java index 5f2f0d30..6083bc43 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java @@ -2,23 +2,90 @@ import lombok.Data; +/** + * 페이징, 검색, 정렬, 필터링을 위한 공통 매개변수 클래스입니다. + * + *

목록 조회 API에서 공통적으로 사용되는 요청 파라미터를 정의합니다. 현재 페이지 번호({@link #current}), 페이지 크기({@link #pageSize}), + * 검색어({@link #search}), 정렬 조건({@link #sorters}), 필터 조건({@link #filters})를 포함합니다. + * + *

사용 예시: + * + *

{@code
+ * PageParams params = new PageParams();
+ * params.setCurrent(2);
+ * params.setPageSize(20);
+ * params.setSearch("회원");
+ *
+ * int offset = params.getOffset(); // 20
+ * boolean searchable = params.hasSearch(); // true
+ * }
+ * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + * @see lombok.Data + */ @Data public class PageParams { + /** + * 현재 페이지 번호 (1부터 시작). + * + *

1부터 시작하며, 기본값은 1입니다. 0 이하의 값은 유효하지 않습니다. + */ private int current = 1; + + /** + * 한 펭지에 표시할 데이터 개수. + * + *

한 페이지에 표시할 항목의 개수를 지정합니다. 기본값은 10개이며, 일반적으로 10, 20, 50, 100 등의 값을 사용합니다. + */ private int pageSize = 10; + + /** + * 검색어. + * + *

목록에서 특정 조건으로 검색할 때 사용되는 키워드입니다. {@code null}이거나 빈 문자열인 경우 검색 조건이 적용되지 않습니다. + */ private String search; + + /** + * 정렬 조건 배열. + * + *

예: {@code ["name:asc", "createdAt:desc"]} API 설계에 따라 "필드명:정렬방향" 형식을 권장합니다. {@code null}이거나 빈 + * 배열인 경우 기본 정렬이 적용됩니다. + */ private String[] sorters; + + /** + * 필터링 조건 배열. + * + *

예: {@code ["status:active", "role:admin"]} 각 요소는 특정 필드에 대한 필터링 조건을 나타냅니다. 형태는 구현에 따라 다를 수 + * 있습니다. + */ private String[] filters; - // 계산된 offset + /** + * 페이징 처리를 위한 offset(시작 위치)을 계산합니다. + * + * @return (current - 1) * pageSize + */ public int getOffset() { return (current - 1) * pageSize; } + /** + * 검색어가 유효하게 존재하는지 확인합니다. + * + * @return 검색어가 null이 아니고 공백이 아닌 경우 true + */ public boolean hasSearch() { return search != null && !search.trim().isEmpty(); } + /** + * 정렬 조건이 존재하는지 확인합니다. + * + * @return 정렬 조건 배열이 null이 아니고, 1개 이상 있는 경우 true + */ public boolean hasSorters() { return sorters != null && sorters.length > 0; } diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java index 4a2a8bfa..0982be0a 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java @@ -6,17 +6,67 @@ import lombok.Data; import lombok.NoArgsConstructor; +/** + * 페이징 처리된 결과 DTO 클래스. + * + *

목록 조회 API에서 페이징된 데이터를 반환할 때 사용됩니다. 실제 데이터 목록({@link #data}), 전체 개수({@link #total}), 현재 페이지 + * 번호({@link #current}), 페이지 크기({@link #pageSize}), 전체 페이지 수({@link #totalPages}), 다음/이전 페이지 여부를 + * 포함합니다. + * + *

사용 예시: + * + *

{@code
+ * PageParams params = new PageParams();
+ * params.setCurrent(2);
+ * params.setPageSize(10);
+ *
+ * // Repository나 Mapper에서 데이터를 가져와 PageResult 생성
+ * PageResult pageResult = PageResult.from(
+ *     params,
+ *     () -> userRepository.findUsers(params.getOffset(), params.getPageSize()),
+ *     () -> userRepository.countUsers()
+ * );
+ *
+ * boolean hasNext = pageResult.isHasNext(); // true or false
+ * }
+ * + * @param 데이터 타입 + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ @Data @NoArgsConstructor public class PageResult { + + /** 현재 페이지에 포함된 데이터 목록. */ private List data; + + /** 전체 데이터 개수. */ private int total; + + /** 현재 페이지 번호 (1부터 시작). */ private int current; + + /** 한 페이지에 포함되는 데이터 개수. */ private int pageSize; + + /** 전체 페이지 수. */ private int totalPages; + + /** 다음 페이지가 존재하는지 여부. */ private boolean hasNext; + + /** 이전 페이지가 존재하는지 여부. */ private boolean hasPrevious; + /** + * 생성자. + * + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param current 현재 페이지 번호 + * @param pageSize 페이지 크기 + */ public PageResult(List data, int total, int current, int pageSize) { this.data = data; this.total = total; @@ -25,24 +75,55 @@ public PageResult(List data, int total, int current, int pageSize) { calculatePagination(); } - // 페이징 계산 로직 분리 + /** + * 페이징 관련 필드를 계산합니다. + * + *

totalPages, hasNext, hasPrevious 값을 설정합니다. + */ private void calculatePagination() { this.totalPages = total > 0 ? (int) Math.ceil((double) total / pageSize) : 0; this.hasNext = current < totalPages; this.hasPrevious = current > 1; } - // 기존 of 메서드 + /** + * PageResult 객체를 생성합니다. + * + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param current 현재 페이지 번호 + * @param pageSize 페이지 크기 + * @param 데이터 타입 + * @return PageResult 객체 + */ public static PageResult of(List data, int total, int current, int pageSize) { return new PageResult<>(data, total, current, pageSize); } - // PageParams를 받는 of 메서드 + /** + * PageParams를 기반으로 PageResult 객체를 생성합니다. + * + * @param data 현재 페이지 데이터 + * @param total 전체 데이터 개수 + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param 데이터 타입 + * @return PageResult 객체 + */ public static PageResult of(List data, int total, PageParams pageParams) { return new PageResult<>(data, total, pageParams.getCurrent(), pageParams.getPageSize()); } - // 함수형 인터페이스를 활용한 from 메서드 (트랜잭션 내에서 실행) + /** + * 함수형 인터페이스를 활용해 PageResult를 생성합니다. + * + *

데이터 조회와 카운트 조회를 별도의 Supplier로 받아 트랜잭션 내에서 실행할 수 있습니다. + * + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param dataSupplier 데이터 조회 함수 + * @param countSupplier 전체 개수 조회 함수 + * @param 데이터 타입 + * @return PageResult 객체 + */ public static PageResult from( PageParams pageParams, Supplier> dataSupplier, Supplier countSupplier) { List data = dataSupplier.get(); @@ -50,27 +131,50 @@ public static PageResult from( return new PageResult<>(data, total, pageParams.getCurrent(), pageParams.getPageSize()); } - // 빈 페이지 결과 생성 + /** + * 비어 있는 페이지 결과를 생성합니다. + * + * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param 데이터 타입 + * @return 빈 PageResult 객체 + */ public static PageResult empty(PageParams pageParams) { return new PageResult<>(List.of(), 0, pageParams.getCurrent(), pageParams.getPageSize()); } - // 빈 페이지 결과 생성 (기본값) + /** + * 기본값(1페이지, 10개)으로 비어 있는 페이지 결과를 생성합니다. + * + * @param 데이터 타입 + * @return 빈 PageResult 객체 + */ public static PageResult empty() { return new PageResult<>(List.of(), 0, 1, 10); } - // 데이터가 있는지 확인 + /** + * 현재 페이지에 데이터가 있는지 확인합니다. + * + * @return 데이터가 존재하면 true + */ public boolean hasData() { return data != null && !data.isEmpty(); } - // 첫 번째 페이지인지 확인 + /** + * 현재 페이지가 첫 번째 페이지인지 확인합니다. + * + * @return 첫 번째 페이지면 true + */ public boolean isFirstPage() { return current == 1; } - // 마지막 페이지인지 확인 + /** + * 현재 페이지가 마지막 페이지인지 확인합니다. + * + * @return 마지막 페이지면 true + */ public boolean isLastPage() { return current == totalPages; } diff --git a/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java b/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java index e673ab86..b8c4a8a2 100644 --- a/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java +++ b/apps/user-service/src/main/java/site/icebang/common/exception/DuplicateDataException.java @@ -1,23 +1,87 @@ package site.icebang.common.exception; +/** + * 데이터 중복 상황에서 발생하는 예외 클래스입니다. + * + *

이 예외는 데이터베이스나 컬렉션에서 이미 존재하는 데이터를 중복해서 생성하거나 저장하려고 할 때 발생합니다. 주로 유니크 제약 조건 위반이나 비즈니스 로직상 중복을 + * 허용하지 않는 경우에 사용됩니다. + * + *

사용 예제:

+ * + *
{@code
+ * // 사용자 이메일 중복 체크
+ * if (userRepository.existsByEmail(email)) {
+ *     throw new DuplicateDataException("이미 존재하는 이메일입니다: " + email);
+ * }
+ *
+ * // 상품 코드 중복 체크
+ * try {
+ *     productService.createProduct(product);
+ * } catch (DataIntegrityViolationException e) {
+ *     throw new DuplicateDataException("중복된 상품 코드입니다", e);
+ * }
+ * }
+ * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ public class DuplicateDataException extends RuntimeException { + /** + * 상세 메시지 없이 새로운 {@code DuplicateDataException}을 생성합니다. + * + * @since v0.0.1-alpha + */ public DuplicateDataException() { super(); } + /** + * 지정된 상세 메시지와 함께 새로운 {@code DuplicateDataException}을 생성합니다. + * + * @param message 상세 메시지 (나중에 {@link Throwable#getMessage()} 메서드로 검색됨) + * @since v0.0.1-alpha + */ public DuplicateDataException(String message) { super(message); } + /** + * 지정된 상세 메시지와 원인과 함께 새로운 {@code DuplicateDataException}을 생성합니다. + * + *

{@code cause}와 연관된 상세 메시지가 이 예외의 상세 메시지에 자동으로 포함되지는 않습니다. + * + * @param message 상세 메시지 (나중에 {@link Throwable#getMessage()} 메서드로 검색됨) + * @param cause 원인 (나중에 {@link Throwable#getCause()} 메서드로 검색됨). {@code null} 값이 허용되며, 원인이 존재하지 않거나 + * 알 수 없음을 나타냄 + * @since v0.0.1-alpha + */ public DuplicateDataException(String message, Throwable cause) { super(message, cause); } + /** + * 지정된 원인과 상세 메시지와 함께 새로운 {@code DuplicateDataException}을 생성합니다. 상세 메시지는 {@code (cause==null ? + * null : cause.toString())}로 설정됩니다. (일반적으로 {@code cause}의 클래스와 상세 메시지를 포함) + * + * @param cause 원인 (나중에 {@link Throwable#getCause()} 메서드로 검색됨). {@code null} 값이 허용되며, 원인이 존재하지 않거나 + * 알 수 없음을 나타냄 + * @since v0.0.1-alpha + */ public DuplicateDataException(Throwable cause) { super(cause); } + /** + * 지정된 상세 메시지, 원인, suppression 활성화 여부, 그리고 writable stack trace 여부와 함께 새로운 {@code + * DuplicateDataException}을 생성합니다. + * + * @param message 상세 메시지 + * @param cause 원인. ({@code null} 값이 허용되며, 원인이 존재하지 않거나 알 수 없음을 나타냄) + * @param enableSuppression suppression이 활성화되는지 또는 비활성화되는지 여부 + * @param writableStackTrace stack trace가 writable해야 하는지 여부 + * @since v0.0.1-alpha + */ protected DuplicateDataException( String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); diff --git a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java index 25d41d29..da3ae215 100644 --- a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java +++ b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java @@ -3,6 +3,42 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +/** + * 페이징 가능한 서비스 인터페이스. + * + *

엔티티나 DTO 목록을 페이징 처리하여 반환해야 하는 서비스에서 구현합니다. 공통적으로 {@link PageParams} 요청 파라미터를 받아 {@link + * PageResult} 응답을 제공합니다. + * + *

사용 예시: + * + *

{@code
+ * @Service
+ * public class UserService implements PageableService {
+ *
+ *     private final UserRepository userRepository;
+ *
+ *     @Override
+ *     public PageResult getPagedResult(PageParams pageParams) {
+ *         List users = userRepository.findUsers(
+ *             pageParams.getOffset(), pageParams.getPageSize()
+ *         );
+ *         int total = userRepository.countUsers();
+ *         return PageResult.of(users, total, pageParams);
+ *     }
+ * }
+ * }
+ * + * @param 페이징 처리할 데이터 타입 + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ public interface PageableService { + + /** + * 페이징 처리된 결과를 반환합니다. + * + * @param pageParams 페이징 및 검색/정렬 요청 파라미터 + * @return 페이징 처리된 결과 객체 + */ PageResult getPagedResult(PageParams pageParams); } diff --git a/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java b/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java index c77189c2..5bab1f87 100644 --- a/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java +++ b/apps/user-service/src/main/java/site/icebang/common/utils/RandomPasswordGenerator.java @@ -8,16 +8,61 @@ import org.springframework.stereotype.Component; +/** + * 보안이 강화된 랜덤 패스워드를 생성하는 유틸리티 클래스입니다. + * + *

이 클래스는 소문자, 대문자, 숫자, 특수문자를 포함한 강력한 패스워드를 생성합니다. 생성된 패스워드는 각 문자 유형을 최소 하나씩 포함하도록 보장됩니다. + * + *

사용 예제:

+ * + *
{@code
+ * @Autowired
+ * private RandomPasswordGenerator passwordGenerator;
+ *
+ * // 기본 길이(12자)로 패스워드 생성
+ * String password1 = passwordGenerator.generate();
+ *
+ * // 사용자 지정 길이로 패스워드 생성
+ * String password2 = passwordGenerator.generate(16);
+ * }
+ * + * @author jys01012@gmail.com + * @since v0.0.1-alpha + */ @Component public class RandomPasswordGenerator { + /** 소문자 알파벳 문자 집합 */ private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + + /** 대문자 알파벳 문자 집합 */ private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + /** 숫자 문자 집합 */ private static final String DIGITS = "0123456789"; + + /** 특수문자 집합 */ private static final String SPECIAL_CHARS = "!@#$%^&*()-_+=<>?"; + /** 암호학적으로 안전한 난수 생성기 */ private final SecureRandom random = new SecureRandom(); + /** + * 지정된 길이의 랜덤 패스워드를 생성합니다. + * + *

생성되는 패스워드는 다음 규칙을 준수합니다: + * + *

    + *
  • 최소 길이는 8자입니다 + *
  • 소문자, 대문자, 숫자, 특수문자를 각각 최소 1개씩 포함합니다 + *
  • 모든 문자는 무작위로 섞여서 배치됩니다 + *
+ * + * @param length 생성할 패스워드의 길이 (8보다 작으면 8로 조정됩니다) + * @return 생성된 랜덤 패스워드 + * @throws IllegalArgumentException length가 음수인 경우 + * @since v0.0.1-alpha + */ public String generate(int length) { if (length < 8) { length = 8; @@ -49,13 +94,28 @@ public String generate(int length) { .toString(); } - // 특정 문자열에서 랜덤으로 한 문자 선택 + /** + * 지정된 문자 집합에서 랜덤으로 한 문자를 선택합니다. + * + * @param charSet 선택할 문자들이 포함된 문자열 + * @return 선택된 랜덤 문자 + * @throws IllegalArgumentException charSet이 null이거나 빈 문자열인 경우 + * @since v0.0.1-alpha + */ private char getRandomChar(String charSet) { int randomIndex = random.nextInt(charSet.length()); return charSet.charAt(randomIndex); } - // 기본 길이로 비밀번호 생성 + /** + * 기본 길이(12자)로 랜덤 패스워드를 생성합니다. + * + *

이는 {@code generate(12)}를 호출하는 것과 동일합니다. + * + * @return 12자 길이의 랜덤 패스워드 + * @see #generate(int) + * @since v0.0.1-alpha + */ public String generate() { return generate(12); // 기본 길이를 12로 설정 } diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java index d8348e7e..667637b1 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java @@ -7,12 +7,39 @@ import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.workflow.scheduler.WorkflowTriggerJob; +/** + * Spring Quartz 스케줄러의 Job과 Trigger를 동적으로 관리하는 서비스 클래스입니다. + * + *

이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, + * Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 역할을 담당합니다. + * + *

주요 기능:

+ *
    + *
  • DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트
  • + *
  • 기존에 등록된 Quartz 스케줄 삭제
  • + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Service @RequiredArgsConstructor public class QuartzScheduleService { + + /** Quartz 스케줄러의 메인 인스턴스 */ private final Scheduler scheduler; + /** + * DB에 정의된 Schedule 객체를 기반으로 Quartz에 스케줄을 등록하거나 업데이트합니다. + * + *

지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 + * 새로운 정보로 다시 생성하여 스케줄을 업데이트합니다. {@code JobDataMap}을 통해 + * 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다. + * + * @param schedule Quartz에 등록할 스케줄 정보를 담은 도메인 모델 객체 + * @since v0.1.0 + */ public void addOrUpdateSchedule(Schedule schedule) { try { JobKey jobKey = JobKey.jobKey("workflow-" + schedule.getWorkflowId()); @@ -40,6 +67,12 @@ public void addOrUpdateSchedule(Schedule schedule) { } } + /** + * 지정된 워크플로우 ID와 연결된 Quartz 스케줄을 삭제합니다. + * + * @param workflowId 삭제할 스케줄에 연결된 워크플로우의 ID + * @since v0.1.0 + */ public void deleteSchedule(Long workflowId) { try { JobKey jobKey = JobKey.jobKey("workflow-" + workflowId); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java index e61faa75..38c1ae38 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java @@ -7,6 +7,13 @@ public class ExecutionMdcManager { private static final String SOURCE_ID = "sourceId"; private static final String EXECUTION_TYPE = "executionType"; + private static final String TRACE_ID = "traceID"; + + public void setWorkflowContext(Long workflowId, String traceId) { + MDC.put(SOURCE_ID, workflowId.toString()); + MDC.put(EXECUTION_TYPE, "WORKFLOW"); + MDC.put(TRACE_ID, traceId); + } public void setWorkflowContext(Long workflowId) { MDC.put(SOURCE_ID, workflowId.toString()); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java index d22e2a68..aec0bb36 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java @@ -10,53 +10,61 @@ import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; import site.icebang.domain.workflow.dto.WorkflowRunDto; +/** + * 워크플로우 실행 히스토리 관련 데이터베이스 매퍼 인터페이스입니다. + * + *

워크플로우, 작업(Job), 태스크(Task)의 실행 기록과 관련된 데이터 조회를 담당합니다. + * + * @author jys01012@gmail.com + * @since v0.0.1-beta + */ @Mapper public interface WorkflowHistoryMapper { /** - * 워크플로우 실행 정보 조회 + * 워크플로우 실행 정보를 조회합니다. * * @param runId workflow_run.id - * @return WorkflowRunDto + * @return 워크플로우 실행 정보 */ WorkflowRunDto selectWorkflowRun(Long runId); /** - * 워크플로우 실행의 Job 목록 조회 + * 워크플로우 실행의 작업 목록을 조회합니다. * * @param workflowRunId workflow_run.id - * @return List + * @return 작업 실행 정보 목록 */ List selectJobRunsByWorkflowRunId(Long workflowRunId); /** - * Job 실행의 Task 목록 조회 + * 작업 실행의 태스크 목록을 조회합니다. * * @param jobRunId job_run.id - * @return List + * @return 태스크 실행 정보 목록 */ List selectTaskRunsByJobRunId(Long jobRunId); /** - * 워크플로우 실행 TraceId 조회 + * 워크플로우 실행 TraceId를 조회합니다. * * @param runId workflow_run.id - * @return String traceId + * @return 추적 ID 문자열 */ String selectTraceIdByRunId(Long runId); /** - * 워크플로우 런 페이지네이션 + * 페이지네이션을 적용한 워크플로우 히스토리 목록을 조회합니다. * - * @param pageParams pageParams - * @return List + * @param pageParams 페이지 매개변수 + * @return 워크플로우 히스토리 정보 목록 */ List selectWorkflowHistoryList(PageParams pageParams); /** - * 워크플로우 런 인스턴스 개수 조회 + * 워크플로우 런 인스턴스의 총 개수를 조회합니다. * - * @param pageParams pageParams - * @return 결과 개수 + * @param pageParams 페이지 매개변수 + * @return 총 결과 개수 */ int selectWorkflowHistoryCount(PageParams pageParams); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java index f8ad27c8..72c9f078 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java @@ -5,19 +5,53 @@ import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.model.TaskRun; -/** 워크플로우의 개별 Task를 실행하는 모든 Runner가 구현해야 할 인터페이스 */ +/** + * 워크플로우 내 개별 Task의 실행을 담당하는 모든 Runner 객체가 구현해야 할 공통 인터페이스입니다. + * + *

이 인터페이스는 전략 패턴(Strategy Pattern)의 '전략(Strategy)' 역할을 수행합니다. {@code WorkflowExecutionService}는 + * 이 인터페이스에 의존하여, Task의 타입('FastAPI' 등)에 따라 적절한 Runner 구현체를 선택하고 실행 로직을 위임합니다. + * + *

주요 구성 요소:

+ * + *
    + *
  • TaskExecutionResult: 모든 Task 실행 결과가 따라야 할 표준 응답 형식을 정의하는 내부 Record + *
  • execute: Task 실행을 위한 단일 추상 메소드 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ public interface TaskRunner { - /** Task 실행 결과를 담는 Record. status: SUCCESS 또는 FAILED message: 실행 결과 또는 에러 메시지 */ + /** + * Task 실행 결과를 담는 불변(Immutable) 데이터 객체(Record)입니다. + * + *

실행의 성공/실패 여부(status)와 결과 메시지(message)를 표준화된 방식으로 반환합니다. + * + * @param status 실행 상태 ("SUCCESS" 또는 "FAILED") + * @param message 실행 결과 (성공 시 응답 Body, 실패 시 에러 메시지) + * @since v0.1.0 + */ record TaskExecutionResult(String status, String message) { public static TaskExecutionResult success(String message) { return new TaskExecutionResult("SUCCESS", message); } + /** + * 실패 결과를 생성하는 정적 팩토리 메소드입니다. + * + * @param message 실패 원인 메시지 + * @return status가 "FAILED"로 설정된 결과 객체 + */ public static TaskExecutionResult failure(String message) { return new TaskExecutionResult("FAILED", message); } + /** + * 해당 결과가 실패했는지 여부를 반환합니다. + * + * @return 실패했다면 true, 아니면 false + */ public boolean isFailure() { return "FAILED".equals(this.status); } @@ -26,10 +60,11 @@ public boolean isFailure() { /** * 특정 Task를 실행합니다. * - * @param task 실행할 Task의 정적 정의 - * @param taskRun 현재 실행에 대한 기록 객체 - * @param requestBody 동적으로 생성된 요청 데이터 - * @return Task 실행 결과 + * @param task 실행할 Task의 정적 정의 (이름, 타입, 파라미터 등) + * @param taskRun 현재 실행에 대한 DB 기록 객체 (ID 추적 등에 사용) + * @param requestBody {@code TaskBodyBuilder}에 의해 동적으로 생성된 최종 요청 Body + * @return Task 실행 결과를 담은 {@code TaskExecutionResult} 객체 + * @since v0.1.0 */ TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java index 136a9d93..95a0e89c 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/FastApiTaskRunner.java @@ -13,12 +13,43 @@ import site.icebang.domain.workflow.runner.TaskRunner; import site.icebang.external.fastapi.adapter.FastApiAdapter; +/** + * FastAPI 서버와 통신하는 Task를 실행하는 구체적인 Runner 구현체입니다. + * + *

이 클래스는 {@code TaskRunner} 인터페이스를 구현하며, Task의 타입이 'FastAPI'일 때 선택됩니다. 실제 HTTP 통신은 {@code + * FastApiAdapter}에 위임하고, 이 클래스는 워크플로우의 {@code Task} 객체를 {@code FastApiAdapter}가 이해할 수 있는 호출 형식으로 + * 변환하는 **어댑터(Adapter)** 역할을 수행합니다. + * + *

주요 기능:

+ * + *
    + *
  • Task 파라미터에서 endpoint와 method 정보 파싱 + *
  • 사전에 생성된 Request Body를 {@code FastApiAdapter}에 전달하여 실행 위임 + *
  • 어댑터의 실행 결과를 {@code TaskExecutionResult} 형식으로 변환하여 반환 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Component("fastapiTaskRunner") @RequiredArgsConstructor public class FastApiTaskRunner implements TaskRunner { + /** FastAPI 서버와의 통신을 전담하는 어댑터 */ private final FastApiAdapter fastApiAdapter; + /** + * FastAPI 타입의 Task를 실행합니다. + * + *

Task의 파라미터에서 엔드포인트와 HTTP 메소드를 추출하고, {@code WorkflowExecutionService}로부터 전달받은 동적 Request + * Body를 사용하여 {@code FastApiAdapter}를 호출합니다. + * + * @param task 실행할 Task의 정적 정의 + * @param taskRun 현재 실행에 대한 기록 객체 + * @param requestBody {@code TaskBodyBuilder}에 의해 동적으로 생성된 최종 요청 Body + * @return {@code FastApiAdapter}의 호출 결과를 담은 {@code TaskExecutionResult} 객체 + * @since v0.1.0 + */ @Override public TaskExecutionResult execute(Task task, TaskRun taskRun, ObjectNode requestBody) { JsonNode params = task.getParameters(); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java index 196c1fa0..a3076d1f 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java @@ -9,12 +9,38 @@ import site.icebang.domain.workflow.service.WorkflowExecutionService; +/** + * Spring Quartz 스케줄러에 의해 실행되는 실제 작업(Job) 클래스입니다. + * + *

이 클래스는 Quartz의 스케줄링 세계와 애플리케이션의 비즈니스 로직을 연결하는 **브릿지(Bridge)** 역할을 수행합니다. Quartz의 Trigger가 정해진 + * 시간에 발동하면, Quartz 엔진은 이 Job을 인스턴스화하고 {@code executeInternal} 메소드를 호출합니다. + * + *

주요 기능:

+ * + *
    + *
  • 스케줄 실행 시점에 {@code JobDataMap}에서 실행할 워크플로우 ID를 추출 + *
  • 추출된 ID를 사용하여 {@code WorkflowExecutionService}를 호출하여 실제 워크플로우 실행을 위임 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Component @RequiredArgsConstructor public class WorkflowTriggerJob extends QuartzJobBean { private final WorkflowExecutionService workflowExecutionService; + /** + * Quartz 스케줄러에 의해 트리거가 발동될 때 호출되는 메인 실행 메소드입니다. + * + *

이 메소드는 실행 컨텍스트({@code JobExecutionContext})에서 {@code JobDataMap}을 통해 스케줄 등록 시점에 저장된 + * 'workflowId'를 추출합니다. 그 후, 해당 ID를 파라미터로 하여 {@code WorkflowExecutionService}의 {@code + * executeWorkflow} 메소드를 호출하여 실제 비즈니스 로직의 실행을 시작합니다. + * + * @param context Quartz가 제공하는 현재 실행에 대한 런타임 정보. JobDetail과 Trigger 정보를 포함합니다. + * @since v0.1.0 + */ @Override protected void executeInternal(JobExecutionContext context) { Long workflowId = context.getJobDetail().getJobDataMap().getLong("workflowId"); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java index 80cf44a3..29f28d98 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java @@ -13,20 +13,50 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.model.TaskRun; import site.icebang.domain.workflow.runner.TaskRunner; -@Slf4j +/** + * 워크플로우 내 개별 Task의 실행과 재시도 정책을 전담하는 서비스입니다. + * + *

이 클래스는 {@code WorkflowExecutionService}로부터 Task 실행 책임을 위임받습니다. Spring AOP의 '자기 + * 호출(Self-invocation)' 문제를 회피하고, 재시도 로직을 비즈니스 흐름과 분리하기 위해 별도의 서비스로 구현되었습니다. + * + *

주요 기능:

+ * + *
    + *
  • {@code @Retryable} 어노테이션을 통한 선언적 재시도 처리 + *
  • {@code @Recover} 어노테이션을 이용한 최종 실패 시 복구 로직 수행 + *
  • Task 타입에 맞는 적절한 {@code TaskRunner} 선택 및 실행 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Service @RequiredArgsConstructor -public class TaskExecutionService { // 📌 클래스 이름 변경 +public class TaskExecutionService { + /** 워크플로우 실행 이력 전용 로거 */ private static final Logger workflowLogger = LoggerFactory.getLogger("WORKFLOW_HISTORY"); + private final Map taskRunners; - /** RestClientException 발생 시, 5초 간격으로 최대 3번 재시도합니다. */ + /** + * 지정된 Task를 재시도 정책을 적용하여 실행합니다. + * + *

HTTP 통신 오류 등 {@code RestClientException} 발생 시, 5초의 고정된 간격({@code Backoff})으로 최대 3회({@code + * maxAttempts})까지 실행을 재시도합니다. 지원하지 않는 Task 타입의 경우 재시도 없이 즉시 {@code IllegalArgumentException}을 + * 발생시킵니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param taskRun 현재 실행에 대한 기록 객체 + * @param requestBody 동적으로 생성된 요청 Body + * @return Task 실행 결과 + * @throws IllegalArgumentException 지원하지 않는 Task 타입일 경우 + * @since v0.1.0 + */ @Retryable( value = {RestClientException.class}, maxAttempts = 3, @@ -45,7 +75,19 @@ public TaskRunner.TaskExecutionResult executeWithRetry( return runner.execute(task, taskRun, requestBody); } - /** 모든 재시도가 실패했을 때 마지막으로 호출될 복구 메소드입니다. */ + /** + * {@code @Retryable} 재시도가 모두 실패했을 때 호출되는 복구 메소드입니다. + * + *

이 메소드는 {@code executeWithRetry} 메소드와 동일한 파라미터 시그니처를 가지며, 발생한 예외를 첫 번째 파라미터로 추가로 받습니다. 최종 실패 + * 상태를 기록하고 실패 결과를 반환하는 역할을 합니다. + * + * @param e 재시도를 유발한 마지막 예외 객체 + * @param task 실패한 Task의 도메인 모델 + * @param taskRun 실패한 실행의 기록 객체 + * @param requestBody 실패 당시 사용된 요청 Body + * @return 최종 실패를 나타내는 Task 실행 결과 + * @since v0.1.0 + */ @Recover public TaskRunner.TaskExecutionResult recover( RestClientException e, Task task, TaskRun taskRun, ObjectNode requestBody) { diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index a27807ec..3421c043 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -48,11 +48,12 @@ public class WorkflowExecutionService { @Transactional @Async("traceExecutor") public void executeWorkflow(Long workflowId) { - mdcManager.setWorkflowContext(workflowId); + WorkflowRun workflowRun = WorkflowRun.start(workflowId); + workflowRunMapper.insert(workflowRun); + + mdcManager.setWorkflowContext(workflowId, workflowRun.getTraceId()); try { workflowLogger.info("========== 워크플로우 실행 시작: WorkflowId={} ==========", workflowId); - WorkflowRun workflowRun = WorkflowRun.start(workflowId); - workflowRunMapper.insert(workflowRun); Map workflowContext = new HashMap<>(); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java index d04e238f..17887630 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java @@ -10,19 +10,44 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.common.service.PageableService; -import site.icebang.domain.workflow.dto.*; +import site.icebang.domain.workflow.dto.JobRunDto; +import site.icebang.domain.workflow.dto.TaskRunDto; +import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; +import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.dto.WorkflowRunDto; +import site.icebang.domain.workflow.dto.WorkflowRunLogsResponse; import site.icebang.domain.workflow.mapper.WorkflowHistoryMapper; +/** + * 워크플로우 실행 이력(History) 조회 관련 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 워크플로우 실행 목록의 페이징 처리, 특정 실행 건의 상세 정보 조회 등 읽기 전용(Read-Only) 기능에 집중합니다. + * + *

주요 기능:

+ * + *
    + *
  • 워크플로우 실행 이력 목록 페이징 조회 + *
  • 워크플로우 실행 상세 정보 조회 (Job 및 Task 실행 이력 포함) + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Service @RequiredArgsConstructor public class WorkflowHistoryService implements PageableService { private final WorkflowHistoryMapper workflowHistoryMapper; /** - * 워크플로우 런 조회 + * 워크플로우 실행 이력 목록을 페이징 처리하여 조회합니다. * - * @param pageParams pageParams - * @return PageResult + *

이 메소드는 {@code PageableService} 인터페이스를 구현하며, {@code PageResult} 유틸리티를 사용하여 전체 카운트 쿼리와 목록 조회 + * 쿼리를 실행하고 페이징 결과를 생성합니다. + * + * @param pageParams 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) + * @return 페이징 처리된 워크플로우 실행 이력 목록 + * @see PageResult + * @since v0.1.0 */ @Override @Transactional(readOnly = true) @@ -35,10 +60,14 @@ public PageResult getPagedResult(PageParams pageParams) { } /** - * 워크플로우 실행 상세 조회 + * 특정 워크플로우 실행 건의 상세 정보를 조회합니다. + * + *

지정된 실행 ID(`runId`)에 해당하는 워크플로우 실행 정보와, 그에 속한 모든 Job 실행 정보, 그리고 각 Job에 속한 모든 Task 실행 정보를 + * 계층적으로 조회하여 반환합니다. * - * @param runId workflow_run.id - * @return WorkflowRunDetailResponse + * @param runId 조회할 워크플로우 실행의 ID (`workflow_run.id`) + * @return 워크플로우, Job, Task 실행 정보를 포함하는 상세 응답 객체 + * @since v0.1.0 */ @Transactional(readOnly = true) public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { @@ -69,10 +98,11 @@ public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { } /** - * 워크플로우 실행 로그 조회 + * 특정 워크플로우 실행과 관련된 모든 로그를 조회합니다. * - * @param runId workflow_run.id - * @return WorkflowRunLogsResponse + * @param runId 조회할 워크플로우 실행의 ID (`workflow_run.id`) + * @return 워크플로우 실행 로그 응답 객체 + * @since v0.1.0 */ public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { // TODO: 구현 예정 @@ -80,10 +110,11 @@ public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { } /** - * TraceId로 워크플로우 실행 조회 + * Trace ID를 사용하여 특정 워크플로우 실행 정보를 조회합니다. * - * @param traceId workflow_run.trace_id - * @return WorkflowRunDetailResponse + * @param traceId 조회할 워크플로우 실행의 Trace ID (`workflow_run.trace_id`) + * @return 워크플로우 실행 상세 응답 객체 + * @since v0.1.0 */ public WorkflowRunDetailResponse getWorkflowRunByTraceId(String traceId) { // TODO: 구현 예정 diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index b994c82e..e8c857f3 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -17,12 +17,38 @@ import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.mapper.WorkflowMapper; +/** + * 워크플로우의 '정의'와 관련된 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 워크플로우의 실행(Execution)이 아닌, 생성된 워크플로우의 구조를 조회하는 기능에 집중합니다. + * + *

주요 기능:

+ * + *
    + *
  • 워크플로우 목록 페이징 조회 + *
  • 특정 워크플로우의 상세 구조 조회 (Job, Task, Schedule 포함) + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Service @RequiredArgsConstructor public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; + /** + * 워크플로우 목록을 페이징 처리하여 조회합니다. + * + *

이 메소드는 {@code PageableService} 인터페이스를 구현하며, {@code PageResult} 유틸리티를 사용하여 전체 카운트 쿼리와 목록 조회 + * 쿼리를 실행하고 페이징 결과를 생성합니다. + * + * @param pageParams 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) + * @return 페이징 처리된 워크플로우 카드 목록 + * @see PageResult + * @since v0.1.0 + */ @Override @Transactional(readOnly = true) public PageResult getPagedResult(PageParams pageParams) { @@ -32,6 +58,16 @@ public PageResult getPagedResult(PageParams pageParams) { () -> workflowMapper.selectWorkflowCount(pageParams)); } + /** + * 특정 워크플로우의 상세 구조를 조회합니다. + * + *

지정된 워크플로우 ID에 해당하는 기본 정보, 연결된 스케줄 목록, 그리고 Job과 Task의 계층 구조를 모두 조회하여 하나의 DTO로 조합하여 반환합니다. + * + * @param workflowId 조회할 워크플로우의 ID + * @return 워크플로우의 전체 구조를 담은 상세 DTO + * @throws IllegalArgumentException 주어진 ID에 해당하는 워크플로우가 존재하지 않을 경우 + * @since v0.1.0 + */ @Transactional(readOnly = true) public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java index 2a5bd001..6951bab8 100644 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java @@ -1,5 +1,6 @@ package site.icebang.external.fastapi.adapter; +import org.slf4j.MDC; import org.springframework.http.*; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClientException; @@ -10,6 +11,24 @@ import site.icebang.global.config.properties.FastApiProperties; +/** + * 외부 FastAPI 서버와의 모든 HTTP 통신을 전담하는 어댑터 클래스입니다. + * + *

이 클래스는 내부 시스템의 다른 부분들이 외부 시스템의 상세한 통신 방법을 알 필요가 없도록 HTTP 요청/응답 로직을 캡슐화합니다. {@code + * RestTemplate}을 사용하여 실제 통신을 수행하며, 모든 FastAPI 요청은 이 클래스의 {@code call} 메소드를 통해 이루어져야 합니다. + * + *

사용 예제:

+ * + *
{@code
+ * @Autowired
+ * private FastApiAdapter fastApiAdapter;
+ *
+ * String response = fastApiAdapter.call("/keywords/search", HttpMethod.POST, "{\"tag\":\"naver\"}");
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Component @RequiredArgsConstructor @@ -18,11 +37,30 @@ public class FastApiAdapter { private final RestTemplate restTemplate; private final FastApiProperties properties; - // 📌 Task나 context에 대한 의존성이 완전히 사라짐 + /** + * FastAPI 서버에 API 요청을 보내는 범용 메소드입니다. + * + *

지정된 엔드포인트, HTTP 메소드, 요청 Body를 사용하여 외부 API를 호출합니다. 통신 성공 시 응답 Body를 문자열로 반환하고, 실패 시 에러 로그를 + * 남기고 null을 반환합니다. + * + * @param endpoint 호출할 엔드포인트 경로 (예: "/keywords/search") + * @param method 사용할 HTTP 메소드 (예: HttpMethod.POST) + * @param requestBody 요청에 담을 JSON 문자열 + * @return 성공 시 API 응답 Body 문자열, 실패 시 null + * @see RestTemplate + * @since v0.1.0 + */ public String call(String endpoint, HttpMethod method, String requestBody) { String fullUrl = properties.getUrl() + endpoint; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + + String traceId = MDC.get("traceId"); + if (traceId != null) { + headers.set("X-Request-ID", traceId); + log.debug("TraceID 헤더 추가: {}", traceId); + } + HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); try { diff --git a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java index 233f5834..bdca3015 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java @@ -9,6 +9,22 @@ import site.icebang.domain.schedule.service.QuartzScheduleService; import java.util.List; +/** + * 애플리케이션 시작 시 데이터베이스에 저장된 스케줄을 Quartz 스케줄러에 동적으로 등록하는 초기화 클래스입니다. + * + *

이 클래스는 {@code CommandLineRunner}를 구현하여, Spring Boot 애플리케이션이 완전히 + * 로드된 후 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을 'Source of Truth'로 삼아, + * 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다. + * + *

주요 기능:

+ *
    + *
  • 애플리케이션 시작 시점에 DB의 활성 스케줄 조회
  • + *
  • 조회된 스케줄을 {@code QuartzScheduleService}를 통해 Quartz 엔진에 등록
  • + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Slf4j @Component @RequiredArgsConstructor @@ -17,6 +33,15 @@ public class QuartzSchedulerInitializer implements CommandLineRunner { private final ScheduleMapper scheduleMapper; private final QuartzScheduleService quartzScheduleService; + /** + * Spring Boot 애플리케이션 시작 시 호출되는 메인 실행 메소드입니다. + * + *

데이터베이스에서 활성화된 모든 스케줄을 조회하고, 각 스케줄을 + * {@code QuartzScheduleService}를 통해 Quartz 스케줄러에 등록합니다. + * + * @param args 애플리케이션 실행 시 전달되는 인자 + * @since v0.1.0 + */ @Override public void run(String... args) { log.info("Quartz 스케줄러 초기화 시작: DB 스케줄을 등록합니다."); diff --git a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java index 43cfd8b1..7029b7d9 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java @@ -8,9 +8,37 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; +/** + * 애플리케이션의 웹 관련 설정을 담당하는 Java 기반 설정 클래스입니다. + * + *

이 클래스는 애플리케이션 전역에서 사용될 웹 관련 빈(Bean)들을 생성하고 구성합니다. 현재는 외부 API 통신을 위한 {@code RestTemplate} 빈을 + * 중앙에서 관리하는 역할을 합니다. + * + *

주요 기능:

+ * + *
    + *
  • 커넥션 및 읽기 타임아웃이 설정된 RestTemplate 빈 생성 + *
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Configuration public class WebConfig { + /** + * 외부 API 통신을 위한 RestTemplate 빈을 생성하여 스프링 컨테이너에 등록합니다. + * + *

기본 {@code RestTemplateBuilder}를 사용하되, 커넥션 및 읽기 타임아웃을 각각 30초로 명시적으로 설정하기 위해 {@code + * SimpleClientHttpRequestFactory}를 구성하여 주입합니다. 이렇게 생성된 RestTemplate 빈은 애플리케이션의 다른 컴포넌트에서 주입받아 외부 + * 시스템과의 HTTP 통신에 사용됩니다. + * + * @param builder Spring Boot가 자동으로 구성해주는 RestTemplateBuilder 객체 + * @return 타임아웃이 설정된 RestTemplate 인스턴스 + * @see RestTemplate + * @see RestTemplateBuilder + * @since v0.1.0 + */ @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { // 1. SimpleClientHttpRequestFactory 객체를 직접 생성 diff --git a/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java index 4079c9f3..3def3d9d 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java @@ -13,11 +13,42 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +/** + * MyBatis에서 Jackson 라이브러리의 {@code JsonNode} 타입을 데이터베이스의 문자열 타입(예: VARCHAR, JSON)과 매핑하기 위한 커스텀 타입 + * 핸들러입니다. + * + *

이 핸들러를 통해, 애플리케이션에서는 JSON 데이터를 편리하게 {@code JsonNode} 객체로 다루고, 데이터베이스에는 해당 객체를 JSON 문자열 형태로 + * 저장하거나 읽어올 수 있습니다. + * + *

MyBatis XML 매퍼에서의 사용 예제:

+ * + *
{@code
+ * 
+ * 
+ * 
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @MappedTypes(JsonNode.class) public class JsonNodeTypeHandler extends BaseTypeHandler { private static final ObjectMapper objectMapper = new ObjectMapper(); + /** + * {@code JsonNode} 파라미터를 DB에 저장하기 위해 JSON 문자열로 변환하여 PreparedStatement에 설정합니다. + * + * @param ps PreparedStatement 객체 + * @param i 파라미터 인덱스 + * @param parameter 변환할 JsonNode 객체 + * @param jdbcType JDBC 타입 + * @throws SQLException JSON 직렬화 실패 시 + */ @Override public void setNonNullParameter( PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException { @@ -28,21 +59,52 @@ public void setNonNullParameter( } } + /** + * ResultSet에서 컬럼 이름으로 문자열을 가져와 {@code JsonNode} 객체로 파싱합니다. + * + * @param rs ResultSet 객체 + * @param columnName 컬럼 이름 + * @return 파싱된 JsonNode 객체, 원본이 null이면 null + * @throws SQLException JSON 파싱 실패 시 + */ @Override public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { return parseJson(rs.getString(columnName)); } + /** + * ResultSet에서 컬럼 인덱스로 문자열을 가져와 {@code JsonNode} 객체로 파싱합니다. + * + * @param rs ResultSet 객체 + * @param columnIndex 컬럼 인덱스 + * @return 파싱된 JsonNode 객체, 원본이 null이면 null + * @throws SQLException JSON 파싱 실패 시 + */ @Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseJson(rs.getString(columnIndex)); } + /** + * CallableStatement에서 컬럼 인덱스로 문자열을 가져와 {@code JsonNode} 객체로 파싱합니다. + * + * @param cs CallableStatement 객체 + * @param columnIndex 컬럼 인덱스 + * @return 파싱된 JsonNode 객체, 원본이 null이면 null + * @throws SQLException JSON 파싱 실패 시 + */ @Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseJson(cs.getString(columnIndex)); } + /** + * JSON 문자열을 {@code JsonNode} 객체로 변환하는 private 헬퍼 메소드입니다. + * + * @param json 파싱할 JSON 문자열 + * @return 파싱된 JsonNode 객체 + * @throws SQLException JSON 문자열이 유효하지 않을 경우 + */ private JsonNode parseJson(String json) throws SQLException { if (json == null) { return null; diff --git a/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java b/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java index 24fa309d..f35d1ee6 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/properties/FastApiProperties.java @@ -8,7 +8,31 @@ import lombok.Getter; import lombok.Setter; -/** FastAPI 연동을 위한 설정값을 application.yml에서 바인딩하는 클래스 */ +/** + * FastAPI 서버 연동을 위한 설정값을 application.yml에서 타입-세이프(Type-safe)하게 바인딩하는 클래스입니다. + * + *

이 클래스는 {@code @ConfigurationProperties}를 통해 'api.fastapi' 경로의 설정값을 자동으로 주입받습니다. + * {@code @Validated}와 {@code @NotBlank}를 사용하여, 필수 설정값(url)이 누락될 경우 애플리케이션 시작 시점에 즉시 에러를 발생시켜 설정 오류를 + * 방지합니다. + * + *

사용 예제:

+ * + *
{@code
+ * @Component
+ * @RequiredArgsConstructor
+ * public class FastApiAdapter {
+ * private final FastApiProperties properties;
+ *
+ * public void someMethod() {
+ * String baseUrl = properties.getUrl(); // 설정된 URL 사용
+ * // ...
+ * }
+ * }
+ * }
+ * + * @author jihu0210@naver.com + * @since v0.1.0 + */ @Getter @Setter @Component // Component로 등록하여 Spring이 Bean으로 관리하도록 함 @@ -16,10 +40,19 @@ @Validated // 아래의 유효성 검사 어노테이션을 활성화 public class FastApiProperties { - /** FastAPI 서버의 기본 URL */ + /** + * FastAPI 서버의 기본 URL 주소입니다. + * + *

{@code @NotBlank} 어노테이션이 적용되어 있어, application.yml 파일에 반드시 값이 존재해야 합니다. (예: + * "http://host.docker.internal:8000") + */ @NotBlank // 값이 비어있을 수 없음을 검증 private String url; - /** API 호출 시 적용될 타임아웃 (밀리초 단위) */ + /** + * API 호출 시 적용될 타임아웃 시간 (밀리초 단위)입니다. + * + *

별도로 설정하지 않을 경우 기본값으로 5000ms (5초)가 적용됩니다. + */ private int timeout = 5000; // 기본값 5초 설정 } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java index 3543a8dd..514998e2 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java @@ -82,6 +82,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/v0/auth/check-session") .authenticated() + .requestMatchers(SecurityEndpoints.SUPER_ADMIN.getMatchers()) + .hasAnyRole("SUPER_ADMIN") .requestMatchers(SecurityEndpoints.DATA_ADMIN.getMatchers()) .hasRole("SUPER_ADMIN") // hasAuthority -> hasRole .requestMatchers(SecurityEndpoints.DATA_ENGINEER.getMatchers()) diff --git a/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java b/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java index e6e24243..98129d8b 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/security/endpoints/SecurityEndpoints.java @@ -11,7 +11,6 @@ public enum SecurityEndpoints { "/js/**", "/images/**", "/v0/organizations/**", - "/v0/auth/register", "/v0/check-execution-log-insert"), // 데이터 관리 관련 엔드포인트 @@ -27,7 +26,9 @@ public enum SecurityEndpoints { OPS("/api/scheduler/**", "/api/monitoring/**"), // 일반 사용자 엔드포인트 - USER("/user/**", "/profile/**", "/v0/auth/check-session", "/v0/workflows/**"); + USER("/user/**", "/profile/**", "/v0/auth/check-session", "/v0/workflows/**"), + + SUPER_ADMIN("/v0/auth/register"); private final String[] patterns; diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 8243acde..485e7e1e 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -1,8 +1,11 @@ package site.icebang.global.handler.exception; +import java.util.stream.Collectors; + import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; @@ -14,16 +17,52 @@ import site.icebang.common.dto.ApiResponse; import site.icebang.common.exception.DuplicateDataException; +/** + * 전역 예외 처리기 (Global Exception Handler). + * + *

이 클래스는 애플리케이션 전역에서 발생하는 예외를 {@link ApiResponse} 형태로 변환하여 클라이언트에게 반환합니다. 예외 유형에 따라 적절한 {@link + * HttpStatus} 코드를 설정하며, 공통적인 예외 처리 로직을 중앙화합니다. + * + *

처리되는 주요 예외는 다음과 같습니다: + * + *

    + *
  • {@link MethodArgumentNotValidException} - 요청 데이터 유효성 검증 실패 + *
  • {@link NoResourceFoundException} - 존재하지 않는 리소스 접근 + *
  • {@link AuthenticationException} - 인증 실패 + *
  • {@link AccessDeniedException} - 인가 실패 + *
  • {@link DuplicateDataException} - 중복 데이터 발생 + *
  • {@link Exception} - 그 외 처리되지 않은 일반 예외 + *
+ * + *

모든 응답은 {@code ApiResponse.error(...)} 메서드를 통해 생성되며, 에러 메시지와 HTTP 상태 코드가 포함됩니다. + */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { + + /** + * 요청 데이터 유효성 검증 실패 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link MethodArgumentNotValidException} + * @return {@link ApiResponse} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} + */ @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ApiResponse handleValidation(MethodArgumentNotValidException ex) { - String detail = ex.getBindingResult().toString(); - return ApiResponse.error("Validation failed: " + detail, HttpStatus.BAD_REQUEST); + public ApiResponse handleValidation(MethodArgumentNotValidException ex) { + String errorMessage = + ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return ApiResponse.error("입력 값 검증 실패: " + errorMessage, HttpStatus.BAD_REQUEST); } + /** + * 처리되지 않은 모든 일반 예외를 처리합니다. 서버 내부 오류로 간주되며, 에러 로그를 남깁니다. + * + * @param ex 발생한 {@link Exception} + * @return {@link ApiResponse} - 내부 오류 메시지와 {@link HttpStatus#INTERNAL_SERVER_ERROR} + */ @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse handleGeneric(Exception ex) { @@ -31,24 +70,48 @@ public ApiResponse handleGeneric(Exception ex) { return ApiResponse.error("Internal error: ", HttpStatus.INTERNAL_SERVER_ERROR); } + /** + * 존재하지 않는 리소스 접근 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link NoResourceFoundException} + * @return {@link ApiResponse} - 리소스 없음 메시지와 {@link HttpStatus#NOT_FOUND} + */ @ExceptionHandler(NoResourceFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ApiResponse handleNotFound(NoResourceFoundException ex) { return ApiResponse.error("Notfound: " + ex.getMessage(), HttpStatus.NOT_FOUND); } + /** + * 인증 실패 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link AuthenticationException} + * @return {@link ApiResponse} - 인증 실패 메시지와 {@link HttpStatus#UNAUTHORIZED} + */ @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ApiResponse handleAuthentication(AuthenticationException ex) { return ApiResponse.error("Authentication failed: " + ex.getMessage(), HttpStatus.UNAUTHORIZED); } + /** + * 인가(권한) 실패 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link AccessDeniedException} + * @return {@link ApiResponse} - 접근 거부 메시지와 {@link HttpStatus#FORBIDDEN} + */ @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ApiResponse handleAccessDenied(AccessDeniedException ex) { return ApiResponse.error("Access denied: " + ex.getMessage(), HttpStatus.FORBIDDEN); } + /** + * 중복 데이터 발생 시 발생하는 예외를 처리합니다. + * + * @param ex 발생한 {@link DuplicateDataException} + * @return {@link ApiResponse} - 중복 데이터 메시지와 {@link HttpStatus#CONFLICT} + */ @ExceptionHandler(DuplicateDataException.class) @ResponseStatus(HttpStatus.CONFLICT) public ApiResponse handleDuplicateData(DuplicateDataException ex) { diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java index efeffde1..9e6672f3 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java @@ -15,11 +15,37 @@ import site.icebang.common.dto.ApiResponse; +/** + * 접근 거부 처리기 (REST 전용 AccessDeniedHandler). + * + *

Spring Security에서 인가(Authorization) 실패 시 호출됩니다. 사용자가 필요한 권한 없이 보호된 리소스에 접근하려고 하면 이 핸들러가 실행되어 + * JSON 형식의 에러 응답을 반환합니다. + * + *

주요 특징: + * + *

    + *
  • HTTP 상태 코드: {@link HttpStatus#FORBIDDEN} (403) + *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지 + *
  • 응답 Content-Type: {@code application/json;charset=UTF-8} + *
+ * + *

이 핸들러는 기본 HTML 오류 페이지 대신, REST API 클라이언트에 JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다. + */ @Component @RequiredArgsConstructor public class RestAccessDeniedHandler implements AccessDeniedHandler { + + /** JSON 직렬화를 위한 ObjectMapper */ private final ObjectMapper objectMapper; + /** + * 인가되지 않은 요청이 들어왔을 때 실행됩니다. + * + * @param request 현재 요청 + * @param response 응답 객체 + * @param ex 발생한 {@link AccessDeniedException} + * @throws IOException 응답 스트림 처리 중 오류 발생 시 + */ @Override public void handle( HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java index b7c50d76..9d3ec7b5 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java @@ -15,11 +15,37 @@ import site.icebang.common.dto.ApiResponse; +/** + * 인증 진입점 처리기 (REST 전용 AuthenticationEntryPoint). + * + *

Spring Security에서 인증(Authentication) 실패 시 호출됩니다. 인증되지 않은 사용자가 보호된 리소스에 접근하려고 하면 이 핸들러가 실행되어 + * JSON 형식의 에러 응답을 반환합니다. + * + *

주요 특징: + * + *

    + *
  • HTTP 상태 코드: {@link HttpStatus#UNAUTHORIZED} (401) + *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지 + *
  • 응답 Content-Type: {@code application/json;charset=UTF-8} + *
+ * + *

이 핸들러는 기본 로그인 페이지 리다이렉트 대신, REST API 클라이언트에 JSON 기반의 표준 에러 응답을 제공하기 위해 사용됩니다. + */ @Component @RequiredArgsConstructor public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + /** JSON 직렬화를 위한 ObjectMapper */ private final ObjectMapper objectMapper; + /** + * 인증되지 않은 요청이 들어왔을 때 실행됩니다. + * + * @param request 현재 요청 + * @param response 응답 객체 + * @param ex 발생한 {@link AuthenticationException} + * @throws IOException 응답 스트림 처리 중 오류 발생 시 + */ @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) diff --git a/apps/user-service/src/main/resources/application-production.yml b/apps/user-service/src/main/resources/application-production.yml index c3645e13..406fed87 100644 --- a/apps/user-service/src/main/resources/application-production.yml +++ b/apps/user-service/src/main/resources/application-production.yml @@ -18,21 +18,21 @@ spring: pool-name: HikariCP-MyBatis # Gmail 연동 설정 - mail: - host: smtp.gmail.com - port: 587 - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true - connectiontimeout: 5000 - timeout: 5000 - writetimeout: 5000 - debug: true + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + debug: true # quartz: # jdbc: # initialize-schema: never diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java index 03c5f899..67e6820a 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java @@ -6,8 +6,8 @@ import java.util.HashMap; import java.util.Map; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.springframework.http.*; import org.springframework.test.context.jdbc.Sql; @@ -22,9 +22,9 @@ class UserLogoutFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") - @Test - @DisplayName("정상 로그아웃 전체 플로우 - TDD REd 단계") - void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Exception { + @Disabled + @DisplayName("정상 로그아웃 전체 플로우 - TDD Red 단계") + void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exception { logStep(1, "관리자 로그인 (최우선)"); // 1. 관리자 로그인으로 인증 상태 확립 @@ -45,33 +45,16 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); - logSuccess("관리자 로그인 성공 - 인증 상태 확립 완료"); + logSuccess("관리자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); logStep(2, "로그인 상태에서 보호된 리소스 접근 확인"); - // 로그인 응답에서 세션 쿠키 추출 - String sessionCookie = null; - java.util.List cookies = loginResponse.getHeaders().get("Set-Cookie"); - if (cookies != null) { - for (String cookie : cookies) { - if (cookie.startsWith("JSESSIONID")) { - sessionCookie = cookie.split(";")[0]; // JSESSIONID=XXX 부분만 추출 - break; - } - } - } - // 2. 로그인된 상태에서 본인 프로필 조회로 인증 상태 확인 // /v0/users/me는 인증된 사용자만 접근 가능한 일반적인 API - HttpHeaders authenticatedHeaders = new HttpHeaders(); - if (sessionCookie != null) { - authenticatedHeaders.set("Cookie", sessionCookie); - } - - HttpEntity authenticatedEntity = new HttpEntity<>(authenticatedHeaders); + // 쿠키는 인터셉터에 의해 자동으로 전송됨 ResponseEntity beforeLogoutResponse = - restTemplate.exchange( - getV0ApiUrl("/users/me"), HttpMethod.GET, authenticatedEntity, Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -79,51 +62,64 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex logSuccess("인증된 상태에서 본인 프로필 조회 성공"); - // 3. 로그아웃 API 호출 + logStep(3, "로그아웃 API 호출"); + + // 3. 로그아웃 API 호출 (세션 쿠키는 인터셉터가 자동 처리) HttpHeaders logoutHeaders = new HttpHeaders(); logoutHeaders.setContentType(MediaType.APPLICATION_JSON); logoutHeaders.set("Origin", "https://admin.icebang.site"); logoutHeaders.set("Referer", "https://admin.icebang.site/"); - // 로그아웃 요청에도 세션 쿠키 포함 - if (sessionCookie != null) { - logoutHeaders.set("Cookie", sessionCookie); - } - HttpEntity> logoutEntity = new HttpEntity<>(new HashMap<>(), logoutHeaders); try { ResponseEntity logoutResponse = restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); - logStep(4, "로그아웃 응답 검증 (API구현 돼있으면)"); + + logStep(4, "로그아웃 응답 검증"); + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) logoutResponse.getBody().get("success")).isTrue(); logSuccess("로그아웃 API 호출 성공"); logStep(5, "로그아웃 후 인증 무효화 확인"); + // 로그아웃 후 세션 쿠키 상태 확인 + logDebug("로그아웃 후 세션 쿠키: " + getSessionCookies()); + // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 - HttpEntity afterLogoutEntity = new HttpEntity<>(authenticatedHeaders); ResponseEntity afterLogoutResponse = - restTemplate.exchange( - getV0ApiUrl("/users/me"), HttpMethod.GET, afterLogoutEntity, Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 assertThat(afterLogoutResponse.getStatusCode()) .withFailMessage( "로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", afterLogoutResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); - logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); - logCompletion("일반 사용자 로그아웃 플로우"); + logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); + logCompletion("관리자 로그아웃 플로우"); } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { - logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found"); - logError("에러 메시지 : " + ex.getMessage()); + logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found)"); + logError("에러 메시지: " + ex.getMessage()); + logError("TDD Red 단계 - API 구현 필요"); fail( "로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " + "다음 단계에서 API를 구현해야 합니다. 에러: " + ex.getMessage()); + + } catch (org.springframework.web.client.HttpClientErrorException ex) { + logError("HTTP 클라이언트 에러: " + ex.getStatusCode() + " - " + ex.getMessage()); + + if (ex.getStatusCode() == HttpStatus.METHOD_NOT_ALLOWED) { + logError("로그아웃 엔드포인트는 존재하지만 POST 메서드를 지원하지 않습니다."); + fail("로그아웃 API가 POST 메서드를 지원하지 않습니다. 구현을 확인해주세요."); + } else { + fail("로그아웃 API 호출 중 HTTP 에러 발생: " + ex.getStatusCode() + " - " + ex.getMessage()); + } + } catch (Exception ex) { logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); logError("에러 메시지: " + ex.getMessage()); @@ -133,7 +129,63 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex } } - /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 관리자가 아닌 콘텐츠팀장으로 로그인 */ + @SuppressWarnings("unchecked") + @DisplayName("일반 사용자 로그아웃 플로우 테스트") + void regularUserLogoutFlow() throws Exception { + logStep(1, "일반 사용자 로그인"); + + // 세션 쿠키 초기화 + clearSessionCookies(); + + // 일반 사용자 로그인 수행 + performRegularUserLogin(); + + logStep(2, "일반 사용자 권한으로 프로필 조회"); + + // 로그인된 상태에서 프로필 조회 + ResponseEntity beforeLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); + + logSuccess("일반 사용자 프로필 조회 성공"); + + logStep(3, "일반 사용자 로그아웃 시도"); + + try { + HttpHeaders logoutHeaders = new HttpHeaders(); + logoutHeaders.setContentType(MediaType.APPLICATION_JSON); + logoutHeaders.set("Origin", "https://admin.icebang.site"); + logoutHeaders.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> logoutEntity = + new HttpEntity<>(new HashMap<>(), logoutHeaders); + + ResponseEntity logoutResponse = + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); + + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + logSuccess("일반 사용자 로그아웃 성공"); + + logStep(4, "로그아웃 후 접근 권한 무효화 확인"); + + ResponseEntity afterLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(afterLogoutResponse.getStatusCode()) + .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); + + logSuccess("일반 사용자 로그아웃 후 접근 차단 확인"); + logCompletion("일반 사용자 로그아웃 플로우"); + + } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { + logError("예상된 실패: 로그아웃 API 미구현"); + fail("로그아웃 API가 구현되지 않았습니다: " + ex.getMessage()); + } + } + + /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 - 관리자가 아닌 콘텐츠팀장으로 로그인 */ private void performRegularUserLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "viral.jung@icebang.site"); @@ -154,6 +206,34 @@ private void performRegularUserLogin() { throw new RuntimeException("Regular user login failed for logout test"); } - logSuccess("일반 사용자 로그인 완료 (로그아웃 테스트용)"); + logSuccess("일반 사용자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("일반 사용자 세션 쿠키: " + getSessionCookies()); + } + + /** 관리자 로그인을 수행하는 헬퍼 메서드 */ + private void performAdminLogin() { + clearSessionCookies(); // 기존 세션 정리 + + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Origin", "https://admin.icebang.site"); + headers.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> entity = new HttpEntity<>(loginRequest, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + logError("관리자 로그인 실패: " + response.getStatusCode()); + throw new RuntimeException("Admin login failed"); + } + + logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("관리자 세션 쿠키: " + getSessionCookies()); } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java index df66a7c6..1bc1903b 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java @@ -44,11 +44,12 @@ void completeUserRegistrationFlow() throws Exception { assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); - logSuccess("관리자 로그인 성공 - 이제 모든 리소스 접근 가능"); + logSuccess("관리자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); logStep(2, "조직 목록 조회 (인증된 상태)"); - // 2. 조직 목록 조회 (로그인 후 가능) + // 2. 조직 목록 조회 (로그인 후 가능, 쿠키 자동 전송) ResponseEntity organizationsResponse = restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); @@ -56,7 +57,7 @@ void completeUserRegistrationFlow() throws Exception { assertThat((Boolean) organizationsResponse.getBody().get("success")).isTrue(); assertThat(organizationsResponse.getBody().get("data")).isNotNull(); - logSuccess("조직 목록 조회 성공"); + logSuccess("조직 목록 조회 성공 (인증된 요청)"); logStep(3, "부서 및 각종 데이터 조회 (특정 조직 옵션)"); @@ -229,7 +230,8 @@ private void performAdminLogin() { throw new RuntimeException("Admin login failed"); } - logSuccess("관리자 로그인 완료"); + logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("세션 쿠키: " + getSessionCookies()); } /** 사용자 등록을 수행하는 헬퍼 메서드 */ diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java index c2d10870..97d1cf0d 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java @@ -1,14 +1,21 @@ package site.icebang.e2e.setup.support; +import java.util.ArrayList; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Import; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; + import site.icebang.e2e.setup.annotation.E2eTest; import site.icebang.e2e.setup.config.E2eTestConfiguration; @@ -26,6 +33,53 @@ public abstract class E2eTestSupport { protected MockMvc mockMvc; + private List sessionCookies = new ArrayList<>(); + + @PostConstruct + void setupCookieManagement() { + // RestTemplate에 쿠키 인터셉터 추가 + restTemplate.getRestTemplate().getInterceptors().add(createCookieInterceptor()); + logDebug("쿠키 관리 인터셉터 설정 완료"); + } + + private ClientHttpRequestInterceptor createCookieInterceptor() { + return (request, body, execution) -> { + // 요청에 저장된 쿠키 추가 + if (!sessionCookies.isEmpty()) { + request.getHeaders().put("Cookie", sessionCookies); + logDebug("쿠키 전송: " + String.join("; ", sessionCookies)); + } + + // 요청 실행 + ClientHttpResponse response = execution.execute(request, body); + + // 응답에서 Set-Cookie 헤더 추출하여 저장 + List setCookieHeaders = response.getHeaders().get("Set-Cookie"); + if (setCookieHeaders != null && !setCookieHeaders.isEmpty()) { + updateSessionCookies(setCookieHeaders); + logDebug("세션 쿠키 업데이트: " + String.join("; ", sessionCookies)); + } + + return response; + }; + } + + private void updateSessionCookies(List setCookieHeaders) { + for (String setCookie : setCookieHeaders) { + // 쿠키 이름 추출 + String cookieName = setCookie.split("=")[0]; + String cookieValue = setCookie.split(";")[0]; // 쿠키 값만 추출 (속성 제외) + + // 같은 이름의 쿠키가 있으면 제거 + sessionCookies.removeIf(cookie -> cookie.startsWith(cookieName + "=")); + + // 새 쿠키 추가 (빈 값이 아닌 경우만) + if (!cookieValue.endsWith("=")) { + sessionCookies.add(cookieValue); + } + } + } + protected String getBaseUrl() { return "http://localhost:" + port; } @@ -38,6 +92,20 @@ protected String getV0ApiUrl(String path) { return getBaseUrl() + "/v0" + path; } + /** 세션 쿠키 관리 메서드들 */ + protected void clearSessionCookies() { + sessionCookies.clear(); + logDebug("세션 쿠키 초기화됨"); + } + + protected List getSessionCookies() { + return new ArrayList<>(sessionCookies); + } + + protected boolean hasSessionCookie(String cookieName) { + return sessionCookies.stream().anyMatch(cookie -> cookie.startsWith(cookieName + "=")); + } + /** 테스트 시나리오 단계별 로깅을 위한 유틸리티 메서드 */ protected void logStep(int stepNumber, String description) { System.out.println(String.format("📋 Step %d: %s", stepNumber, description)); @@ -57,4 +125,16 @@ protected void logError(String message) { protected void logCompletion(String scenario) { System.out.println(String.format("🎉 %s 시나리오 완료!", scenario)); } + + /** 디버그 로깅을 위한 유틸리티 메서드 */ + protected void logDebug(String message) { + if (isDebugEnabled()) { + System.out.println("🐛 DEBUG: " + message); + } + } + + private boolean isDebugEnabled() { + return System.getProperty("test.debug", "false").equals("true") + || System.getProperty("e2e.debug", "false").equals("true"); + } } diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java index 4fe3b00d..95d0cfbd 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java @@ -7,6 +7,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -15,6 +16,8 @@ import org.springframework.http.*; import org.springframework.mock.web.MockHttpSession; import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.jdbc.Sql; import org.springframework.transaction.annotation.Transactional; @@ -29,7 +32,7 @@ class AuthApiIntegrationTest extends IntegrationTestSupport { @Test @DisplayName("사용자 로그인 성공") - void login_success() throws Exception { + void loginSuccess() throws Exception { // given Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); @@ -81,9 +84,243 @@ void login_success() throws Exception { .build()))); } + @Test + @DisplayName("사용자 등록 실패 - 이메일 양식 오류") + @WithUserDetails("admin@icebang.site") + void registerFailureWhenInvalidEmail() throws Exception { + // given + Map registerRequest = new HashMap<>(); + registerRequest.put("name", "홍길동"); + registerRequest.put("email", "invalid-email"); // 잘못된 이메일 형식 + registerRequest.put("orgId", 1); + registerRequest.put("deptId", 1); + registerRequest.put("positionId", 1); + registerRequest.put("roleIds", Arrays.asList(1)); + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("BAD_REQUEST")) + .andDo( + document( + "auth-register-invalid-email", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 잘못된 이메일") + .description("잘못된 이메일 형식으로 인한 회원가입 실패") + .requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자명"), + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("잘못된 형식의 이메일 주소"), + fieldWithPath("orgId").type(JsonFieldType.NUMBER).description("조직 ID"), + fieldWithPath("deptId").type(JsonFieldType.NUMBER).description("부서 ID"), + fieldWithPath("positionId") + .type(JsonFieldType.NUMBER) + .description("직책 ID"), + fieldWithPath("roleIds") + .type(JsonFieldType.ARRAY) + .description("역할 ID 목록")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("사용자 등록 실패 - 필수 필드 누락") + @WithUserDetails("admin@icebang.site") + void registerFailureWhenMissingRequiredFields() throws Exception { + // given - 필수 필드 누락 + Map registerRequest = new HashMap<>(); + registerRequest.put("email", "test@icebang.site"); + // name, orgId, deptId, positionId, roleIds 누락 + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("BAD_REQUEST")) + .andDo( + document( + "auth-register-missing-fields", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 필수 필드 누락") + .description("필수 필드 누락으로 인한 회원가입 실패") + .requestFields( + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("사용자 등록 실패 - Authentication이 없는 경우") + void registerFailureWhenAuthenticationMissing() throws Exception { + // given + Map registerRequest = new HashMap<>(); + registerRequest.put("name", "홍길동"); + registerRequest.put("email", "hong@icebang.site"); + registerRequest.put("orgId", 1); + registerRequest.put("deptId", 1); + registerRequest.put("positionId", 1); + registerRequest.put("roleIds", Arrays.asList(1, 2)); + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.message").value("Authentication required")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-register-unauthorized", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 인증 없음") + .description("인증 정보가 없어서 회원가입 실패") + .requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자명"), + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소"), + fieldWithPath("orgId").type(JsonFieldType.NUMBER).description("조직 ID"), + fieldWithPath("deptId").type(JsonFieldType.NUMBER).description("부서 ID"), + fieldWithPath("positionId") + .type(JsonFieldType.NUMBER) + .description("직책 ID"), + fieldWithPath("roleIds") + .type(JsonFieldType.ARRAY) + .description("역할 ID 목록")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("인증 에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + + @Test + @DisplayName("사용자 등록 실패 - Permission이 없는 경우") + @WithMockUser("content.choi@icebang.site") + void registerFailureWhenNoPermissionProvided() throws Exception { + // given + Map registerRequest = new HashMap<>(); + registerRequest.put("name", "홍길동"); + registerRequest.put("email", "hong@icebang.site"); + registerRequest.put("orgId", 1); + registerRequest.put("deptId", 1); + registerRequest.put("positionId", 1); + registerRequest.put("roleIds", Arrays.asList(1, 2)); + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/register")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("FORBIDDEN")) + .andExpect(jsonPath("$.message").value("Access denied")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-register-forbidden", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 회원가입 실패 - 권한 부족") + .description("적절한 권한이 없어서 회원가입 실패") + .requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자명"), + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소"), + fieldWithPath("orgId").type(JsonFieldType.NUMBER).description("조직 ID"), + fieldWithPath("deptId").type(JsonFieldType.NUMBER).description("부서 ID"), + fieldWithPath("positionId") + .type(JsonFieldType.NUMBER) + .description("직책 ID"), + fieldWithPath("roleIds") + .type(JsonFieldType.ARRAY) + .description("역할 ID 목록")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.NULL).description("에러 시 null"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("권한 에러 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } + @Test @DisplayName("사용자 로그아웃 성공") - void logout_success() throws Exception { + void logoutSuccess() throws Exception { // given - 먼저 로그인 Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site");