Skip to content

feature: 대회 도메인 어플리케이션 지표 구현#85

Open
singingsandhill wants to merge 6 commits intodevelopfrom
feature/#64-competition-monitoring
Open

feature: 대회 도메인 어플리케이션 지표 구현#85
singingsandhill wants to merge 6 commits intodevelopfrom
feature/#64-competition-monitoring

Conversation

@singingsandhill
Copy link
Copy Markdown
Collaborator

@singingsandhill singingsandhill commented Apr 29, 2025

관련 이슈

변경 타입

  • 신규 기능 추가/수정
  • 버그 수정
  • 리팩토링
  • 설정
  • 비기능 (주석 등 기능에 영향을 주지 않음)

변경 내용

  • as-is

    • 어플리케이션 지표 부재
  • to-be

    • 어플리케이션 지표 로그 수집
    • Grafana 대시보드 생성

코멘트

  • 아직 시간 측정 관련해서는 미완입니다.

Summary by CodeRabbit

  • 신규 기능

    • 대회 신청 프로세스의 각 단계와 전체 진행 상황을 추적하는 세션 관리 기능 및 메트릭 수집 기능이 추가되었습니다.
    • Redis를 활용한 신청 세션 저장소와 관련 설정이 도입되었습니다.
    • Grafana 대시보드가 추가되어 신청 성공률, 처리 시간, 오류 등을 시각적으로 모니터링할 수 있습니다.
  • 버그 수정

    • Saga 상태 조회 및 관리 로직이 개선되어 효율성과 신뢰성이 향상되었습니다.
  • 문서화

    • Grafana 대시보드 JSON 파일이 새로 추가되었습니다.
  • 설정

    • 로그 전송 엔드포인트가 localhost로 변경되었습니다.
    • Prometheus 관련 설정 파일이 삭제되고, 관련 엔드포인트가 비활성화되었습니다.
    • JPA 스키마 생성 방식이 update에서 create로 변경되었습니다.
  • 스타일/리팩터링

    • 코드 내부에 상세 로깅 및 메트릭 기록이 추가되어 추적성이 강화되었습니다.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 29, 2025

Walkthrough

이번 변경 사항은 마라톤 대회 신청 프로세스의 추적, 모니터링, 상태 관리, 그리고 관측성(Observability) 강화를 중심으로 다양한 계층에 걸쳐 이루어졌습니다.
신청 세션(ApplicationSession) 및 세션 저장소가 새롭게 도입되어, 참가자의 신청 진행 단계별 상태와 타임스탬프를 기록할 수 있게 되었습니다.
신청 파사드(CompetitionApplicationFacade)는 세션을 기반으로 각 단계별로 진행 상황을 관리하고, Micrometer 기반의 카운터 및 타이머 메트릭을 통해 시작, 완료, 실패, 각 단계별 완료 횟수 및 소요 시간을 기록합니다.
Saga의 컨텍스트 관리를 위해 ThreadLocal 기반의 SagaContextHolder가 추가되어, 도메인 및 서비스 계층에서 일관된 Saga ID 전달이 가능해졌습니다.
SagaStateRepository는 Redis 내에서 참가자와 대회별로 액티브한 Saga ID를 빠르게 조회할 수 있도록 인덱스 키를 관리하도록 최적화되었습니다.
도메인 모델(SagaState, Competition 등)에는 세부 로그 및 데이터 무결성 체크가 추가되었습니다.
인프라 측면에서는 RedisTemplate, 메트릭, 로그 설정, Grafana 대시보드 등 관측성 및 운영 편의성을 위한 설정 파일과 구성이 추가 및 변경되었습니다.
Prometheus 설정 파일(prometheus.yml)은 삭제되었고, JPA 스키마 생성 정책 및 Loki 로그 전송 URL 등이 변경되었습니다.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CompetitionApplicationFacade
    participant ApplicationSessionRepository
    participant SagaServiceImpl
    participant SagaContextHolder
    participant Competition
    participant SagaStateRepository
    participant Metrics (Micrometer)

    Client->>CompetitionApplicationFacade: applyForCompetition(appDto)
    CompetitionApplicationFacade->>ApplicationSessionRepository: findByCompetitionAndParticipant()
    alt 세션 없음
        ApplicationSessionRepository-->>CompetitionApplicationFacade: null
        CompetitionApplicationFacade->>ApplicationSessionRepository: saveSession(newSession)
        CompetitionApplicationFacade->>Metrics: applicationStartCounter.increment()
    else 세션 있음
        ApplicationSessionRepository-->>CompetitionApplicationFacade: session
    end
    CompetitionApplicationFacade->>SagaServiceImpl: processCompleteApplication(appDto)
    SagaServiceImpl->>SagaContextHolder: setCurrentSagaId(sagaId)
    SagaServiceImpl->>Competition: processApplicationStep(...)
    Competition->>SagaStateRepository: saveSagaState()
    SagaServiceImpl->>SagaContextHolder: clear()
    SagaServiceImpl-->>CompetitionApplicationFacade: stepResult
    CompetitionApplicationFacade->>ApplicationSessionRepository: saveSession(updateSession)
    CompetitionApplicationFacade->>Metrics: 단계별 counter/timer 기록
    alt 모든 단계 완료
        CompetitionApplicationFacade->>Metrics: applicationCompleteCounter.increment()
        CompetitionApplicationFacade->>Metrics: applicationTotalTimeTimer.record()
    else 예외 발생
        CompetitionApplicationFacade->>Metrics: applicationFailCounter.increment()
    end
    CompetitionApplicationFacade-->>Client: stepResult
