Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3000dec
refactor: UUID BINARY(16) 식별자 체계로 전환
shAn-kor Mar 3, 2026
4a4a437
feat: 쿠폰 어드민 API와 주문 유즈케이스를 통합한다
shAn-kor Mar 6, 2026
8a32fad
test: Testcontainers 설정을 ImportTestcontainers 패턴으로 정리
shAn-kor Mar 3, 2026
f78e15f
test: 상품 좋아요 동시성 테스트 추가
shAn-kor Mar 3, 2026
0b1864c
test: 상품 재고 차감 동시성 테스트 추가
shAn-kor Mar 4, 2026
03e89b5
test: 쿠폰 도메인 단위 테스트 추가
shAn-kor Mar 5, 2026
ac27e5c
test: 쿠폰 애플리케이션·컨트롤러 통합 테스트 추가
shAn-kor Mar 5, 2026
c9bb813
test: 쿠폰 API E2E 테스트 추가
shAn-kor Mar 5, 2026
a296cc2
test: 쿠폰 발급·사용 동시성 테스트 추가
shAn-kor Mar 5, 2026
90d00f3
test: 쿠폰·주문 취소 통합 시나리오를 보강한다
shAn-kor Mar 6, 2026
fef0a3d
test: application 서비스 테스트를 통합 테스트로 분리한다
shAn-kor Mar 6, 2026
a3a0acb
docs: 주문·쿠폰 설계문서와 체크리스트 정합성 업데이트
shAn-kor Mar 5, 2026
2f63f17
test: 재고 낙관락 재시도 지표를 업데이트한다
shAn-kor Mar 6, 2026
167ba80
docs: 재고 차감 동시성 ADR을 추가한다
shAn-kor Mar 6, 2026
26b6287
fix: 원자적 재고/좋아요 업데이트 경로와 주문 컨트롤러 의존성을 정합화한다
shAn-kor Mar 6, 2026
a37e965
fix: 주문-쿠폰 연계 계약을 memberId 기반으로 정합화한다
shAn-kor Mar 6, 2026
4b5ddcb
test: UUID/memberId 모델 변경에 맞춰 동시성·도메인 테스트를 정합화한다
shAn-kor Mar 6, 2026
4eb2a24
refactor: AutoIncrementBaseEntity를 추가한다
shAn-kor Mar 12, 2026
5fc8ded
refactor: 카테고리 영속 계층을 referenceId 기반으로 전환한다
shAn-kor Mar 12, 2026
c78d9d4
refactor: 브랜드 영속 계층을 referenceId 기반으로 전환한다
shAn-kor Mar 12, 2026
1f80521
refactor: 상품 영속 계층을 referenceId 기반으로 전환한다
shAn-kor Mar 12, 2026
04266a3
chore: 로컬 컨테이너 DB 시드 구성을 정리한다
shAn-kor Mar 12, 2026
5f8906f
docs: 식별자 구조 변경을 설계 문서에 반영한다
shAn-kor Mar 12, 2026
002f8b0
refactor: 브랜드 캐시 조회 포트를 application 계층으로 이동한다
shAn-kor Mar 12, 2026
6cffeac
refactor: 카테고리 캐시 조회 포트를 application 계층으로 이동한다
shAn-kor Mar 12, 2026
1e6a225
refactor: 상품 생성 검증에서 브랜드·카테고리 캐시를 사용한다
shAn-kor Mar 12, 2026
86db778
feat: 브랜드·카테고리 캐시 동기화와 실패 적재 구조를 추가한다
shAn-kor Mar 12, 2026
7522cdf
refactor: 캐시 조회를 위한 저장소와 시드 경로를 보강한다
shAn-kor Mar 12, 2026
3fa9023
test: 브랜드·카테고리 캐시 경로와 동기화를 검증한다
shAn-kor Mar 12, 2026
d0ba04c
refactor: 상품 목록 조회를 specification 기반으로 전환한다
shAn-kor Mar 12, 2026
fb0c644
refactor: 상품 참조 스키마를 uuid 기준으로 정리한다
shAn-kor Mar 12, 2026
cb9303c
test: 상품 목록 조회 테스트와 불필요 코드를 정리한다
shAn-kor Mar 12, 2026
b8600c9
feat: 유저 상품 목록 응답과 삭제 조건 오류를 분리한다
shAn-kor Mar 12, 2026
558c5b4
fix: 상품 시드 적재 포맷 호환성을 복구한다
shAn-kor Mar 12, 2026
012161b
docs: 상품 검색 인덱스와 페이징 ADR을 분리한다
shAn-kor Mar 12, 2026
2e0afb1
refactor: 상품 검색 인덱스 DDL을 운영 결론에 맞게 정리한다
shAn-kor Mar 12, 2026
d00788e
feat: 상품 목록 커서 페이징 모델을 추가한다
shAn-kor Mar 12, 2026
c273c5e
feat: 유저 상품 목록 조회를 커서 페이징으로 전환한다
shAn-kor Mar 12, 2026
b36f5a0
test: 상품 목록 페이징 계약 변경을 검증한다
shAn-kor Mar 12, 2026
c510d0f
refactor: 브랜드·카테고리 캐시 토글 경로를 정리한다
shAn-kor Mar 13, 2026
1e93812
feat: 상품 상세 Redis 캐시를 적용한다
shAn-kor Mar 13, 2026
17da468
chore: 로컬 인프라 시드 경로를 보강한다
shAn-kor Mar 13, 2026
2c7bc0c
docs: 상품 캐시와 브랜드·카테고리 캐시 ADR을 정리한다
shAn-kor Mar 13, 2026
e5f0299
docs: 상품 페이징 ADR을 보완한다
shAn-kor Mar 13, 2026
d5a8bbd
Merge remote-tracking branch 'upstream/shAn-kor' into shAn-kor
shAn-kor Mar 13, 2026
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
1 change: 1 addition & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies {

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("org.springframework.security:spring-security-crypto")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,33 @@

import com.loopers.application.brand.command.CreateBrandCommand;
import com.loopers.application.brand.command.UpdateBrandCommand;
import com.loopers.application.brand.BrandCacheRepository;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.infrastructure.brand.redis.BrandCacheSyncer;
import com.loopers.domain.brand.vo.BrandName;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class BrandApplicationService {

private final BrandRepository brandRepository;
private final BrandCacheRepository brandCacheRepository;
private final BrandCacheSyncer brandCacheSyncer;

@Transactional
public Brand create(CreateBrandCommand command) {
Expand All @@ -36,33 +41,34 @@ public Brand create(CreateBrandCommand command) {
Brand brand = new Brand(brandName, command.description(), command.imageUrl());

try {
return brandRepository.save(brand);
Brand saved = brandRepository.save(brand);
brandCacheSyncer.registerUpsert(saved);
return saved;
} catch (DataIntegrityViolationException e) {
throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다.");
}
}

@Transactional(readOnly = true)
public Brand findById(UUID id) {
return brandRepository.findById(id)
return brandCacheRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
}

@Transactional(readOnly = true)
public Page<Brand> list(Pageable pageable) {
return brandRepository.findAll(pageable);
List<Brand> brands = brandCacheRepository.findAll();
if (pageable.isUnpaged()) {
return new PageImpl<>(brands, pageable, brands.size());
}
int start = Math.min((int) pageable.getOffset(), brands.size());
int end = Math.min(start + pageable.getPageSize(), brands.size());
return new PageImpl<>(brands.subList(start, end), pageable, brands.size());
}

@Transactional(readOnly = true)
public Map<UUID, String> findNamesByIds(Collection<UUID> brandIds) {
return brandIds.stream()
.distinct()
.collect(Collectors.toMap(
brandId -> brandId,
brandId -> brandRepository.findById(brandId)
.map(brand -> brand.name().value())
.orElse(null)
));
return brandCacheRepository.findNamesByIds(brandIds);
}

@Transactional
Expand All @@ -71,13 +77,16 @@ public Brand update(UUID id, UpdateBrandCommand command) {
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));

Brand updated = brand.update(command.description(), command.imageUrl());
return brandRepository.save(updated);
Brand saved = brandRepository.save(updated);
brandCacheSyncer.registerUpsert(saved);
return saved;
}

@Transactional
public void delete(UUID id) {
Brand brand = brandRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
brandRepository.delete(brand);
brandCacheSyncer.registerDelete(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.loopers.application.brand;

import com.loopers.domain.brand.Brand;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

public interface BrandCacheRepository {
Optional<Brand> findById(UUID id);

List<Brand> findAll();

Map<UUID, String> findNamesByIds(Collection<UUID> brandIds);

boolean existsById(UUID id);

void save(Brand brand);

void delete(UUID id);
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
package com.loopers.application.coupon.category;

import com.loopers.application.coupon.category.CategoryCacheRepository;
import com.loopers.domain.category.Category;
import com.loopers.domain.category.CategoryRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class CategoryApplicationService {
private final CategoryRepository categoryRepository;
private final CategoryCacheRepository categoryCacheRepository;

public Category findById(UUID categoryId) {
return categoryRepository.findById(categoryId)
return categoryCacheRepository.findById(categoryId)
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "카테고리를 찾을 수 없습니다."));
}

public Page<Category> list(Pageable pageable) {
return categoryRepository.findAll(pageable);
List<Category> categories = categoryCacheRepository.findAll();
if (pageable.isUnpaged()) {
return new PageImpl<>(categories, pageable, categories.size());
}
int start = Math.min((int) pageable.getOffset(), categories.size());
int end = Math.min(start + pageable.getPageSize(), categories.size());
return new PageImpl<>(categories.subList(start, end), pageable, categories.size());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.loopers.application.coupon.category;

import com.loopers.domain.category.Category;

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

public interface CategoryCacheRepository {
Optional<Category> findById(UUID id);

List<Category> findAll();

boolean existsById(UUID id);

void save(Category category);

void delete(UUID id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
import com.loopers.domain.member.PasswordEncoder;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberRepository;
import com.loopers.domain.member.vo.MemberId;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;

@RequiredArgsConstructor
@Service
Expand All @@ -30,14 +28,4 @@ public Member authenticate(AuthenticateCommand command) {

return member;
}

public UUID findDbIdByMemberId(MemberId memberId) {
return memberRepository.findDbIdByMemberId(memberId)
.orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "회원을 찾을 수 없습니다."));
}

public UUID findDbIdByMember(Member member) {
return memberRepository.findDbIdByMemberId(member.id())
.orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "회원을 찾을 수 없습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package com.loopers.application.product;

import com.loopers.application.brand.BrandCacheRepository;
import com.loopers.application.coupon.category.CategoryCacheRepository;
import com.loopers.application.product.cache.EvictPublicProductDetailCache;
import com.loopers.application.product.command.CreateProductCommand;
import com.loopers.application.product.command.UpdateProductCommand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.domain.category.CategoryRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.product.query.ProductCursorPage;
import com.loopers.domain.product.query.ProductListCriteria;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -23,15 +24,15 @@
public class ProductApplicationService {

private final ProductRepository productRepository;
private final BrandRepository brandRepository;
private final CategoryRepository categoryRepository;
private final BrandCacheRepository brandCacheRepository;
private final CategoryCacheRepository categoryCacheRepository;

@Transactional
public Product create(CreateProductCommand command) {
if (brandRepository.findById(command.brandId()).isEmpty()) {
if (!brandCacheRepository.existsById(command.brandId())) {
throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않거나 삭제된 브랜드입니다.");
}
if (categoryRepository.findById(command.categoryId()).isEmpty()) {
if (!categoryCacheRepository.existsById(command.categoryId())) {
throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않거나 삭제된 카테고리입니다.");
}

Expand All @@ -53,13 +54,8 @@ public Product get(UUID productId) {
}

@Transactional(readOnly = true)
public Page<Product> list(UUID brandId, Pageable pageable) {
return productRepository.findAll(brandId, pageable);
}

@Transactional(readOnly = true)
public Page<Product> list(ProductListCriteria criteria) {
return productRepository.findAll(criteria.brandId(), criteria.toPageable());
public ProductCursorPage listByCursor(ProductListCriteria criteria) {
return productRepository.searchByCursor(criteria);
}

@Transactional(readOnly = true)
Expand All @@ -68,14 +64,9 @@ public Product getIncludingDeleted(UUID productId) {
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
}

@Transactional(readOnly = true)
public Page<Product> listIncludingDeleted(UUID brandId, Pageable pageable) {
return productRepository.findAllIncludingDeleted(brandId, pageable);
}

@Transactional(readOnly = true)
public Page<Product> listIncludingDeleted(ProductListCriteria criteria) {
return productRepository.findAllIncludingDeleted(criteria.brandId(), criteria.toPageable());
return productRepository.findAllIncludingDeleted(criteria);
}

@Transactional(readOnly = true)
Expand All @@ -89,6 +80,7 @@ public void deleteSoftByBrandId(UUID brandId) {
}

@Transactional
@EvictPublicProductDetailCache
public Product update(UUID productId, UpdateProductCommand command) {
Product existing = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
Expand All @@ -97,7 +89,7 @@ public Product update(UUID productId, UpdateProductCommand command) {
throw new CoreException(ErrorType.BAD_REQUEST, "브랜드는 수정할 수 없습니다.");
}

if (categoryRepository.findById(command.categoryId()).isEmpty()) {
if (!categoryCacheRepository.existsById(command.categoryId())) {
throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않거나 삭제된 카테고리입니다.");
}

Expand All @@ -116,6 +108,7 @@ public Product update(UUID productId, UpdateProductCommand command) {
}

@Transactional
@EvictPublicProductDetailCache
public void deleteSoft(UUID productId) {
Product existing = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.application.product;

import com.loopers.application.product.cache.EvictPublicProductDetailCache;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.support.error.CoreException;
Expand Down Expand Up @@ -40,6 +41,7 @@ public void validateCancelable(UUID productId) {
}

@Transactional
@EvictPublicProductDetailCache
public void increaseLikeCount(UUID productId) {
int updatedCount = productRepository.updateLikeCount(productId, 1);
if (updatedCount == 0) {
Expand All @@ -48,6 +50,7 @@ public void increaseLikeCount(UUID productId) {
}

@Transactional
@EvictPublicProductDetailCache
public void decreaseLikeCount(UUID productId) {
int updatedCount = productRepository.updateLikeCount(productId, -1);
if (updatedCount == 0) {
Expand All @@ -56,6 +59,7 @@ public void decreaseLikeCount(UUID productId) {
}

@Transactional
@EvictPublicProductDetailCache
public void decreaseLikeCountIfPresent(UUID productId) {
productRepository.updateLikeCount(productId, -1);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.loopers.application.product;

import com.loopers.application.brand.BrandApplicationService;
import com.loopers.application.product.cache.CachedPublicProductDetail;
import com.loopers.application.product.view.ProductListView;
import com.loopers.application.product.view.ProductView;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.query.ProductListCriteria;
import com.loopers.domain.product.query.ProductListQuery;
import lombok.RequiredArgsConstructor;
Expand All @@ -21,8 +21,8 @@ public class ProductQueryFacade {

private final ProductApplicationService productApplicationService;
private final BrandApplicationService brandApplicationService;
private final ProductService productService;

@CachedPublicProductDetail
public ProductView get(UUID productId) {
Product product = productApplicationService.get(productId);
Map<UUID, String> brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId()));
Expand All @@ -35,26 +35,8 @@ public ProductView getIncludingDeleted(UUID productId) {
return ProductView.from(product, brandNames.get(product.brandId()));
}

public ProductListView list(ProductListQuery query) {
ProductListCriteria criteria = productService.toCriteria(query);
Page<Product> products = productApplicationService.list(criteria);
Map<UUID, String> brandNames = brandApplicationService.findNamesByIds(
products.getContent().stream().map(Product::brandId).toList()
);
List<ProductView> items = products.getContent().stream()
.map(product -> ProductView.from(product, brandNames.get(product.brandId())))
.toList();
return new ProductListView(
items,
products.getNumber(),
products.getSize(),
products.getTotalElements(),
products.getTotalPages()
);
}

public ProductListView listIncludingDeleted(ProductListQuery query) {
ProductListCriteria criteria = productService.toCriteria(query);
ProductListCriteria criteria = ProductListCriteria.fromAdmin(query);
Page<Product> products = productApplicationService.listIncludingDeleted(criteria);
Map<UUID, String> brandNames = brandApplicationService.findNamesByIds(
products.getContent().stream().map(Product::brandId).toList()
Expand Down
Loading