Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ public void addFavorite(String loginId, String password, Long productId) {
Member member = memberService.findMember(loginId, password);
Product product = productService.findProduct(productId);
FavoriteCommand.Add command = new FavoriteCommand.Add(member.getId(), product.getId());
favoriteService.addFavorite(command);
boolean added = favoriteService.addFavorite(command);
if (added) {
productService.increaseLikeCount(product.getId());
}
Comment on lines +27 to +30
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# ProductService의 increaseLikeCount 구현 확인 - DB와 Redis 업데이트 순서 및 에러 처리
ast-grep --pattern $'void increaseLikeCount($_) {
  $$$
}'

# 또는 메서드 내용 확인
rg -n "increaseLikeCount" --type=java -A 15 apps/commerce-api/src/main/java/com/loopers/domain/product/service/

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

Length of output: 2249


🏁 Script executed:

# FavoriteFacade 전체 메서드 구조 확인
rg -n "addFavorite|deleteFavorite|@Transactional" --type=java apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java -B 2 -A 8

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

Length of output: 1420


🏁 Script executed:

# ProductService 클래스 구조 및 `@Transactional` 확인
rg -n "class ProductService|@Transactional" --type=java apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java -B 1 -A 1

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

Length of output: 135


🏁 Script executed:

# ProductCacheRepository의 예외 처리 방식 확인
fd "ProductCacheRepository" --type=java -exec cat {} \;

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

Length of output: 256


🏁 Script executed:

# ProductService의 `@Transactional` 확인
rg -n "@Transactional|public void increase|public void decrease" --type=java apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java -B 1 -A 3

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

Length of output: 644


🏁 Script executed:

# ProductCacheRepository 구현 찾기
find apps/commerce-api/src/main/java -name "*CacheRepository*" -type f

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

Length of output: 287


🏁 Script executed:

# 캐시 실패 시 예외 처리 확인
rg -n "try|catch|exception|throw" --type=java apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java

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

Length of output: 242


🏁 Script executed:

# ProductCacheRepository 인터페이스 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java

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

Length of output: 1033


🏁 Script executed:

# ProductCacheRepositoryImpl 구현 확인
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java

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

Length of output: 3902


🏁 Script executed:

# RedisCacheManager 찾기 및 increment/decrement 메서드 확인
find apps/commerce-api/src/main/java -name "RedisCacheManager.java" -type f

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

Length of output: 165


🏁 Script executed:

# increment/decrement 메서드 구현 확인
rg -n "increment|decrement" apps/commerce-api/src/main/java -A 5 -B 1 | head -100

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

Length of output: 6103


Redis 캐시 실패로 인한 DB-캐시 불일치 처리 필요

productService.increaseLikeCount에서 DB 업데이트는 트랜잭션 범위 내에서 성공하지만, Redis 캐시 업데이트가 실패하면 예외 대신 경고만 로깅하고 계속 진행된다. 이로 인해 DB에는 좋아요 수가 증가했으나 캐시는 이전 값으로 유지되어 조회 시 부정확한 데이터를 반환하는 운영 문제가 발생한다.

