Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@EnableScheduling
@ConfigurationPropertiesScan
@SpringBootApplication
public class CommerceApiApplication {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ public class LikeFacade {

private final LikeService likeService;

public void likeProduct(String memberId, Long productId) {
public void likeProduct(Long memberId, Long productId) {
likeService.like(memberId, productId);
}

public void unlikeProduct(String memberId, Long productId) {
public void unlikeProduct(Long memberId, Long productId) {
likeService.unlike(memberId, productId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class MemberFacade {
@Transactional
public MemberInfo registerMember(String memberId, String email, String password, String birthDate, Gender gender) {
Member member = memberService.registerMember(memberId, email, password, birthDate, gender);
pointService.initializeMemberPoints(memberId);
pointService.initializeMemberPoints(member.getId()); // Member PK μ‚¬μš©
return MemberInfo.from(member);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@
@Builder
public class OrderCommand {

private final String memberId;
private final Long memberId;
private final List<OrderLineCommand> orderLines;
private final Long memberCouponId;

public static OrderCommand of(String memberId, List<OrderLineCommand> orderLines) {
public static OrderCommand of(Long memberId, List<OrderLineCommand> orderLines) {
return OrderCommand.builder()
.memberId(memberId)
.orderLines(orderLines)
.memberCouponId(null)
.build();
}

public static OrderCommand of(String memberId, List<OrderLineCommand> orderLines, Long memberCouponId) {
public static OrderCommand of(Long memberId, List<OrderLineCommand> orderLines, Long memberCouponId) {
return OrderCommand.builder()
.memberId(memberId)
.orderLines(orderLines)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
public class OrderInfo {

private final Long id;
private final String memberId;
private final Long memberId;
private final Money totalPrice;
private final List<OrderItemInfo> items;
private final ZonedDateTime orderedAt;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.loopers.application.product;

import lombok.Getter;

import java.util.List;

@Getter
public class CursorPageInfo<T> {

private final List<T> content;
private final String nextCursor;
private final boolean hasNext;

private CursorPageInfo(List<T> content, String nextCursor, boolean hasNext) {
this.content = content;
this.nextCursor = nextCursor;
this.hasNext = hasNext;
}

public static <T> CursorPageInfo<T> of(List<T> content, String nextCursor, boolean hasNext) {
return new CursorPageInfo<>(content, nextCursor, hasNext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.loopers.application.product;

import com.loopers.domain.product.enums.ProductSortCondition;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class ProductCursorSearchCommand {

private final Long brandId;
private final String keyword;
private final ProductSortCondition sort;
private final String cursor;
private final int size;
private final Long memberIdOrNull;

public static ProductCursorSearchCommand of(Long brandId, String keyword, ProductSortCondition sort, String cursor, int size, Long memberIdOrNull) {
return ProductCursorSearchCommand.builder()
.brandId(brandId)
.keyword(keyword)
.sort(sort)
.cursor(cursor)
.size(size)
.memberIdOrNull(memberIdOrNull)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.loopers.application.product;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import com.loopers.domain.common.vo.Money;
import com.loopers.domain.product.vo.Stock;
import lombok.Builder;

@Builder
@JsonDeserialize(builder = ProductDetailInfo.ProductDetailInfoBuilder.class)
public class ProductDetailInfo {

private final Long id;
private final String name;
private final String description;
Expand All @@ -17,7 +20,7 @@ public class ProductDetailInfo {
private final Stock stock;
private final int likeCount;
private final boolean isLikedByMember;

public Long getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
Expand All @@ -26,7 +29,11 @@ public class ProductDetailInfo {
public Money getPrice() { return price; }
public Stock getStock() { return stock; }
public int getLikeCount() { return likeCount; }

@JsonProperty("likedByMember")
public boolean isLikedByMember() { return isLikedByMember; }

@JsonPOJOBuilder(withPrefix = "")
public static class ProductDetailInfoBuilder {
}
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,129 @@
package com.loopers.application.product;

import com.loopers.domain.like.service.LikeReadService;
import com.loopers.domain.product.service.ProductReadService;
import com.loopers.domain.product.command.ProductSearchFilter;
import com.loopers.domain.product.enums.ProductSortCondition;
import com.loopers.infrastructure.cache.ProductDetailCache;
import com.loopers.infrastructure.cache.ProductListCache;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Component
@Transactional
public class ProductFacade {

private final ProductReadService productReadService;
private final LikeReadService likeReadService;
private final ProductDetailCache productDetailCache;
private final ProductListCache productListCache;

@Transactional(readOnly = true)
public Page<ProductSummaryInfo> getProducts(ProductSearchCommand command) {
// Cache only for LIKES_DESC sort
if (command.getSort() == ProductSortCondition.LIKES_DESC) {
return productListCache.get(
command.getBrandId(),
command.getSort(),
command.getPage(),
command.getSize()
).orElseGet(() -> {
Page<ProductSummaryInfo> result = fetchProducts(command);
productListCache.set(
command.getBrandId(),
command.getSort(),
command.getPage(),
command.getSize(),
result
);
return result;
});
}

return fetchProducts(command);
}
Comment on lines 29 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

μΊμ‹œλœ μƒν’ˆ λͺ©λ‘μ—μ„œ isLikedByMember μƒνƒœκ°€ μΌκ΄€λ˜μ§€ μ•Šμ„ 수 μžˆμŠ΅λ‹ˆλ‹€.

productListCacheλŠ” memberIdλ₯Ό 킀에 ν¬ν•¨ν•˜μ§€ μ•Šμ§€λ§Œ, fetchProductsλŠ” memberIdOrNull을 μ „λ‹¬ν•˜μ—¬ isLikedByMemberλ₯Ό κ³„μ‚°ν•©λ‹ˆλ‹€. 첫 번째 μš”μ²­μžμ˜ μ’‹μ•„μš” μƒνƒœκ°€ μΊμ‹œλ˜μ–΄ λ‹€λ₯Έ μ‚¬μš©μžμ—κ²Œ 잘λͺ»λœ 정보가 제곡될 수 μžˆμŠ΅λ‹ˆλ‹€.

μƒν’ˆ λͺ©λ‘ μΊμ‹œλŠ” isLikedByMember=false인 μƒνƒœλ‘œ μ €μž₯ν•˜κ³ , 상세 쑰회처럼 λ™μ μœΌλ‘œ κ³„μ‚°ν•˜λŠ” 방식을 ꢌμž₯ν•©λ‹ˆλ‹€:

     if (command.getSort() == ProductSortCondition.LIKES_DESC) {
         return productListCache.get(
                 command.getBrandId(),
                 command.getSort(),
                 command.getPage(),
                 command.getSize()
         ).orElseGet(() -> {
-            Page<ProductSummaryInfo> result = fetchProducts(command);
+            // μΊμ‹œμ—λŠ” isLikedByMember=false μƒνƒœλ‘œ μ €μž₯
+            Page<ProductSummaryInfo> result = fetchProducts(command.withMemberIdNull());
             productListCache.set(
                     command.getBrandId(),
                     command.getSort(),
                     command.getPage(),
                     command.getSize(),
                     result
             );
             return result;
-        });
+        }).map(cached -> enrichWithLikeStatus(cached, command.getMemberIdOrNull()));
     }

Committable suggestion skipped: line range outside the PR's diff.

πŸ€– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
around lines 29 to 51, the product list cache is keyed without memberId but
fetchProducts computes isLikedByMember using memberIdOrNull, causing one user's
like state to be cached and returned to others; fix by caching member-agnostic
product summaries (remove or set isLikedByMember to false/null before caching)
and compute/set isLikedByMember per-request after retrieving from cache (or
alternatively include memberId in the cache key if you intend to cache
per-user), ensuring the cached value is independent of the requesting member.


private Page<ProductSummaryInfo> fetchProducts(ProductSearchCommand command) {
ProductSearchFilter filter = ProductSearchFilter.of(
command.getBrandId(),
command.getKeyword(),
command.getSort()
);

Pageable pageable = PageRequest.of(command.getPage(), command.getSize());

return productReadService.getProducts(
filter,
pageable,
filter,
pageable,
command.getMemberIdOrNull()
);
}

@Transactional(readOnly = true)
public ProductDetailInfo getProductDetail(Long productId, String memberIdOrNull) {
return productReadService.getProductDetail(productId, memberIdOrNull);
public CursorPageInfo<ProductSummaryInfo> getProductsByCursor(ProductCursorSearchCommand command) {
ProductSearchFilter filter = ProductSearchFilter.of(
command.getBrandId(),
command.getKeyword(),
command.getSort()
);

return productReadService.getProductsByCursor(
filter,
command.getCursor(),
command.getSize(),
command.getMemberIdOrNull()
);
}

@Transactional(readOnly = true)
public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
// 1. μΊμ‹œμ—μ„œ μƒν’ˆ 정보 쑰회 (isLikedByMember=false인 μƒνƒœ)
ProductDetailInfo cachedInfo = productDetailCache.get(productId)
.orElseGet(() -> {
// μΊμ‹œ miss: DB 쑰회 (isLikedByMemberλŠ” λ‚˜μ€‘μ— 계산)
ProductDetailInfo result = productReadService.getProductDetail(productId, null);
productDetailCache.set(productId, result);
return result;
});

// 2. λ‘œκ·ΈμΈν•˜μ§€ μ•Šμ€ 경우 λ°”λ‘œ λ°˜ν™˜
if (memberIdOrNull == null) {
return cachedInfo; // isLikedByMember=false κ·ΈλŒ€λ‘œ
}

// 3. isLikedByMember만 동적 계산
boolean isLiked = likeReadService.isLikedBy(memberIdOrNull, productId);

// 4. isLikedByMember ν•„λ“œλ§Œ κ΅μ²΄ν•΄μ„œ λ°˜ν™˜
return ProductDetailInfo.builder()
.id(cachedInfo.getId())
.name(cachedInfo.getName())
.description(cachedInfo.getDescription())
.brandName(cachedInfo.getBrandName())
.brandDescription(cachedInfo.getBrandDescription())
.price(cachedInfo.getPrice())
.stock(cachedInfo.getStock())
.likeCount(cachedInfo.getLikeCount())
.isLikedByMember(isLiked) // ⭐ 동적 계산
.build();
}

@Transactional(readOnly = true)
public List<ProductSummaryInfo> getPopularProducts(Long memberIdOrNull) {
// μΊμ‹œ 제거 - 순수 DB 쑰회
return productReadService.getPopularProducts(memberIdOrNull);
}

@Transactional(readOnly = true)
public List<ProductSummaryInfo> getBrandPopularProducts(Long brandId, int limit, Long memberIdOrNull) {
// μΊμ‹œ 제거 - 순수 DB 쑰회
return productReadService.getBrandPopularProducts(brandId, limit, memberIdOrNull);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
@Getter
@Builder
public class ProductSearchCommand {


private final Long brandId;
private final String keyword;
private final ProductSortCondition sort;
private final int page;
private final int size;
private final String memberIdOrNull;
public static ProductSearchCommand of(String keyword, ProductSortCondition sort, int page, int size, String memberIdOrNull) {
private final Long memberIdOrNull;

public static ProductSearchCommand of(Long brandId, String keyword, ProductSortCondition sort, int page, int size, Long memberIdOrNull) {
return ProductSearchCommand.builder()
.brandId(brandId)
.keyword(keyword)
.sort(sort)
.page(page)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
package com.loopers.application.product;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import com.loopers.domain.common.vo.Money;
import lombok.Builder;

@Builder
@JsonDeserialize(builder = ProductSummaryInfo.ProductSummaryInfoBuilder.class)
public class ProductSummaryInfo {

private final Long id;
private final String name;
private final String brandName;
private final Money price;
private final int likeCount;
private final boolean isLikedByMember;

public Long getId() { return id; }
public String getName() { return name; }
public String getBrandName() { return brandName; }
public Money getPrice() { return price; }
public int getLikeCount() { return likeCount; }

@JsonProperty("likedByMember")
public boolean isLikedByMember() { return isLikedByMember; }

@JsonPOJOBuilder(withPrefix = "")
public static class ProductSummaryInfoBuilder {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class MemberCoupon {
private Long id;

@Column(nullable = false)
private String memberId;
private Long memberId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "coupon_id", nullable = false)
Expand All @@ -28,14 +28,14 @@ public class MemberCoupon {
@Column(nullable = false)
private boolean used;

private MemberCoupon(String memberId, Coupon coupon) {
private MemberCoupon(Long memberId, Coupon coupon) {
validate(memberId, coupon);
this.memberId = memberId;
this.coupon = coupon;
this.used = false;
}

public static MemberCoupon issue(String memberId, Coupon coupon) {
public static MemberCoupon issue(Long memberId, Coupon coupon) {
return new MemberCoupon(memberId, coupon);
}

Expand All @@ -54,7 +54,7 @@ public Money calculateDiscount(Money originalPrice) {
return coupon.calculateDiscount(originalPrice);
}

public void validateOwnership(String memberId) {
public void validateOwnership(Long memberId) {
if (!this.memberId.equals(memberId)) {
throw new CoreException(ErrorType.BAD_REQUEST, "본인의 쿠폰만 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.");
}
Expand All @@ -66,8 +66,8 @@ public void validateUsable() {
}
}

private void validate(String memberId, Coupon coupon) {
if (memberId == null || memberId.isBlank()) {
private void validate(Long memberId, Coupon coupon) {
if (memberId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.");
}
if (coupon == null) {
Expand Down
Loading