Loading
sequenceDiagram
    participant SagaEventConsumer
    participant SagaStateRepository
    participant SagaState

    SagaEventConsumer->>SagaStateRepository: getSagaState(sagaId)
    SagaStateRepository-->>SagaEventConsumer: sagaState
    SagaEventConsumer->>SagaState: update/markStepAsCompleted/setNextStep
    SagaState-->>SagaEventConsumer: (상태 변경 및 로그)
    SagaEventConsumer->>SagaStateRepository: saveSagaState(sagaState)
    alt 결제 실패
        SagaEventConsumer->>SagaState: markAsFailed()
        SagaEventConsumer->>SagaStateRepository: saveSagaState(sagaState)
    else 결제 성공
        SagaEventConsumer->>SagaState: markStepAsCompleted()
        SagaEventConsumer->>SagaStateRepository: saveSagaState(sagaState)
    end
Loading
sequenceDiagram
    participant MetricsConfig
    participant MeterRegistry
    participant Application

    Application->>MetricsConfig: (빈 등록 시)
    MetricsConfig->>MeterRegistry: counter/timer 생성 요청
    MeterRegistry-->>MetricsConfig: Counter/Timer 인스턴스 반환
    MetricsConfig-->>Application: Counter/Timer 빈 제공
Loading
✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@singingsandhill singingsandhill changed the title feature: 대회 도메인 어플리케이션 지표 구 feature: 대회 도메인 어플리케이션 지표 구현 Apr 29, 2025
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🔭 Outside diff range comments (2)
competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/model/SagaState.java (1)

31-33: 🛠️ Refactor suggestion

completedStepsList에서 Set으로 변경 권장

markStepAsCompleted()에서 중복 여부를 매번 contains로 확인하고 있습니다.
completedStepsHashSetLinkedHashSet으로 선언하면

  • O(1) 중복 체크
  • 코드 간결화 (contains 검사/중복 추가 로직 불필요)
- private List<SagaStep> completedSteps = new ArrayList<>();
+ private Set<SagaStep> completedSteps = new LinkedHashSet<>();

그리고 markStepAsCompleted를 다음과 같이 단순화할 수 있습니다.

- if (!this.completedSteps.contains(step)) {
-     this.completedSteps.add(step);
- }
+ this.completedSteps.add(step);

Also applies to: 72-75

competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/messaging/SagaEventConsumer.java (1)

55-101: 🧹 Nitpick (assertive)

🛠️ Refactor suggestion

동시 처리 시 데이터 경합 가능성 – 분산 락/트랜잭션 검토 필요

processApplicationEvent 는 동일 sagaId 에 대한 여러 Kafka 메시지가 거의 동시 도착할 수 있습니다.
현재 구현은

  1. Redis 에서 상태 조회
  2. Java 객체 수정
  3. 다시 Redis 저장

을 여러 스레드가 병렬로 수행할 때 최종 상태가 꼬일 가능성이 있습니다.

해결 방안 예시

  • SETNX 기반 분산 락(예: Redisson)으로 saga 단위 동시 실행 제한
  • Redis WATCH/MULTI/EXEC 트랜잭션 사용
  • Spring Transaction + Lua Script 로 CAS 구현

경합이 빈번한 환경이라면 위 방안을 검토해 주세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between df260aa and 785f980.