개선 방안:

  • 캐시 실패를 비즈니스 로직 레벨에서 감지하고 처리 또는 재시도 로직 추가
  • 또는 Redis 장애 시에도 DB를 신뢰할 수 있도록 캐시 동기화 배치 작업 구현
  • 모니터링: 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/favorite/FavoriteFacade.java`
around lines 27 - 30, The current flow in FavoriteFacade (after calling
favoriteService.addFavorite(command)) calls
productService.increaseLikeCount(product.getId()) but swallows Redis cache
failures inside that call; change the logic so FavoriteFacade detects
cache-update failures from productService.increaseLikeCount (e.g., propagate a
specific exception or return a result/status), and handle it by either
triggering a retry path or marking the operation as partially failed and
enqueuing a background/sync job; specifically update
productService.increaseLikeCount to surface cache errors (throw
CacheUpdateException or return a boolean/status), then in FavoriteFacade check
that result and on failure: (a) attempt a limited retry, or (b) record an async
reconciliation task (or flag) to resync cache and emit an alert/event so the DB
remains authoritative until cache is reconciled.

}

@Transactional(rollbackFor = {Exception.class})
Expand All @@ -33,5 +36,6 @@ public void deleteFavorite(String loginId, String password, Long productId) {
Product product = productService.findProduct(productId);
FavoriteCommand.Delete command = new FavoriteCommand.Delete(member.getId(), product.getId());
favoriteService.delete(command);
productService.decreaseLikeCount(product.getId());
}
}
Original file line number Diff line number Diff line change
@@ -1,50 +1,54 @@
package com.loopers.application.product;

import com.loopers.application.product.dto.FindProductListReqDto;
import com.loopers.application.product.dto.FindProductListResDto;
import com.loopers.application.product.dto.FindProductResDto;
import com.loopers.domain.brand.model.Brand;
import com.loopers.domain.brand.service.BrandService;
import com.loopers.domain.favorite.service.FavoriteService;
import com.loopers.domain.member.model.Member;
import com.loopers.domain.member.service.MemberService;
import com.loopers.domain.product.model.Product;
import com.loopers.domain.product.model.ProductItem;
import com.loopers.domain.product.service.ProductService;
import com.loopers.support.enums.SortFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;

@RequiredArgsConstructor
@Component
@Transactional(readOnly = true)
public class ProductFacade {

private final ProductService productService;
private final BrandService brandService;
private final FavoriteService favoriteService;
private final MemberService memberService;
private final FavoriteService favoriteService;

public Page<FindProductListResDto> findProductList(String loginId, String password, Long brandId, SortFilter sortFilter, Pageable pageable) {
if (brandId != null) {
brandService.findBrand(brandId);
public Page<FindProductListResDto> findProductList(FindProductListReqDto req, Pageable pageable) {
if (req.brandId() != null) {
brandService.findBrand(req.brandId());
}
Long memberId = resolveMemberId(loginId, password);
Page<ProductItem> items = productService.findProductList(brandId, memberId, sortFilter, pageable);
return items.map(FindProductListResDto::from);
Long memberId = resolveMemberId(req.loginId(), req.password());

Page<ProductItem> products = productService.findProductList(req.brandId(), req.sortFilter(), pageable);
List<Long> productIds = products.getContent().stream().map(ProductItem::id).toList();
Set<Long> favoriteIds = favoriteService.getFavoriteProductIds(memberId, productIds);

return products.map(item -> FindProductListResDto.from(
item.withIsFavorite(favoriteIds.contains(item.id()))
));
}

public FindProductResDto findProduct(String loginId, String password, Long productId) {
Product product = productService.findProduct(productId);
Brand brand = brandService.findBrand(product.getBrandId());

long favoriteCnt = favoriteService.countByProductId(productId);
Long memberId = resolveMemberId(loginId, password);
boolean isFavorite = memberId != null && favoriteService.existsByMemberIdAndProductId(memberId, productId);

return FindProductResDto.of(product, brand, favoriteCnt, isFavorite);
ProductItem item = productService.findProductDetail(productId);
boolean isFavorite = memberId != null
&& favoriteService.existsByMemberIdAndProductId(memberId, productId);
return FindProductResDto.from(item.withIsFavorite(isFavorite));
}

private Long resolveMemberId(String loginId, String password) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.loopers.application.product.dto;

import com.loopers.support.enums.SortFilter;

public record FindProductListReqDto(
String loginId,
String password,
Long brandId,
SortFilter sortFilter
Comment on lines +5 to +9
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

🌐 Web query:

In Java records, does the compiler-generated toString() include all record component values by default?

💡 Result:

Yes. If you don’t declare your own toString(), the Java compiler synthesizes one for a record that includes the record’s name and the string representation of all record components (with their names) by default. (docs.oracle.com)

Citations:


Record에 평문 비밀번호를 포함하면 안 된다.

Java record는 컴파일러가 자동으로 모든 컴포넌트를 포함하는 toString()을 생성한다. 따라서 이 DTO가 로그, 예외 스택 트레이스, AOP 인터셉터를 거치면 비밀번호가 평문으로 기록된다. 운영 환경에서 로그에 수집된 자격 증명은 회수 불가능하며 보안 사고 범위를 키운다.

개선 방안:

  • 인증 로직을 컨트롤러/보안 계층에서 처리하고, 여기에는 memberId나 인증된 principal만 전달한다.
  • 임시 조치로 toString() 메서드를 override하여 민감 정보를 마스킹한다.

추가 검증:

  • toString() 호출 결과에 비밀번호가 포함되지 않음을 테스트한다.
  • 비밀번호를 제거한 상태에서 상품 목록 조회가 정상 동작함을 통합 테스트로 검증한다.
🤖 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/product/dto/FindProductListReqDto.java`
around lines 5 - 9, FindProductListReqDto currently holds a plain-text password
field which will be included in the compiler-generated toString(); remove
sensitive data by eliminating the password component from the record signature
(pass only an authenticated memberId/principal into controllers) or, as a
short-term mitigation, override FindProductListReqDto#toString() to mask or omit
the password field so it never appears in logs; update callers of
FindProductListReqDto to stop supplying raw credentials and add tests asserting
toString() does not contain the password and that product listing still works
when password is removed.

) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.loopers.application.product.dto;

import com.loopers.domain.brand.model.Brand;
import com.loopers.domain.product.model.Product;
import com.loopers.domain.product.model.ProductItem;

