-
Notifications
You must be signed in to change notification settings - Fork 0
feat/117 :: Release 릴리즈 서비스 구현 #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
35db47e
f79c8da
fb32475
c43e703
616e03c
87cf732
56edccf
0c59c17
503d3c3
f334e59
6e3c690
59650b6
6d817f6
7452d15
831584a
e92585c
6f90fdb
23ac3b3
613a3e6
b68418b
613fefc
45d146b
fff6513
1ec5ebc
12d1716
af1f284
a35f211
b5ba18e
481ceb4
7bfbddd
947d50a
1531aa1
92bede8
08ae806
2b276d1
4109f1f
320c666
df2ab7a
04a0457
dada21f
7ca20c6
3e82824
9d1b838
dbafa83
fbbf849
be3e263
ae99e01
1fea8c9
19f1287
525a95a
24d2402
95d0e35
c15bac0
ef6480a
36b339a
de9c5f8
26bc10a
aeef1e8
62aaa64
413f8aa
7c6f82a
2bf145b
5bb879c
362bf6e
0d66382
0394205
a72677b
172f5e2
e7bf5c8
1b1d96c
b0327b5
14a67c3
418df4d
4b9b8fa
6cdb350
53f1aff
9a1f7d4
3bf4654
4017fd3
0722dca
82f2719
ff85478
6807066
4ab4ca7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"] |
| 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
|
||
| 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; | ||
| 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())); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Major] 같은 도메인 행위(
위험 요소:
제안: 주문→릴리즈 sold quantity 증가는 Outbox→Inbox(Kafka) 단일 경로로 통일하고 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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] proto .setCreatedAt(Timestamp.newBuilder()
.setSeconds(scheduledAt.getEpochSecond())
.setNanos(scheduledAt.getNano())
.build())
제안: proto 정의에 |
||
| .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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] 분산 락 없이 1분 주기로 동작하여 다중 인스턴스에서 충돌 위험이 큽니다.
@Scheduled(fixedDelay = 60000) // 1분
public void autoStartScheduledReleases() { ... }부가적으로, |
||
| 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()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Major] .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
이 필요합니다. 외부 노출 위험이 큰 상태 전이(판매 시작/종료) 엔드포인트라 우선순위를 높여 봐주세요.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Major]
isReleaseOnSale응답을 status enum 비교 대신 도메인 메서드로 위임하는 것이 안전합니다.ReleaseAggregate 에 이미isOnSale()이 정의되어 있으므로, 도메인 객체를 직접 조회하는 별도 UseCase 를 두거나ReleaseResult에isOnSale파생 필드를 두어 도메인 규칙이 한 곳에서 관리되도록 해 주세요.또한
getReleaseUseCase.getRelease가 NOT_FOUND 인 경우 그대로 예외가 전파되어 gRPC 클라이언트에는INVALID_ARGUMENT/NOT_FOUND가 섞여 나갑니다(GrpcExceptionInterceptor의 분기). order/waiting 호출 측에서isReleaseOnSale=false와 "릴리즈 없음" 을 명확히 구분할 수 있도록 응답 정책을 정리해 주세요.