Conversation
- Add Cafe24 OAuth service, controller, and DTOs - Configure SecurityConfig to allow OAuth endpoints - Add OAuth path to TokenAuthenticationFilter whitelist - Add WebClient dependency for API calls - Enable scheduling for future order synchronization - Use environment variables for Cafe24 API credentials
- OAuth redirect URI를 환경변수로 관리 (CAFE24_API_REDIRECT_URI) - OAuth 콜백에서 불필요한 리다이렉트 제거, JSON 응답 반환 - application-local.yml 환경변수 참조 방식 통일 - Security 설정에서 OAuth 엔드포인트 permitAll 처리 - 불필요한 문서 파일 삭제 (README, HELP, 가이드 문서)
- WholesaleStatus를 영어로 변경 (일관성 유지) - 주문_확인 -> ORDER_CONFIRMED - 입금_대기_중 -> PAYMENT_PENDING - 출고_대기_중 -> SHIPMENT_PENDING - 출고 -> SHIPPED - SecurityConfig: Cafe24 OAuth 엔드포인트를 PUBLIC_APIS에 포함
- 카페24 OAuth 인증 흐름 구현 (Cafe24AuthController, Cafe24AuthService) - access_token Redis 저장 로직 구현 - state 검증 로직 구현 (CSRF 방지) - 고객 주문 엔티티 및 DTO 생성 (CustomerOrder, CustomerOrderItem) - 고객 주문 수신 API 엔드포인트 구현 - RDS 테이블 생성 SQL 추가
WalkthroughCafe24 전자상거래 플랫폼과의 주문 통합을 구현하는 대규모 기능 추가입니다. OAuth 인증, 주문 동기화, 재고 관리, 도매 주문 변환 기능을 포함하며, 데이터베이스 스키마 확장, 설정 속성 추가, 보안 규칙 업데이트를 포함합니다. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Cafe24OAuthCtrl as Cafe24OAuthController
participant RedisService
participant Cafe24OAuthService
participant Cafe24 as Cafe24 OAuth Server
participant Cafe24TokenRepo as Cafe24TokenRepository
participant DB as Database
User->>Cafe24OAuthCtrl: GET /redirect
Cafe24OAuthCtrl->>RedisService: saveCafe24OAuthState(state)
RedisService->>RedisService: Redis 저장 (10분 TTL)
Cafe24OAuthCtrl-->>User: 리다이렉트 (OAuth URL)
User->>Cafe24: OAuth 인증
Cafe24-->>User: 리다이렉트 (?code=..., state=...)
User->>Cafe24OAuthCtrl: GET /callback?code=X&state=Y
Cafe24OAuthCtrl->>RedisService: hasCafe24OAuthState(state)
alt state 유효
RedisService-->>Cafe24OAuthCtrl: true
Cafe24OAuthCtrl->>Cafe24OAuthService: exchangeCodeForAccessToken(code)
Cafe24OAuthService->>Cafe24: POST /token (code, credentials)
Cafe24-->>Cafe24OAuthService: access_token, expires_in
Cafe24OAuthService->>RedisService: saveCafe24AccessToken(token, expiry)
Cafe24OAuthService->>Cafe24TokenRepo: save(Cafe24Token)
Cafe24TokenRepo->>DB: 저장
Cafe24OAuthService-->>Cafe24OAuthCtrl: accessToken
Cafe24OAuthCtrl->>RedisService: deleteCafe24OAuthState(state)
Cafe24OAuthCtrl-->>User: 리다이렉트 (success URL + token)
else state 무효
Cafe24OAuthCtrl-->>User: 리다이렉트 (failure URL)
end
sequenceDiagram
participant Scheduler as Cafe24OrderSyncScheduler
participant NotificationSvc as Cafe24OrderNotificationService
participant OAuthSvc as Cafe24OAuthService
participant ApiClient as Cafe24ApiClient
participant Cafe24 as Cafe24 API
participant Redis
participant CustomerOrderSvc as CustomerOrderService
participant WholesaleSvc as WholesaleService
participant DiscordSvc as Discord API
participant DB as Database
Scheduler->>NotificationSvc: checkAndNotifyNewOrders()
NotificationSvc->>OAuthSvc: getValidAccessTokenOrRefresh()
OAuthSvc->>Redis: 토큰 조회/갱신 (필요시)
OAuthSvc-->>NotificationSvc: accessToken
NotificationSvc->>ApiClient: getOrders(token, startDate, endDate)
ApiClient->>Cafe24: GET /orders?start_date=...&end_date=...
Cafe24-->>ApiClient: Cafe24OrderResponse (orders)
ApiClient-->>NotificationSvc: orders
NotificationSvc->>Redis: 이전 알림 여부 확인
loop 각 주문별
NotificationSvc->>ApiClient: getOrderDetail(token, orderId)
ApiClient->>Cafe24: GET /orders/{id}?embed=items
Cafe24-->>ApiClient: OrderDetailResponse
NotificationSvc->>CustomerOrderSvc: createCustomerOrderAndProcessStock(request)
CustomerOrderSvc->>DB: 중복 확인 (cafe24OrderId)
CustomerOrderSvc->>DB: 상품/재고 조회
CustomerOrderSvc->>DB: CustomerOrder 저장
CustomerOrderSvc-->>NotificationSvc: orderId
NotificationSvc->>WholesaleSvc: createWholesaleFromCafe24Order(order)
WholesaleSvc->>DB: 기본 Store 조회
WholesaleSvc->>DB: 상품 및 재고 정보 조회
WholesaleSvc->>DB: Wholesale + WholesaleItems 저장
WholesaleSvc->>DB: 재고 차감
WholesaleSvc-->>NotificationSvc: wholesale
NotificationSvc->>DiscordSvc: sendMessage(orderDetails)
DiscordSvc-->>NotificationSvc: 전송 완료
NotificationSvc->>Redis: saveCafe24OrderNotified(orderId, 7days TTL)
end
NotificationSvc-->>Scheduler: 완료
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45–60 minutes 주의가 필요한 영역:
Possibly related PRs
Suggested labels
Poem
칭찬과 제언이번 PR은 상당히 광범위한 기능을 담고 있습니다. OAuth 플로우, 비동기 API 통합, 도메인 설계 등 여러 패턴을 잘 조합했네요. 다만 몇 가지 개선점을 제안드립니다:
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 20
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (5)
src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderDetailResponse.java-32-35 (1)
32-35: Use@JsonAnySetterto properly capture unknown fields into theadditionalPropertiesMap, as@JsonPropertyalone cannot achieve thisThe comment states "알 수 없는 필드도 받을 수 있도록" (to receive unknown fields), but the current implementation does not support this:
@JsonPropertywithout a value expects a JSON field literally named"additionalProperties", not unknown fields@JsonIgnoreProperties(ignoreUnknown = true)explicitly ignores unmapped fields- Unknown fields are discarded, not captured into the Map as intended
To capture unknown fields, use
@JsonAnySetter:-import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import java.util.HashMap; - // 알 수 없는 필드도 받을 수 있도록 Map 추가 - @JsonProperty - private Map<String, Object> additionalProperties; + // 알 수 없는 필드를 수집하기 위한 Map + private Map<String, Object> additionalProperties = new HashMap<>(); + + @JsonAnySetter + public void addAdditionalProperty(String key, Object value) { + additionalProperties.put(key, value); + }Apply the same fix to
OrderItemDetail(lines 66-68) and the same pattern inCafe24OrderResponse.java.Separately,
priceis typed asObject—consider usingBigDecimalif Cafe24 always returns numeric values.Committable suggestion skipped: line range outside the PR's diff.
src/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.java-99-99 (1)
99-99: 디버그 코멘트 제거 필요
F_TEST는 개발 중 테스트용 마커로 보입니다. 프로덕션 코드에서 제거해 주세요.- // 추가적인 고객 주문 관련 비즈니스 로직은 여기에 구현될 수 있습니다. F_TEST + // 추가적인 고객 주문 관련 비즈니스 로직은 여기에 구현될 수 있습니다.src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderResponse.java-68-71 (1)
68-71:additionalProperties캡처 방식이 올바르지 않습니다.
@JsonProperty어노테이션만으로는 알 수 없는 필드를 Map에 자동으로 캡처하지 않습니다. Jackson에서 동적 속성을 캡처하려면@JsonAnySetter를 사용해야 합니다.- // 알 수 없는 필드도 받을 수 있도록 Map 추가 - @JsonProperty - private Map<String, Object> additionalProperties; + // 알 수 없는 필드도 받을 수 있도록 Map 추가 + private Map<String, Object> additionalProperties = new HashMap<>(); + + @JsonAnySetter + public void setAdditionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + } + + @JsonAnyGetter + public Map<String, Object> getAdditionalProperties() { + return this.additionalProperties; + }src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OAuthService.java-91-93 (1)
91-93: 에러 로그에 인가 코드(code)가 노출됩니다.인가 코드는 일회성이지만, 로그에 남기면 보안 감사 시 문제가 될 수 있습니다.
- log.error("요청 파라미터 - redirect_uri: {}, code: {}", redirectUri, code); + log.error("요청 파라미터 - redirect_uri: {}, code: [REDACTED]", redirectUri);src/main/java/com/almang/inventory/wholesale/service/WholesaleService.java-174-175 (1)
174-175: 수량이 null인 경우 0으로 처리하여 0개 아이템이 생성됩니다.
quantity가 null일 때 0으로 설정하면, 수량 0개인WholesaleItem이 생성되어 의미 없는 데이터가 저장됩니다.수량이 없거나 0인 경우 아이템 생성을 건너뛰는 것이 더 적절합니다:
- int quantity = cafe24Item.getQuantity() != null ? cafe24Item.getQuantity() : 0; + int quantity = cafe24Item.getQuantity() != null ? cafe24Item.getQuantity() : 0; + if (quantity <= 0) { + log.warn("수량이 0 이하 - productCode: {}, quantity: {}", + cafe24Item.getProductCode(), quantity); + return null; + } int amount = quantity * unitPrice;Committable suggestion skipped: line range outside the PR's diff.
🧹 Nitpick comments (31)
src/main/java/com/almang/inventory/order/dto/response/OrderResponse.java (1)
14-14: 필드명 개선을 고려해보세요.
orderDate라는 이름은 날짜만을 의미하는 것처럼 보이지만, 실제 타입은 시간 정보까지 포함하는LocalDateTime입니다. 기능적으로는 문제없지만, 더 명확한 의미 전달을 위해orderedAt또는orderDateTime과 같은 이름을 고려해보세요.build.gradle (1)
53-55: WebClient 도입을 위한 WebFlux 의존성 추가는 적절합니다만, MVC와 혼용 전략을 한 번만 정리해 두면 좋겠습니다지금처럼
spring-boot-starter-web+spring-boot-starter-webflux를 함께 두는 건 서블릿 기반 애플리케이션에서 WebClient를 쓰는 전형적인 패턴입니다. 다만 두 스타터를 같이 사용하면 WebMVC와 WebFlux 자동 설정이 동시에 일부 올라올 수 있어서,
- HTTP 엔드포인트는 계속 MVC(@RestController, 반환 타입=동기)로만 갈 것인지
- 리액티브 엔드포인트도 도입할지
팀 차원에서 컨벤션을 정해 두는 걸 추천드립니다. WebClient만 쓸 계획이라면, 별도
@Configuration에서WebClient.Builder를 주입받아 타임아웃·로깅·connection pool 등을 공통으로 설정하는 구성도 고려해 보시면 좋겠습니다. Spring Boot Reference Documentation의 WebClient 섹션도 함께 참고해 보세요.src/main/java/com/almang/inventory/global/api/SuccessMessage.java (1)
68-70: 성공 메시지 enum 네이밍을 기존 패턴과 맞추면 가독성이 더 좋아집니다다른 상수들이
CREATE_XXX_SUCCESS,UPDATE_XXX_SUCCESS처럼동사_대상_SUCCESS패턴을 따르는 반면,CUSTOMER_ORDER_CREATED는 과거분사 형태라 검색/분류 시 약간 튀어 보입니다. 지금 시점에 이름을 맞춰 두면 이후 기능 확장이나 검색할 때 훨씬 편해집니다.예를 들어 다음처럼 바꾸는 걸 제안드립니다:
- // CUSTOMER_ORDER - CUSTOMER_ORDER_CREATED("고객 주문 생성 성공"), + // CUSTOMER_ORDER + CREATE_CUSTOMER_ORDER_SUCCESS("고객 주문 생성 성공"),사용처(
SuccessMessage.CUSTOMER_ORDER_CREATED)도 함께 변경이 필요합니다.src/main/java/com/almang/inventory/InventoryServerApplication.java (1)
6-11: 스케줄링 활성화는 방향이 좋고, 스케줄러 작업 특성만 잘 관리하면 됩니다Cafe24 주문 동기화 같은 주기 작업을 위해
@EnableScheduling을 여기에 거는 선택은 자연스럽습니다. 다만 기본 스케줄러는 단일 스레드 기반이라,
- 각
@Scheduled메서드가 너무 오래 걸리면 다음 주기가 밀릴 수 있고- 외부 API(Cafe24, DB 등) 호출 실패 시 재시도/예외 처리 전략이 중요합니다.
카페24 동기화 작업은 가능하면 idempotent 하게 설계하고, 실패 시 로깅 + 재시도/수동 재실행 경로를 만들어 두는 걸 추천드립니다. 자세한 설정/스레드 풀 튜닝은 Spring Framework 문서의 Scheduling 섹션을 참고해 보시면 좋습니다.
src/main/java/com/almang/inventory/wholesale/domain/WholesaleStatus.java (1)
1-8: 도메인 상태 enum 설계가 깔끔합니다. 다만 저장 방식과 확장 포인트만 미리 정해 두면 더 좋겠습니다도메인 관점에서 주문 확정 → 결제 대기 → 출고 대기 → 출고 완료로 이어지는 기본 플로우가 잘 드러나는 enum 구성이라 이해하기 좋습니다.
두 가지 정도만 함께 고민해 보시면 좋겠습니다.
JPA 매핑 방식
Wholesale 엔티티에서 이 enum을 사용할 때@Enumerated(EnumType.STRING)로 저장하면, 이후 상태를 추가/순서 변경해도 DB 값이 깨지지 않아 안전합니다.취소/반품 등 추가 상태
카페24 연동이나 실제 운영 플로우에서 주문 취소/반품/정산 완료 같은 상태가 필요하다면, 지금 구조에서 어떤 enum 값으로 확장할지(예:CANCELLED,RETURNED등)를 미리 생각해 두면 좋습니다.현재 구현 자체는 그대로 사용해도 무방해 보입니다.
src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24TokenResponse.java (1)
16-50: 필드 매핑이 정확하고 깔끔합니다! 👍모든 필드가
@JsonProperty로 올바르게 매핑되어 있고,LocalDateTime필드에 적절한@JsonFormat패턴이 적용되어 있습니다.선택적 개선사항: 디버깅과 미래 확장성을 위해 다른 Cafe24 DTO들처럼
additionalProperties맵을 추가하는 것을 고려해보세요:+ @JsonAnySetter + private Map<String, Object> additionalProperties = new HashMap<>();이렇게 하면 알 수 없는 필드를 캡처하여 로깅이나 디버깅 시 유용하게 활용할 수 있습니다.
src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java (1)
84-94: 중복된 화이트리스트 검사를 제거하세요.
shouldNotFilter가true를 반환하면 Spring의OncePerRequestFilter가doFilterInternal호출을 건너뜁니다. 따라서 84-94라인의 추가 검사는 불필요하며, 모든 요청에 대해 화이트리스트를 두 번 검사하는 오버헤드를 발생시킵니다.다음 diff를 적용하여 중복 로직을 제거하세요:
protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { - String path = request.getServletPath(); - - // 추가 안전장치: whitelist에 있는 경로는 필터를 건너뛰기 - boolean isWhitelisted = whitelist.stream().anyMatch(whitePath -> - path.equals(whitePath) || path.startsWith(whitePath + "/")); - - if (isWhitelisted) { - log.debug("[AUTH] Whitelisted path, skipping filter: {}", path); - filterChain.doFilter(request, response); - return; - } - request.removeAttribute("authErrorCode");참고: Spring Framework의 OncePerRequestFilter 문서에서
shouldNotFilter메서드의 동작을 확인하실 수 있습니다.src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java (1)
80-83: 외부 통합을 위한 보안 규칙이 올바르게 구성되었습니다.Cafe24 주문 수신 엔드포인트와 OAuth 콜백이 적절하게 public으로 설정되었습니다. 주석으로 외부 호출 목적을 명시한 것도 좋습니다.
보안 강화 제안: Webhook 엔드포인트(
/api/v1/customer-orders)는 public이어야 하지만, 다음과 같은 추가 보안 조치를 고려하세요:
- 요청 서명 검증: Cafe24가 제공하는 webhook 서명을 검증하여 요청이 실제 Cafe24에서 온 것인지 확인
- IP 화이트리스트: Cafe24의 공식 IP 대역만 허용 (가능한 경우)
- Rate Limiting: 과도한 요청을 방지하기 위한 속도 제한
참고: OWASP Webhook Security에서 webhook 보안 베스트 프랙티스를 확인하실 수 있습니다.
src/main/java/com/almang/inventory/global/cafe24/repository/Cafe24TokenRepository.java (1)
9-14: mallId + active 단일성 가정에 대한 DB 제약 고려 제안
findByMallIdAndActiveTrue를Optional<Cafe24Token>으로 정의한 것은 하나의 mallId 당 활성 토큰이 최대 1개 라는 강한 도메인 가정을 표현하고 있습니다. 하지만Cafe24Token엔티티를 보면mall_id에 대한 unique 제약이나(mall_id, is_active)복합 unique 인덱스는 보이지 않습니다. 이 경우, 버그나 동시성 이슈로 활성 토큰이 2개 이상 생기면 Spring Data JPA 가IncorrectResultSizeDataAccessException을 던질 수 있습니다.
- 원인: 도메인 가정(활성 토큰은 mall 당 1개)과 DB 스키마(해당 제약 부재) 사이에 불일치가 있습니다.
- 개선 방향:
- DB 마이그레이션에서
(mall_id, is_active)복합 unique 인덱스를 추가하거나,- 활성 토큰 조회 시
List<Cafe24Token>로 받고, 크기가 1을 초과할 경우 로깅/정리 로직을 두는 방식을 고려할 수 있습니다.특히 OAuth 토큰 관리의 안정성을 위해 “도메인 규칙 = DB 제약” 을 맞춰 두면 장애 탐지와 운영이 훨씬 수월해집니다. Spring Data JPA 및 DB 인덱스 전략 관련 내용은 Spring Data JPA 참조 문서와 사용하는 DB 의 인덱스 가이드 문서를 같이 보시면 좋겠습니다.
src/main/java/com/almang/inventory/global/cafe24/scheduler/Cafe24OrderSyncScheduler.java (1)
9-30: 스케줄 타임존 명시와 모니터링 포인트를 같이 고려해보면 좋겠습니다현재 구현은 구조적으로 잘 되어 있고, 예외를 잡아서 로그만 남기는 것도 스케줄러 쓰레드가 죽지 않게 하려는 의도로 보입니다. 여기에 두 가지를 더 고민해볼 만합니다.
타임존 명시
@Scheduled(cron = "0 */10 * * * *")는 서버 JVM 기본 타임존에 의존합니다.- 운영 환경 타임존이 KST 가 아닐 가능성이 있거나, 인프라 이전 시 동작이 달라질 수 있다면
@Scheduled(cron = "0 */10 * * * *", zone = "Asia/Seoul")처럼 명시해 두는 것을 권장합니다.- 공식 문서는 Spring Scheduling (
@EnableScheduling,@Scheduled) 섹션을 참고하시면 됩니다.실패 모니터링
- 지금은 실패 시 로그만 남는데, 장기적으로는
- 실패 횟수/최근 실패 시각을 Meter(예: Micrometer)로 남기거나
- 별도의 알림 채널(슬랙/디스코드 등)과 연동
를 붙여두면, 카페24 연동 장애를 조기에 감지하는 데 도움이 됩니다.기능적으로는 이미 잘 동작할 구조이므로, 위 사항은 “운영 편의성” 측면에서의 추천입니다.
src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderRequest.java (1)
17-79: 결제/취소 플래그와 날짜 포맷에 대한 입력 검증을 조금 더 강화해보면 좋겠습니다구조 자체는 카페24 JSON 구조를 잘 반영하고 있습니다. 다만 운영 관점에서 두 지점을 보완하면 장애를 더 빨리 잡을 수 있을 것 같습니다.
isPaid,isCanceled의 값 검증
- 현재는
String으로 받고equalsIgnoreCase("T")로만 boolean 변환을 하고 있어,
"T"→true- 그 외(오타 포함
"Y","YES"등) → 모두false- 이러면 카페24 API 변화나 데이터 오염이 발생해도 “조용히 false 처리” 되어 원인 파악이 늦어질 수 있습니다.
- 제안:
- DTO 필드에 제약 추가
@NotNull @Pattern(regexp = "[TFtf]", message = "paid 필드는 'T' 또는 'F' 여야 합니다.") @JsonProperty("paid") private String isPaid;canceled도 동일하게.- 혹은 커스텀 컨버터/Deserializer 를 두고, 잘못된 값은 곧바로
BaseException으로 터뜨리는 것도 방법입니다.- Bean Validation 의
@Pattern사용법은 Jakarta Bean Validation 공식 스펙을 참고하시면 됩니다.
orderAt의 타입과 포맷 정책
- 카페24 가 ISO-8601(+09:00) 형식으로 보내는 것을 반영해
pattern = "yyyy-MM-dd'T'HH:mm:ssXXX"를 쓰신 점은 맞습니다.- 다만 타입이
LocalDateTime이라 실제 코드에서 타임존/오프셋 정보는 사라진 상태로 쓰이게 됩니다.- 정책적으로
- “카페24 주문 시간은 항상 KST(+09:00) 로 온다” 를 전제로 한다면 → 지금처럼
LocalDateTime으로 고정하고timezone = "Asia/Seoul"을 명시한 현재 방식이 괜찮습니다.- 추후 다른 타임존/멀티몰 시나리오를 고려한다면 →
OffsetDateTime또는ZonedDateTime으로 받아 내부에서 KST 로 normalize 하는 설계도 검토할 수 있습니다.- Jackson JSR-310 모듈과
@JsonFormat의 조합에 대해서는 Jackson 공식 문서의 Java Time 섹션을 참고해 보시면 이해에 도움이 됩니다.위 두 부분은 지금 바로 막히는 버그는 아니지만, “입력 데이터가 이상해졌을 때 빨리 터지고, 원인을 찾기 쉽게 만들기 위한 방어적 설계” 측면에서 추천드립니다.
src/main/java/com/almang/inventory/global/cafe24/domain/Cafe24Token.java (1)
9-65: 토큰 만료 판단 로직은 좋고, 테스트 용이성만 약간 보완 여지가 있습니다구조적으로는 카페24 OAuth 토큰 관리에 필요한 필드와 메서드들이 잘 정리되어 있습니다.
updateTokens(...)로 갱신 정보를 한 번에 바꾸고,deactivate()로 활성 상태를 명확히 끌 수 있으며,isAccessTokenExpired(),isRefreshTokenExpired()로 만료 여부를 도메인 객체 내부에서 판단하는 패턴은 좋습니다.조금 더 개선해 볼 수 있는 부분은 시간 소스(clock) 의 주입 입니다.
- 현재 구현:
처럼 시스템 시간을 직접 읽고 있어, 단위 테스트에서 만료 경계 조건(예: 만료 직전/직후)을 안정적으로 테스트하기가 다소 어렵습니다.public boolean isAccessTokenExpired() { return LocalDateTime.now().isAfter(expiresAt); }- 개선 아이디어:
- 도메인에서 직접
Clock을 주입받기는 번거로울 수 있으니,
- Service 레이어에서
Clock을 주입받아 만료 여부 판단을 위임하거나,- 혹은 최소한 static 헬퍼(예:
TimeProvider.now()) 를 두고, 테스트에서 해당 헬퍼를 재정의/override 할 수 있게 해 두는 방식도 있습니다.이 부분은 필수 변경 사항이라기보다는 “시간 의존 로직의 테스트 가능성” 을 높이는 설계 패턴에 가깝고, 참고하시면 이후 복잡한 토큰 만료/갱신 시나리오를 테스트할 때 도움이 됩니다.
src/main/java/com/almang/inventory/customerorder/controller/CustomerOrderController.java (1)
13-37: 엔드포인트 구조는 깔끔하고, 향후 카페24 호출 인증 전략만 별도로 정의해두면 좋겠습니다구현 자체는 전형적인 레이어드 아키텍처 패턴을 잘 따르고 있습니다.
@RestController+@RequestMapping("/api/v1/customer-orders")로 책임이 명확하고,@Valid @RequestBody CustomerOrderRequest로 입력 검증을 위임하며,- 서비스(
createCustomerOrderAndProcessStock)에 비즈니스 로직을 위임하고,ApiResponse.success(SuccessMessage.CUSTOMER_ORDER_CREATED, ...)로 응답도 일관성 있게 맞추셨습니다.
짧게 말해, 컨트롤러 레벨에서는 “얇고 단순하게” 잘 뽑혔습니다.실서비스 관점에서 고려해볼 만한 점은 카페24 쪽에서 이 엔드포인트를 어떻게 호출하는지에 대한 인증/검증 전략입니다.
- 만약 단순 “공개 URL + 토큰 헤더” 형태라면:
- 토큰 유출 시 주문 생성/재고 변경이 임의로 일어날 수 있으므로, 토큰 스코프/회전 주기, IP 화이트리스트 등 인프라 레벨 보완이 필요할 수 있습니다.
- 카페24 웹훅/콜백이 서명(Signature) 기반 검증을 제공한다면:
- 헤더의 서명을 검증하고, 실패 시 401/403 을 명확히 반환하는 필터나 인터셉터를 추가하는 설계도 고려해볼 만합니다.
이 부분은 이 컨트롤러 파일만으로는 전모를 알 수 없어서 “필수 수정” 까지는 아니고, 전체 API 보안 정책을 설계할 때 함께 정리해두면 좋겠습니다. 관련 내용은 Spring Security 공식 문서(특히 stateless API, 토큰 기반 인증 섹션)를 참고해 보시면 도움이 됩니다.
src/main/java/com/almang/inventory/global/config/properties/Cafe24Properties.java (1)
8-21: 불변 설정 클래스로 개선을 권장드립니다.현재
@Setter를 사용한 가변 설정은 런타임에 값이 변경될 수 있는 위험이 있습니다. Spring Boot 2.2+ 에서는 Constructor Binding을 통한 불변 설정을 권장합니다.-@Getter -@Setter -@Component -@ConfigurationProperties("cafe24.api") -public class Cafe24Properties { - - private String oauthUrl; - private String baseUrl; - private String clientId; - private String clientSecret; - private String redirectUri; - private String scope; - private String oauthSuccessRedirectUrl; -} +@Getter +@ConfigurationProperties("cafe24.api") +public class Cafe24Properties { + + private final String oauthUrl; + private final String baseUrl; + private final String clientId; + private final String clientSecret; + private final String redirectUri; + private final String scope; + private final String oauthSuccessRedirectUrl; + + public Cafe24Properties(String oauthUrl, String baseUrl, String clientId, + String clientSecret, String redirectUri, String scope, + String oauthSuccessRedirectUrl) { + this.oauthUrl = oauthUrl; + this.baseUrl = baseUrl; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.scope = scope; + this.oauthSuccessRedirectUrl = oauthSuccessRedirectUrl; + } +}그리고 메인 애플리케이션 클래스나 설정 클래스에
@EnableConfigurationProperties(Cafe24Properties.class)또는@ConfigurationPropertiesScan을 추가하세요.src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java (1)
36-39: URL 구성 방식이 취약합니다.
String.replace를 통한 URL 구성은oauthUrl형식이 변경되면 조용히 실패할 수 있습니다. 별도의tokenUrl설정을 추가하거나 URI 빌더를 사용하는 것이 더 안전합니다.- String tokenUrl = cafe24Properties.getOauthUrl().replace("/authorize", "/token"); + // Cafe24Properties에 tokenUrl 필드 추가를 권장합니다. + // 또는 최소한 검증 로직을 추가하세요: + String oauthUrl = cafe24Properties.getOauthUrl(); + if (!oauthUrl.endsWith("/authorize")) { + log.warn("OAuth URL 형식이 예상과 다릅니다: {}", oauthUrl); + } + String tokenUrl = oauthUrl.replace("/authorize", "/token");src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.java (1)
44-49: import 문 사용으로 가독성을 개선할 수 있습니다.
RedirectView를 fully qualified name으로 사용하고 있습니다. import 문을 추가하면 코드가 더 깔끔해집니다.import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.view.RedirectView; ... @GetMapping("/authorize") - public org.springframework.web.servlet.view.RedirectView redirectToCafe24Authorization() { + public RedirectView redirectToCafe24Authorization() { String authorizationUrl = cafe24OAuthService.generateAuthorizationUrl(); log.info("카페24 Authorization URL 생성: {}", authorizationUrl); - return new org.springframework.web.servlet.view.RedirectView(authorizationUrl); + return new RedirectView(authorizationUrl); }src/main/java/com/almang/inventory/customerorder/domain/CustomerOrder.java (1)
4-4: 사용하지 않는 import 제거 필요
User클래스가 import되었으나 실제 코드에서 사용되지 않습니다. 주석에서 미래 연결 가능성을 언급하고 있지만, 사용하지 않는 import는 코드 정리 차원에서 제거하는 것이 좋습니다.-import com.almang.inventory.user.domain.User; // 고객 정보가 필요하다면 나중에 User 엔티티와 연결할 수 있습니다. +// 고객 정보가 필요하다면 나중에 User 엔티티와 연결할 수 있습니다.src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.java (1)
25-27: 수량에 대한 추가 검증 권장
quantity에@NotNull만 적용되어 있어 0이나 음수 값이 허용됩니다. 비즈니스 로직상 주문 수량은 1 이상이어야 하므로@Min(1)또는@Positive어노테이션 추가를 권장합니다.+import jakarta.validation.constraints.Min;@NotNull + @Min(1) @JsonProperty("quantity") private Integer quantity;src/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.java (2)
19-23: 사용하지 않는 import 정리
LocalDateTime과Collectors가 현재 코드에서 사용되지 않습니다. IDE의 "Optimize Imports" 기능으로 정리하거나 수동으로 제거해 주세요.-import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; +import java.util.List;
69-76: 재고 감소 로직 TODO 확인재고 감소 로직이 placeholder로 남아있고 TODO 주석이 잘 작성되어 있습니다. 다만 현재 상태로 배포 시 주문이 들어와도 재고가 차감되지 않으므로, 이 PR의 범위에 포함되는지 또는 후속 작업인지 명확히 해주세요.
재고 감소 로직 구현이 필요하시면 도움드릴 수 있습니다. 별도 이슈로 트래킹하시겠습니까?
src/main/java/com/almang/inventory/user/auth/controller/Cafe24AuthController.java (1)
70-70: 민감 정보 로깅 주의
code(인가 코드)를 로그에 출력하고 있습니다. 인가 코드는 일회성이고 짧은 유효기간을 가지지만, 프로덕션에서는 마스킹을 권장합니다.MaskingUtil이 이미 프로젝트에 있으니 활용해 보세요.- log.info("카페24 OAuth 콜백 수신: code={}, state={}, error={}, errorDescription={}", code, state, error, errorDescription); + log.info("카페24 OAuth 콜백 수신: code={}, state={}, error={}, errorDescription={}", + code != null ? code.substring(0, Math.min(4, code.length())) + "***" : null, + state, error, errorDescription);src/main/java/com/almang/inventory/global/cafe24/client/Cafe24ApiClient.java (3)
29-29: API 버전 상수화 또는 설정 외부화 권장
"2025-09-01"API 버전이 두 곳에 하드코딩되어 있습니다. 유지보수성을 위해 상수 또는Cafe24Properties로 외부화하세요.+ private static final String CAFE24_API_VERSION = "2025-09-01"; + public Mono<Cafe24OrderResponse> getOrders(...) { return webClient.get() ... - .header("X-Cafe24-Api-Version", "2025-09-01") + .header("X-Cafe24-Api-Version", CAFE24_API_VERSION)Also applies to: 57-57
32-38: 커스텀 예외 사용 권장
RuntimeException은 너무 일반적입니다. 호출자가 Cafe24 API 오류를 구별하기 어렵습니다. 프로젝트의BaseException과ErrorCode패턴을 활용하는 것이 일관성 있습니다.- return Mono.error(new RuntimeException(errorMsg)); + return Mono.error(new BaseException(ErrorCode.CAFE24_API_ERROR, errorMsg));
ErrorCode에CAFE24_API_ERROR추가가 필요할 수 있습니다.Also applies to: 60-66
45-47: 예외 타입 구체화
catch (Exception e)는 너무 광범위합니다. Jackson의JsonProcessingException으로 한정하면 예상치 못한 예외를 조기에 발견할 수 있습니다.+import com.fasterxml.jackson.core.JsonProcessingException;- } catch (Exception e) { + } catch (JsonProcessingException e) { return Mono.error(new RuntimeException("JSON 파싱 실패: " + e.getMessage(), e)); }Also applies to: 73-75
src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OrderNotificationService.java (1)
62-63: Reactive 스트림에서.block()호출은 블로킹을 유발합니다.스케줄러 스레드에서
.block()을 호출하면 스레드 풀이 고갈될 수 있습니다. 현재 스케줄러가 10분 간격으로 실행되므로 즉각적인 문제는 아니지만, 타임아웃 설정 없이 무한 대기할 수 있습니다.타임아웃 추가를 권장합니다:
- Cafe24OrderResponse response = cafe24ApiClient.getOrders(accessToken, startDateStr, endDateStr) - .block(); + Cafe24OrderResponse response = cafe24ApiClient.getOrders(accessToken, startDateStr, endDateStr) + .block(Duration.ofSeconds(30));src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderResponse.java (1)
106-107:price필드의Object타입은 타입 안전성을 저하시킵니다.주석에 "String 또는 Number일 수 있음"이라고 명시되어 있지만, 사용 시점에 타입 체크가 필요하고 런타임 오류 가능성이 있습니다.
BigDecimal이나 커스텀 역직렬화기를 사용하면 타입 안전성을 높일 수 있습니다:@JsonProperty("price") @JsonDeserialize(using = FlexiblePriceDeserializer.class) private BigDecimal price;현재
Cafe24OrderDetailResponse의price도Object타입으로 동일한 패턴을 사용하고 있어 일관성은 유지됩니다.src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OAuthService.java (2)
136-145:findAll()로 전체 토큰을 조회 후 필터링하는 방식은 비효율적입니다.토큰이 많아지면 성능 저하가 발생합니다. Repository에 적절한 쿼리 메서드를 추가하는 것이 좋습니다.
Repository에 다음 메서드 추가를 권장합니다:
// Cafe24TokenRepository Optional<Cafe24Token> findFirstByActiveTrue(); Optional<Cafe24Token> findFirstByActiveTrueAndExpiresAtAfter(LocalDateTime now);서비스 코드:
- Cafe24Token token = cafe24TokenRepository.findAll().stream() - .filter(Cafe24Token::isActive) - .filter(t -> !t.isAccessTokenExpired()) - .findFirst() - .orElseThrow(() -> new RuntimeException("유효한 Cafe24 Access Token이 없습니다...")); + Cafe24Token token = cafe24TokenRepository + .findFirstByActiveTrueAndExpiresAtAfter(LocalDateTime.now()) + .orElseThrow(() -> new RuntimeException("유효한 Cafe24 Access Token이 없습니다..."));
147-172: 동시 요청 시 토큰 갱신 경쟁 조건(Race Condition)이 발생할 수 있습니다.여러 스레드가 동시에
getValidAccessTokenOrRefresh()를 호출하면, 여러 번 토큰 갱신이 시도될 수 있습니다.분산 락(Redis Lock) 또는
synchronized블록으로 동시성 제어를 고려해보세요:private final Object refreshLock = new Object(); public String getValidAccessTokenOrRefresh() { // 먼저 유효한 토큰 확인 Cafe24Token token = findValidAccessToken(); if (token != null) { return token.getAccessToken(); } synchronized (refreshLock) { // Double-check after acquiring lock token = findValidAccessToken(); if (token != null) { return token.getAccessToken(); } return refreshAccessToken(); } }현재 스케줄러가 10분 간격으로만 호출하므로 즉각적인 문제는 아니지만, API 엔드포인트에서도 호출되므로 고려가 필요합니다.
src/main/java/com/almang/inventory/wholesale/service/WholesaleService.java (1)
119-129:findAll()로 전체 Store 조회 후 첫 번째를 선택하는 방식은 비효율적입니다.Repository에 적절한 쿼리 메서드를 추가하세요:
// StoreRepository Optional<Store> findFirstBy(); // 또는 @Query("SELECT s FROM Store s ORDER BY s.id ASC") Optional<Store> findFirstStore();- return storeRepository.findAll().stream() - .findFirst() - .orElseThrow(() -> new BaseException(ErrorCode.STORE_NOT_FOUND)); + return storeRepository.findFirstBy() + .orElseThrow(() -> new BaseException(ErrorCode.STORE_NOT_FOUND));src/main/java/com/almang/inventory/user/auth/service/RedisService.java (1)
16-18: Cafe24 토큰/STATE Redis 처리 전반은 방향 좋고, 방어 코드·키 헬퍼·확장성만 조금 다듬으면 더 단단해질 것 같습니다.
TTL 버퍼 처리 👍
saveCafe24AccessToken에서 만료 5분 전에 끊고 최소 60초를 보장하는 방식은 토큰 만료 경계 이슈를 피하는 데 실무적으로 꽤 괜찮은 선택입니다. 이 기본 전략은 유지해도 좋아 보입니다.파라미터 방어 부족으로 인한 잠재 버그 가능성
- 현재
saveCafe24AccessToken/saveCafe24OAuthState는accessToken/state가null또는 빈 문자열이어도 그대로 Redis에 기록하려고 시도합니다.
accessToken == null인 경우, 사용하는 RedisSerializer 설정에 따라 NPE가 나거나"null"같은 예기치 않은 값이 저장될 수 있습니다.state == null인 경우"cafe24:oauth:state:null"같은 키가 생성될 수 있는데, 실제hasCafe24OAuthState/deleteCafe24OAuthState에서는 null/empty를 바로 false/무시 처리하고 있어서, 나중에 디버깅하기 어려운 “유령 키”가 생길 수 있습니다.- 상위 레이어에서 이미 방어하고 있더라도, 보안·CSRF 관련 state 저장 레이어에서 한 번 더 막아두는 편이 안전합니다. 예시로는 아래처럼 조기 return 정도가 부담이 적습니다:
- public void saveCafe24AccessToken(String accessToken, long expiresInSeconds) { - // 만료 시간을 약간 여유있게 설정 (예: 5분 전에 만료되도록) - long ttlSeconds = Math.max(expiresInSeconds - 300, 60); // 최소 1분은 보장 + public void saveCafe24AccessToken(String accessToken, long expiresInSeconds) { + if (accessToken == null || accessToken.isBlank() || expiresInSeconds <= 0) { + // 필요하다면 여기서 로그만 남기고 조용히 무시하는 것도 고려해볼 수 있습니다. + return; + } + // 만료 시간을 약간 여유있게 설정 (예: 5분 전에 만료되도록) + long ttlSeconds = Math.max(expiresInSeconds - 300, 60); // 최소 1분은 보장 Duration ttl = Duration.ofSeconds(ttlSeconds); redisTemplate.opsForValue().set(CAFE24_ACCESS_TOKEN_KEY, accessToken, ttl); }- public void saveCafe24OAuthState(String state) { - Duration ttl = Duration.ofMinutes(10); // 10분 후 자동 만료 - redisTemplate.opsForValue().set(CAFE24_OAUTH_STATE_PREFIX + state, "true", ttl); - } + public void saveCafe24OAuthState(String state) { + if (state == null || state.isEmpty()) { + return; + } + Duration ttl = Duration.ofMinutes(10); // 10분 후 자동 만료 + redisTemplate.opsForValue().set(CAFE24_OAUTH_STATE_PREFIX + state, "true", ttl); + }
- 키 생성 로직 재사용으로 유지보수성 향상
- 기존에
userKey,tokenKey,blacklistAccessTokenKey로 prefix 로직을 감싸 둔 것처럼 OAuth state도 작은 헬퍼를 두면 좋습니다.- 장점: prefix 문자열 변경 시 실수 가능성을 줄이고, 테스트에서도 키 포맷을 한 곳에서만 검증하면 됩니다.
@@ - private static final String CAFE24_OAUTH_STATE_PREFIX = "cafe24:oauth:state:"; + private static final String CAFE24_OAUTH_STATE_PREFIX = "cafe24:oauth:state:"; @@ + private String cafe24OAuthStateKey(String state) { + return CAFE24_OAUTH_STATE_PREFIX + state; + } @@ - Duration ttl = Duration.ofMinutes(10); // 10분 후 자동 만료 - redisTemplate.opsForValue().set(CAFE24_OAUTH_STATE_PREFIX + state, "true", ttl); + Duration ttl = Duration.ofMinutes(10); // 10분 후 자동 만료 + redisTemplate.opsForValue().set(cafe24OAuthStateKey(state), "true", ttl); @@ - return redisTemplate.hasKey(CAFE24_OAUTH_STATE_PREFIX + state); + return redisTemplate.hasKey(cafe24OAuthStateKey(state)); @@ - if (state != null && !state.isEmpty()) { - redisTemplate.delete(CAFE24_OAUTH_STATE_PREFIX + state); - } + if (state != null && !state.isEmpty()) { + redisTemplate.delete(cafe24OAuthStateKey(state)); + }
향후 멀티 쇼핑몰/테넌트 지원 가능성을 염두에 둔 키 설계
CAFE24_ACCESS_TOKEN_KEY가 현재 고정 문자열이라, 나중에 여러 Cafe24 쇼핑몰(또는 테넌트)을 하나의 서비스에서 관리하게 되면 서로 토큰을 덮어쓸 여지가 있습니다.- 아직 단일 쇼핑몰만 지원한다면 지금 구조도 충분하지만, 중장기적으로 멀티테넌트를 고려한다면
cafe24:{shopId}:access_token처럼 shopId·mallId를 포함한 키 스키마를 미리 잡아두는 것도 좋은 선택지입니다.참고 문서
- Redis TTL 동작, null 직렬화 처리 등에 대해서는 Spring Data Redis 공식 레퍼런스의
RedisTemplate/ValueOperations섹션을 한 번 훑어보시면 현재 구현과의 궁합을 확인하는 데 도움이 될 것 같습니다.Also applies to: 90-154
docs/rds_create_tables.sql (1)
1-45: customer_orders / customer_order_items 스키마는 전반적으로 잘 잡혀 있고, 인덱스·컬럼 제약 몇 가지를 다듬으면 장기 유지보수에 더 유리해 보입니다.
- UNIQUE + 일반 인덱스 중복 정의
cafe24_order_id에UNIQUE제약이 있으므로 이미 고유 인덱스가 생성됩니다. 여기에INDEX idx_cafe24_order_id (cafe24_order_id)를 한 번 더 두면 동일 컬럼에 인덱스가 중복 생성되어 쓰기 성능·스토리지 면에서 미미하지만 불필요한 오버헤드가 생깁니다.- 중복 인덱스는 제거하는 편이 깔끔합니다. 예시는 다음과 같습니다.
CREATE TABLE IF NOT EXISTS customer_orders ( customer_order_id BIGINT AUTO_INCREMENT PRIMARY KEY, cafe24_order_id VARCHAR(50) NOT NULL UNIQUE, @@ - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_cafe24_order_id (cafe24_order_id), + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_order_at (order_at), INDEX idx_is_paid (is_paid), INDEX idx_is_canceled (is_canceled) );
VARCHAR 길이와 외부 시스템(Cafe24) 규격 정합성 점검
cafe24_order_id VARCHAR(50),product_code / variant_code / item_code VARCHAR(50),member_email VARCHAR(100)등은 대부분 충분해 보이지만,
- 실제 Cafe24 주문번호·상품코드·옵션코드 최대 길이가 50자를 넘는지,
- 이메일 최대 길이를 100자로 제한해도 되는지
를 한 번 공식 API 스펙과 맞춰보는 것을 추천드립니다.- 너무 짧으면 데이터 잘림/마이그레이션 이슈가 생기고, 너무 길면 인덱스 크기 증가로 이어지므로, 초기 설계 때 스펙에 맞춰 두는 것이 가장 비용이 적습니다.
NULL 허용 컬럼에 대한 도메인 의미 정리
payment_method,member_id,member_email,option_value,variant_code,item_code등이 NULL 허용인데,
- “실제로 값이 존재하지 않는 경우”인지,
- “아직 미수신/미매핑 상태”인지
를 도메인 측면에서 구분해 둘 필요가 있습니다.- 예를 들어
payment_method가 항상 있다고 가정하는 비즈니스 로직이라면NOT NULL DEFAULT ''또는 enum 매핑 테이블로 강제하는 편이 런타임 NPE를 줄일 수 있습니다. 반대로 정말 “미존재” 상태를 의미하려면 NULL이 맞습니다.BOOLEAN 인덱스 설계 재검토 (옵션)
is_paid,is_canceled는 카드inality가 낮은 Boolean 컬럼이라, 단독 인덱스가 항상 큰 이득을 주지는 않을 수 있습니다. 실제 조회 패턴이
WHERE is_paid = TRUE AND order_at BETWEEN ...같이 날짜와 함께 쓰인다면(is_paid, order_at)복합 인덱스가 더 도움이 될 수 있습니다.- 현재 트래픽 규모가 크지 않다면 지금 구조도 충분하지만, 나중에 조회 부하가 커질 경우 쿼리 플랜을 보고 인덱스를 재조정할 여지가 있다는 정도만 미리 인지하고 계시면 좋겠습니다.
참고 문서
- 인덱스/제약 설계와 관련해서는 MySQL Reference Manual 의 Indexes, Data Types 챕터, 그리고 Cafe24 공식 API 문서의 “주문/상품/옵션 ID·코드 길이” 스펙을 함께 확인해 보시면 현재 스키마가 장기적으로도 안전한지 판단하는 데 도움이 됩니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (45)
HELP.md(0 hunks)README.md(0 hunks)build.gradle(1 hunks)docs/rds_create_tables.sql(1 hunks)src/main/java/com/almang/inventory/InventoryServerApplication.java(1 hunks)src/main/java/com/almang/inventory/customerorder/controller/CustomerOrderController.java(1 hunks)src/main/java/com/almang/inventory/customerorder/domain/CustomerOrder.java(1 hunks)src/main/java/com/almang/inventory/customerorder/domain/CustomerOrderItem.java(1 hunks)src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.java(1 hunks)src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderRequest.java(1 hunks)src/main/java/com/almang/inventory/customerorder/repository/CustomerOrderRepository.java(1 hunks)src/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.java(1 hunks)src/main/java/com/almang/inventory/global/api/ApiResponse.java(1 hunks)src/main/java/com/almang/inventory/global/api/SuccessMessage.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/client/Cafe24ApiClient.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OrderSyncController.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/domain/Cafe24Token.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderDetailResponse.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderResponse.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24TokenResponse.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/repository/Cafe24TokenRepository.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/scheduler/Cafe24OrderSyncScheduler.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OAuthService.java(1 hunks)src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OrderNotificationService.java(1 hunks)src/main/java/com/almang/inventory/global/config/RestTemplateConfig.java(1 hunks)src/main/java/com/almang/inventory/global/config/properties/Cafe24Properties.java(1 hunks)src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java(3 hunks)src/main/java/com/almang/inventory/global/exception/BaseException.java(1 hunks)src/main/java/com/almang/inventory/global/exception/ErrorCode.java(1 hunks)src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java(2 hunks)src/main/java/com/almang/inventory/inventory/repository/InventoryRepository.java(2 hunks)src/main/java/com/almang/inventory/order/dto/response/OrderResponse.java(2 hunks)src/main/java/com/almang/inventory/product/repository/ProductRepository.java(2 hunks)src/main/java/com/almang/inventory/user/auth/controller/Cafe24AuthController.java(1 hunks)src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java(1 hunks)src/main/java/com/almang/inventory/user/auth/service/RedisService.java(2 hunks)src/main/java/com/almang/inventory/wholesale/domain/Wholesale.java(1 hunks)src/main/java/com/almang/inventory/wholesale/domain/WholesaleItem.java(1 hunks)src/main/java/com/almang/inventory/wholesale/domain/WholesaleStatus.java(1 hunks)src/main/java/com/almang/inventory/wholesale/repository/WholesaleItemRepository.java(1 hunks)src/main/java/com/almang/inventory/wholesale/repository/WholesaleRepository.java(1 hunks)src/main/java/com/almang/inventory/wholesale/service/WholesaleService.java(1 hunks)src/main/resources/application-local.yml(1 hunks)src/main/resources/application-prod.yml(1 hunks)
💤 Files with no reviewable changes (2)
- HELP.md
- README.md
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-11-22T18:12:13.172Z
Learnt from: JoonKyoLee
Repo: almang2/inventory-server PR: 66
File: src/main/java/com/almang/inventory/order/domain/OrderItem.java:41-53
Timestamp: 2025-11-22T18:12:13.172Z
Learning: In the almang2/inventory-server repository, OrderItem entity update methods (updateQuantity, updatePrice in src/main/java/com/almang/inventory/order/domain/OrderItem.java) do not require null checks because OrderService will validate parameters before calling these update methods, following the same pattern as Product entity updates.
<!--
Applied to files:
src/main/java/com/almang/inventory/customerorder/domain/CustomerOrderItem.javasrc/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.javasrc/main/java/com/almang/inventory/customerorder/domain/CustomerOrder.javasrc/main/java/com/almang/inventory/wholesale/domain/WholesaleItem.javasrc/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.javasrc/main/java/com/almang/inventory/inventory/repository/InventoryRepository.javasrc/main/java/com/almang/inventory/wholesale/domain/Wholesale.java
📚 Learning: 2025-11-20T10:43:47.502Z
Learnt from: JoonKyoLee
Repo: almang2/inventory-server PR: 33
File: src/main/java/com/almang/inventory/product/domain/Product.java:62-66
Timestamp: 2025-11-20T10:43:47.502Z
Learning: In the almang2/inventory-server repository, Product entity update methods (e.g., updateVendor in src/main/java/com/almang/inventory/product/domain/Product.java) do not require null checks on vendor parameters because ProductService validates vendor existence via findVendorByIdAndValidateAccess before calling update methods, and the vendor field has nullable=false constraint ensuring this.vendor is never null for persisted entities.
Applied to files:
src/main/java/com/almang/inventory/wholesale/domain/WholesaleItem.javasrc/main/java/com/almang/inventory/inventory/repository/InventoryRepository.javasrc/main/java/com/almang/inventory/wholesale/domain/Wholesale.java
🧬 Code graph analysis (12)
src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderRequest.java (4)
src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.java (1)
Getter(11-37)src/main/java/com/almang/inventory/global/config/properties/Cafe24Properties.java (1)
Getter(8-21)src/main/java/com/almang/inventory/global/exception/BaseException.java (1)
Getter(5-19)src/main/java/com/almang/inventory/global/util/MaskingUtil.java (1)
NoArgsConstructor(7-43)
src/main/java/com/almang/inventory/customerorder/domain/CustomerOrderItem.java (2)
src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.java (1)
Getter(11-37)src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderRequest.java (2)
Getter(17-79)Getter(66-78)
src/main/java/com/almang/inventory/user/auth/controller/Cafe24AuthController.java (1)
src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java (1)
Slf4j(17-119)
src/main/java/com/almang/inventory/customerorder/controller/CustomerOrderController.java (1)
src/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.java (1)
Slf4j(25-100)
src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java (1)
src/main/java/com/almang/inventory/user/auth/service/RedisService.java (1)
Service(9-155)
src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.java (1)
src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java (1)
Slf4j(25-148)
src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OAuthService.java (1)
src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OrderNotificationService.java (1)
Slf4j(26-268)
src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OrderSyncController.java (5)
src/main/java/com/almang/inventory/wholesale/service/WholesaleService.java (1)
Slf4j(28-199)src/main/java/com/almang/inventory/global/cafe24/scheduler/Cafe24OrderSyncScheduler.java (1)
Slf4j(9-30)src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OrderNotificationService.java (1)
Slf4j(26-268)src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java (1)
Slf4j(25-148)src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.java (1)
RestController(16-50)
src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.java (4)
src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderRequest.java (2)
Getter(17-79)Getter(66-78)src/main/java/com/almang/inventory/global/config/properties/Cafe24Properties.java (1)
Getter(8-21)src/main/java/com/almang/inventory/global/exception/BaseException.java (1)
Getter(5-19)src/main/java/com/almang/inventory/global/util/MaskingUtil.java (1)
NoArgsConstructor(7-43)
src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderResponse.java (1)
src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderDetailResponse.java (3)
Getter(12-70)Getter(21-35)Getter(37-69)
src/main/java/com/almang/inventory/wholesale/domain/Wholesale.java (1)
src/main/java/com/almang/inventory/wholesale/domain/WholesaleItem.java (1)
Entity(8-53)
src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24TokenResponse.java (2)
src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderDetailResponse.java (3)
Getter(12-70)Getter(21-35)Getter(37-69)src/main/java/com/almang/inventory/global/cafe24/dto/Cafe24OrderResponse.java (4)
Getter(12-125)Getter(20-71)Getter(73-89)Getter(91-124)
🔇 Additional comments (23)
src/main/java/com/almang/inventory/inventory/repository/InventoryRepository.java (2)
4-4: Import 추가는 적절합니다.새로운 메서드 시그니처를 위해 필요한 import이며, 문제없습니다.
16-17: Verification confirmsfindByProductis used appropriately — no redundancy issue.The method is called once in
CustomerOrderService.java:66, where theProductentity has already been loaded fromfindByProductCodeat line 63. The caller already possesses theProductentity, making this method the natural choice rather than requiring a separate ID-based lookup. This is a valid design pattern:findByProduct_Idserves lookups when only the ID is available, whilefindByProductis appropriate when the entity is already loaded. No inefficiency detected.src/main/java/com/almang/inventory/order/dto/response/OrderResponse.java (1)
30-30: Based on my investigation, I now have enough information to determine the correctness of the review comment.Key findings:
- BaseTimeEntity.createdAt: Auto-populated by Spring Data Audit - represents system creation time
- Order entity: Represents VENDOR PROCUREMENT ORDERS (not Cafe24 customer orders)
- CustomerOrder entity: The separate entity that stores Cafe24 customer orders with
orderAtfield- OrderResponse usage: Used in OrderController/OrderService for vendor order management
The review comment confuses two different order types. The Order entity is for vendor orders where
createdAt(order creation in the system) is the correct timestamp fororderDate. The Cafe24 customer orders are handled separately in the CustomerOrder entity.
고객 주문 시각 매핑 검증 완료
OrderResponse의 orderDate 매핑은 의도한 사용 사례에 맞게 올바르게 설정되어 있습니다.
조사 결과:
Order엔티티는 **발주처 주문(vendor procurement orders)**을 나타냅니다- Cafe24 고객 주문은 별도의
CustomerOrder엔티티에서 관리되며,orderAt필드에 고객의 실제 주문 시각이 저장됩니다order.getCreatedAt()은 시스템에서 발주가 생성된 시각으로, 발주 추적 목적으로는 정확한 타임스탐프입니다- OrderResponse는 발주 관리용이므로, 발주 생성 시각을 반환하는 것이 맞습니다
결론: 현재 매핑은 의미론적으로 정확합니다. Order 엔티티는 고객 주문이 아닌 발주 관리용이므로 검증 불필요합니다.
src/main/java/com/almang/inventory/global/exception/BaseException.java (1)
15-18: 에러 코드 + 커스텀 메시지 생성자 추가로 예외 표현력이 좋아졌습니다
ErrorCode는 동일하게 유지하면서, 상황별로 다른message를 줄 수 있는 생성자를 별도로 둔 설계가 깔끔합니다. 공통 핸들러에서는errorCode로 HTTP 상태/에러 타입을 일관되게 처리하고, 로그나 클라이언트 메시지에는 이 커스텀 메시지를 활용하면 디버깅과 운영 모두에 도움이 될 것 같습니다.src/main/java/com/almang/inventory/wholesale/repository/WholesaleItemRepository.java (1)
1-7: 표준 JpaRepository 확장으로 CRUD 레포지토리 구성이 잘 되어 있습니다
JpaRepository<WholesaleItem, Long>만 상속해서 기본 CRUD와 페이징을 모두 쓸 수 있는 전형적인 패턴이라, 초기 단계에서는 과하지 않고 딱 적당해 보입니다. 이후 카페24 주문번호나 도매 주문과의 관계로 조회해야 할 때는findByWholesaleId(...),findByProductCode(...)같은 메서드 시그니처 기반 쿼리를 이 인터페이스에 추가하는 방향으로 자연스럽게 확장할 수 있을 것 같습니다. Spring Data JPA 레퍼런스의 “Query Methods” 섹션을 함께 참고해 보시면 좋아요.src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java (2)
44-45: OAuth 경로 화이트리스트 추가가 적절합니다.Cafe24 OAuth 플로우를 위한 경로 추가가 올바르며, 주석으로 하위 경로 포함을 명시한 점이 좋습니다.
SecurityConfig의 변경사항과도 일관성 있게 연동됩니다.
68-76: 경로 매칭 로직 개선이 우수합니다! 👏정확한 일치 또는 하위 경로 매칭(
whitePath + "/")으로 변경하여 오탐을 방지했습니다. 디버그 로깅 추가도 운영 환경에서 트러블슈팅에 유용할 것입니다.src/main/java/com/almang/inventory/product/repository/ProductRepository.java (1)
8-8: 깔끔한 레포지토리 메서드 추가입니다! ✨
findByCode메서드가 Spring Data JPA 네이밍 규칙을 따르고 있으며,Optional반환으로 null 안전성을 확보했습니다.CustomerOrderService에서 Cafe24 주문 처리 시 상품 코드로 조회하는 용도로 적절하게 활용될 것입니다.Also applies to: 28-28
src/main/java/com/almang/inventory/global/api/ApiResponse.java (1)
50-59: 타입 안전한 성공 메시지 처리가 훌륭합니다! 🎯
SuccessMessageenum을 활용한 새로운 팩토리 메서드가 기존 패턴과 일관성 있게 구현되었습니다. 이 방식은 컨트롤러에서 하드코딩된 문자열을 방지하고, 성공 메시지를 중앙에서 관리할 수 있게 해줍니다.CustomerOrderController에서 주문 생성 응답 시 활용되는 것으로 보입니다.장점: enum 기반 메시지 관리는 IDE의 자동완성과 컴파일 타임 검증을 통해 오타를 방지하고 유지보수성을 높입니다.
src/main/resources/application-local.yml (2)
38-45: 로컬 환경 설정이 프로덕션과 일관성 있습니다.로컬 개발 환경에서도 동일한 구조로 Cafe24 API 설정을 관리하는 것이 좋습니다.
확인 필요: 프로덕션 설정과 마찬가지로
oauth-success-redirect-url속성이 누락되어 있습니다.Cafe24Properties클래스에서 이 속성이 필요한지 확인해주세요 (application-prod.yml 리뷰 참조).
47-50: 개발 환경을 위한 적절한 로깅 설정입니다! 🔍
Cafe24OAuthService와reactor.netty.http.client.HttpClient에 대한 DEBUG 로깅은 OAuth 토큰 교환 및 HTTP 요청/응답을 추적하는 데 매우 유용합니다. 로컬 개발 시 문제 진단이 훨씬 수월해질 것입니다.팁: Spring WebClient는 내부적으로 Reactor Netty를 사용하므로, HTTP 클라이언트 로깅을 통해 실제 요청 헤더, 바디, 응답 상태 코드 등을 확인할 수 있습니다.
src/main/java/com/almang/inventory/global/exception/ErrorCode.java (1)
67-70: 고객 주문 에러 코드가 RESTful 규약에 맞게 정의되었습니다! 📋새로운
CUSTOMER_ORDER섹션의 에러 코드들이 의미론적으로 정확하고, HTTP 상태 코드도 적절합니다:
DUPLICATE_CUSTOMER_ORDER는409 CONFLICT를 사용하여 중복 리소스 생성 시도를 명확히 표현NOT_ENOUGH_STOCK은400 BAD_REQUEST를 사용하여 비즈니스 규칙 위반을 표현에러 메시지도 사용자 친화적이며 명확합니다.
참고: RFC 7231과 RESTful API 디자인 가이드에서 HTTP 상태 코드의 적절한 사용법을 확인할 수 있습니다. 중복 리소스는 409, 요청 검증 실패는 400을 사용하는 것이 표준 관례입니다.
src/main/java/com/almang/inventory/customerorder/domain/CustomerOrderItem.java (1)
10-66: 엔티티 매핑과 연관관계 설계가 고객 주문 도메인에 잘 맞습니다
CustomerOrder의addOrderItem과CustomerOrderItem.setCustomerOrder조합으로 양방향 연관관계를 한쪽에서만 관리하도록 한 점이 깔끔합니다.- 카페24 쪽 식별자(
product_code,item_code,variant_code)를 별도 필드로 보존하면서, 내부 도메인Product와도 연관을 두어 “외부 시스템 추적 + 내부 도메인 조회” 를 동시에 만족하는 구조입니다.현재 수준에서는 엔티티 자체에 별도 비즈니스 로직을 두지 않고, 서비스/DTO 레벨에서 검증과 매핑을 맡기는 방향이 명확해서 유지보수도 용이해 보입니다. 자세한 매핑 규칙은 JPA 엔티티 설계 관련 표준 문서(특히 ManyToOne LAZY 로딩 섹션)를 함께 참고하시면 좋겠습니다.
src/main/java/com/almang/inventory/customerorder/repository/CustomerOrderRepository.java (1)
9-13: 중복 방지 요구사항과 잘 맞는 레포지토리 설계입니다
CustomerOrder.cafe24OrderId가unique = true로 지정되어 있고, 여기서Optional<CustomerOrder> findByCafe24OrderId(...)로 조회하는 패턴은:
- “카페24 주문 ID 는 시스템 내에서 유일하다” 는 도메인 규칙을 잘 드러내고,
- 서비스 레벨에서 중복 체크(
ifPresent(...)) 를 하기에도 직관적입니다.현재 형태 그대로 사용하는 것이 읽기 좋고, 향후 Query 메서드가 늘어나더라도 명명 규칙(Spring Data JPA 메서드 네이밍 규칙)과 잘 맞아 확장성이 좋아 보입니다.
src/main/java/com/almang/inventory/wholesale/repository/WholesaleRepository.java (1)
8-10: 깔끔한 Repository 구현입니다! 👍Spring Data JPA의 Query Derivation 규칙을 잘 따르고 있고,
Optional반환 타입 사용도 적절합니다.cafe24OrderId가 unique constraint로 설정되어 있어 중복 주문 방지에 효과적입니다.src/main/java/com/almang/inventory/wholesale/domain/Wholesale.java (2)
18-50: 엔티티 설계가 잘 되어 있습니다! 👏양방향 관계 설정(
addItem메서드)과@Builder.Default를 통한 컬렉션 초기화가 적절합니다.cafe24OrderId의 unique 제약 조건도 중복 주문 방지에 효과적입니다.
52-62: 업데이트 메서드 패턴이 일관성 있습니다.기존
Product,OrderItem엔티티와 동일한 패턴을 따르고 있어 코드베이스의 일관성이 유지됩니다. Based on learnings, 서비스 레이어에서 유효성 검증이 수행되므로 별도 null 체크는 불필요합니다.src/main/java/com/almang/inventory/customerorder/domain/CustomerOrder.java (2)
19-74: 엔티티 구조가 잘 설계되었습니다 👍JPA 엔티티로서 적절한 구조입니다.
@Builder.Default로items리스트 초기화,CascadeType.ALL과orphanRemoval = true조합, 그리고AccessLevel.PROTECTEDno-args 생성자 패턴 모두 JPA 베스트 프랙티스를 따르고 있습니다.
76-89: 편의 메서드 구현 적절합니다
addOrderItem에서 양방향 관계 설정이 올바르게 처리되었습니다.item.setCustomerOrder(this)호출로 일관성을 유지하는 것은 JPA 양방향 관계의 표준 패턴입니다.src/main/java/com/almang/inventory/customerorder/dto/request/CustomerOrderItemRequest.java (1)
11-37: DTO 구조가 깔끔합니다Jackson 매핑과 Bean Validation 조합이 적절하고, Lombok을 활용한 보일러플레이트 제거도 좋습니다. CustomerOrderRequest의
@Valid어노테이션과 연계하여 중첩 검증이 잘 동작할 것입니다.src/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.java (1)
54-55: No null check needed—validation is enforced at the controller level.
request.getInitialOrderAmount()is safe because the controller method has@Valid @RequestBody CustomerOrderRequest(CustomerOrderController.java:27), which validates all@NotNullconstraints before the request reaches the service. TheinitialOrderAmountfield and its nested fields (orderPriceAmount,shippingFee) are all annotated with@NotNull, so an NPE cannot occur at lines 54-55 under normal operation. This method is only called from the validated controller endpoint—no other callers exist that could bypass validation.src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OrderNotificationService.java (1)
85-107: 주문 처리 중 부분 실패 시 일관성 문제가 발생할 수 있습니다.도매 주문 생성이 실패해도
markAsNotified()가 호출되어 해당 주문이 다시 처리되지 않습니다. 비즈니스 요구사항에 따라 재시도가 필요할 수 있습니다.현재 로직:
- 도매 주문 생성 실패 → 알림 전송 →
markAsNotified()호출- 다음 스케줄링 시 해당 주문은 건너뜀
도매 주문 생성 실패 시 재시도가 필요한지 확인이 필요합니다. 필요하다면 실패한 주문을 별도로 추적하거나
markAsNotified()호출 시점을 조정하세요.src/main/java/com/almang/inventory/wholesale/service/WholesaleService.java (1)
47-117: 전반적인 Cafe24 주문 변환 로직이 잘 구조화되어 있습니다.중복 체크, 상태 전이(PAYMENT_PENDING → ORDER_CONFIRMED), 재고 차감 로직이 명확하게 분리되어 있어 읽기 좋네요! 👍
다만 위에서 언급한 부분 실패 처리만 보완하면 더욱 안정적인 서비스가 될 것입니다.
| .isPaid(request.getIsPaid().equalsIgnoreCase("T")) // 'T'/'F' 문자열을 boolean으로 변환 | ||
| .isCanceled(request.getIsCanceled().equalsIgnoreCase("T")) // 'T'/'F' 문자열을 boolean으로 변환 |
There was a problem hiding this comment.
NullPointerException 위험 - null 체크 필요
request.getIsPaid()와 request.getIsCanceled()가 null일 경우 equalsIgnoreCase() 호출 시 NPE가 발생합니다. DTO의 @NotNull 검증이 있지만, 방어적 코딩을 권장합니다.
- .isPaid(request.getIsPaid().equalsIgnoreCase("T")) // 'T'/'F' 문자열을 boolean으로 변환
- .isCanceled(request.getIsCanceled().equalsIgnoreCase("T")) // 'T'/'F' 문자열을 boolean으로 변환
+ .isPaid("T".equalsIgnoreCase(request.getIsPaid())) // null-safe 비교
+ .isCanceled("T".equalsIgnoreCase(request.getIsCanceled())) // null-safe 비교"T".equalsIgnoreCase()를 사용하면 request.getIsPaid()가 null이어도 NPE 없이 false를 반환합니다.
🤖 Prompt for AI Agents
In
src/main/java/com/almang/inventory/customerorder/service/CustomerOrderService.java
around lines 47 to 48, the calls request.getIsPaid().equalsIgnoreCase(...) and
request.getIsCanceled().equalsIgnoreCase(...) can throw NullPointerException if
the DTO fields are null; change them to null-safe comparisons such as
"T".equalsIgnoreCase(request.getIsPaid()) and
"T".equalsIgnoreCase(request.getIsCanceled()) (or explicitly check for null
before calling equalsIgnoreCase) so a null value results in false without
throwing.
| com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); | ||
| return Mono.just(mapper.readValue(rawResponse, Cafe24OrderResponse.class)); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
ObjectMapper 재사용 권장 - 성능 개선
ObjectMapper를 매 요청마다 새로 생성하고 있습니다. ObjectMapper는 스레드 세이프하고 생성 비용이 높으므로, 필드로 한 번만 생성하여 재사용하는 것이 좋습니다.
+import com.fasterxml.jackson.databind.ObjectMapper;
+
@Component
public class Cafe24ApiClient {
private final WebClient webClient;
+ private final ObjectMapper objectMapper;
public Cafe24ApiClient(WebClient.Builder webClientBuilder,
- @Value("${cafe24.api.base-url}") String baseUrl) {
+ @Value("${cafe24.api.base-url}") String baseUrl,
+ ObjectMapper objectMapper) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
+ this.objectMapper = objectMapper;
}Spring Boot는 기본적으로 ObjectMapper 빈을 제공하므로 주입받아 사용하면 됩니다. 관련 문서: Spring Boot Jackson Auto-configuration
Also applies to: 71-72
🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/global/cafe24/client/Cafe24ApiClient.java
around lines 43-44 (and similarly lines 71-72), ObjectMapper is being
instantiated per request which is costly; change it to a single reusable
instance by injecting the Spring-managed ObjectMapper (prefer constructor
injection or @Autowired on a final field) into this class and replace the local
new ObjectMapper() calls with the injected mapper; ensure the field is private
final and used for deserialization calls throughout the class.
| @GetMapping("/callback") | ||
| public ResponseEntity<ApiResponse<Map<String, String>>> cafe24OAuthCallback( | ||
| @RequestParam("code") String code, | ||
| @RequestParam(value = "state", required = false) String state) { | ||
| log.info("카페24 OAuth 콜백 요청 수신. Code: {}, State: {}", code, state); | ||
|
|
||
| try { | ||
| cafe24OAuthService.exchangeCodeForAccessToken(code); | ||
| log.info("Access Token 발급 및 저장 성공."); | ||
|
|
||
| Map<String, String> responseData = new HashMap<>(); | ||
| responseData.put("message", "Cafe24 OAuth 인증이 성공적으로 완료되었습니다."); | ||
|
|
||
| return ResponseEntity.ok(ApiResponse.success("Cafe24 OAuth 인증 성공", responseData)); | ||
| } catch (Exception e) { | ||
| log.error("카페24 OAuth 콜백 처리 중 오류 발생", e); | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
CSRF 보호를 위한 state 파라미터 검증이 누락되었습니다.
state 파라미터를 받고 있지만 검증하지 않고 있습니다. RedisService에 hasCafe24OAuthState, deleteCafe24OAuthState 메서드가 이미 구현되어 있으니 활용해야 합니다.
+ private final RedisService redisService;
+
@GetMapping("/callback")
public ResponseEntity<ApiResponse<Map<String, String>>> cafe24OAuthCallback(
@RequestParam("code") String code,
@RequestParam(value = "state", required = false) String state) {
- log.info("카페24 OAuth 콜백 요청 수신. Code: {}, State: {}", code, state);
+ log.info("카페24 OAuth 콜백 요청 수신");
+
+ // CSRF 보호: state 검증
+ if (state == null || !redisService.hasCafe24OAuthState(state)) {
+ log.warn("OAuth state 검증 실패: state={}", state);
+ throw new BaseException(ErrorCode.INVALID_REQUEST, "OAuth state 검증 실패");
+ }
+ redisService.deleteCafe24OAuthState(state);
try {
cafe24OAuthService.exchangeCodeForAccessToken(code);📚 참고: OAuth 2.0 CSRF Protection - state 파라미터는 반드시 검증되어야 합니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.java
around lines 24-42, the controller accepts the OAuth state param but does not
validate it against stored CSRF tokens; use
RedisService.hasCafe24OAuthState(state) to confirm the state exists before
calling cafe24OAuthService.exchangeCodeForAccessToken(code), and if the state is
missing return a 400 Bad Request (or throw a meaningful exception) with an
appropriate log; after a successful token exchange remove the stored state via
RedisService.deleteCafe24OAuthState(state) to avoid reuse, and ensure you handle
null/empty state inputs with the same validation and logging.
| @GetMapping("/authorize") | ||
| public org.springframework.web.servlet.view.RedirectView redirectToCafe24Authorization() { | ||
| String authorizationUrl = cafe24OAuthService.generateAuthorizationUrl(); | ||
| log.info("카페24 Authorization URL 생성: {}", authorizationUrl); | ||
| return new org.springframework.web.servlet.view.RedirectView(authorizationUrl); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the Cafe24OAuthService file
fd -t f "Cafe24OAuthService" --type javaRepository: almang2/inventory-server
Length of output: 239
🏁 Script executed:
#!/bin/bash
# Search for generateAuthorizationUrl method implementation
rg -n "generateAuthorizationUrl" --type java -A 10Repository: almang2/inventory-server
Length of output: 2302
🏁 Script executed:
#!/bin/bash
# Search for saveCafe24OAuthState usage
rg -n "saveCafe24OAuthState" --type java -B 2 -A 2Repository: almang2/inventory-server
Length of output: 1376
🏁 Script executed:
#!/bin/bash
# Search for callback endpoint in the controller
rg -n "callback|Callback" --type java -C 3 src/main/java/com/almang/inventory/global/cafe24/controller/Repository: almang2/inventory-server
Length of output: 1155
🏁 Script executed:
#!/bin/bash
# Get full content of Cafe24OAuthController
cat -n src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.javaRepository: almang2/inventory-server
Length of output: 2391
🏁 Script executed:
#!/bin/bash
# Get full content of Cafe24AuthController to see proper state handling
cat -n src/main/java/com/almang/inventory/user/auth/controller/Cafe24AuthController.javaRepository: almang2/inventory-server
Length of output: 6353
🏁 Script executed:
#!/bin/bash
# Check callback validation logic in Cafe24OAuthController
rg -n "cafe24OAuthCallback" --context 20 src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.javaRepository: almang2/inventory-server
Length of output: 1735
State parameter is accepted but never validated in the /callback endpoint, creating a CSRF vulnerability alongside the missing state generation in /authorize.
The review comment is accurate. The Cafe24OAuthController has two critical CSRF protection gaps:
- Missing state generation in
/authorize: No state is created or saved to Redis before redirecting to Cafe24 - Missing state validation in
/callback: The state parameter is accepted (line 27) but never validated (line 31 only processes the code). The callback logs the state but ignores it completely
The codebase shows the correct pattern in Cafe24AuthController (separate OAuth implementation at /api/v1/cafe24/oauth), which properly:
- Generates and saves state to Redis in
/redirect - Validates state against Redis in
/callbackbefore token exchange - Deletes state after validation for one-time use
The Cafe24OAuthController should follow this same pattern to fix the CSRF vulnerability.
🤖 Prompt for AI Agents
In
src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.java
around lines 44 to 49, the /authorize endpoint currently redirects to Cafe24
without generating or storing an anti-CSRF state and the /callback endpoint
accepts but never validates the state; fix by generating a cryptographically
secure random state in /authorize, persist it to Redis with an expiry, include
it in the authorization URL, then in /callback fetch the stored state from Redis
and compare to the returned state before exchanging the code for a token, reject
if missing/mismatched, and delete the stored state after successful validation
(follow the same save/validate/delete flow used in Cafe24AuthController).
| @GetMapping("/test") | ||
| public ResponseEntity<Map<String, Object>> testCafe24Api() { | ||
| log.info("Cafe24 API 테스트 요청"); | ||
| try { | ||
| // 유효한 Access Token 가져오기 | ||
| String accessToken = cafe24OAuthService.getValidAccessTokenOrRefresh(); | ||
|
|
||
| // 최근 7일간의 주문 조회 | ||
| LocalDate endDate = LocalDate.now(); | ||
| LocalDate startDate = endDate.minusDays(7); | ||
| String startDateStr = startDate.format(DATE_FORMATTER); | ||
| String endDateStr = endDate.format(DATE_FORMATTER); | ||
|
|
||
| log.info("Cafe24 주문 조회 테스트 - 시작일: {}, 종료일: {}", startDateStr, endDateStr); | ||
|
|
||
| Cafe24OrderResponse response = cafe24ApiClient.getOrders(accessToken, startDateStr, endDateStr) | ||
| .block(); | ||
|
|
||
| Map<String, Object> result = new HashMap<>(); | ||
| result.put("success", true); | ||
| result.put("startDate", startDateStr); | ||
| result.put("endDate", endDateStr); | ||
| result.put("accessToken", accessToken.substring(0, Math.min(20, accessToken.length())) + "..."); | ||
|
|
||
| if (response != null && response.getOrders() != null) { | ||
| // 각 주문에 대해 상세 정보 가져오기 (items 포함) | ||
| List<Cafe24OrderResponse.Order> ordersWithItems = response.getOrders().stream() | ||
| .map(order -> { | ||
| try { | ||
| return enrichOrderWithItems(accessToken, order); | ||
| } catch (Exception e) { | ||
| log.warn("주문 상세 정보를 가져오는 중 오류 발생 - orderId: {}", order.getOrderId(), e); | ||
| return order; // 실패해도 기본 정보 반환 | ||
| } | ||
| }) | ||
| .collect(java.util.stream.Collectors.toList()); | ||
|
|
||
| result.put("orderCount", ordersWithItems.size()); | ||
| result.put("orders", ordersWithItems); | ||
| } else { | ||
| result.put("orderCount", 0); | ||
| result.put("orders", null); | ||
| result.put("response", response); | ||
| } | ||
|
|
||
| return ResponseEntity.ok(result); | ||
| } catch (Exception e) { | ||
| log.error("Cafe24 API 테스트 실패", e); | ||
| Map<String, Object> result = new HashMap<>(); | ||
| result.put("success", false); | ||
| result.put("error", e.getMessage()); | ||
| result.put("errorClass", e.getClass().getName()); | ||
|
|
||
| // 원인 예외도 포함 | ||
| if (e.getCause() != null) { | ||
| result.put("cause", e.getCause().getMessage()); | ||
| result.put("causeClass", e.getCause().getClass().getName()); | ||
| } | ||
|
|
||
| return ResponseEntity.internalServerError().body(result); | ||
| } | ||
| } |
There was a problem hiding this comment.
/test 엔드포인트가 프로덕션 환경에서 보안 위험을 초래할 수 있습니다.
이 엔드포인트는:
- Access Token 일부를 응답에 포함 (Line 72)
- 전체 주문 데이터(개인정보 포함)를 반환
TokenAuthenticationFilter의 whitelist에 없어 인증이 필요하지만, 테스트 목적의 민감한 데이터 노출
프로덕션 배포 전 다음 조치를 권장합니다:
@Profile("!prod")추가로 프로덕션 환경에서 비활성화- 또는 관리자 전용 권한 체크 추가
+ @Profile("!prod") // 프로덕션 환경에서는 비활성화
@GetMapping("/test")
public ResponseEntity<Map<String, Object>> testCafe24Api() {Access Token 노출도 제거하세요:
- result.put("accessToken", accessToken.substring(0, Math.min(20, accessToken.length())) + "...");
+ result.put("accessToken", "[HIDDEN]");🤖 Prompt for AI Agents
In
src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OrderSyncController.java
around lines 50-111, the /test endpoint exposes sensitive data (partial access
token on line ~72 and full order/PII) and is accessible in production; disable
or restrict it: annotate the controller or method with @Profile("!prod") to
prevent loading in prod or add a role-based check (e.g.,
@PreAuthorize("hasRole('ADMIN')") or explicit admin-auth guard) so only admins
can call it, and remove the accessToken from the response (do not put any token
or replace with a non-sensitive flag like "tokenPresent": true), avoid returning
full orders/PII (return counts or sanitized summaries) and ensure the endpoint
is not left unauthenticated in production.
| if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { | ||
| Map<String, Object> responseBody = response.getBody(); | ||
| String accessToken = (String) responseBody.get("access_token"); | ||
|
|
||
| if (accessToken == null || accessToken.isEmpty()) { | ||
| log.error("카페24 Access Token 응답에 access_token이 없습니다. 응답: {}", responseBody); | ||
| throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR, "카페24 Access Token 응답에 access_token이 없습니다."); | ||
| } | ||
|
|
||
| // expires_in 추출 (초 단위, 기본값: 3600초 = 1시간) | ||
| Object expiresInObj = responseBody.get("expires_in"); | ||
| long expiresInSeconds = 3600; // 기본값 | ||
| if (expiresInObj != null) { | ||
| if (expiresInObj instanceof Number) { | ||
| expiresInSeconds = ((Number) expiresInObj).longValue(); | ||
| } else if (expiresInObj instanceof String) { | ||
| try { | ||
| expiresInSeconds = Long.parseLong((String) expiresInObj); | ||
| } catch (NumberFormatException e) { | ||
| log.warn("카페24 Access Token 응답의 expires_in을 파싱할 수 없습니다: {}", expiresInObj); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Redis에 Access Token 저장 | ||
| redisService.saveCafe24AccessToken(accessToken, expiresInSeconds); | ||
| log.info("카페24 Access Token 획득 및 저장 성공: expires_in={}초", expiresInSeconds); | ||
|
|
||
| return accessToken; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, check the full Cafe24AuthService.java file to understand the complete flow
cat -n src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java 2>/dev/null || echo "File not found at expected path"Repository: almang2/inventory-server
Length of output: 6191
🏁 Script executed:
#!/bin/bash
# Also search for any refresh token related code in the repository
rg -i "refresh.*token|token.*refresh" --type=java -n -C3Repository: almang2/inventory-server
Length of output: 50380
🌐 Web query:
Cafe24 OAuth API documentation refresh token
💡 Result:
Summary from Cafe24 docs:
- Access token lifetime: 2 hours. [1]
- Refresh token lifetime: 2 weeks. If you call the refresh-token endpoint before it expires, the response returns a renewed access token AND a new refresh token; the previous refresh token becomes invalid. [1][2]
- When you revoke an access token, the corresponding refresh token is also revoked. [1][2]
References:
[1] Cafe24 REST API — Get Access Token / Refresh token (developers.cafe24.com).
[2] Cafe24 REST API (admin docs) — Get Access Token / Refresh token (developers.cafe24.com).
🏁 Script executed:
#!/bin/bash
# Search for usages of Cafe24AuthService
rg "Cafe24AuthService" --type=java -n -B2 -A2 | head -100Repository: almang2/inventory-server
Length of output: 2497
🏁 Script executed:
#!/bin/bash
# Search for usages of exchangeCodeForAccessToken method
rg "exchangeCodeForAccessToken" --type=java -n -B2 -A2Repository: almang2/inventory-server
Length of output: 2496
🏁 Script executed:
#!/bin/bash
# Check Cafe24OAuthService to understand the full flow
sed -n '1,100p' src/main/java/com/almang/inventory/global/cafe24/service/Cafe24OAuthService.javaRepository: almang2/inventory-server
Length of output: 4585
🏁 Script executed:
#!/bin/bash
# Check Cafe24AuthController to see what endpoints it has
cat -n src/main/java/com/almang/inventory/user/auth/controller/Cafe24AuthController.javaRepository: almang2/inventory-server
Length of output: 6353
🏁 Script executed:
#!/bin/bash
# Check Cafe24OAuthController to see what endpoints it has
cat -n src/main/java/com/almang/inventory/global/cafe24/controller/Cafe24OAuthController.javaRepository: almang2/inventory-server
Length of output: 2391
🏁 Script executed:
#!/bin/bash
# Check if there are multiple OAuth callback endpoints
rg "oauth.*callback|callback.*oauth" --type=java -i -nRepository: almang2/inventory-server
Length of output: 580
Refresh token handling is missing from the active OAuth endpoint, but a complete implementation exists elsewhere in the codebase.
The review comment is correct: Cafe24AuthService.java (the active endpoint per SecurityConfig line 83: /api/v1/cafe24/oauth/callback) extracts only access_token from the Cafe24 OAuth response and does not handle refresh_token. Cafe24's OAuth API does return refresh_token with a 2-week lifetime, as confirmed by their documentation.
However, the codebase contains a complete, production-ready implementation in Cafe24OAuthService.java (using /api/v1/oauth/cafe24/callback) that properly:
- Extracts both
access_tokenandrefresh_token - Persists both tokens with expiry tracking to the database
- Implements automatic token refresh via
getValidAccessTokenOrRefresh()method - Handles refresh token rotation
This indicates either code duplication or an incomplete migration. The active endpoint should either be updated to match the complete implementation or migrated entirely to Cafe24OAuthService.
Extract and store the Cafe24 refresh_token from the OAuth response.
The current implementation saves only the access token, requiring users to re-authenticate when it expires in 2 hours. Cafe24 provides a refresh token with a 2-week lifetime specifically for automatic token renewal.
Update exchangeCodeForAccessToken() to extract refresh_token and refresh_token_expires_at from the response and persist them alongside the access token. Consider consolidating with the Cafe24OAuthService implementation which already handles this correctly.
🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java
around lines 59-87: the method extracts and persists only access_token (and its
expires_in) from the Cafe24 OAuth response but ignores refresh_token and its
expiry, causing inability to refresh tokens automatically; update
exchangeCodeForAccessToken() to also extract "refresh_token" and its expiry (or
compute refresh_token_expires_at from returned lifetime), persist both tokens
(use existing redisService or DB method consistent with Cafe24OAuthService), and
reuse the refresh flow from Cafe24OAuthService (including refresh token rotation
and getValidAccessTokenOrRefresh()) to ensure automatic refresh; if duplication
is undesired, refactor this endpoint to delegate to Cafe24OAuthService's
implementation instead of reimplementing token handling.
| if (accessToken == null || accessToken.isEmpty()) { | ||
| log.error("카페24 Access Token 응답에 access_token이 없습니다. 응답: {}", responseBody); | ||
| throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR, "카페24 Access Token 응답에 access_token이 없습니다."); | ||
| } |
There was a problem hiding this comment.
보안 위험: 응답 본문에 토큰이 포함되어 있을 수 있습니다.
responseBody를 로그에 그대로 출력하면 access_token이 노출될 수 있습니다.
- log.error("카페24 Access Token 응답에 access_token이 없습니다. 응답: {}", responseBody);
+ log.error("카페24 Access Token 응답에 access_token이 없습니다. 응답 키: {}", responseBody.keySet());Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/user/auth/service/Cafe24AuthService.java
around lines 63-66, the code logs the full responseBody which may contain the
access_token; change the logging to avoid exposing secrets by either (a)
removing responseBody from the log and logging a generic error message only, or
(b) sanitize the responseBody before logging by parsing the JSON and replacing
the access_token value with a masked placeholder (e.g. "****") or stripping that
field, and ensure the thrown BaseException message contains no sensitive data;
implement one of these approaches and update the log call and exception
accordingly.
| @Entity | ||
| @Table(name = "wholesales_items") | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @AllArgsConstructor | ||
| @Builder | ||
| public class WholesaleItem extends BaseTimeEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| @Column(name = "id") | ||
| private Long id; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "wholesales_id", nullable = false) | ||
| private Wholesale wholesale; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "product_id", nullable = false) | ||
| private Product product; | ||
|
|
||
| @Column(name = "quantity", nullable = false) | ||
| private Integer quantity; | ||
|
|
||
| @Column(name = "unit_price", nullable = false) | ||
| private Integer unitPrice; | ||
|
|
||
| @Column(name = "amount", nullable = false) | ||
| private Integer amount; | ||
|
|
||
| public void setWholesale(Wholesale wholesale) { | ||
| this.wholesale = wholesale; | ||
| } | ||
|
|
||
| public void update(Integer quantity, Integer unitPrice) { | ||
| if (quantity != null) { | ||
| this.quantity = quantity; | ||
| } | ||
| if (unitPrice != null) { | ||
| this.unitPrice = unitPrice; | ||
| } | ||
| if (quantity != null && unitPrice != null) { | ||
| this.amount = quantity * unitPrice; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check OrderItem.update() to verify the learning note about the pattern
find . -type f -name "OrderItem.java" | head -5Repository: almang2/inventory-server
Length of output: 132
🏁 Script executed:
#!/bin/bash
# Read OrderItem.java to see the update method implementation
cat -n ./src/main/java/com/almang/inventory/order/domain/OrderItem.java | head -80Repository: almang2/inventory-server
Length of output: 1968
🏁 Script executed:
#!/bin/bash
# Search for WholesaleService to see how it uses WholesaleItem.update()
find . -type f -name "*WholesaleService*"Repository: almang2/inventory-server
Length of output: 144
🏁 Script executed:
#!/bin/bash
# Read WholesaleService to see how it calls WholesaleItem.update()
cat -n ./src/main/java/com/almang/inventory/wholesale/service/WholesaleService.javaRepository: almang2/inventory-server
Length of output: 9303
🏁 Script executed:
#!/bin/bash
# Search for calls to WholesaleItem.update()
rg -n "\.update\(" --type java | grep -i wholesale | head -20Repository: almang2/inventory-server
Length of output: 50
🏁 Script executed:
#!/bin/bash
# Search more broadly for update calls and see all occurrences
rg -n "\.update\(" --type java -A 2 | head -100Repository: almang2/inventory-server
Length of output: 2030
🏁 Script executed:
#!/bin/bash
# Comprehensive search for any reference to WholesaleItem.update
rg -n "update" --type java src/main/java/com/almang/inventory/wholesale/ -A 1 -B 1Repository: almang2/inventory-server
Length of output: 1671
🏁 Script executed:
#!/bin/bash
# Check OrderService to understand the validation pattern before calling update()
sed -n '140,160p' ./src/main/java/com/almang/inventory/order/service/OrderService.javaRepository: almang2/inventory-server
Length of output: 1129
🏁 Script executed:
#!/bin/bash
# Find UpdateOrderItemRequest to see if it has validation
find . -type f -name "*UpdateOrderItemRequest*"Repository: almang2/inventory-server
Length of output: 150
🏁 Script executed:
#!/bin/bash
# Read UpdateOrderItemRequest
cat -n ./src/main/java/com/almang/inventory/order/dto/request/UpdateOrderItemRequest.javaRepository: almang2/inventory-server
Length of output: 416
🏁 Script executed:
#!/bin/bash
# Search for any indirect calls through reflection or generic patterns
rg -n "update\(" --type java src/main/java/com/almang/inventory/wholesale/ -B 2 -A 2Repository: almang2/inventory-server
Length of output: 576
Based on my verification, I can now provide the final rewritten review comment. The review identifies a real data consistency issue in the WholesaleItem.update() method. My investigation confirmed:
- OrderItem.update() correctly recalculates
amounton every call (always:this.amount = this.quantity * this.unitPrice) - WholesaleItem.update() has the bug described - it only recalculates
amountwhen both parameters are non-null - UpdateOrderItemRequest allows null
quantityandunitPrice, confirming that services must handle partial updates - WholesaleItem.update() is currently unused in the codebase, but represents a latent bug
The review comment's analysis and suggested fixes are accurate.
update 메서드의 amount 재계산 조건이 데이터 정합성을 깨뜨릴 수 있습니다
현재 구현:
public void update(Integer quantity, Integer unitPrice) {
if (quantity != null) {
this.quantity = quantity;
}
if (unitPrice != null) {
this.unitPrice = unitPrice;
}
if (quantity != null && unitPrice != null) {
this.amount = quantity * unitPrice;
}
}문제점:
quantity만 변경 시:this.quantity는 바뀌지만amount는 이전 값 유지 → 정합성 깨짐unitPrice만 변경 시:this.unitPrice는 바뀌지만amount는 이전 값 유지 → 정합성 깨짐
amount 가 quantity * unitPrice 의 파생값이라면, 두 필드 중 하나라도 변경될 때 항상 재계산되어야 합니다.
개선 방향:
OrderItem 의 패턴을 따르되, 변경 후 필드 기준으로 항상 재계산하세요:
public void update(Integer quantity, Integer unitPrice) {
if (quantity != null) {
this.quantity = quantity;
}
if (unitPrice != null) {
this.unitPrice = unitPrice;
}
this.amount = this.quantity * this.unitPrice;
}이렇게 하면 amount = quantity * unitPrice 불변식이 항상 보장됩니다.
🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/wholesale/domain/WholesaleItem.java around
lines 8 to 53, the update method only recalculates amount when both incoming
parameters are non-null, which can leave amount inconsistent if only quantity or
unitPrice is updated; change the method to apply non-null updates to fields and
then always recompute amount from the current fields (this.amount =
this.quantity * this.unitPrice) so amount remains consistent after partial
updates.
| for (WholesaleItem item : existing.getItems()) { | ||
| try { | ||
| deductInventory(item.getProduct(), BigDecimal.valueOf(item.getQuantity())); | ||
| } catch (Exception e) { | ||
| log.error("재고 차감 실패 - productId: {}, quantity: {}", | ||
| item.getProduct().getId(), item.getQuantity(), e); | ||
| } | ||
| } |
There was a problem hiding this comment.
재고 차감 실패 시 부분 차감으로 인한 데이터 불일치 가능성이 있습니다.
@Transactional 메서드 내에서 재고 차감 실패를 catch하고 계속 진행하면, 일부 상품만 재고가 차감되는 상태가 발생합니다.
비즈니스 요구사항에 따라:
- 전체 롤백 필요: 예외를 다시 throw
- 부분 차감 허용: 현재 로직 유지하되, 실패한 항목 추적
for (WholesaleItem item : existing.getItems()) {
try {
deductInventory(item.getProduct(), BigDecimal.valueOf(item.getQuantity()));
} catch (Exception e) {
log.error("재고 차감 실패 - productId: {}, quantity: {}",
item.getProduct().getId(), item.getQuantity(), e);
+ // 전체 롤백이 필요한 경우:
+ throw new BaseException(ErrorCode.INVENTORY_DEDUCTION_FAILED);
}
}Line 102-104의 신규 주문 재고 차감도 동일한 문제가 있습니다.
🤖 Prompt for AI Agents
In src/main/java/com/almang/inventory/wholesale/service/WholesaleService.java
around lines 64 to 71 (and likewise at lines ~102-104), the current catch of
exceptions during per-item deductInventory allows partial commits and data
inconsistency; either rethrow the exception to trigger a full @Transactional
rollback or explicitly track failed items and persist/report them while
continuing. Update the code to one of the two approaches: A) remove the
swallowing catch (or rethrow after logging) so any deductInventory failure
propagates and causes the transaction to roll back; or B) keep the loop but
collect failed item identifiers and reasons into a list, persist or return that
failure list after the loop, and ensure downstream callers handle
partial-success state; apply the same change at the new-order deduction sites
(lines ~102-104) so behavior is consistent across both places.
|
|
||
| cafe24: | ||
| api: | ||
| oauth-url: ${CAFE24_API_OAUTH_URL} | ||
| base-url: ${CAFE24_API_BASE_URL} | ||
| client-id: ${CAFE24_API_CLIENT_ID} | ||
| client-secret: ${CAFE24_API_CLIENT_SECRET} | ||
| redirect-uri: ${CAFE24_API_REDIRECT_URI} | ||
| scope: ${CAFE24_API_SCOPE} |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for Cafe24Properties class definition
fd -t f -i 'cafe24' | head -20Repository: almang2/inventory-server
Length of output: 1225
🏁 Script executed:
#!/bin/bash
# Read the Cafe24Properties class
cat -n src/main/java/com/almang/inventory/global/config/properties/Cafe24Properties.javaRepository: almang2/inventory-server
Length of output: 985
Perfect! I've found the issue. The Cafe24Properties class indeed defines 7 fields (lines 14-20), but the application-prod.yml configuration only includes 6 properties. The oauthSuccessRedirectUrl field is missing from the YAML configuration.
Here's the rewritten review comment:
Add missing oauth-success-redirect-url property to Cafe24 configuration
The Cafe24Properties class (line 20) defines an oauthSuccessRedirectUrl field, but this property is missing from the application-prod.yml configuration block (lines 44-52). Add the missing property:
cafe24:
api:
oauth-url: ${CAFE24_API_OAUTH_URL}
base-url: ${CAFE24_API_BASE_URL}
client-id: ${CAFE24_API_CLIENT_ID}
client-secret: ${CAFE24_API_CLIENT_SECRET}
redirect-uri: ${CAFE24_API_REDIRECT_URI}
scope: ${CAFE24_API_SCOPE}
oauth-success-redirect-url: ${CAFE24_OAUTH_SUCCESS_REDIRECT_URL}Without this property, the bound field will be null, potentially causing NullPointerException if the OAuth flow attempts to use it.
🤖 Prompt for AI Agents
In src/main/resources/application-prod.yml around lines 44-52, the cafe24.api
block is missing the oauth-success-redirect-url property declared in
Cafe24Properties; add a new line under cafe24.api named
oauth-success-redirect-url and bind it to an environment variable (e.g.
${CAFE24_OAUTH_SUCCESS_REDIRECT_URL}) so the property is populated and not left
null at runtime.
✨ 작업 내용
📝 적용 범위
📌 참고 사항
Summary by CodeRabbit
릴리스 노트
새로운 기능
데이터베이스
✏️ Tip: You can customize this high-level summary in your review settings.