Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
35db47e
feat/116 :: Order 도메인 Aggregate, VO, 이벤트, 예외, 글로벌 예외 계층
lian2945 May 18, 2026
f79c8da
feat/116 :: Order UseCase, Port, Service, DTO 구현
lian2945 May 18, 2026
fb32475
feat/116 :: Order 어댑터 (Web/Kafka/gRPC/Persistence/Security/Scheduler)
lian2945 May 18, 2026
c43e703
feat/116 :: Order application-*.yml, Dockerfile, build.gradle 설정
lian2945 May 18, 2026
616e03c
feat/117 :: Release 도메인 Aggregate, VO, 예외, 글로벌 예외 계층
lian2945 May 18, 2026
87cf732
feat/117 :: Release UseCase, Port, Service, DTO 구현
lian2945 May 18, 2026
56edccf
feat/117 :: Release 어댑터 (Web/gRPC/Persistence/Security/Scheduler)
lian2945 May 18, 2026
0c59c17
feat/117 :: Release application-*.yml, Dockerfile, build.gradle 설정
lian2945 May 18, 2026
503d3c3
feat/116 :: SonarCloud 경고 해결 (빈 테스트 메서드 주석, Dockerfile non-root 유저)
lian2945 May 18, 2026
f334e59
feat/117 :: SonarCloud 경고 해결 (빈 테스트 메서드 주석, Dockerfile non-root 유저)
lian2945 May 18, 2026
6e3c690
feat/117 :: release application-dev.yml trusted.ips 수정
lian2945 May 19, 2026
59650b6
feat/116 :: order application-dev.yml trusted.ips 수정
lian2945 May 19, 2026
6d817f6
feat/117 :: release Dockerfile 경로 및 줄바꿈 오류 수정
lian2945 May 19, 2026
7452d15
feat/116 :: order Dockerfile 경로 및 줄바꿈 오류 수정
lian2945 May 19, 2026
831584a
feat/117 :: release application-prod.yml 추가
lian2945 May 19, 2026
e92585c
feat/116 :: order application-prod.yml 수정 (server.port/TRUSTED_IPS 제거…
lian2945 May 19, 2026
6f90fdb
feat/116 :: subscribe/creator application-dev/prod.yml server.port 제거
lian2945 May 20, 2026
23ac3b3
feat/117 :: subscribe/creator application-dev/prod.yml server.port 제거
lian2945 May 20, 2026
613a3e6
fix(grpc): GrpcAdapter withDeadlineAfter(2s) 설정 추가
lian2945 May 20, 2026
b68418b
fix(grpc): GrpcAdapter withDeadlineAfter(2s) 설정 추가
lian2945 May 20, 2026
613fefc
fix(inbox): @DltHandler 추가로 DLT 전환 시 Inbox DEAD_LETTERED 상태 추적
lian2945 May 20, 2026
45d146b
fix(inbox): @DltHandler 추가로 DLT 전환 시 Inbox DEAD_LETTERED 상태 추적
lian2945 May 20, 2026
fff6513
fix(user/kafka): 전체 리스너에 @RetryableTopic + @DltHandler 추가
lian2945 May 20, 2026
1ec5ebc
fix(user/kafka): 전체 리스너에 @RetryableTopic + @DltHandler 추가
lian2945 May 20, 2026
12d1716
refactor(inbox): InboxEvent에 occurredAt() 추가, IdempotentAspect 리플렉션 제거
lian2945 May 20, 2026
af1f284
refactor(inbox): InboxEvent에 occurredAt() 추가, IdempotentAspect 리플렉션 제거
lian2945 May 20, 2026
a35f211
refactor(inbox): InboxEvent occurredAt() 계약 추가, IdempotentAspect 리플렉션 제거
lian2945 May 20, 2026
b5ba18e
fix(inbox): InboxProperties 누락 및 inbox 설정 추가
lian2945 May 20, 2026
481ceb4
fix(inbox): InboxEntity에 occurredAt 필드 추가
lian2945 May 20, 2026
7bfbddd
fix(inbox): InboxEntity에 occurredAt 필드 추가
lian2945 May 20, 2026
947d50a
ci: trigger build
lian2945 May 20, 2026
1531aa1
ci: trigger build
lian2945 May 20, 2026
92bede8
fix(inbox): InboxProperties 누락 및 inbox 설정 추가
lian2945 May 20, 2026
08ae806
fix(inbox): SubscribeInboxStatus/Repository, GeneralGoodsInboxStatus/…
lian2945 May 20, 2026
2b276d1
fix(inbox): SubscribeInboxStatus/Repository, GeneralGoodsInboxStatus/…
lian2945 May 20, 2026
4109f1f
fix(creator): CreatorDomainEvent → CreatorOutbox 리네이밍 및 테이블명 creator_…
lian2945 May 20, 2026
320c666
fix(creator): CreatorDomainEvent → CreatorOutbox 리네이밍 및 테이블명 creator_…
lian2945 May 20, 2026
df2ab7a
fix(creator): 서비스 레이어 CreatorDomainEventRepositoryPort → CreatorOutbo…
lian2945 May 20, 2026
04a0457
fix(creator): 서비스 레이어 CreatorDomainEventRepositoryPort → CreatorOutbo…
lian2945 May 20, 2026
dada21f
fix(creator): 도메인 이벤트 occurredAt 필드 추가 및 Creator.createBuilder() 복구
lian2945 May 20, 2026
7ca20c6
fix(creator): 도메인 이벤트 occurredAt 필드 추가 및 Creator.createBuilder() 복구
lian2945 May 20, 2026
3e82824
fix(kafka): creator/subscribe retry 설정 spring.kafka.retry.topic 형식으로 수정
lian2945 May 20, 2026
9d1b838
fix(kafka): creator/subscribe retry 설정 spring.kafka.retry.topic 형식으로 수정
lian2945 May 20, 2026
dbafa83
fix(creator): refactor/115 기준으로 creator 서비스 전체 동기화
lian2945 May 20, 2026
fbbf849
fix(creator): refactor/115 기준으로 creator 서비스 전체 동기화
lian2945 May 20, 2026
be3e263
fix(creator): creator.proto에 GetCreatorIdByUserId rpc 추가
lian2945 May 20, 2026
ae99e01
fix(creator): creator.proto에 GetCreatorIdByUserId rpc 추가
lian2945 May 20, 2026
1fea8c9
fix(docker): Dockerfile appuser 보안 패턴 및 JAR_FILE 경로 통일
lian2945 May 20, 2026
19f1287
fix(docker): Dockerfile appuser 보안 패턴 및 JAR_FILE 경로 통일
lian2945 May 20, 2026
525a95a
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
24d2402
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
95d0e35
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
c15bac0
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
ef6480a
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
36b339a
feat(order): Resilience4j CircuitBreaker/TimeLimiter 설정 추가 (releaseSe…
lian2945 May 20, 2026
de9c5f8
feat(order): Resilience4j CircuitBreaker/TimeLimiter 설정 추가 (releaseSe…
lian2945 May 21, 2026
26bc10a
refactor(order): @Transactional 범위 내 gRPC 호출 제거
lian2945 May 21, 2026
aeef1e8
feat(release): Kafka consumer로 soldQuantity 비동기 처리 (Inbox 패턴)
lian2945 May 21, 2026
62aaa64
refactor(order): AutoConfirm 스케줄러 청크 처리 + 분산 락 + 설정 외부화
lian2945 May 21, 2026
413f8aa
refactor(order): AutoConfirm 스케줄러 청크 처리 + 분산 락 + 설정 외부화 (누락 파일 추가)
lian2945 May 21, 2026
7c6f82a
fix(kafka): IdempotentAspect catch(Throwable) 수정 및 @RetryableTopic ex…
lian2945 May 22, 2026
2bf145b
Merge branch 'feat/116' into feat/117
lian2945 May 22, 2026
5bb879c
fix(order): ConfirmOrderLineService 이벤트 중복 발행 방지 가드 추가
lian2945 May 22, 2026
362bf6e
Merge branch 'feat/116' into feat/117
lian2945 May 22, 2026
0d66382
fix(order): confirmOrderLine 도메인에서 PREPARING 상태만 허용하도록 수정
lian2945 May 22, 2026
0394205
Merge branch 'feat/116' into feat/117
lian2945 May 22, 2026
a72677b
fix(kafka): @RetryableTopic에 dltStrategy, dltTopicSuffix 명시
lian2945 May 22, 2026
172f5e2
Merge branch 'feat/116' into feat/117
lian2945 May 22, 2026
e7bf5c8
fix(kafka): IdempotentAspect catch(Exception) → catch(Throwable) 컴파일 …
lian2945 May 22, 2026
1b1d96c
Merge branch 'feat/116' into feat/117
lian2945 May 22, 2026
b0327b5
Merge remote-tracking branch 'origin/main' into feat/117
lian2945 May 22, 2026
14a67c3
fix(grpc): proto Release 메시지 누락 필드 추가 및 created_at 매핑 오류 수정
lian2945 May 22, 2026
418df4d
fix(scheduler): Redisson 분산 락 + 청크 처리로 AutoStartSale 다중 인스턴스 충돌 해소
lian2945 May 22, 2026
4b9b8fa
chore(deps): redisson-spring-boot-starter 3.45.1 → 3.50.0
lian2945 May 22, 2026
6cdb350
refactor(grpc): IncreaseSoldQuantity gRPC 엔드포인트 제거 — Kafka Inbox 단일 경…
lian2945 May 22, 2026
53f1aff
refactor(release): gRPC 호출을 트랜잭션 밖으로 분리
lian2945 May 22, 2026
9a1f7d4
fix(release): PR 리뷰 코멘트 반영 — isOnSale 도메인 위임, Validator 개선, gRPC 채널 재사용
lian2945 May 22, 2026
3bf4654
refactor(validation): ZonedDateTime 제거 — epochSecond % 600 으로 단순화
lian2945 May 22, 2026
4017fd3
fix(general-goods): CreatorGrpcAdapter — grpcChannelFactory 직접 호출 제거,…
lian2945 May 23, 2026
0722dca
fix(order): shedlock-provider-redisson → shedlock-provider-redis-spri…
lian2945 May 23, 2026
82f2719
fix(docker): COPY --chown으로 chown RUN 레이어 제거 — I/O 에러 방지
lian2945 May 23, 2026
ff85478
feat(user): GET /user/me 엔드포인트 추가
lian2945 May 26, 2026
6807066
[feat/117] :: order 서비스 Debezium CDC 역직렬화 오류 수정
lian2945 Jun 1, 2026
4ab4ca7
[feat/117] :: order outbox payload에 event_id 포함
lian2945 Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions services/release/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu
ARG JAR_FILE=build/libs/*.jar
ARG JAR_FILE=services/release/build/libs/*.jar
WORKDIR /app
COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-jar", "app.jar"]
42 changes: 41 additions & 1 deletion services/release/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
plugins {
id 'com.google.protobuf' version '0.9.6'
}

ext {
springGrpcVersion = "1.0.2"
springCloudVersion = "2025.1.0"
}
version = '0.0.1'
description = 'release'

dependencies {
}
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.grpc:spring-grpc-server-spring-boot-starter'
implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter'
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
runtimeOnly 'com.mysql:mysql-connector-j'

annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}

dependencyManagement {
imports {
mavenBom "org.springframework.grpc:spring-grpc-dependencies:$springGrpcVersion"
mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion"
}
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.34.0"
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.79.0'
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package kr.magicbox.release.adapter.in.grpc;

import io.grpc.*;
import kr.magicbox.release.global.exception.BusinessException;
import kr.magicbox.release.global.exception.SystemError;
import lombok.extern.slf4j.Slf4j;
import org.springframework.grpc.server.GlobalServerInterceptor;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@GlobalServerInterceptor
public class GrpcExceptionInterceptor implements ServerInterceptor {

@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(

Check warning on line 16 in services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/GrpcExceptionInterceptor.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=f-lab-edu_MagicBox&issues=AZ46LSqMLZvu6kXfElUP&open=AZ46LSqMLZvu6kXfElUP&pullRequest=25

Check warning on line 16 in services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/GrpcExceptionInterceptor.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=f-lab-edu_MagicBox&issues=AZ46LSqMLZvu6kXfElUO&open=AZ46LSqMLZvu6kXfElUO&pullRequest=25
ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
ServerCall.Listener<ReqT> delegate = next.startCall(call, headers);
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(delegate) {
@Override
public void onHalfClose() {
try {
super.onHalfClose();
} catch (BusinessException e) {
log.warn("gRPC 비즈니스 예외: {}", e.getMessage());
call.close(toGrpcStatus(e).withDescription(e.getMessage()), new Metadata());
} catch (SystemError e) {
log.error("gRPC 시스템 오류: {}", e.getMessage(), e);
call.close(Status.INTERNAL.withDescription("서버 내부 오류가 발생했습니다."), new Metadata());
} catch (Exception e) {
log.error("gRPC 예상치 못한 예외: {}", e.getMessage(), e);
call.close(Status.UNKNOWN.withDescription("예상치 못한 오류가 발생했습니다."), new Metadata());
}
}
};
}

private Status toGrpcStatus(BusinessException e) {
return switch (e.getStatus()) {
case NOT_FOUND -> Status.NOT_FOUND;
case CONFLICT -> Status.ALREADY_EXISTS;
case FORBIDDEN -> Status.PERMISSION_DENIED;
case UNAUTHORIZED -> Status.UNAUTHENTICATED;
default -> Status.INVALID_ARGUMENT;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package kr.magicbox.release.adapter.in.grpc;

import com.google.protobuf.Timestamp;
import io.grpc.stub.StreamObserver;
import kr.magicbox.release.application.dto.query.GetReleaseQuery;
import kr.magicbox.release.application.dto.result.ReleaseResult;
import kr.magicbox.release.application.port.in.GetReleaseCountByCreatorUseCase;
import kr.magicbox.release.application.port.in.GetReleaseListByCreatorUseCase;
import kr.magicbox.release.application.port.in.GetReleaseUseCase;
import kr.magicbox.release.application.port.in.IncreaseSoldQuantityUseCase;
import kr.magicbox.release.domain.enums.ReleaseStatus;
import kr.magicbox.release.domain.vo.CreatorId;
import kr.magicbox.release.domain.vo.ReleaseId;
import kr.magicbox.release.grpc.release.GetReleaseCountRequest;
import kr.magicbox.release.grpc.release.GetReleaseCountResponse;
import kr.magicbox.release.grpc.release.GetReleasesByCreatorIdRequest;
import kr.magicbox.release.grpc.release.GetReleasesByCreatorIdResponse;
import kr.magicbox.release.grpc.release.GetRemainingQuantityRequest;
import kr.magicbox.release.grpc.release.GetRemainingQuantityResponse;
import kr.magicbox.release.grpc.release.IncreaseSoldQuantityRequest;
import kr.magicbox.release.grpc.release.IncreaseSoldQuantityResponse;
import kr.magicbox.release.grpc.release.IsReleaseOnSaleRequest;
import kr.magicbox.release.grpc.release.IsReleaseOnSaleResponse;
import kr.magicbox.release.grpc.release.Release;
import kr.magicbox.release.grpc.release.ReleaseLevel;
import kr.magicbox.release.grpc.release.ReleaseServiceGrpc;
import lombok.RequiredArgsConstructor;
import org.springframework.grpc.server.service.GrpcService;

import java.time.Instant;
import java.util.List;

@GrpcService
@RequiredArgsConstructor
public class ReleaseGrpcService extends ReleaseServiceGrpc.ReleaseServiceImplBase {

private final GetReleaseCountByCreatorUseCase getReleaseCountByCreatorUseCase;
private final GetReleaseListByCreatorUseCase getReleaseListByCreatorUseCase;
private final GetReleaseUseCase getReleaseUseCase;
private final IncreaseSoldQuantityUseCase increaseSoldQuantityUseCase;

@Override
public void getReleaseCount(GetReleaseCountRequest request,
StreamObserver<GetReleaseCountResponse> responseObserver) {
long count = getReleaseCountByCreatorUseCase.getReleaseCount(CreatorId.of(request.getCreatorId()));
responseObserver.onNext(GetReleaseCountResponse.newBuilder()
.setReleaseCount(count)
.build());
responseObserver.onCompleted();
}

@Override
public void getReleasesByCreatorId(GetReleasesByCreatorIdRequest request,
StreamObserver<GetReleasesByCreatorIdResponse> responseObserver) {
List<ReleaseResult> results = getReleaseListByCreatorUseCase.getReleaseListByCreator(
CreatorId.of(request.getCreatorId()));

List<Release> releases = results.stream()
.map(this::toProtoRelease)
.toList();

responseObserver.onNext(GetReleasesByCreatorIdResponse.newBuilder()
.addAllReleases(releases)
.build());
responseObserver.onCompleted();
}

@Override
public void isReleaseOnSale(IsReleaseOnSaleRequest request,
StreamObserver<IsReleaseOnSaleResponse> responseObserver) {
ReleaseResult result = getReleaseUseCase.getRelease(
GetReleaseQuery.builder().releaseId(request.getReleaseId()).build());
boolean onSale = result.status() == ReleaseStatus.ON_SALE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major] isReleaseOnSale 응답을 status enum 비교 대신 도메인 메서드로 위임하는 것이 안전합니다.

ReleaseResult result = getReleaseUseCase.getRelease(...);
boolean onSale = result.status() == ReleaseStatus.ON_SALE;
  • 향후 "판매 중" 판단 기준이 단순 상태 비교 이상으로 확장될 경우(예: 매진 직전, 일시중단 등) 호출 측마다 누락이 발생합니다.
  • Release Aggregate 에 이미 isOnSale() 이 정의되어 있으므로, 도메인 객체를 직접 조회하는 별도 UseCase 를 두거나 ReleaseResultisOnSale 파생 필드를 두어 도메인 규칙이 한 곳에서 관리되도록 해 주세요.

또한 getReleaseUseCase.getRelease 가 NOT_FOUND 인 경우 그대로 예외가 전파되어 gRPC 클라이언트에는 INVALID_ARGUMENT/NOT_FOUND 가 섞여 나갑니다(GrpcExceptionInterceptor의 분기). order/waiting 호출 측에서 isReleaseOnSale=false 와 "릴리즈 없음" 을 명확히 구분할 수 있도록 응답 정책을 정리해 주세요.

responseObserver.onNext(IsReleaseOnSaleResponse.newBuilder()
.setOnSale(onSale)
.build());
responseObserver.onCompleted();
}

@Override
public void getRemainingQuantity(GetRemainingQuantityRequest request,
StreamObserver<GetRemainingQuantityResponse> responseObserver) {
ReleaseResult result = getReleaseUseCase.getRelease(
GetReleaseQuery.builder().releaseId(request.getReleaseId()).build());
int remaining = result.limitedQuantity() - result.soldQuantity();
responseObserver.onNext(GetRemainingQuantityResponse.newBuilder()
.setRemainingQuantity(remaining)
.build());
responseObserver.onCompleted();
}

@Override
public void increaseSoldQuantity(IncreaseSoldQuantityRequest request,
StreamObserver<IncreaseSoldQuantityResponse> responseObserver) {
increaseSoldQuantityUseCase.increaseSoldQuantity(ReleaseId.of(request.getReleaseId()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major] increaseSoldQuantity 동기 gRPC 경로 / Kafka Inbox 경로가 중복됩니다.

같은 도메인 행위(Release#increaseSoldQuantity)가 두 진입점에서 호출됩니다.

  • 동기: ReleaseGrpcService#increaseSoldQuantity (RPC)
  • 비동기: OrderEventKafkaListener#handleReleaseSoldQuantityIncrease (Kafka Inbox)

위험 요소:

  1. 주문 서비스가 두 경로를 모두 사용하면 동일 주문에 대해 sold_quantity 가 2회 증가하여 oversell 됩니다.
  2. gRPC 경로에는 멱등성 보호(Inbox/eventId 검증)가 없어, 클라이언트가 재시도하면 그대로 또 증가합니다.
  3. 두 경로 모두 @Version 낙관락에 의존하기 때문에 단순 충돌 시 예외만 던지고 재시도 정책이 없습니다.

제안: 주문→릴리즈 sold quantity 증가는 Outbox→Inbox(Kafka) 단일 경로로 통일하고 gRPC increaseSoldQuantity 엔드포인트는 제거하거나, 반대로 동기 차감을 유지하려면 Inbox 리스너를 제거하고 멱등키(eventId)에 대한 명시적 중복 방지를 gRPC에 도입해 주세요.

responseObserver.onNext(IncreaseSoldQuantityResponse.newBuilder().build());
responseObserver.onCompleted();
}

private Release toProtoRelease(ReleaseResult result) {
Instant scheduledAt = result.scheduledAt();
return Release.newBuilder()
.setReleaseId(result.releaseId())
.setTitle(result.title())
.setThumbnailUrl(result.thumbnailUrl() != null ? result.thumbnailUrl() : "")
.setLevel(toProtoLevel(result.level()))
.setPrice(result.price())
.setCreatedAt(Timestamp.newBuilder()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Critical] proto created_at 필드에 scheduledAt 값을 매핑하고 있습니다.

.setCreatedAt(Timestamp.newBuilder()
        .setSeconds(scheduledAt.getEpochSecond())
        .setNanos(scheduledAt.getNano())
        .build())
  • 호출 측(creator 서비스의 ReleaseQueryGrpcAdapter 등)은 응답의 created_at을 실제 생성 시각으로 신뢰합니다. 여기에 판매 예정 시각이 들어가면 정렬/노출 로직이 모두 어긋납니다.
  • 그리고 getReleasesByCreatorId 응답에는 status, scheduled_at, limited_quantity, sold_quantity 같은 release 의 핵심 속성이 누락되어 있어 order/waiting 같은 호출 측에서 다시 확인 호출이 필요해질 가능성이 큽니다.

제안: proto 정의에 created_at/scheduled_at/status/limited_quantity/sold_quantity 를 추가하고, 매핑을 의미에 맞게 분리해 주세요. (특히 created_atresult.createdAt()을 써야 합니다.)

.setSeconds(scheduledAt.getEpochSecond())
.setNanos(scheduledAt.getNano())
.build())
.build();
}

private ReleaseLevel toProtoLevel(kr.magicbox.release.domain.enums.ReleaseLevel level) {
return switch (level) {
case BEGINNER -> ReleaseLevel.BEGINNER;
case INTERMEDIATE -> ReleaseLevel.INTERMEDIATE;
case ADVANCED -> ReleaseLevel.ADVANCED;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.magicbox.release.adapter.in.scheduler;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@Configuration
public class SchedulerConfiguration {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.magicbox.release.adapter.in.scheduler;

import kr.magicbox.release.application.port.in.AutoStartSaleUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class StartSaleScheduler {

private final AutoStartSaleUseCase autoStartSaleUseCase;

@Scheduled(fixedDelay = 60000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Critical] 분산 락 없이 1분 주기로 동작하여 다중 인스턴스에서 충돌 위험이 큽니다.

  • AutoStartSaleService#autoStartScheduledReleasesfindScheduledBefore(now) 로 모든 대상 릴리즈를 한 트랜잭션에서 가져와 release.startSale() + update()를 반복 호출합니다. 여러 pod 가 같은 SCHEDULED 행에 대해 동시에 진입하면 @Version 기반 낙관락 충돌로 일부 instance 가 예외와 함께 종료되고, 다음 주기까지 시작이 지연됩니다.
  • 최근 order 서비스의 AutoConfirm 스케줄러에 적용한 "분산 락 + 청크 처리" 패턴을 동일하게 도입하는 것이 자연스럽습니다 (ShedLock / Redisson 등).
  • PR 설명에는 "10분 단위 자동 판매 시작"이라고 적혀있지만 fixedDelay = 60000(1분)으로 설정되어 있어 설명과 코드가 불일치합니다. 의도가 1분이라면 PR 문서를 수정하고, 10분이 의도라면 fixedDelayString = "PT10M" 또는 cron 으로 명시해 주세요.
@Scheduled(fixedDelay = 60000) // 1분
public void autoStartScheduledReleases() { ... }

부가적으로, @ScheduledAtMultipleOfTenMinutes 로 등록 시각을 10분 단위로 강제했다면 스케줄러도 10분 주기(또는 cron 0 */10 * * * *)로 정렬하는 것이 의미 충돌이 적습니다.

public void autoStartScheduledReleases() {
log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 시작");
autoStartSaleUseCase.autoStartScheduledReleases();
log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 완료");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kr.magicbox.release.adapter.in.security.configuration;

import kr.magicbox.release.adapter.in.security.filter.UserInfoExtractFilter;
import kr.magicbox.release.adapter.in.security.properties.TrustedIpProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.ForwardedHeaderFilter;

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(TrustedIpProperties.class)
@RequiredArgsConstructor
public class SecurityConfiguration {

private final TrustedIpProperties trustedIpProperties;

@Bean
public ForwardedHeaderFilter forwardedHeaderFilter() {
return new ForwardedHeaderFilter();
}

@Bean
public UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new UserInfoExtractFilter(trustedIpProperties), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major] /admin/release/* 가 인증/권한 없이 노출되어 있습니다.

.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())

AdminReleaseCommandController(start-sale / end-sale)는 운영자만 호출해야 하는데, 현재 설정에선 외부에서 누구나 호출 가능합니다. 게이트웨이가 보호한다 하더라도 서비스 자체적으로:

  • /admin/** 경로는 UserInfoExtractFilter 가 주입한 ROLE_ADMIN 권한 보유자만 통과하도록 requestMatchers("/admin/**").hasRole("ADMIN") 추가
  • 또는 컨트롤러 메서드에 @PreAuthorize("hasRole('ADMIN')") 적용

이 필요합니다. 외부 노출 위험이 큰 상태 전이(판매 시작/종료) 엔드포인트라 우선순위를 높여 봐주세요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

이것은 AuthorizationPolicy를 통해 해결할 예정입니다

.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package kr.magicbox.release.adapter.in.security.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.magicbox.release.adapter.in.security.properties.TrustedIpProperties;
import kr.magicbox.release.domain.vo.UserId;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class UserInfoExtractFilter extends OncePerRequestFilter {

private final TrustedIpProperties trustedIpProperties;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String clientIp = request.getRemoteAddr();

if (!trustedIpProperties.getIps().contains(clientIp)) {
filterChain.doFilter(request, response);
return;
}

String userIdHeader = request.getHeader("X-User-Id");

if (!isValidUserId(userIdHeader)) {
filterChain.doFilter(request, response);
return;
}

UserId userId = UserId.of(Long.valueOf(userIdHeader));
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userId, null);
SecurityContextHolder.getContext().setAuthentication(authToken);

filterChain.doFilter(request, response);
}

private boolean isValidUserId(String userIdHeader) {
try {
return Long.parseLong(userIdHeader) > 0;
} catch (Exception e) {
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.magicbox.release.adapter.in.security.properties;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.List;

@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "security.trusted")
public class TrustedIpProperties {
private final List<String> ips;
}
Loading
Loading