public record FindProductResDto(
Long id,
Expand All @@ -14,17 +13,17 @@ public record FindProductResDto(
long favoriteCnt,
boolean isFavorite
) {
public static FindProductResDto of(Product product, Brand brand, long favoriteCnt, boolean isFavorite) {
public static FindProductResDto from(ProductItem item) {
return new FindProductResDto(
product.getId(),
product.getName().value(),
brand.getId(),
brand.getName().value(),
product.getPrice().value(),
product.getStock().value(),
product.getDisplayStatus().name(),
favoriteCnt,
isFavorite
item.id(),
item.name(),
item.brandId(),
item.brandName(),
item.price(),
item.stock(),
item.displayStatus(),
item.favoriteCnt(),
item.isFavorite()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.loopers.domain.favorite.model.Favorite;

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

public interface FavoriteRepository {
Expand All @@ -10,6 +11,8 @@ public interface FavoriteRepository {

Optional<Favorite> findByMemberIdAndProductId(Long memberId, Long productId);

List<Favorite> findByMemberIdAndProductIds(Long memberId, List<Long> productIds);

void delete(Favorite favorite);

boolean existsByMemberIdAndProductId(Long memberId, Long productId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,33 @@
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Component
public class FavoriteService {

private final FavoriteRepository favoriteRepository;

public void addFavorite(FavoriteCommand.Add command) {
public boolean addFavorite(FavoriteCommand.Add command) {
if (favoriteRepository.existsByMemberIdAndProductId(command.memberId(), command.productId())) {
return;
return false;
}
try {
Favorite favorite = Favorite.create(command.memberId(), command.productId());
favoriteRepository.save(favorite);
return true;
} catch (DataIntegrityViolationException e) {
// 동시 요청으로 중복 등록 시도 — 이미 등록된 것이므로 무시
if (e.getCause() instanceof ConstraintViolationException) {
return false;
}
throw e;
}
}

Expand All @@ -40,4 +49,14 @@ public boolean existsByMemberIdAndProductId(Long memberId, Long productId) {
public long countByProductId(Long productId) {
return favoriteRepository.countByProductId(productId);
}

public Set<Long> getFavoriteProductIds(Long memberId, List<Long> productIds) {
if (memberId == null || productIds == null || productIds.isEmpty()) {
return Set.of();
}
return favoriteRepository.findByMemberIdAndProductIds(memberId, productIds)
.stream()
.map(Favorite::getProductId)
.collect(Collectors.toSet());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ public class Product {
private Money price;
private Stock stock;
private DisplayStatus displayStatus;
private long likeCount;

private Product(Long brandId, ProductName name, Money price, Stock stock, DisplayStatus displayStatus) {
this.brandId = brandId;
this.name = name;
this.price = price;
this.stock = stock;
this.displayStatus = displayStatus;
this.likeCount = 0;
}

public static Product create(Long brandId, ProductCommand.Create command) {
Expand All @@ -34,7 +36,7 @@ public static Product create(Long brandId, ProductCommand.Create command) {
);
}

public static Product reconstruct(Long id, Long brandId, String name, int price, int stock, DisplayStatus displayStatus) {
public static Product reconstruct(Long id, Long brandId, String name, int price, int stock, DisplayStatus displayStatus, long likeCount) {
Product product = new Product(
brandId,
new ProductName(name),
Expand All @@ -43,6 +45,7 @@ public static Product reconstruct(Long id, Long brandId, String name, int price,
displayStatus
);
product.id = id;
product.likeCount = likeCount;
return product;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,12 @@ public record ProductItem(
String displayStatus,
long favoriteCnt,
boolean isFavorite
) {}
) {
public ProductItem withIsFavorite(boolean isFavorite) {
return new ProductItem(id, name, brandId, brandName, price, stock, displayStatus, favoriteCnt, isFavorite);
}

public ProductItem withFavoriteCnt(long favoriteCnt) {
return new ProductItem(id, name, brandId, brandName, price, stock, displayStatus, favoriteCnt, isFavorite);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.loopers.domain.product.repository;

import com.loopers.domain.product.model.ProductItem;

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

public interface ProductCacheRepository {

Optional<ProductItem> get(Long productId);

void put(Long productId, ProductItem item);

void evict(Long productId);

record CachedPage(List<ProductItem> items, long totalElements) {}

Optional<CachedPage> getFirstPage();

void putFirstPage(List<ProductItem> items, long totalElements);

void evictFirstPage();

Optional<Long> getLikeCount(Long productId);

void initLikeCountIfAbsent(Long productId, long count);

void incrementLikeCount(Long productId);

void decrementLikeCount(Long productId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.loopers.domain.product.repository;

import com.loopers.domain.product.model.ProductItem;
import com.loopers.domain.product.model.ProductItem;
import com.loopers.support.enums.SortFilter;
import org.springframework.data.domain.Page;
Expand All @@ -10,6 +9,6 @@

public interface ProductCustomRepository {

Page<ProductItem> findProductList(Long brandId, Long memberId, SortFilter sortFilter, Pageable pageable);
Optional<ProductItem> findProduct(Long productId, Long memberId);
Page<ProductItem> findProductList(Long brandId, SortFilter sortFilter, Pageable pageable);
Optional<ProductItem> findProduct(Long productId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ public interface ProductRepository {
List<Product> findByIds(List<Long> ids);

int decreaseStock(Long id, int quantity);

int increaseLikeCount(Long id);

int decreaseLikeCount(Long id);
}
Loading