📒 Files selected for processing (17)
  • .gitignore (1 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/application/context/SagaContextHolder.java (1 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/application/facade/CompetitionApplicationFacade.java (5 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/application/service/SagaServiceImpl.java (4 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/model/ApplicationSession.java (1 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/model/Competition.java (6 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/model/SagaState.java (2 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/repository/ApplicationSessionRepository.java (1 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/config/MetricsConfig.java (1 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/config/RedisConfig.java (2 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/messaging/SagaEventConsumer.java (5 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/messaging/SagaOrchestratorImpl.java (1 hunks)
  • competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/persistence/SagaStateRepository.java (3 hunks)
  • competition-service/src/main/resources/application.properties (1 hunks)
  • competition-service/src/main/resources/logback-spring.xml (1 hunks)
  • grafana/grafana-dashboard.json (1 hunks)
  • prometheus.yml (0 hunks)
💤 Files with no reviewable changes (1)
  • prometheus.yml
🔇 Additional comments (31)
.gitignore (1)

44-44: 로그 파일 무시 패턴이 추가되었습니다.

기존에는 logs/competition-service.log 파일만 무시했지만, 이제 logs/competition-service.* 패턴을 추가하여 모든 관련 로그 파일을 무시하도록 했습니다. 이는 롤링 파일 정책에 의해 생성되는 날짜별 로그 파일들도 함께 무시하게 되어 좋은 변경입니다.

competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/config/RedisConfig.java (3)

10-10: ApplicationSession 임포트가 추가되었습니다.

신규 추가된 ApplicationSession 클래스를 위한 임포트가 추가되었습니다. 이는 아래에서 구성하는 Redis 템플릿과 함께 사용됩니다.


42-50: String 기반 Redis 템플릿이 추가되었습니다.

문자열 키-값 쌍을 저장하기 위한 sagaStringRedisTemplate 빈이 추가되었습니다. 이는 간단한 인덱스나 조회를 위한 목적으로 보이며, 적절하게 StringRedisSerializer로 구성되어 있습니다.

이 템플릿은 Saga 관련 문자열 데이터를 효율적으로 저장하고 검색하는 데 유용할 것입니다. 특히 대회 신청 과정의 상태 관리와 모니터링에 도움이 될 것입니다.


52-67: ApplicationSession을 위한 Redis 템플릿이 추가되었습니다.

ApplicationSession 객체를 저장하기 위한 applicationSessionRedisTemplate 빈이 추가되었습니다. 이는 복잡한 객체를 JSON으로 변환하여 저장하기 위해 Jackson2JsonRedisSerializer를 적절히 사용하고 있습니다.

이 구현은 다음과 같은 좋은 접근 방식을 사용하고 있습니다:

  • 적절한 직렬화 도구 사용
  • 의존성 주입 적절히 활용
  • 기존 ObjectMapper 빈 재사용
  • 명확한 빈 이름 지정

이는 PR 목표인 대회 도메인 애플리케이션 지표 수집을 위한 세션 관리 구현의 일부로 적절합니다.

competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/messaging/SagaOrchestratorImpl.java (1)

282-282: 코드 변경 확인되었습니다.

파일 끝의 개행 문자 제거와 같은 사소한 변경만 확인됩니다. 기능적 변경사항은 없습니다.

competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/model/Competition.java (6)

14-14: SagaContextHolder 의존성 추가 확인

애플리케이션 지표 수집을 위한 Saga 컨텍스트 관리 클래스 의존성이 추가되었습니다.


188-190: Saga ID 관리 방식 개선

이전에는 각 이벤트마다 새로운 Saga ID를 생성했지만, 이제는 ThreadLocal을 통해 중앙 집중식으로 관리하는 방식으로 변경되었습니다. 이는 신청 프로세스 전반에 걸쳐 일관된 Saga ID를 유지하여 추적성을 향상시킵니다.


197-198: 이벤트 발행 조건 강화

이벤트 퍼블리셔와 saga ID가 모두 null이 아닌 경우에만 이벤트를 발행하도록 조건이 강화되었습니다. 이는 이벤트 추적과 관측성을 향상시키는 중요한 변경사항입니다.


213-214: 기념품 선택 이벤트 발행 조건 강화

이벤트 퍼블리셔와 saga ID가 모두 null이 아닌 경우에만 이벤트를 발행하도록 조건이 강화되었습니다. 일관된 조건 적용을 통해 이벤트 추적 및 메트릭 수집의 정확성을 보장합니다.


229-230: 배송지 이벤트 발행 조건 강화

이벤트 퍼블리셔와 saga ID가 모두 null이 아닌 경우에만 이벤트를 발행하도록 조건이 강화되었습니다. 다른 단계들과 일관된 방식으로 적용되었습니다.


259-260: 결제 단계 이벤트 발행 조건 강화

결제 완료 이벤트 발행에도 동일하게 이벤트 퍼블리셔와 saga ID 검증 로직이 적용되었습니다. 모든 단계에서 일관된 조건 적용을 통해 메트릭 수집의 완전성을 확보합니다.

competition-service/src/main/java/com/_42195km/msa/competitionservice/application/context/SagaContextHolder.java (1)

1-31: ThreadLocal을 활용한 SagaContextHolder 구현

Saga ID를 ThreadLocal 변수로 관리하는 새로운 컴포넌트가 추가되었습니다. 이는 HTTP 요청이나 메시지 처리 과정에서 Saga ID를 일관되게 전파하고 관리하기 위한 중요한 구현입니다. 각 스레드마다 독립적인 컨텍스트를 유지하므로 동시성 문제를 방지할 수 있습니다.

다음 부분들이 잘 구현되었습니다:

  1. Saga ID 설정, 조회, 제거를 위한 정적 메서드 제공
  2. ThreadLocal을 사용한 스레드 안전성 확보
  3. 명확한 Javadoc 주석으로 사용 방법 문서화
competition-service/src/main/java/com/_42195km/msa/competitionservice/application/service/SagaServiceImpl.java (4)

8-8: SagaContextHolder 를 사용하여 스레드 로컬 컨텍스트 관리 추가

Saga ID를 스레드 로컬에서 관리할 수 있는 SagaContextHolder를 가져오는 import가 추가되었습니다. 이 변경은 대회 신청 메트릭 수집에 중요한 컨텍스트 관리를 지원합니다.


62-63: Saga ID를 스레드 로컬 컨텍스트에 설정

SagaContextHolder를 통해 현재 Saga ID를 스레드 로컬에 설정합니다. 이를 통해 도메인 이벤트 처리 시 일관된 Saga ID를 유지할 수 있습니다.


65-70: Saga 상태 검증 로직 강화

Saga 상태가 null인 경우에 대한 예외 처리와 로깅이 추가되었습니다. 이는 애플리케이션의 안정성을 높이고 문제 추적을 용이하게 합니다.


139-142: 스레드 로컬 자원 정리

try-finally 블록을 사용하여 메서드 실행 후 항상 스레드 로컬 컨텍스트를 정리합니다. 이는 메모리 누수 방지와 스레드 안전성 확보에 필수적입니다.

competition-service/src/main/java/com/_42195km/msa/competitionservice/domain/model/ApplicationSession.java (4)

9-25: 주요 세션 속성 정의 및 직렬화 지원

ApplicationSession 클래스는 대회 신청 과정의 상태를 추적하기 위한 핵심 도메인 모델로, 각 단계별 타임스탬프와 상태 정보를 포함합니다. Serializable 구현으로 Redis에 저장 가능하도록 했습니다.


27-34: 세션 생성 팩토리 메서드 구현

정적 팩토리 메서드 패턴을 사용하여 세션 생성 로직을 캡슐화했습니다. 이는 세션 ID 생성 및 초기 상태 설정의 일관성을 보장합니다.


36-70: 단계별 상태 변경 메서드 구현

각 신청 단계 완료 및 실패 처리를 위한 메서드들이 명확하게 정의되어 있습니다. 이 메서드들은 적절한 타임스탬프를 설정하여 단계 진행 상황을 기록합니다.


68-92: 단계별 소요 시간 계산 메서드

각 단계 간 소요 시간 계산 메서드를 제공하여 성능 지표 수집을 용이하게 합니다. null 체크가 잘 구현되어 있어 예외 발생을 방지합니다.

grafana/grafana-dashboard.json (6)

24-30: 대시보드 기본 설정

그라파나 대시보드 설정에 대회 신청 프로세스 모니터링에 적합한 기본 설정이 잘 구성되어 있습니다. 실시간 모니터링과 분석을 위한 준비가 되어 있습니다.


42-118: 대회 신청 성공률 패널 구성

대시보드 첫 섹션에 신청 성공률을 시각화하는 게이지 및 시계열 패널이 잘 구성되어 있습니다. 성공률에 대한 임계값 설정과 시간별 추이 분석이 가능합니다.


269-472: 단계별 완료율 및 전환율 패널

신청 단계별 완료율과 전환율을 시각화하는 패널들이 적절하게 구성되어 있습니다. 사용자 여정에서 이탈이 발생하는 지점을 식별하는 데 유용합니다.


473-717: 처리 시간 모니터링 패널

처리 시간 분포 및 단계별 소요 시간을 분석할 수 있는 패널들이 적절히 구성되어 있습니다. 백분위수 기반 성능 분석으로 성능 병목 현상을 식별할 수 있습니다.


718-894: 오류 분석 패널

로그 및 오류 발생 추이를 분석할 수 있는 패널들이 포함되어 있습니다. 다양한 유형의 오류(Saga, 비즈니스, 시스템 오류 등)를 추적하여 문제 해결에 도움이 됩니다.


896-917: 대시보드 리프레시 및 태그 설정

10초 간격의 리프레시 설정과 적절한 태그 지정으로 모니터링 편의성이 향상되었습니다. 기본 6시간 시간 범위는 실시간 모니터링에 적합합니다.

competition-service/src/main/java/com/_42195km/msa/competitionservice/infrastructure/config/MetricsConfig.java (5)

10-17: 메트릭 설정 클래스 구성

MeterRegistry를 주입받는 설정 클래스로 메트릭 수집을 위한 기반을 잘 구성했습니다. Spring의 설정 클래스 패턴을 적절히 활용하고 있습니다.


19-70: 대회 신청 단계별 카운터 메트릭 정의

대회 신청의 시작, 완료, 실패 및 각 단계별 완료 횟수를 추적하는 카운터를 빈으로 등록했습니다. 이는 PR 목적인 어플리케이션 메트릭 수집에 직접 기여합니다.


72-80: 전체 신청 프로세스 타이머 설정

전체 신청 과정의 소요 시간을 측정하는 타이머를 구성했습니다. 백분위수 히스토그램을 발행하도록 설정하여 성능 분석에 필요한 상세 데이터를 제공합니다.


82-113: 단계별 소요 시간 타이머 설정

각 단계별 소요 시간을 측정하는 타이머들이 잘 정의되어 있습니다. 이를 통해 어느 단계에서 지연이 발생하는지 파악하여 사용자 경험 개선에 활용할 수 있습니다.


1-114: 전반적인 메트릭 설계

대회 신청 프로세스의 전체 메트릭 계층을 RED 방법론(요청율, 오류율, 지속시간)에 따라 체계적으로 설계했습니다. 이는 관측성을 크게 향상시키고 그라파나 대시보드와 잘 통합됩니다.

eureka.client.service-url.defaultZone= ${EUREKA_SERVER}

spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.ddl-auto=create
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

spring.jpa.hibernate.ddl-auto 설정이 create로 변경되었습니다.

spring.jpa.hibernate.ddl-auto=create 설정은 애플리케이션이 시작될 때마다 데이터베이스 스키마를 새로 생성합니다. 이는 데이터 손실을 초래할 수 있으며, 특히 운영 환경에서는 위험합니다.

이 설정은 개발이나 테스트 환경에서만 사용하고, 운영 환경에서는 다음 옵션 중 하나를 권장합니다:

-spring.jpa.hibernate.ddl-auto=create
+spring.jpa.hibernate.ddl-auto=validate

또는 배포 환경에 따라 설정을 다르게 적용하는 것을 고려하세요:

# 개발 환경
spring.jpa.hibernate.ddl-auto=update

# 운영 환경
spring.jpa.hibernate.ddl-auto=validate

<!-- Loki 서버 URL 설정 -->
<http>
<url>http://loki:3100/loki/api/v1/push</url>
<url>http://localhost:3100/loki/api/v1/push</url>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Loki 서버 주소가 localhost로 변경되었습니다.

Loki 서버 URL이 http://loki:3100/loki/api/v1/push에서 http://localhost:3100/loki/api/v1/push로 변경되었습니다. 이는 로컬 개발 환경에서는 작동할 수 있지만, 컨테이너화된 환경(Docker, Kubernetes 등)에서는 문제가 될 수 있습니다.

환경에 따라 유연하게 설정할 수 있도록 환경 변수를 사용하는 것이 좋습니다:

-            <url>http://localhost:3100/loki/api/v1/push</url>
+            <url>${LOKI_URL:http://loki:3100/loki/api/v1/push}</url>

그리고 application.properties 파일에 추가:

# Loki 설정
loki.url=http://loki:3100/loki/api/v1/push
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<url>http://localhost:3100/loki/api/v1/push</url>
<url>${LOKI_URL:http://loki:3100/loki/api/v1/push}</url>

Comment on lines +1 to +37
package com._42195km.msa.competitionservice.domain.repository;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import com._42195km.msa.competitionservice.domain.model.ApplicationSession;

@Repository
public class ApplicationSessionRepository {
private static final String SESSION_KEY_PREFIX = "app_session:";
private static final long SESSION_TTL_HOURS = 24;

private final RedisTemplate<String, ApplicationSession> redisTemplate;

public ApplicationSessionRepository(RedisTemplate<String, ApplicationSession> redisTemplate) {
this.redisTemplate = redisTemplate;
}

public void saveSession(ApplicationSession session) {
String key = SESSION_KEY_PREFIX + session.getSessionId();
redisTemplate.opsForValue().set(key, session);
redisTemplate.expire(key, SESSION_TTL_HOURS, TimeUnit.HOURS);
}

public ApplicationSession getSession(String sessionId) {
String key = SESSION_KEY_PREFIX + sessionId;
return redisTemplate.opsForValue().get(key);
}

public ApplicationSession findByCompetitionAndParticipant(UUID competitionId, UUID participantId) {
// TODO : 보조 인덱스나 검색을 사용 구현
return null;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Redis 기반 ApplicationSession 저장소 구현

대회 신청 세션을 Redis에 저장하고 관리하는 새로운 저장소가 구현되었습니다. 24시간 TTL을 가진 세션 데이터를 효율적으로 관리하는 기능을 제공합니다.

잘 구현된 부분:

  1. 명확한 키 접두사와 TTL 설정
  2. 세션 저장 및 조회 기능

개선 필요 사항:

  1. findByCompetitionAndParticipant 메서드는 현재 구현되지 않고 null을 반환합니다. TODO 주석에 명시된 대로 보조 인덱스나 검색 기능을 빠른 시일 내에 구현해주세요.
  2. Redis 작업 시 발생할 수 있는 예외 처리가 없습니다. 네트워크 지연이나 연결 실패와 같은 문제가 발생할 경우를 대비한 예외 처리를 추가하는 것이 좋겠습니다.
 public ApplicationSession getSession(String sessionId) {
     String key = SESSION_KEY_PREFIX + sessionId;
-    return redisTemplate.opsForValue().get(key);
+    try {
+        return redisTemplate.opsForValue().get(key);
+    } catch (Exception e) {
+        // 로깅 및 예외 처리
+        log.error("세션 조회 중 오류 발생. 세션 ID: " + sessionId, e);
+        return null;
+    }
 }

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines 171 to 181
public void updateEligibilityStatus(String eligibilityStatus) {
log.debug("Updating eligibility status: {} for saga: {}", eligibilityStatus, this.sagaId);
this.eligibilityStatus = eligibilityStatus;
this.updatedAt = LocalDateTime.now();
}

public void updateEligibilityReason(String eligibilityReason) {
log.debug("Updating eligibility reason: {} for saga: {}", eligibilityReason, this.sagaId);
this.eligibilityReason = eligibilityReason;
this.updatedAt = LocalDateTime.now();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

updatedAt 갱신 방식의 불일치

updateEligibilityStatus / updateEligibilityReason 두 메서드는 updateModifiedTime() 대신 직접 updatedAt 값을 설정하고 있습니다.
다른 update 메서드와 일관성을 유지하려면 updateModifiedTime() 호출로 통일하는 것이 좋습니다.

- this.eligibilityStatus = eligibilityStatus;
- this.updatedAt = LocalDateTime.now();
+ this.eligibilityStatus = eligibilityStatus;
+ updateModifiedTime();

같은 변경을 updateEligibilityReason에도 적용해 주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void updateEligibilityStatus(String eligibilityStatus) {
log.debug("Updating eligibility status: {} for saga: {}", eligibilityStatus, this.sagaId);
this.eligibilityStatus = eligibilityStatus;
this.updatedAt = LocalDateTime.now();
}
public void updateEligibilityReason(String eligibilityReason) {
log.debug("Updating eligibility reason: {} for saga: {}", eligibilityReason, this.sagaId);
this.eligibilityReason = eligibilityReason;
this.updatedAt = LocalDateTime.now();
}
public void updateEligibilityStatus(String eligibilityStatus) {
log.debug("Updating eligibility status: {} for saga: {}", eligibilityStatus, this.sagaId);
this.eligibilityStatus = eligibilityStatus;
updateModifiedTime();
}
public void updateEligibilityReason(String eligibilityReason) {
log.debug("Updating eligibility reason: {} for saga: {}", eligibilityReason, this.sagaId);
this.eligibilityReason = eligibilityReason;
updateModifiedTime();
}

Comment on lines +17 to 18
import com._42195km.msa.competitionservice.domain.model.SagaStatus;
import com._42195km.msa.competitionservice.infrastructure.persistence.ParticipantDetailRepositoryImpl;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

사용되지 않는 SagaStatus import 삭제

현재 파일 내에서 SagaStatus 타입을 참조하지 않습니다.
불필요한 import 는 제거하여 가독성과 빌드 속도를 개선하세요.

Comment on lines 160 to 165
if (!"SUCCESS".equals(event.getPaymentStatus())) {
log.error("Payment not successful for saga: {}", event.getSagaId());
sagaState.markAsFailed();
sagaStateRepository.saveSagaState(sagaState);
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

하드코딩된 "SUCCESS" 문자열 → Enum 사용 권장

결제 상태를 문자열로 비교하면 오타·대소문자 오류에 취약합니다.
PaymentStatus.SUCCESS 같은 enum / 상수로 치환하여 타입 안전성을 확보하세요.

-if (!"SUCCESS".equals(event.getPaymentStatus())) {
+if (event.getPaymentStatus() != PaymentStatus.SUCCESS) {

(enum 정의가 없다면 새로 추가)

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +38 to +44
// 활성 상태인 경우 인덱스 저장 (문자열 템플릿 사용)
if (state.getStatus() == SagaStatus.STARTED || state.getStatus() == SagaStatus.IN_PROGRESS) {
String indexKey = "sagaIndex:" + state.getCompetitionId() + ":" + state.getParticipantId();
sagaStringRedisTemplate.opsForValue().set(indexKey, state.getSagaId());
sagaStringRedisTemplate.expire(indexKey, SAGA_TTL_HOURS, TimeUnit.HOURS);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Saga 완료·실패 시 인덱스 즉시 삭제 고려

지금은 STARTED/IN_PROGRESS 에만 인덱스를 쓰고,
완료 후엔 나중에 조회될 때 Lazy 삭제합니다.

  • 즉시 삭제하지 않으면 같은 참가자·대회로 신규 Saga 시작 시 “활성 Saga 존재” 오탐 가능
  • TTL 24 h 동안 메모리를 불필요하게 사용

saveSagaState 내부에서 COMPLETED/FAILED/COMPENSATED 상태로 전환될 때 해당 인덱스 키를 즉시 delete하도록 보완하는 방안을 권장합니다.

Comment on lines +51 to +85
public CompetitionApplicationFacade(
CompetitionService competitionService,
ParticipantService participantService,
SagaService sagaService,
ApplicationSessionRepository sessionRepository,
@Qualifier("applicationStartCounter") Counter applicationStartCounter,
@Qualifier("applicationCompleteCounter") Counter applicationCompleteCounter,
@Qualifier("applicationFailCounter") Counter applicationFailCounter,
@Qualifier("termsAgreementCounter") Counter termsAgreementCounter,
@Qualifier("souvenirSelectionCounter") Counter souvenirSelectionCounter,
@Qualifier("shippingAddressCounter") Counter shippingAddressCounter,
@Qualifier("paymentCounter") Counter paymentCounter,
@Qualifier("applicationTotalTimeTimer") Timer applicationTotalTimeTimer,
@Qualifier("termsStepTimeTimer") Timer termsStepTimeTimer,
@Qualifier("souvenirStepTimeTimer") Timer souvenirStepTimeTimer,
@Qualifier("shippingStepTimeTimer") Timer shippingStepTimeTimer,
@Qualifier("paymentStepTimeTimer") Timer paymentStepTimeTimer
) {
this.competitionService = competitionService;
this.participantService = participantService;
this.sagaService = sagaService;
this.sessionRepository = sessionRepository;
this.applicationStartCounter = applicationStartCounter;
this.applicationCompleteCounter = applicationCompleteCounter;
this.applicationFailCounter = applicationFailCounter;
this.termsAgreementCounter = termsAgreementCounter;
this.souvenirSelectionCounter = souvenirSelectionCounter;
this.shippingAddressCounter = shippingAddressCounter;
this.paymentCounter = paymentCounter;
this.applicationTotalTimeTimer = applicationTotalTimeTimer;
this.termsStepTimeTimer = termsStepTimeTimer;
this.souvenirStepTimeTimer = souvenirStepTimeTimer;
this.shippingStepTimeTimer = shippingStepTimeTimer;
this.paymentStepTimeTimer = paymentStepTimeTimer;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

메트릭 Bean 주입의 가독성 및 유지보수성 개선 제안

현재 생성자에 10개 이상의 Counter, Timer가 개별 주입되고 있어 서명과 DI 설정이 장황합니다.
빈 수가 늘어날 경우 생성자가 급격히 비대해지므로, 전용 DTO/컨테이너(MetricsHolder)로 래핑하거나
Map<String, Counter> 형태로 주입받아 이름으로 조회하도록 리팩터링을 고려해 보세요.

장점

  1. 생성자 간결화 및 테스트 용이성 향상
  2. 메트릭 추가‧삭제 시 파사드 코드 변경 최소화
  3. Bean Qualifier 남발로 인한 오타‧중복 위험 감소

선택 사항이지만 장기적으로 운영 서비스의 가독성을 크게 높일 수 있습니다.

Comment on lines +134 to +142
// 현재 단계 결정
ApplicationStep currentStep = determineCurrentStep(appDto);

// 서비스 호출하여 실제 처리
String response = sagaService.processCompleteApplication(appDto);

// 결과에 따라 세션 업데이트 및 메트릭 기록
updateSessionAndMetrics(session, currentStep, appDto, response);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

사용되지 않는 매개변수 제거 또는 활용

currentStepresponseupdateSessionAndMetrics 인자로 전달하지만 내부에서 전혀 사용되지 않습니다.
경고를 유발할 뿐 아니라 향후 유지보수 시 혼란을 초래하므로 제거하거나 실제 로깅/메트릭에 활용하세요.

-ApplicationStep currentStep = determineCurrentStep(appDto);
-...
-updateSessionAndMetrics(session, currentStep, appDto, response);
+// currentStep가 불필요하다면 아래처럼 단순화
+updateSessionAndMetrics(session, appDto);
-private void updateSessionAndMetrics(ApplicationSession session, ApplicationStep currentStep,
-    CompleteAppDto appDto, String response) {
+private void updateSessionAndMetrics(ApplicationSession session, CompleteAppDto appDto) {

불필요한 인자를 제거하면 코드가 간결해지고 의도가 명확해집니다.

Also applies to: 217-220

Comment on lines +222 to +273
// 1. 약관 동의 단계 (항상 처음부터 확인)
if (appDto.getTermsAgreed() != null && appDto.getTermsAgreed()) {
if (session.getTermsAgreedTime() == null) {
session.completeTermsAgreement();
termsAgreementCounter.increment();
termsStepTimeTimer.record(session.getTermsStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("약관 동의 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getTermsStepTimeMillis());
}

// 2. 약관 동의가 완료된 후에만 기념품 선택 단계 처리
if (appDto.getSouvenirSelection() != null && session.getTermsAgreedTime() != null) {
if (session.getSouvenirSelectedTime() == null) {
session.completeSouvenirSelection();
souvenirSelectionCounter.increment();
souvenirStepTimeTimer.record(session.getSouvenirStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("기념품 선택 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getSouvenirStepTimeMillis());
}

// 3. 기념품 선택이 완료된 후에만 배송지 입력 단계 처리
if (appDto.getShippingAddress() != null && session.getSouvenirSelectedTime() != null) {
if (session.getShippingEnteredTime() == null) {
session.completeShippingAddress();
shippingAddressCounter.increment();
shippingStepTimeTimer.record(session.getShippingStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("배송지 입력 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getShippingStepTimeMillis());
}

// 4. 배송지 입력이 완료된 후에만 결제 완료 단계 처리
if (appDto.getPaymentStatus() != null && "SUCCESS".equals(appDto.getPaymentStatus())
&& session.getShippingEnteredTime() != null) {
if (session.getPaymentCompletedTime() == null) {
session.completePayment();
paymentCounter.increment();
paymentStepTimeTimer.record(session.getPaymentStepTimeMillis(), TimeUnit.MILLISECONDS);

// 모든 단계가 완료되었는지 확인하여 신청 완료 처리
if (isAllStepsCompleted(session)) {
session.complete();
applicationCompleteCounter.increment();
applicationTotalTimeTimer.record(session.getTotalTimeMillis(), TimeUnit.MILLISECONDS);
log.info("대회 신청 프로세스 완료: sessionId={}, 총 소요시간={}ms",
session.getSessionId(), session.getTotalTimeMillis());
}
}
}
}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

후속 단계가 처리되지 않는 논리적 결함 수정 필요

updateSessionAndMetrics 메서드의 최상위 조건문이 termsAgreed 체크로 감싸져 있어,
약관 동의 이후 단계(기념품 선택, 배송지 입력, 결제 완료)가 요청 DTO에 termsAgreed 필드가 다시 전달되지 않으면 절대 실행되지 않습니다.
프론트엔드가 단계별로 필요한 필드만 전송할 가능성이 높기 때문에, 실제 운영 시 후속 단계가 진행되지 않는 치명적 버그가 발생합니다.

-// 1. 약관 동의 단계 (항상 처음부터 확인)
-if (appDto.getTermsAgreed() != null && appDto.getTermsAgreed()) {
-    ...
-}
+// 1. 약관 동의 단계
+if (appDto.getTermsAgreed() != null && appDto.getTermsAgreed()
+    && session.getTermsAgreedTime() == null) {
+    session.completeTermsAgreement();
+    ...
+}
+
+// 2. 기념품 선택 단계 (선행 단계 완료 여부만 확인)
+if (appDto.getSouvenirSelection() != null
+    && session.getTermsAgreedTime() != null
+    && session.getSouvenirSelectedTime() == null) {
+    ...
+}
+
+// 3. 배송지 입력 단계
+if (appDto.getShippingAddress() != null
+    && session.getSouvenirSelectedTime() != null
+    && session.getShippingEnteredTime() == null) {
+    ...
+}
+
+// 4. 결제 완료 단계
+if ("SUCCESS".equals(appDto.getPaymentStatus())
+    && session.getShippingEnteredTime() != null
+    && session.getPaymentCompletedTime() == null) {
+    ...
+}

위와 같이 각 단계별로 독립적인 if 블록을 두고, 선행 단계 완료 여부만 확인하도록 수정해야 합니다.
그렇지 않으면 메트릭 누락 및 사용자 불만이 발생할 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 1. 약관 동의 단계 (항상 처음부터 확인)
if (appDto.getTermsAgreed() != null && appDto.getTermsAgreed()) {
if (session.getTermsAgreedTime() == null) {
session.completeTermsAgreement();
termsAgreementCounter.increment();
termsStepTimeTimer.record(session.getTermsStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("약관 동의 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getTermsStepTimeMillis());
}
// 2. 약관 동의가 완료된 후에만 기념품 선택 단계 처리
if (appDto.getSouvenirSelection() != null && session.getTermsAgreedTime() != null) {
if (session.getSouvenirSelectedTime() == null) {
session.completeSouvenirSelection();
souvenirSelectionCounter.increment();
souvenirStepTimeTimer.record(session.getSouvenirStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("기념품 선택 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getSouvenirStepTimeMillis());
}
// 3. 기념품 선택이 완료된 후에만 배송지 입력 단계 처리
if (appDto.getShippingAddress() != null && session.getSouvenirSelectedTime() != null) {
if (session.getShippingEnteredTime() == null) {
session.completeShippingAddress();
shippingAddressCounter.increment();
shippingStepTimeTimer.record(session.getShippingStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("배송지 입력 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getShippingStepTimeMillis());
}
// 4. 배송지 입력이 완료된 후에만 결제 완료 단계 처리
if (appDto.getPaymentStatus() != null && "SUCCESS".equals(appDto.getPaymentStatus())
&& session.getShippingEnteredTime() != null) {
if (session.getPaymentCompletedTime() == null) {
session.completePayment();
paymentCounter.increment();
paymentStepTimeTimer.record(session.getPaymentStepTimeMillis(), TimeUnit.MILLISECONDS);
// 모든 단계가 완료되었는지 확인하여 신청 완료 처리
if (isAllStepsCompleted(session)) {
session.complete();
applicationCompleteCounter.increment();
applicationTotalTimeTimer.record(session.getTotalTimeMillis(), TimeUnit.MILLISECONDS);
log.info("대회 신청 프로세스 완료: sessionId={}, 총 소요시간={}ms",
session.getSessionId(), session.getTotalTimeMillis());
}
}
}
}
}
}
}
// 1. 약관 동의 단계
if (appDto.getTermsAgreed() != null
&& appDto.getTermsAgreed()
&& session.getTermsAgreedTime() == null) {
session.completeTermsAgreement();
termsAgreementCounter.increment();
termsStepTimeTimer.record(session.getTermsStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("약관 동의 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getTermsStepTimeMillis());
}
// 2. 기념품 선택 단계 (선행 단계 완료 여부만 확인)
if (appDto.getSouvenirSelection() != null
&& session.getTermsAgreedTime() != null
&& session.getSouvenirSelectedTime() == null) {
session.completeSouvenirSelection();
souvenirSelectionCounter.increment();
souvenirStepTimeTimer.record(session.getSouvenirStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("기념품 선택 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getSouvenirStepTimeMillis());
}
// 3. 배송지 입력 단계
if (appDto.getShippingAddress() != null
&& session.getSouvenirSelectedTime() != null
&& session.getShippingEnteredTime() == null) {
session.completeShippingAddress();
shippingAddressCounter.increment();
shippingStepTimeTimer.record(session.getShippingStepTimeMillis(), TimeUnit.MILLISECONDS);
log.info("배송지 입력 단계 완료: sessionId={}, 소요시간={}ms",
session.getSessionId(), session.getShippingStepTimeMillis());
}
// 4. 결제 완료 단계
if ("SUCCESS".equals(appDto.getPaymentStatus())
&& session.getShippingEnteredTime() != null
&& session.getPaymentCompletedTime() == null) {
session.completePayment();
paymentCounter.increment();
paymentStepTimeTimer.record(session.getPaymentStepTimeMillis(), TimeUnit.MILLISECONDS);
// 모든 단계가 완료되었는지 확인하여 신청 완료 처리
if (isAllStepsCompleted(session)) {
session.complete();
applicationCompleteCounter.increment();
applicationTotalTimeTimer.record(session.getTotalTimeMillis(), TimeUnit.MILLISECONDS);
log.info("대회 신청 프로세스 완료: sessionId={}, 총 소요시간={}ms",
session.getSessionId(), session.getTotalTimeMillis());
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Competition] 대회 도메인 모니터링 적용

3 participants