-
Notifications
You must be signed in to change notification settings - Fork 43
[volume-3] 도메인 & 객체 설계 및 아키텍처, 패키지 구성 #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1db71d5
168fa16
6b9f132
f4da482
e473164
02cb42e
9451727
4fd089d
94eb87e
a28550d
52fd7a7
5041e08
c625529
ce6631d
08c3784
fab111c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
| } | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find Product entity and related classes
find . -name "Product.java" -o -name "ProductRepository.java" -o -name "OrderService.java" | grep -v targetRepository: 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 -100Repository: 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 javaRepository: 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 5Repository: 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.javaRepository: 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.javaRepository: 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 -10Repository: 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 10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1718 동시 주문 시 재고 차감 경쟁 조건(Race Condition) 발생 가능성이 있다. 동일 상품에 대해 동시에 여러 주문이 발생할 경우, 운영 환경에서 재고 정합성 문제는 CS 이슈, 환불 처리 비용, 고객 신뢰도 하락으로 이어진다. 수정 방안:
추가로 동시 주문에 대한 통합 테스트(여러 스레드에서 동일 상품 주문 시뮬레이션)를 작성하여 재고 정합성 검증이 필요하다. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 -5Repository: 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.javaRepository: 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.javaRepository: 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.javaRepository: 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/nullRepository: 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.javaRepository: 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 -nRepository: 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/nullRepository: 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=javaRepository: 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 -nRepository: 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 값 객체 매핑 정의가 없어 런타임 영속화 실패 위험이 크다.
수정 방법: 추가 테스트: 🤖 Prompt for AI Agents |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 도메인 계층에 Spring 컴포넌트 의존이 섞여 아키텍처 경계가 약화된다. Line 6~10의 🤖 Prompt for AI Agents |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 사전 중복 확인 후 저장하는 흐름은 동시성 상황에서 오류 응답 일관성을 깨뜨린다. Line 16~20은 check-then-act 구조라 동시 요청에서 둘 다 통과한 뒤 저장 시점 충돌이 발생할 수 있다. 운영에서는 간헐적 500 계열 오류와 비일관 에러 포맷으로 이어진다. DB unique 제약을 최종 보루로 두고, 저장 단계의 중복 충돌을 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋아요 중복 방지 제약이 없어 동시성 상황에서 데이터가 중복될 수 있다. 운영 관점에서 동일 사용자-상품 좋아요가 중복 저장되면 카운트가 부정확해지고 정합성 장애로 이어진다. 수정 예시 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 |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 식별자 범위 검증이 없어 0/음수 ID가 도메인에 유입된다. 운영 관점에서 잘못된 ID는 DB FK 예외로 전이되어 API 오류 일관성을 깨고 장애 분석 비용을 높인다. 생성자에서 수정 예시 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;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Like 엔티티 파일 찾기
find . -type f -name "*Like*.java" | grep -E "(domain|entity)" | head -20Repository: 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 -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: #!/bin/bash
# 전체 리포지토리에서 `@Index` 어노테이션 검색
rg -n '@Index|@Table.*indexes' --type javaRepository: 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 20Repository: 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.javaRepository: 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 -20Repository: 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 -20Repository: 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 -20Repository: 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/nullRepository: 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 -30Repository: 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 sqlRepository: 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.ymlRepository: 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/nullRepository: 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 javaRepository: 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/nullRepository: 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 fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 139 Like 엔티티에 인덱스 정의를 추가하고, 운영 DB 스키마에 인덱스 생성을 확인해야 한다. 현재 코드를 검토한 결과, Like 엔티티에
다음과 같이 수정하고 검증해야 한다:
🤖 Prompt for AI Agents |
||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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"
doneRepository: 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"
doneRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1562 동시 요청에서 중복 좋아요가 생성될 수 있다. Line 15~20의 조회 후 저장 패턴은 운영 환경 동시성에서 TOCTOU(Time-Of-Check-Time-Of-Use) 경쟁이 발생한다. 수정안으로 DB 유니크 제약 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Order를 DB에서 조회하면 PR 설명에서 "JPA relationship mapping... kept simple for now"로 언급되었으나, 향후 조회 기능 구현 시 다음 중 하나를 선택해야 한다:
현재 상태에서 주문 조회 기능이 추가되면 데이터 정합성 문제가 발생하므로, 최소한 TODO 주석이나 문서화가 필요하다. 📝 문서화 예시+ // TODO: 주문 조회 기능 추가 시 `@OneToMany` 관계 매핑 또는 별도 조회 로직 필요
+ // 현재는 생성 시에만 사용되며, DB 조회 시 빈 리스트로 복원됨
`@Transient`
private List<OrderItem> orderItems = new ArrayList<>();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| 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); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
주문 항목 목록에 대한 방어적 검증이 누락되었다.
itemRequests가 빈 리스트인 경우 주문 항목 없이 주문이 생성된다. 또한 동일productId가 중복 포함된 경우 재고 차감이 여러 번 발생하여 의도치 않은 결과가 발생할 수 있다.운영 관점: 빈 주문 생성은 데이터 정합성 문제를 야기하고, 중복 상품 ID는 재고 부정확을 초래한다.
수정안:
itemRequests가 비어있으면 예외를 발생시켜라.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