Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
705 changes: 705 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.loopers.application.like;

import com.loopers.domain.like.Like;
import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.ProductRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.List;

@RequiredArgsConstructor
@Component
public class LikeFacade {

private final LikeService likeService;
private final ProductRepository productRepository;

public LikeInfo like(Long userId, Long productId) {
validateProductExists(productId);
Like like = likeService.like(userId, productId);
return LikeInfo.from(like);
}

public void unlike(Long userId, Long productId) {
validateProductExists(productId);
likeService.unlike(userId, productId);
}

public List<LikeInfo> getLikesByUserId(Long userId) {
return likeService.findByUserId(userId).stream()
.map(LikeInfo::from)
.toList();
}

private void validateProductExists(Long productId) {
if (!productRepository.existsById(productId)) {
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.application.like;

import com.loopers.domain.like.Like;

public record LikeInfo(Long likeId, Long userId, Long productId) {

public static LikeInfo from(Like like) {
return new LikeInfo(
like.getId(),
like.getUserId(),
like.getProductId()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.loopers.application.order;

import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
import com.loopers.domain.order.OrderService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@RequiredArgsConstructor
@Component
public class OrderFacade {

private final OrderService orderService;
private final ProductRepository productRepository;
private final UserRepository userRepository;

@Transactional
public OrderInfo createOrder(Long userId, List<OrderItemRequest> itemRequests) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다"));

List<OrderItem> orderItems = new ArrayList<>();
for (OrderItemRequest req : itemRequests) {
Product product = productRepository.findById(req.productId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다"));

product.decreaseStock(req.quantity());
productRepository.save(product);

OrderItem orderItem = new OrderItem(
product.getId(),
product.getName(),
product.getPrice(),
req.quantity()
);
orderItems.add(orderItem);
}
Comment on lines +28 to +47
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

주문 항목 목록에 대한 방어적 검증이 누락되었다.

itemRequests가 빈 리스트인 경우 주문 항목 없이 주문이 생성된다. 또한 동일 productId가 중복 포함된 경우 재고 차감이 여러 번 발생하여 의도치 않은 결과가 발생할 수 있다.

운영 관점: 빈 주문 생성은 데이터 정합성 문제를 야기하고, 중복 상품 ID는 재고 부정확을 초래한다.

수정안:

  1. itemRequests가 비어있으면 예외를 발생시켜라.
  2. 동일 productId 항목은 수량을 합산하거나 예외를 발생시켜라.

테스트: 빈 주문 요청, 중복 상품 ID 요청에 대한 단위 테스트를 추가하라.

🛡️ 검증 로직 추가 제안
 `@Transactional`
 public OrderInfo createOrder(Long userId, List<OrderItemRequest> itemRequests) {
+    if (itemRequests == null || itemRequests.isEmpty()) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다");
+    }
+
+    Set<Long> productIds = new HashSet<>();
+    for (OrderItemRequest req : itemRequests) {
+        if (!productIds.add(req.productId())) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다");
+        }
+    }
+
     User user = userRepository.findById(userId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 28 - 47, In createOrder, guard against empty or duplicate
itemRequests: first throw a CoreException(ErrorType.INVALID_REQUEST, "주문 항목이
비어있습니다") if itemRequests is empty; then collapse duplicate OrderItemRequest
entries by grouping on productId and summing quantities (so you only call
product.decreaseStock once per productId) before fetching Product, calling
product.decreaseStock, productRepository.save, and constructing OrderItem; add
unit tests covering an empty request and requests with duplicate productId to
verify aggregation and stock updates.


Order order = new Order(userId, orderItems);
user.deductPoint(order.getTotalPrice());
userRepository.save(user);

return OrderInfo.from(orderService.createOrder(order));
Comment on lines +27 to +53
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find Product entity and related classes
find . -name "Product.java" -o -name "ProductRepository.java" -o -name "OrderService.java" | grep -v target

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 319


🏁 Script executed:

# Look for Product entity with decreaseStock method
rg -n "class Product" --type java -A 20 | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6291


🏁 Script executed:

# Check for `@Version`, `@Lock` annotations in the codebase
rg -n "@Version|@Lock" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Search for decreaseStock method implementation
rg -n "decreaseStock" --type java -B 2 -A 5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5978


🏁 Script executed:

# Read full Product.java to check for `@Version` and complete implementation
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2552


🏁 Script executed:

# Read OrderService.java implementation
cat -n apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 859


🏁 Script executed:

# Search for concurrent order or stock tests
find . -name "*Test.java" -o -name "*IT.java" | xargs grep -l "concurrent\|Thread\|stock" | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check ProductJpaRepository for any lock annotations
rg -n "ProductJpaRepository" --type java -A 10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1718


동시 주문 시 재고 차감 경쟁 조건(Race Condition) 발생 가능성이 있다.

동일 상품에 대해 동시에 여러 주문이 발생할 경우, product.decreaseStock()의 재고 확인 로직이 트랜잭션 격리 수준에서 보호되지 않아 과다 판매(overselling)가 발생할 수 있다. 예를 들어 재고 1개인 상품에 두 주문이 동시에 들어오면, 두 트랜잭션이 각각 재고 확인 후 모두 통과하여 재고가 음수로 될 수 있다.

운영 환경에서 재고 정합성 문제는 CS 이슈, 환불 처리 비용, 고객 신뢰도 하락으로 이어진다.

수정 방안:

  1. 비관적 잠금: ProductJpaRepositoryfindById()를 오버라이드하여 @Lock(LockModeType.PESSIMISTIC_WRITE) 적용 또는 @Query("SELECT p FROM Product p WHERE p.id = :id FOR UPDATE")로 변경
  2. 낙관적 잠금: Product 엔티티에 @Version 필드 추가 및 재시도 로직 구현
  3. 분산 락: 고성능 요구 시 Redis 기반 락 적용

추가로 동시 주문에 대한 통합 테스트(여러 스레드에서 동일 상품 주문 시뮬레이션)를 작성하여 재고 정합성 검증이 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 27 - 51, The createOrder method in OrderFacade currently reads
Product via productRepository.findById(...) then calls
product.decreaseStock(...), which can cause race conditions; fix by loading the
Product with a DB lock or optimistic version and adding retry: either add a
repository method in ProductJpaRepository e.g. findByIdForUpdate(Long id)
annotated with `@Lock`(LockModeType.PESSIMISTIC_WRITE) (or a FOR UPDATE query) and
use that in OrderFacade.createOrder when fetching products, or add a `@Version`
field to Product and implement retry logic around product.decreaseStock() to
handle OptimisticLockException (retry N times and then fail); also add an
integration test that simulates concurrent orders for the same product to assert
no overselling occurs.

}

public record OrderItemRequest(Long productId, Integer quantity) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.loopers.application.order;

import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;

import java.util.List;

public record OrderInfo(Long orderId, Long userId, Long totalPrice, List<OrderItemInfo> items) {

public record OrderItemInfo(Long productId, String productName, Long productPrice, Integer quantity) {

public static OrderItemInfo from(OrderItem item) {
return new OrderItemInfo(
item.getProductId(),
item.getProductName(),
item.getProductPrice(),
item.getQuantity()
);
}
}

public static OrderInfo from(Order order) {
List<OrderItemInfo> items = order.getOrderItems().stream()
.map(OrderItemInfo::from)
.toList();
return new OrderInfo(
order.getId(),
order.getUserId(),
order.getTotalPrice(),
items
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.loopers.domain.brand;

import com.loopers.domain.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

@Entity
@Table(name = "brands")
public class Brand extends BaseEntity {

@Column(name = "name", nullable = false, unique = true, length = 50)
private BrandName name;
Comment on lines +12 to +13
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# BrandName 정의와 JPA 매핑 확인
rg -n --type=java 'record\s+BrandName|class\s+BrandName|@Embeddable|AttributeConverter<\s*BrandName|@Convert'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 307


🏁 Script executed:

#!/bin/bash
# Brand.java 전체 파일 조회
find . -path "*/domain/brand/Brand.java" -type f | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 154


🏁 Script executed:

#!/bin/bash
# Brand.java 파일 크기 및 내용 확인
wc -l apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 155


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 773


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 695


🏁 Script executed:

# 컨버터나 임베디드 매핑이 전체 domain/brand 디렉토리에 있는지 확인
rg -n --type=java '@Embeddable|AttributeConverter|@Convert' apps/commerce-api/src/main/java/com/loopers/domain/brand/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# 전체 도메인 패키지에서 BrandName 관련 컨버터 검색
rg -n --type=java 'BrandNameConverter|AttributeConverter.*BrandName|BrandName.*AttributeConverter' apps/commerce-api/src/main/java/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# 스프링 설정, JPA 설정 확인 (config, configuration 디렉토리)
find apps/commerce-api/src/main/java -name '*Config*.java' -o -name '*Configuration*.java' | xargs grep -l "BrandName\|@Converter\|AttributeConverter" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 69


🏁 Script executed:

# BrandNameUnitTest 내용 확인 (테스트가 어떤 수준인지)
cat -n apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameUnitTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2260


🏁 Script executed:

# BaseEntity 확인 (공통 설정이 있을 가능성)
find apps/commerce-api/src/main/java -name "BaseEntity.java" | xargs cat -n

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# 혹시 application.yml, application.properties에서 JPA 설정이 있는지 확인
find apps/commerce-api/src -name "application*.yml" -o -name "application*.properties" | xargs cat 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1044


🏁 Script executed:

# 혹시 다른 곳에 BrandName 관련 컨버터/처리가 있는지 더 광범위 검색
rg -i "brandname" apps/commerce-api/src/main/java --type=java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 973


🏁 Script executed:

# jpa.yml 파일 내용 확인
find apps/commerce-api -name "jpa.yml" | xargs cat -n

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# BaseEntity.java 경로 및 내용 다시 확인
find apps/commerce-api -name "BaseEntity.java" -exec wc -l {} \; -exec cat -n {} \;

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


JPA 값 객체 매핑 정의가 없어 런타임 영속화 실패 위험이 크다.

BrandName은 record 타입이며 기본 스칼라 타입이 아니므로, @Convert 어노테이션과 AttributeConverter<BrandName, String> 구현이나 @Embeddable 전략이 명시되지 않으면 JPA가 필드를 매핑할 수 없다. 현재 @Column만으로는 record 직렬화/역직렬화가 정의되지 않아, 애플리케이션 기동 시 매핑 오류가 발생하거나 BrandRepository.existsByName(BrandName) 같은 쿼리 메서드 호출 시 실패할 수 있다. 운영 배포 직후 장애로 이어질 수 있다.

수정 방법: @Convert(converter = BrandNameConverter.class) 어노테이션을 추가하고 AttributeConverter<BrandName, String> 구현체를 작성하여 value()String 변환을 처리해야 한다. 또는 BrandName@Embeddable 전략으로 변경할 수 있다.

추가 테스트: @DataJpaTest에서 Brand 엔티티의 저장/조회 라운드트립 테스트를 추가하여 BrandName의 직렬화/역직렬화가 보장되는지 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java` around
lines 12 - 13, The Brand entity's name field is a non-scalar record (BrandName)
so JPA cannot map it as-is; add `@Convert`(converter = BrandNameConverter.class)
to the Brand.name field and implement an AttributeConverter<BrandName,String>
named BrandNameConverter that converts BrandName.value() to String for database
writes and reconstructs BrandName from String on reads; alternatively mark
BrandName as `@Embeddable` and map it accordingly. After implementing the
converter (or embeddable), add a DataJpaTest that saves and loads a Brand (and
exercises BrandRepository.existsByName(BrandName)) to verify round-trip
serialization/deserialization works.


protected Brand() {}

public Brand(String name) {
this.name = new BrandName(name);
}

public BrandName getName() {
return name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.loopers.domain.brand;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;

public record BrandName(String value) {
public BrandName {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어 있을 수 없습니다");
}
value = value.trim();
if (value.length() > 50) {
throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 1~50자여야 합니다");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.loopers.domain.brand;

import java.util.List;
import java.util.Optional;

public interface BrandRepository {
Brand save(Brand brand);
Optional<Brand> findById(Long id);
List<Brand> findAll();
boolean existsByName(BrandName name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.loopers.domain.brand;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class BrandService {
Comment on lines +6 to +10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

도메인 계층에 Spring 컴포넌트 의존이 섞여 아키텍처 경계가 약화된다.

Line 6~10의 @Component는 도메인 모델을 프레임워크에 고정시켜 재사용성과 테스트 격리를 저하시킨다. 도메인 서비스는 순수 객체로 유지하고, 빈 등록과 조합은 Application/Infrastructure 계층으로 이동하는 편이 운영 변경(프레임워크 교체, 배치/메시지 진입점 추가)에 안전하다. 추가로 Application 계층에서 유스케이스 조합 테스트를 두어 경계가 유지되는지 검증해야 한다.
As per coding guidelines "**/domain/**/*.java: 도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java`
around lines 6 - 10, Remove the Spring framework annotation from the domain
service so the domain stays framework-free: delete the `@Component` on the
BrandService class (and keep it a plain POJO, leaving RequiredArgsConstructor if
needed), then register/compose BrandService as a Spring bean in the
Application/Infrastructure layer (create a `@Configuration` or factory in the
application module that constructs BrandService with its dependencies). After
moving bean registration, add an application-layer integration/use-case test to
verify the service is correctly composed without introducing framework
annotations into the domain package.


private final BrandRepository brandRepository;

public Brand register(String name) {
BrandName brandName = new BrandName(name);
if (brandRepository.existsByName(brandName)) {
throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드 이름입니다");
}
Brand brand = new Brand(name);
return brandRepository.save(brand);
Comment on lines +16 to +20
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

사전 중복 확인 후 저장하는 흐름은 동시성 상황에서 오류 응답 일관성을 깨뜨린다.

Line 16~20은 check-then-act 구조라 동시 요청에서 둘 다 통과한 뒤 저장 시점 충돌이 발생할 수 있다. 운영에서는 간헐적 500 계열 오류와 비일관 에러 포맷으로 이어진다. DB unique 제약을 최종 보루로 두고, 저장 단계의 중복 충돌을 CoreException(CONFLICT)로 일관 변환하는 경로를 추가해야 한다. 추가로 동일 브랜드명 동시 등록 통합 테스트(동시 2요청, 1건 성공/1건 CONFLICT)를 작성해야 한다.
Based on learnings "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java`
around lines 16 - 20, Replace the current check-then-act flow in BrandService
(the existsByName + new Brand(name) + brandRepository.save(...) sequence) by
attempting the save directly and converting any persistent-uniqueness violation
into a CoreException with ErrorType.CONFLICT; specifically, remove reliance on
brandRepository.existsByName, call brandRepository.save(brand) inside a
try/catch that catches the DB/ORM uniqueness exception (e.g.,
DataIntegrityViolationException or the specific persistence exception your stack
throws) and rethrow new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드 이름입니다");
also add a concurrent integration test that issues two simultaneous create
requests for the same name and asserts one succeeds and the other receives
CoreException -> CONFLICT handled by ApiControllerAdvice for consistent response
formatting.

}
}
40 changes: 40 additions & 0 deletions apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.loopers.domain.like;

import com.loopers.domain.BaseEntity;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

@Entity
@Table(name = "likes")
public class Like extends BaseEntity {

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "product_id", nullable = false)
private Long productId;
Comment on lines +10 to +18
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

좋아요 중복 방지 제약이 없어 동시성 상황에서 데이터가 중복될 수 있다.

운영 관점에서 동일 사용자-상품 좋아요가 중복 저장되면 카운트가 부정확해지고 정합성 장애로 이어진다. user_id + product_id 유니크 제약(및 조회용 인덱스)을 테이블 레벨에 추가해 DB에서 최종 방어선을 두는 것이 필요하다. 동시 요청(또는 중복 저장) 통합 테스트를 추가해 1건만 유지되는지 검증해야 한다.

수정 예시
 import jakarta.persistence.Column;
 import jakarta.persistence.Entity;
 import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import jakarta.persistence.Index;
 
 `@Entity`
-@Table(name = "likes")
+@Table(
+    name = "likes",
+    uniqueConstraints = {
+        `@UniqueConstraint`(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"})
+    },
+    indexes = {
+        `@Index`(name = "idx_likes_user_id", columnList = "user_id"),
+        `@Index`(name = "idx_likes_product_id", columnList = "product_id")
+    }
+)
 public class Like extends BaseEntity {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java` around
lines 10 - 18, Add a DB-level unique constraint and index on the Like entity to
prevent duplicate userId+productId rows: modify the `@Table` on class Like to
declare a UniqueConstraint across columns "user_id" and "product_id" (and
optionally a supporting index) so the database enforces uniqueness; keep the
existing fields userId and productId in class Like (extends BaseEntity). Also
add an integration/ concurrency test that issues concurrent create-like requests
for the same userId/productId and asserts only one Like row is persisted.


protected Like() {}

public Like(Long userId, Long productId) {
if (userId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다");
}
if (productId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다");
}
this.userId = userId;
this.productId = productId;
}
Comment on lines +22 to +31
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

식별자 범위 검증이 없어 0/음수 ID가 도메인에 유입된다.

운영 관점에서 잘못된 ID는 DB FK 예외로 전이되어 API 오류 일관성을 깨고 장애 분석 비용을 높인다. 생성자에서 null뿐 아니라 <= 0도 차단해 입력을 도메인 경계에서 종료하는 것이 바람직하다. userId/productId 각각에 대해 0, -1 예외 케이스 단위 테스트를 추가해야 한다.

수정 예시
     public Like(Long userId, Long productId) {
-        if (userId == null) {
+        if (userId == null || userId <= 0) {
             throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다");
         }
-        if (productId == null) {
+        if (productId == null || productId <= 0) {
             throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다");
         }
         this.userId = userId;
         this.productId = productId;
     }
Based on learnings: This project intentionally performs validation in the domain layer and routes errors through CoreException/ApiControllerAdvice instead of Bean Validation.
📝 Committable suggestion

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

Suggested change
public Like(Long userId, Long productId) {
if (userId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다");
}
if (productId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다");
}
this.userId = userId;
this.productId = productId;
}
public Like(Long userId, Long productId) {
if (userId == null || userId <= 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다");
}
if (productId == null || productId <= 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다");
}
this.userId = userId;
this.productId = productId;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java` around
lines 22 - 31, The Like constructor currently only rejects nulls but allows
0/negative IDs; update the Like(Long userId, Long productId) constructor to
validate that userId and productId are > 0 and throw new
CoreException(ErrorType.BAD_REQUEST, "...") with clear messages when they are <=
0; add unit tests for the Like constructor to assert CoreException is thrown for
userId 0, userId -1, productId 0, and productId -1 (separately) to ensure
domain-level rejection of non-positive IDs and consistent error routing via
CoreException/ApiControllerAdvice.


public Long getUserId() {
return userId;
}

public Long getProductId() {
return productId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.loopers.domain.like;

import java.util.List;
import java.util.Optional;

public interface LikeRepository {

Like save(Like like);

Optional<Like> findByUserIdAndProductId(Long userId, Long productId);

List<Like> findByUserId(Long userId);

void delete(Like like);

long countByProductId(Long productId);
Comment on lines +10 to +16
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Like 엔티티 파일 찾기
find . -type f -name "*Like*.java" | grep -E "(domain|entity)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 636


🏁 Script executed:

#!/bin/bash
# Flyway/Liquibase 마이그레이션 파일 검색
find . -type f \( -name "*.sql" -o -name "*.xml" \) | xargs grep -l -i "likes\|like" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# 전체 리포지토리에서 `@Index` 어노테이션 검색
rg -n '@Index|@Table.*indexes' --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Like 관련 엔티티 정의 확인
rg -n 'class Like|@Entity.*Like|@Table.*like' --type java -A 20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 23822


🏁 Script executed:

#!/bin/bash
# Like.java 전체 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1370


🏁 Script executed:

#!/bin/bash
# Flyway 마이그레이션 파일 찾기 (V*.sql 패턴)
find . -type f -name "V*.sql" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# 데이터베이스 설정/마이그레이션 관련 파일 전체 검색
find . -type f \( -path "*/db/migration*" -o -path "*/flyway*" -o -path "*/liquibase*" \) -name "*.sql" -o -name "*.xml" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 558


🏁 Script executed:

#!/bin/bash
# likes 테이블 관련 SQL 파일 검색
find . -type f \( -name "*.sql" -o -name "*.xml" \) -exec grep -l "likes\|LIKES" {} \; 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# 모든 SQL 파일 검색
find . -type f -name "*.sql" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# resources 디렉토리 구조 확인
find ./apps/commerce-api/src/main/resources -type f 2>/dev/null | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 139


🏁 Script executed:

#!/bin/bash
# schema 또는 database 관련 파일 검색
find . -type f \( -name "*schema*" -o -name "*migration*" -o -name "*database*" \) 2>/dev/null | grep -E "\.(sql|xml|yaml)$"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# 전체 리포지토리에서 CREATE INDEX 또는 INDEX 정의 검색
rg -i "create index|create.*index.*likes|index.*user_id.*product_id" --type sql

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 233


🏁 Script executed:

#!/bin/bash
# application.yml 전체 내용 확인
cat apps/commerce-api/src/main/resources/application.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1044


🏁 Script executed:

#!/bin/bash
# 모든 application 설정 파일 검색
find . -type f -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

#!/bin/bash
# 리포지토리 전체에서 `@Index` 또는 indexes 검색 (Java 파일에서)
rg -n "@Index|indexes\s*=" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

#!/bin/bash
# jpa.yml 파일 위치 찾기
find . -type f -name "jpa.yml" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 125


🏁 Script executed:

#!/bin/bash
# jpa.yml 내용 확인
find . -type f -name "jpa.yml" -exec cat {} \;

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2231


🏁 Script executed:

#!/bin/bash
# 전체 resources 디렉토리 구조 확인
find ./apps/commerce-api/src/main/resources -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 139


Like 엔티티에 인덱스 정의를 추가하고, 운영 DB 스키마에 인덱스 생성을 확인해야 한다.

현재 코드를 검토한 결과, Like 엔티티에 @Table(indexes=...) 어노테이션이 없으며, 프로젝트 전체에서 명시적인 DB 마이그레이션 파일(Flyway/Liquibase)도 없다. 더욱이 운영 환경(dev/qa/prd)에서는 hibernate.ddl-auto: none으로 설정되어 있어 자동 스키마 생성이 비활성화된 상태다.

findByUserIdAndProductId(), findByUserId(), countByProductId() 메서드는 user_idproduct_id 컬럼을 기반으로 자주 호출되는 쿼리인데, 이 컬럼들에 인덱스가 없으면 테이블 풀 스캔 발생으로 조회 성능이 심각하게 저하될 수 있다.

다음과 같이 수정하고 검증해야 한다:

  1. Like 엔티티의 @Table 어노테이션에 인덱스 정의 추가: @Table(name = "likes", indexes = {@Index(name = "idx_user_id", columnList = "user_id"), @Index(name = "idx_product_id", columnList = "product_id")})
  2. 또는 복합 인덱스 고려: @Index(name = "idx_user_product", columnList = "user_id,product_id")
  3. 운영 환경 DB 마이그레이션 전략 수립 및 스키마에 인덱스가 실제로 생성되었는지 확인
  4. 인덱스 생성 후 findByUserIdAndProductId 등 각 쿼리 메서드별로 실행 계획(EXPLAIN) 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java`
around lines 10 - 16, Add proper DB indexes for the Like entity and ensure
migrations are applied: modify the Like entity to include a
`@Table`(...indexes=...) declaration (e.g., add indexes named idx_user_id on
user_id and idx_product_id on product_id, or a composite idx_user_product on
user_id,product_id) so queries implemented by repository methods
findByUserIdAndProductId, findByUserId, and countByProductId use indexes;
because hibernate.ddl-auto is disabled in prod, also create and commit a
migration (Flyway or Liquibase) that creates these indexes and deploy it to
dev/qa/prd, then verify the indexes exist in each environment and run EXPLAIN
for the above methods to confirm the query plans use the new indexes.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.loopers.domain.like;

import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Component
public class LikeService {

private final LikeRepository likeRepository;

@Transactional
public Like like(Long userId, Long productId) {
return likeRepository.findByUserIdAndProductId(userId, productId)
.orElseGet(() -> {
Like like = new Like(userId, productId);
return likeRepository.save(like);
});
Comment on lines +15 to +20
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find Like entity and check for unique constraints
echo "=== Searching for Like entity definition and constraints ==="
find apps/commerce-api -name "Like.java" -type f | while read file; do
  echo "Found: $file"
  cat -n "$file" | head -50
done

echo ""
echo "=== Checking for unique constraints in entity mapping ==="
rg -n "@UniqueConstraint|@Table|@Column.*unique" apps/commerce-api/src/main/java/com/loopers/domain/like/

echo ""
echo "=== Checking LikeRepository implementation ==="
find apps/commerce-api -name "*LikeRepository*" -type f | while read file; do
  echo "File: $file"
  cat -n "$file"
done

echo ""
echo "=== Checking for concurrent test cases ==="
find apps/commerce-api/src/test -name "*Like*Test*.java" -o -name "*LikeConcurrent*" | while read file; do
  [ -n "$file" ] && cat -n "$file"
done

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 15554


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find and display full LikeService implementation
find apps/commerce-api -name "LikeService.java" -type f | while read file; do
  echo "=== File: $file ==="
  cat -n "$file"
done

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1562


동시 요청에서 중복 좋아요가 생성될 수 있다.

Line 15~20의 조회 후 저장 패턴은 운영 환경 동시성에서 TOCTOU(Time-Of-Check-Time-Of-Use) 경쟁이 발생한다. @Transactional 어노테이션이 있더라도 기본 격리 수준(READ_COMMITTED)에서는 두 스레드가 동시에 조회 결과를 받아 모두 저장 로직을 실행할 수 있어, (userId, productId) 중복 데이터가 누적될 수 있다.

수정안으로 DB 유니크 제약 (user_id, product_id)을 추가하고, 저장 중 제약 위반 시 기존 레코드를 재조회해 멱등하게 반환하도록 처리하는 것이 안전하다. 추가로 동일 사용자/상품에 대해 동시 like 요청을 여러 스레드로 발생시켜 최종 건수가 1건인지 검증하는 동시성 통합 테스트를 추가해야 한다. 현재 기존 테스트는 순차 실행만 검증하고 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java`
around lines 15 - 20, The like(Long userId, Long productId) path is vulnerable
to TOCTOU races; add a DB unique constraint on (user_id, product_id) in the Like
entity/migration and change LikeService.like to attempt save and, on
unique-constraint/DataIntegrityViolationException, requery via
likeRepository.findByUserIdAndProductId(userId, productId) and return the
existing record to make the operation idempotent; keep the optimistic
read-or-create fallback if desired but ensure the save is wrapped in a
transaction and exception handler that returns the re-fetched Like. Also add a
concurrency integration test that fires many parallel calls to LikeService.like
(same userId/productId) and asserts that only one Like row exists and all calls
get the same logical result.

}

@Transactional
public void unlike(Long userId, Long productId) {
likeRepository.findByUserIdAndProductId(userId, productId)
.ifPresent(likeRepository::delete);
}

@Transactional(readOnly = true)
public List<Like> findByUserId(Long userId) {
return likeRepository.findByUserId(userId);
}

@Transactional(readOnly = true)
public long countByProductId(Long productId) {
return likeRepository.countByProductId(productId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.loopers.domain.order;

import com.loopers.domain.BaseEntity;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order extends BaseEntity {

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "total_price", nullable = false)
private Long totalPrice;

@Transient
private List<OrderItem> orderItems = new ArrayList<>();
Comment on lines +25 to +26
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

@Transient 사용으로 DB 조회 시 orderItems가 복원되지 않는다.

Order를 DB에서 조회하면 orderItems는 빈 ArrayList로 초기화된다. 현재 생성 기능만 구현되어 있다면 당장 문제가 없지만, 주문 조회 API 추가 시 Order.getOrderItems()가 빈 리스트를 반환하여 장애로 이어질 수 있다.

PR 설명에서 "JPA relationship mapping... kept simple for now"로 언급되었으나, 향후 조회 기능 구현 시 다음 중 하나를 선택해야 한다:

  1. @OneToMany 관계 매핑 추가
  2. 별도 쿼리로 OrderItem 조회 후 Order에 주입

현재 상태에서 주문 조회 기능이 추가되면 데이터 정합성 문제가 발생하므로, 최소한 TODO 주석이나 문서화가 필요하다.

📝 문서화 예시
+    // TODO: 주문 조회 기능 추가 시 `@OneToMany` 관계 매핑 또는 별도 조회 로직 필요
+    // 현재는 생성 시에만 사용되며, DB 조회 시 빈 리스트로 복원됨
     `@Transient`
     private List<OrderItem> orderItems = new ArrayList<>();
📝 Committable suggestion

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

Suggested change
@Transient
private List<OrderItem> orderItems = new ArrayList<>();
// TODO: 주문 조회 기능 추가 시 `@OneToMany` 관계 매핑 또는 별도 조회 로직 필요
// 현재는 생성 시에만 사용되며, DB 조회 시 빈 리스트로 복원됨
`@Transient`
private List<OrderItem> orderItems = new ArrayList<>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java` around
lines 25 - 26, The Order.entity currently marks the field orderItems as
`@Transient` so it will not be restored from the DB and Order.getOrderItems() will
return an empty list on reads; to fix, either remove `@Transient` and add a proper
JPA relationship (e.g., annotate List<OrderItem> orderItems with
`@OneToMany`(mappedBy="order", cascade=..., fetch=...) and ensure OrderItem has
the corresponding `@ManyToOne` order field), or keep `@Transient` but implement a
repository method that loads OrderItem by orderId and injects them into Order
after fetch (e.g., in OrderService or a mapper), and if you intentionally leave
it transient for now add a clear TODO comment on the Order.orderItems field
referencing getOrderItems and the intended migration plan so future read APIs
won’t be broken.


protected Order() {}

public Order(Long userId, List<OrderItem> orderItems) {
if (userId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다");
}
if (orderItems == null || orderItems.isEmpty()) {
throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다");
}
this.userId = userId;
this.orderItems = new ArrayList<>(orderItems);
this.totalPrice = calculateTotalPrice();
}

private long calculateTotalPrice() {
return orderItems.stream()
.mapToLong(OrderItem::totalPrice)
.sum();
}

public Long getUserId() {
return userId;
}

public Long getTotalPrice() {
return totalPrice;
}

public List<OrderItem> getOrderItems() {
return Collections.unmodifiableList(orderItems);
}
}
Loading