diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 83b7e14dc..000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index df3d44c1a..ae37b1d34 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,8 @@ out/ ### Kotlin ### .kotlin +### OS ### +.DS_Store + /.claude CLAUDE.md \ No newline at end of file diff --git a/application/commerce-service/build.gradle.kts b/application/commerce-service/build.gradle.kts new file mode 100644 index 000000000..bd8f34a31 --- /dev/null +++ b/application/commerce-service/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":domain")) + + // @Service, @Transactional + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-context") + + testImplementation(testFixtures(project(":domain"))) +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java new file mode 100644 index 000000000..552a9ad62 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java @@ -0,0 +1,17 @@ +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ExampleFacade { + private final ExampleService exampleService; + + public ExampleInfo getExample(Long id) { + ExampleModel example = exampleService.getExample(id); + return ExampleInfo.from(example); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java new file mode 100644 index 000000000..877aba96c --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; + +public record ExampleInfo(Long id, String name, String description) { + public static ExampleInfo from(ExampleModel model) { + return new ExampleInfo( + model.getId(), + model.getName(), + model.getDescription() + ); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java b/application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java new file mode 100644 index 000000000..02ac993d9 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java @@ -0,0 +1,76 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.BrandCreateCommand; +import com.loopers.application.service.dto.BrandInfo; +import com.loopers.application.service.dto.BrandUpdateCommand; +import com.loopers.domain.catalog.BrandDeleteService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +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.List; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + private final BrandDeleteService brandDeleteService; + + @Transactional + public void create(BrandCreateCommand command) { + if (brandRepository.existsByName(command.name())) { + throw new CoreException(ErrorType.CONFLICT, + BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + Brand brand = Brand.register(command.name()); + brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getById(Long id) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public List getAll() { + return brandRepository.findAll().stream() + .map(BrandInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getActiveBrands() { + return brandRepository.findAllByDeletedAtIsNull().stream() + .map(BrandInfo::from) + .toList(); + } + + @Transactional + public void update(Long id, BrandUpdateCommand command) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + if (brandRepository.existsByName(command.name())) { + throw new CoreException(ErrorType.CONFLICT, + BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + brand.updateName(command.name()); + } + + @Transactional + public void delete(Long id) { + brandDeleteService.delete(id); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java b/application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java new file mode 100644 index 000000000..d36a17261 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java @@ -0,0 +1,91 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.LikeRegisterCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.domain.catalog.ActiveProductService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeExceptionMessage; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeSubjectType; +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.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final ActiveProductService activeProductService; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + // 좋아요를 등록한다 + + @Transactional + public void like(LikeRegisterCommand command) { + Product product = activeProductService.get(command.productId()); + + if (likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId( + command.memberId(), LikeSubjectType.PRODUCT, command.productId())) { + throw new CoreException(ErrorType.BAD_REQUEST, + LikeExceptionMessage.Like.ALREADY_LIKED.message()); + } + + likeRepository.save(Like.mark(command.memberId(), LikeSubjectType.PRODUCT, command.productId())); + product.increaseLikesCount(); + } + + // 좋아요를 취소한다 + + @Transactional + public void unlike(Long memberId, Long productId) { + Like like = likeRepository.findByMemberIdAndSubjectTypeAndSubjectId( + memberId, LikeSubjectType.PRODUCT, productId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, + LikeExceptionMessage.Like.NOT_LIKED.message())); + + likeRepository.delete(like); + + productRepository.findById(productId) + .ifPresent(Product::decreaseLikesCount); + } + + // 내 좋아요 목록을 조회한다 + + @Transactional(readOnly = true) + public List getMyLikes(Long memberId) { + List likes = likeRepository.findByMemberIdAndSubjectType(memberId, LikeSubjectType.PRODUCT); + + List productIds = likes.stream() + .map(Like::getSubjectId) + .toList(); + + List activeProducts = productRepository.findAllByIdIn(productIds).stream() + .filter(product -> !product.isDeleted()) + .toList(); + + List brandIds = activeProducts.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + return activeProducts.stream() + .map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()))) + .toList(); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java b/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java new file mode 100644 index 000000000..4bba14675 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java @@ -0,0 +1,70 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncryptor; +import com.loopers.domain.member.vo.*; +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; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncryptor passwordEncryptor; + + @Transactional + public void register(MemberRegisterCommand request) { + boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId()); + + if (isLoginIdAlreadyExists) { + throw new CoreException(ErrorType.CONFLICT, MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + Member member = Member.register( + LoginId.of(request.loginId()), + Password.of(request.password(), request.birthdate(), passwordEncryptor), + MemberName.of(request.name()), + request.birthdate(), + Email.of(request.email()) + ); + memberRepository.save(member); + } + + @Transactional(readOnly = true) + public MemberInfo getMyInfo(String userId, String password) { + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + if (!member.matchesPassword(password, passwordEncryptor)) { + throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + return new MemberInfo( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } + + @Transactional + public void updatePassword(String userId, String currentPassword, String newPassword) { + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + if (!member.matchesPassword(currentPassword, passwordEncryptor)) { + throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + } + + member.updatePassword(newPassword, passwordEncryptor); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java b/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java new file mode 100644 index 000000000..21bdf0c7e --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java @@ -0,0 +1,196 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.OrderCreateCommand; +import com.loopers.application.service.dto.OrderInfo; +import com.loopers.application.service.dto.OrderLineInfo; +import com.loopers.application.service.dto.OrderLineRequest; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.order.*; +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.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderLineRepository orderLineRepository; + private final OrderLineSnapshotRepository orderLineSnapshotRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + @Transactional + public OrderInfo create(OrderCreateCommand command) { + List requests = command.orderLines(); + List productIds = extractProductIds(requests); + + Map productMap = findActiveProducts(productIds); + Map brandMap = findBrandMap(productMap); + + OrderStatus status = determineStatus(requests, productMap); + if (status == OrderStatus.ACCEPTED) { + decreaseStock(requests, productMap); + } + + List orderLines = createOrderLines(requests, productMap, brandMap); + Order savedOrder = orderRepository.save(Order.place(command.memberId(), orderLines, status)); + List savedLines = orderLineRepository.saveAll(savedOrder.assignOrderLines(orderLines)); + List snapshots = saveSnapshots(savedLines); + + return toOrderInfo(savedOrder, savedLines, snapshots); + } + + @Transactional(readOnly = true) + public OrderInfo getById(Long orderId, Long memberId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + OrderExceptionMessage.Order.NOT_FOUND.message())); + + if (!order.isOwnedBy(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, + OrderExceptionMessage.Order.NOT_OWNER.message()); + } + + return toOrderInfo(order); + } + + @Transactional(readOnly = true) + public List getByMemberId(Long memberId) { + return orderRepository.findByMemberId(memberId).stream() + .map(this::toOrderInfo) + .toList(); + } + + @Transactional(readOnly = true) + public List getAll() { + return orderRepository.findAll().stream() + .map(this::toOrderInfo) + .toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getByIdForAdmin(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + OrderExceptionMessage.Order.NOT_FOUND.message())); + + return toOrderInfo(order); + } + + private List extractProductIds(List requests) { + return requests.stream() + .map(OrderLineRequest::productId) + .distinct() + .sorted() + .toList(); + } + + private Map findBrandMap(Map productMap) { + List brandIds = productMap.values().stream() + .map(Product::getBrandId).distinct().toList(); + return brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + } + + private OrderStatus determineStatus(List requests, Map productMap) { + boolean allEnough = requests.stream() + .allMatch(req -> productMap.get(req.productId()) + .hasEnoughStock(Quantity.of(req.quantity()))); + return OrderStatus.determine(allEnough); + } + + private void decreaseStock(List requests, Map productMap) { + requests.forEach(req -> + productMap.get(req.productId()).decreaseStock(Quantity.of(req.quantity()))); + } + + private List createOrderLines(List requests, Map productMap, Map brandMap) { + return requests.stream() + .map(req -> { + Product product = productMap.get(req.productId()); + Brand brand = brandMap.get(product.getBrandId()); + return OrderLine.of( + req.productId(), Quantity.of(req.quantity()), + product.getName().getValue(), product.getDescription(), + product.getPrice().getValue(), + brand != null ? brand.getName().getValue() : null + ); + }) + .toList(); + } + + private List saveSnapshots(List savedLines) { + List snapshots = savedLines.stream() + .map(line -> line.assignSnapshot().getSnapshot()) + .toList(); + orderLineSnapshotRepository.saveAll(snapshots); + return snapshots; + } + + private Map findActiveProducts(List productIds) { + List products = productRepository.findAllByIdIn(productIds); + + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + products.forEach(product -> { + if (product.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + }); + + return products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + } + + private OrderInfo toOrderInfo(Order order) { + List lines = orderLineRepository.findByOrderId(order.getId()); + List lineIds = lines.stream().map(OrderLine::getId).toList(); + List snapshots = orderLineSnapshotRepository.findByOrderLineIdIn(lineIds); + return toOrderInfo(order, lines, snapshots); + } + + private OrderInfo toOrderInfo(Order order, List lines, List snapshots) { + Map snapshotMap = snapshots.stream() + .collect(Collectors.toMap(OrderLineSnapshot::getOrderLineId, Function.identity())); + + List lineInfos = lines.stream() + .map(line -> { + OrderLineSnapshot snapshot = snapshotMap.get(line.getId()); + return new OrderLineInfo( + line.getId(), + line.getProductId(), + line.getQuantity().getValue(), + snapshot != null ? snapshot.getProductName() : null, + snapshot != null ? snapshot.getProductDescription() : null, + snapshot != null ? snapshot.getPrice() : 0, + snapshot != null ? snapshot.getBrandName() : null + ); + }) + .toList(); + + return new OrderInfo( + order.getId(), + order.getMemberId(), + order.getStatus(), + order.getCreatedAt(), + lineInfos + ); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java b/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java new file mode 100644 index 000000000..8b2daf658 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java @@ -0,0 +1,115 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.ProductCreateCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.application.service.dto.ProductUpdateCommand; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +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.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + @Transactional + public void create(ProductCreateCommand command) { + Brand brand = brandRepository.findById(command.brandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + if (brand.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + + Product product = Product.register( + command.name(), + command.description(), + Money.of(command.price()), + Stock.of(command.stock()), + command.brandId() + ); + productRepository.save(product); + } + + @Transactional(readOnly = true) + public ProductInfo getById(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + return ProductInfo.from(product, brand); + } + + @Transactional(readOnly = true) + public List getAll() { + List products = productRepository.findAll(); + return toProductInfos(products); + } + + @Transactional(readOnly = true) + public List getActiveProducts(ProductSortType sortType) { + List products = productRepository.findAllActive(sortType); + return toProductInfos(products); + } + + @Transactional + public void update(Long id, ProductUpdateCommand command) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + product.update( + command.name(), + command.description(), + Money.of(command.price()), + Stock.of(command.stock()) + ); + } + + @Transactional + public void delete(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + product.delete(); + } + + private List toProductInfos(List products) { + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + return products.stream() + .map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()))) + .toList(); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java new file mode 100644 index 000000000..7cd1af049 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record BrandCreateCommand( + String name +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java new file mode 100644 index 000000000..a028ee6a9 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.catalog.brand.Brand; + +public record BrandInfo( + Long id, + String name +) { + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName().getValue()); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java new file mode 100644 index 000000000..e7a6b8546 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record BrandUpdateCommand( + String name +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java new file mode 100644 index 000000000..50eb074e2 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java @@ -0,0 +1,4 @@ +package com.loopers.application.service.dto; + +public record LikeRegisterCommand(Long memberId, Long productId) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java new file mode 100644 index 000000000..b3a8cf2fd --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java @@ -0,0 +1,16 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberName; + +import java.time.LocalDate; + +public record MemberInfo( + Long memberId, + LoginId loginId, + MemberName name, + LocalDate birthdate, + Email email +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberRegisterCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberRegisterCommand.java new file mode 100644 index 000000000..b3c27bc48 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberRegisterCommand.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +import java.time.LocalDate; + +public record MemberRegisterCommand( + String loginId, + String password, + String name, + LocalDate birthdate, + String email +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java new file mode 100644 index 000000000..68a299c64 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.service.dto; + +import java.util.List; + +public record OrderCreateCommand( + Long memberId, + List orderLines +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java new file mode 100644 index 000000000..3317c1db4 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java @@ -0,0 +1,18 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.order.OrderStatus; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long memberId, + OrderStatus status, + ZonedDateTime createdAt, + List orderLines +) { + public boolean isAccepted() { + return this.status == OrderStatus.ACCEPTED; + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java new file mode 100644 index 000000000..d1e1d7505 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +public record OrderLineInfo( + Long orderLineId, + Long productId, + long quantity, + String productName, + String productDescription, + long price, + String brandName +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java new file mode 100644 index 000000000..a53b78205 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java @@ -0,0 +1,7 @@ +package com.loopers.application.service.dto; + +public record OrderLineRequest( + Long productId, + long quantity +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/PasswordUpdateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/PasswordUpdateCommand.java new file mode 100644 index 000000000..a6e4fa71a --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/PasswordUpdateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record PasswordUpdateCommand( + String newPassword +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java new file mode 100644 index 000000000..5c285811f --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java @@ -0,0 +1,10 @@ +package com.loopers.application.service.dto; + +public record ProductCreateCommand( + String name, + String description, + long price, + long stock, + Long brandId +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java new file mode 100644 index 000000000..6465c3f3a --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.product.Product; + +public record ProductInfo( + Long id, + String name, + String description, + long price, + long stock, + long likesCount, + String brandName +) { + + public static ProductInfo from(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getName().getValue(), + product.getDescription(), + product.getPrice().getValue(), + product.getStock().getValue(), + product.getLikesCount(), + brand != null ? brand.getName().getValue() : null + ); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java new file mode 100644 index 000000000..add00cdd2 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.service.dto; + +public record ProductUpdateCommand( + String name, + String description, + long price, + long stock +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java new file mode 100644 index 000000000..f4442b476 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -0,0 +1,44 @@ +package com.loopers.domain.example; + +import com.loopers.domain.SoftDeletableEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "example") +public class ExampleModel extends SoftDeletableEntity { + + private String name; + private String description; + + protected ExampleModel() {} + + public ExampleModel(String name, String description) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (description == null || description.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + } + + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public void update(String newDescription) { + if (newDescription == null || newDescription.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + } + this.description = newDescription; + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java new file mode 100644 index 000000000..3625e5662 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.example; + +import java.util.Optional; + +public interface ExampleRepository { + Optional find(Long id); +} diff --git a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java new file mode 100644 index 000000000..c0e8431e8 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java @@ -0,0 +1,20 @@ +package com.loopers.domain.example; + +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; + +@RequiredArgsConstructor +@Component +public class ExampleService { + + private final ExampleRepository exampleRepository; + + @Transactional(readOnly = true) + public ExampleModel getExample(Long id) { + return exampleRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java new file mode 100644 index 000000000..3765c10dd --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java @@ -0,0 +1,171 @@ +package com.loopers.application; + +import com.loopers.application.service.BrandService; +import com.loopers.application.service.dto.BrandCreateCommand; +import com.loopers.application.service.dto.BrandInfo; +import com.loopers.application.service.dto.BrandUpdateCommand; +import com.loopers.domain.catalog.BrandDeleteService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @InjectMocks + private BrandService brandService; + + @Mock + private BrandRepository brandRepository; + + @Mock + private BrandDeleteService brandDeleteService; + + @Test + void 브랜드_생성_성공_시_저장된다() { + // given + BrandCreateCommand command = new BrandCreateCommand("나이키"); + given(brandRepository.existsByName("나이키")).willReturn(false); + + // when + brandService.create(command); + + // then + verify(brandRepository).save(any(Brand.class)); + } + + @Test + void 브랜드_생성_시_중복_이름이면_예외() { + // given + BrandCreateCommand command = new BrandCreateCommand("나이키"); + given(brandRepository.existsByName("나이키")).willReturn(true); + + // when & then + assertThatThrownBy(() -> brandService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + @Test + void 브랜드_단건_조회_성공() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + + // when + BrandInfo result = brandService.getById(brandId); + + // then + assertThat(result.name()).isEqualTo("나이키"); + } + + @Test + void 브랜드_단건_조회_시_존재하지_않으면_예외() { + // given + Long brandId = 999L; + given(brandRepository.findById(brandId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandService.getById(brandId)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } + + @Test + void 브랜드_전체_목록_조회() { + // given + List brands = List.of(Brand.register("나이키"), Brand.register("아디다스")); + given(brandRepository.findAll()).willReturn(brands); + + // when + List result = brandService.getAll(); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 활성_브랜드_목록_조회() { + // given + List brands = List.of(Brand.register("나이키"), Brand.register("아디다스")); + given(brandRepository.findAllByDeletedAtIsNull()).willReturn(brands); + + // when + List result = brandService.getActiveBrands(); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 브랜드_수정_시_존재하지_않으면_예외() { + // given + Long brandId = 999L; + BrandUpdateCommand command = new BrandUpdateCommand("아디다스"); + given(brandRepository.findById(brandId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandService.update(brandId, command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } + + @Test + void 브랜드_수정_시_다른_브랜드와_이름_중복이면_예외() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + BrandUpdateCommand command = new BrandUpdateCommand("아디다스"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + given(brandRepository.existsByName("아디다스")).willReturn(true); + + // when & then + assertThatThrownBy(() -> brandService.update(brandId, command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + @Test + void 브랜드_수정_성공() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + BrandUpdateCommand command = new BrandUpdateCommand("아디다스"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + given(brandRepository.existsByName("아디다스")).willReturn(false); + + // when + brandService.update(brandId, command); + + // then + assertThat(brand.hasName("아디다스")).isTrue(); + } + + @Test + void 브랜드_삭제_시_BrandDeleteService에_위임() { + // given + Long brandId = 1L; + + // when + brandService.delete(brandId); + + // then + verify(brandDeleteService).delete(brandId); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java new file mode 100644 index 000000000..00055299b --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java @@ -0,0 +1,192 @@ +package com.loopers.application; + +import com.loopers.application.service.LikeService; +import com.loopers.application.service.dto.LikeRegisterCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.domain.catalog.ActiveProductService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeExceptionMessage; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeSubjectType; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @Mock + private ActiveProductService activeProductService; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + // 좋아요를 등록한다 + + @Test + void 좋아요_등록_성공() { + // given + LikeRegisterCommand command = new LikeRegisterCommand(1L, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + given(activeProductService.get(100L)).willReturn(product); + given(likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(false); + + // when + likeService.like(command); + + // then + verify(likeRepository).save(any(Like.class)); + } + + @Test + void 좋아요_등록_시_likesCount_증가() { + // given + LikeRegisterCommand command = new LikeRegisterCommand(1L, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + given(activeProductService.get(100L)).willReturn(product); + given(likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(false); + + // when + likeService.like(command); + + // then + assertThat(product.hasLikesCount(1L)).isTrue(); + } + + @Test + void 이미_좋아요한_상품이면_예외() { + // given + LikeRegisterCommand command = new LikeRegisterCommand(1L, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + given(activeProductService.get(100L)).willReturn(product); + given(likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> likeService.like(command)) + .isInstanceOf(CoreException.class) + .hasMessage(LikeExceptionMessage.Like.ALREADY_LIKED.message()); + } + + // 좋아요를 취소한다 + + @Test + void 좋아요_취소_성공() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + product.increaseLikesCount(); + given(likeRepository.findByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(Optional.of(like)); + given(productRepository.findById(100L)).willReturn(Optional.of(product)); + + // when + likeService.unlike(1L, 100L); + + // then + verify(likeRepository).delete(like); + } + + @Test + void 좋아요_취소_시_likesCount_감소() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + product.increaseLikesCount(); + given(likeRepository.findByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(Optional.of(like)); + given(productRepository.findById(100L)).willReturn(Optional.of(product)); + + // when + likeService.unlike(1L, 100L); + + // then + assertThat(product.hasLikesCount(0L)).isTrue(); + } + + @Test + void 좋아요하지_않은_상품_취소_시_예외() { + // given + given(likeRepository.findByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> likeService.unlike(1L, 100L)) + .isInstanceOf(CoreException.class) + .hasMessage(LikeExceptionMessage.Like.NOT_LIKED.message()); + } + + // 내 좋아요 목록을 조회한다 + + @Test + void 내_좋아요_목록_조회_성공() { + // given + List likes = List.of( + Like.mark(1L, LikeSubjectType.PRODUCT, 100L), + Like.mark(1L, LikeSubjectType.PRODUCT, 200L) + ); + Product product1 = Product.register("에어맥스", "설명1", Money.of(100000), Stock.of(50), 10L); + Product product2 = Product.register("조던", "설명2", Money.of(200000), Stock.of(30), 10L); + Brand brand = Brand.register("나이키"); + given(likeRepository.findByMemberIdAndSubjectType(1L, LikeSubjectType.PRODUCT)).willReturn(likes); + given(productRepository.findAllByIdIn(List.of(100L, 200L))).willReturn(List.of(product1, product2)); + given(brandRepository.findAllByIdIn(List.of(10L))).willReturn(List.of(brand)); + + // when + List result = likeService.getMyLikes(1L); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 삭제된_상품은_목록에서_제외() { + // given + List likes = List.of( + Like.mark(1L, LikeSubjectType.PRODUCT, 100L), + Like.mark(1L, LikeSubjectType.PRODUCT, 200L) + ); + Product product1 = Product.register("에어맥스", "설명1", Money.of(100000), Stock.of(50), 10L); + Product product2 = Product.register("조던", "설명2", Money.of(200000), Stock.of(30), 10L); + product2.delete(); + Brand brand = Brand.register("나이키"); + given(likeRepository.findByMemberIdAndSubjectType(1L, LikeSubjectType.PRODUCT)).willReturn(likes); + given(productRepository.findAllByIdIn(List.of(100L, 200L))).willReturn(List.of(product1, product2)); + given(brandRepository.findAllByIdIn(List.of(10L))).willReturn(List.of(brand)); + + // when + List result = likeService.getMyLikes(1L); + + // then + assertThat(result).hasSize(1); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java new file mode 100644 index 000000000..a6d586423 --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java @@ -0,0 +1,126 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.domain.member.*; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberName; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.PasswordEncryptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + @Spy + private PasswordEncryptor passwordEncryptor = new FakePasswordEncryptor(); + + @Test + void 회원가입_시_아이디_중복_불가() { + // given + String inputId = "apape123"; + MemberRegisterCommand request = new MemberRegisterCommand( + inputId, "password123!", "공명선", LocalDate.of(2001, 2, 9), "gms72901217@gmail.com"); + when(memberRepository.existsByLoginId(inputId)).thenReturn(true); + + // when + + // then + assertThatThrownBy(() -> memberService.register(request)) + .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + @Test + void 회원가입_성공_시_저장된다() { + // given + String inputId = "newId123"; + MemberRegisterCommand request = new MemberRegisterCommand( + inputId, "password123!", "공명선", LocalDate.of(2001, 2, 9), "gms72901217@gmail.com"); + when(memberRepository.existsByLoginId(inputId)).thenReturn(false); + + // when + memberService.register(request); + + // then + verify(memberRepository).save(any(Member.class)); + } + + @Test + void 존재하지_않는_회원_조회_시_예외_발생() { + // given + String dummyId = "unknownId"; + String dummyPwd = "password123!"; + given(memberRepository.findByLoginId(dummyId)).willReturn(Optional.empty()); + + // when + + // then + assertThatThrownBy(() -> memberService.getMyInfo(dummyId, dummyPwd)) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + void 내_정보_조회_성공_loginId_반환() { + // given + String loginId = "apape123"; + String password = MemberFixture.DEFAULT_RAW_PASSWORD; + Member member = MemberFixture.create(LoginId.of(loginId)); + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(LoginId.of(loginId)); + } + + @Test + void 내_정보_조회_성공_name_반환() { + // given + String loginId = "apape123"; + String password = MemberFixture.DEFAULT_RAW_PASSWORD; + Member member = MemberFixture.create(LoginId.of(loginId)); + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.name()).isEqualTo(MemberName.of("홍길동")); + } + + @Test + void 현재_비밀번호가_틀리면_수정_불가() { + // given + String loginId = "tester12"; + String correctPassword = "correctPw1!"; + Member member = MemberFixture.create( + LoginId.of(loginId), + Password.of(correctPassword, MemberFixture.DEFAULT_BIRTH_DATE, new FakePasswordEncryptor()) + ); + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrongPassword1!", "newPass123!")) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java new file mode 100644 index 000000000..e7437362d --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java @@ -0,0 +1,290 @@ +package com.loopers.application; + +import com.loopers.application.service.OrderService; +import com.loopers.application.service.dto.OrderCreateCommand; +import com.loopers.application.service.dto.OrderInfo; +import com.loopers.application.service.dto.OrderLineRequest; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.domain.order.*; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @InjectMocks + private OrderService orderService; + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderLineRepository orderLineRepository; + + @Mock + private OrderLineSnapshotRepository orderLineSnapshotRepository; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + // 주문을 생성한다 + + @Test + void 주문_생성_성공_재고_충분하면_수락() { + // given + givenProductAndBrand(1L, "에어맥스", 50, 1L, "나이키"); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2) + )); + + // when + OrderInfo result = orderService.create(command); + + // then + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void 주문_생성_성공_재고_부족하면_거절() { + // given + givenProductAndBrand(1L, "에어맥스", 1, 1L, "나이키"); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 5) + )); + + // when + OrderInfo result = orderService.create(command); + + // then + assertThat(result.isAccepted()).isFalse(); + } + + @Test + void 주문_거절_시_재고_차감하지_않는다() { + // given + Product product = createProduct(1L, "에어맥스", 1, 1L); + Brand brand = createBrand(1L, "나이키"); + given(productRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(product)); + given(brandRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(brand)); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 5) + )); + + // when + orderService.create(command); + + // then + assertThat(product.hasEnoughStock(Quantity.of(1))).isTrue(); + } + + @Test + void 주문_수락_시_재고_차감된다() { + // given + Product product = createProduct(1L, "에어맥스", 50, 1L); + Brand brand = createBrand(1L, "나이키"); + given(productRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(product)); + given(brandRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(brand)); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2) + )); + + // when + orderService.create(command); + + // then + assertThat(product.hasEnoughStock(Quantity.of(49))).isFalse(); + } + + @Test + void 삭제된_상품_포함_시_예외() { + // given + Product product = createProduct(1L, "에어맥스", 50, 1L); + product.delete(); + given(productRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(product)); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2) + )); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + + @Test + void 존재하지_않는_상품_포함_시_예외() { + // given + given(productRepository.findAllByIdIn(List.of(999L))).willReturn(List.of()); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(999L, 2) + )); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + @Test + void 중복_상품_주문_시_예외() { + // given + givenProductAndBrand(1L, "에어맥스", 50, 1L, "나이키"); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2), + new OrderLineRequest(1L, 3) + )); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.DUPLICATE_PRODUCT.message()); + } + + @Test + void 빈_주문_시_예외() { + // given + OrderCreateCommand command = new OrderCreateCommand(10L, List.of()); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.EMPTY_ORDER_LINES.message()); + } + + // 내 주문 내역을 조회한다 + + @Test + void 내_주문_내역_조회() { + // given + Long memberId = 10L; + Order order = Order.place(memberId, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findByMemberId(memberId)).willReturn(List.of(order)); + given(orderLineRepository.findByOrderId(any())).willReturn(List.of()); + given(orderLineSnapshotRepository.findByOrderLineIdIn(any())).willReturn(List.of()); + + // when + List result = orderService.getByMemberId(memberId); + + // then + assertThat(result).hasSize(1); + } + + // 주문 상세를 조회한다 + + @Test + void 주문_상세_조회_본인() { + // given + Long orderId = 1L; + Long memberId = 10L; + Order order = Order.place(memberId, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(orderLineRepository.findByOrderId(any())).willReturn(List.of()); + given(orderLineSnapshotRepository.findByOrderLineIdIn(any())).willReturn(List.of()); + + // when + OrderInfo result = orderService.getById(orderId, memberId); + + // then + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void 주문_상세_조회_타인_예외() { + // given + Long orderId = 1L; + Order order = Order.place(10L, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> orderService.getById(orderId, 99L)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.NOT_OWNER.message()); + } + + @Test + void 존재하지_않는_주문_조회_시_예외() { + // given + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> orderService.getById(999L, 10L)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.NOT_FOUND.message()); + } + + // 전체 주문을 조회한다 (관리자) + + @Test + void 전체_주문_목록_조회() { + // given + Order order = Order.place(10L, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findAll()).willReturn(List.of(order)); + given(orderLineRepository.findByOrderId(any())).willReturn(List.of()); + given(orderLineSnapshotRepository.findByOrderLineIdIn(any())).willReturn(List.of()); + + // when + List result = orderService.getAll(); + + // then + assertThat(result).hasSize(1); + } + + private Product createProduct(Long id, String name, long stock, Long brandId) { + Product product = Product.register(name, "설명", Money.of(100000), Stock.of(stock), brandId); + ReflectionTestUtils.setField(product, "id", id); + return product; + } + + private Brand createBrand(Long id, String name) { + Brand brand = Brand.register(name); + ReflectionTestUtils.setField(brand, "id", id); + return brand; + } + + private void givenProductAndBrand(Long productId, String productName, long stock, Long brandId, String brandName) { + Product product = createProduct(productId, productName, stock, brandId); + Brand brand = createBrand(brandId, brandName); + given(productRepository.findAllByIdIn(List.of(productId))).willReturn(List.of(product)); + given(brandRepository.findAllByIdIn(List.of(brandId))).willReturn(List.of(brand)); + } + + private void givenOrderSave() { + given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(orderLineRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java new file mode 100644 index 000000000..f814f247f --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java @@ -0,0 +1,210 @@ +package com.loopers.application; + +import com.loopers.application.service.ProductService; +import com.loopers.application.service.dto.ProductCreateCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.application.service.dto.ProductUpdateCommand; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @InjectMocks + private ProductService productService; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + // 상품을 생성한다 + + @Test + void 상품_생성_성공_시_저장된다() { + // given + ProductCreateCommand command = new ProductCreateCommand("에어맥스", "설명", 100000, 50, 1L); + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + + // when + productService.create(command); + + // then + verify(productRepository).save(any(Product.class)); + } + + @Test + void 상품_생성_시_브랜드가_없으면_예외() { + // given + ProductCreateCommand command = new ProductCreateCommand("에어맥스", "설명", 100000, 50, 999L); + given(brandRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } + + @Test + void 상품_생성_시_삭제된_브랜드면_예외() { + // given + ProductCreateCommand command = new ProductCreateCommand("에어맥스", "설명", 100000, 50, 1L); + Brand brand = Brand.register("나이키"); + brand.delete(); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + + // when & then + assertThatThrownBy(() -> productService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + + // 상품을 상세 조회한다 + + @Test + void 상품_상세_조회_성공_브랜드명_포함() { + // given + Long productId = 1L; + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + Brand brand = Brand.register("나이키"); + given(productRepository.findById(productId)).willReturn(Optional.of(product)); + given(brandRepository.findById(10L)).willReturn(Optional.of(brand)); + + // when + ProductInfo result = productService.getById(productId); + + // then + assertThat(result.brandName()).isEqualTo("나이키"); + } + + @Test + void 상품_조회_시_존재하지_않으면_예외() { + // given + Long productId = 999L; + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.getById(productId)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + // 전체 상품을 조회한다 + + @Test + void 전체_목록_조회() { + // given + List products = List.of( + Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L) + ); + given(productRepository.findAll()).willReturn(products); + given(brandRepository.findAllByIdIn(List.of(1L))).willReturn(List.of()); + + // when + List result = productService.getAll(); + + // then + assertThat(result).hasSize(1); + } + + // 활성 상품을 정렬 조건으로 조회한다 + + @Test + void 활성_상품_목록_조회() { + // given + List products = List.of( + Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L), + Product.register("슈퍼스타", "설명", Money.of(80000), Stock.of(30), 2L) + ); + given(productRepository.findAllActive(ProductSortType.LATEST)).willReturn(products); + given(brandRepository.findAllByIdIn(List.of(1L, 2L))).willReturn(List.of()); + + // when + List result = productService.getActiveProducts(ProductSortType.LATEST); + + // then + assertThat(result).hasSize(2); + } + + // 상품을 수정한다 + + @Test + void 상품_수정_성공() { + // given + Long productId = 1L; + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L); + ProductUpdateCommand command = new ProductUpdateCommand("에어맥스2", "새설명", 120000, 60); + given(productRepository.findById(productId)).willReturn(Optional.of(product)); + + // when + productService.update(productId, command); + + // then + assertThat(product.hasName("에어맥스2")).isTrue(); + } + + @Test + void 상품_수정_시_존재하지_않으면_예외() { + // given + Long productId = 999L; + ProductUpdateCommand command = new ProductUpdateCommand("에어맥스2", "새설명", 120000, 60); + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.update(productId, command)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + // 상품을 삭제한다 + + @Test + void 상품_삭제_성공() { + // given + Long productId = 1L; + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L); + given(productRepository.findById(productId)).willReturn(Optional.of(product)); + + // when + productService.delete(productId); + + // then + assertThat(product.isDeleted()).isTrue(); + } + + @Test + void 상품_삭제_시_존재하지_않으면_예외() { + // given + Long productId = 999L; + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.delete(productId)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java new file mode 100644 index 000000000..94e9e7c8d --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.example; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ExampleModelTest { + @DisplayName("예시 모델을 생성할 때, ") + @Nested + class Create { + @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") + @Test + void createsExampleModel_whenNameAndDescriptionAreProvided() { + // arrange + String name = "제목"; + String description = "설명"; + + // act + ExampleModel exampleModel = new ExampleModel(name, description); + + // assert + assertAll( + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) + ); + } + + @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenTitleIsBlank() { + // arrange + String name = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ExampleModel(name, "설명"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenDescriptionIsEmpty() { + // arrange + String description = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ExampleModel("제목", description); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java index c588c4a8a..f4442b476 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -1,6 +1,6 @@ package com.loopers.domain.example; -import com.loopers.domain.BaseEntity; +import com.loopers.domain.SoftDeletableEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; @@ -8,7 +8,7 @@ @Entity @Table(name = "example") -public class ExampleModel extends BaseEntity { +public class ExampleModel extends SoftDeletableEntity { private String name; private String description; diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..e24a0bd86 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,4 @@ import org.gradle.api.Project.DEFAULT_VERSION -import org.springframework.boot.gradle.tasks.bundling.BootJar /** --- configuration functions --- */ fun getGitHash(): String { @@ -35,12 +34,12 @@ allprojects { subprojects { apply(plugin = "java") - apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") apply(plugin = "jacoco") dependencyManagement { imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${project.properties["springBootVersion"]}") mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") } } @@ -69,14 +68,6 @@ subprojects { testImplementation("org.testcontainers:junit-jupiter") } - tasks.withType(Jar::class) { enabled = true } - tasks.withType(BootJar::class) { enabled = false } - - configure(allprojects.filter { it.parent?.name.equals("apps") }) { - tasks.withType(Jar::class) { enabled = false } - tasks.withType(BootJar::class) { enabled = true } - } - tasks.test { maxParallelForks = 1 useJUnitPlatform() @@ -106,6 +97,7 @@ subprojects { } // module-container 는 task 를 실행하지 않도록 한다. -project("apps") { tasks.configureEach { enabled = false } } -project("modules") { tasks.configureEach { enabled = false } } +project("application") { tasks.configureEach { enabled = false } } +project("presentation") { tasks.configureEach { enabled = false } } +project("infrastructure") { tasks.configureEach { enabled = false } } project("supports") { tasks.configureEach { enabled = false } } diff --git a/docs/analysis/class-diagram-analysis.md b/docs/analysis/class-diagram-analysis.md index e4b0af55a..4ac4cbbb1 100644 --- a/docs/analysis/class-diagram-analysis.md +++ b/docs/analysis/class-diagram-analysis.md @@ -1,5 +1,7 @@ # 클래스 다이어그램 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 03-class-diagram.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. > 향후 클래스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/analysis/erd-analysis.md b/docs/analysis/erd-analysis.md index 97590d930..1649a5ad9 100644 --- a/docs/analysis/erd-analysis.md +++ b/docs/analysis/erd-analysis.md @@ -1,5 +1,7 @@ # ERD 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 04-erd.md 작성 과정에서 드러난 ERD 설계 방식, 의사결정 패턴, 보완이 필요한 영역을 분석한다. > 향후 ERD 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/analysis/prompt-design-process-analysis.md b/docs/analysis/prompt-design-process-analysis.md index 25a0a4058..252cc4f27 100644 --- a/docs/analysis/prompt-design-process-analysis.md +++ b/docs/analysis/prompt-design-process-analysis.md @@ -1,5 +1,7 @@ # 설계 프로세스 분석 프롬프트 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 각 설계 산출물(요구사항 정의서, 시퀀스 다이어그램, 클래스 다이어그램 등)을 함께 작성한 뒤, > 그 과정을 분석하여 재사용 가능한 규칙을 도출하기 위한 프롬프트이다. > 아래 프롬프트를 산출물명에 맞게 수정하여 사용한다. diff --git a/docs/analysis/requirements-gathering-analysis.md b/docs/analysis/requirements-gathering-analysis.md index 36ca1b8ee..b54941597 100644 --- a/docs/analysis/requirements-gathering-analysis.md +++ b/docs/analysis/requirements-gathering-analysis.md @@ -1,5 +1,7 @@ # 요구사항 정리 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 01-requirements.md 작성 과정에서 드러난 요구사항 정리 패턴, 의사결정 방식, 보완이 필요한 영역을 분석한다. > 향후 요구사항 정리 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/analysis/sequence-diagram-analysis.md b/docs/analysis/sequence-diagram-analysis.md index 5939a2ecf..afbee364f 100644 --- a/docs/analysis/sequence-diagram-analysis.md +++ b/docs/analysis/sequence-diagram-analysis.md @@ -1,5 +1,7 @@ # 시퀀스 다이어그램 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 02-sequence-diagrams.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. > 향후 시퀀스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 66ba80f4c..1ab162d2c 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -10,8 +10,8 @@ ``` OrderService → ProductService (주문 시 재고 확인/차감) LikeService → ProductService (좋아요 시 상품/브랜드 유효성 확인) -BrandService ↔ ProductService (순환: 브랜드 삭제 연쇄 / 상품 등록 시 브랜드 검증) - → Facade로 해소: AdminBrandFacade, AdminProductFacade +BrandService ↔ ProductService (같은 BC 내 cross-aggregate 규칙) + → BrandDeleteService로 해소 (Domain 레이어, Brand↔Product는 같은 Catalog BC) ``` --- @@ -102,10 +102,10 @@ sequenceDiagram Note over PS: 상품 존재·삭제 여부, 브랜드 삭제 여부 확인 PS-->>LS: Product - LS->>LR: 중복 확인 existsByMemberIdAndProductId(memberId, productId) + LS->>LR: 중복 확인 existsByMemberIdAndSubjectTypeAndSubjectId(memberId, PRODUCT, productId) LR-->>LS: boolean - LS->>LR: 좋아요 저장 save(newLike) + LS->>LR: 좋아요 저장 save(Like.of(memberId, PRODUCT, productId)) LS-->>C: 등록 완료 C-->>M: 201 Created @@ -113,7 +113,7 @@ sequenceDiagram #### 읽는 포인트 - **LikeService**: 등록 흐름 조율. 상품 유효성은 ProductService에 위임하여, 상품/브랜드 상태를 직접 알 필요가 없다. -- **LikeRepository**: 중복 확인과 저장의 책임. hard-delete 방식이므로 취소 이력 없이 단순하게 존재 여부만 확인한다. +- **LikeRepository**: 중복 확인과 저장의 책임. hard-delete 방식이므로 취소 이력 없이 단순하게 존재 여부만 확인한다. Like는 `subjectType(PRODUCT) + subjectId`로 대상을 식별한다. - 이미 좋아요가 있으면 LikeService가 예외를 발생시킨다. --- @@ -130,10 +130,10 @@ sequenceDiagram M->>C: DELETE /api/v1/products/{productId}/likes C->>LS: 좋아요 취소 cancelLike(memberId, productId) - LS->>LR: 좋아요 조회 findByMemberIdAndProductId(memberId, productId) - LR-->>LS: ProductLike + LS->>LR: 좋아요 조회 findByMemberIdAndSubjectTypeAndSubjectId(memberId, PRODUCT, productId) + LR-->>LS: Like - LS->>LR: 좋아요 삭제 delete(productLike) + LS->>LR: 좋아요 삭제 delete(like) Note over LR: 물리 삭제 (hard-delete) LS-->>C: 취소 완료 @@ -157,8 +157,8 @@ sequenceDiagram M->>C: GET /api/v1/likes?page&size C->>LS: 내 좋아요 목록 조회 getMyLikes(memberId, page, size) - LS->>LR: 좋아요 목록 조회 findLikesByMemberId(memberId, page, size) - Note over LR: 상품 활성 + 브랜드 활성 조건 필터
삭제된 상품·브랜드의 좋아요는 제외 + LS->>LR: 좋아요 목록 조회 findProductLikesByMemberId(memberId, page, size) + Note over LR: subjectType=PRODUCT 필터
상품 활성 + 브랜드 활성 조건 필터
삭제된 상품·브랜드의 좋아요는 제외 LR-->>LS: Page LS-->>C: Page C-->>M: 200 OK @@ -203,9 +203,9 @@ sequenceDiagram loop 각 상품 OS->>P: 재고 차감 decreaseStock(quantity) end - OS->>OR: 수락 주문 저장 save(order: ACCEPTED, snapshots) + OS->>OR: 수락 주문 저장 save(order: ACCEPTED, lines + snapshots) else 하나라도 재고 부족 - OS->>OR: 거절 주문 저장 save(order: REJECTED, snapshots) + OS->>OR: 거절 주문 저장 save(order: REJECTED, lines + snapshots) end OS-->>C: 주문 결과 @@ -257,8 +257,8 @@ sequenceDiagram M->>C: GET /api/v1/orders/{orderId} C->>OS: 주문 상세 조회 getOrderDetail(memberId, orderId) - OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) - OR-->>OS: Order + List + OS->>OR: 주문 + 주문항목 + 스냅샷 조회 findWithLinesById(orderId) + OR-->>OS: Order + OrderLines + Snapshots OS->>O: 본인 확인 isOwnedBy(memberId) Note over O: 본인 주문이 아니면 예외 @@ -269,7 +269,7 @@ sequenceDiagram #### 읽는 포인트 - **Order 엔티티**: `isOwnedBy(memberId)` — 본인 확인은 Order 객체 스스로가 판단한다. Service가 memberId를 비교하는 것이 아니다. -- **OrderRepository**: 주문과 스냅샷을 함께 로딩하는 책임. +- **OrderRepository**: 주문, 주문항목, 스냅샷을 함께 로딩하는 책임. --- @@ -369,38 +369,37 @@ sequenceDiagram ### 4-5. 브랜드 삭제 (연쇄 soft-delete) -> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. +> Brand와 Product는 같은 Catalog BC. Brand 삭제 시 소속 Product 연쇄 삭제는 BrandDeleteService(Domain 레이어)에서 처리한다. ```mermaid sequenceDiagram actor A as 관리자 participant C as AdminBrandController - participant F as AdminBrandFacade - participant BS as BrandService - participant PS as ProductService + participant BS as AdminBrandService + participant BDS as BrandDeleteService + participant BR as BrandRepository + participant PR as ProductRepository participant B as Brand A->>C: DELETE /api/v1/admin/brands/{brandId} - C->>F: 브랜드 삭제 deleteBrand(brandId) - - F->>BS: 브랜드 조회 getBrand(brandId) - BS-->>F: Brand + C->>BS: 브랜드 삭제 delete(brandId) - F->>B: 삭제 여부 확인 guardNotDeleted() - Note over B: 이미 삭제된 상태면 예외 + BS->>BDS: 브랜드 삭제 delete(brandId) + BDS->>BR: 브랜드 조회 findById(brandId) + BR-->>BDS: Brand - F->>PS: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) - F->>B: 삭제 delete() - Note over B: name 변경 + deletedAt 세팅
(UNIQUE 제약 해소) + BDS->>PR: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) + BDS->>B: 삭제 delete() + Note over B: guardNotDeleted() + name 변경
+ deletedAt 세팅 (UNIQUE 해소) - F-->>C: 삭제 완료 + BS-->>C: 삭제 완료 C-->>A: 204 No Content ``` #### 읽는 포인트 -- **AdminBrandFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 삭제 순서(상품 먼저 → 브랜드 나중)를 결정하는 책임. -- **Brand 엔티티**: `guardNotDeleted()` — 삭제 가능 상태인지 스스로 검증한다. `delete()` — deletedAt 세팅도 스스로 수행한다. -- **ProductService**: 브랜드 ID로 소속 상품을 일괄 soft-delete하는 책임. 좋아요는 건드리지 않는다. +- **BrandDeleteService**: 같은 BC(Catalog) 내 cross-aggregate 규칙 처리. 삭제 순서(상품 먼저 → 브랜드 나중)는 도메인 규칙. +- **AdminBrandService**: 트랜잭션 경계 소유. BrandDeleteService를 호출하는 Application 조정자. +- **Brand 엔티티**: `delete()` 내부에서 `guardNotDeleted()` + name 변경 + deletedAt 세팅을 스스로 수행한다. --- @@ -448,44 +447,40 @@ sequenceDiagram ### 5-3. 상품 등록 -> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. +> 상품 등록 시 Brand 활성 여부 확인은 AdminProductService(Application 레이어)에서 오케스트레이션한다. ```mermaid sequenceDiagram actor A as 관리자 participant C as AdminProductController - participant F as AdminProductFacade - participant BS as BrandService + participant PS as AdminProductService + participant BR as BrandRepository participant B as Brand - participant PS as ProductService participant P as Product participant PR as ProductRepository A->>C: POST /api/v1/admin/products {name, description, price, stock, brandId} - C->>F: 상품 등록 createProduct(name, description, price, stock, brandId) + C->>PS: 상품 등록 create(BrandCreateCommand) - F->>BS: 브랜드 조회 getBrand(brandId) - BS-->>F: Brand + PS->>BR: 브랜드 조회 findById(brandId) + BR-->>PS: Brand - F->>B: 삭제 여부 확인 guardNotDeleted() + PS->>B: 삭제 여부 확인 Note over B: 삭제된 브랜드면 예외 - F->>PS: 상품 생성 createProduct(name, description, price, stock, brandId) - PS->>P: 생성 new Product(name, description, price, stock, brandId) + PS->>P: 생성 Product.register(name, description, price, stock, brandId) Note over P: 가격 > 0, 재고 >= 0 검증 PS->>PR: 상품 저장 save(product) PR-->>PS: Product - PS-->>F: Product - F-->>C: ProductInfo + PS-->>C: ProductInfo C-->>A: 201 Created ``` #### 읽는 포인트 -- **AdminProductFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 브랜드 검증 → 상품 생성 순서를 결정하는 책임. -- **Brand 엔티티**: `guardNotDeleted()` — 삭제된 브랜드에 상품을 등록할 수 없다는 불변식을 Brand 스스로가 지킨다. +- **AdminProductService**: 트랜잭션 경계 소유. Brand 조회 → 활성 확인 → Product 생성의 오케스트레이션. +- **Brand 엔티티**: 삭제 여부는 Brand 자신의 상태. Application Service가 조회 후 확인한다. - **Product 엔티티**: 생성 시 입력값 검증(가격 > 0, 재고 >= 0)을 스스로 수행한다. -- **ProductService**: 상품 생성 조율과 저장의 책임. 입력값 검증은 Product에 위임. BrandService를 모른다. --- @@ -578,8 +573,8 @@ sequenceDiagram A->>C: GET /api/v1/admin/orders/{orderId} C->>OS: 주문 상세 조회 getOrderDetail(orderId) - OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) - OR-->>OS: Order + List + OS->>OR: 주문 + 주문항목 + 스냅샷 조회 findWithLinesById(orderId) + OR-->>OS: Order + OrderLines + Snapshots OS-->>C: OrderDetailInfo C-->>A: 200 OK ``` diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 1a1a5c400..5c0c7bb3d 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -61,29 +61,49 @@ classDiagram +guardNotDeleted() void } - class ProductLike { + class Like { -Long memberId - -Long productId + -LikeSubjectType subjectType + -Long subjectId + +mark(memberId, subjectType, subjectId)$ Like + +isOwnedBy(memberId) boolean + +isForSubject(subjectType, subjectId) boolean } + class LikeSubjectType { + <> + PRODUCT + } + + note for Like "호감 표현 (hard-delete)\n사용자의 특정 대상에 대한 관심/호감 표현\n서비스가 얻는 선호도 데이터\nsubjectType + subjectId로 대상 식별\n상속 없이 enum으로 확장" + %% ── 주문 ── class Order { -Long memberId -OrderStatus status - -ZonedDateTime orderedAt - -List~OrderLineSnapshot~ lines + +place(memberId, orderLines, status)$ Order +isOwnedBy(memberId) boolean + +assignOrderLines(orderLines) List~OrderLine~ } - class OrderLineSnapshot { - <> + class OrderLine { + -Long orderId -Long productId + -Quantity quantity + +of(productId, quantity, name, desc, price, brand)$ OrderLine + +assignToOrder(orderId) OrderLine + +assignSnapshot() OrderLine + } + + class OrderLineSnapshot { + <> + -Long orderLineId -String productName -String productDescription - -Price price - -Quantity quantity + -long price -String brandName + +assignToOrderLine(orderLineId) void } class OrderStatus { @@ -95,18 +115,22 @@ classDiagram %% ── VO 포함 (Composition) ── Product *-- Stock : stock Product *-- Price : price + OrderLine *-- Quantity : quantity + OrderLine --> OrderLineSnapshot : snapshot (1:1) OrderLineSnapshot *-- Price : 주문 시점 가격 - OrderLineSnapshot *-- Quantity : quantity %% ── VO 간 행위 의존 ── Stock ..> Quantity : isEnough / decrease %% ── 연관 (단방향, ID 참조) ── Product ..> Brand : brandId (Long) - ProductLike ..> Product : productId (Long) - ProductLike ..> Member : memberId (Long) + Like ..> Member : memberId (Long) + Like ..> Product : subjectId (Long, subjectType=PRODUCT) + Like --> LikeSubjectType : subjectType Order ..> Member : memberId (Long) - Order *-- OrderLineSnapshot : 1..N + OrderLine ..> Order : orderId (Long) + OrderLine ..> Product : productId (Long) + OrderLineSnapshot ..> OrderLine : orderLineId (Long) Order --> OrderStatus : status ``` @@ -120,22 +144,23 @@ classDiagram - **Brand**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. - **Product**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. 브랜드 삭제 시 연쇄 삭제되지만, 이는 비즈니스 규칙이지 생명주기 종속이 아니다. -- **ProductLike**: `memberId + productId`로 고유 식별. 등록 → 삭제의 독립 생명주기. -- **Order**: 고유 ID. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. +- **Like**: `memberId + subjectType + subjectId`로 고유 식별. 등록 → 삭제의 독립 생명주기. `subjectType`(enum)으로 좋아요 대상 종류를, `subjectId`로 대상 ID를 지정한다. +- **Order**: 고유 ID. Aggregate Root. `place()` 시 orderLines를 받아 불변식(빈 주문, 중복 상품)을 검증하지만, 필드로 보유하지 않는다. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. `assignOrderLines()`로 하위 엔티티의 소속을 관리한다. +- **OrderLine**: 주문 항목. `orderId(Long)`로 소속 주문을 식별한다. `of()` 팩토리에서 OrderLineSnapshot을 내부 생성한다. 나중에 쿠폰/부분취소 등 라인별 기능의 확장 지점. **Value Object (VO)**: 고유 식별자가 불필요하며, 자체 규칙(불변식)을 캡슐화하는 불변 객체다. - **Stock**: 재고의 본질적 규칙("음수가 될 수 없다")을 스스로 지킨다. `decrease(Quantity)` 시 부족하면 예외, 충분하면 새 Stock을 반환한다. - **Price**: 가격의 규칙("0보다 커야 한다")을 생성 시 검증한다. 불변. - **Quantity**: 수량의 규칙("0보다 커야 한다")을 생성 시 검증한다. Stock.decrease의 인자로 사용된다. -- **OrderLineSnapshot**: Order 없이 존재할 수 없다. 주문 시점의 Price와 Quantity를 포함하며, 한번 생성되면 불변이다. +- **OrderLineSnapshot**: 도메인 관점에서는 VO(불변, 독립 식별 불필요)이지만, 정규화를 위해 @Entity로 별도 테이블에 매핑한다. `orderLineId(Long)`로 소속 주문항목을 식별한다. 주문 시점의 상품 정보(이름, 가격, 브랜드명)를 보존한다. ### 원칙 2: 단방향 연관, 양방향 최소화 모든 연관이 **단방향**이다. 양방향 참조는 하나도 없다. - `Product → Brand`: Product가 `brandId(Long)`로 브랜드를 참조한다. Brand는 자기에게 소속된 Product를 모른다. -- `ProductLike → Product`: ProductLike가 `productId(Long)`로 상품을 참조한다. Product는 자기에게 달린 좋아요를 모른다. +- `Like → Product/Brand`: Like가 `subjectType(enum) + subjectId(Long)`로 대상을 참조한다. Product/Brand는 자기에게 달린 좋아요를 모른다. - `Order → Member`: Order가 `memberId(Long)`로 회원을 참조한다. Member는 자기의 주문을 모른다. **BC 간 참조는 ID(Long)만 사용한다.** 객체 참조가 아닌 ID 참조이므로 BC 간 직접 의존이 없다. @@ -171,7 +196,7 @@ classDiagram | Product | `guardNotDeleted()` | 삭제 여부를 자기가 검증한다 | | Order | `isOwnedBy(memberId)` | 본인 주문 확인을 자기가 판단한다 | -**ProductLike에 메서드가 없는 이유**: 좋아요는 "회원과 상품 사이의 관계 기록"이라는 본질에 충실한 단순 엔티티다. 등록은 `new ProductLike(memberId, productId)`, 삭제는 물리 삭제(hard-delete). +**Like의 도메인 정의**: 좋아요는 사용자의 특정 대상에 대한 관심/호감 표현이다. 서비스가 사용자와의 계약을 통해 얻는 선호도 데이터로서의 가치를 가진다. 생성은 `Like.mark(memberId, subjectType, subjectId)`, 철회는 물리 삭제(hard-delete). 불변이며 수정은 없다. `isOwnedBy(memberId)` — 누구의 호감인지, `isForSubject(subjectType, subjectId)` — 어떤 대상에 대한 호감인지를 자기가 답한다. `subjectType` enum으로 대상 종류(PRODUCT, 향후 BRAND 등)를 구분하며, 상속 없이 확장 가능하다. ### 원칙 4: 한 객체에 책임이 몰리지 않았는가? @@ -185,12 +210,14 @@ classDiagram |--------|------|---------|--------------|---------------|----------| | Brand | Entity | 고유 ID | 독립 (생성→수정→삭제) | - | soft-delete | | Product | Entity | 고유 ID | 독립, 브랜드 연쇄 삭제 가능 | - | soft-delete | -| ProductLike | Entity | member+product 식별 | 독립 (등록→삭제) | - | hard-delete | +| Like | Entity | member+subjectType+subjectId 식별 | 독립 (등록→삭제) | - | hard-delete | +| LikeSubjectType | enum | - | - | - | - | | Order | Entity | 고유 ID | 독립 (생성→최종 상태) | - | 삭제 없음 | +| OrderLine | Entity | 고유 ID | Order에 종속 | - | Order와 동일 | +| OrderLineSnapshot | **VO** (JPA @Entity) | JPA 매핑용 | OrderLine에 종속, 불변 | Price 포함 | OrderLine과 동일 | | Stock | **VO** | 불필요 | Product에 종속 | value >= 0, decrease 시 비음수 검증 | Product와 동일 | | Price | **VO** | 불필요 | Product 또는 Snapshot에 종속 | value > 0 | 소유자와 동일 | -| Quantity | **VO** | 불필요 | Snapshot에 종속 | value > 0 | 소유자와 동일 | -| OrderLineSnapshot | **VO** | 불필요 | Order에 종속, 불변 | Price·Quantity에 위임 | Order와 동일 | +| Quantity | **VO** | 불필요 | OrderLine에 종속 | value > 0 | 소유자와 동일 | | OrderStatus | enum | - | - | - | - | ### VO 선별 기준: "자체 규칙이 있는가?" @@ -200,7 +227,7 @@ classDiagram ├── Stock: 음수 불가 + 차감 행위 ├── Price: 양수만 가능 ├── Quantity: 양수만 가능 -└── OrderLineSnapshot: 주문에 종속 + 불변 + Price·Quantity 포함 +└── OrderLineSnapshot: OrderLine에 종속 + 불변 + Price 포함 (JPA @Entity로 별도 테이블) 자체 규칙 없음 → 원시 타입 유지 ├── name (String): 단순 필수값 @@ -221,9 +248,10 @@ classDiagram | Stock | 2 | isEnough(Quantity), decrease(Quantity) | **적절**. 재고의 핵심 규칙만 보유. 같은 불변식(value >= quantity)의 조회/변경 | | Price | 0+1 | 생성자 검증 | **적절**. 가격 규칙만 보유 | | Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | -| Order | 1 | isOwnedBy | **적절**. 현재 최소 | -| ProductLike | 0 | - | **적절**. 단순 관계 레코드 | -| OrderLineSnapshot | 0 | - | **적절**. 불변 VO. Price, Quantity를 포함하여 스냅샷 | +| Order | 3 | place(검증), isOwnedBy, assignOrderLines | **적절**. Aggregate Root로서 불변식 검증 + 하위 소속 관리 | +| OrderLine | 3 | of(스냅샷 내부 생성), assignToOrder, assignSnapshot | **적절**. 주문 항목 생성 + 소속 관리. 연산의 닫힘(self 반환) | +| Like | 2 | isOwnedBy, isForSubject | **적절**. 호감 표현. 자기 정체성(누구의, 어떤 대상에 대한)에 답하는 행위만 보유 | +| OrderLineSnapshot | 0 | - | **적절**. 불변 스냅샷. Price 포함 | ### Service별 @@ -234,12 +262,11 @@ classDiagram | LikeService | 좋아요 등록/취소, 목록 조회 + 상품 유효성 확인 위임 | **적절** | | OrderService | 주문 생성 조율 (중복 검증, 정렬, 상품 확보, 스냅샷 생성, 수락/거절 판단) | **모니터링 필요**. 확장 시 분리 고려 | -### Facade별 +### Domain Service별 -| Facade | 존재 이유 | 판단 | -|--------|----------|------| -| AdminBrandFacade | BrandService ↔ ProductService 순환 해소 (브랜드 삭제 연쇄) | **적절** | -| AdminProductFacade | BrandService ↔ ProductService 순환 해소 (상품 등록 브랜드 검증) | **적절** | +| Domain Service | 존재 이유 | 판단 | +|----------------|----------|------| +| BrandDeleteService | Brand 삭제 시 소속 Product 연쇄 soft-delete | **적절**. 같은 BC 내 cross-aggregate 도메인 규칙이므로 Domain Service가 적합 | --- @@ -269,18 +296,19 @@ classDiagram ### 5-3. 좋아요 → 선호 BC (Preference Context) -좋아요 대상이 상품에서 브랜드로 확장되고, "선호"라는 상위 개념으로 통합되어 랭킹/추천으로 연결된다. +좋아요 대상이 상품에서 브랜드, 판매자 등으로 확장되고, "선호"라는 상위 개념으로 통합되어 랭킹/추천으로 연결된다. ``` -현재: ProductLike (상품 좋아요만) +현재: Like (subjectType=PRODUCT) 확장: Preference BC - ├── ProductLike (상품 좋아요) - ├── BrandLike (브랜드 좋아요) + ├── Like (subjectType=PRODUCT) + ├── Like (subjectType=BRAND) + ├── Like (subjectType=SELLER, ...) └── → 랭킹/추천 시스템 연동 ``` -- **현재 구조의 대응**: ProductLike가 독립 엔티티이며 상품 BC에 소속. 나중에 BrandLike를 추가하고, 이들을 "선호 BC"로 묶으면 된다. -- **막히지 않는 이유**: ProductLike는 `memberId + productId` ID 참조만 사용하므로, 동일 패턴으로 `BrandLike(memberId + brandId)`를 만들 수 있다. 랭킹/추천은 이 데이터를 이벤트 기반으로 소비하면 된다. +- **현재 구조의 대응**: Like가 `subjectType(enum) + subjectId(Long)`로 대상을 일반화. enum 값 추가만으로 새 대상 타입 확장. +- **막히지 않는 이유**: 스키마 변경 없이 `LikeSubjectType`에 `BRAND`를 추가하면 끝. 타입 메타데이터가 필요해지면 enum형 코드 테이블(`like_subject_type`)로 전환 가능. ### 5-4. 주문 취소 @@ -302,19 +330,23 @@ classDiagram | # | 결정 | 이유 | 대안 | |---|------|------|------| -| 1 | BaseTimeEntity 신규 도입 | ProductLike(hard-delete)와 Order(never deleted)는 deletedAt 불필요. 상속으로 삭제 정책을 코드에 명시 | BaseEntity 그대로 상속 (불필요한 컬럼, 의도 불명확) | +| 1 | BaseTimeEntity 신규 도입 | Like(hard-delete)와 Order(never deleted)는 deletedAt 불필요. 상속으로 삭제 정책을 코드에 명시 | BaseEntity 그대로 상속 (불필요한 컬럼, 의도 불명확) | | 2 | Brand/Product는 BaseEntity 상속 | soft-delete 필요. deletedAt 활용 | 별도 closedAt 관리 (폐점 개념 제거됨, 불필요) | | 3 | Brand.delete(): 이름 변경 + soft-delete | DB UNIQUE 제약 유지하면서 삭제된 브랜드 이름 재사용 가능 | 앱 레벨 검증만 (UNIQUE 없음), UNIQUE 제거 (데이터 정합성 약화) | | 4 | OrderStatus: ACCEPTED, REJECTED만 | 현재 요구사항에 중간 상태/취소 없음. enum이므로 확장 용이 | CANCELLED 포함 (현재 불필요, YAGNI) | | 5 | 모든 BC 간 참조를 ID(Long)만 사용 | BC 간 직접 의존 제거. MSA 전환 시 변경 최소화 | 객체 참조 (편리하나 BC 경계 위반) | -| 6 | OrderLineSnapshot은 VO | Order 없이 존재 불가, 불변, 독립 식별 불필요 | Entity로 분류 (불필요한 생명주기 관리) | -| 7 | ProductLike에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | +| 6 | OrderLine(Entity) + OrderLineSnapshot(VO, @Entity) 분리 | OrderLine은 주문 항목으로 라인별 확장 지점(쿠폰, 부분취소). OrderLineSnapshot은 불변 스냅샷으로 정규화를 위해 별도 테이블 | OrderLineSnapshot 하나로 합치기 (확장 어려움), @Embeddable (정규화 위반) | +| 7 | Like에 정체성 행위 메서드 추가 | "호감 표현"이라는 도메인 정의에 따라 `isOwnedBy`, `isForSubject`로 자기 정체성에 답함. 단순 관계 레코드가 아닌 선호도 데이터로서의 의미 부여 | 메서드 없음 (도메인 의미 손실), toggle() (과도한 추상화) | | 8 | 양방향 연관 0개 | 단방향만으로 모든 요구사항 충족. 양방향은 순환 의존과 복잡성 유발 | Product ↔ Brand 양방향 (편의성 vs 복잡성 트레이드오프) | -| 9 | 좋아요를 상품 BC에 배치 (현재) | 현재는 상품 좋아요만 존재. 확장 시 선호 BC로 분리 | 처음부터 선호 BC 분리 (YAGNI, 과도한 설계) | +| 9 | Like를 subjectType+subjectId로 일반화 | 상속(JOINED/SINGLE_TABLE) 대신 enum+ID 패턴 채택. UNIQUE 제약 자연스러움, 스키마 변경 없이 타입 확장, 무FK 철학 일관 | JPA 상속 (JOINED: UNIQUE 불가+JOIN 비용, SINGLE_TABLE: nullable 컬럼), ProductLike/BrandLike 클래스 분리 (타입 추가마다 엔티티+테이블 필요) | | 10 | Stock, Price, Quantity를 VO로 분리 | 자체 규칙(불변식)이 있는 속성만 VO로 캡슐화. "규칙 없으면 원시 타입" 기준 | 원시 타입 유지 (규칙이 엔티티나 Service에 흩어짐) | | 11 | Product.decreaseStock → Stock.decrease 위임 | 재고 규칙은 재고의 책임. Product는 조율만 수행 | Product가 직접 검증 (책임 혼재) | | 12 | BaseEntity/BaseTimeEntity를 다이어그램에서 제외 | 비즈니스 설계에 기술 인프라 클래스가 불필요. 코드 구현 시 적용 | 포함 (기술적 완전성은 높지만 비즈니스 가독성 저하) | | 13 | Stock.isEnough(Quantity) + Product.hasEnoughStock(Quantity) 추가 | 주문 시 "확인 먼저, 차감 나중" 흐름에서 재고 확인 판단 주체를 명확화. Quantity가 아닌 Stock이 보유 (같은 불변식, 의존 방향 유지) | Quantity.canBeSatisfiedBy(Stock) (VO 간 순환 의존 발생) | +| 14 | JPA 관계 매핑(@OneToMany, @ManyToOne, @OneToOne) 금지 | 모든 엔티티 간 참조를 ID(Long)로만. BC 간뿐 아니라 같은 Aggregate 내에서도 동일 적용. 일관된 무FK 철학 | @OneToMany + cascade (편리하나 결합도 증가, JPA 의존 심화) | +| 15 | Aggregate Root가 하위 불변식 직접 검증 | Order.place()가 orderLines를 받아 빈 주문/중복 상품 검증. DomainService에 위임하지 않음. Aggregate Root = 불변식 게이트키퍼 | OrderDomainService에서 검증 (Root의 책임 약화) | +| 16 | 연산의 닫힘 패턴 | assign류 메서드가 self를 반환하여 map/체이닝 가능. forEach(void) 대신 map(self 반환) 선호 | void 반환 + forEach (체이닝 불가, 함수형 스타일 불일치) | +| 17 | Order.place() — 도메인 행위를 표현하는 팩토리 네이밍 | "주문하다" = place. Brand.register()와 동일한 원칙. create() 같은 기술적 이름 금지 | Order.create() (행위 의도 불명확) | --- @@ -336,5 +368,7 @@ classDiagram | VO 간 의존 (Stock ──▷ Quantity) | "재고를 차감하려면 수량이 필요하다"는 도메인 관계를 표현 | | 위임 패턴 (decreaseStock → Stock.decrease) | "규칙은 규칙을 아는 객체가 수행한다"는 객체지향 원칙을 표현 | | 연관 방향 (전부 단방향 ID 참조) | BC 경계가 다이어그램에서 바로 보임 | -| Composition (Order ◆── OrderLineSnapshot) | "스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | -| 메서드 없는 엔티티 (ProductLike) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음 | +| ID 참조 (OrderLine → orderId, OrderLineSnapshot → orderLineId) | "주문 항목과 스냅샷은 주문에 종속되지만 ID로만 참조"라는 무FK 원칙 일관성 | +| Aggregate Root 불변식 (Order.place → 검증) | "Aggregate Root가 하위 엔티티의 불변식을 직접 검증"하는 DDD 원칙 | +| 연산의 닫힘 (assignToOrder → OrderLine) | assign류 메서드가 self를 반환하여 map/체이닝을 가능하게 하는 함수형 패턴 | +| 호감 표현 엔티티 (Like) | "선호도 데이터"라는 본질에 충실 — 자기 정체성(누구의, 어떤 대상)에 답하는 행위를 보유. subjectType enum으로 대상 종류 구분 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 408c8609b..656d969ac 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -40,10 +40,11 @@ erDiagram DATETIME deleted_at "nullable" } - product_like { + likes { BIGINT id PK "AUTO_INCREMENT" BIGINT member_id "NOT NULL" - BIGINT product_id "NOT NULL" + VARCHAR subject_type "NOT NULL" + BIGINT subject_id "NOT NULL" DATETIME created_at "NOT NULL" DATETIME updated_at "NOT NULL" } @@ -57,22 +58,27 @@ erDiagram DATETIME updated_at "NOT NULL" } - order_line_snapshot { + order_line { BIGINT id PK "AUTO_INCREMENT" BIGINT order_id "NOT NULL" BIGINT product_id "NOT NULL" + INT quantity "NOT NULL" + } + + order_line_snapshot { + BIGINT id PK "AUTO_INCREMENT" + BIGINT order_line_id "NOT NULL" VARCHAR product_name "NOT NULL" TEXT product_description "nullable" INT price "NOT NULL" - INT quantity "NOT NULL" VARCHAR brand_name "NOT NULL" } brand ||--o{ product : "brand_id" - member ||--o{ product_like : "member_id" - product ||--o{ product_like : "product_id" + member ||--o{ likes : "member_id" member ||--o{ orders : "member_id" - orders ||--o{ order_line_snapshot : "order_id" + orders ||--o{ order_line : "order_id" + order_line ||--|| order_line_snapshot : "order_line_id" ``` > **관계선 = 논리 참조**. DB에 FK 제약조건은 존재하지 않는다. 참조 무결성은 애플리케이션 레벨에서 보장한다. @@ -96,7 +102,7 @@ erDiagram |----|----------|---------|--------------| | Stock | product.stock | INT | >= 0 (음수 불가) | | Price | product.price, order_line_snapshot.price | INT | > 0 (양수만) | -| Quantity | order_line_snapshot.quantity | INT | > 0 (양수만) | +| Quantity | order_line.quantity | INT | > 0 (양수만) | > VO는 코드 구조이지 DB 구조가 아니다 (클래스 다이어그램 안티패턴 #3). > DB에는 INT 컬럼으로 저장되고, 앱에서 VO 객체로 감싸서 규칙을 검증한다. @@ -108,15 +114,15 @@ JPA 상속은 `@MappedSuperclass`를 사용한다. 상속 클래스별로 별도 | 상속 클래스 | 포함 컬럼 | 상속하는 테이블 | |------------|----------|---------------| | BaseEntity | id, created_at, updated_at, **deleted_at** | brand, product | -| BaseTimeEntity (신규) | id, created_at, updated_at | product_like, orders | +| BaseTimeEntity (신규) | id, created_at, updated_at | likes, orders | ### 삭제 정책별 테이블 구분 | 삭제 정책 | 테이블 | deleted_at 유무 | 상속 | |----------|--------|----------------|------| | soft-delete | brand, product | 있음 | BaseEntity | -| hard-delete | product_like | 없음 | BaseTimeEntity | -| 삭제 없음 | orders, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | +| hard-delete | likes | 없음 | BaseTimeEntity | +| 삭제 없음 | orders, order_line, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | --- @@ -166,19 +172,22 @@ BaseEntity 상속 (soft-delete). | updated_at | DATETIME | NOT NULL | BaseEntity | | deleted_at | DATETIME | nullable | soft-delete 마커 | -### product_like +### likes -BaseTimeEntity 상속 (hard-delete). +BaseTimeEntity 상속 (hard-delete). 테이블명은 `likes` (LIKE는 SQL 예약어). | 컬럼 | 타입 | 제약 | 비고 | |-------|------|------|------| | id | BIGINT | PK, AUTO_INCREMENT | | | member_id | BIGINT | NOT NULL | → member.id (FK 없음) | -| product_id | BIGINT | NOT NULL | → product.id (FK 없음) | +| subject_type | VARCHAR | NOT NULL | LikeSubjectType enum (EnumType.STRING) | +| subject_id | BIGINT | NOT NULL | 대상 ID (subject_type에 따라 해석) | | created_at | DATETIME | NOT NULL | BaseTimeEntity | | updated_at | DATETIME | NOT NULL | BaseTimeEntity | -- **UNIQUE(member_id, product_id)**: 같은 회원이 같은 상품에 중복 좋아요를 할 수 없다. +- **UNIQUE(member_id, subject_type, subject_id)**: 같은 회원이 같은 대상에 중복 좋아요를 할 수 없다. +- **subject_type**: 앱 enum(`LikeSubjectType`)을 문자열로 저장. 현재 `PRODUCT`만 존재. 확장 시 enum 값 추가. 규모 확장 시 enum형 코드 테이블로 전환 가능. +- **subject_id**: subject_type에 따라 `product.id`, `brand.id` 등을 가리킨다. FK 없이 앱 레벨에서 해석. ### orders @@ -195,23 +204,33 @@ BaseTimeEntity 상속 (삭제 없음). 테이블명은 `orders` (ORDER는 SQL - **status**: 주문 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정된다. 중간 상태 없음. -### order_line_snapshot +### order_line -Order에 종속되는 VO. Composition 1:N. +Order에 종속되는 주문 항목. Composition 1:N. | 컬럼 | 타입 | 제약 | 비고 | |-------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | JPA 매핑용 | +| id | BIGINT | PK, AUTO_INCREMENT | | | order_id | BIGINT | NOT NULL | → orders.id (FK 없음) | -| product_id | BIGINT | NOT NULL | 스냅샷 시점 상품 ID | +| product_id | BIGINT | NOT NULL | 주문 시점 상품 ID | +| quantity | INT | NOT NULL | VO: Quantity (주문 수량) | + +- **확장 지점**: 향후 쿠폰 적용, 부분 취소 등 라인별 기능 확장 시 이 테이블에 컬럼/관계 추가. + +### order_line_snapshot + +OrderLine에 1:1로 종속되는 불변 스냅샷. 도메인 VO이지만 정규화를 위해 @Entity로 별도 테이블. + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| order_line_id | BIGINT | NOT NULL | → order_line.id (FK 없음) | | product_name | VARCHAR | NOT NULL | 스냅샷 | | product_description | TEXT | nullable | 스냅샷 | | price | INT | NOT NULL | VO: Price (주문 시점 가격) | -| quantity | INT | NOT NULL | VO: Quantity (주문 수량) | | brand_name | VARCHAR | NOT NULL | 스냅샷 시점 브랜드명 | -- **timestamp 없음**: 불변 VO. 생성 시점은 소속 Order의 created_at/ordered_at이 대변한다. -- **id 컬럼 존재 이유**: 도메인에서는 VO(독립 식별 불필요)이지만, JPA 1:N 매핑에 PK가 필요하다. +- **timestamp 없음**: 불변. 생성 시점은 소속 Order의 created_at/ordered_at이 대변한다. - **상품/브랜드 삭제 무관**: 스냅샷이므로 원본이 삭제되어도 기록은 유지된다. --- @@ -223,7 +242,94 @@ Order에 종속되는 VO. Composition 1:N. | 1 | FK 제약조건 없음 | 앱 레벨에서 참조 무결성 관리. BC 간 결합도 최소화. MSA 전환 대비 | FK 설정 (DB 정합성 보장이 강하나, BC 간 결합 증가) | | 2 | VO는 컬럼으로 매핑 | VO는 코드 구조이지 DB 구조가 아니다. 별도 테이블은 안티패턴 | VO별 테이블 (과도한 JOIN, 도메인 의미 왜곡) | | 3 | order → orders 테이블명 | ORDER는 SQL 예약어. 백틱 의존보다 명확한 이름 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | -| 4 | OrderLineSnapshot에 id 컬럼 포함 | 도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요 | @ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈) | +| 4 | OrderLine + OrderLineSnapshot 분리 | OrderLine은 주문 항목(Entity), OrderLineSnapshot은 불변 스냅샷(VO, @Entity). 정규화 유지 + 라인별 확장 지점 확보 | 하나로 합치기 (확장 어려움), @Embeddable (정규화 위반) | | 5 | OrderLineSnapshot에 timestamp 없음 | 불변 VO. Order의 created_at이 생성 시점을 대변 | timestamp 포함 (불필요한 중복 정보) | -| 6 | product_like에 UNIQUE(member_id, product_id) | 중복 좋아요 방지를 DB 레벨에서 보장. 앱 레벨 검증만으로는 동시성 이슈 가능 | 앱 레벨만 (경쟁 조건에 취약) | +| 6 | likes에 UNIQUE(member_id, subject_type, subject_id) | 중복 좋아요 방지를 DB 레벨에서 보장. subjectType+subjectId 일반화로 단일 테이블에서 모든 좋아요 타입의 중복 차단 | 앱 레벨만 (경쟁 조건에 취약), 타입별 테이블 분리 (UNIQUE는 쉬우나 스키마 변경 필요) | | 7 | brand.name에 UNIQUE 제약 | 이름 중복 불가 요구사항. delete 시 이름 변경으로 UNIQUE 해소 (클래스 다이어그램 결정 #3) | UNIQUE 없이 앱 검증만 (동시성에 취약) | +| 8 | like → likes 테이블명 | LIKE는 SQL 예약어. orders(#3)와 동일한 이유로 복수형 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | + +--- + +## 5. 무FK 운영 규약 + +> FK 제약조건 없이 참조 무결성을 보장하기 위한 앱 레벨 규칙. +> 각 참조 컬럼에 대해 **누가, 언제, 어떻게** 무결성을 검증하는지 명시한다. + +### 참조 무결성 검증 매트릭스 + +| 참조 컬럼 | 참조 대상 | 검증 시점 | 검증 주체 | 검증 방법 | +|-----------|----------|----------|----------|----------| +| product.brand_id | brand.id | 상품 등록 | AdminProductFacade | `BrandService.getBrand()` + `Brand.guardNotDeleted()` | +| likes.member_id | member.id | 좋아요 등록 | 인증 컨텍스트 | 인증된 memberId만 사용 (암묵적 검증) | +| likes.subject_id | product.id | 좋아요 등록 | LikeService | `ProductService.getActiveProduct()` (상품+브랜드 활성 확인) | +| orders.member_id | member.id | 주문 생성 | 인증 컨텍스트 | 인증된 memberId만 사용 (암묵적 검증) | +| order_line.order_id | orders.id | 주문 생성 | OrderService | Order와 함께 생성 (Composition, 독립 생성 불가) | +| order_line.product_id | product.id | 주문 생성 | OrderService | `ProductService.getProductForOrder()` (비관적 락 + 활성 확인) | +| order_line_snapshot.order_line_id | order_line.id | 주문 생성 | OrderService | OrderLine과 함께 생성 (1:1 종속, 독립 생성 불가) | + +### 삭제 시 참조 보호 규칙 + +| 삭제 대상 | 영향 받는 테이블 | 처리 방식 | 처리 주체 | +|-----------|----------------|----------|----------| +| brand (soft-delete) | product | 연쇄 soft-delete | AdminBrandFacade → `ProductService.softDeleteByBrandId()` | +| brand (soft-delete) | likes | 처리 없음 | 목록 조회 시 LikeRepository가 자연 필터링 | +| product (soft-delete) | likes | 처리 없음 | 목록 조회 시 LikeRepository가 자연 필터링 | +| product (soft-delete) | order_line, order_line_snapshot | 영향 없음 | 스냅샷이므로 원본 상태와 무관 | + +### 고아 레코드 방지 원칙 + +1. **쓰기 시점 검증**: 참조 대상의 존재·활성 여부는 **레코드 생성 시점**에 앱 레벨에서 반드시 검증한다. +2. **읽기 시점 필터링**: 참조 대상이 이후 삭제되더라도, 조회 쿼리에서 활성 필터링으로 자연스럽게 제외한다. +3. **스냅샷 불변성**: order_line_snapshot은 생성 후 변경되지 않으므로, 원본 삭제와 무관하게 기록이 유지된다. +4. **취소는 무검증**: 좋아요 취소 시 상품/브랜드 상태를 확인하지 않는다 (요구사항: 삭제된 브랜드의 좋아요도 취소 가능). + +--- + +## 6. 인덱스 전략 + +> 시퀀스 다이어그램의 쿼리 패턴에서 도출한 인덱스 정의. +> PK 인덱스는 생략한다. + +### member + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| uk_member_login_id | login_id | UNIQUE | `findByLoginId`, `existsByLoginId` | + +### brand + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| uk_brand_name | name | UNIQUE | `existsByName`, `existsByNameAndIdNot` | + +### product + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_product_brand_id | brand_id | INDEX | `findAllByBrandId`, `softDeleteByBrandId` | + +### likes + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| uk_likes_member_subject | (member_id, subject_type, subject_id) | UNIQUE | `existsByMemberIdAndSubjectTypeAndSubjectId`, `findByMemberIdAndSubjectTypeAndSubjectId` | + +> `findProductLikesByMemberId(memberId, page, size)`는 `uk_likes_member_subject`의 선두 컬럼 `(member_id, subject_type)`으로 커버된다. + +### orders + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_orders_member_ordered | (member_id, ordered_at) | INDEX | `findByMemberIdAndPeriod` | + +### order_line + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_ol_order_id | order_id | INDEX | Order와 함께 로딩 | + +### order_line_snapshot + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_ols_order_line_id | order_line_id | INDEX | OrderLine과 함께 로딩 (1:1) | diff --git a/docs/design/05-domain-model.md b/docs/design/05-domain-model.md new file mode 100644 index 000000000..85f9d6fa9 --- /dev/null +++ b/docs/design/05-domain-model.md @@ -0,0 +1,227 @@ +# 도메인 모델 정의서 + +> 작성일: 2026-02-22 +> 상태: Draft (SoT 후보) +> 기반 문서: `docs/design/01-requirements.md`, `docs/design/02-sequence-diagrams.md`, `docs/design/03-class-diagram.md`, `docs/design/04-erd.md` + +--- + +## 1. 목적 + +이 문서는 현재 프로젝트의 도메인 모델 경계와 레이어 책임을 명확히 정의한다. +특히 다음 질문에 답한다. + +- 어떤 바운디드 컨텍스트(BC)가 현재 존재하는가? +- Like는 현재 어디에 속하며, 언제 독립 BC로 전환하는가? +- Application Service와 Domain Service의 책임 경계는 무엇인가? +- Repository Port, 트랜잭션, 외부 의존의 허용 범위는 어디까지인가? + +--- + +## 2. 현재 BC 경계 + +현재 기준 BC는 다음 4개다. + +1. `Member Context` +- 책임: 회원 가입, 인증 기반 식별, 비밀번호 변경, 내 정보 조회 +- Aggregate: `Member` + +2. `Catalog Context` +- 책임: 브랜드/상품 관리 및 조회 +- Aggregate: `Brand`, `Product` + +3. `Like Context` +- 책임: 사용자의 특정 대상에 대한 관심/호감 표현 관리. 서비스가 사용자와의 계약을 통해 얻는 선호도 데이터 +- Aggregate: `Like` +- 모델: `Like(memberId, subjectType, subjectId)` + `mark()`, `isOwnedBy()`, `isForSubject()` +- 비정규화 관계: Product.likesCount(인기도)는 Catalog BC가 소유. Like BC의 개별 레코드가 원본이며, likesCount는 BC 경계를 사유로 한 정당한 비정규화 + +4. `Order Context` +- 책임: 주문 생성/조회, 주문 스냅샷 보존, 수락/거절 판단 +- Aggregate: `Order` (+ `OrderLine` Entity, `OrderLineSnapshot` VO) +- 참고: 재고 차감은 Catalog Context(Product)의 책임이며, Order Context는 ProductService를 통해 요청만 한다. + +참고: +- BC 간 참조는 객체 참조가 아닌 ID(Long) 참조만 사용한다. +- FK 제약조건을 사용하지 않는다. 참조 무결성은 애플리케이션 레벨에서 검증한다. + - 이유: 운영 유연성 확보 + MSA 전환 시 FK로 인한 확장성 제한 제거 + - 같은 BC 내부(예: product.brand_id → brand.id)에도 동일하게 적용한다. + +--- + +## 3. Like BC의 현재 위치와 확장 방향 + +### 3-1. 현재 모델 + +- 엔티티: `Like` +- 식별: `memberId + subjectType + subjectId` +- 저장: `likes` 테이블 단일 구조 +- 제약: `UNIQUE(member_id, subject_type, subject_id)` + +### 3-2. 현재 `subjectType` + +- 현재 값: `PRODUCT` +- 확장 예정 값: `BRAND`, `SELLER` 등 + +### 3-3. Preference BC 전환 기준 + +다음 조건 중 1개 이상 만족 시 `Like Context`를 `Preference Context`로 승격 검토한다. + +1. `subjectType`이 2종 이상으로 확장되고 타입별 정책 분기가 발생할 때 +2. 좋아요 외 선호 행위(북마크, 팔로우, 숨김 등)가 추가될 때 +3. 랭킹/추천 파이프라인과 독립 배포 경계가 필요할 때 + +--- + +## 4. 레이어 책임 규칙 + +### 4-1. Domain Layer — 논리적 영역 + +- 역할: **논리적 영역의 비즈니스 규칙** 처리. 불변식, 상태 전이, 도메인 모델 캡슐화 +- 기준: "이 규칙은 기술 구현과 무관하게 항상 성립하는가?" → Yes라면 Domain +- 허용: JPA 매핑 어노테이션 (`@Entity`, `@Embeddable`, `@MappedSuperclass`, `@Table`, `@Column` 등) +- 금지: HTTP/Kafka/Redis 등 인프라 세부 구현 직접 의존 + +### 4-2. Application Layer — 물리적 영역 + +- 역할: **물리적 영역의 비즈니스 로직** 처리. 유스케이스 오케스트레이션(조정자) +- 기준: 도메인 레이어에서 전부 해결하지 못하는 경우 Application으로 올라온다 +- Application으로 올라오는 조건: + 1. **BC 경계를 넘는 조율**: 서로 다른 BC의 도메인 객체/서비스를 조합해야 할 때 (각 BC의 논리적 규칙은 해당 Domain이 처리하고, Application은 이를 오케스트레이션) + 2. **외부 인프라 의존**: 변경점이 많은 프레임워크/서비스/모듈(HTTP, Kafka, Redis 등)을 써야 할 때 + 3. **물리적 기술 관심사**: 트랜잭션 경계 관리, 데드락 방지 정렬, 락 획득 순서 등 +- 책임: + - 트랜잭션 경계 소유 (@Transactional) + - Cross-BC 오케스트레이션 + - 외부 Port 호출 조합 + +### 4-3. Domain Service + +- 역할: 같은 BC 내에서 단일 엔티티에 귀속되지 않는 도메인 규칙 처리 +- 허용: + - 도메인 객체 사용 + - Repository Port 호출 (`find`, `save`) — DIP된 포트이므로 도메인 레이어에서 사용 가능 +- 금지: + - 트랜잭션 애노테이션 소유 + - 외부 시스템 직접 호출(HTTP/Kafka/Redis) +- 호출 규칙: + - Domain Service는 **반드시 Application Service를 통해 호출**된다 + - Controller → Domain Service 직접 호출 금지 (트랜잭션 없이 save가 실행되는 것을 방지) + +### 4-4. Facade + +- 역할: **Application Service 간 순환 참조 해소** +- 위치: Application 레이어 +- 도입 기준: Application Service A가 Application Service B를 필요로 하고, B도 A를 필요로 할 때 +- 주의: 같은 BC 내 cross-aggregate 규칙은 Facade가 아닌 Domain Service로 해결한다 + - 예: Brand 삭제 → Product 연쇄 삭제는 같은 BC(Catalog)이므로 `BrandDeleteService`가 처리 + +--- + +## 5. Service 분류 기준 + +### 핵심 기준: 논리적인가, 물리적인가? + +| 질문 | 위치 | +|------|------| +| 이 규칙은 기술 구현과 무관하게 항상 성립하는가? (논리적) | Domain (Entity/VO/Domain Service) | +| 이 로직은 기술적 조율이 필요한가? (물리적) | Application Service | + +### 세부 판별 순서 + +1. 이 로직이 단일 엔티티의 상태 전이/불변식 판단인가? + - Yes → Entity/VO + +2. 이 로직이 같은 BC 내부 규칙이지만 단일 엔티티에 넣기 어려운가? (cross-aggregate 규칙) + - Yes → Domain Service + +3. 이 로직이 BC 경계를 넘는 조율인가? + - Yes → Application Service (각 BC의 논리적 규칙은 해당 Domain이 처리, Application은 오케스트레이션) + +4. 이 로직이 물리적 기술 관심사인가? (트랜잭션, 데드락 방지, 락 순서 등) + - Yes → Application Service + +5. Application Service 간 순환 참조가 발생하는가? + - Yes → Facade로 해소 + +### 예시: 주문 생성 + +| 로직 | 분류 | 이유 | +|------|------|------| +| 중복 상품 검증 | Domain (Order.place) | "같은 상품 중복 주문 불가" = Aggregate Root가 직접 검증하는 논리적 비즈니스 규칙 | +| productId 정렬 | Application | 데드락 방지 = 물리적/기술적 관심사 | +| 재고 충분 여부 확인 | Domain (Stock.isEnough) | Stock의 불변식 = 논리적 | +| 수락/거절 판단 | Domain (Order.place) | "전부 아니면 전무" = Aggregate Root가 상태를 결정하는 논리적 비즈니스 규칙 | +| 위 흐름의 오케스트레이션 | Application (OrderService) | Product 조회 + 락 획득 + 트랜잭션 = 물리적 | + +### 예시: 좋아요 등록 (Cross-BC) + +| 로직 | 분류 | 이유 | +|------|------|------| +| "좋아요 대상은 유효해야 한다" | Domain (Like BC 규칙) | 논리적 비즈니스 규칙 | +| 상품 활성 여부 확인 | Domain (Catalog BC — Product/Brand) | 논리적. 각 BC가 자기 규칙을 처리 | +| 위 흐름의 오케스트레이션 | Application (LikeService) | Cross-BC 조율 = 물리적 | + +--- + +## 6. 규칙 반복 시 승격 기준 + +동일 규칙이 아래 기준을 만족하면 도메인 레이어로 승격한다. + +1. 두 개 이상의 Application Service에서 동일 규칙이 반복된다. +2. 규칙 변경 시 두 곳 이상 수정이 필요하다. +3. 규칙 테스트가 Service 테스트에서만 간접 검증되고 있다. + +승격 원칙: +- 불변식이면 Entity/VO로 이동 +- 단일 엔티티에 담기 어렵다면 Domain Service로 이동 + +--- + +## 7. Aggregate 규칙 + +### 7-1. Aggregate Root 원칙 + +- Aggregate 내부 객체는 **Root를 통해서만** 외부에 노출된다. +- Repository는 **Aggregate Root에 대해서만** 존재한다. +- Aggregate 간 참조는 **ID(Long)만** 사용한다. + +### 7-2. 현재 Aggregate 구조 + +| BC | Aggregate Root | 내부 객체 (VO) | Repository | +|-----|---------------|---------------|-----------| +| Member | Member | LoginId, Password, MemberName, Email | MemberRepository | +| Catalog | Brand | (없음) | BrandRepository | +| Catalog | Product | Price, Stock | ProductRepository | +| Like | Like | (없음) | LikeRepository | +| Order | Order | (OrderLine, OrderLineSnapshot은 ID 참조) | OrderRepository | + +### 7-3. Catalog BC: Brand와 Product가 독립 Aggregate인 이유 + +- **독립적 생명주기**: Product 없이 Brand만 존재 가능 +- **규모 차이**: 하나의 Brand에 수천 개 Product가 소속 가능. Brand Aggregate에 Product를 포함하면 메모리/성능 문제 +- **독립 변경**: Product 가격/재고 수정 시 Brand를 잠글 필요 없음 + +Brand 삭제 시 소속 Product 연쇄 삭제는 **BrandDeleteService**에서 처리한다. 상품 등록 시 Brand 활성 검증은 Application Service에서 오케스트레이션한다. + +### 7-4. 트랜잭션 경계 + +- **기본 원칙**: 하나의 트랜잭션에서 하나의 Aggregate만 변경한다. +- **같은 BC 내 예외**: 같은 BC 안에서 cross-aggregate 변경이 필요한 경우, Domain Service가 같은 트랜잭션에서 처리할 수 있다. + - 예: Brand 삭제 → Product 연쇄 삭제 (BrandDeleteService, 같은 트랜잭션) +- **다른 BC 간**: 현재는 Application Service가 같은 트랜잭션에서 조율한다. 규모 확장 시 이벤트 기반(eventual consistency)으로 전환을 검토한다. + +--- + +## 8. 향후 문서 반영 포인트 + +다음 문서와의 정합성을 함께 유지한다. + +1. `docs/design/03-class-diagram.md` +- Like/Preference 확장 문단과 본 문서의 BC 정의를 동일하게 유지 + +2. `docs/design/04-erd.md` +- `likes(subject_type, subject_id)` 구조와 본 문서의 Like BC 정의를 동일하게 유지 + +3. `docs/design/base/domain-definition-v2.md` (ARCHIVE) +- 히스토리 참고만 허용, 현재 설계 판단의 직접 근거로 사용하지 않음 diff --git a/docs/design/06-architecture.md b/docs/design/06-architecture.md new file mode 100644 index 000000000..2fb375aa3 --- /dev/null +++ b/docs/design/06-architecture.md @@ -0,0 +1,804 @@ +# 아키텍처 설계서 + +> 작성일: 2026-02-22 +> +> 기반 문서: `01-requirements.md` ~ `05-domain-model.md` + +--- + +## 1. 이 문서의 목적 + +이 문서는 다음 세 가지 질문에 답한다. + +1. **WHY** — 왜 이 레이어 구조인가? 각 레이어는 어떤 문제를 해결하기 위해 존재하는가? +2. **WHERE** — 새 기능이 추가될 때 코드는 어디에 놓는가? +3. **BOUNDARY** — 현재 구조가 어떤 확장을 지원하고, 어떤 제약이 있는가? + +기존 설계 문서(01~05)는 요구사항, 시퀀스, 클래스, ERD, 도메인 모델을 각각 다룬다. 이 문서는 그것들 위에서 **"왜 이 구조인가"를 종합**하는 조감도 역할이다. + +--- + +## 2. 아키텍처 전체 조감도 + +### 2-1. 레이어 구성도 + +```mermaid +graph TB + subgraph Presentation["Presentation Layer"] + direction LR + P_CTRL["Controller"] + P_DTO["API DTO"] + P_RES["ApiResponse\nApiControllerAdvice"] + end + + subgraph Application["Application Layer"] + direction LR + A_SVC["Service"] + A_FACADE["Facade"] + A_DTO["Command / Query DTO"] + end + + subgraph Domain["Domain Layer"] + direction LR + D_ENT["Entity"] + D_VO["VO"] + D_PORT["Repository\n(interface)"] + D_DS["Domain Service"] + D_ERR["ErrorType\nCoreException"] + end + + subgraph Infrastructure["Infrastructure Layer"] + direction LR + I_IMPL["RepositoryImpl"] + I_JPA["JpaRepository"] + end + + Presentation -->|depends on| Application + Application -->|depends on| Domain + Infrastructure -->|"implements\n(Repository)"| Domain +``` + +### 2-2. 읽는 포인트 + +- **의존 방향은 항상 Domain을 향한다.** Domain은 아무것도 의존하지 않는다. +- **Infrastructure는 Domain의 Repository를 구현**한다. Domain이 정의한 Repository(interface)를 Infrastructure가 RepositoryImpl로 구현한다. +- **Presentation이 3개**(HTTP, Batch, Kafka)다. 같은 Application을 서로 다른 프로토콜로 노출한다. ErrorType에 HttpStatus를 넣지 않은 이유가 여기에 있다. + +### 2-3. 물리적 디렉토리 구조 + +``` +Root +├── domain/ [Domain Layer] +├── application/ +│ └── commerce-service/ [Application Layer] +├── presentation/ +│ ├── commerce-api/ [Presentation — REST API] +│ ├── commerce-batch/ [Presentation — Spring Batch] +│ └── commerce-streamer/ [Presentation — Kafka Consumer] +├── modules/ +│ ├── jpa/ [Infrastructure — DB] +│ ├── redis/ [Infrastructure — Cache] +│ └── kafka/ [Infrastructure — Messaging] +└── supports/ + ├── jackson/ [Cross-cutting — Serialization] + ├── logging/ [Cross-cutting — Logging] + └── monitoring/ [Cross-cutting — Metrics] +``` + +--- + +## 3. 레이어 상세 설계 + +### 3-1. Domain Layer (논리적 영역) + +#### 존재 이유 + +**"기술 구현과 무관하게 항상 성립하는 비즈니스 규칙"**을 격리한다. + +Spring Boot가 Django로 바뀌어도, MySQL이 MongoDB로 바뀌어도, 이 레이어의 코드는 변하지 않아야 한다. "재고는 음수가 될 수 없다", "같은 회원이 같은 상품에 중복 좋아요 불가" — 이런 규칙은 기술 스택과 무관하다. + +#### 허용 / 금지 + +| 허용 | 금지 | +|------|------| +| JPA 매핑 어노테이션 (`@Entity`, `@Embeddable`, `@MappedSuperclass`, `@Table`, `@Column`) | `@Transactional`, `@Service` | +| `@Component` (Domain Service용) | `HttpStatus`, Spring Web | +| Repository (interface 정의) | Kafka, Redis, 외부 HTTP 클라이언트 | + +> **왜 JPA 어노테이션과 `@Component`를 허용하는가?** +> `jakarta.persistence-api`는 인터페이스 수준의 표준 스펙이다. `spring-context`의 `@Component`도 Domain Service를 빈으로 등록하기 위한 최소한의 의존이다. 순수성을 고집하면 `@Bean` Config 클래스가 필요해져 Domain Service 추가마다 관리 포인트가 늘어난다. 이 프로젝트에서 Spring을 벗어날 가능성은 현실적으로 없으므로, 실용성을 우선한다. + +#### 클래스 목록 + +**공통 기반** + +| 클래스 | 종류 | 책임 | +|--------|------|------| +| `BaseTimeEntity` | @MappedSuperclass | `id`, `createdAt`, `updatedAt` 자동 관리. soft-delete가 불필요한 엔티티용 | +| `BaseEntity` | @MappedSuperclass | `BaseTimeEntity` 상속 + `deletedAt`, `delete()`, `restore()`. soft-delete가 필요한 엔티티용 | +| `ErrorType` | enum | 비즈니스 실패 분류. `code`와 `message`만 보유. **HttpStatus를 모른다** | +| `CoreException` | RuntimeException | `ErrorType` + 선택적 `customMessage` | + +> **왜 BaseTimeEntity / BaseEntity를 분리하는가?** +> 삭제 정책을 **상속 구조로 명시**하기 위해서다. Like(hard-delete)와 Order(삭제 없음)에 `deletedAt` 컬럼은 불필요하다. 어떤 엔티티가 `BaseEntity`를 상속하면 "이 엔티티는 soft-delete를 사용한다"는 설계 의도가 코드 레벨에서 드러난다. + +```java +BaseTimeEntity (id, createdAt, updatedAt) + ├── BaseEntity + deletedAt + │ ├── Brand (soft-delete) + │ └── Product (soft-delete) + │ + └── BaseTimeEntity만 + ├── Member (탈퇴 미구현, 향후 결정) + ├── Like (hard-delete) + └── Order (삭제 없음, 불변) +``` + +**BC별 클래스** + +| BC | 클래스 | 종류 | 책임 | +|----|--------|------|------| +| Member | `Member` | Entity | 회원 등록, 비밀번호 검증/변경. VO에 규칙 위임 | +| Member | `LoginId`, `Password`, `MemberName`, `Email` | VO (@Embeddable) | 각 필드의 자체 규칙 캡슐화 (길이, 형식, 암호화) | +| Member | `MemberRepository` | interface | 회원 조회/저장 계약 | +| Member | `MemberExceptionMessage` | enum | 예외 메시지 상수 | +| Catalog | `Brand` | Entity | 브랜드 CRUD, `delete()` override (guardNotDeleted + name suffix로 UNIQUE 해소) | +| Catalog | `Product` | Entity | 상품 CRUD, VO 위임 (`hasEnoughStock`, `decreaseStock`) | +| Catalog | `Price` | VO (@Embeddable) | 가격 > 0 자체 검증 | +| Catalog | `Stock` | VO (@Embeddable) | 재고 >= 0 자체 검증, `isEnough(Quantity)`, `decrease(Quantity)` | +| Catalog | `BrandDeleteService` | Domain Service | Brand 삭제 시 소속 Product 연쇄 soft-delete | +| Catalog | `BrandRepository`, `ProductRepository` | interface | Catalog 조회/저장 계약 | +| Like | `Like` | Entity | 관계 레코드 (hard-delete). `subjectType(enum) + subjectId(Long)` | +| Like | `LikeRepository` | interface | 좋아요 조회/저장 계약 | +| Order | `Order` | Entity | Aggregate Root. `place()`로 불변식 검증(빈 주문, 중복 상품), `assignOrderLines()`로 하위 소속 관리, `isOwnedBy(memberId)` | +| Order | `OrderLine` | Entity | 주문 항목. `orderId(Long)`로 Order 참조. `of()`에서 스냅샷 내부 생성. `assignToOrder()` / `assignSnapshot()`은 self 반환(연산의 닫힘) | +| Order | `OrderLineSnapshot` | VO (@Entity) | 주문 시점 불변 스냅샷. `orderLineId(Long)`로 OrderLine 참조. 정규화를 위해 별도 테이블 | +| Order | `Quantity` | VO (@Embeddable) | 수량 > 0 자체 검증 | +| Order | `OrderRepository` | interface | 주문 조회/저장 계약 | + +#### 패키지 구조 + +Domain 레이어는 **BC 단위로 패키지를 구성**한다. Brand와 Product는 같은 Catalog BC이므로 `catalog/` 하위에 배치하여, 디렉토리만 봐도 BC 경계가 드러나게 한다. + +> 상위 레이어(Application, Presentation, Infrastructure)는 BC 기준 패키지를 적용하지 않는다. 상위 레이어는 Admin/User 분리, Cross-BC 조합 등 도메인 경계와 다른 기준으로 클래스가 구성되므로, 별도의 패키지 전략을 적용한다. + +``` +com.loopers +├── domain/ +│ ├── BaseTimeEntity.java +│ ├── BaseEntity.java +│ ├── member/ +│ │ ├── Member.java +│ │ ├── MemberRepository.java +│ │ ├── MemberExceptionMessage.java +│ │ └── vo/ +│ │ ├── LoginId.java +│ │ ├── Password.java +│ │ ├── MemberName.java +│ │ └── Email.java +│ ├── catalog/ +│ │ ├── BrandDeleteService.java +│ │ ├── brand/ +│ │ │ ├── Brand.java +│ │ │ ├── BrandRepository.java +│ │ │ └── BrandExceptionMessage.java +│ │ └── product/ +│ │ ├── Product.java +│ │ ├── ProductRepository.java +│ │ ├── ProductExceptionMessage.java +│ │ └── vo/ +│ │ ├── Price.java +│ │ └── Stock.java +│ ├── like/ +│ │ ├── Like.java +│ │ ├── LikeRepository.java +│ │ └── LikeSubjectType.java +│ └── order/ +│ ├── Order.java +│ ├── OrderRepository.java +│ ├── OrderLine.java +│ ├── OrderLineSnapshot.java +│ ├── OrderStatus.java +│ └── vo/ +│ └── Quantity.java +└── support/ + └── error/ + ├── ErrorType.java + └── CoreException.java +``` + +--- + +### 3-2. Application Layer (물리적 영역) + +#### 존재 이유 + +**Domain만으로 해결할 수 없는 "물리적/기술적 관심사"**를 처리한다. + +Domain이 "논리적으로 항상 성립하는 규칙"이라면, Application은 "그 규칙을 실행하기 위해 필요한 기술적 조율"이다. + +#### Application으로 올라오는 3가지 조건 + +1. **BC 경계를 넘는 조율**: 서로 다른 BC의 도메인 객체/서비스를 조합해야 할 때 + - 예: LikeService가 ProductService를 호출하여 상품 유효성 확인 +2. **외부 인프라 의존**: 변경점이 많은 프레임워크/서비스를 써야 할 때 + - 예: HTTP 클라이언트, Kafka Producer, Redis Cache +3. **물리적 기술 관심사**: 트랜잭션 경계, 데드락 방지 정렬, 락 획득 순서 등 + - 예: OrderService에서 productId 오름차순 정렬 후 비관적 락 획득 + +#### 허용 / 금지 + +| 허용 | 금지 | +|------|------| +| `@Service`, `@Transactional` | `@Controller`, `@RequestMapping` | +| Domain 객체 사용, Repository 호출 | HttpStatus, HttpServletRequest | +| Domain Service 호출 | Presentation DTO 직접 사용 | + +> **왜 spring-web을 포함하지 않는가?** +> Application 모듈의 `build.gradle.kts`에는 `spring-tx`와 `spring-context`만 있다. `spring-web`이 없으므로 Application은 HTTP의 존재를 모른다. 이것이 같은 Application을 HTTP(commerce-api), Batch(commerce-batch), Kafka(commerce-streamer) 세 가지 Presentation에서 재사용할 수 있는 근본 이유다. + +```kotlin +// application/commerce-service/build.gradle.kts +dependencies { + api(project(":domain")) + implementation("org.springframework:spring-tx") // @Transactional + implementation("org.springframework:spring-context") // @Service, DI + // spring-web 없음 +} +``` + +#### 클래스 목록 + +| 클래스 | BC | 책임 | +|--------|-----|------| +| `MemberService` | Member | 회원 등록, 조회, 비밀번호 변경 오케스트레이션 | +| `BrandService` | Catalog | 활성 브랜드 목록 조회 (User) | +| `AdminBrandService` | Catalog | 브랜드 CRUD. 삭제 시 BrandDeleteService 호출 | +| `AdminProductService` | Catalog | 상품 CRUD. 등록 시 Brand 활성 여부 확인 | +| `ProductService` | Catalog | 상품 조회, 수정, 삭제, 주문용 락 조회 | +| `LikeService` | Like | 좋아요 등록/취소/조회. Cross-BC 유효성 확인 (ProductService 호출) | +| `OrderService` | Order | 주문 생성 오케스트레이션 (정렬, 락, 스냅샷, 수락/거절) | + +**DTO 분류** + +| 종류 | 역할 | 네이밍 규칙 | 예시 | +|---------|------|-----------|------| +| Command | 외부 → Application 요청 (상태 변경) | `{Domain}{Action}Command` | `BrandCreateCommand`, `MemberRegisterCommand` | +| Info | Application → 외부 응답 (조회 결과) | `{Domain}Info` | `BrandInfo`, `MemberInfo` | + +> DTO는 Java `record`로 구현한다. 불변이며, HTTP 관심사(status code, header)를 알지 않는다. + +#### Domain Service 호출 규칙 + +``` +Controller → Application Service → Domain Service → Repository + (@Transactional) (트랜잭션 안에서 동작) +``` + +**Controller → Domain Service 직접 호출을 금지하는 이유**: Domain Service가 `Repository.save()`를 호출하는데, 트랜잭션 없이 save가 실행되면 DB 일관성이 깨진다. Application Service가 `@Transactional` 경계를 소유하므로, Domain Service는 이 경계 안에서만 동작해야 한다. + +#### Facade 규칙 + +Facade는 **Application Service 간 순환 참조를 해소**하기 위해서만 도입한다. 같은 BC 내 cross-aggregate 규칙은 Facade가 아닌 Domain Service로 해결한다. + +| 구분 | Facade | Domain Service | +|------|--------|----------------| +| 위치 | Application 레이어 | Domain 레이어 | +| 역할 | Application Service 간 순환 참조 해소 | 같은 BC 내 cross-aggregate 규칙 | +| 예시 | (현재 해당 없음) | BrandDeleteService (Brand 삭제 → Product 연쇄) | +| 의존 | 여러 Application Service 주입 | 도메인 객체 + Repository | + +#### 패키지 구조 + +``` +com.loopers.application +├── service/ +│ ├── MemberService.java +│ ├── BrandService.java +│ ├── AdminBrandService.java +│ ├── AdminProductService.java +│ ├── ProductService.java +│ ├── LikeService.java +│ ├── OrderService.java +│ └── dto/ +│ ├── BrandCreateCommand.java +│ ├── BrandUpdateCommand.java +│ ├── BrandInfo.java +│ ├── MemberRegisterCommand.java +│ ├── MemberInfo.java +│ └── ... +└── facade/ + └── (현재 비어 있음 — 순환 참조 발생 시 도입) +``` + +--- + +### 3-3. Presentation Layer + +#### 존재 이유 + +**"어떤 프로토콜로 외부와 소통하는가"**를 격리한다. + +같은 비즈니스 로직(Application)을 HTTP, Batch, Kafka 세 가지 인터페이스로 노출할 수 있다. 이것이 ErrorType에서 HttpStatus를 분리한 근본 이유다. + +```mermaid +graph LR + subgraph Presentation + API["commerce-api\n(HTTP)"] + BATCH["commerce-batch\n(Batch)"] + STREAMER["commerce-streamer\n(Kafka)"] + end + + subgraph ErrorHandling["에러 해석 — 각 Presentation이 자기 프로토콜로 해석"] + API_ERR["ErrorType → HttpStatus\n(ApiControllerAdvice)"] + BATCH_ERR["ErrorType → ExitCode"] + STREAM_ERR["ErrorType → DLQ / Retry"] + end + + SVC["Application Layer\ncommerce-service"] + + API --> SVC + BATCH --> SVC + STREAMER --> SVC + API --- API_ERR + BATCH --- BATCH_ERR + STREAMER --- STREAM_ERR +``` + +> **ErrorType은 "무슨 종류의 실패인가"만 표현한다.** "어떻게 응답할 것인가"는 Presentation이 결정한다. commerce-api는 HttpStatus로, commerce-batch는 ExitCode로, commerce-streamer는 DLQ/Retry로 해석한다. 이것이 Domain에 HttpStatus를 넣지 않은 이유다. + +#### ErrorType → HttpStatus 매핑 (commerce-api) + +```java +// ApiControllerAdvice.java — Presentation 레이어에서 해석 +private HttpStatus toHttpStatus(ErrorType errorType) { + return switch (errorType) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; // 400 + case NOT_FOUND -> HttpStatus.NOT_FOUND; // 404 + case CONFLICT -> HttpStatus.CONFLICT; // 409 + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; // 401 + case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; // 500 + }; +} +``` + +#### 클래스 목록 (commerce-api 기준) + +| 클래스 | 책임 | +|--------|------| +| `ApiResponse` | 통합 응답 래퍼. `Metadata(result, errorCode, message) + data` | +| `ApiControllerAdvice` | `CoreException` → `ErrorType` → `HttpStatus` 매핑. 프레임워크 예외도 일관된 응답으로 변환 | +| `MemberController` | 회원 HTTP 엔드포인트. 헤더 인증 | +| `BrandController` | 사용자용 브랜드 조회 | +| `AdminBrandController` | 관리자용 브랜드 CRUD | +| `ProductController` | 사용자용 상품 조회 | +| `AdminProductController` | 관리자용 상품 CRUD | +| `LikeController` | 좋아요 등록/취소/조회 | +| `OrderController` | 회원 주문 생성/조회 | +| `AdminOrderController` | 관리자 주문 조회 | +| `Presentation DTO` | API 전용 Request/Response. Application DTO와 분리 | + +#### Presentation DTO vs Application DTO + +| 레이어 | DTO 위치 | 역할 | 예시 | +|--------|---------|------|------| +| Presentation | `interfaces/api/{도메인}/dto/` | HTTP 계약 (Request Body, Response Body) | `BrandCreateApiRequest`, `BrandApiResponse` | +| Application | `application/service/dto/` | Use Case 계약 (프로토콜 무관) | `BrandCreateCommand`, `BrandInfo` | + +> **왜 분리하는가?** Presentation DTO는 API 클라이언트와의 계약이고, Application DTO는 Use Case의 계약이다. 분리하면 API 스펙 변경이 Domain/Application에 영향을 주지 않고, 같은 Application을 다른 Presentation(Batch, Kafka)에서도 재사용할 수 있다. + +#### 패키지 구조 (commerce-api) + +``` +com.loopers.interfaces.api +├── ApiResponse.java +├── ApiControllerAdvice.java +├── member/ +│ ├── MemberController.java +│ └── dto/ ... +├── brand/ +│ ├── BrandController.java +│ ├── AdminBrandController.java +│ └── dto/ ... +├── product/ ... +├── like/ ... +└── order/ ... +``` + +--- + +### 3-4. Infrastructure Layer (modules/) + +#### 존재 이유 + +**Repository 인터페이스의 구현체**가 위치한다. Domain이 정의한 Repository(interface)를 JPA/Redis/Kafka로 구현한다. + +#### 클래스 목록 (modules/jpa 기준) + +| 클래스 | 종류 | 책임 | +|--------|------|------| +| `MemberJpaRepository` | Spring Data JPA | JPA 쿼리 정의 (`findByLoginId_Value`) | +| `MemberRepositoryImpl` | 구현체 | `MemberRepository` 인터페이스 구현, JpaRepository에 위임 | +| `BrandJpaRepository` | Spring Data JPA | Brand JPA 쿼리 | +| `BrandRepositoryImpl` | 구현체 | `BrandRepository` 인터페이스 구현 | +| `JpaConfig` | Configuration | `@EntityScan`, `@EnableJpaRepositories` | +| `QueryDslConfig` | Configuration | `JPAQueryFactory` 빈 등록 | +| `DataSourceConfig` | Configuration | DataSource 설정 (HikariCP) | + +#### Repository 추상체 — 구현체 + +``` +Domain (추상체 정의) Infrastructure (구현체) +┌─────────────────┐ ┌─────────────────────────┐ +│ MemberRepository │◄───────│ MemberRepositoryImpl │ +│ (interface) │ │ (@Repository) │ +└─────────────────┘ │ └── MemberJpaRepository│ + │ (Spring Data JPA) │ + └─────────────────────────┘ +``` + +> **@Embedded 필드 쿼리 규칙**: VO가 `@Embeddable`일 때 Spring Data JPA는 `findByLoginId_Value` 형태(언더스코어로 내부 필드 접근)를 사용한다. + +#### 패키지 구조 + +``` +com.loopers +├── config/ +│ └── jpa/ +│ ├── JpaConfig.java +│ ├── QueryDslConfig.java +│ └── DataSourceConfig.java +└── infrastructure/ + ├── member/ + │ ├── MemberJpaRepository.java + │ └── MemberRepositoryImpl.java + ├── brand/ ... + ├── product/ ... + └── ... +``` + +--- + +### 3-5. Cross-cutting Concerns (supports/) + +#### 존재 이유 + +모든 Presentation 모듈이 공유하는 인프라 설정이다. **레이어가 아닌 add-on** 성격이며, 비즈니스 로직을 모른다. + +| 모듈 | 책임 | +|------|------| +| `supports/jackson` | ObjectMapper 설정 (JSR-310, NON_NULL, FAIL_ON_UNKNOWN_PROPERTIES 비활성화) | +| `supports/logging` | Logback 설정 + Slack Appender (에러 알림) | +| `supports/monitoring` | Prometheus + Micrometer 메트릭 노출 | + +--- + +## 4. 의존 방향과 DIP + +### 4-1. 의존 방향 + +```mermaid +graph TB + P["Presentation\n(commerce-api / batch / streamer)"] + A["Application\n(commerce-service)"] + D["Domain"] + I["Infrastructure\n(modules/jpa, redis, kafka)"] + S["Supports\n(jackson, logging, monitoring)"] + + P -->|depends on| A + P -->|depends on| I + P -->|depends on| S + A -->|depends on| D + I -->|depends on| D + D -->|depends on| NOTHING["없음\n(jakarta.persistence-api만)"] +``` + +**핵심**: 모든 화살표가 Domain을 향한다. Domain은 프레임워크에 의존하지 않는다. + +### 4-2. Repository 추상체와 구현체 + +```mermaid +graph LR + subgraph Domain["Domain Layer"] + REPO["MemberRepository\n(interface)"] + end + subgraph Application["Application Layer"] + SVC["MemberService\n(@Transactional)"] + end + subgraph Infrastructure["Infrastructure Layer"] + IMPL["MemberRepositoryImpl\n(implements)"] + JPA["MemberJpaRepository\n(Spring Data JPA)"] + end + + SVC -->|"uses"| REPO + IMPL -->|"implements"| REPO + IMPL -->|"delegates to"| JPA +``` + +**왜 Repository 인터페이스를 Domain에 두는가?** `MemberService`는 `MemberRepository` **인터페이스에만** 의존한다. JPA 구현체를 모르므로, DB가 바뀌어도 Application/Domain은 변하지 않는다. 테스트에서 `FakeMemberRepository`를 주입하면 DB 없이 순수 단위 테스트가 가능하다. + +### 4-3. 실제 Gradle 의존성 + +```kotlin +// domain/build.gradle.kts — 프레임워크 의존 최소화 +dependencies { + api("jakarta.persistence:jakarta.persistence-api") +} + +// application/commerce-service/build.gradle.kts — Domain만 의존, HTTP 모름 +dependencies { + api(project(":domain")) + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-context") +} + +// modules/jpa/build.gradle.kts — Domain의 Repository를 구현 +dependencies { + api(project(":domain")) + api("org.springframework.boot:spring-boot-starter-data-jpa") +} +``` + +--- + +## 5. 바운디드 컨텍스트 매핑 + +### 5-1. BC 경계와 Aggregate + +```mermaid +graph TB + subgraph MemberBC["Member Context"] + M["Member\n(Aggregate Root)"] + end + + subgraph CatalogBC["Catalog Context"] + B["Brand\n(Aggregate Root)"] + P["Product\n(Aggregate Root)"] + CDS["BrandDeleteService\n(Brand 삭제 + Product 연쇄)"] + CDS ---|"조율"| B + CDS ---|"조율"| P + end + + subgraph LikeBC["Like Context"] + L["Like\n(Aggregate Root)"] + end + + subgraph OrderBC["Order Context"] + O["Order\n(Aggregate Root)"] + OL["OrderLine\n(Entity)"] + OLS["OrderLineSnapshot\n(VO, @Entity)"] + OL -.->|"orderId (Long)"| O + OLS -.->|"orderLineId (Long)"| OL + end + + P -..->|"brandId (Long)"| B + L -..->|"memberId (Long)"| M + L -..->|"subjectId (Long)"| P + O -..->|"memberId (Long)"| M + OL -..->|"productId (Long)"| P +``` + +**점선(`..>`) = ID(Long) 참조**. 객체 참조가 아니다. + +### 5-2. 무FK 정책 + +BC 간 참조뿐 아니라 **같은 BC 내(Product → Brand)에서도 FK를 사용하지 않는다.** + +| 이유 | 설명 | +|------|------| +| 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 BrandDeleteService로 제어. 삭제 순서(상품 먼저 → 브랜드 나중)와 부가 로직을 코드에 명시적으로 표현 | +| 운영 유연성 | 데이터 마이그레이션, 벌크 작업 시 FK가 제약이 됨 | + +참조 무결성은 **애플리케이션 레벨에서 보장**한다 (상세: `04-erd.md` 5절). + +### 5-3. Brand↔Product가 독립 Aggregate인 이유 + +Brand와 Product는 같은 Catalog BC에 속하지만 **독립 Aggregate**이다. + +| 기준 | 판단 | +|------|------| +| 독립적 생명주기 | Product 없이 Brand만 존재 가능 | +| 규모 차이 | Brand 1개에 Product 수천 개 가능. Brand Aggregate에 포함하면 메모리/성능 문제 | +| 독립 변경 | Product 가격/재고 수정 시 Brand를 잠글 필요 없음 | + +Cross-aggregate 규칙(삭제 연쇄)은 **BrandDeleteService**에서 처리한다. 상품 등록 시 Brand 활성 검증은 Application Service에서 오케스트레이션한다. + +--- + +## 6. 요청 흐름 추적 + +### 6-1. 단순 흐름 — 브랜드 등록 + +레이어 경계가 participant로 드러나는 시퀀스 다이어그램. + +```mermaid +sequenceDiagram + actor A as 관리자 + participant CTRL as AdminBrandController
(Presentation) + participant SVC as AdminBrandService
(Application) + participant BRAND as Brand
(Domain Entity) + participant PORT as BrandRepository
(Domain — interface) + participant IMPL as BrandRepositoryImpl
(Infrastructure — 구현체) + + A->>CTRL: POST /api/admin/brands {name} + Note over CTRL: Presentation DTO → Application Command 변환 + + CTRL->>SVC: create(BrandCreateCommand) + Note over SVC: @Transactional 시작 + + SVC->>PORT: existsByName(name) + PORT->>IMPL: (실제 JPA 호출) + IMPL-->>SVC: boolean + + alt 중복이면 + SVC-->>CTRL: CoreException(CONFLICT) + CTRL-->>A: 409 Conflict (ApiControllerAdvice가 ErrorType 해석) + end + + SVC->>BRAND: Brand.create(name) + Note over BRAND: 이름 검증 (빈 값, 길이) + + SVC->>PORT: save(brand) + PORT->>IMPL: (구현체 위임) + IMPL-->>SVC: Brand + + Note over SVC: @Transactional 종료 + + SVC-->>CTRL: BrandInfo + Note over CTRL: Application DTO → Presentation DTO 변환 + CTRL-->>A: 201 Created + ApiResponse +``` + +#### 읽는 포인트 + +- **DTO 변환이 두 번** 일어난다: Presentation → Application (요청), Application → Presentation (응답) +- **이름 검증(논리적)**은 Brand Entity가, **중복 검증(물리적 — DB 조회 필요)**은 Application Service가 수행한다 +- **에러 해석**은 ApiControllerAdvice(Presentation)가 담당한다: `ErrorType.CONFLICT → HttpStatus.CONFLICT(409)` + +### 6-2. 복합 흐름 — 주문 생성 (Cross-BC) + +논리적(Domain)과 물리적(Application)의 경계가 드러나는 흐름. + +```mermaid +sequenceDiagram + actor M as 회원 + participant CTRL as OrderController
(Presentation) + participant OS as OrderService
(Application) + participant PS as ProductService
(Application) + participant P as Product
(Domain Entity) + participant STOCK as Stock
(Domain VO) + participant PORT as OrderRepository
(Domain — interface) + + M->>CTRL: POST /api/orders [{productId, quantity}, ...] + CTRL->>OS: createOrder(memberId, items) + Note over OS: @Transactional 시작 + + OS->>OS: productId 오름차순 정렬 + Note right of OS: 물리적 — 데드락 방지 + + loop 각 상품 (Application 오케스트레이션) + OS->>PS: getProductForOrder(productId) + Note over PS: 비관적 락 + 유효성 확인 (물리적) + PS-->>OS: Product + end + + loop 재고 확인 (Domain 규칙) + OS->>P: hasEnoughStock(quantity) + P->>STOCK: isEnough(quantity) + Note over STOCK: 논리적 — stock >= quantity + end + + alt 모든 재고 충분 + loop 재고 차감 (Domain 규칙) + OS->>P: decreaseStock(quantity) + P->>STOCK: decrease(quantity) + Note over STOCK: 새 Stock 반환 (불변 VO) + end + OS->>PORT: save(ACCEPTED + lines + snapshots) + else 재고 부족 + OS->>PORT: save(REJECTED + lines + snapshots) + end + + Note over OS: @Transactional 종료 + OS-->>CTRL: OrderResult + CTRL-->>M: Response (ApiResponse) +``` + +#### 읽는 포인트 + +이 흐름에서 **논리적(Domain)과 물리적(Application)의 경계**가 드러난다. + +| 로직 | 레이어 | 이유 | +|------|--------|------| +| productId 오름차순 정렬 | Application (물리적) | 데드락 방지 = 기술 관심사 | +| 비관적 락 획득 | Application (물리적) | 동시성 제어 = 기술 관심사 | +| `Stock.isEnough(quantity)` | Domain (논리적) | "재고 >= 수량" = 기술 무관한 규칙 | +| `Stock.decrease(quantity)` | Domain (논리적) | 재고 차감 = 기술 무관한 규칙 | +| 수락/거절 판단 | Domain (논리적) | "전부 충분하면 수락" = 비즈니스 규칙 | +| 위 흐름의 조율 | Application (물리적) | 트랜잭션 + Cross-BC 오케스트레이션 | + +--- + +## 7. 새 기능은 어디에 놓는가 — 의사결정 프레임워크 + +### 7-1. 판별 플로우 + +```mermaid +flowchart TD + START["새 로직 추가"] --> Q1{"이 규칙은 기술 구현과\n무관하게 항상 성립하는가?"} + + Q1 -->|"Yes — 논리적"| Q2{"단일 엔티티의\n상태/불변식인가?"} + Q1 -->|"No — 물리적"| Q5{"BC 경계를 넘는\n조율인가?"} + + Q2 -->|"Yes"| A1["Entity 또는 VO"] + Q2 -->|"No"| Q3{"같은 BC 내\ncross-aggregate 규칙인가?"} + + Q3 -->|"Yes"| A2["Domain Service"] + Q3 -->|"No"| Q5 + + Q5 -->|"Yes"| A3["Application Service"] + Q5 -->|"No"| Q6{"물리적 기술 관심사인가?\n(트랜잭션, 락, 데드락 방지)"} + + Q6 -->|"Yes"| A3 + Q6 -->|"No"| Q7{"Application Service 간\n순환 참조인가?"} + + Q7 -->|"Yes"| A4["Facade"] + Q7 -->|"No"| A3 + + A1 --> DOMAIN["Domain Layer"] + A2 --> DOMAIN + A3 --> APP["Application Layer"] + A4 --> APP + + style DOMAIN fill:#e8f5e9 + style APP fill:#e3f2fd +``` + +### 7-2. 구체적 예시 + +| 시나리오 | 결정 | 이유 | +|---------|------|------| +| "재고는 음수가 될 수 없다" | `Stock` VO (Domain) | 기술 무관한 불변식 | +| "가격은 0보다 커야 한다" | `Price` VO (Domain) | 기술 무관한 불변식 | +| "이미 삭제된 브랜드는 다시 삭제 불가" | `Brand.guardNotDeleted()` (Domain) | 엔티티 자기 상태 검증 | +| "Brand 삭제 시 Product 연쇄 삭제" | `BrandDeleteService` (Domain) | 같은 BC 내 cross-aggregate 규칙 | +| "productId 오름차순 정렬 (데드락 방지)" | `OrderService` (Application) | 물리적 기술 관심사 | +| "좋아요 등록 시 상품 유효성 확인" | `LikeService` (Application) | Cross-BC 조율 (Like → Catalog) | +| "ErrorType → HttpStatus 매핑" | `ApiControllerAdvice` (Presentation) | 프로토콜 해석 | +| "MemberRepository 구현" | `MemberRepositoryImpl` (Infrastructure) | Repository 구현체 | + +### 7-3. 핵심 원칙 + +1. **판단 주체는 규칙을 아는 객체**다. Service가 `if (stock >= quantity)`를 직접 검사하지 않는다. `Stock.isEnough(quantity)`를 호출한다. +2. **같은 BC 내 cross-aggregate 규칙은 Domain Service**다. Facade가 아니다. +3. **Application Service는 조율자**다. 규칙 자체를 구현하지 않고, Domain 객체에 위임한다. +4. **Facade는 순환 참조 해소 전용**이다. "BC 간 조율"이 Facade의 역할이 아니다. + +--- + +## 8. 설계 결정 기록 (ADR) + +아키텍처 레벨 결정만 정리한다. 클래스 레벨 결정은 `03-class-diagram.md` 6절 참조. + +| # | 결정 | 맥락 | 선택 이유 | 대안 | +|---|------|------|----------|------| +| 1 | Domain에 JPA 어노테이션 허용 | 순수 POJO vs 실용적 매핑 | `jakarta.persistence-api`는 인터페이스 수준 스펙. 매핑 레이어 추가 비용 > 이득 | 순수 POJO + 별도 매핑 레이어 | +| 2 | ErrorType에 HttpStatus 미포함 | Presentation이 3개 (HTTP, Batch, Kafka) | Batch/Kafka에서 HttpStatus는 무의미. 각 Presentation이 자기 프로토콜로 해석해야 한다 | ErrorType에 HttpStatus 포함 | +| 3 | Repository 인터페이스를 Domain에 배치 | 의존 역전 | Domain이 추상체를 소유하고 Infrastructure가 구현하면 의존 방향이 안쪽을 향한다. 테스트 시 Fake 주입 가능 | Repository를 Infrastructure에 배치 | +| 4 | Application에 spring-web 미포함 | Application의 프로토콜 독립성 | 트랜잭션(`@Transactional`)과 DI(`@Service`)만 필요. HTTP는 Presentation의 책임 | spring-web 포함 | +| 5 | BC 간/내 모두 FK 없음 | 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 BrandDeleteService로 제어. 규칙이 코드에 표현된다 | FK 사용 | +| 6 | BaseTimeEntity / BaseEntity 분리 | 삭제 정책을 상속으로 표현 | Like(hard-delete), Order(삭제 없음)에 `deletedAt`은 불필요. 상속이 의도를 코드로 드러낸다 | BaseEntity 하나만 | +| 7 | Domain Service 필요할 때만 도입 | YAGNI | 현재 BrandDeleteService만 실제 필요. Member BC에 DomainService는 불필요 | 모든 BC에 미리 생성 | +| 8 | presentation에서만 Spring Boot 플러그인 | Library 모듈에 bootJar 불필요 | domain, application, modules는 `java-library`. bootJar는 실행 모듈(presentation)만 | 전체 모듈에 적용 | + +--- + +## 9. 기반 문서 참조 + +| 문서 | 내용 | 본 문서와의 관계 | +|------|------|----------------| +| `01-requirements.md` | 기능 정의서 | 아키텍처가 지원해야 하는 유스케이스의 원천 | +| `02-sequence-diagrams.md` | 시퀀스 다이어그램 | 6절(요청 흐름)의 세부 참조. 각 API 흐름의 객체 간 메시지 | +| `03-class-diagram.md` | 클래스 다이어그램 | 3절(레이어 상세)의 클래스 설계 원천. 엔티티/VO 분류, 책임 분산 점검 | +| `04-erd.md` | ERD | VO → 컬럼 매핑, 무FK 운영 규약 세부 | +| `05-domain-model.md` | 도메인 모델 정의서 | BC 경계, 레이어 책임 규칙, Service 분류 기준의 원천 (SoT) | diff --git a/docs/design/base/class-diagram-erd.md b/docs/design/base/class-diagram-erd.md index f18c7e7e0..04a7ac1fd 100644 --- a/docs/design/base/class-diagram-erd.md +++ b/docs/design/base/class-diagram-erd.md @@ -1,5 +1,7 @@ # 클래스 다이어그램 & ERD +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 도메인 정의서(v2) + 요구사항 분석 기반 diff --git a/docs/design/base/domain-definition-v2.md b/docs/design/base/domain-definition-v2.md index de0ecdaf4..2b6d29b24 100644 --- a/docs/design/base/domain-definition-v2.md +++ b/docs/design/base/domain-definition-v2.md @@ -1,5 +1,7 @@ # 감성 이커머스 도메인 정의서 (v2) +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 상태: **확정** diff --git a/docs/design/base/member-class-diagram.md b/docs/design/base/member-class-diagram.md index a2696cf76..59d806398 100644 --- a/docs/design/base/member-class-diagram.md +++ b/docs/design/base/member-class-diagram.md @@ -1,5 +1,7 @@ # 회원(Member) 도메인 클래스 다이어그램 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-01 > 상태: **Planner Mode - 승인 대기** diff --git a/docs/design/base/requirements-input.md b/docs/design/base/requirements-input.md index 656931567..1b548e6f6 100644 --- a/docs/design/base/requirements-input.md +++ b/docs/design/base/requirements-input.md @@ -1,5 +1,7 @@ # 요구사항 분석 입력 문서 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 도메인 정의서(v2) 기반 — `/requirements-analysis` 스킬 입력용 ## 프로젝트 컨텍스트 diff --git a/docs/design/base/sequence-diagrams.md b/docs/design/base/sequence-diagrams.md index 16a33f5c8..41590e143 100644 --- a/docs/design/base/sequence-diagrams.md +++ b/docs/design/base/sequence-diagrams.md @@ -1,5 +1,7 @@ # 시퀀스 다이어그램 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 도메인 정의서(v2) + 요구사항 분석 기반 > 각 다이어그램은 "왜 필요한가 → 다이어그램 → 읽는 포인트" 순서로 기술한다. diff --git a/docs/design/base/ubiquitous-language.md b/docs/design/base/ubiquitous-language.md index 833db3689..6aca72609 100644 --- a/docs/design/base/ubiquitous-language.md +++ b/docs/design/base/ubiquitous-language.md @@ -1,5 +1,7 @@ # 유비쿼터스 언어 사전 (Ubiquitous Language Dictionary) +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 이 문서는 기획, 설계, 코드에서 동일한 용어를 사용하기 위한 약속입니다. > 코드의 클래스명, 변수명, enum 값은 이 사전의 영문 표현을 따릅니다. diff --git a/docs/design/base/user-story.md b/docs/design/base/user-story.md index 7e98c772b..6159282d5 100644 --- a/docs/design/base/user-story.md +++ b/docs/design/base/user-story.md @@ -1,5 +1,7 @@ # 유저 스토리 & 유스케이스 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 도메인 정의서(v2) 기반 diff --git a/docs/design/member-class-diagram.md b/docs/design/member-class-diagram.md new file mode 100644 index 000000000..a2696cf76 --- /dev/null +++ b/docs/design/member-class-diagram.md @@ -0,0 +1,238 @@ +# 회원(Member) 도메인 클래스 다이어그램 + +> 작성일: 2026-02-01 +> 상태: **Planner Mode - 승인 대기** + +## 1. 요구사항 정리 + +### 1.1 회원가입 요구사항 +| 항목 | 설명 | 검증 규칙 | +|------|------|-----------| +| loginId | 로그인 ID | 중복 불가, 포맷 검증 (추후 정의) | +| password | 비밀번호 | 암호화 저장, 아래 규칙 적용 | +| name | 이름 | 포맷 검증 (추후 정의) | +| email | 이메일 | 포맷 검증 (추후 정의) | +| birthDate | 생년월일 | 비밀번호 검증에 사용 | + +### 1.2 비밀번호 규칙 +1. **길이**: 8~16자 +2. **허용 문자**: 영문 대소문자, 숫자, 특수문자만 가능 +3. **금지 조건**: 생년월일(YYYYMMDD, YYMMDD, MMDD 등)이 비밀번호에 포함될 수 없음 + +--- + +## 2. 클래스 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BaseEntity │ +├─────────────────────────────────────────────────────────────┤ +│ - id: Long │ +│ - createdAt: ZonedDateTime │ +│ - updatedAt: ZonedDateTime │ +│ - deletedAt: ZonedDateTime │ +├─────────────────────────────────────────────────────────────┤ +│ + delete(): void │ +│ + restore(): void │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + △ + │ extends + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ Member │ +├─────────────────────────────────────────────────────────────┤ +│ - loginId: String // 로그인 ID (Unique) │ +│ - password: String // 암호화된 비밀번호 │ +│ - name: String // 이름 │ +│ - email: String // 이메일 │ +│ - birthDate: LocalDate // 생년월일 │ +├─────────────────────────────────────────────────────────────┤ +│ + Member(loginId, rawPassword, name, email, birthDate, │ +│ passwordEncoder): Member │ +│ + updatePassword(rawPassword, passwordEncoder): void │ +│ + matchesPassword(rawPassword, passwordEncoder): boolean │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + │ + │ uses + ▽ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordValidator │ +├─────────────────────────────────────────────────────────────┤ +│ + validate(rawPassword, birthDate): void │ +│ - validateLength(password): void │ +│ - validateCharacters(password): void │ +│ - validateNotContainsBirthDate(password, birthDate): void │ +└─────────────────────────────────────────────────────────────┘ + + +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordEncoder │ +├─────────────────────────────────────────────────────────────┤ +│ + encode(rawPassword): String │ +│ + matches(rawPassword, encodedPassword): boolean │ +└─────────────────────────────────────────────────────────────┘ + △ + │ implements + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BCryptPasswordEncoder (Spring) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 계층별 클래스 구조 + +``` +com.loopers +├── interfaces/ +│ └── api/ +│ └── member/ +│ ├── MemberV1Controller.java // REST API +│ ├── MemberV1ApiSpec.java // OpenAPI 스펙 +│ └── MemberV1Dto.java // 요청/응답 DTO +│ +├── application/ +│ └── member/ +│ ├── MemberFacade.java // 비즈니스 조율 +│ └── MemberInfo.java // 응답 정보 (Record) +│ +├── domain/ +│ └── member/ +│ ├── Member.java // 엔티티 +│ ├── MemberService.java // 도메인 서비스 +│ ├── MemberRepository.java // 도메인 인터페이스 +│ └── PasswordValidator.java // 비밀번호 검증 +│ +└── infrastructure/ + └── member/ + ├── MemberJpaRepository.java // Spring Data JPA + └── MemberRepositoryImpl.java // 도메인 구현체 +``` + +--- + +## 4. 주요 클래스 상세 + +### 4.1 Member (엔티티) + +```java +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; // 암호화된 값 + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + // JPA용 기본 생성자 + protected Member() {} + + // 생성자에서 비밀번호 검증 + 암호화 + public Member(String loginId, String rawPassword, String name, + String email, LocalDate birthDate, + PasswordEncoder passwordEncoder) { + PasswordValidator.validate(rawPassword, birthDate); + this.loginId = loginId; + this.password = passwordEncoder.encode(rawPassword); + this.name = name; + this.email = email; + this.birthDate = birthDate; + guard(); + } +} +``` + +### 4.2 PasswordValidator (검증기) + +```java +public final class PasswordValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern VALID_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"); + + public static void validate(String rawPassword, LocalDate birthDate) { + validateLength(rawPassword); + validateCharacters(rawPassword); + validateNotContainsBirthDate(rawPassword, birthDate); + } + + // 8~16자 검증 + private static void validateLength(String password) { ... } + + // 영문 대소문자, 숫자, 특수문자만 허용 + private static void validateCharacters(String password) { ... } + + // 생년월일 포함 여부 검증 (YYYYMMDD, YYMMDD, MMDD 등) + private static void validateNotContainsBirthDate(String password, LocalDate birthDate) { ... } +} +``` + +--- + +## 5. 데이터베이스 스키마 + +```sql +CREATE TABLE member ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, -- BCrypt 해시 + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + birth_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + + INDEX idx_member_login_id (login_id), + INDEX idx_member_email (email) +); +``` + +--- + +## 6. 검토 필요 사항 + +### 확인 요청 +1. **loginId 포맷**: 어떤 형식을 허용할지 (영문+숫자, 길이 제한 등) +2. **email 포맷**: 표준 이메일 검증만 할지, 특정 도메인 제한이 있는지 +3. **name 포맷**: 한글/영문 허용 범위, 길이 제한 +4. **생년월일 검증 범위**: `YYYYMMDD`, `YYMMDD`, `MMDD` 외 추가 패턴이 있는지 + +### 추후 확장 고려 +- [ ] 로그인 기능 (JWT/Session) +- [ ] 비밀번호 변경 +- [ ] 이메일 인증 +- [ ] 소셜 로그인 연동 + +--- + +## 7. 승인 요청 + +위 설계에 대해 검토 부탁드립니다. + +- [ ] 클래스 구조 승인 +- [ ] DB 스키마 승인 +- [ ] 검증 규칙 추가 정보 제공 + +**승인 후 TDD Red Phase로 진입합니다.** diff --git a/docs/planning/brand-plan.md b/docs/planning/brand-plan.md new file mode 100644 index 000000000..3516e743c --- /dev/null +++ b/docs/planning/brand-plan.md @@ -0,0 +1,311 @@ +# Brand 도메인 TDD 구현 계획서 + +> 작성일: 2026-02-22 +> 선행 조건: Phase 1~3 (Member 리팩토링) 완료 +> 후행 작업: Product 도메인 구현 (brand-plan 완료 후) + +--- + +## 1. 요구사항 요약 + +### 1-1. API 목록 + +| # | 역할 | Method | URI | 설명 | +|---|------|--------|-----|------| +| 1 | User | GET | `/api/brands` | 활성 브랜드 목록 조회 | +| 2 | Admin | POST | `/api/admin/brands` | 브랜드 생성 | +| 3 | Admin | GET | `/api/admin/brands/{id}` | 브랜드 단건 조회 (삭제 포함) | +| 4 | Admin | PUT | `/api/admin/brands/{id}` | 브랜드 수정 | +| 5 | Admin | DELETE | `/api/admin/brands/{id}` | 브랜드 삭제 (soft-delete) | +| 6 | Admin | GET | `/api/admin/brands` | 브랜드 전체 목록 조회 (삭제 포함) | + +### 1-2. 도메인 규칙 + +- Brand는 `name` (브랜드명)을 가진다. +- Brand name은 **UNIQUE** 제약이 있다 (활성 상태 기준). +- Brand는 soft-delete를 지원한다 (`BaseEntity` 상속, `deletedAt`). +- 삭제된 Brand는 User API에 노출되지 않는다. +- 삭제 시 name을 변경하여 UNIQUE 제약을 해소한다 (예: `"나이키"` → `"나이키_deleted_1708XXX"`). +- 이미 삭제된 Brand를 다시 삭제하면 예외를 던진다 (`guardNotDeleted`). + +### 1-3. 에러 시나리오 매트릭스 + +| # | 시나리오 | ErrorType | ExceptionMessage | +|---|---------|-----------|-----------------| +| 1 | 브랜드명 빈 값 또는 길이 초과 | BAD_REQUEST | `BrandExceptionMessage.INVALID_NAME` | +| 2 | 브랜드명 중복 (생성/수정 시) | CONFLICT | `BrandExceptionMessage.DUPLICATE_NAME` | +| 3 | 존재하지 않는 브랜드 조회/수정/삭제 | NOT_FOUND | `BrandExceptionMessage.NOT_FOUND` | +| 4 | 이미 삭제된 브랜드 삭제 시도 | BAD_REQUEST | `BrandExceptionMessage.ALREADY_DELETED` | +| 5 | 이미 삭제된 브랜드 수정 시도 | BAD_REQUEST | `BrandExceptionMessage.ALREADY_DELETED` | + +--- + +## 2. 설계 결정 + +### 2-1. Entity 상속: `BaseEntity` + +Brand는 soft-delete가 필요하므로 `BaseEntity`를 상속한다. + +``` +BaseTimeEntity (id, createdAt, updatedAt) + └── BaseEntity (deletedAt, delete(), restore()) + └── Brand (name) +``` + +### 2-2. `Brand.delete()` override + +`BaseEntity.delete()`를 override하여 두 가지 추가 동작을 수행한다: + +1. **guardNotDeleted**: 이미 삭제된 상태이면 예외 +2. **name 변경**: UNIQUE 제약 해소를 위해 `"브랜드명_deleted_{timestamp}"` 형태로 변경 + +```java +@Override +public void delete() { + guardNotDeleted(); + this.name = this.name + "_deleted_" + System.currentTimeMillis(); + super.delete(); +} + +private void guardNotDeleted() { + if (getDeletedAt() != null) { + throw new CoreException(ErrorType.BAD_REQUEST, + BrandExceptionMessage.ALREADY_DELETED.message()); + } +} +``` + +### 2-3. VO 불필요 + +Brand는 `name` 하나의 필드만 가지며, 검증 규칙이 단순하여 별도 VO 없이 Entity 내에서 직접 검증한다. + +### 2-4. DTO 분리 구조 + +``` +Presentation Layer (commerce-api) +├── interfaces/api/brand/dto/ +│ ├── BrandCreateApiRequest.java → BrandCreateCommand 변환 +│ ├── BrandUpdateApiRequest.java → BrandUpdateCommand 변환 +│ └── BrandApiResponse.java ← BrandInfo 변환 +│ +Application Layer (commerce-service) +├── application/service/dto/ +│ ├── BrandCreateCommand.java (record) +│ ├── BrandUpdateCommand.java (record) +│ └── BrandInfo.java (record, from(Brand)) +``` + +### 2-5. BrandDeleteService (Product 구현 후) + +Brand 삭제 시 소속 Product도 연쇄 soft-delete해야 한다. Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate이므로, cross-aggregate 규칙은 **BrandDeleteService**(Domain 레이어)에서 처리한다. + +- **현재**: `AdminBrandService.delete()` — Brand만 삭제 (Product 미구현) +- **Product 구현 후**: `AdminBrandService.delete()` → `BrandDeleteService.delete()` 호출 + +```java +// domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java +// Product 구현 후 추가 +@RequiredArgsConstructor +public class BrandDeleteService { + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + public void delete(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.NOT_FOUND.message())); + productRepository.softDeleteByBrandId(brandId); + brand.delete(); + } +} +``` + +> Facade와의 차이: Facade는 Application Service 간 순환 참조 해소 용도. Brand 삭제 → Product 연쇄는 같은 BC의 cross-aggregate 도메인 규칙이므로 Domain Service가 적합하다. + +--- + +## 3. TDD 구현 순서 + +### Step 1: ExceptionMessage 정의 + +> 파일: `domain/src/main/java/com/loopers/domain/brand/BrandExceptionMessage.java` + +- `INVALID_NAME` — 브랜드명 빈 값 또는 길이 초과 +- `DUPLICATE_NAME` — 브랜드명 중복 +- `NOT_FOUND` — 브랜드 없음 +- `ALREADY_DELETED` — 이미 삭제된 브랜드 + +### Step 2: Entity (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/brand/Brand.java` +> 테스트: `domain/src/test/java/com/loopers/domain/brand/BrandTest.java` + +테스트 케이스: +- 브랜드 생성 성공 +- 브랜드명 빈 값이면 예외 +- 브랜드명 길이 초과 시 예외 +- 브랜드명 수정 성공 +- 삭제 시 deletedAt 설정 +- 삭제 시 name 변경 (`_deleted_` suffix) +- 이미 삭제된 브랜드 삭제 시 예외 +- 이미 삭제된 브랜드 수정 시 예외 + +### Step 3: Fixture + +> 파일: `domain/src/testFixtures/java/com/loopers/domain/brand/BrandFixture.java` + +```java +public class BrandFixture { + public static final String DEFAULT_NAME = "나이키"; + + public static Brand create() { ... } + public static Brand create(String name) { ... } +} +``` + +### Step 4: Repository Port + +> 파일: `domain/src/main/java/com/loopers/domain/brand/BrandRepository.java` + +```java +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + boolean existsByName(String name); + List findAllActive(); + List findAll(); +} +``` + +### Step 5: Service + DTOs (Red → Green) + +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/AdminBrandService.java` +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/AdminBrandServiceTest.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/BrandServiceTest.java` + +**BrandService** (User): +- `getActiveBrands()` — 활성 브랜드 목록 반환 + +**AdminBrandService** (Admin): +- `create(BrandCreateCommand)` — 중복 검사 + 생성 +- `getById(Long)` — 단건 조회 +- `getAll()` — 전체 목록 조회 (삭제 포함) +- `update(Long, BrandUpdateCommand)` — 중복 검사 + 수정 +- `delete(Long)` — soft-delete + +테스트 케이스 (AdminBrandServiceTest): +- 생성 시 브랜드명 중복이면 CONFLICT 예외 +- 생성 성공 시 save 호출 확인 +- 조회 시 존재하지 않으면 NOT_FOUND 예외 +- 수정 시 존재하지 않으면 NOT_FOUND 예외 +- 수정 시 다른 브랜드와 이름 중복이면 CONFLICT 예외 +- 삭제 시 존재하지 않으면 NOT_FOUND 예외 + +### Step 6: Repository Adapter + +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java` +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java` + +```java +public interface BrandJpaRepository extends JpaRepository { + boolean existsByName(String name); + List findAllByDeletedAtIsNull(); +} +``` + +### Step 7: Controller + +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java` +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java` + +**BrandController**: +- `GET /api/brands` → `BrandService.getActiveBrands()` + +**AdminBrandController**: +- `POST /api/admin/brands` → `AdminBrandService.create()` +- `GET /api/admin/brands/{id}` → `AdminBrandService.getById()` +- `GET /api/admin/brands` → `AdminBrandService.getAll()` +- `PUT /api/admin/brands/{id}` → `AdminBrandService.update()` +- `DELETE /api/admin/brands/{id}` → `AdminBrandService.delete()` (Product 구현 후: `BrandDeleteService.delete()` 경유) + +### Step 8: 통합 / E2E 테스트 + +> 파일: `presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java` + +테스트 시나리오: +- 브랜드 생성 → 201 Created +- 브랜드명 중복 생성 → 409 Conflict +- 활성 브랜드 목록 조회 → 200 OK (삭제된 브랜드 미포함) +- 브랜드 수정 → 200 OK +- 브랜드 삭제 → 204 No Content +- 삭제된 브랜드 재삭제 → 400 Bad Request +- Admin 전체 목록 조회 → 삭제 포함 + +--- + +## 4. 파일 생성 목록 + +### Domain Layer (`domain/`) + +| 경로 | 설명 | +|------|------| +| `domain/src/main/java/com/loopers/domain/brand/Brand.java` | Entity | +| `domain/src/main/java/com/loopers/domain/brand/BrandRepository.java` | Repository Port | +| `domain/src/main/java/com/loopers/domain/brand/BrandExceptionMessage.java` | 예외 메시지 | +| `domain/src/test/java/com/loopers/domain/brand/BrandTest.java` | 도메인 단위 테스트 | +| `domain/src/testFixtures/java/com/loopers/domain/brand/BrandFixture.java` | Fixture | + +### Application Layer (`application/commerce-service/`) + +| 경로 | 설명 | +|------|------| +| `application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java` | User Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/AdminBrandService.java` | Admin Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java` | 생성 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java` | 수정 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java` | 응답 DTO | +| `application/commerce-service/src/test/java/com/loopers/application/service/BrandServiceTest.java` | User Service 테스트 | +| `application/commerce-service/src/test/java/com/loopers/application/service/AdminBrandServiceTest.java` | Admin Service 테스트 | + +### Presentation Layer (`presentation/commerce-api/`) + +| 경로 | 설명 | +|------|------| +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java` | User Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java` | Admin Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java` | Presentation 생성 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java` | Presentation 수정 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java` | Presentation 응답 DTO | +| `presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java` | E2E 테스트 | + +### Infrastructure Layer (`modules/jpa/`) + +| 경로 | 설명 | +|------|------| +| `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java` | Spring Data JPA | +| `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java` | Repository Adapter | + +--- + +## 5. DB 스키마 + +```sql +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL +); +``` + +--- + +## 6. 참조 + +- 기존 패턴: `domain/src/main/java/com/loopers/domain/member/Member.java` +- BaseEntity: `domain/src/main/java/com/loopers/domain/BaseEntity.java` +- ErrorType: `domain/src/main/java/com/loopers/support/error/ErrorType.java` +- 도메인 모델 정의서: `docs/design/05-domain-model.md` +- Member 리팩토링 기록: `docs/planning/refactoring-plan.md` diff --git a/docs/planning/phase1-structure-refactoring.md b/docs/planning/phase1-structure-refactoring.md new file mode 100644 index 000000000..40605bc4b --- /dev/null +++ b/docs/planning/phase1-structure-refactoring.md @@ -0,0 +1,391 @@ +# Phase 1: 구조 변경 설계서 + +> 작성일: 2026-02-21 (최종 수정: 2026-02-21) +> 상태: 완료 + +--- + +## 1. 목표 + +현재 `apps/modules/supports` 3계층 구조를 레이어드 아키텍처 + DIP 원칙에 맞게 +`domain/application/presentation/modules/supports` 5계층 구조로 재배치한다. + +**원칙: 파일 이동과 의존성 변경만 수행. 비즈니스 로직 변경 없음.** + +--- + +## 2. 레이어 아키텍처 + +### 2-1. 레이어 다이어그램 + +``` +┌───────────────────────────────────────────────────────────┐ +│ presentation/ Presentation Layer (bootJar)│ +│ Controller, DTO, API Spec, app-specific Adapter │ +│ Spring Boot main, application.yml │ +│ → application, modules, supports 에 의존 │ +├───────────────────────────────────────────────────────────┤ +│ application/ Application Layer │ +│ Service, Facade, 비즈니스 유스케이스 조합 │ +│ → domain 에만 의존 │ +├──────────────────┬────────────────────────────────────────┤ +│ modules/ │ domain/ Domain Layer │ +│ Infrastructure │ Entity, VO, Repository Port │ +│ jpa, redis, │ 순수 비즈니스 규칙 │ +│ kafka │ → 아무것도 의존하지 않음 │ +│ → domain 에 │ │ +│ 의존 │ │ +├──────────────────┴────────────────────────────────────────┤ +│ supports/ Cross-cutting │ +│ jackson, logging, monitoring │ +│ → 독립 │ +└───────────────────────────────────────────────────────────┘ +``` + +### 2-2. 의존 방향 + +``` +presentation/commerce-api (bootJar) + ├─→ application/commerce-service (서비스 로직) + ├─→ domain (엔티티, 포트) + ├─→ modules/jpa, redis (인프라 어댑터) + └─→ supports/* (횡단 관심사) + +application/commerce-service (java-library) + └─→ domain (엔티티, 포트만 참조) + +modules/jpa (java-library) + └─→ domain (엔티티 참조, 포트 구현) + +domain (java-library) + └─→ (없음) +``` + +### 2-3. 각 레이어의 책임 + +| 레이어 | 책임 | 포함 내용 | 의존 | +|--------|------|----------|------| +| **domain** | 순수 비즈니스 규칙 | Entity, VO, Repository Port, Policy, 도메인 예외 | 없음 (jakarta.persistence-api만) | +| **application** | 유스케이스 조합 | Service, Facade, 서비스 DTO | domain | +| **presentation** | 외부 인터페이스 | Controller, API DTO, Spring Boot main, app-specific Adapter | application, domain, modules, supports | +| **modules** | 인프라 어댑터 | DB/Redis/Kafka 설정, 공유 Repository 구현체 | domain | +| **supports** | 횡단 관심사 | Jackson, Logging, Monitoring 설정 | 없음 | + +--- + +## 3. 결과 모듈 구조 + +### 3-1. Before (현재) + +``` +Root +├── apps/ +│ ├── commerce-api/ ← 모든 것이 혼재 (Controller, Service, Entity 참조) +│ ├── commerce-batch/ +│ └── commerce-streamer/ +├── modules/ +│ ├── jpa/ ← 도메인 코드가 여기에 혼재 +│ ├── redis/ +│ └── kafka/ ← 패키지 오타 (confg) +└── supports/ +``` + +### 3-2. After (목표) + +``` +Root +├── domain/ ← 신규: Domain Layer +│ ├── build.gradle.kts +│ └── src/ +│ ├── main/java/com/loopers/ +│ │ ├── domain/ +│ │ │ ├── BaseEntity.java +│ │ │ └── member/ +│ │ │ ├── Member.java +│ │ │ ├── MemberExceptionMessage.java +│ │ │ ├── MemberRepository.java (Port) +│ │ │ └── policy/MemberPolicy.java +│ │ └── utils/PasswordEncryptor.java +│ └── test/java/com/loopers/domain/member/ +│ └── MemberTest.java +│ +├── application/ ← 신규: Application Layer +│ └── commerce-service/ +│ ├── build.gradle.kts +│ └── src/ +│ ├── main/java/com/loopers/ +│ │ ├── application/ +│ │ │ ├── service/ +│ │ │ │ ├── MemberService.java +│ │ │ │ └── dto/ (Request/Response DTOs) +│ │ │ └── example/ +│ │ │ ├── ExampleFacade.java +│ │ │ └── ExampleInfo.java +│ │ ├── domain/example/ (app-specific 도메인) +│ │ │ ├── ExampleModel.java +│ │ │ ├── ExampleRepository.java +│ │ │ └── ExampleService.java +│ │ └── support/error/ +│ │ ├── CoreException.java +│ │ └── ErrorType.java +│ └── test/java/com/loopers/ (단위 테스트) +│ ├── application/MemberServiceTest.java +│ ├── domain/example/ExampleModelTest.java +│ └── support/error/CoreExceptionTest.java +│ +├── presentation/ ← 신규: Presentation Layer +│ ├── commerce-api/ +│ │ ├── build.gradle.kts +│ │ └── src/ +│ │ ├── main/java/com/loopers/ +│ │ │ ├── CommerceApiApplication.java +│ │ │ ├── interfaces/api/ +│ │ │ │ ├── ApiResponse.java +│ │ │ │ ├── ApiControllerAdvice.java +│ │ │ │ ├── member/MemberController.java +│ │ │ │ └── example/ (Controller, ApiSpec, Dto) +│ │ │ └── infrastructure/example/ +│ │ │ ├── ExampleJpaRepository.java +│ │ │ └── ExampleRepositoryImpl.java +│ │ ├── main/resources/application.yml +│ │ └── test/java/com/loopers/ (통합/E2E 테스트) +│ │ ├── CommerceApiContextTest.java +│ │ ├── application/MemberServiceIntegrationTest.java +│ │ ├── controller/MemberE2ETest.java +│ │ └── interfaces/api/ExampleV1ApiE2ETest.java +│ ├── commerce-batch/ (기존 apps/commerce-batch 이동) +│ └── commerce-streamer/ (기존 apps/commerce-streamer 이동) +│ +├── modules/ ← Infrastructure Layer +│ ├── jpa/ +│ │ └── src/main/java/com/loopers/ +│ │ ├── config/jpa/ (DataSourceConfig, JpaConfig, QueryDslConfig) +│ │ └── infrastructure/member/ ← 신규: 공유 Adapter +│ │ ├── MemberJpaRepository.java +│ │ └── MemberRepositoryImpl.java +│ ├── redis/ +│ └── kafka/ +│ └── src/main/.../config/kafka/ ← confg → config 수정 +│ +└── supports/ (변경 없음) +``` + +--- + +## 4. Gradle 설정 변경 + +### 4-1. settings.gradle.kts + +```kotlin +include( + ":domain", + ":application:commerce-service", + ":presentation:commerce-api", + ":presentation:commerce-batch", + ":presentation:commerce-streamer", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", +) +``` + +### 4-2. root build.gradle.kts 변경 사항 + +| 항목 | Before | After | +|------|--------|-------| +| Spring Boot 플러그인 | 전체 서브프로젝트에 적용 | presentation 모듈에서만 적용 | +| BOM 버전 관리 | Spring Boot 플러그인이 자동 제공 | `spring-boot-dependencies` BOM 명시 import | +| bootJar/jar 태스크 설정 | root에서 기본 비활성화/활성화 | 불필요 (Spring Boot 플러그인 없는 모듈에는 bootJar 자체가 없음) | +| 컨테이너 비활성화 | `project("apps")` | `project("application")` + `project("presentation")` | + +### 4-3. 신규 모듈 build.gradle.kts + +**domain/build.gradle.kts:** +- Plugins: `java-library`, `java-test-fixtures` +- Dependencies: `api("jakarta.persistence:jakarta.persistence-api")` + +**application/commerce-service/build.gradle.kts:** +- Plugins: `java-library` +- Dependencies: `api(project(":domain"))`, `implementation("org.springframework:spring-web")`, `implementation("org.springframework:spring-tx")` + +**presentation/commerce-api/build.gradle.kts:** +- Plugin: `apply(plugin = "org.springframework.boot")` (bootJar 활성화) +- Dependencies: domain, application:commerce-service, modules:jpa, modules:redis, supports:*, web, actuator, springdoc +- TestFixtures: domain, modules:jpa, modules:redis + +**presentation/commerce-batch/build.gradle.kts, commerce-streamer/build.gradle.kts:** +- Plugin: `apply(plugin = "org.springframework.boot")` (bootJar 활성화) + +**modules/jpa/build.gradle.kts 수정:** +- `api(project(":domain"))` 추가 + +--- + +## 5. 파일 이동 매핑 + +### 5-1. modules/jpa → domain (도메인 코드) + +| 파일 | 패키지 변경 | +|------|-----------| +| BaseEntity.java | 없음 | +| Member.java | 없음 | +| MemberExceptionMessage.java | 없음 | +| MemberPolicy.java | 없음 | +| PasswordEncryptor.java | 없음 | + +신규 생성: `domain/.../member/MemberRepository.java` (Port 인터페이스) +이동: `MemberTest.java` (testFixtures → domain/src/test/, 패키지 변경) + +### 5-2. apps/commerce-api → application/commerce-service (비즈니스 로직) + +| 파일 | 패키지 변경 | +|------|-----------| +| MemberService.java | 없음 (import만: `infrastructure.member.MemberRepository` → `domain.member.MemberRepository`) | +| MemberRegisterRequest.java | 없음 | +| MyMemberInfoResponse.java | 없음 | +| PasswordUpdateRequest.java | 없음 | +| ExampleFacade.java, ExampleInfo.java | 없음 | +| ExampleModel.java, ExampleRepository.java, ExampleService.java | 없음 | +| CoreException.java, ErrorType.java | 없음 | + +### 5-3. apps/commerce-api → presentation/commerce-api (인터페이스 + 부트) + +| 파일 | 패키지 변경 | +|------|-----------| +| CommerceApiApplication.java | 없음 | +| ApiResponse.java, ApiControllerAdvice.java | 없음 | +| ExampleV1Controller.java, ExampleV1ApiSpec.java, ExampleV1Dto.java | 없음 | +| MemberController.java | `com.loopers.controller` → `com.loopers.interfaces.api.member` | +| ExampleJpaRepository.java, ExampleRepositoryImpl.java | 없음 | +| application.yml | 없음 | + +### 5-4. apps/commerce-api → modules/jpa (공유 Adapter) + +신규 생성: +- `modules/jpa/.../infrastructure/member/MemberJpaRepository.java` +- `modules/jpa/.../infrastructure/member/MemberRepositoryImpl.java` + +삭제: +- `apps/commerce-api/.../infrastructure/member/MemberRepository.java` + +### 5-5. 테스트 파일 분리 + +**단위 테스트 → application/commerce-service/src/test/:** +- MemberServiceTest.java (Mockito) +- ExampleModelTest.java +- CoreExceptionTest.java + +**통합/E2E 테스트 → presentation/commerce-api/src/test/:** +- CommerceApiContextTest.java +- MemberServiceIntegrationTest.java +- MemberE2ETest.java +- ExampleServiceIntegrationTest.java +- ExampleV1ApiE2ETest.java + +### 5-6. batch/streamer + +`apps/commerce-batch/` → `presentation/commerce-batch/` (내용 변경 없음) +`apps/commerce-streamer/` → `presentation/commerce-streamer/` (내용 변경 없음) + +--- + +## 6. 기타 수정 + +### 6-1. BaseEntity.id final 제거 +```java +// Before: private final Long id = 0L; +// After: private Long id; +``` + +### 6-2. Kafka 패키지 오타 수정 +`com.loopers.confg.kafka` → `com.loopers.config.kafka` + +--- + +## 7. 영향도 분석 + +### 7-1. @EntityScan / @EnableJpaRepositories + +| 설정 | 영향 | 이유 | +|------|------|------| +| `@EntityScan({"com.loopers"})` | 영향 없음 | domain, application 모듈 엔티티 모두 `com.loopers.*` 패키지 | +| `@EnableJpaRepositories({"com.loopers.infrastructure"})` | 영향 없음 | modules/jpa, presentation의 JPA Repo 모두 `com.loopers.infrastructure.*` | + +### 7-2. @SpringBootApplication 컴포넌트 스캔 + +presentation/commerce-api의 `@SpringBootApplication`이 `com.loopers` 패키지를 스캔. +application/의 `@Service`, modules/의 `@Configuration` 등 모두 자동 감지됨. + +### 7-3. 전이 의존성 + +`modules/jpa`가 `api(project(":domain"))`을 선언 → modules:jpa 의존하는 모듈이 domain을 자동으로 받음. +`application/commerce-service`가 `api(project(":domain"))` 선언 → presentation이 domain을 자동으로 받음. + +### 7-4. Spring Boot 플러그인 적용 범위 + +| 모듈 그룹 | Spring Boot 플러그인 | bootJar 태스크 | BOM 버전 관리 | +|-----------|---------------------|---------------|-------------| +| domain, application, modules, supports | 미적용 | 없음 | `spring-boot-dependencies` BOM 명시 import | +| presentation/* | 적용 | 있음 (기본 활성화) | 플러그인 자동 제공 + BOM import | +그래도 presentation에서 `implementation(project(":domain"))` 명시하여 의도를 명확히 함. + +--- + +## 8. 작업 순서 + +1. **Gradle 설정 변경** (settings, root build, 신규 build 파일들) +2. **domain 모듈 생성** + 코드 이동 (modules/jpa → domain) +3. **MemberRepository Adapter** 생성 (modules/jpa) +4. **application/commerce-service** 생성 + 비즈니스 코드 이동 +5. **presentation/commerce-api** 생성 + 인터페이스/부트 코드 이동 +6. **batch/streamer** 이동 (apps → presentation) +7. **Kafka 오타 수정** +8. **apps/ 디렉토리 삭제** +9. **검증**: `./gradlew clean build` + +--- + +## 9. 완료 기준 + +- [x] `./gradlew clean build -x test` 전체 통과 +- [x] `./gradlew :domain:test` — MemberTest 통과 +- [x] `./gradlew :application:commerce-service:test` — 단위 테스트 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 (코드 이상 없음) +- [ ] `./gradlew :presentation:commerce-api:bootRun` — Docker 환경 필요 +- [x] `apps/` 디렉토리 완전 제거 +- [x] `modules/jpa`에 비즈니스 로직 없음 (설정 + Adapter만) + +--- + +## 10. 작업 제외 사항 (이번 범위 밖) + +- Brand, Product, ProductLike, Order 등 신규 도메인 구현 +- VO(@Embeddable) 도입 — 신규 도메인에서 적용 +- MemberPolicy 규칙 내재화 — Phase 2 +- 도메인 예외 체계 구축 (DomainException → domain 레이어) — Phase 2 +- 예외 처리 체계 재설계 — Phase 2 +- Service 구조 변경 (ApplicationService + DomainService 분리) — Phase 2 +- `@Builder`, `@AllArgsConstructor` 제거 → 정적 팩토리 메서드 전환 — Phase 2 +- DTO 네이밍 통일 (`RegisterMemberRequest` 스타일) — Phase 2 +- Service 책임 분리 (마스킹 로직 이동) — Phase 2 +- supports 모듈 의존성 중복 정리 — 별도 작업 + +--- + +## 11. Phase 2 예고: 코드 스타일 적용 사항 + +> Phase 1 구현 전 논의에서 결정된 코드 스타일. Phase 1(구조 변경)에서는 적용하지 않고, +> Phase 2(모델링 및 설계 변경)에서 일괄 적용. + +| 항목 | Before | After | +|------|--------|-------| +| Entity 생성 | `@Builder` + `@AllArgsConstructor` | 정적 팩토리 메서드만 | +| `@Transactional` | Service 메서드에 개별 적용 | ApplicationService 클래스 레벨에만 | +| Service 구조 | Service가 Repository 직접 사용 | ApplicationService → DomainService → Repository | +| 예외 처리 위치 | application 레이어 (CoreException) | domain 레이어에 도메인 예외 정의, modules/jpa에서 던짐 | +| DTO 네이밍 | `MemberRegisterRequest` | `RegisterMemberRequest` (행동 먼저) | +| 메서드 내부 주석 | 있음 | 없음 (메서드명으로 의도 표현) | +| Javadoc | 없음 | Controller 메서드에만 | diff --git a/docs/planning/product-plan.md b/docs/planning/product-plan.md new file mode 100644 index 000000000..3ff5886ce --- /dev/null +++ b/docs/planning/product-plan.md @@ -0,0 +1,561 @@ +# Product 도메인 TDD 구현 계획서 + +> 작성일: 2026-02-22 +> 선행 조건: Brand 도메인 구현 완료 +> 후행 작업: Like 도메인, Order 도메인 (별도 계획서) + +--- + +## 1. 요구사항 요약 + +### 1-1. API 목록 + +| # | 역할 | Method | URI | 설명 | +|---|------|--------|-----|------| +| 1 | User | GET | `/api/products` | 활성 상품 목록 조회 (정렬, 페이징) | +| 2 | User | GET | `/api/products/{id}` | 활성 상품 단건 조회 (상품+브랜드 모두 활성) | +| 3 | Admin | POST | `/api/admin/products` | 상품 생성 | +| 4 | Admin | GET | `/api/admin/products/{id}` | 상품 단건 조회 (삭제 포함) | +| 5 | Admin | PUT | `/api/admin/products/{id}` | 상품 수정 | +| 6 | Admin | DELETE | `/api/admin/products/{id}` | 상품 삭제 (soft-delete) | +| 7 | Admin | GET | `/api/admin/products` | 상품 전체 목록 조회 (삭제 포함) | + +### 1-2. 도메인 규칙 + +- Product는 하나의 Brand에 소속된다 (`brandId` FK). +- Product는 `name`, `price`, `stock`, `description` 필드를 가진다. +- Product는 soft-delete를 지원한다 (`BaseEntity` 상속). +- 삭제된 Product는 User API에 노출되지 않는다. +- 소속 Brand가 삭제된 Product도 User API에 노출되지 않는다. +- Brand 삭제 시 소속 Product를 벌크 soft-delete한다. +- 상품 생성 시 소속 Brand가 활성 상태여야 한다. + +### 1-3. 정렬 옵션 (User 목록 조회) + +| 정렬 키 | 정렬 방식 | 설명 | +|---------|----------|------| +| `latest` | `created_at DESC` | 최신 등록순 (기본값) | +| `price_asc` | `price ASC` | 가격 낮은순 | +| `likes_desc` | `like_count DESC` | 좋아요 많은순 (LEFT JOIN + COUNT) | + +### 1-4. 에러 시나리오 매트릭스 + +| # | 시나리오 | ErrorType | ExceptionMessage | +|---|---------|-----------|-----------------| +| 1 | 상품명 빈 값 또는 길이 초과 | BAD_REQUEST | `ProductExceptionMessage.INVALID_NAME` | +| 2 | 가격이 0 이하 | BAD_REQUEST | `ProductExceptionMessage.INVALID_PRICE` | +| 3 | 재고가 음수 | BAD_REQUEST | `ProductExceptionMessage.INVALID_STOCK` | +| 4 | 수량이 0 이하 | BAD_REQUEST | `ProductExceptionMessage.INVALID_QUANTITY` | +| 5 | 존재하지 않는 상품 조회/수정/삭제 | NOT_FOUND | `ProductExceptionMessage.NOT_FOUND` | +| 6 | 이미 삭제된 상품 삭제 시도 | BAD_REQUEST | `ProductExceptionMessage.ALREADY_DELETED` | +| 7 | 이미 삭제된 상품 수정 시도 | BAD_REQUEST | `ProductExceptionMessage.ALREADY_DELETED` | +| 8 | 생성 시 소속 Brand가 없음 | NOT_FOUND | `BrandExceptionMessage.NOT_FOUND` | +| 9 | 생성 시 소속 Brand가 삭제 상태 | BAD_REQUEST | `BrandExceptionMessage.ALREADY_DELETED` | +| 10 | User 단건 조회 시 상품 또는 브랜드가 삭제 상태 | NOT_FOUND | `ProductExceptionMessage.NOT_FOUND` | +| 11 | 재고 부족 (decrease 시) | BAD_REQUEST | `ProductExceptionMessage.INSUFFICIENT_STOCK` | + +--- + +## 2. 설계 결정 + +### 2-1. Entity 상속: `BaseEntity` + +``` +BaseTimeEntity (id, createdAt, updatedAt) + └── BaseEntity (deletedAt, delete(), restore()) + └── Product (brandId, name, price, stock, description) +``` + +### 2-2. Value Objects (3개) + +#### Price + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Price.java` + +```java +@Embeddable +public class Price { + @Column(name = "price") + private int value; + + public static Price of(int value) { + if (value <= 0) throw ...; + return new Price(value); + } +} +``` + +- 검증: `value > 0` +- 타입: `int` (원 단위, 소수점 불필요) + +#### Stock + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Stock.java` + +```java +@Embeddable +public class Stock { + @Column(name = "stock") + private int value; + + public static Stock of(int value) { + if (value < 0) throw ...; + return new Stock(value); + } + + public boolean isEnough(int quantity) { + return this.value >= quantity; + } + + public Stock decrease(int quantity) { + if (!isEnough(quantity)) throw ...; + return new Stock(this.value - quantity); + } +} +``` + +- 검증: `value >= 0` +- 도메인 메서드: `isEnough(quantity)`, `decrease(quantity)` +- `decrease()`는 새 Stock 인스턴스를 반환하는 불변 패턴 + +#### Quantity + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Quantity.java` + +```java +@Embeddable +public class Quantity { + private int value; + + public static Quantity of(int value) { + if (value <= 0) throw ...; + return new Quantity(value); + } +} +``` + +- 검증: `value > 0` +- 용도: Order 구현 시 주문 수량으로 활용 (현재는 Stock.decrease의 파라미터로만 사용) +- 현 단계에서는 정의만 해두고, Order 구현 시 본격 활용 + +### 2-3. Product Entity + +```java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "product") +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false) + private String name; + + @Embedded + private Price price; + + @Embedded + private Stock stock; + + @Column(name = "description") + private String description; + + public static Product create(Long brandId, String name, int price, int stock, String description) { + validateName(name); + return new Product(brandId, name, Price.of(price), Stock.of(stock), description); + } + + public void update(String name, int price, int stock, String description) { + guardNotDeleted(); + validateName(name); + this.name = name; + this.price = Price.of(price); + this.stock = Stock.of(stock); + this.description = description; + } + + @Override + public void delete() { + guardNotDeleted(); + super.delete(); + } + + public void decreaseStock(int quantity) { + this.stock = stock.decrease(quantity); + } +} +``` + +### 2-4. Brand와의 관계: `brandId` (FK only) + +- Product는 `brandId`만 가진다 (JPA `@ManyToOne` 연관관계 사용하지 않음). +- Brand 활성 여부 확인은 AdminProductService(Application 레이어)에서 오케스트레이션한다. +- 이유: Brand 활성 검증은 상품 등록의 사전 조건이지 독립적 도메인 규칙이 아니다. + +### 2-5. DTO 분리 구조 + +``` +Presentation Layer (commerce-api) +├── interfaces/api/product/dto/ +│ ├── ProductCreateApiRequest.java → ProductCreateCommand 변환 +│ ├── ProductUpdateApiRequest.java → ProductUpdateCommand 변환 +│ ├── ProductApiResponse.java ← ProductInfo 변환 +│ └── ProductListApiResponse.java ← ProductSummary 변환 +│ +Application Layer (commerce-service) +├── application/service/dto/ +│ ├── ProductCreateCommand.java (record) +│ ├── ProductUpdateCommand.java (record) +│ ├── ProductInfo.java (record, from(Product)) +│ └── ProductSummary.java (record, 목록용 간략 정보) +``` + +### 2-6. QueryDSL 활용 + +#### findActiveById: 상품+브랜드 활성 조건 + +```java +// 상품이 활성(deletedAt IS NULL)이고 +// 소속 브랜드도 활성(deletedAt IS NULL)인 경우에만 반환 +public Optional findActiveById(Long id) { + return Optional.ofNullable( + queryFactory.selectFrom(product) + .where( + product.id.eq(id), + product.deletedAt.isNull(), + JPAExpressions.selectOne() + .from(brand) + .where( + brand.id.eq(product.brandId), + brand.deletedAt.isNull() + ).exists() + ) + .fetchOne() + ); +} +``` + +#### 정렬 3종 + 페이징 + +```java +public Page findAllActive(Pageable pageable, String sort) { + // sort: "latest", "price_asc", "likes_desc" + // likes_desc: LEFT JOIN like + COUNT + GROUP BY +} +``` + +#### softDeleteByBrandId: 벌크 UPDATE + +```java +public long softDeleteByBrandId(Long brandId) { + return queryFactory.update(product) + .set(product.deletedAt, ZonedDateTime.now()) + .where( + product.brandId.eq(brandId), + product.deletedAt.isNull() + ) + .execute(); +} +``` + +### 2-7. BrandDeleteService (cross-aggregate 규칙) + +Brand 삭제 시 소속 Product 연쇄 soft-delete는 **BrandDeleteService**(Domain 레이어)에서 처리한다. 상품 등록 시 Brand 활성 검증은 AdminProductService(Application)에서 오케스트레이션한다. + +> 파일: `domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java` + +```java +@RequiredArgsConstructor +public class BrandDeleteService { + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + public void delete(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.NOT_FOUND.message())); + productRepository.softDeleteByBrandId(brandId); + brand.delete(); + } +} +``` + +호출 규칙: Application Service → BrandDeleteService. Controller 직접 호출 금지 (트랜잭션 보장). + +### 2-8. 향후 Domain Service 도입 후보 + +| 후보 | 도입 시점 | 사유 | +|------|----------|------| +| `OrderDomainService` | Order 구현 시 | 재고 판단 + 주문 승인/거절 — 여러 Product 종합 판단 | + +현재 `Stock.decrease()`와 `Product.decreaseStock()`으로 단일 상품 재고 차감은 Entity 책임으로 충분하다. + +--- + +## 3. TDD 구현 순서 + +### Step 1: ExceptionMessage 정의 + +> 파일: `domain/src/main/java/com/loopers/domain/product/ProductExceptionMessage.java` + +- `INVALID_NAME` — 상품명 빈 값 또는 길이 초과 +- `INVALID_PRICE` — 가격 0 이하 +- `INVALID_STOCK` — 재고 음수 +- `INVALID_QUANTITY` — 수량 0 이하 +- `NOT_FOUND` — 상품 없음 +- `ALREADY_DELETED` — 이미 삭제된 상품 +- `INSUFFICIENT_STOCK` — 재고 부족 + +### Step 2: Price VO (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Price.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/vo/PriceTest.java` + +테스트 케이스: +- 양수 가격 생성 성공 +- 0 이하 가격이면 예외 +- equals/hashCode 동등성 + +### Step 3: Stock VO (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Stock.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/vo/StockTest.java` + +테스트 케이스: +- 0 이상 재고 생성 성공 +- 음수 재고이면 예외 +- `isEnough` — 충분하면 true +- `isEnough` — 부족하면 false +- `decrease` — 정상 차감 시 새 Stock 반환 +- `decrease` — 재고 부족 시 예외 +- equals/hashCode 동등성 + +### Step 4: Quantity VO (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Quantity.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/vo/QuantityTest.java` + +테스트 케이스: +- 양수 수량 생성 성공 +- 0 이하 수량이면 예외 +- equals/hashCode 동등성 + +### Step 5: Entity (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/Product.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/ProductTest.java` + +테스트 케이스: +- 상품 생성 성공 +- 상품명 빈 값이면 예외 +- 상품명 길이 초과 시 예외 +- 상품 수정 성공 +- 삭제 시 deletedAt 설정 +- 이미 삭제된 상품 삭제 시 예외 +- 이미 삭제된 상품 수정 시 예외 +- `decreaseStock` 성공 +- `decreaseStock` 재고 부족 시 예외 + +### Step 6: Fixture + +> 파일: `domain/src/testFixtures/java/com/loopers/domain/product/ProductFixture.java` + +```java +public class ProductFixture { + public static final Long DEFAULT_BRAND_ID = 1L; + public static final String DEFAULT_NAME = "에어맥스 90"; + public static final int DEFAULT_PRICE = 139000; + public static final int DEFAULT_STOCK = 100; + public static final String DEFAULT_DESCRIPTION = "나이키 에어맥스 90"; + + public static Product create() { ... } + public static Product create(Long brandId) { ... } + public static Product create(Long brandId, String name, int price, int stock) { ... } +} +``` + +### Step 7: Repository Port + +> 파일: `domain/src/main/java/com/loopers/domain/product/ProductRepository.java` + +```java +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + Optional findActiveById(Long id); // 상품+브랜드 활성 + Page findAllActive(Pageable pageable, String sort); + List findAll(); + long softDeleteByBrandId(Long brandId); +} +``` + +### Step 8: Service + DTOs (Red → Green) + +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java` +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/AdminProductService.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/ProductServiceTest.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/AdminProductServiceTest.java` + +**ProductService** (User): +- `getActiveProducts(Pageable, String sort)` — 활성 상품 목록 (정렬, 페이징) +- `getActiveProduct(Long id)` — 활성 상품 단건 조회 + +**AdminProductService** (Admin): +- `create(ProductCreateCommand)` — 생성 +- `getById(Long)` — 단건 조회 (삭제 포함) +- `getAll()` — 전체 목록 조회 +- `update(Long, ProductUpdateCommand)` — 수정 +- `delete(Long)` — soft-delete +- `softDeleteByBrandId(Long)` — 벌크 삭제 (BrandDeleteService에서 호출) + +### Step 9: Repository Adapter (QueryDSL) + +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java` +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` + +QueryDSL 구현: +- `findActiveById` — 서브쿼리로 Brand 활성 조건 확인 +- `findAllActive` — 정렬 3종 + 페이징 (likes_desc는 LEFT JOIN) +- `softDeleteByBrandId` — 벌크 UPDATE + +### Step 10: Controller + +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java` +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java` + +**ProductController**: +- `GET /api/products` → `ProductService.getActiveProducts()` +- `GET /api/products/{id}` → `ProductService.getActiveProduct()` + +**AdminProductController**: +- `POST /api/admin/products` → `AdminProductService.create()` (Brand 활성 확인 후 생성) +- `GET /api/admin/products/{id}` → `AdminProductService.getById()` +- `GET /api/admin/products` → `AdminProductService.getAll()` +- `PUT /api/admin/products/{id}` → `AdminProductService.update()` +- `DELETE /api/admin/products/{id}` → `AdminProductService.delete()` + +### Step 11: BrandDeleteService (cross-aggregate 규칙) + +> 파일: `domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java` +> 테스트: `domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java` + +테스트 케이스 (브랜드 삭제 연쇄): +- 브랜드 삭제 시 소속 상품도 soft-delete +- 소속 상품이 없어도 브랜드 삭제 정상 수행 +- 존재하지 않는 브랜드 삭제 시 NOT_FOUND 예외 + +> **주의**: Brand 계획서의 AdminBrandController DELETE 엔드포인트가 BrandDeleteService.delete()를 경유하도록 교체 + +### Step 13: Cascade 통합 테스트 + +> 파일: `presentation/commerce-api/src/test/java/com/loopers/controller/BrandProductCascadeE2ETest.java` + +테스트 시나리오: +- 브랜드 생성 → 상품 생성 → 브랜드 삭제 → 상품도 삭제 확인 +- 브랜드 삭제 후 User 상품 목록에서 소속 상품 미노출 +- 브랜드 삭제 후 User 상품 단건 조회 시 NOT_FOUND + +### Step 14: E2E 테스트 + +> 파일: `presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java` + +테스트 시나리오: +- 상품 생성 → 201 Created +- 삭제된 브랜드로 상품 생성 → 400 Bad Request +- 활성 상품 목록 조회 (latest) → 200 OK +- 활성 상품 목록 조회 (price_asc) → 가격 오름차순 확인 +- 활성 상품 단건 조회 → 200 OK +- 삭제된 상품 User 조회 → 404 Not Found +- 상품 수정 → 200 OK +- 상품 삭제 → 204 No Content +- 삭제된 상품 재삭제 → 400 Bad Request + +--- + +## 4. 파일 생성 목록 + +### Domain Layer (`domain/`) + +| 경로 | 설명 | +|------|------| +| `domain/src/main/java/com/loopers/domain/product/Product.java` | Entity | +| `domain/src/main/java/com/loopers/domain/product/ProductRepository.java` | Repository Port | +| `domain/src/main/java/com/loopers/domain/product/ProductExceptionMessage.java` | 예외 메시지 | +| `domain/src/main/java/com/loopers/domain/product/vo/Price.java` | 가격 VO | +| `domain/src/main/java/com/loopers/domain/product/vo/Stock.java` | 재고 VO | +| `domain/src/main/java/com/loopers/domain/product/vo/Quantity.java` | 수량 VO | +| `domain/src/test/java/com/loopers/domain/product/ProductTest.java` | Entity 테스트 | +| `domain/src/test/java/com/loopers/domain/product/vo/PriceTest.java` | Price VO 테스트 | +| `domain/src/test/java/com/loopers/domain/product/vo/StockTest.java` | Stock VO 테스트 | +| `domain/src/test/java/com/loopers/domain/product/vo/QuantityTest.java` | Quantity VO 테스트 | +| `domain/src/testFixtures/java/com/loopers/domain/product/ProductFixture.java` | Fixture | +| `domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java` | Brand 삭제 Domain Service | +| `domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java` | Domain Service 테스트 | + +### Application Layer (`application/commerce-service/`) + +| 경로 | 설명 | +|------|------| +| `application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java` | User Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/AdminProductService.java` | Admin Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java` | 생성 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java` | 수정 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java` | 상세 응답 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductSummary.java` | 목록 응답 DTO | +| `application/commerce-service/src/test/java/com/loopers/application/service/ProductServiceTest.java` | User Service 테스트 | +| `application/commerce-service/src/test/java/com/loopers/application/service/AdminProductServiceTest.java` | Admin Service 테스트 | + +### Presentation Layer (`presentation/commerce-api/`) + +| 경로 | 설명 | +|------|------| +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java` | User Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java` | Admin Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java` | Presentation 생성 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java` | Presentation 수정 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java` | Presentation 상세 응답 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductListApiResponse.java` | Presentation 목록 응답 DTO | +| `presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java` | E2E 테스트 | +| `presentation/commerce-api/src/test/java/com/loopers/controller/BrandProductCascadeE2ETest.java` | Cascade 통합 테스트 | + +### Infrastructure Layer (`modules/jpa/`) + +| 경로 | 설명 | +|------|------| +| `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java` | Spring Data JPA | +| `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` | Repository Adapter (QueryDSL) | + +--- + +## 5. DB 스키마 + +```sql +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + price INT NOT NULL, + stock INT NOT NULL DEFAULT 0, + description TEXT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + -- FK 제약조건 없음 (운영 유연성 + MSA 전환 대비, 앱 레벨 검증) +); + +CREATE INDEX idx_product_brand_id ON product(brand_id); +CREATE INDEX idx_product_deleted_at ON product(deleted_at); +``` + +--- + +## 6. 참조 + +- 도메인 모델 정의서: `docs/design/05-domain-model.md` +- Brand 계획서: `docs/planning/brand-plan.md` +- 기존 패턴: `domain/src/main/java/com/loopers/domain/member/Member.java` +- VO 패턴: `domain/src/main/java/com/loopers/domain/member/vo/LoginId.java` +- BaseEntity: `domain/src/main/java/com/loopers/domain/BaseEntity.java` +- Member 리팩토링 기록: `docs/planning/refactoring-plan.md` diff --git a/docs/planning/refactoring-plan.md b/docs/planning/refactoring-plan.md new file mode 100644 index 000000000..f1b87b7eb --- /dev/null +++ b/docs/planning/refactoring-plan.md @@ -0,0 +1,116 @@ +# Member 리팩토링 작업 계획서 + +> 작성일: 2026-02-21 (최종 수정: 2026-02-22) +> 범위: 기존 구현된 Member 기능의 구조 변경 및 리팩토링 +> 신규 기능(Brand, Product, Order 등)은 이 작업 이후 별도 진행 +> +> **Phase 1 완료.** 실제 구현 구조는 이 초기 계획과 차이 있음: +> - `apps/` → `presentation/` + `application/commerce-service/` 으로 분리 +> - 상세 설계서: `docs/planning/phase1-structure-refactoring.md` +> - 구현 로그: `docs/thought/phase1-implementation-log.md` +> +> **Phase 2 완료.** 초기 계획과 실제 구현의 차이: +> - MemberPolicy → VO 4개로 전환 (초기 계획: 엔티티 내부 검증) +> - ErrorType: HttpStatus 제거, pure enum으로 domain 이동 +> - DomainService: 현재 불필요하여 보류 +> - 구현 로그: `docs/thought/phase2-implementation-log.md` +> - 논의 기록: `docs/thought/phase2-discussion-log.md` + +--- + +## 1. 작업 배경 + +### 1-1. 현재 상태 +- Member 관련 기능(회원가입, 로그인, 비밀번호 변경)만 구현되어 있음 +- 도메인 코드가 `modules/jpa`(인프라 설정 모듈)에 위치 +- 예외 처리 체계가 일관되지 않음 +- 도메인 단위 테스트(MemberTest)가 없음 +- 서비스 테스트에서 mock/capture로 간접 검증 + +### 1-2. 아키텍처 분석에서 도출된 문제점 + +| # | 문제 | 위치 | 심각도 | 해결 Phase | +|---|------|------|--------|-----------| +| 1 | domain 모듈 부재 — 도메인 코드가 인프라 모듈에 혼재 | `modules/jpa` | Critical | Phase 1 | +| 2 | IllegalArgumentException → 전부 401 UNAUTHORIZED 반환 | `ApiControllerAdvice` | Critical | Phase 2 | +| 3 | BaseTimeEntity 미구현 (soft-delete 불필요한 엔티티용) | `modules/jpa` | Critical | Phase 2 | +| 4 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | `modules/jpa` | High | Phase 2 | +| 5 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | `apps/commerce-api` | High | Phase 1 | +| 6 | Service에 표현 로직 혼재 (이름 마스킹) | `MemberService` | Medium | Phase 2 | +| 7 | BaseEntity.id `final` 선언 | `BaseEntity` | Medium | Phase 1 | +| 8 | Kafka 패키지 오타 (`confg` → `config`) | `modules/kafka` | Low | Phase 1 | + +--- + +## 2. 작업 순서 + +### Phase 1: 구조 변경 ✅ + +> 상세: `docs/planning/phase1-structure-refactoring.md`, `docs/thought/phase1-implementation-log.md` + +- [x] domain/ 모듈 신설 (루트 레벨) +- [x] application/commerce-service 모듈 신설 +- [x] presentation/commerce-api 모듈 신설 (bootJar) +- [x] 도메인 코드 이동 (modules/jpa → domain) +- [x] 비즈니스 코드 이동 (apps → application) +- [x] 인터페이스 코드 이동 (apps → presentation) +- [x] MemberRepository DIP 분리 (Port + Adapter) +- [x] MemberController 패키지 통일 +- [x] BaseEntity.id final 제거 +- [x] Kafka 패키지 오타 수정 (**재검증**: confg 잔존 확인 → 문서 정리 시 삭제 완료) +- [x] batch/streamer 이동 (apps → presentation) +- [x] apps/ 디렉토리 삭제 +- [x] 네이밍 개선 (commerce-api-core → commerce-service) +- [x] Spring Boot 플러그인 적용 범위 축소 + +### Phase 2: 모델링 및 설계 변경 ✅ + +> 상세: `docs/thought/phase2-implementation-log.md`, `docs/thought/phase2-discussion-log.md` + +- [x] ErrorType → pure enum (HttpStatus 제거), domain 레이어로 이동 +- [x] CoreException → domain 레이어로 이동 +- [x] UNAUTHORIZED ErrorType 추가 +- [x] BaseTimeEntity 신설 (id + createdAt + updatedAt) +- [x] BaseEntity → BaseTimeEntity 상속 + deletedAt +- [x] MemberPolicy → VO 4개 전환 (LoginId, Password, MemberName, Email) +- [x] Member: @Builder/@AllArgsConstructor 제거 → 정적 팩토리 +- [x] Member: BaseTimeEntity 상속 +- [x] IllegalArgumentException → CoreException 전면 교체 +- [x] ApiControllerAdvice: ErrorType → HttpStatus switch 매핑, IllegalArgumentException 핸들러 제거 +- [x] 마스킹 로직: MemberService → GetMemberInfoResponse.withMaskedName() +- [x] DTO 네이밍 통일 (RegisterMemberRequest, GetMemberInfoResponse, UpdatePasswordRequest) +- [x] MemberFixture testFixtures 생성 +- [ ] DomainService 분리 → **보류** (현재 불필요, 신규 도메인 추가 시 도입) + +### Phase 3: 테스트 코드 수정 ✅ + +> **Phase 3 완료.** 구현 로그: `docs/thought/phase3-implementation-log.md` + +- [x] 도메인 단위 테스트 보강 (domain/src/test/) +- [x] 기존 Service 테스트 정리 (mock capture 방식 개선) +- [x] 테스트 계층 명확화 (단위/통합/E2E 분리) +- [x] MemberTest: .isInstanceOf 제거, 빈/중복 테스트 삭제 +- [x] MemberServiceTest: mock capture 제거, 단언문 분리 +- [x] MemberServiceIntegrationTest: 단언문 분리, 마스킹 버그 수정 +- [x] MemberE2ETest: 시나리오 분리 (5개 독립 테스트) +- [x] CLAUDE.md: 테스트 단위 원칙 (1 테스트 = 1 단언문) 추가 +- [x] Squash 잔류 파일 정리 + +--- + +## 3. 완료 기준 + +- [x] `domain/` 모듈이 루트 레벨에 존재하며, 다른 모듈에 의존하지 않음 +- [x] `modules/jpa`에 비즈니스 로직이 없음 (설정 + Repository 구현체만) (**재검증**: 도메인 코드 잔존 확인 → 문서 정리 시 삭제 완료) +- [x] 모든 예외가 `CoreException` 기반으로 통일 +- [x] `MemberTest` 도메인 단위 테스트가 존재하며 통과 +- [ ] 기존 모든 테스트(`./gradlew test`)가 통과 — Docker 환경 필요 +- [x] 패키지 구조가 `interfaces/api/` 컨벤션에 맞춤 + +--- + +## 4. 작업 제외 사항 (이번 범위 밖) + +- Brand, Product, Like, Order 등 신규 도메인 구현 +- Facade 패턴 도입 (신규 도메인 간 의존 해소 시 적용) +- supports 모듈 의존성 중복 정리 (별도 작업) diff --git a/docs/temp/architecture-discussion-log.md b/docs/temp/architecture-discussion-log.md new file mode 100644 index 000000000..2d380ddc0 --- /dev/null +++ b/docs/temp/architecture-discussion-log.md @@ -0,0 +1,324 @@ +# 아키텍처 논의 기록 + +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + +> 작성일: 2026-02-21 +> 참여: 개발자, AI (Claude Code) +> 맥락: TDD + DDD 기반 커머스 프로젝트 리팩토링 전 논의 + +--- + +## 1. 아키텍처 분석에서 발견된 문제 + +### 분석 요약 + +현재 프로젝트는 문서(요구사항, 클래스 다이어그램, ERD)는 잘 정의되어 있지만, +실제 구현은 Member CRUD만 존재하며, 구현된 코드에도 구조적 문제가 있음. + +### 핵심 문제 3가지 + +1. **domain 모듈 부재**: 도메인 코드(Member, Policy, PasswordEncryptor)가 인프라 설정 모듈(`modules/jpa`)에 위치 +2. **예외 처리 붕괴**: 모든 `IllegalArgumentException`이 401 UNAUTHORIZED로 반환 +3. **도메인 테스트 부재**: `MemberTest`(도메인 단위 테스트)가 testFixtures에 있어 실행되지 않고, Service mock 테스트만 존재 + +### 추가 발견 사항 + +| # | 문제 | 위치 | 심각도 | +|---|------|------|--------| +| 4 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | `modules/jpa` | High | +| 5 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | `apps/commerce-api` | High | +| 6 | Application/Presentation 레이어 미분리 | `apps/commerce-api` | High | +| 7 | Service에 표현 로직 혼재 (이름 마스킹) | `MemberService` | Medium | +| 8 | BaseEntity.id `final` 선언 | `BaseEntity` | Medium | +| 9 | Kafka 패키지 오타 (`confg` → `config`) | `modules/kafka` | Low | +| 10 | supports 모듈 의존성 중복 | `logging`/`monitoring` | Low | + +--- + +## 2. 멘토 피드백 반영 + +### 피드백 1: MemberPolicy 중앙화의 문제 + +> "MemberPolicy에 들어갈 로직 자체가 Name이랑 코드위치상 거리가 멀어짐에 따라 +> 찾아가야하는 문제가 생깁니다. 더불어, MemberPolicy 하나에서 관리되고 있으므로, +> 변경사항이 발생하면 변경된 곳의 위치를 찾고 수정함에 있어서 불편함을 초래할 수 있습니다." + +**논의 결과:** +- 별도 Policy 클래스 대신, 도메인 객체가 자기 규칙을 가지는 방향으로 전환 +- VO가 자체 규칙을 가지면 해당 VO만 보면 되므로 응집도 향상 +- 다만 VO 도입은 신규 도메인(Stock, Price, Quantity)에 우선 적용하고, + 기존 Member는 Phase 2에서 규칙 내재화 + +### 피드백 2: 도메인 테스트 부재 + +> "MemberTest가 없습니다. 즉 도메인 테스트 코드가 없습니다." +> "spy를 사용하는 것이 적절한지 확인해볼 필요가 있습니다." + +**논의 결과:** +- 도메인 단위 테스트를 최우선으로 작성 +- Service 테스트에서 mock capture로 도메인 규칙을 검증하는 방식 지양 +- 도메인 규칙은 도메인 테스트에서, Service는 조합/트랜잭션 검증에 집중 + +### 피드백 3: 도메인 코드 위치 + +> "jpa가 modules에 들어가져 있는데요. 여기서 jpa는 reusable configuration이기 때문에 +> 적절하지 않습니다. 도메인 코드는 app 쪽에 있어야 합니다." + +**논의 결과:** +- 별도 `domain/` 모듈을 루트 레벨에 신설 +- `modules/jpa`는 인프라 설정 + Repository Adapter만 담당 +- DIP: domain이 Repository 인터페이스(Port)를 정의하고, modules/jpa가 구현(Adapter) + +--- + +## 3. 주요 설계 결정 + +### 3-1. 레이어 분리: 모듈 수준 (최종 결정) + +**논의 과정:** +- 처음에는 domain/ 모듈 분리만 고려 +- Application Layer와 Presentation Layer도 모듈 수준으로 분리하자는 논의 발생 +- 패키지 수준 정리 vs 모듈 수준 분리를 비교 + +**결정: 모듈 수준 분리 (안 A: 레이어 명시적 분리)** + +``` +Root +├── domain/ ← Domain Layer (java-library) +├── application/ ← Application Layer (java-library) +│ └── commerce-service/ +├── presentation/ ← Presentation Layer (bootJar) +│ └── commerce-api/ +├── modules/ ← Infrastructure Layer (java-library) +└── supports/ ← Cross-cutting +``` + +이유: +- 컴파일 시점에 의존 방향을 강제할 수 있음 +- 레이어 간 책임이 명확히 분리됨 +- 디렉토리 이름 = 레이어 이름으로 직관적 + +**비교했던 대안:** +| 안 | 설명 | 기각 이유 | +|---|------|----------| +| 패키지 수준 정리 | apps 내부에서 패키지로만 구분 | 컴파일러가 의존 방향 강제 불가 | +| apps 내부 분리 | apps/commerce-api-core + apps/commerce-api | apps가 두 가지 의미를 가짐 | +| apps를 application으로 | apps 이름 유지 + presentation 추가 | "apps"가 "application layer" 의미로 혼란 | + +### 3-2. domain 모듈 위치: 루트 레벨 + +**결정: `domain/` (루트 레벨)** + +이유: `modules/`는 jpa, redis, kafka 등 "외부 시스템 연결 어댑터"의 성격. +도메인은 이와 본질적으로 다르므로 분리하는 것이 의미적으로 명확함. + +### 3-3. JPA 어노테이션: 실용적 방향 + +**결정: domain에서 `@Entity`, `@Embeddable` 등 JPA 어노테이션 허용** + +이유: +- 현재 프로젝트가 이미 BaseEntity에서 @MappedSuperclass 등 사용 중 +- 순수주의 적용 시 매핑 레이어 추가로 복잡도 증가 +- `jakarta.persistence-api`는 인터페이스 수준 의존 + +### 3-4. Repository Adapter 위치: modules/jpa + +**결정: MemberJpaRepository + MemberRepositoryImpl을 `modules/jpa`에 배치** + +논의: +- 처음 제안은 apps(현 presentation) 내부에 두는 것 +- 개발자가 modules/jpa에 두기를 선택 +- 이유: 모든 앱(batch, streamer)이 같은 Repository 구현을 공유 + +결과: +- Port(MemberRepository 인터페이스) → `domain/` 모듈 +- Adapter(MemberJpaRepository + MemberRepositoryImpl) → `modules/jpa` +- app-specific adapter(ExampleJpaRepository 등) → `presentation/commerce-api` + +### 3-5. bootJar 위치: presentation + +**결정: `presentation/`이 Spring Boot 실행 애플리케이션을 담당** + +이유: +- 가장 바깥 레이어가 실행 진입점 +- 레이어 구조와 일관됨 +- `./gradlew :presentation:commerce-api:bootRun`으로 실행 + +### 3-6. VO 전략: @Embeddable 사용 + +| 대상 | JPA 매핑 | 자체 규칙 | +|------|---------|----------| +| Stock | `@Embeddable` → product.stock INT | value >= 0, isEnough(), decrease() | +| Price | `@Embeddable` → product.price INT | value > 0 | +| Quantity | `@Embeddable` → order_line_snapshot.quantity INT | value > 0 | + +VO 도입 기준: 자체 규칙(invariant)이 있는 필드만 VO로 승격. +(Phase 2 이후 신규 도메인 구현 시 적용) + +### 3-7. Presentation 레이어 네이밍: "presentation" + +검토한 후보: `interfaces`, `presentation`, `api` +결정: `presentation` — 레이어드 아키텍처 용어에 부합, 직관적. + +--- + +## 4. "테스트 가능한 코드"에 대한 고민 + +### 핵심 질문 +> "Test 가능한 코드란 무엇인가?" + +### 도출된 3가지 기준 + +#### 기준 1: 비즈니스 규칙이 객체 안에 있는가? +- Service에 if문으로 규칙이 있으면 → mock 테스트 필요 (테스트 어려움) +- 도메인 객체가 자기 규칙을 가지면 → new로 생성해서 바로 검증 (테스트 쉬움) +- **예시:** `new Stock(10).decrease(new Quantity(3))` → Mock 없이 순수 자바로 검증 가능 + +#### 기준 2: 외부 의존이 주입 가능한가? +- 객체가 직접 외부를 호출하면 → 테스트 시 그 외부를 통째로 구성해야 함 +- 인터페이스로 받아서 사용하면 → Fake 구현으로 대체 가능 +- **예시:** `ProductRepository` 인터페이스를 domain에 정의 → 테스트 시 `FakeProductRepository` 주입 + +#### 기준 3: 부수효과(side effect)가 분리되어 있는가? +- 하나의 메서드에서 검증 + 저장 + 이벤트 발행 → 전부 필요해서 테스트 무거움 +- 순수 로직(도메인)과 부수효과(Service)가 분리 → 각각 적절한 수준으로 테스트 + +### 테스트 피라미드 적용 + +``` + / E2E \ ← 적고 느림 (Spring Context + DB) + / 통합 \ ← 적당 (Service + Repository) + / 단위 \ ← 많고 빠름 (순수 도메인 객체) +``` + +도메인에 규칙이 내재되어 있으면 → 피라미드 하단(단위 테스트)이 두꺼워짐 +Service에 규칙이 있으면 → 피라미드가 뒤집혀서 통합/E2E에 의존 + +### 현재 코드의 문제 + +```java +// 현재: Service에서 mock capture로 간접 검증 +verify(memberRepository).save(memberCaptor.capture()); +assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); +// → 구현 세부사항에 결합, Member 도메인 규칙 자체를 검증하지 않음 + +// 목표: 도메인 객체를 직접 테스트 +Member member = Member.register("testId", "Password1!", "홍길동", + LocalDate.of(1990, 1, 1), "test@test.com"); +assertThat(member.isSamePassword("Password1!")).isTrue(); +// → Mock 없음, DB 없음, Spring 없음. 규칙만 검증. +``` + +--- + +## 5. 리팩토링 진행 순서 + +``` +Phase 1: 구조 변경 + → domain, application, presentation 모듈 분리 + → 코드 이동, 의존 방향 설정, 패키지 통일, 기타 수정 + +Phase 2: 모델링 및 설계 변경 + → BaseTimeEntity 분리 + → MemberPolicy 제거 (규칙 내재화) + → 예외 체계 통일 (CoreException 기반) + → Service 책임 분리 (마스킹 로직 이동) + +Phase 3: 테스트 코드 수정 + → 도메인 단위 테스트 보강 + → 기존 Service 테스트 정리 + → 테스트 계층 명확화 +``` + +각 Phase 완료 후 `./gradlew test` 통과를 확인하며 점진적으로 진행. + +--- + +## 6. 코드 스타일 논의 (Phase 1 구현 전) + +> 날짜: 2026-02-21 +> 맥락: Phase 1 구현 직전, 코드 스타일 통일을 위한 논의 + +### 6-1. @Builder 사용 금지 + +**결정: `@Builder`, `@AllArgsConstructor` 사용하지 않음** + +이유: +- `@Builder`는 필드 추가/삭제 시 기존 호출부에서 컴파일 에러가 발생하지 않음 +- 런타임에 가서야 문제를 발견하게 됨 +- 정적 팩토리 메서드(`Member.register(...)`)는 파라미터 변경 시 즉시 컴파일 에러 + +대안: +- 생성: 정적 팩토리 메서드만 사용 +- `@NoArgsConstructor(access = AccessLevel.PROTECTED)` 유지 (JPA용) + +### 6-2. @Transactional 정책 + +**결정: ApplicationService 클래스 레벨에만 적용** + +- 기본: `@Transactional` (클래스 레벨) +- 조회 메서드: `@Transactional(readOnly = true)` 오버라이드 +- DomainService에는 절대 `@Transactional` 사용하지 않음 + +### 6-3. Service 구조: ApplicationService + DomainService + +**결정:** +- **ApplicationService**: 유스케이스 조합, 트랜잭션 경계 담당 +- **DomainService**: 바운디드 컨텍스트/애그리거트 내 도메인 로직 조합 담당 + +ApplicationService는 Repository를 직접 사용하지 않고 DomainService를 통해 접근. + +### 6-4. Repository 예외 처리 위치 + +**논의 과정:** +1. 처음: DomainService에서 Repository + 예외 처리를 담당하는 안 검토 +2. 문제 제기: Repository + 예외 처리 역할은 DomainService(도메인 로직 조합)와 성격이 다름 +3. 별도 이름 부여 검토 (MemberStore, MemberReader 등) +4. 개발자 제안: "예외 처리를 modules/jpa로 넘기고, 도메인은 불러오기만" +5. 문제 발견: modules/jpa → application 의존 방향 위반 (CoreException이 application에 있으므로) +6. 해결: **도메인 예외를 domain 레이어에 정의** + +**최종 결정: 도메인 예외를 domain 레이어에 정의** + +``` +domain/ + └── member/ + ├── Member.java + ├── MemberRepository.java (Port) + └── exception/ + └── MemberNotFoundException.java + └── support/error/ + └── DomainException.java ← 도메인 예외 베이스 +``` + +의존 방향: +``` +presentation → application → domain ← modules/jpa + ↑ + 모두 domain 예외 사용 가능 +``` + +각 레이어 역할: +- **domain**: 예외 정의 (DomainException, MemberNotFoundException 등) +- **modules/jpa**: RepositoryImpl에서 도메인 예외를 던짐 (domain에 의존하므로 가능) +- **application**: 그대로 전파하거나 비즈니스 판단 후 다른 예외로 변환 +- **presentation**: ApiControllerAdvice에서 도메인 예외를 HTTP 응답으로 매핑 + +### 6-5. DTO 네이밍 + +**결정: 행동을 먼저 작성** +- `RegisterMemberRequest` (O) +- `MemberRegisterRequest` (X) + +### 6-6. 주석 정책 + +**결정:** +- 메서드 내부 주석 없음 — 메서드명 자체로 로직이 드러나야 함 +- Javadoc은 Controller 메서드에만 +- Service의 public 메서드가 많아질 때 public에만 Javadoc 추가 + +### 6-7. @ResponseStatus + +**결정: 컨벤션 확립 예정** +- 현재 MemberController에서 `@ResponseStatus(HttpStatus.CREATED)`, `@ResponseStatus(HttpStatus.NO_CONTENT)` 사용 중 +- 구현하면서 정리할 예정 diff --git a/docs/thought/architecture-direction-v2.md b/docs/thought/architecture-direction-v2.md new file mode 100644 index 000000000..d64752ea0 --- /dev/null +++ b/docs/thought/architecture-direction-v2.md @@ -0,0 +1,408 @@ +# 설계 방향 v2 — 260223 멘토링 이후 + +> 작성일: 2026-02-24 +> +> 기반: 260223 Kev님 멘토링 + 기존 06-architecture.md +> +> 상태: **확정** — 모든 아키텍처 결정 완료 + +--- + +## 변경 배경 + +기존 `06-architecture.md`에서는 JPA Entity로 Domain을 구현하는 것이 **단점이 더 작다**고 판단했다. + +하지만 260223 멘토링을 듣고 나서 든 생각: + +> DIP를 과제로 내주셨다는 것 == DIP는 요구사항이고, 기획의 의도가 담긴 것이 아닐까? +> 우리가 아직은 모르는 Repository나 Entity의 변경사항이 이후 주차에서 생기기 때문에 DIP를 구현하는 것이 아닐까? + +→ **결론: Domain 레이어를 순수하게 유지하자.** + +--- + +## 1. Domain Layer — 기능적인 요구사항의 레이어 + +기술 구현과 상관 없는 **기능적인 요구사항(공책 게임으로 구현할 수 있는 요구사항)**을 구현하는 레이어. + +### 1-1. Entity, VO — 순수 도메인 객체 + +**기존 결정 (Deprecated):** +- JPA `@Entity`, `@Embeddable`을 사용 +- 순수 도메인 엔티티보다 JPA 엔티티로 구현했을 때 단점이 더 작다고 판단 + +| 순수한 도메인 엔티티의 단점 | JPA 엔티티의 단점 | +|---|---| +| JPA만의 편의성을 잃어버림: 영속성 컨텍스트 사용 불가 | 비즈니스 로직 가독성 저하: 엔티티 기능 구현 시 JPA 고려 필요 | +| 코드 복잡도 상승: 관리해야 하는 레이어 및 코드 증가 | 테스트 코드 작성 시 영속성 컨텍스트 고려 필요 | +| | 프레임워크 변경 시 엔티티 코드도 수정 | + +당시에는 JPA 엔티티의 단점이 더 작다고 판단했고, 필요하면 AI로 빠르게 변경할 수 있다고 봤다. + +**현재 결정: 순수 도메인 엔티티로 변경** + +DIP를 요구사항으로 받아들여, Domain 레이어를 순수하게 유지한다. + +### 1-1-1. ID VO화 + +모든 엔티티의 ID를 `Long`이 아닌 **전용 VO**로 감싼다. + +```java +// Before +private Long id; + +// After +public class MemberId { + private final Long value; +} +``` + +| VO | 대상 | 효과 | +|----|------|------| +| `MemberId` | Member | Cross-BC 참조 시 타입으로 구분 가능 | +| `BrandId` | Brand | `ProductId`와 혼동 방지 | +| `ProductId` | Product | 주문, 좋아요 등에서 명확한 참조 | +| `OrderId` | Order | | +| `LikeId` | Like | | + +**타입 안전성 확보:** +```java +// Before — Long끼리 섞여도 컴파일 에러 없음 +public void doSomething(Long memberId, Long productId) { ... } +doSomething(productId, memberId); // 컴파일 OK, 런타임 버그 + +// After — 타입이 다르면 컴파일 에러 +public void doSomething(MemberId memberId, ProductId productId) { ... } +doSomething(productId, memberId); // 컴파일 에러 +``` + +JPA Entity에서는 `Long`으로 저장하고, `toModel()`/`fromModel()`에서 VO로 변환. + +### 1-1-2. VO 팩토리 메서드 — `of()` 통일 + +모든 VO는 `of()` 하나로 생성한다. DB에서 복원할 때도 `of()`를 사용하여 검증을 통과시킨다. +`fromValue()` 같은 검증 스킵 메서드를 별도로 만들지 않는다. + +- 문자열 검증 비용은 무시할 수준 +- 생성 경로가 하나이므로 혼동 없음 +- DB 데이터 오염 시 검증이 잡아줌 +- 검증 규칙 변경 시 기존 데이터 문제는 데이터 마이그레이션으로 해결 (VO 책임 아님) + +```java +// toModel() 에서도 of() 사용 +public Member toModel() { + return new Member( + new MemberId(getId()), + LoginId.of(loginId), + Password.fromEncrypted(password), // Password만 예외 + MemberName.of(name), + birthDate, + Email.of(email), + getCreatedAt(), + getUpdatedAt() + ); +} +``` + +**Password만 예외:** `of()`는 평문 → 암호화, `fromEncrypted()`는 이미 암호화된 값 복원. +이건 검증 스킵이 아니라 **생성 의미 자체가 다른 것**이다. + +**신규 엔티티의 ID가 없는 상태 처리:** + +DB auto-increment를 사용하므로 `save()` 전에는 ID가 없다. +`save()` 반환값에서 ID가 채워진 Domain Entity를 받는 방식으로 처리한다. + +```java +Member member = Member.register(...); // ID 없음 (null) +Member saved = memberRepository.save(member); // ID가 채워진 새 객체 반환 +saved.getId(); // MemberId(1L) +``` + +RepositoryImpl의 `save()`가 JPA Entity를 저장한 뒤, auto-generated ID를 포함하여 `toModel()`로 변환해 반환한다. + +### 1-2. Domain Service — 함수의 객체화 + +나만의 정의: + +> Domain Service는 **단일 엔티티에서 구현 불가능**하지만, **하나의 BC 내에서 책임을 맡는 기능**을 구현. + +근거: +- Service의 의미는 Input과 Output을 가지며 상태를 가지지 않는다 +- → **`Service == 함수의 객체화`** 라고 정의 +- Domain Service도 하나의 객체라는 판단 하에 SRP를 적용 +- → 단일 엔티티에서 구현 불가능 + 하나의 BC 내 책임 = Domain Service + +**Bean 등록: `@Component` 사용 안 함. Application 레이어에서 `@Configuration` + `@Bean` 수동 등록.** + +Domain을 순수하게 유지하는 방향이므로, Spring 어노테이션(`@Component` 포함)을 Domain에서 전부 제거한다. +Application 레이어의 `@Configuration` 클래스에서 `@Bean`으로 등록한다. + +이 패턴은 DDD 레퍼런스 프로젝트들(DDDSample, ddd-by-examples/library, Baeldung Hexagonal)에서 표준으로 쓰이는 방식이다. + +```java +// application/commerce-service 내 @Configuration +@Configuration +public class DomainServiceConfig { + @Bean + public BrandDeleteService brandDeleteService( + BrandRepository brandRepo, ProductRepository productRepo) { + return new BrandDeleteService(brandRepo, productRepo); + } +} +``` + +- 컴파일 시점: Application은 Repository **인터페이스**(Domain)만 알면 됨 +- 런타임: Presentation(Composition Root)에서 Infrastructure의 구현체 Bean이 주입됨 +- Application에 이미 `spring-context` 의존이 있으므로 `@Configuration` 사용 가능 + +### 1-3. Repository + +도메인 레이어는 알 수 없는 저장소랑 상호작용(저장, 조회, 수정, 삭제)하는 용도. + +공책 게임으로 치면 연필로 쓰고, 지우개로 지우고 하며 정보를 작성하는 것이라고 판단. +→ Domain 레이어에서 호출하는 것을 정당하다고 생각함. +그것 또한 도메인 레이어가 담당하는 것이 아닐까? + +### 1-4. CoreException / ErrorType — supports 레이어로 이동 + +- AOP를 통해 예외 처리를 간단하게 처리하기 위해 커스텀 예외 클래스를 만듦 +- 기존처럼 레이어를 나누면 커스텀 예외 클래스를 모든 레이어마다 만들고 매핑해야 한다는 것을 깨달음 +- 예외 클래스에 대해서만 예외적으로 공통적인 클래스를 사용하는 것으로 우회함 + +**결정: supports 레이어로 이동. Domain이 supports/error에 의존하는 것을 예외적으로 허용.** + +| 항목 | 내용 | +|------|------| +| 신규 모듈 | `supports/error` | +| 포함 클래스 | `ErrorType`, `CoreException` | +| 의존 방향 | `domain → supports/error` (예외적 허용) | +| 모듈 성격 | 순수 Java (Spring 의존 없음) | + +기존 supports 모듈들(jackson, logging, monitoring)은 모두 Presentation add-on 성격이지만, +`supports/error`는 **모든 레이어가 공유하는 예외 기반**이라는 점에서 성격이 다름. +이 차이를 인지한 상태에서 예외적으로 허용한다. + +### 1-5. PasswordEncryptor — DIP로 Infrastructure로 이동 + +현재 상태: `domain/utils/PasswordEncryptor.java` (SHA-256, static 유틸리티) + +**결정: 인터페이스는 Domain, 구현체(BCrypt)는 Infrastructure. DIP 적용.** + +Domain에 `PasswordEncryptor` 인터페이스를 두고, Infrastructure에서 BCrypt로 구현한다. + +``` +Domain Infrastructure +┌──────────────────────┐ ┌──────────────────────────┐ +│ PasswordEncryptor │◄──────────│ BCryptPasswordEncryptor │ +│ (interface) │implements │ (spring-security-crypto) │ +│ encode(raw) │ └──────────────────────────┘ +│ matches(raw, enc) │ +└──────────────────────┘ +``` + +**Password VO 변경 — encryptor를 파라미터로 받는 방식:** + +```java +// Before (static 호출) +public static Password of(String rawPassword, LocalDate birthDate) { + return new Password(PasswordEncryptor.encode(rawPassword)); +} + +// After (인터페이스 주입) +public static Password of(String rawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateFormat(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + return new Password(encryptor.encode(rawPassword)); +} + +public boolean matches(String rawPassword, PasswordEncryptor encryptor) { + return encryptor.matches(rawPassword, this.value); +} +``` + +Application Service(MemberService)가 PasswordEncryptor를 주입받아 Member에 전달: +```java +// MemberService +private final PasswordEncryptor passwordEncryptor; + +public void register(...) { + Member member = Member.register(loginId, rawPassword, name, birthDate, email, passwordEncryptor); +} +``` + +### 1-6. Aggregate vs BC + +> 애그리거트는 데이터 일관성을 지키는 하나의 단위임. +> 그래서 특정 트랜잭션 안에서 그 애그리거트 단위의 것들은 항상 같이 작동해야 함. +> 그러므로 BC가 현재의 선택임. + +--- + +## 2. Application Layer — 비기능적 요구사항 + BC 조합의 레이어 + +**비기능적인 요구사항(트랜잭션 등)**을 구현하거나, 도메인 레이어에서 해결하지 못하는 **여러 BC들을 조합하여 기능**을 구현하는 레이어. + +### 2-1. Application Service + +여러 도메인 계층의 BC나 외부 기술 등을 응용하여 비즈니스 로직으로 조합시켜 만드는, **인풋과 아웃풋이 분명한 함수 같은 객체**. + +### 2-2. Facade + +Service들을 가져와서 조합해야 하는데 Service 사이의 **순환 참조가 발생할 때**, 이를 막기 위해 만들어진 패턴. + +--- + +## 3. Presentation Layer (Interfaces Layer) — 값을 표현하는 레이어 + +클래스를 매핑하여 **사용자나 다른 서버 등 인터페이스로 값을 표현**하는 레이어. + +### 3-1. API DTO (컨트롤러 기준) + +Application의 Command / Query DTO로부터 API 요청값 및 응답값을 매핑해 레이어 사이 또는 다른 개체(클라이언트, 서버 등)와 통신함. + +### 3-2. ApiControllerAdvice + +Domain 레이어의 ErrorType을 HttpStatus로 매핑. + +--- + +## 4. Infrastructure Layer — DIP를 위한 레이어 + +> 물리적 모듈명: `infrastructure/` (구 `modules/`에서 리네임 완료) + +Domain Layer에서 DB와 연결하여 도메인 객체를 가져다 쓸 수 있도록 하기 위해 **기존의 의존 방향을 뒤집어, Domain Layer의 변경을 최소화**하기 위해 만들어진 레이어. + +### 4-1. RepositoryImpl + +Domain 레이어의 Repository를 구현해 DIP를 만족하도록 구현하는 구현체. 실제 기능은 JpaRepository에 위임. + +### 4-2. JpaRepository + +Spring Data JPA로 만들어둔 DB에서 값을 가져다 쓸 수 있는 Repository 객체. + +### 4-3. JPA Entity — Domain Entity와 분리 + +Domain Entity가 순수해지므로, **JPA Entity는 Infrastructure에 별도로 존재**한다. + +RepositoryImpl이 Domain Entity ↔ JPA Entity 간 변환을 담당한다. + +``` +Domain Infrastructure (infrastructure/jpa) +┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Member │◄─toModel─│ MemberJpaEntity │─────│ MemberJpaRepository │ +│ (순수 POJO) │ │ (@Entity) │ │ (Spring Data JPA) │ +└─────────────┘─fromModel→└──────────────────┘ └─────────────────────┘ + │ + ┌────────┴────────┐ + │ MemberRepoImpl │ + │ (implements │ + │ MemberRepo) │ + └─────────────────┘ +``` + +**RepositoryImpl의 역할 변경:** + +```java +// Before — Domain Entity를 직접 JPA에 전달 +public Member save(Member member) { + return memberJpaRepository.save(member); +} + +// After — Domain Entity ↔ JPA Entity 변환 +public Member save(Member member) { + MemberJpaEntity jpaEntity = MemberJpaEntity.fromModel(member); + MemberJpaEntity saved = memberJpaRepository.save(jpaEntity); + return saved.toModel(); +} +``` + +**변환 메서드는 JPA Entity가 소유:** + +```java +// MemberJpaEntity — Infrastructure +@Entity @Table(name = "member") +public class MemberJpaEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "login_id") private String loginId; + @Column(name = "password") private String password; + @Column(name = "name") private String name; + private LocalDate birthDate; + @Column(name = "email") private String email; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + + // Infrastructure → Domain + public Member toModel() { ... } + + // Domain → Infrastructure + public static MemberJpaEntity fromModel(Member member) { ... } +} +``` + +**trade-off 인지:** + +| 얻는 것 | 잃는 것 | +|---------|---------| +| Domain이 JPA를 모름 — 테스트에서 영속성 컨텍스트 고려 불필요 | 매핑 코드 증가 (toModel/fromModel) | +| 프레임워크 변경 시 Domain 코드 무변경 | JPA dirty checking 직접 사용 불가 — save 명시 호출 필요 | +| Entity 설계가 비즈니스 로직에만 집중 | 객체 복사 비용 (현재 규모에서는 무시 가능) | + +### 4-4. BaseTimeJpaEntity / BaseJpaEntity — Infrastructure로 이동 + +기존 Domain의 `BaseTimeEntity`, `BaseEntity`는 `@MappedSuperclass`, `@PrePersist`, `@PreUpdate` 등 JPA 어노테이션을 사용한다. +Domain을 순수하게 유지하므로, **JPA용 Base 클래스는 Infrastructure(infrastructure/jpa)로 이동**한다. + +``` +Domain (순수) Infrastructure (infrastructure/jpa) +┌─────────────────────┐ ┌─────────────────────────────┐ +│ (Base 클래스 없음 │ │ BaseTimeJpaEntity │ +│ 또는 순수 Base) │ │ @MappedSuperclass │ +│ │ │ @PrePersist, @PreUpdate │ +│ Member │ │ │ +│ id, createdAt, │ │ BaseJpaEntity │ +│ updatedAt │ │ extends BaseTimeJpaEntity │ +└─────────────────────┘ │ + deletedAt │ + └─────────────────────────────┘ +``` + +Domain Entity는 `id`, `createdAt`, `updatedAt` 등을 직접 필드로 가지되, JPA 어노테이션 없이 순수하게 유지한다. +JPA Entity(`MemberJpaEntity` 등)가 `BaseTimeJpaEntity`를 상속하고, `toModel()`에서 Domain Entity로 변환할 때 이 필드들을 옮겨준다. + +### 4-5. BCryptPasswordEncryptor — infrastructure/security + +> 물리적 모듈: `infrastructure/security` + +Domain의 `PasswordEncryptor` 인터페이스를 BCrypt로 구현. + +`spring-security-crypto`의 `BCryptPasswordEncoder`에 위임. + +암호화는 DB와 무관한 기술 관심사이므로 `infrastructure/jpa`가 아닌 별도 모듈로 분리한다. + +--- + +## 5. 변경 영향 분석 — 06-architecture.md 대비 + +### 5-1. 결정 완료 + +| # | 항목 | 기존 | 변경 | +|---|------|------|------| +| 1 | Domain Entity | JPA 어노테이션 허용 | **순수 POJO. JPA Entity는 Infrastructure에 분리** | +| 2 | Domain Service Bean 등록 | `@Component` 허용 | **`@Component` 제거. `@Bean` 수동 등록** | +| 3 | Spring 어노테이션 | `@Component` Domain 허용 | **Domain에서 Spring 어노테이션 전부 제거** | +| 4 | CoreException / ErrorType | Domain Layer | **supports/error 모듈로 이동. Domain의 의존을 예외적으로 허용** | +| 5 | PasswordEncryptor | `domain/utils/` (SHA-256, static) | **Domain에 interface, Infrastructure에 BCrypt 구현체. DIP 적용** | +| 6 | Aggregate vs BC | 미결정 | **BC 단위. Aggregate는 데이터 일관성 단위로 이해하되, 현재는 BC로 운영** | +| 7 | ID | `Long id` (원시값) | **VO화. `MemberId`, `BrandId`, `ProductId` 등으로 타입 안전성 확보** | +| 8 | ID null 처리 | — | **save() 반환값에서 ID가 채워진 객체를 받는 방식 (C방식)** | +| 9 | Domain Service Bean 등록 | 미정 | **Application 레이어에서 `@Configuration` + `@Bean`. DDDSample, ddd-by-examples/library 등 DDD 레퍼런스의 표준 패턴** | +| 10 | BaseTimeEntity / BaseEntity | Domain Layer (JPA 어노테이션) | **Infrastructure(infrastructure/jpa)로 이동. BaseTimeJpaEntity / BaseJpaEntity** | +| 11 | 모듈 리네임 | `modules/` | **`infrastructure/`로 리네임 완료** | +| 12 | supports/error | 미생성 | **모듈 생성 완료. 순수 Java, Spring 의존 없음** | + +| 13 | BCryptPasswordEncryptor 모듈 위치 | 미정 | **`infrastructure/security` 모듈 생성 완료. `spring-security-crypto` 의존** | +| 14 | VO 팩토리 메서드 | — | **`of()` 하나로 통일. DB 복원 시에도 검증. Password만 `fromEncrypted()` 예외 (생성 의미가 다름)** | + +### 5-2. 미결정 + +없음 — 모든 아키텍처 결정 완료. diff --git a/docs/thought/architecture-discussion-log.md b/docs/thought/architecture-discussion-log.md new file mode 100644 index 000000000..f85f1606d --- /dev/null +++ b/docs/thought/architecture-discussion-log.md @@ -0,0 +1,218 @@ +# 아키텍처 논의 기록 + +> 작성일: 2026-02-21 +> 참여: 개발자, AI (Claude Code) +> 맥락: TDD + DDD 기반 커머스 프로젝트 리팩토링 전 논의 + +--- + +## 1. 아키텍처 분석에서 발견된 문제 + +### 분석 요약 + +현재 프로젝트는 문서(요구사항, 클래스 다이어그램, ERD)는 잘 정의되어 있지만, +실제 구현은 Member CRUD만 존재하며, 구현된 코드에도 구조적 문제가 있음. + +### 핵심 문제 3가지 + +1. **domain 모듈 부재**: 도메인 코드(Member, Policy, PasswordEncryptor)가 인프라 설정 모듈(`modules/jpa`)에 위치 +2. **예외 처리 붕괴**: 모든 `IllegalArgumentException`이 401 UNAUTHORIZED로 반환 +3. **도메인 테스트 부재**: `MemberTest`(도메인 단위 테스트)가 없고, Service mock 테스트만 존재 + +--- + +## 2. 멘토 피드백 반영 + +### 피드백 1: MemberPolicy 중앙화의 문제 + +> "MemberPolicy에 들어갈 로직 자체가 Name 이랑 코드위치상 거리가 멀어짐에 따라 +> 찾아가야하는 문제가 생깁니다. 더불어, MemberPolicy 하나에서 관리되고 있으므로, +> 변경사항이 발생하면 변경된 곳의 위치를 찾고 수정함에 있어서 불편함을 초래할 수 있습니다." + +**논의 결과:** +- 별도 Policy 클래스 대신, 도메인 객체가 자기 규칙을 가지는 방향으로 전환 +- VO가 자체 규칙을 가지면 해당 VO만 보면 되므로 응집도 향상 +- 다만 VO 도입은 신규 도메인(Stock, Price, Quantity)에 우선 적용하고, + 기존 Member는 엔티티 내부 검증으로 최소 변경 + +### 피드백 2: 도메인 테스트 부재 + +> "MemberTest가 없습니다. 즉 도메인 테스트 코드가 없습니다." +> "spy를 사용하는 것이 적절한지 확인해볼 필요가 있습니다." + +**논의 결과:** +- 도메인 단위 테스트를 최우선으로 작성 +- Service 테스트에서 mock capture로 도메인 규칙을 검증하는 방식 지양 +- 도메인 규칙은 도메인 테스트에서, Service는 조합/트랜잭션 검증에 집중 + +### 피드백 3: 도메인 코드 위치 + +> "jpa 가 modules에 들어가져 있는데요. 여기서 jpa 는 reusable configuration이기 때문에 +> 적절하지 않습니다. 도메인 코드는 app 쪽에 있어야 합니다." + +**논의 결과:** +- 별도 `domain/` 모듈을 루트 레벨에 신설 +- `modules/jpa`는 인프라 설정 + Repository 구현체만 담당 +- DIP: domain이 Repository 인터페이스를 정의하고, modules/jpa가 구현 + +--- + +## 3. 주요 설계 결정 + +### 3-1. domain 모듈 위치: 루트 레벨 vs modules 하위 + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| `modules/domain/` | modules 안에서 관리 일원화 | modules의 성격(인프라 설정)과 안 맞음 | +| **`domain/` (루트)** | 직관적, 계층 구분 명확 | 기존 3분류(apps/modules/supports) 깨짐 | + +**결정: 루트 레벨 `domain/`** + +이유: `modules/`는 jpa, redis, kafka 등 "외부 시스템 연결 어댑터"의 성격. +도메인은 이와 본질적으로 다르므로 분리하는 것이 의미적으로 명확함. + +### 3-2. JPA 어노테이션: 실용적 vs 순수주의 + +| 선택지 | domain에 JPA 어노테이션 | 장점 | 단점 | +|--------|------------------------|------|------| +| **실용적 (선택)** | `@Entity`, `@Embeddable` 허용 | 코드 간결, 매핑 클래스 불필요 | domain이 `jakarta.persistence` API에 의존 | +| 순수주의 | 순수 POJO만 | 완전한 프레임워크 독립 | JPA 매핑 레이어 별도 필요, 코드량 증가 | + +**결정: 실용적 방향** + +이유: +- 현재 프로젝트가 이미 `BaseEntity`에서 `@MappedSuperclass`, `@PrePersist` 등 사용 중 +- 순수주의 적용 시 매핑 레이어 추가로 복잡도 증가, 현 단계에서 불필요 +- `jakarta.persistence-api`는 인터페이스 수준이므로 Hibernate 구현에 직접 의존하지 않음 + +### 3-3. VO 전략: @Embeddable 사용 + +| 대상 | JPA 매핑 | 자체 규칙 | +|------|---------|----------| +| Stock | `@Embeddable` → product.stock INT | value >= 0, isEnough(), decrease() | +| Price | `@Embeddable` → product.price INT | value > 0 | +| Quantity | `@Embeddable` → order_line_snapshot.quantity INT | value > 0 | + +**VO 도입 기준:** 자체 규칙(invariant)이 있는 필드만 VO로 승격. +규칙 없이 단순 저장만 하는 필드는 primitive 유지. + +### 3-4. 정책 검증 패턴: Policy 분리 vs 객체 내재 + +**결정: 객체에 내재 (co-location)** + +``` +Before: Member → MemberPolicy.Name.validate(name) // 찾아가야 함 +After: Member.register() 내부에서 직접 검증 // 한 곳에서 확인 + 또는 VO가 생성자에서 검증 // 해당 VO만 보면 됨 +``` + +--- + +## 4. "테스트 가능한 코드"에 대한 고민 + +### 핵심 질문 +> "Test 가능한 코드란 무엇인가?" + +### 도출된 3가지 기준 + +#### 기준 1: 비즈니스 규칙이 객체 안에 있는가? +- Service에 if문으로 규칙이 있으면 → mock 테스트 필요 (테스트 어려움) +- 도메인 객체가 자기 규칙을 가지면 → new로 생성해서 바로 검증 (테스트 쉬움) +- **예시:** `new Stock(10).decrease(new Quantity(3))` → Mock 없이 순수 자바로 검증 가능 + +#### 기준 2: 외부 의존이 주입 가능한가? +- 객체가 직접 외부를 호출하면 → 테스트 시 그 외부를 통째로 구성해야 함 +- 인터페이스로 받아서 사용하면 → Fake 구현으로 대체 가능 +- **예시:** `ProductRepository` 인터페이스를 domain에 정의 → 테스트 시 `FakeProductRepository` 주입 + +#### 기준 3: 부수효과(side effect)가 분리되어 있는가? +- 하나의 메서드에서 검증 + 저장 + 이벤트 발행 → 전부 필요해서 테스트 무거움 +- 순수 로직(도메인)과 부수효과(Service)가 분리 → 각각 적절한 수준으로 테스트 + +### 테스트 피라미드 적용 + +``` + / E2E \ ← 적고 느림 (Spring Context + DB) + / 통합 \ ← 적당 (Service + Repository) + / 단위 \ ← 많고 빠름 (순수 도메인 객체) +``` + +도메인에 규칙이 내재되어 있으면 → 피라미드 하단(단위 테스트)이 두꺼워짐 +Service에 규칙이 있으면 → 피라미드가 뒤집혀서 통합/E2E에 의존 + +### 현재 코드의 문제 + +```java +// 현재: Service에서 mock capture로 간접 검증 +verify(memberRepository).save(memberCaptor.capture()); +assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); +// → 구현 세부사항에 결합, Member 도메인 규칙 자체를 검증하지 않음 + +// 목표: 도메인 객체를 직접 테스트 +Member member = Member.register("testId", "Password1!", "홍길동", + LocalDate.of(1990, 1, 1), "test@test.com"); +assertThat(member.isSamePassword("Password1!")).isTrue(); +// → Mock 없음, DB 없음, Spring 없음. 규칙만 검증. +``` + +--- + +## 5. 의존 방향 정리 (DIP 적용) + +``` +┌─────────────────────────────────────────────┐ +│ apps/commerce-api (최상위 — 조합 + 설정) │ +│ - Controller, Service(Facade) │ +│ - 의존: domain, modules/jpa, supports/* │ +└───────────┬─────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌───────────────────┐ ┌─────────────────────┐ +│ modules/jpa │ │ supports/* │ +│ (인프라 어댑터) │ │ (횡단 관심사) │ +│ - Repository 구현 │ │ - Jackson, Logging │ +│ - DataSource 설정 │ │ - Monitoring │ +│ - 의존: domain │ │ - 의존: 없음 │ +└────────┬──────────┘ └─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ domain/ (최하위 — 순수 비즈니스 규칙) │ +│ - Entity, VO, Repository 인터페이스 │ +│ - 도메인 정책, 예외 │ +│ - 의존: 없음 (jakarta.persistence API만) │ +└─────────────────────────────────────────────┘ +``` + +**핵심 원칙:** 화살표는 항상 아래(domain)를 향한다. +domain은 상위 계층의 존재를 모른다. + +--- + +## 6. 리팩토링 진행 순서 + +``` +Phase 1: 구조 변경 + → domain 모듈 생성, 코드 이동, 의존 방향 설정, 패키지 통일 + +Phase 2: 모델링 및 설계 변경 + → BaseTimeEntity 분리, MemberPolicy 제거(규칙 내재화), 예외 체계 통일 + +Phase 3: 테스트 코드 수정 + → 도메인 단위 테스트 작성, 기존 Service 테스트 정리, 테스트 계층 명확화 +``` + +각 Phase 완료 후 `./gradlew test` 통과를 확인하며 점진적으로 진행. + +--- + +## 7. Phase 2 논의 사항 (2026-02-22 추가) + +Phase 2에서는 예외 체계 설계, VO 전략, DomainService 보류 등 중요한 설계 논의가 진행됨. +상세 기록: `docs/thought/phase2-discussion-log.md` + +주요 결정 사항: +- **ErrorType**: HttpStatus 제거 → pure enum, domain 레이어에 위치. 각 presentation이 자기 프로토콜에 맞게 해석. +- **VO 도입 기준**: "검증이 자주 변하거나 정책적으로 자주 변하는 속성" → VO. Password도 VO로 전환. +- **DomainService**: 현재 Member만으로는 불필요. 신규 도메인 간 로직 발생 시 도입. +- **실용주의 일관성**: JPA 허용(표준 스펙, 분리 비용 높음) vs HttpStatus 불허(Spring 고유, 분리 비용 낮음) — 같은 기준, 다른 결론. diff --git a/docs/thought/phase1-implementation-log.md b/docs/thought/phase1-implementation-log.md new file mode 100644 index 000000000..ab03f3fa3 --- /dev/null +++ b/docs/thought/phase1-implementation-log.md @@ -0,0 +1,331 @@ +# Phase 1: 구조 변경 - 구현 실시간 로그 + +> 작성일: 2026-02-21 +> 상태: 완료 +> 관련 문서: [설계서](../planning/phase1-structure-refactoring.md), [Step 7 트러블슈팅](./phase1-step7-troubleshooting.md) + +--- + +## Step 1: Gradle 설정 변경 + +### 작업 내용 + +**1-1. `settings.gradle.kts` 수정** + +모듈 include 목록을 기존 `apps/*` 기반에서 `domain/application/presentation` 기반으로 변경. + +```kotlin +// Before +include( + ":apps:commerce-api", + ":apps:commerce-batch", + ":apps:commerce-streamer", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", +) + +// After +include( + ":domain", + ":application:commerce-api", + ":presentation:commerce-api", + ":presentation:commerce-batch", + ":presentation:commerce-streamer", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", +) +``` + +**1-2. `build.gradle.kts` (root) 수정** + +- bootJar 활성화 필터: `"apps"` → `"presentation"` +- 컨테이너 프로젝트 비활성화: `project("apps")` → `project("application")` + `project("presentation")` + +**1-3. 신규 build.gradle.kts 파일 생성** + +| 파일 | 주요 설정 | +|------|----------| +| `domain/build.gradle.kts` | `java-library`, `java-test-fixtures`, `api("jakarta.persistence:jakarta.persistence-api")` | +| `application/commerce-api/build.gradle.kts` | `java-library`, `api(project(":domain"))` | +| `presentation/commerce-api/build.gradle.kts` | domain, application, modules, supports 의존 + web, actuator, springdoc | +| `presentation/commerce-batch/build.gradle.kts` | 기존 apps/commerce-batch와 동일 | +| `presentation/commerce-streamer/build.gradle.kts` | 기존 apps/commerce-streamer와 동일 | + +**1-4. `modules/jpa/build.gradle.kts` 수정** + +`api(project(":domain"))` 의존성 추가. + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 2: domain 모듈 생성 + 코드 이동 (modules/jpa → domain) + +### 작업 내용 + +**2-1. 디렉토리 구조 생성** + +``` +domain/src/main/java/com/loopers/domain/member/policy/ +domain/src/main/java/com/loopers/utils/ +domain/src/test/java/com/loopers/domain/member/ +``` + +**2-2. 파일 이동 (modules/jpa → domain)** + +| 파일 | 패키지 변경 | 비고 | +|------|-----------|------| +| `BaseEntity.java` | 없음 | `id` 필드: `private final Long id = 0L` → `private Long id` | +| `Member.java` | 없음 | | +| `MemberExceptionMessage.java` | 없음 | | +| `MemberPolicy.java` | 없음 | | +| `PasswordEncryptor.java` | 없음 | | + +**2-3. 신규 생성: MemberRepository Port 인터페이스** + +```java +package com.loopers.domain.member; + +public interface MemberRepository { + Member save(Member member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +**2-4. MemberTest 이동** + +- From: `modules/jpa/src/testFixtures/.../testcontainers/domain/member/MemberTest.java` +- To: `domain/src/test/java/com/loopers/domain/member/MemberTest.java` +- 패키지 변경: `com.loopers.testcontainers.domain.member` → `com.loopers.domain.member` + +**2-5. modules/jpa 원본 삭제** + +`modules/jpa/src/main/.../domain/`, `.../utils/` 디렉토리 및 파일 삭제. + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 3: MemberRepository Adapter 생성 (modules/jpa) + +### 작업 내용 + +`modules/jpa/src/main/java/com/loopers/infrastructure/member/` 디렉토리에 두 파일 생성. + +**3-1. MemberJpaRepository.java** (Spring Data JPA 인터페이스) + +```java +package com.loopers.infrastructure.member; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); +} +``` + +**3-2. MemberRepositoryImpl.java** (Port 구현체) + +```java +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { return memberJpaRepository.save(member); } + @Override + public Optional findByLoginId(String loginId) { return memberJpaRepository.findByLoginId(loginId); } + @Override + public boolean existsByLoginId(String loginId) { return memberJpaRepository.existsByLoginId(loginId); } +} +``` + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 4: application/commerce-api 생성 + 비즈니스 코드 이동 + +### 작업 내용 + +**4-1. 소스 코드 이동 (apps/commerce-api → application/commerce-api)** + +| 파일 | import 변경 | +|------|------------| +| `MemberService.java` | `infrastructure.member.MemberRepository` → `domain.member.MemberRepository` | +| `MemberRegisterRequest.java` | 없음 | +| `MyMemberInfoResponse.java` | 없음 | +| `PasswordUpdateRequest.java` | 없음 | +| `ExampleFacade.java` | 없음 | +| `ExampleInfo.java` | 없음 | +| `ExampleModel.java` | 없음 | +| `ExampleRepository.java` | 없음 | +| `ExampleService.java` | 없음 | +| `CoreException.java` | 없음 | +| `ErrorType.java` | 없음 | + +**4-2. 테스트 코드 이동 (단위 테스트만)** + +| 파일 | import 변경 | +|------|------------| +| `MemberServiceTest.java` | `infrastructure.member.MemberRepository` → `domain.member.MemberRepository` | +| `ExampleModelTest.java` | 없음 | +| `CoreExceptionTest.java` | 없음 | + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 5: presentation/commerce-api 생성 + 인터페이스/부트 코드 이동 + +### 작업 내용 + +**5-1. 소스 코드 이동 (apps/commerce-api → presentation/commerce-api)** + +| 파일 | 변경 사항 | +|------|----------| +| `CommerceApiApplication.java` | 없음 | +| `ApiResponse.java` | 없음 | +| `ApiControllerAdvice.java` | 없음 | +| `ExampleV1Controller.java` | 없음 | +| `ExampleV1ApiSpec.java` | 없음 | +| `ExampleV1Dto.java` | 없음 | +| `MemberController.java` | 패키지: `com.loopers.controller` → `com.loopers.interfaces.api.member` | +| `ExampleJpaRepository.java` | 없음 | +| `ExampleRepositoryImpl.java` | 없음 | +| `application.yml` | 없음 | + +**5-2. 테스트 코드 이동 (통합/E2E 테스트)** + +| 파일 | import 변경 | +|------|------------| +| `CommerceApiContextTest.java` | 없음 | +| `MemberServiceIntegrationTest.java` | `infrastructure.member.MemberRepository` → `domain.member.MemberRepository` | +| `MemberE2ETest.java` | 없음 | +| `ExampleServiceIntegrationTest.java` | 없음 | +| `ExampleV1ApiE2ETest.java` | 없음 | + +**5-3. 기존 apps/commerce-api infrastructure/member/ 삭제** + +apps에 남아 있던 `MemberRepository.java` (구 JPA 인터페이스) 삭제. + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 6: batch/streamer 이동 + Kafka 오타 수정 + +### 작업 내용 + +**6-1. 파일 복사** + +- `apps/commerce-batch/src/*` → `presentation/commerce-batch/src/*` (내용 변경 없음) +- `apps/commerce-streamer/src/*` → `presentation/commerce-streamer/src/*` (내용 변경 없음) + +**6-2. Kafka 패키지 오타 수정** + +- 디렉토리: `modules/kafka/.../confg/kafka/` → `config/kafka/` +- `KafkaConfig.java` 패키지: `com.loopers.confg.kafka` → `com.loopers.config.kafka` +- `DemoKafkaConsumer.java` import: `confg.kafka.KafkaConfig` → `config.kafka.KafkaConfig` + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 7: apps/ 디렉토리 삭제 + 빌드 검증 + +> 상세 트러블슈팅 문서: [phase1-step7-troubleshooting.md](./phase1-step7-troubleshooting.md) + +### 요약 + +`apps/` 삭제 후 첫 빌드에서 **Gradle Circular Dependency** 발생. +원인은 `:application:commerce-api`와 `:presentation:commerce-api`의 **프로젝트 이름 충돌** (`commerce-api`). + +8가지 접근법을 시도한 끝에 **디렉토리 이름 변경**(`commerce-api` → `commerce-service`)으로 해결. +추가로 컴파일 오류(`HttpStatus`, `@Transactional`) 및 테스트 오류(`ExampleModelTest`) 수정. + +### 최종 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** (53 tasks) | +| `./gradlew :domain:test` | **PASS** | +| `./gradlew :application:commerce-service:test` | **PASS** (10 tests) | +| `./gradlew :presentation:commerce-api:test` | 16 실패 (Docker/Testcontainers 미실행 — 코드 문제 아님) | +| `apps/` 디렉토리 | **삭제 완료** | + +--- + +## Phase 1 완료 상태 + +- [x] `./gradlew clean build -x test` 전체 통과 +- [x] `./gradlew :domain:test` — MemberTest 통과 +- [x] `./gradlew :application:commerce-service:test` — 단위 테스트 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 (코드 이상 없음) +- [ ] `./gradlew :presentation:commerce-api:bootRun` — Docker 환경 필요 +- [x] `apps/` 디렉토리 완전 제거 +- [x] `modules/jpa`에 비즈니스 로직 없음 (설정 + Adapter만) +- [x] library 모듈에 bootJar 태스크 없음 (Spring Boot 플러그인 미적용) + +--- + +## Step 8: 네이밍 개선 + Gradle 설정 정비 (Phase 1 후속) + +### 배경 + +Phase 1 트러블슈팅에서 발견된 두 가지 구조적 문제 해결. + +### 작업 내용 + +**8-1. 모듈 이름 변경** + +`application/commerce-api-core` → `application/commerce-service` + +- `-core`는 프레임워크 코어 모듈에 쓰이는 관례로 부적절 +- `commerce-service`가 application 레이어의 서비스 로직 역할을 명확히 표현 +- `settings.gradle.kts`, `presentation/commerce-api/build.gradle.kts` 참조 업데이트 + +**8-2. Spring Boot 플러그인 적용 범위 축소** + +``` +Before: 모든 서브프로젝트에 org.springframework.boot 적용 → bootJar 비활성화 +After: presentation 모듈에서만 org.springframework.boot 적용 + library 모듈은 spring-boot-dependencies BOM 명시 import로 버전 관리 +``` + +변경 파일: +- `build.gradle.kts` (root): `apply(plugin = "org.springframework.boot")` 제거, BOM import 추가 +- `presentation/commerce-api/build.gradle.kts`: `apply(plugin = "org.springframework.boot")` 추가 +- `presentation/commerce-batch/build.gradle.kts`: 동일 +- `presentation/commerce-streamer/build.gradle.kts`: 동일 + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** (47 tasks) | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | +| `:domain` bootJar 태스크 | **없음** | +| `:application:commerce-service` bootJar 태스크 | **없음** | +| `:presentation:commerce-api` bootJar 태스크 | **있음** | diff --git a/docs/thought/phase1-step7-troubleshooting.md b/docs/thought/phase1-step7-troubleshooting.md new file mode 100644 index 000000000..a75ab5cef --- /dev/null +++ b/docs/thought/phase1-step7-troubleshooting.md @@ -0,0 +1,660 @@ +# Phase 1 Step 7: 빌드 검증 트러블슈팅 상세 기록 + +> 작성일: 2026-02-21 +> 관련 문서: [구현 로그](./phase1-implementation-log.md) + +--- + +## 목차 + +1. [최초 오류 발생](#1-최초-오류-발생) +2. [시도 1: configure 블록 위치 변경](#2-시도-1-configure-블록-위치-변경) +3. [시도 2: tasks.withType → tasks.named 변경](#3-시도-2-taskswithtype--tasksnamed-변경) +4. [시도 3: 각 모듈에 직접 설정](#4-시도-3-각-모듈에-직접-설정) +5. [시도 4: jar 비활성화 제거](#5-시도-4-jar-비활성화-제거) +6. [진단: 다른 모듈 테스트](#6-진단-다른-모듈-테스트) +7. [시도 5: application:commerce-api 의존성 제거 테스트](#7-시도-5-applicationcommerce-api-의존성-제거-테스트) +8. [중간 수정: application 모듈 컴파일 오류 해결](#8-중간-수정-application-모듈-컴파일-오류-해결) +9. [시도 6: 디렉토리 이름 변경 (성공)](#9-시도-6-디렉토리-이름-변경-성공) +10. [시도 7: settings 레벨 이름 오버라이드 (실패)](#10-시도-7-settings-레벨-이름-오버라이드-실패) +11. [시도 8: Spring Boot 플러그인 조건부 적용 (실패)](#11-시도-8-spring-boot-플러그인-조건부-적용-실패) +12. [최종 해결: 디렉토리 이름 변경 확정](#12-최종-해결-디렉토리-이름-변경-확정) +13. [후속 오류: ExampleModelTest 실패](#13-후속-오류-examplemodeltest-실패) +14. [최종 검증](#14-최종-검증) +15. [근본 원인 분석](#15-근본-원인-분석) + +--- + +## 1. 최초 오류 발생 + +### 실행 명령 + +```bash +rm -rf apps/ +./gradlew clean build +``` + +### 오류 메시지 + +``` +FAILURE: Build failed with an exception. + +* What went wrong: +Circular dependency between the following tasks: +:presentation:commerce-api:classes +\--- :presentation:commerce-api:compileJava + \--- :presentation:commerce-api:jar + +--- :presentation:commerce-api:classes (*) + \--- :presentation:commerce-api:compileJava (*) +``` + +### 분석 + +`compileJava` → `jar` → `classes` → `compileJava` 로 순환하는 태스크 의존성. +정상적인 Gradle 빌드에서는 발생하지 않는 구조. Spring Boot 플러그인의 bootJar 태스크 와이어링과 관련된 문제로 추정. + +--- + +## 2. 시도 1: configure 블록 위치 변경 + +### 가설 + +`subprojects {}` 블록 안에 있는 `configure(allprojects.filter { ... })` 블록이 적용 순서 문제를 일으키고 있다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) + +// Before: subprojects 블록 안에 configure 존재 +subprojects { + // ... 공통 설정 ... + + configure(allprojects.filter { it.parent?.name.equals("presentation") }) { + tasks.withType(Jar::class) { enabled = false } + tasks.withType(BootJar::class) { enabled = true } + } +} + +// After: configure 블록을 subprojects 밖으로 분리 +subprojects { + // ... 공통 설정 ... +} + +configure(subprojects.filter { it.parent?.name == "presentation" }) { + tasks.withType(Jar::class) { enabled = false } + tasks.withType(BootJar::class) { enabled = true } +} +``` + +### 결과: 실패 + +``` +Circular dependency between the following tasks: +:presentation:commerce-api:classes +\--- :presentation:commerce-api:compileJava + \--- :presentation:commerce-api:jar + +--- :presentation:commerce-api:classes (*) + \--- :presentation:commerce-api:compileJava (*) +``` + +동일한 순환 의존성 오류. 블록 위치는 원인이 아님. + +--- + +## 3. 시도 2: tasks.withType → tasks.named 변경 + +### 가설 + +`tasks.withType(Jar::class)`는 `BootJar`도 포함한다 (BootJar extends Jar). +이로 인해 BootJar까지 비활성화되면서 태스크 해석 순서가 꼬인다. +`tasks.named`로 정확한 태스크만 지정하면 해결될 수 있다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) + +// Before +configure(subprojects.filter { it.parent?.name == "presentation" }) { + tasks.withType(Jar::class) { enabled = false } + tasks.withType(BootJar::class) { enabled = true } +} + +// After +configure(subprojects.filter { it.parent?.name == "presentation" }) { + tasks.named("jar") { enabled = false } + tasks.named("bootJar") { enabled = true } +} +``` + +### 결과: 실패 + +동일한 순환 의존성 오류. `withType` vs `named`는 원인이 아님. + +--- + +## 4. 시도 3: 각 모듈에 직접 설정 + +### 가설 + +root의 configure 블록이 presentation 모듈에 제대로 적용되지 않는다. +각 presentation 모듈의 `build.gradle.kts`에 직접 bootJar/jar 설정을 넣으면 해결된다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) — configure 블록 완전 제거 + +// presentation/commerce-api/build.gradle.kts +import org.springframework.boot.gradle.tasks.bundling.BootJar + +tasks.named("jar") { enabled = false } +tasks.named("bootJar") { enabled = true } + +dependencies { + // ... 기존 의존성 ... +} +``` + +`commerce-batch`, `commerce-streamer`에도 동일하게 적용. + +### 결과: 실패 + +동일한 순환 의존성 오류. 설정 위치(root vs 개별 모듈)는 원인이 아님. + +--- + +## 5. 시도 4: jar 비활성화 제거 + +### 가설 + +`jar` 태스크를 비활성화하는 것 자체가 순환을 만든다. +`bootJar`만 활성화하고 `jar` 비활성화는 제거하면 된다. + +### 변경 코드 + +```kotlin +// presentation/commerce-api/build.gradle.kts + +// Before +tasks.named("jar") { enabled = false } +tasks.named("bootJar") { enabled = true } + +// After — jar 비활성화 라인 제거 +tasks.named("bootJar") { enabled = true } +``` + +### 결과: 실패 + +```bash +./gradlew :presentation:commerce-api:compileJava +``` + +동일한 순환 의존성 오류. `compileJava`에서조차 발생하므로 jar/bootJar 설정과 무관한 근본적 문제. + +--- + +## 6. 진단: 다른 모듈 테스트 + +### 실행 명령 + +```bash +./gradlew :presentation:commerce-batch:compileJava +``` + +### 결과: BUILD SUCCESSFUL + +`commerce-batch`는 정상 컴파일. 문제는 **`:presentation:commerce-api`에만 국한**. + +### 핵심 차이점 + +| 모듈 | `application:commerce-api` 의존 | 결과 | +|------|-------------------------------|------| +| `presentation:commerce-api` | **있음** | 순환 의존성 | +| `presentation:commerce-batch` | 없음 | 정상 | +| `presentation:commerce-streamer` | 없음 | 정상 | + +→ `:application:commerce-api` 의존성이 순환의 트리거. + +--- + +## 7. 시도 5: application:commerce-api 의존성 제거 테스트 + +### 가설 + +`:application:commerce-api`와 `:presentation:commerce-api`가 동일한 Gradle 프로젝트 이름 `commerce-api`를 공유하여 충돌한다. + +### 변경 코드 + +```kotlin +// presentation/commerce-api/build.gradle.kts + +dependencies { + implementation(project(":domain")) + // implementation(project(":application:commerce-api")) // ← 주석 처리 + implementation(project(":modules:jpa")) + // ... 나머지 유지 ... +} +``` + +### 결과: 순환 의존성 해소 (컴파일 오류 발생) + +```bash +./gradlew :presentation:commerce-api:compileJava +``` + +``` +ExampleJpaRepository.java:3: error: package com.loopers.domain.example does not exist +ExampleRepositoryImpl.java:3: error: package com.loopers.domain.example does not exist +``` + +순환 의존성은 사라짐. 컴파일 오류는 application 모듈의 코드를 참조할 수 없어서 발생. + +**확정: 프로젝트 이름 충돌이 근본 원인.** + +의존성을 다시 복원. + +--- + +## 8. 중간 수정: application 모듈 컴파일 오류 해결 + +순환 의존성과 별도로, `application:commerce-api` 자체의 컴파일도 확인. + +### 실행 명령 + +```bash +./gradlew :application:commerce-api:compileJava +``` + +### 오류 메시지 + +``` +ErrorType.java:5: error: package org.springframework.http does not exist +import org.springframework.http.HttpStatus; + +ErrorType.java:16: error: cannot find symbol + private final HttpStatus status; + +ExampleService.java:7: error: package org.springframework.transaction.annotation does not exist +import org.springframework.transaction.annotation.Transactional; + +MemberService.java:11: error: package org.springframework.transaction.annotation does not exist +import org.springframework.transaction.annotation.Transactional; +``` + +### 원인 + +`application/commerce-api/build.gradle.kts`에 `api(project(":domain"))`만 선언. +`ErrorType`이 사용하는 `HttpStatus`는 `spring-web`에, `@Transactional`은 `spring-tx`에 존재. +application 레이어는 `spring-boot-starter-web`을 의존하지 않으므로 개별 의존성 필요. + +### 해결 코드 + +```kotlin +// application/commerce-api/build.gradle.kts + +// Before +plugins { + `java-library` +} +dependencies { + api(project(":domain")) +} + +// After +plugins { + `java-library` +} +dependencies { + api(project(":domain")) + implementation("org.springframework:spring-web") + implementation("org.springframework:spring-tx") +} +``` + +### 결과: BUILD SUCCESSFUL + +```bash +./gradlew :application:commerce-api:compileJava +# BUILD SUCCESSFUL in 1s +``` + +--- + +## 9. 시도 6: 디렉토리 이름 변경 (성공) + +### 가설 + +Gradle은 디렉토리 이름에서 프로젝트 이름을 유도한다. +`application/commerce-api`(이름: `commerce-api`)와 `presentation/commerce-api`(이름: `commerce-api`)가 충돌. +디렉토리를 `commerce-api-core`로 변경하면 프로젝트 이름이 유일해진다. + +### 변경 내용 + +```bash +mv application/commerce-api application/commerce-api-core +``` + +```kotlin +// settings.gradle.kts +// Before: ":application:commerce-api" +// After: ":application:commerce-api-core" + +// presentation/commerce-api/build.gradle.kts +// Before: implementation(project(":application:commerce-api")) +// After: implementation(project(":application:commerce-api-core")) +``` + +### 결과: BUILD SUCCESSFUL + +```bash +./gradlew :presentation:commerce-api:compileJava +# BUILD SUCCESSFUL in 1s — 10 actionable tasks: 2 executed, 8 up-to-date +``` + +**순환 의존성 완전 해소.** + +> 그러나 이 시점에서 "더 깔끔한 방법"을 찾기 위해 이 변경을 되돌리고 다른 접근을 시도. + +```bash +mv application/commerce-api-core application/commerce-api # 되돌림 +``` + +--- + +## 10. 시도 7: settings 레벨 이름 오버라이드 (실패) + +### 가설 + +디렉토리를 바꾸지 않고 `settings.gradle.kts`에서 프로젝트 이름만 오버라이드할 수 있다. + +### 변경 코드 + +```kotlin +// settings.gradle.kts + +include( + ":domain", + ":application:commerce-api", + // ... +) + +// 프로젝트 이름 오버라이드 +project(":application:commerce-api").name = "commerce-api-core" +``` + +### 결과: 실패 + +``` +* What went wrong: +Project with path ':application:commerce-api' could not be found +in project ':presentation:commerce-api'. +``` + +### 원인 + +`project(":application:commerce-api")`로 이름을 변경하면 Gradle 내부 경로 자체가 바뀐다. +`build.gradle.kts`의 `project(":application:commerce-api")` 참조가 더 이상 유효하지 않음. +참조를 `project(":application:commerce-api-core")`로 바꿔야 하는데, 그러면 디렉토리 이름 변경과 동일한 효과. + +--- + +## 11. 시도 8: Spring Boot 플러그인 조건부 적용 (실패) + +### 가설 + +Spring Boot 플러그인이 bootJar 태스크를 와이어링할 때 같은 이름의 프로젝트를 혼동한다. +library 모듈(domain, application)에서 Spring Boot 플러그인을 제거하면 해결된다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) + +subprojects { + apply(plugin = "java") + apply(plugin = "io.spring.dependency-management") + apply(plugin = "jacoco") + + // domain과 application 모듈에는 Spring Boot 플러그인 미적용 + if (project.path != ":domain" && project.parent?.name != "application") { + apply(plugin = "org.springframework.boot") + } + // ... +} +``` + +### 결과: 실패 — 순환 의존성 동일 + +``` +Circular dependency between the following tasks: +:presentation:commerce-api:classes +\--- :presentation:commerce-api:compileJava + \--- :presentation:commerce-api:jar + +--- :presentation:commerce-api:classes (*) + \--- :presentation:commerce-api:compileJava (*) +``` + +Spring Boot 플러그인 적용 여부와 무관하게 Gradle의 프로젝트 이름 해석 레벨에서 충돌 발생. + +### 부수 효과 + +이 상태에서 디렉토리 이름 변경(시도 6)을 다시 적용해도, +Spring Boot 플러그인이 domain/application에 없어서 **BOM 버전 해석 실패**: + +``` +Execution failed for task ':domain:compileJava'. +> Could not resolve all files for configuration ':domain:compileClasspath'. + > Could not find jakarta.persistence:jakarta.persistence-api:. + Required by: project :domain + +> Could not find org.springframework.boot:spring-boot-starter:. + Required by: project :domain + +> Could not find com.fasterxml.jackson.datatype:jackson-datatype-jsr310:. + Required by: project :domain + +> Could not find org.projectlombok:lombok:. + Required by: project :domain +``` + +`io.spring.dependency-management`만으로는 Spring Boot BOM이 자동 적용되지 않음. +`org.springframework.boot` 플러그인이 있어야 BOM을 통한 버전 관리가 동작. + +--- + +## 12. 최종 해결: 디렉토리 이름 변경 확정 + +### 최종 변경 사항 + +**1단계: Spring Boot 플러그인 전체 복원** + +```kotlin +// build.gradle.kts (root) + +subprojects { + apply(plugin = "java") + apply(plugin = "org.springframework.boot") // 전체 서브프로젝트에 적용 + apply(plugin = "io.spring.dependency-management") + apply(plugin = "jacoco") + // ... + + // 기본: jar 활성화, bootJar 비활성화 (library 모듈용) + tasks.withType(Jar::class) { enabled = true } + tasks.withType(BootJar::class) { enabled = false } +} +``` + +**2단계: 디렉토리 이름 변경** + +```bash +mv application/commerce-api application/commerce-api-core +``` + +**3단계: Gradle 참조 업데이트** + +```kotlin +// settings.gradle.kts +include( + ":domain", + ":application:commerce-api-core", // ← 변경 + ":presentation:commerce-api", + // ... +) + +// presentation/commerce-api/build.gradle.kts +dependencies { + implementation(project(":application:commerce-api-core")) // ← 변경 + // ... +} +``` + +**4단계: 각 presentation 모듈에서 bootJar 활성화** + +```kotlin +// presentation/commerce-api/build.gradle.kts +import org.springframework.boot.gradle.tasks.bundling.BootJar +tasks.named("bootJar") { enabled = true } + +// presentation/commerce-batch/build.gradle.kts (동일) +// presentation/commerce-streamer/build.gradle.kts (동일) +``` + +### 결과: BUILD SUCCESSFUL + +```bash +./gradlew clean build -x test +# BUILD SUCCESSFUL in 3s (53 actionable tasks: 51 executed, 2 up-to-date) +``` + +--- + +## 13. 후속 오류: ExampleModelTest 실패 + +### 실행 명령 + +```bash +./gradlew :application:commerce-api-core:test +``` + +### 오류 메시지 + +``` +ExampleModelTest > Create > 제목과 설명이 모두 주어지면, 정상적으로 생성된다. FAILED + org.opentest4j.MultipleFailuresError at ExampleModelTest.java:28 + Caused by: java.lang.AssertionError at ExampleModelTest.java:29 + +10 tests completed, 1 failed +``` + +### 원인 + +Step 2에서 `BaseEntity.id`를 `private final Long id = 0L` → `private Long id`로 변경. +영속화 전 `id`가 `0L`이 아닌 `null`이 되었음. +테스트에 `assertThat(exampleModel.getId()).isNotNull()` assertion이 있어 실패. + +### 해결 코드 + +```java +// application/commerce-api-core/src/test/.../ExampleModelTest.java + +// Before +assertAll( + () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) +); + +// After — getId() assertion 제거 +assertAll( + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) +); +``` + +### 결과: 테스트 통과 + +```bash +./gradlew :domain:test :application:commerce-api-core:test +# BUILD SUCCESSFUL — 전체 단위 테스트 통과 +``` + +--- + +## 14. 최종 검증 + +| 명령 | 결과 | +|------|------| +| `./gradlew clean build -x test` | BUILD SUCCESSFUL (53 tasks) | +| `./gradlew :domain:test` | PASS | +| `./gradlew :application:commerce-api-core:test` | PASS (10 tests) | +| `./gradlew :presentation:commerce-api:test` | 16 FAIL — Docker/Testcontainers 미실행 (코드 문제 아님) | +| `ls apps/` | `No such file or directory` (삭제 완료) | + +--- + +## 15. 근본 원인 분석 + +### 원인 + +Gradle은 프로젝트 이름을 **디렉토리 이름**에서 유도한다. + +``` +:application:commerce-api → 프로젝트 이름: "commerce-api" +:presentation:commerce-api → 프로젝트 이름: "commerce-api" +``` + +동일한 이름의 프로젝트가 2개 존재하면, Spring Boot 플러그인이 bootJar 태스크 의존성 그래프를 +와이어링할 때 **다른 프로젝트의 태스크를 자기 프로젝트의 태스크로 혼동**하여 순환 발생: + +``` +:presentation:commerce-api:compileJava + → :presentation:commerce-api:jar (여기서 :application:commerce-api:jar과 혼동) + → :presentation:commerce-api:classes + → :presentation:commerce-api:compileJava ← 순환! +``` + +### 시도한 접근법 총 정리 + +| # | 접근법 | 변경 위치 | 결과 | 실패 이유 | +|---|--------|----------|------|----------| +| 1 | configure 블록 위치 이동 | root build.gradle.kts | 실패 | 이름 충돌은 블록 위치와 무관 | +| 2 | withType → named | root build.gradle.kts | 실패 | 태스크 선택 방식과 무관 | +| 3 | 각 모듈에 직접 설정 | presentation/*/build.gradle.kts | 실패 | 설정 위치와 무관 | +| 4 | jar 비활성화 제거 | presentation/commerce-api/build.gradle.kts | 실패 | jar/bootJar 설정과 무관 | +| 5 | application 의존성 제거 | presentation/commerce-api/build.gradle.kts | **순환 해소** | 이름 충돌 트리거 제거 확인 | +| 6 | **디렉토리 이름 변경** | 파일시스템 + settings + build | **성공** | 프로젝트 이름 유일화 | +| 7 | settings 이름 오버라이드 | settings.gradle.kts | 실패 | 경로 참조 깨짐 | +| 8 | Spring Boot 플러그인 조건부 적용 | root build.gradle.kts | 실패 | BOM 버전 해석 실패 | + +### 교훈 + +1. **Gradle 멀티모듈에서 서로 다른 부모 아래에 같은 이름의 서브모듈을 두면 안 된다.** +2. `settings.gradle.kts`의 `project(...).name` 오버라이드는 경로 기반 참조를 깨뜨린다. +3. `io.spring.dependency-management`만으로는 Spring Boot BOM 버전이 해석되지 않는다. 단, `spring-boot-dependencies` BOM을 명시적으로 import하면 Spring Boot 플러그인 없이도 버전 관리가 가능하다. +4. 순환 의존성 오류 메시지는 실제 원인(이름 충돌)을 직접 알려주지 않는다. 다른 모듈과의 비교 테스트가 핵심 진단법이었다. + +--- + +## 16. 후속 조치: 네이밍 개선 + Spring Boot 플러그인 정비 + +> 이 문서의 트러블슈팅 결과를 바탕으로 Phase 1 후속 작업으로 진행. + +### 16-1. `commerce-api-core` → `commerce-service` 이름 변경 + +`-core`는 프레임워크 코어 모듈 관례. `commerce-service`가 application 레이어 역할을 더 명확히 표현. + +### 16-2. Spring Boot 플러그인 적용 범위 축소 + +교훈 3번의 발견(BOM 명시 import)을 활용하여 근본 구조 개선: + +``` +Before: 모든 서브프로젝트에 org.springframework.boot 적용 → bootJar 기본 비활성화 +After: presentation 모듈에서만 적용 + library 모듈은 BOM 명시 import +``` + +이로써: +- library 모듈에 불필요한 bootJar 태스크가 생기지 않음 +- 프로젝트 이름 충돌 시에도 순환 의존성 위험이 원천 차단됨 +- 각 모듈의 역할이 Gradle 설정에서도 명확히 드러남 diff --git a/docs/thought/phase2-discussion-log.md b/docs/thought/phase2-discussion-log.md new file mode 100644 index 000000000..f68d3a967 --- /dev/null +++ b/docs/thought/phase2-discussion-log.md @@ -0,0 +1,228 @@ +# Phase 2: 모델링 및 설계 변경 - 논의 기록 + +> 작성일: 2026-02-22 +> 참여: 개발자, AI (Claude Code) +> 맥락: Phase 1(구조 변경) 완료 후, 도메인 모델링 및 예외 체계 설계 논의 + +--- + +## 1. 예외 체계 설계 + +### 1-1. 문제 정의 + +Phase 1 완료 후 남은 핵심 문제: +- 도메인 레이어에서 `IllegalArgumentException` 사용 → `ApiControllerAdvice`에서 전부 401로 반환 +- `ErrorType`이 `HttpStatus`를 직접 보유 → application 레이어가 `spring-web`에 의존 +- presentation이 3개(api, batch, streamer)인데, 각각의 에러 모델이 다름 + +### 1-2. HttpStatus를 application에 두는 것의 적절성 + +**개발자 질문:** "application layer는 HTTP에 의존해선 안 되는 걸까?" + +**분석:** +- application이 HTTP를 아는 것 자체는 실용적으로 문제없다는 의견 +- 다만 이 프로젝트는 presentation이 3개(HTTP API, Batch, Kafka Streamer) +- HTTP status는 HTTP 전용 개념이므로, application에 두면 Batch/Kafka에서 불필요한 의존 발생 + +**결론:** HTTP status를 application에 두는 것이 "잘못된" 것은 아니지만, 이 프로젝트에서는 분리가 더 적합. + +### 1-3. DomainException 설계 방향 — 4가지 선택지 검토 + +| 방식 | 설명 | 장점 | 단점 | +|------|------|------|------| +| A. DomainException에 HTTP/Kafka 로직 직접 보유 | 예외가 모든 프로토콜의 매핑 정보를 가짐 | 간편, 한곳에서 파악 | 프로토콜 추가 시 domain 변경 필요. domain이 spring-web에 의존 | +| B. DomainException 추상화 + 위에서 매핑 | 순수 분류만 하고 presentation이 해석 | 깨끗한 분리 | 매핑 코드 중복, 수정 포인트 분산 | +| C. ErrorCode + HttpStatus (가장 보편적) | ErrorCode enum에 HttpStatus 포함 | 대부분의 실무 프로젝트가 사용 | API만 있을 때 최적, 다중 presentation에서 부적합 | +| D. ErrorCode + ErrorGroup | HttpStatus 없이 의미 분류만 | 순수성과 실용성 균형 | 간접 레이어 하나 추가 | + +### 1-4. sealed class 제안과 검토 + +**개발자 제안:** sealed interface/class를 사용하면 어떨까? + +**분석:** +- Java 21 pattern matching과 조합하면 컴파일 타임에 exhaustive check 가능 +- 새 예외 타입 추가 시 모든 presentation의 switch 문에서 **컴파일 에러** 발생 → 강제로 핸들링 전략 정의 +- 개발자의 `@Builder` 거부 이유(컴파일 타임 안전성)와 동일한 맥락 + +```java +// 가능한 구조 +public sealed class DomainException extends RuntimeException + permits DomainException.BadRequest, DomainException.NotFound, ... + +// presentation에서 +switch (e) { + case DomainException.BadRequest b -> HttpStatus.BAD_REQUEST; + case DomainException.NotFound n -> HttpStatus.NOT_FOUND; + // 새 타입 추가 시 → 컴파일 에러 +} +``` + +### 1-5. Kafka 에러 모델 학습 + +**개발자 질문:** "Kafka는 어떤 식의 오류 상태가 존재해?" + +HTTP와 Kafka의 에러 모델은 근본적으로 다름: + +| 상황 | HTTP | Kafka | +|------|------|-------| +| 정상 처리 | 200 OK | ACK (offset commit) | +| 일시적 오류 | 503 → 재시도 | Retry + Backoff | +| 비즈니스 검증 실패 | 400 | DLQ (재시도 무의미) | +| 인증/권한 오류 | 401/403 | DLQ + Alert | +| 역직렬화 실패 | 422 | Skip or DLQ | +| 재시도 소진 | - | DLQ | + +**핵심 차이:** HTTP는 "요청자에게 응답 코드를 돌려주는" 모델, Kafka는 "내가 처리할 수 있느냐/없느냐"만 판단. + +이 분석이 최종 설계 결정에 결정적 영향을 미침 → "domain에 HttpStatus를 넣지 않아야 한다"는 확신. + +### 1-6. 최종 결정: ErrorType pure enum + +**개발자의 최종 판단:** + +> "enum 으로만 쓰고 이걸 해석하는 건 자유로 남겨두게 어때?" + +HTTP status 코드의 분류(400, 401, 404, 409, 500)는 사실 비즈니스 의미에서 파생된 것: +- 400 = 규칙 위반, 401 = 인증 실패, 404 = 존재하지 않음, 409 = 중복/충돌, 500 = 시스템 오류 + +이 의미론적 분류를 ErrorType enum으로 표현하고, 각 presentation이 자기 프로토콜에 맞게 해석: + +```java +// domain 레이어 +public enum ErrorType { + BAD_REQUEST, NOT_FOUND, CONFLICT, UNAUTHORIZED, INTERNAL_ERROR +} + +// presentation/commerce-api +ErrorType.BAD_REQUEST → HttpStatus.BAD_REQUEST +ErrorType.UNAUTHORIZED → HttpStatus.UNAUTHORIZED + +// presentation/commerce-streamer +ErrorType.BAD_REQUEST → DLQ (재시도 무의미) +ErrorType.NOT_FOUND → Retry → DLQ +ErrorType.CONFLICT → ACK (멱등) +``` + +sealed class 대신 enum을 선택한 이유: +- 현재 에러 분류가 5개 수준으로 충분 +- enum이 더 간결하고 기존 구조와 변경량 최소 +- sealed class는 에러 타입별로 다른 데이터를 가져야 할 때(ex: Retry 횟수) 도입 검토 + +--- + +## 2. VO 전환 전략 + +### 2-1. JPA 허용 결정과의 연장선 + +Phase 1에서 "domain에 JPA 어노테이션 허용"을 결정한 바 있음. +`@Embeddable` VO도 같은 맥락으로 자연스럽게 사용 가능. + +### 2-2. VO 대상 선정 + +**개발자 판단:** "결국에는 검증이 자주 변하거나, 정책적으로 자주 변하는 속성이 존재하면 바뀔 거 같은데" + +이 기준으로 Password도 VO에 포함: + +| 필드 | VO 여부 | 근거 | +|------|---------|------| +| loginId | `LoginId` VO | 형식/길이 규칙, 정책 변경 가능 | +| password | `Password` VO | 길이/형식/생년월일 규칙 + 암호화 + 비교. **가장 정책 변경이 잦은 필드** | +| name | `MemberName` VO | 형식/길이 규칙 | +| email | `Email` VO | RFC 형식/길이 규칙 | +| birthDate | `LocalDate` 유지 | "미래 불가"만 검증. LocalDate 자체가 value type | + +### 2-3. Password VO의 특수성 + +Password는 다른 VO와 다른 점: +- **저장 값이 raw와 다름**: raw → 암호화 → 저장 +- **생성 시 외부 컨텍스트 필요**: `birthDate`가 검증에 사용됨 +- **비교 로직 소유**: `matches(rawPassword)` + +이 모든 것을 Password VO가 소유하게 함으로써: +- Member에서 `PasswordEncryptor` 직접 호출 제거 +- `isSamePassword()` 위임 메서드가 `password.matches()` 호출로 변경 +- 비밀번호 정책 변경 시 **Password VO 하나만 수정** + +--- + +## 3. DomainService 보류 결정 + +### 3-1. 분석 + +현재 MemberService의 메서드별 책임: + +| 메서드 | 로직 | 분류 | +|--------|------|------| +| `register()` | 중복 체크 → 도메인 생성 → 저장 | Application (유스케이스 조합) | +| `getMyInfo()` | 조회 → 인증 → DTO 변환 | Application | +| `updatePassword()` | 조회 → 인증 → 도메인 위임 | Application | + +검증은 VO가, 비밀번호 변경은 엔티티가, 유스케이스 조합은 ApplicationService가 담당. +**DomainService가 담당할 로직이 현재 없음.** + +### 3-2. 결정 + +개발자 동의 하에 보류. DomainService는 다음 상황에서 도입: +- 여러 도메인 간 규칙이 필요할 때 (ex: "재고가 충분한지 확인 후 주문 생성") +- 하나의 엔티티에 담기 어려운 도메인 로직이 생길 때 + +빈 껍데기를 미리 만들지 않는다는 원칙 (CLAUDE.md: "오버엔지니어링 금지"). + +--- + +## 4. 마스킹 로직의 위치 + +### 4-1. 문제 + +`MemberService.maskName()`이 Service에 위치 — 표현 관심사가 비즈니스 레이어에 혼재. + +### 4-2. 논의 없이 합의 + +이전 Phase 2 계획에서 이미 "마스킹은 Presentation으로 이동"으로 합의되어 있었음. + +### 4-3. 구현 방식 선택 + +마스킹 로직을 어디에 둘지: +- Controller 내부 메서드 → Controller가 비대해짐 +- 별도 Masking 유틸 → 과도한 추상화 +- **Response DTO의 `withMaskedName()` 메서드** → DTO가 자기 표현을 소유 + +`withMaskedName()`으로 결정. record의 불변성을 유지하면서 마스킹된 새 인스턴스를 반환. + +--- + +## 5. 설계 원칙 정리 + +Phase 2 논의를 통해 확인/정립된 설계 원칙: + +### 5-1. 실용주의 기준의 일관성 + +| 판단 대상 | 결정 | 근거 | +|-----------|------|------| +| JPA `@Entity` in domain | 허용 | 표준 스펙, 분리 비용 높음 | +| `HttpStatus` in domain | 불허 | Spring 고유, 분리 비용 낮음 (switch 하나) | +| `@Embeddable` VO | 허용 | JPA 허용의 연장선 | + +같은 "실용주의" 기준이지만 대상의 특성에 따라 결론이 다름. + +### 5-2. VO 도입 기준 + +> "검증이 자주 변하거나, 정책적으로 자주 변하는 속성이 존재하면" + +- 자체 규칙(invariant)이 있는 필드 → VO +- 단순 저장만 하는 필드 → primitive/표준 타입 유지 +- 불변 보장 + 검증 내재화가 핵심 가치 + +### 5-3. 예외 설계 원칙 + +> "enum으로만 쓰고 해석하는 건 자유로 남겨두게" + +- domain은 **"무슨 종류의 실패인가"**만 표현 +- **"어떻게 응답할 것인가"**는 presentation이 결정 +- 프로토콜(HTTP, Kafka, Batch)마다 같은 ErrorType을 다르게 해석 + +### 5-4. 오버엔지니어링 방지 + +- 현재 필요하지 않은 DomainService는 만들지 않음 +- sealed class는 현재 enum으로 충분하므로 도입하지 않음 +- "신규 도메인 추가 시" 같은 미래 시점에 재검토 diff --git a/docs/thought/phase2-implementation-log.md b/docs/thought/phase2-implementation-log.md new file mode 100644 index 000000000..6831331c1 --- /dev/null +++ b/docs/thought/phase2-implementation-log.md @@ -0,0 +1,415 @@ +# Phase 2: 모델링 및 설계 변경 - 구현 실시간 로그 + +> 작성일: 2026-02-22 +> 상태: 완료 +> 관련 문서: [설계 논의](./phase2-discussion-log.md), [리팩토링 계획서](../planning/refactoring-plan.md) + +--- + +## Step 1: ErrorType(pure enum) + CoreException → domain 이동 + +### 배경 + +기존 `ErrorType`은 `HttpStatus`를 직접 보유하고 있어 application 레이어가 `spring-web`에 의존. +Phase 2 논의에서 ErrorType을 순수 enum으로 변경하고, 각 presentation 레이어가 자기 프로토콜에 맞게 해석하기로 결정. + +### 작업 내용 + +**1-1. ErrorType 변경 (HttpStatus 제거, UNAUTHORIZED 추가)** + +```java +// Before (application 레이어) +@Getter +@RequiredArgsConstructor +public enum ErrorType { + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ...), + BAD_REQUEST(HttpStatus.BAD_REQUEST, ...), + NOT_FOUND(HttpStatus.NOT_FOUND, ...), + CONFLICT(HttpStatus.CONFLICT, ...); + private final HttpStatus status; + private final String code; + private final String message; +} + +// After (domain 레이어) +@Getter +@RequiredArgsConstructor +public enum ErrorType { + INTERNAL_ERROR("Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST("Bad Request", "잘못된 요청입니다."), + NOT_FOUND("Not Found", "존재하지 않는 요청입니다."), + CONFLICT("Conflict", "이미 존재하는 리소스입니다."), + UNAUTHORIZED("Unauthorized", "인증에 실패했습니다."); + private final String code; + private final String message; +} +``` + +**1-2. CoreException → domain 이동** + +`application/commerce-service/.../support/error/` → `domain/.../support/error/` + +내용 변경 없음. 패키지 동일(`com.loopers.support.error`). + +**1-3. ApiControllerAdvice 재설계** + +- `IllegalArgumentException` 핸들러 **삭제** (모든 도메인/서비스 예외가 CoreException으로 통일됨) +- `toHttpStatus()` switch 매핑 메서드 추가 + +```java +private HttpStatus toHttpStatus(ErrorType errorType) { + return switch (errorType) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; +} +``` + +**1-4. application/commerce-service build.gradle.kts 변경** + +```kotlin +// Before +implementation("org.springframework:spring-web") // ErrorType의 HttpStatus 때문에 필요했음 +implementation("org.springframework:spring-tx") + +// After +implementation("org.springframework:spring-tx") +implementation("org.springframework:spring-context") // @Service 어노테이션 +``` + +**1-5. CoreExceptionTest → domain 이동** + +`application/commerce-service/src/test/` → `domain/src/test/` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** (47 tasks) | +| `./gradlew :domain:test` | **PASS** | +| `./gradlew :application:commerce-service:test` | **PASS** | + +--- + +## Step 2: BaseTimeEntity 신설 + +### 배경 + +기존 `BaseEntity`가 id + createdAt + updatedAt + deletedAt 을 모두 가지고 있어, +soft-delete가 불필요한 엔티티에도 deletedAt 컬럼이 강제됨. + +### 작업 내용 + +**2-1. BaseTimeEntity 신설** + +```java +@MappedSuperclass +@Getter +public abstract class BaseTimeEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + + protected void guard() {} + + @PrePersist → createdAt, updatedAt 설정 + @PreUpdate → updatedAt 갱신 +} +``` + +**2-2. BaseEntity 변경** + +```java +// Before: 독립 클래스 (id, createdAt, updatedAt, deletedAt 모두 보유) +// After: BaseTimeEntity 상속, deletedAt + delete()/restore()만 추가 +@MappedSuperclass +@Getter +public abstract class BaseEntity extends BaseTimeEntity { + private ZonedDateTime deletedAt; + public void delete() { ... } + public void restore() { ... } +} +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | + +--- + +## Step 3: MemberPolicy → VO 전환 + @Builder 제거 + +### 배경 + +MemberPolicy가 중앙 집중 방식으로 모든 검증 규칙을 보유하고 있어 응집도가 낮음. +검증 규칙을 각 VO에 내재화하여 "해당 VO만 보면 규칙을 알 수 있는" 구조로 전환. + +### 작업 내용 + +**3-1. VO 4개 생성** + +| VO | 패키지 | 검증 규칙 | +|----|--------|----------| +| `LoginId` | `domain/.../member/vo/` | 6~20자, 영문+숫자, 영문 필수, 숫자만 불가 | +| `Password` | `domain/.../member/vo/` | 8~16자, 허용 문자, 생년월일 미포함, 암호화 | +| `MemberName` | `domain/.../member/vo/` | 2~40자, 한글/영문만 | +| `Email` | `domain/.../member/vo/` | RFC 5321, 255자 이하 | + +각 VO 공통 구조: + +```java +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + @Column(name = "login_id") + private String value; + + private LoginId(String value) { this.value = value; } + + public static LoginId of(String value) { + validate(value); + return new LoginId(value); + } + + private static void validate(String value) { + // CoreException(ErrorType.BAD_REQUEST, ...) 사용 + } + + // equals(), hashCode() 구현 +} +``` + +**Password VO 특이사항:** + +```java +public class Password { + private String value; // 암호화된 값 저장 + + public static Password of(String rawPassword, LocalDate birthDate) { + validateFormat(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + return new Password(PasswordEncryptor.encode(rawPassword)); + } + + public boolean matches(String rawPassword) { ... } + + public void validateChangeable(String newRawPassword, LocalDate birthDate) { + // 동일 비밀번호 체크 + 형식 검증 + 생년월일 검증 + } +} +``` + +Password VO가 검증 + 암호화 + 비교를 모두 소유. Member에서 `PasswordEncryptor` 직접 호출이 사라짐. + +**3-2. Member 엔티티 리팩토링** + +```java +// Before +@Entity @Getter @NoArgsConstructor @AllArgsConstructor @Builder +public class Member { + @Id @GeneratedValue private Long id; + private String loginId; + private String password; + ... + public static Member register(...) { + MemberPolicy.LoginId.validate(loginId); + ... + return Member.builder().loginId(loginId).password(encodedPassword(password))...build(); + } +} + +// After +@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + @Embedded private LoginId loginId; + @Embedded private Password password; + @Embedded private MemberName name; + private LocalDate birthDate; + @Embedded private Email email; + + private Member(LoginId loginId, Password password, MemberName name, LocalDate birthDate, Email email) { + validateBirthDate(birthDate); + this.loginId = loginId; + ... + } + + public static Member register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + return new Member(LoginId.of(loginId), Password.of(rawPassword, birthDate), MemberName.of(name), birthDate, Email.of(email)); + } + + // getValue() 위임 메서드: getLoginIdValue(), getNameValue(), getEmailValue() +} +``` + +변경 포인트: +- `@Builder`, `@AllArgsConstructor` 삭제 → private 생성자 + `register()` 정적 팩토리 +- `@Embedded` VO 사용 +- `BaseTimeEntity` 상속 (id, createdAt, updatedAt 자동 관리) +- `isSamePassword()` → `password.matches()` 위임 +- `updatePassword()` → `password.validateChangeable()` + 새 Password 생성 + +**3-3. MemberPolicy 삭제** + +검증 로직이 VO로 분산되었으므로 `MemberPolicy.java` 및 `policy/` 패키지 삭제. + +**3-4. JPA 쿼리 메서드 변경** + +`@Embedded` 사용 시 Spring Data JPA 쿼리 메서드가 변경됨: + +```java +// Before +boolean existsByLoginId(String loginId); +Optional findByLoginId(String loginId); + +// After +boolean existsByLoginId_Value(String loginId); +Optional findByLoginId_Value(String loginId); +``` + +`MemberRepositoryImpl`에서 변환하므로 Port 인터페이스(`MemberRepository`)는 변경 없음. + +**3-5. MemberFixture 생성 (testFixtures)** + +`@Builder` 제거로 테스트에서 Member 생성이 `register()` 경유 필수. +`domain/src/testFixtures/`에 `MemberFixture` 생성: + +```java +public class MemberFixture { + public static Member create() { ... } + public static Member create(String loginId) { ... } + public static Member create(String loginId, String password) { ... } +} +``` + +**3-6. 테스트 수정** + +- `IllegalArgumentException` → `CoreException` 검증으로 변경 +- `Member.builder()` → `Member.register()` 또는 `MemberFixture.create()` 로 변경 +- DTO `@Builder` 제거 → `new` 생성자 사용 + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test` | **PASS** | +| `./gradlew :application:commerce-service:test` | **PASS** | + +--- + +## Step 4: 마스킹 로직 Presentation 이동 + +### 배경 + +이름 마스킹은 표현(presentation) 관심사. Service가 이를 담당하면 Service 책임이 비대해지고, +다른 presentation(batch, streamer)에서 다른 형태의 마스킹이 필요할 때 대응 불가. + +### 작업 내용 + +**4-1. MemberService에서 maskName() 제거** + +Service는 raw name을 반환. 마스킹 없음. + +**4-2. GetMemberInfoResponse에 withMaskedName() 추가** + +```java +public record GetMemberInfoResponse(String loginId, String name, LocalDate birthdate, String email) { + public GetMemberInfoResponse withMaskedName() { + return new GetMemberInfoResponse(loginId, maskName(name), birthdate, email); + } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) return ""; + if (name.length() == 1) return "*"; + return name.substring(0, name.length() - 1) + "*"; + } +} +``` + +**4-3. MemberController에서 마스킹 호출** + +```java +@GetMapping("/me") +public GetMemberInfoResponse getMyInfo(...) { + GetMemberInfoResponse response = memberService.getMyInfo(loginId, password); + return response.withMaskedName(); +} +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | + +--- + +## Step 5: DTO 네이밍 통일 + +### 배경 + +기존 DTO가 "대상 먼저" 패턴(`MemberRegisterRequest`)이었으나, +코드 스타일 컨벤션에서 "행동 먼저" 패턴으로 결정. + +### 작업 내용 + +| Before | After | +|--------|-------| +| `MemberRegisterRequest` | `RegisterMemberRequest` | +| `MyMemberInfoResponse` | `GetMemberInfoResponse` | +| `PasswordUpdateRequest` | `UpdatePasswordRequest` | + +변경 파일: +- DTO 파일 3개 (신규 생성 + 기존 삭제) +- `MemberService.java` (import + 참조) +- `MemberController.java` (import + 참조) +- `MemberServiceTest.java` (import + 참조) +- `MemberServiceIntegrationTest.java` (import + 참조) +- `MemberE2ETest.java` (import + 참조) + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | + +--- + +## Step 보류: DomainService 분리 + +### 판단 + +현재 Member 도메인에는 DomainService가 필요한 복잡한 도메인 간 로직이 없음. +- 검증 → VO가 담당 +- 비밀번호 변경 → Member 엔티티 메서드 +- 유스케이스 조합 → ApplicationService(MemberService) + +**결론:** 신규 도메인(Brand, Product, Order) 추가 시 도메인 간 로직이 생기면 그때 도입. +빈 껍데기 서비스를 미리 만드는 것은 오버엔지니어링. + +--- + +## Phase 2 완료 상태 + +- [x] `./gradlew clean build -x test` 전체 통과 +- [x] `./gradlew :domain:test` — MemberTest, CoreExceptionTest 통과 +- [x] `./gradlew :application:commerce-service:test` — MemberServiceTest 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 (코드 컴파일 통과) +- [x] ErrorType: pure enum (HttpStatus 없음), domain 레이어에 위치 +- [x] CoreException: domain 레이어에 위치 +- [x] BaseTimeEntity 신설, BaseEntity가 상속 +- [x] Member: VO 4개 사용, @Builder 제거, BaseTimeEntity 상속 +- [x] MemberPolicy 삭제 +- [x] IllegalArgumentException 전부 CoreException으로 대체 +- [x] 마스킹 로직: Presentation 레이어로 이동 +- [x] DTO: 행동 먼저(action-first) 네이밍 +- [ ] DomainService: 현재 불필요, 신규 도메인 추가 시 도입 diff --git a/docs/thought/phase3-implementation-log.md b/docs/thought/phase3-implementation-log.md new file mode 100644 index 000000000..d4f377c2d --- /dev/null +++ b/docs/thought/phase3-implementation-log.md @@ -0,0 +1,243 @@ +# Phase 3: 테스트 코드 수정 - 구현 실시간 로그 + +> 작성일: 2026-02-22 +> 상태: 완료 +> 관련 문서: [Phase 2 구현 로그](./phase2-implementation-log.md), [리팩토링 계획서](../planning/refactoring-plan.md) +> 핵심 원칙: "1 테스트 = 1 단언문" + +--- + +## Step 1: MemberTest (domain 단위 테스트) 정리 + +### 배경 + +Phase 2에서 모든 도메인 예외를 `CoreException`으로 통일했으므로, +테스트 코드의 `.isInstanceOf(CoreException.class)` 검증은 아키텍처가 이미 보장하는 부분. +불필요한 단언문을 제거하고, 빈 테스트 및 중복 테스트를 삭제하여 테스트 구조를 정리. + +### 작업 내용 + +**1-1. `.isInstanceOf(CoreException.class)` 전체 제거** + +```java +// Before +assertThatThrownBy(() -> Member.register(...)) + .isInstanceOf(CoreException.class) + .hasMessage("..."); + +// After +assertThatThrownBy(() -> Member.register(...)) + .hasMessage("..."); +``` + +helper 메서드(`throwIfWrongIdInput` 등)에서도 `.isInstanceOf()` 제거, `assertThatThrownBy`만 반환하도록 통일. + +**1-2. 빈 테스트 삭제** + +- `아이디는_중복_가입할_수_없음` — 본문이 비어 있고, 중복 체크는 Repository 의존이므로 Service 테스트 영역 + +**1-3. 중복 테스트 삭제** + +| 삭제 대상 | 이유 | +|----------|------| +| `RegistrationSuccess.successWhenAllFieldsValid` | `회원가입_성공` 테스트와 동일 | +| `비밀번호는_암호화해_저장` (2곳) | `SamePasswordValidation.isSamePassword_Success`가 이미 검증 | +| `UpdatePasswordPolicy.PasswordFormatValidation` 내부 전체 | 회원가입 섹션과 동일한 형식 검증 중복 | + +**1-4. 최종 테스트 구조** + +``` +MemberTest (20개 테스트) +├── 회원가입_성공 +├── @Nested LoginIdValidation (5개) +├── @Nested PasswordFormatValidation (5개) +├── @Nested NameValidation (4개) +├── @Nested EmailValidation (2개) +├── @Nested BirthDateValidation (1개) +├── @Nested SamePasswordValidation (2개) +└── @Nested UpdatePasswordPolicy (2개: 동일비밀번호, 생년월일포함) +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew :domain:test` | **PASS** | + +--- + +## Step 2: MemberServiceTest (mock 단위 테스트) 정리 + +### 배경 + +Service 테스트의 역할은 "유스케이스 조합이 올바르게 이루어지는가"를 검증하는 것. +Member 필드 값 검증은 MemberTest(domain 단위 테스트)가 담당하므로, Service 테스트에서 `ArgumentCaptor`로 필드를 꺼내 검증하는 것은 책임 영역 초과. + +### 작업 내용 + +**2-1. `회원가입_성공` 단순화** + +```java +// Before +ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); +verify(memberRepository).save(captor.capture()); +assertThat(captor.getValue().getLoginIdValue()).isEqualTo("testuser1"); + +// After +verify(memberRepository).save(any(Member.class)); +``` + +Service는 "save가 호출되었는가" (조합 검증)만 확인. + +**2-2. `내_정보_조회_성공` → 2개 테스트 분리** + +1 테스트 = 1 단언문 원칙 적용: + +- `내_정보_조회_성공_loginId_반환` +- `내_정보_조회_성공_name_반환` (`MemberFixture.DEFAULT_NAME` 참조) + +**2-3. `.isInstanceOf(CoreException.class)` 전체 제거** + +Step 1과 동일한 이유로 제거. + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew :application:commerce-service:test` | **PASS** | + +--- + +## Step 2.5: Squash 잔류 파일 정리 + +### 배경 + +Phase 2 squash 과정에서 삭제 대상이었던 파일 6개가 잔류하여 컴파일 에러 발생. + +### 작업 내용 + +삭제된 파일: + +| 파일 | 잔류 이유 | +|------|----------| +| `application/.../support/error/ErrorType.java` | 구 버전 (HttpStatus 포함), domain으로 이동됨 | +| `application/.../support/error/CoreException.java` | domain으로 이동됨 | +| `application/.../support/error/CoreExceptionTest.java` | domain으로 이동됨 | +| `application/.../dto/MemberRegisterRequest.java` | `RegisterMemberRequest`로 교체됨 | +| `application/.../dto/MyMemberInfoResponse.java` | `GetMemberInfoResponse`로 교체됨 | +| `application/.../dto/PasswordUpdateRequest.java` | `UpdatePasswordRequest`로 교체됨 | + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | + +--- + +## Step 3: MemberServiceIntegrationTest (통합 테스트) 정리 + +### 배경 + +통합 테스트에서도 1 테스트 = 1 단언문 원칙을 적용. +또한 Phase 2에서 마스킹 로직을 Controller로 이동했으므로, Service 직접 호출 시 원본 이름이 반환되어야 하는데 기대값이 마스킹된 값으로 남아있는 버그 발견. + +### 작업 내용 + +**3-1. `getMyInfo_Success` → 3개 테스트 분리** + +- `getMyInfo_성공_loginId_반환` +- `getMyInfo_성공_이름_반환` +- `getMyInfo_성공_이메일_반환` + +**3-2. 마스킹 기대값 버그 수정** + +```java +// Before (버그) +assertThat(response.name()).isEqualTo("공명*"); + +// After (수정) +assertThat(response.name()).isEqualTo("공명선"); +``` + +Phase 2에서 마스킹을 Controller(`GetMemberInfoResponse.withMaskedName()`)로 이동했으므로, +Service를 직접 호출하는 통합 테스트에서는 원본 이름이 반환되어야 함. + +**3-3. `.isInstanceOf(CoreException.class)` 전체 제거** + +Step 1과 동일한 이유로 제거. + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | + +--- + +## Step 4: MemberE2ETest (E2E 테스트) 시나리오 분리 + +### 배경 + +기존 E2E 테스트는 단일 메서드(`member_full_lifecycle_scenario`)에 전체 생명주기를 넣어두어, +어느 단계에서 실패했는지 파악하기 어렵고, 1 테스트 = 1 단언문 원칙에도 위배. + +### 작업 내용 + +**4-1. 단일 시나리오 → 5개 독립 테스트 분리** + +| # | 테스트명 | 검증 내용 | +|---|---------|----------| +| 1 | `회원가입_성공` | `status().isCreated()` | +| 2 | `내_정보_조회_마스킹된_이름_반환` | `jsonPath("$.name").value("공명*")` | +| 3 | `비밀번호_변경_성공` | `status().isNoContent()` | +| 4 | `변경된_비밀번호로_조회_성공` | `status().isOk()` | +| 5 | `기존_비밀번호로_조회_실패` | `status().isUnauthorized()` | + +**4-2. 공통 로직 헬퍼 메서드 추출** + +```java +private void registerMember() { ... } +private void changePassword() { ... } +private ResultActions createRegisterRequest() { ... } +``` + +**4-3. 상수 추출** + +```java +private static final String LOGIN_ID = "..."; +private static final String INITIAL_PW = "..."; +private static final String NEW_PW = "..."; +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | + +--- + +## Phase 3 완료 상태 + +### 테스트 수 변화 + +| 테스트 클래스 | Before | After | 변화 | +|--------------|--------|-------|------| +| MemberTest | 21개 | 20개 | -1 (빈/중복 삭제) | +| MemberServiceTest | 5개 | 6개 | +1 (단언문 분리) | +| MemberServiceIntegrationTest | 9개 | 11개 | +2 (단언문 분리) | +| MemberE2ETest | 1개 | 5개 | +4 (시나리오 분리) | + +### 체크리스트 + +- [x] MemberTest: `.isInstanceOf` 제거, 빈/중복 테스트 삭제, 구조 정리 (21 -> 20개) +- [x] MemberServiceTest: mock capture 제거, 단언문 분리 (5 -> 6개) +- [x] MemberServiceIntegrationTest: 단언문 분리, 마스킹 버그 수정 (9 -> 11개) +- [x] MemberE2ETest: 시나리오 분리 (1 -> 5개) +- [x] Squash 잔류 파일 6개 정리 +- [x] CLAUDE.md: 테스트 단위 원칙 추가 +- [x] `./gradlew clean build -x test` — BUILD SUCCESSFUL +- [x] `./gradlew :domain:test :application:commerce-service:test` — PASS +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 diff --git a/docs/thought/volume3-discussion-log.md b/docs/thought/volume3-discussion-log.md new file mode 100644 index 000000000..88d458439 --- /dev/null +++ b/docs/thought/volume3-discussion-log.md @@ -0,0 +1,690 @@ +# Volume 3 전체 논의 기록 + +> 작성일: 2026-02-23 +> 참여: 개발자, AI (Claude Code) +> 범위: Volume 3 프로젝트 전 세션의 미문서화 논의 종합 +> 기존 기록: [architecture-discussion-log.md](./architecture-discussion-log.md), [phase2-discussion-log.md](./phase2-discussion-log.md) + +--- + +## 목차 + +1. [초기 프로젝트 분석 및 방향 설정](#1-초기-프로젝트-분석-및-방향-설정) (2026-02-03) +2. [테스트 설계 철학](#2-테스트-설계-철학) (2026-02-03) +3. [도메인 정의 작업](#3-도메인-정의-작업) (2026-02-10) +4. [Brand 도메인 분석 — Soft Delete vs Hard Delete](#4-brand-도메인-분석--soft-delete-vs-hard-delete) (2026-02-12) +5. [Brand & Product BC 설계](#5-brand--product-bc-설계) (2026-02-22) +6. [Facade에서 BrandDeleteService로의 전환](#6-facade에서-branddeleteservice로의-전환) (2026-02-22) +7. [아키텍처 설계서 작성 논의](#7-아키텍처-설계서-작성-논의) (2026-02-22~23) +8. [핵심 설계 결정 요약](#8-핵심-설계-결정-요약) +9. [Member DIP 리팩토링](#9-member-dip-리팩토링) (2026-02-24) +10. [DTO 네이밍 체계 확정](#10-dto-네이밍-체계-확정) (2026-02-24) + +--- + +## 1. 초기 프로젝트 분석 및 방향 설정 + +> 세션일: 2026-02-03 + +### 1-1. 프로젝트 첫 분석 + +기존 템플릿 프로젝트의 구조를 분석했을 때, 개발자는 다음 평가를 내림: + +> "좋은 구조인 거 같아. OOP, DDD, Clean Architecture 고려가 잘 되어 있다." + +다만 실제 구현은 Member CRUD만 존재하며, 문서(요구사항, 클래스 다이어그램, ERD)는 정의되어 있으나 코드와 문서 사이에 간극이 있었음. + +### 1-2. AI 역할 경계 설정 + +개발자가 초기에 중요한 작업 방식을 확립: + +**테스트는 직접 작성한다:** +> "테스트는 내가 직접 쓸 거야. AI에게는 테스트 설계 의도 설명을 요청한다." + +**AI는 방향성 제안만:** +- 주요 의사결정의 최종 승인은 개발자가 수행 +- AI는 선택지와 trade-off를 분석하여 제안 +- 코드 작성 시 개발자의 의도를 확인한 후 진행 + +이 원칙은 이후 CLAUDE.md의 "증강 코딩" 워크플로우로 정형화됨. + +### 1-3. 리팩토링 우선 순서 결정 + +구현보다 리팩토링을 먼저 진행하기로 결정: + +``` +1단계: 리팩토링 (기존 기능) +2단계: 모델링 및 설계 변경 +3단계: 테스트 코드 수정 +4단계: 신규 기능 구현 (Brand, Product, Like, Order) +``` + +--- + +## 2. 테스트 설계 철학 + +> 세션일: 2026-02-03 + +### 2-1. 단언문(Assertion) 원칙 + +**개발자 질문:** "단언문의 특성을 정의해 줘." + +논의 끝에 확립된 원칙: + +> **하나의 테스트 메서드에는 단언문이 1개만 존재해야 한다.** + +**이유:** +- 단언문이 여러 개이면 첫 번째 실패 시 나머지는 실행되지 않아 실패 정보가 손실 +- 테스트 이름이 "무엇을 검증하는가"를 정확히 표현할 수 없게 됨 + +**적용:** 단언문이 2개 이상 필요한 테스트는 별도의 테스트 메서드로 분리. + +### 2-2. VO 테스트 전략 + +**개발자 질문:** "VO를 별도로 테스트할까, Entity와 통합해서 테스트할까?" + +**결정:** Entity 통합 테스트 + +**근거:** +- VO는 Entity의 내부 구성 요소 +- Entity의 행위를 통해 VO의 규칙이 자연스럽게 검증됨 +- 예: `Member.register()`를 테스트하면 LoginId, Password 등 VO의 검증도 함께 검증 + +### 2-3. Builder 패턴 사용 거부 + +**개발자 판단:** + +> "빌더 쓰니까 나중 가서 오류가 많이 생기더라고. 컴파일 오류를 감지하기 어려운." + +Builder 패턴의 문제: +- 필수 파라미터 누락을 컴파일 타임에 잡지 못함 +- 리팩토링 시 새 필드를 추가해도 기존 Builder 호출이 컴파일됨 → 런타임 버그 + +**결정:** 정적 팩토리 메서드 사용. 필드가 추가되면 컴파일 에러 발생 → 안전. + +### 2-4. testFixtures 도입 + +**개발자 질문:** "test/와 testFixtures/의 차이가 뭐야?" + +| 디렉토리 | 역할 | 가시성 | +|----------|------|--------| +| `test/` | 일반 테스트 코드 | 해당 모듈 내부만 | +| `testFixtures/` | 재사용 가능한 테스트 객체 (Fixture) | 의존하는 다른 모듈에서도 사용 가능 | + +**적용:** +- `MemberFixture`: `domain/src/testFixtures/`에 배치 +- application 모듈 테스트에서 `testFixtures(project(":domain"))` 의존으로 사용 + +--- + +## 3. 도메인 정의 작업 + +> 세션일: 2026-02-10 + +### 3-1. 도메인 정의의 목적 + +코드 작성 전, 요구사항에 등장하는 도메인 단어의 근본적 의미를 파악하는 작업을 수행. + +**개발자의 관점:** +> "도메인 정의는 근본적으로 변하지 않는 단어의 뜻을 파악하고, 이를 바탕으로 요구사항과 기능의 방향성을 분석하는 데 사용한다." + +### 3-2. 도메인 정의 결과 + +주요 도메인: + +- **회원(Member)**: 사용자와 서비스 사이의 신뢰 계약. 회원인 사용자에게 편의 기능 제공. +- **상품(Product)**: 판매하는 재화. 브랜드에 소속되며, 이름/가격/재고를 가짐. +- **브랜드(Brand)**: 상품을 묶는 단위. 관리자가 생성/관리. +- **좋아요(Like)**: 회원의 선호 표현. 상품 외 다른 대상으로 확장 가능. +- **주문(Order)**: 회원이 상품을 구매하는 행위. 주문 시점의 상품 정보를 스냅샷으로 보존. + +### 3-3. 바운디드 컨텍스트 초안 + +``` +BC: Member → 회원 가입, 인증, 정보 조회 +BC: Catalog → 브랜드/상품 관리 및 조회 +BC: Like → 선호(좋아요) 관계 기록 +BC: Order → 주문 생성/조회, 스냅샷 보존 +``` + +이 구조는 이후 `05-domain-model.md`로 정형화됨. + +--- + +## 4. Brand 도메인 분석 — Soft Delete vs Hard Delete + +> 세션일: 2026-02-12 + +### 4-1. 삭제 전략 논의 + +Brand 도메인에서 삭제를 어떻게 처리할 것인가에 대한 심층 논의. + +**선택지:** + +| 방식 | 설명 | 장점 | 단점 | +|------|------|------|------| +| Hard Delete | 레코드 물리 삭제 | 간단, 데이터 깔끔 | 복구 불가, 참조 무결성 문제 | +| Soft Delete | `deletedAt` 컬럼으로 논리 삭제 | 복구 가능, 이력 보존 | 쿼리 시 필터 필요 | +| Hybrid | 상태값(closedAt)으로 비활성화 | 도메인 의미 반영 | 복잡도 증가 | + +### 4-2. 입점/폐점 개념 + +개발자가 Brand의 삭제를 "폐점"이라는 도메인 언어로 표현: + +> "입점 폐점 느낌으로, Brand를 폐점하면 기록은 남기되 비활성화" + +### 4-3. 최종 결정 + +Soft Delete 채택 (`BaseEntity.deletedAt` 활용): + +- Brand 삭제 시 `deletedAt` 설정 + `name` 변경 (UNIQUE 해소) +- 삭제된 Brand는 User API에 노출되지 않음 +- 이미 삭제된 Brand 재삭제 시 예외 (`guardNotDeleted`) + +이 결정은 `brand-plan.md` 2-2절에 반영됨. + +--- + +## 5. Brand & Product BC 설계 + +> 세션일: 2026-02-22 (Phase 3 완료 후) + +### 5-1. Brand와 Product를 같은 BC로 결정 + +**개발자 질문:** "Brand와 Product를 하나의 BC로 할까, 분리할까?" + +**결정: 같은 BC (Catalog)** + +**근거:** +- Brand와 Product는 도메인적으로 밀접하게 연관 +- Brand 삭제 시 소속 Product 연쇄 삭제가 필요 → 같은 BC에서 처리하는 것이 자연스러움 +- 단, 독립 Aggregate로 분리 (Brand Aggregate, Product Aggregate) + +**독립 Aggregate인 이유:** +- 독립적 생명주기: Product 없이 Brand만 존재 가능 +- 규모 차이: 하나의 Brand에 수천 개 Product 가능 → 같은 Aggregate에 넣으면 메모리/성능 문제 +- 독립 변경: Product 가격/재고 수정 시 Brand를 잠글 필요 없음 + +### 5-2. Like를 독립 BC로 분리한 이유 + +초기에는 ProductLike로 Product BC에 포함하려 했으나, 독립 BC로 결정. + +**개발자 판단:** + +> "Like는 추천 시스템에서도 사용할 수 있고, 상품 외 다른 대상(브랜드, 셀러)으로 확장 가능하니까 분리하자." + +**분리 근거:** +- `subjectType` 확장 가능성 (`PRODUCT`, `BRAND`, `SELLER` 등) +- 추천/랭킹 파이프라인과의 독립 배포 경계 필요 가능성 +- 다른 팀 책임 가능성 + +**모델:** `Like(memberId, subjectType, subjectId)` — 범용적 좋아요 모델 + +### 5-3. BC 간 참조 규칙 + +**결정:** 모든 BC 간 참조는 객체 참조가 아닌 **ID(Long) 참조만** 사용. + +| 관계 | 참조 방식 | +|------|----------| +| Product → Brand | `product.brandId` (Long) | +| Like → Product | `like.subjectId` (Long) | +| Order → Product | `orderLineSnapshot.productId` (Long) | +| Order → Member | `order.memberId` (Long) | + +**FK 제약조건 없음:** +- 같은 BC 내부(product.brand_id → brand.id)에도 FK 없음 +- 삭제 연쇄를 DB CASCADE가 아닌 `BrandDeleteService`로 명시적 제어 +- 규칙이 코드에 표현되어 추적 가능 + +--- + +## 6. Facade에서 BrandDeleteService로의 전환 + +> 세션일: 2026-02-22 + +### 6-1. 문제 발견 + +초기 설계에서 Brand 삭제 시 Product 연쇄 삭제를 `AdminBrandFacade` (Application 레이어)로 처리하려 했음. + +**기존 설계:** +``` +AdminBrandController → AdminBrandFacade → AdminBrandService + AdminProductService +``` + +### 6-2. 왜 Facade가 부적합한가 + +**개발자와의 논의에서 도출된 결론:** + +| 구분 | Facade | Domain Service | +|------|--------|----------------| +| 위치 | Application 레이어 | Domain 레이어 | +| 역할 | Application Service 간 순환 참조 해소 | 같은 BC 내 cross-aggregate 도메인 규칙 | +| 해당 상황 | Brand↔Product 순환 참조? → 아님 | Brand↔Product 같은 BC의 삭제 연쇄 규칙? → 맞음 | + +Brand 삭제 → Product 연쇄 삭제는: +- **같은 BC**(Catalog)의 규칙 +- **도메인 규칙** ("브랜드가 폐점하면 소속 상품도 비활성화") +- 기술적 조율이 아닌 **비즈니스 규칙** + +따라서 Facade가 아닌 **BrandDeleteService**가 적합. + +### 6-3. 전환 결과 + +**변경 후:** +``` +AdminBrandController → AdminBrandService → BrandDeleteService + ├── BrandRepository + └── ProductRepository +``` + +**BrandDeleteService의 책임:** +- Brand 삭제 시 소속 Product 연쇄 soft-delete + +**반영 문서:** +- `02-sequence-diagrams.md` 5-3절: AdminProductFacade → BrandDeleteService +- `03-class-diagram.md` 4절: Facade 테이블 → Domain Service 테이블 +- `brand-plan.md` 2-5절: BrandDeleteService 설계 +- `05-domain-model.md` 4-3절: Domain Service vs Facade 구분 + +--- + +## 7. 아키텍처 설계서 작성 논의 + +> 세션일: 2026-02-22~23 + +### 7-1. 아키텍처 설계서의 목적 + +**개발자 요청:** + +> "리뷰를 받기 위해 아키텍처 설계도를 그려줘. 내 리뷰가 아니라 시니어 분의 리뷰." +> +> "의도를 좀 담았으면 좋겠어. 내가 왜 이런 구조들을 선택하고 이런 클래스들을 만들었는지." + +**목표:** 시니어가 이 문서 하나로 전체 아키텍처의 의도를 파악할 수 있어야 한다. + +결과물: `docs/design/06-architecture.md` + +### 7-2. Presentation → Application & modules/jpa 의존 이유 + +**개발자 질문:** "아키텍처에서 presentation → application, modules/jpa 둘 다 의존하는 이유가 뭐야?" + +**답변 요약 — Composition Root 패턴:** + +``` +presentation/commerce-api/ +├── CommerceApiApplication.java ← @SpringBootApplication +├── 여기서 모든 Bean이 조립됨 +│ ├── AdminBrandService (application) +│ ├── BrandRepositoryImpl (modules/jpa) +│ └── BrandJpaRepository (modules/jpa) +``` + +- Presentation은 Spring Boot 애플리케이션의 진입점 +- 모든 Bean을 조립하는 **Composition Root** 역할 +- application의 Service Bean과 modules/jpa의 Repository Bean을 모두 알아야 조립 가능 + +### 7-3. Catalog BC 기반 패키징 + +**개발자 질문:** "catalog랑 brand랑 product를 3개로 나눈 이유는 뭐야? catalog 안에 brand랑 product가 들어가야 맞지 않나?" + +**결정:** Domain 레이어에만 BC 기반 패키징 적용 + +``` +domain/src/main/java/com/loopers/domain/ +├── catalog/ +│ ├── brand/ +│ │ ├── Brand.java +│ │ ├── BrandRepository.java +│ │ └── BrandExceptionMessage.java +│ ├── product/ +│ │ ├── Product.java +│ │ ├── ProductRepository.java +│ │ └── ProductExceptionMessage.java +│ └── BrandDeleteService.java +├── member/ +├── like/ +└── order/ +``` + +**상위 레이어는 별도 고민 필요:** + +> "도메인 레이어만 일단 적용해줘. 알다시피 위에서는 여러 응용이 나오게 되고, 그러면 당연히 어디에 두는가를 또 고민해야 하잖아?" + +Application, Presentation, Infrastructure 레이어의 패키징은 추후 결정. + +### 7-4. 다이어그램 간결화 + +**개발자 요청:** + +> "정말 간단한 다이어그램 하나만 그려줘. 클래스도 필요없고... 가볍게만 그려줘." + +초기 버전은 Gradle 모듈까지 포함한 상세 다이어그램이었으나, 개발자의 피드백에 따라 **개념만 표현하는 최소 다이어그램**으로 축소: + +``` +Presentation: Controller, API DTO + ↓ +Application: Service, Facade, Command / Query DTO + ↓ +Domain: BC, Entity, VO, Repository (interface), Domain Service, ErrorType + ↑ +Infrastructure: Repository 구현체, JPA Config +``` + +### 7-5. DTO 네이밍 변경: Info → Query + +**개발자 결정:** + +> "Info는 Query로 바꿀게." + +| Before | After | 예시 | +|--------|-------|------| +| `BrandInfo` | `BrandQuery` | `BrandQuery.from(Brand brand)` | +| `MemberInfo` | `MemberQuery` | `MemberQuery.from(Member member)` | + +**네이밍 컨벤션:** +- 요청 DTO: **Command** (`BrandCreateCommand`, `BrandUpdateCommand`) +- 응답 DTO: **Query** (`BrandQuery`) + +### 7-6. Port-Adapter → Repository 추상체/구현체 + +**개발자 교정:** + +> "Port-Adapter 패턴도 결국 헥사고날에서 쓰는 거고 나는 레이어드 아키텍처니까 그냥 Repository 추상체와 구현체로 해줘." + +| Before (Hexagonal 용어) | After (Layered 용어) | +|------------------------|---------------------| +| Port | Repository (interface) | +| Adapter | Repository 구현체 (RepositoryImpl) | +| Port-Adapter 패턴 | Repository 추상체/구현체 | + +**핵심:** 이 프로젝트는 **레이어드 아키텍처**를 사용한다. 헥사고날 용어를 혼용하지 않는다. + +### 7-7. Domain Service의 Bean 등록 문제 + +**개발자 질문:** "Domain에 @Service 어노테이션이 못 붙으면 Domain Service는 어떻게 구현해?" + +Domain 레이어 규칙: +- `@Service` (spring-context) → Domain에서 사용 가능하나, `@Service`는 Application Service 전용 +- `@Component` → 허용 + +**선택지:** + +| 방식 | 설명 | 장점 | 단점 | +|------|------|------|------| +| A. @Bean 수동 등록 | Application에서 `@Configuration` + `@Bean`으로 생성 | Domain에 Spring 의존 없음 | 등록 보일러플레이트 | +| **B. spring-context 추가** | Domain에 `@Component` 사용 | 간결, JPA 허용과 일관성 | spring-context 의존 추가 | + +**개발자 결정:** B (spring-context 추가) + +> "B가 나을 거 같긴 한데" + +**근거:** JPA 어노테이션 허용과 같은 실용주의 기준. `spring-context`는 DI 프레임워크 인터페이스 수준이므로 허용. + +**Domain 레이어 어노테이션 규칙:** + +| 어노테이션 | 허용 여부 | 이유 | +|-----------|----------|------| +| `@Entity`, `@Embeddable` | 허용 | JPA 표준 스펙 (인터페이스 수준) | +| `@Component` | 허용 | DI 마커 (Domain Service용) | +| `@Service` | 금지 | Application Service 전용으로 의미 구분 | +| `@Transactional` | 금지 | 트랜잭션 경계는 Application 책임 | +| `@RestController` | 금지 | HTTP는 Presentation 책임 | + +### 7-8. VO 불변성과 값 변경 + +**개발자 질문:** "VO는 구현할 때 예를 들어, +1 되는 값이 있으면, 불변해야 하니까 안에서 ++ 로 구현을 안 하고 객체로 +1된 값을 반환하게 되나?" + +**답변:** 맞다. VO는 **새 객체를 반환**한다. + +```java +// Stock VO — 불변 패턴 +public Stock decrease(Quantity quantity) { + if (!isEnough(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 부족"); + } + return new Stock(this.value - quantity.getValue()); // 새 객체 반환 +} + +// Entity에서 VO 교체 +public void decreaseStock(Quantity quantity) { + this.stock = this.stock.decrease(quantity); // 참조 교체 +} +``` + +**핵심:** VO 내부 상태를 변경하지 않고, 새 VO 인스턴스를 생성하여 반환. Entity가 VO 참조를 교체. + +### 7-9. Aggregate 트랜잭션 경계 + +**개발자 질문:** "애그리거트는 하나의 트랜잭션 경계를 가져야 하잖아. 그러면 여러 애그리거트나 BC들이 합쳐진 경우에는? 그때도 트랜잭션을 각자 나눠서 가져?" + +**3가지 경우 정리:** + +| 상황 | 트랜잭션 전략 | 예시 | +|------|-------------|------| +| 같은 BC, cross-aggregate | **같은 트랜잭션** | Brand 삭제 → Product 연쇄 (BrandDeleteService) | +| 다른 BC (모놀리스) | **같은 트랜잭션** (실용적) | 주문 생성 → 재고 차감 (OrderService에서 조율) | +| 다른 BC (규모 확장 시) | 이벤트 기반 (eventual consistency) | 현재 해당 없음 | + +**모놀리스에서의 실용적 판단:** +- 이론적으로는 BC마다 트랜잭션 분리가 이상적 +- 하지만 모놀리스에서 같은 DB를 사용하면 같은 트랜잭션이 실용적 +- Application Service가 `@Transactional`을 소유하고 여러 Domain Service/Repository를 조율 + +### 7-10. MSA 언급 제거 + +**개발자 교정:** + +> "근데 내가 MSA로 옮긴다는 얘기는 안 했는데. 그 부분도 일단 없애줄래?" + +아키텍처 설계서에서 "MSA 전환 대비"라는 문구가 FK 없음 정책의 이유로 포함되어 있었음. 개발자는 MSA 전환을 언급한 적 없으므로 제거. + +**수정:** +- 무FK 정책 사유: ~~"MSA 전환 대비 + 도메인 규칙 명시적 제어"~~ → "도메인 규칙 명시적 제어" +- ADR 테이블: 동일하게 MSA 문구 제거 + +**교훈:** AI가 일반적으로 정당한 이유라도, 개발자가 언급하지 않은 의도를 가정하여 문서에 포함하지 않는다. + +--- + +## 8. 핵심 설계 결정 요약 + +### 8-1. 실용주의 일관성 기준 + +이 프로젝트의 모든 설계 결정은 **동일한 실용주의 기준**으로 판단됨: + +> "분리의 이득이 비용보다 큰가?" + +| 대상 | 결정 | 근거 | +|------|------|------| +| JPA `@Entity` in Domain | 허용 | 표준 스펙. 매핑 레이어 추가 비용 > 이득 | +| `spring-context` in Domain | 허용 | DI 마커. @Bean 등록 보일러플레이트 > 이득 | +| `HttpStatus` in Domain | 불허 | Spring 고유. 분리 비용 낮음 (switch 하나) | +| `@Service` in Domain | 불허 | Application과 의미 구분 필요 | + +### 8-2. 용어 결정 이력 + +| 시점 | Before | After | 이유 | +|------|--------|-------|------| +| Phase 2 | `MemberPolicy` 분리 | Entity/VO 내재화 | 응집도 향상, 코드 위치 근접성 | +| Phase 2 | `DomainException` hierarchy | `ErrorType` pure enum | 간결, 해석 자유도 | +| 설계 단계 | `AdminBrandFacade` | `BrandDeleteService` | 같은 BC = Domain Service | +| 설계 단계 | `ProductLike` (Catalog BC) | `Like` (독립 BC) | 확장성, 책임 분리 | +| 문서 단계 | `BrandInfo` (DTO) | `BrandQuery` (DTO) | 의미론적 명확성 | +| 문서 단계 | Port / Adapter | Repository 추상체 / 구현체 | 레이어드 아키텍처 용어 통일 | + +### 8-3. 아직 미결정 사항 + +| 항목 | 상태 | 결정 시점 | +|------|------|----------| +| Application/Presentation/Infrastructure 레이어의 BC 기반 패키징 | 보류 | Brand/Product 구현 시 | +| sealed class 도입 (ErrorType 확장) | 보류 | 에러 타입별 다른 데이터 필요 시 | +| Event Sourcing / Kafka 비동기 처리 | 보류 | 규모 확장 시 | +| Cache 전략 | 보류 | Brand/Product 구현 시 | +| Preference BC 전환 (Like → Preference) | 보류 | subjectType 2종 이상 + 타입별 정책 분기 시 | + +--- + +## 9. Member DIP 리팩토링 + +> 세션일: 2026-02-24 + +### 9-1. PasswordEncryptor DIP + +**핵심 질문:** "비밀번호 암호화는 정말 기능적 요구사항일까?" + +**분석 결과:** +- 비밀번호 **검증**(형식, 길이, 생년월일 포함 여부) = **기능적 요구사항** → Domain +- 비밀번호 **암호화**(SHA-256, BCrypt) = **비기능적 요구사항**(보안/인프라) → Infrastructure + +**그런데 왜 Domain에 PasswordEncryptor 인터페이스가 필요한가?** + +> "새 비밀번호가 현재 비밀번호와 동일하면 안 된다" + +이 규칙은 **비즈니스 규칙**이다. 그런데 이 규칙을 검증하려면 암호화된 현재 비밀번호와 raw 입력을 비교해야 한다. 즉, 비즈니스 규칙이 암호화 비교를 **요구**한다. + +**결론:** PasswordEncryptor 인터페이스를 Domain에 두는 것은 DIP로 정당화된다. + +``` +Domain Layer: PasswordEncryptor (interface) ← Password VO가 사용 +Infrastructure: BCryptPasswordEncryptor (구현체) +Application: 조정자 — PasswordEncryptor를 보유하고 Domain에 전달 +``` + +### 9-2. MemberPasswordService 생성과 삭제 + +**시도:** PasswordEncryptor를 Domain Service가 보유하게 하면 Application이 깔끔해지지 않을까? + +**검증 — DomainService 3가지 도입 기준:** + +| 기준 | 해당 여부 | 이유 | +|------|----------|------| +| ① 단일 엔티티에 속하지 않는 도메인 규칙 | 해당 없음 | 비밀번호 규칙은 Password VO 하나에 속함 | +| ② 여러 엔티티 간 불변식 검증 | 해당 없음 | Member 하나만 관여 | +| ③ Application에 도메인 로직 누출 | 해당 없음 | Application은 `member.updatePassword()`를 호출할 뿐, 판단하지 않음 | + +**결론:** 3가지 모두 불충족 → MemberPasswordService 삭제. Application이 PasswordEncryptor를 조정자로서 보유하는 것으로 충분. + +### 9-3. changeTo 패턴 + +비밀번호 변경 시 "검증 → 생성"을 하나의 메서드로 통합. + +```java +// Password VO +public Password changeTo(String newRawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateChangeable(newRawPassword, birthDate, encryptor); // 동일 비밀번호, 형식, 생년월일 + return Password.of(newRawPassword, birthDate, encryptor); +} + +// Member Entity +public void updatePassword(String newRawPassword, PasswordEncryptor encryptor) { + this.password = password.changeTo(newRawPassword, birthDate, encryptor); +} +``` + +**이점:** 변경 규칙이 Password VO에 완전히 캡슐화. Member는 `changeTo`만 호출. + +### 9-4. VO 직접 노출 + +**기존:** `member.getLoginIdValue()` (convenience getter, String 반환) +**변경:** `member.getLoginId()` (VO 직접 반환) + +**이유:** +- VO가 값을 캡슐화하고 있으므로 한 번 더 래핑할 이유 없음 +- Application DTO(`MemberInfo`)도 VO를 직접 보유 +- String 변환은 Presentation이 `info.loginId().getValue()`로 수행 + +### 9-5. Presentation DTO 분리 + +마스킹은 표현 관심사이므로 Presentation 레이어로 이동. + +``` +Application: MemberInfo(LoginId, MemberName, LocalDate, Email) ← VO 보유 +Presentation: MemberApiResponse.from(MemberInfo) ← String 변환 + 마스킹 +``` + +### 9-6. MemberId VO + +**선택지:** + +| 방식 | 설명 | +|------|------| +| A. Domain 래퍼 | `MemberId(Long value)` — JPA 구조 변경 없음 | +| B. JPA 통합 | `@EmbeddedId` 사용 — BaseTimeEntity 변경 필요 | + +**결정:** A (Domain 래퍼). JPA의 `Long id`는 그대로 두고, `MemberId.of(getId())`로 감싸서 타입 안전성 확보. + +**Member equals/hashCode:** +- `equals`: id 기반 (id가 null이면 같은 참조만 동등) +- `hashCode`: `getClass().hashCode()` — Hibernate 권장 패턴 (영속화 전후 안정) + +### 9-7. 테스트 컨벤션 확립 + +| 규칙 | 설명 | +|------|------| +| `@DisplayName` 금지 | 한글 메서드명이 테스트 의도를 충분히 표현 | +| 한글 메서드명 | `회원가입_성공()`, `비밀번호_수정_실패_현재_비밀번호와_동일()` | +| 3A 주석만 유지 | `// given`, `// when`, `// then` (또는 `// when & then`) | +| 메서드 내 일반 주석 금지 | 코드가 의도를 표현해야 함 | + +--- + +## 10. DTO 네이밍 체계 확정 + +> 세션일: 2026-02-24 + +### 10-1. Query 네이밍 검토 + +7-5절에서 "Info → Query"로 변경을 결정했으나, 실제 적용 과정에서 문제 발견: + +- `BrandQuery`는 "브랜드를 조회하는 쿼리 객체"(검색 조건)로 읽힐 수 있음 +- 실제 의미는 "조회 결과 응답"인데, 이름이 의도를 정확히 전달하지 못함 +- `QueryDto` 접미도 검토했으나 `dto/` 패키지에 이미 위치하므로 중복 + +**결론:** Query 네이밍 기각. Info로 회귀. + +### 10-2. 확정된 전 레이어 DTO 네이밍 체계 + +**Application Layer:** + +| 방향 | 패턴 | 예시 | +|------|------|------| +| Inbound (상태 변경) | `{Domain}{Action}Command` | `MemberRegisterCommand`, `BrandCreateCommand` | +| Outbound (조회 결과) | `{Domain}Info` | `MemberInfo`, `BrandInfo` | + +**Presentation Layer:** + +| 방향 | 패턴 | 예시 | +|------|------|------| +| Inbound (Request Body) | `{Domain}{Action}ApiRequest` | `MemberRegisterApiRequest`, `BrandCreateApiRequest` | +| Outbound (Response Body) | `{Domain}ApiResponse` | `MemberApiResponse`, `BrandApiResponse` | + +**변환 흐름:** +``` +HTTP Request → {Domain}{Action}ApiRequest.toCommand() → {Domain}{Action}Command +{Domain}Info → {Domain}ApiResponse.from({Domain}Info) → HTTP Response +``` + +**적용 결과:** + +| Before | After | +|--------|-------| +| `RegisterMemberRequest` | `MemberRegisterCommand` | +| `UpdatePasswordRequest` | `PasswordUpdateCommand` | +| `GetMemberInfoResponse` | `MemberInfo` | +| `GetMemberInfoApiResponse` | `MemberApiResponse` | + +--- + +## 참조 문서 + +| 문서 | 내용 | +|------|------| +| [architecture-discussion-log.md](./architecture-discussion-log.md) | Phase 1 아키텍처 분석, 멘토 피드백, 주요 설계 결정 | +| [phase2-discussion-log.md](./phase2-discussion-log.md) | 예외 체계 설계, VO 전략, DomainService 보류 | +| [phase1-implementation-log.md](./phase1-implementation-log.md) | Phase 1 구현 진행 기록 | +| [phase2-implementation-log.md](./phase2-implementation-log.md) | Phase 2 구현 진행 기록 | +| [phase3-implementation-log.md](./phase3-implementation-log.md) | Phase 3 구현 진행 기록 | +| `docs/design/06-architecture.md` | 아키텍처 설계서 (시니어 리뷰용) | +| `docs/design/05-domain-model.md` | 도메인 모델 정의서 | +| `docs/planning/brand-plan.md` | Brand TDD 구현 계획 | +| `docs/planning/product-plan.md` | Product TDD 구현 계획 | diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts new file mode 100644 index 000000000..eff9066d0 --- /dev/null +++ b/domain/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `java-library` + `java-test-fixtures` +} + +dependencies { + api("jakarta.persistence:jakarta.persistence-api") + api(project(":supports:error")) + implementation("org.springframework:spring-context") + + // querydsl Q클래스 생성 (엔티티가 domain에 위치하므로 여기서 apt 실행) + api("com.querydsl:querydsl-jpa::jakarta") + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") +} diff --git a/domain/src/main/java/com/loopers/domain/BaseEntity.java b/domain/src/main/java/com/loopers/domain/BaseEntity.java new file mode 100644 index 000000000..506fa14af --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/BaseEntity.java @@ -0,0 +1,16 @@ +package com.loopers.domain; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@MappedSuperclass +@Getter +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; +} diff --git a/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java b/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java new file mode 100644 index 000000000..9c3c81114 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java @@ -0,0 +1,37 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; +import java.time.ZonedDateTime; + +@MappedSuperclass +@Getter +public abstract class BaseTimeEntity extends BaseEntity { + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected void guard() {} + + @PrePersist + private void prePersist() { + guard(); + + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + guard(); + + this.updatedAt = ZonedDateTime.now(); + } +} diff --git a/domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java b/domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java new file mode 100644 index 000000000..8c2ac5bc5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java @@ -0,0 +1,36 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import java.time.ZonedDateTime; + +@MappedSuperclass +@Getter +public abstract class SoftDeletableEntity extends BaseTimeEntity { + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + /** + * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) + */ + public boolean isDeleted() { + return this.deletedAt != null; + } + + public void delete() { + if (!isDeleted()) { + this.deletedAt = ZonedDateTime.now(); + } + } + + /** + * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) + */ + public void restore() { + if (isDeleted()) { + this.deletedAt = null; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java b/domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java new file mode 100644 index 000000000..e9e6c0779 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ActiveProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public Product get(Long productId) { + Product product = productRepository.findById(productId) + .filter(p -> !p.isDeleted()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + if (brand.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Product.UNAVAILABLE.message()); + } + + return product; + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java b/domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java new file mode 100644 index 000000000..4355a1b3c --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BrandDeleteService { + + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + public void delete(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + productRepository.softDeleteByBrandId(brandId); + brand.delete(); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java b/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java new file mode 100644 index 000000000..07aaac223 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java @@ -0,0 +1,56 @@ +package com.loopers.domain.catalog.brand; + +import com.loopers.domain.SoftDeletableEntity; +import com.loopers.domain.catalog.vo.Name; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "brand") +public class Brand extends SoftDeletableEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false, length = 100, unique = true)) + private Name name; + + private Brand(Name name) { + this.name = name; + } + + public static Brand register(String name) { + return new Brand(Name.of(name)); + } + + public boolean hasName(String name) { + return this.name.getValue().equals(name); + } + + public boolean hasNameStartingWith(String prefix) { + return this.name.startsWith(prefix); + } + + public void updateName(String name) { + guardNotDeleted(); + this.name = Name.of(name); + } + + @Override + public void delete() { + guardNotDeleted(); + this.name = Name.of(this.name.getValue() + "_deleted_" + System.currentTimeMillis()); + super.delete(); + } + + private void guardNotDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java new file mode 100644 index 000000000..deb2140fd --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java @@ -0,0 +1,21 @@ +package com.loopers.domain.catalog.brand; + +import lombok.AllArgsConstructor; + +public class BrandExceptionMessage { + + @AllArgsConstructor + public enum Brand { + INVALID_NAME("브랜드명은 1자 이상 100자 이하여야 합니다.", 2_001), + DUPLICATE_NAME("이미 존재하는 브랜드명입니다.", 2_002), + NOT_FOUND("존재하지 않는 브랜드입니다.", 2_003), + ALREADY_DELETED("이미 삭제된 브랜드입니다.", 2_004); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java new file mode 100644 index 000000000..2eca1a6a2 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.catalog.brand; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + boolean existsByName(String name); + + List findAllByDeletedAtIsNull(); + + List findAll(); + + List findAllByIdIn(List ids); +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/Product.java b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java new file mode 100644 index 000000000..f5c6d8cfc --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java @@ -0,0 +1,104 @@ +package com.loopers.domain.catalog.product; + +import com.loopers.domain.SoftDeletableEntity; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.domain.catalog.vo.Name; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "product") +public class Product extends SoftDeletableEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false, length = 100)) + private Name name; + + @Column(name = "description") + private String description; + + @Embedded + private Money price; + + @Embedded + private Stock stock; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "likes_count", nullable = false) + private long likesCount; + + private Product(Name name, String description, Money price, Stock stock, Long brandId) { + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.brandId = brandId; + this.likesCount = 0L; + } + + public static Product register(String name, String description, Money price, Stock stock, Long brandId) { + return new Product(Name.of(name), description, price, stock, brandId); + } + + public boolean hasName(String name) { + return this.name.getValue().equals(name); + } + + public boolean belongsToBrand(Long brandId) { + return this.brandId.equals(brandId); + } + + public boolean hasDescription() { + return this.description != null; + } + + public boolean hasStock(long value) { + return this.stock.isEqualTo(value); + } + + public void update(String name, String description, Money price, Stock stock) { + guardNotDeleted(); + this.name = Name.of(name); + this.description = description; + this.price = price; + this.stock = stock; + } + + public void decreaseStock(Quantity quantity) { + guardNotDeleted(); + this.stock = this.stock.decrease(quantity); + } + + public boolean hasEnoughStock(Quantity quantity) { + return this.stock.isEnough(quantity); + } + + public void increaseLikesCount() { + this.likesCount++; + } + + public void decreaseLikesCount() { + this.likesCount = Math.max(0, this.likesCount - 1); + } + + public boolean hasLikesCount(long value) { + return this.likesCount == value; + } + + private void guardNotDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java new file mode 100644 index 000000000..86b053665 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java @@ -0,0 +1,58 @@ +package com.loopers.domain.catalog.product; + +import lombok.AllArgsConstructor; + +public class ProductExceptionMessage { + + @AllArgsConstructor + public enum Product { + INVALID_NAME("상품명은 1자 이상 100자 이하여야 합니다.", 3_001), + NOT_FOUND("존재하지 않는 상품입니다.", 3_002), + ALREADY_DELETED("이미 삭제된 상품입니다.", 3_003), + UNAVAILABLE("현재 이용할 수 없는 상품입니다.", 3_004); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + @AllArgsConstructor + public enum Price { + INVALID_PRICE("가격은 0보다 커야 합니다.", 3_101); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + @AllArgsConstructor + public enum Stock { + INVALID_STOCK("재고는 0 이상이어야 합니다.", 3_201), + INSUFFICIENT_STOCK("재고가 부족합니다.", 3_202); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + @AllArgsConstructor + public enum Quantity { + INVALID_QUANTITY("수량은 0보다 커야 합니다.", 3_301); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java new file mode 100644 index 000000000..e7973a47c --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.catalog.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + List findAllActive(ProductSortType sortType); + + List findAll(); + + List findAllByIdIn(List ids); + + void softDeleteByBrandId(Long brandId); +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java new file mode 100644 index 000000000..1ddc926b8 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.catalog.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java new file mode 100644 index 000000000..bc590b744 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java @@ -0,0 +1,45 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + @Column(name = "price", nullable = false) + private long value; + + private Money(long value) { + this.value = value; + } + + public static Money of(long value) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Price.INVALID_PRICE.message()); + } + return new Money(value); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money money)) return false; + return value == money.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java new file mode 100644 index 000000000..cfadba56e --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java @@ -0,0 +1,35 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Quantity { + + @Column(name = "quantity", nullable = false) + private long value; + + private Quantity(long value) { + this.value = value; + } + + public boolean isEqualTo(long value) { + return this.value == value; + } + + public static Quantity of(long value) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Quantity.INVALID_QUANTITY.message()); + } + return new Quantity(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java new file mode 100644 index 000000000..8d4e11cab --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java @@ -0,0 +1,61 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Stock { + + @Column(name = "stock", nullable = false) + private long value; + + private Stock(long value) { + this.value = value; + } + + public static Stock of(long value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Stock.INVALID_STOCK.message()); + } + return new Stock(value); + } + + public boolean isEqualTo(long value) { + return this.value == value; + } + + public Stock decrease(Quantity quantity) { + if (!isEnough(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Stock.INSUFFICIENT_STOCK.message()); + } + return new Stock(this.value - quantity.getValue()); + } + + public boolean isEnough(Quantity quantity) { + return this.value >= quantity.getValue(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Stock stock)) return false; + return value == stock.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/vo/Name.java b/domain/src/main/java/com/loopers/domain/catalog/vo/Name.java new file mode 100644 index 000000000..560b89e8c --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/vo/Name.java @@ -0,0 +1,48 @@ +package com.loopers.domain.catalog.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Name { + + @Column(name = "name", nullable = false, length = 100) + private String value; + + private Name(String value) { + this.value = value; + } + + public static Name of(String value) { + if (value == null || value.isBlank() || value.length() > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이름은 1자 이상 100자 이하여야 합니다."); + } + return new Name(value); + } + + public boolean startsWith(String prefix) { + return this.value.startsWith(prefix); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Name name)) return false; + return Objects.equals(value, name.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/like/Like.java b/domain/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..941e52173 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,45 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_member_subject", + columnNames = {"member_id", "subject_type", "subject_id"}) +}) +public class Like extends BaseTimeEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "subject_type", nullable = false) + private LikeSubjectType subjectType; + + @Column(name = "subject_id", nullable = false) + private Long subjectId; + + private Like(Long memberId, LikeSubjectType subjectType, Long subjectId) { + this.memberId = memberId; + this.subjectType = subjectType; + this.subjectId = subjectId; + } + + public static Like mark(Long memberId, LikeSubjectType subjectType, Long subjectId) { + return new Like(memberId, subjectType, subjectId); + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + public boolean isForSubject(LikeSubjectType subjectType, Long subjectId) { + return this.subjectType == subjectType && this.subjectId.equals(subjectId); + } +} diff --git a/domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java b/domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java new file mode 100644 index 000000000..801e91a6a --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java @@ -0,0 +1,19 @@ +package com.loopers.domain.like; + +import lombok.AllArgsConstructor; + +public class LikeExceptionMessage { + + @AllArgsConstructor + public enum Like { + ALREADY_LIKED("이미 좋아요한 상품입니다.", 5_001), + NOT_LIKED("좋아요하지 않은 상품입니다.", 5_002); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/like/LikeRepository.java b/domain/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..60436fee8 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + void delete(Like like); + + boolean existsByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + Optional findByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + java.util.List findByMemberIdAndSubjectType(Long memberId, LikeSubjectType subjectType); +} diff --git a/domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java b/domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java new file mode 100644 index 000000000..e95fc654a --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public enum LikeSubjectType { + PRODUCT +} diff --git a/domain/src/main/java/com/loopers/domain/member/Member.java b/domain/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..9b08f36c9 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,77 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseTimeEntity; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.domain.member.vo.MemberName; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member") +public class Member extends BaseTimeEntity { + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Embedded + private MemberName name; + + private LocalDate birthDate; + + @Embedded + private Email email; + + private Member(LoginId loginId, Password password, MemberName name, LocalDate birthDate, Email email) { + validateBirthDate(birthDate); + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static Member register( + LoginId loginId, + Password password, + MemberName name, + LocalDate birthDate, + Email email + ) { + return new Member(loginId, password, name, birthDate, email); + } + + public MemberId getMemberId() { + return getId() != null ? MemberId.of(getId()) : null; + } + + public boolean matchesPassword(String rawPassword, PasswordEncryptor encryptor) { + return this.password.matches(rawPassword, encryptor); + } + + public void updatePassword(String newRawPassword, PasswordEncryptor encryptor) { + this.password = password.changeTo(newRawPassword, birthDate, encryptor); + } + + private static void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/domain/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java new file mode 100644 index 000000000..8adce180d --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -0,0 +1,119 @@ +package com.loopers.domain.member; + +import lombok.AllArgsConstructor; + +public class MemberExceptionMessage { + + public interface ExceptionMessage { + String message(); + } + + /** + * 1_000 ~ 1_099: 로그인 ID 관련 오류 + */ + @AllArgsConstructor + public enum LoginId implements ExceptionMessage { + INVALID_ID_FORMAT("아이디는 영문이 포함되어야 하며, 한글이나 특수문자는 사용할 수 없습니다.", 1_001), + INVALID_ID_NUMERIC_ONLY("아이디를 숫자만으로 구성할 수 없습니다. 영문을 포함해주세요.", 1_002), + DUPLICATE_ID_EXISTS("이미 사용 중인 아이디입니다.", 1_003), + INVALID_ID_LENGTH("아이디는 6글자 이상, 20글자 이하여야 합니다.", 1_004) + ; + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + /** + * 1_100 ~ 1_199: 비밀번호 관련 오류 + */ + + @AllArgsConstructor + public enum Password implements ExceptionMessage { + // 1. 기본 형식 검증 (Basic Format) + // 1-1. 길이 제한 (8~16자) + INVALID_PASSWORD_LENGTH("비밀번호는 8자 이상 16자 이하여야 합니다.", 1_101), + + // 메시지 수정: '모두 포함' -> '영문, 숫자, 특수문자만 사용 가능' + INVALID_PASSWORD_COMPOSITION("비밀번호는 영문, 숫자, 특수문자만 사용할 수 있으며 한글 등은 포함할 수 없습니다.", 1_102), + + // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + // 2-1, 2-2, 2-3 통합 검증 + PASSWORD_CONTAINS_BIRTHDATE("비밀번호에 생년월일 정보(YYYYMMDD 또는 YYMMDD)를 포함할 수 없습니다.", 1_103), + + // 3. 수정 시 정책 (Update Policy) + // 3-1. 재사용 금지 + PASSWORD_CANNOT_BE_SAME_AS_CURRENT("현재 사용 중인 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.", 1_104), + + PASSWORD_NOT_ENCODED("비밀번호가 암호화되지 않았습니다.", 1_105), + + PASSWORD_INCORRECT("비밀번호가 일치하지 않습니다.", 1_106) + + ; + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + /** + * 1_200 ~ 1_219: 이름(Name) 관련 오류 + */ + @AllArgsConstructor + public enum Name implements ExceptionMessage { + TOO_SHORT("이름은 최소 2자 이상이어야 합니다.", 1_201), + TOO_LONG("이름은 최대 40자까지 가능합니다.", 1_202), + CONTAINS_INVALID_CHAR("이름에 숫자나 특수문자를 포함할 수 없습니다. 한글과 영문만 가능합니다.", 1_203); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_220 ~ 1_239: 이메일(Email) 관련 오류 + */ + @AllArgsConstructor + public enum Email implements ExceptionMessage { + INVALID_FORMAT("유효하지 않은 이메일 형식입니다.", 1_221), + TOO_LONG("이메일은 255자를 초과할 수 없습니다.", 1_222); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_240 ~ 1_259: 생년월일(BirthDate) 관련 오류 + */ + @AllArgsConstructor + public enum BirthDate implements ExceptionMessage { + CANNOT_BE_FUTURE("생년월일은 미래 날짜일 수 없습니다.", 1_241); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_300 ~ 1_399: 회원(Member) 존재 여부 관련 오류 + */ + @AllArgsConstructor + public enum ExistsMember implements ExceptionMessage { + CANNOT_LOGIN("아이디나 비밀번호가 잘못됐습니다.", 1_301); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + +} diff --git a/domain/src/main/java/com/loopers/domain/member/MemberRepository.java b/domain/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..f06cecd27 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java b/domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java new file mode 100644 index 000000000..fb61bebc5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java @@ -0,0 +1,8 @@ +package com.loopers.domain.member; + +public interface PasswordEncryptor { + + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/domain/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java b/domain/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java new file mode 100644 index 000000000..3bfcca8f1 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java @@ -0,0 +1,93 @@ +package com.loopers.domain.member.policy; + +import com.loopers.domain.member.MemberExceptionMessage; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class MemberPolicy { + + public static class LoginId { + public static void validate(String loginId) { + // 1-4. 길이 제한 (6~20자) + if (loginId == null || loginId.length() < 6 || loginId.length() > 20) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + // 1-2. 숫자만 존재할 수 없음 + if (loginId.matches("^[0-9]*$")) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + // 1-1. 영문/숫자만 허용 및 영문 필수 포함 (한글, 특수문자 불가) + if (!loginId.matches("^[a-zA-Z0-9]*$") || !loginId.matches(".*[a-zA-Z].*")) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + } + } + + public static class Password { + public static void validate(String password, LocalDate birthDate) { + // 1-1. 길이 제한 (8~16자) + if (password == null || password.length() < 8 || password.length() > 16) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-2. 조합 규칙 수정: "한글 등 허용되지 않은 문자"가 포함되었는지만 체크 + // 영문, 숫자, 특수문자(@$!%*?&)만 허용하는 정규식으로 변경 (필수 포함 조건 삭제) + String allowedCharsRegex = "^[A-Za-z\\d@$!%*?&]*$"; + if (!password.matches(allowedCharsRegex)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + + // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + // 이 로직에 도달하기 전에 위 정규식에서 튕기지 않도록 테스트 데이터가 수정되거나 정규식이 유연해야 합니다. + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = yyyyMMdd.substring(2); + if (password.contains(yyyyMMdd) || password.contains(yyMMdd)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } + } + + public static class Name { + public static void validate(String name) { + // 이름 길이 체크 + if (name == null || name.length() < 2) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + if (name.length() > 40) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.TOO_LONG.message()); + } + // 한글/영문만 허용 (숫자, 특수문자 불가) + if (!name.matches("^[a-zA-Z가-힣\\s]*$")) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + } + } + + public static class Email { + public static void validate(String email) { + if (email == null) throw new IllegalArgumentException("이메일은 필수입니다."); + + // 길이 체크 + if (email.length() > 255) { + throw new IllegalArgumentException(MemberExceptionMessage.Email.TOO_LONG.message()); + } + // RFC 5321 기반 기본 형식 체크 + if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + throw new IllegalArgumentException(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + } + } + + public static class BirthDate { + public static void validate(LocalDate birthDate) { + if (birthDate == null) throw new IllegalArgumentException("생년월일은 필수입니다."); + + // 미래 날짜 불가 + if (birthDate.isAfter(LocalDate.now())) { + throw new IllegalArgumentException(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } + } + +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/Email.java b/domain/src/main/java/com/loopers/domain/member/vo/Email.java new file mode 100644 index 000000000..f304aabc5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,54 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + @Column(name = "email") + private String value; + + private Email(String value) { + this.value = value; + } + + public static Email of(String value) { + validate(value); + return new Email(value); + } + + private static void validate(String value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + if (value.length() > 255) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Email.TOO_LONG.message()); + } + if (!value.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/LoginId.java b/domain/src/main/java/com/loopers/domain/member/vo/LoginId.java new file mode 100644 index 000000000..93dee0baf --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/LoginId.java @@ -0,0 +1,54 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + + @Column(name = "login_id") + private String value; + + private LoginId(String value) { + this.value = value; + } + + public static LoginId of(String value) { + validate(value); + return new LoginId(value); + } + + private static void validate(String value) { + if (value == null || value.length() < 6 || value.length() > 20) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + if (value.matches("^[0-9]*$")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + if (!value.matches("^[a-zA-Z0-9]*$") || !value.matches(".*[a-zA-Z].*")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/MemberId.java b/domain/src/main/java/com/loopers/domain/member/vo/MemberId.java new file mode 100644 index 000000000..bfea4c5e9 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/MemberId.java @@ -0,0 +1,38 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Objects; + +public class MemberId { + + private final Long value; + + private MemberId(Long value) { + this.value = value; + } + + public static MemberId of(Long value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "MemberId는 null일 수 없습니다."); + } + return new MemberId(value); + } + + public Long getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberId memberId)) return false; + return Objects.equals(value, memberId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/MemberName.java b/domain/src/main/java/com/loopers/domain/member/vo/MemberName.java new file mode 100644 index 000000000..8d1f13b04 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/MemberName.java @@ -0,0 +1,54 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberName { + + @Column(name = "name") + private String value; + + private MemberName(String value) { + this.value = value; + } + + public static MemberName of(String value) { + validate(value); + return new MemberName(value); + } + + private static void validate(String value) { + if (value == null || value.length() < 2) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Name.TOO_SHORT.message()); + } + if (value.length() > 40) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Name.TOO_LONG.message()); + } + if (!value.matches("^[a-zA-Z가-힣\\s]*$")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/Password.java b/domain/src/main/java/com/loopers/domain/member/vo/Password.java new file mode 100644 index 000000000..b1ad05c57 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,82 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.PasswordEncryptor; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Password { + + private static final String ALLOWED_CHARS_REGEX = "^[A-Za-z\\d@$!%*?&]*$"; + + @Column(name = "password") + private String value; + + private Password(String encryptedValue) { + this.value = encryptedValue; + } + + public static Password of(String rawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateFormat(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + return new Password(encryptor.encode(rawPassword)); + } + + public boolean matches(String rawPassword, PasswordEncryptor encryptor) { + return encryptor.matches(rawPassword, this.value); + } + + public Password changeTo(String newRawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateChangeable(newRawPassword, birthDate, encryptor); + return Password.of(newRawPassword, birthDate, encryptor); + } + + private void validateChangeable(String newRawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + if (matches(newRawPassword, encryptor)) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + validateFormat(newRawPassword); + validateBirthDateNotContained(newRawPassword, birthDate); + } + + private static void validateFormat(String rawPassword) { + if (rawPassword == null || rawPassword.length() < 8 || rawPassword.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + if (!rawPassword.matches(ALLOWED_CHARS_REGEX)) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + } + + private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) { + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = yyyyMMdd.substring(2); + if (rawPassword.contains(yyyyMMdd) || rawPassword.contains(yyMMdd)) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(value, password.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/Order.java b/domain/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..a7cde5469 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,68 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseTimeEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "orders") +public class Order extends BaseTimeEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + private Order(Long memberId, OrderStatus status) { + this.memberId = memberId; + this.status = status; + } + + public static Order place(Long memberId, List orderLines, OrderStatus status) { + validateNotEmpty(orderLines); + validateNoDuplicateProducts(orderLines); + return new Order(memberId, status); + } + + public boolean isAccepted() { + return this.status == OrderStatus.ACCEPTED; + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + public List assignOrderLines(List orderLines) { + return orderLines.stream() + .map(line -> line.assignToOrder(this.getId())) + .toList(); + } + + private static void validateNotEmpty(List orderLines) { + if (orderLines == null || orderLines.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, + OrderExceptionMessage.Order.EMPTY_ORDER_LINES.message()); + } + } + + private static void validateNoDuplicateProducts(List orderLines) { + long distinctCount = orderLines.stream() + .map(OrderLine::getProductId) + .distinct() + .count(); + if (distinctCount != orderLines.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, + OrderExceptionMessage.Order.DUPLICATE_PRODUCT.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java b/domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java new file mode 100644 index 000000000..01a5bd502 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import lombok.AllArgsConstructor; + +public class OrderExceptionMessage { + + @AllArgsConstructor + public enum Order { + NOT_FOUND("존재하지 않는 주문입니다.", 4_001), + NOT_OWNER("본인의 주문이 아닙니다.", 4_002), + EMPTY_ORDER_LINES("주문할 상품을 선택해주세요.", 4_003), + DUPLICATE_PRODUCT("동일한 상품이 중복으로 포함되어 있습니다.", 4_004); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLine.java b/domain/src/main/java/com/loopers/domain/order/OrderLine.java new file mode 100644 index 000000000..b7ed1bf25 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLine.java @@ -0,0 +1,63 @@ +package com.loopers.domain.order; + +import com.loopers.domain.catalog.product.vo.Quantity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "order_line") +public class OrderLine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Embedded + private Quantity quantity; + + @Transient + private OrderLineSnapshot snapshot; + + private OrderLine(Long productId, Quantity quantity, OrderLineSnapshot snapshot) { + this.productId = productId; + this.quantity = quantity; + this.snapshot = snapshot; + } + + public static OrderLine of(Long productId, Quantity quantity, String productName, String productDescription, long price, String brandName) { + OrderLineSnapshot snapshot = OrderLineSnapshot.of(productName, productDescription, price, brandName); + return new OrderLine(productId, quantity, snapshot); + } + + public boolean belongsToProduct(Long productId) { + return this.productId.equals(productId); + } + + public boolean hasQuantity(long value) { + return this.quantity.isEqualTo(value); + } + + public boolean hasSnapshot() { + return this.snapshot != null; + } + + public OrderLine assignToOrder(Long orderId) { + this.orderId = orderId; + return this; + } + + public OrderLine assignSnapshot() { + this.snapshot.assignToOrderLine(this.id); + return this; + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java b/domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java new file mode 100644 index 000000000..151681cfe --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderLineRepository { + + OrderLine save(OrderLine orderLine); + + List saveAll(List orderLines); + + List findByOrderId(Long orderId); +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java new file mode 100644 index 000000000..e77cc897e --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java @@ -0,0 +1,47 @@ +package com.loopers.domain.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "order_line_snapshot") +public class OrderLineSnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_description") + private String productDescription; + + @Column(name = "price", nullable = false) + private long price; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "order_line_id", nullable = false) + private Long orderLineId; + + private OrderLineSnapshot(String productName, String productDescription, long price, String brandName) { + this.productName = productName; + this.productDescription = productDescription; + this.price = price; + this.brandName = brandName; + } + + public static OrderLineSnapshot of(String productName, String productDescription, long price, String brandName) { + return new OrderLineSnapshot(productName, productDescription, price, brandName); + } + + public void assignToOrderLine(Long orderLineId) { + this.orderLineId = orderLineId; + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java new file mode 100644 index 000000000..0420f3107 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderLineSnapshotRepository { + + OrderLineSnapshot save(OrderLineSnapshot snapshot); + + List saveAll(List snapshots); + + List findByOrderLineIdIn(List orderLineIds); +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderRepository.java b/domain/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..69350e0fe --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findByMemberId(Long memberId); + + List findAll(); +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderStatus.java b/domain/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..35376d082 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ACCEPTED, + REJECTED; + + public static OrderStatus determine(boolean allStockAvailable) { + return allStockAvailable ? ACCEPTED : REJECTED; + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java b/domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java new file mode 100644 index 000000000..6e5c56234 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ActiveProductServiceTest { + + @InjectMocks + private ActiveProductService activeProductService; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @Test + void 활성_상품_조회_성공() { + // given + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + Brand brand = Brand.register("나이키"); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(brandRepository.findById(10L)).willReturn(Optional.of(brand)); + + // when + Product result = activeProductService.get(1L); + + // then + assertThat(result.hasName("에어맥스")).isTrue(); + } + + @Test + void 상품_없으면_예외() { + // given + given(productRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> activeProductService.get(999L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + @Test + void 삭제된_상품이면_예외() { + // given + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + product.delete(); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + + // when & then + assertThatThrownBy(() -> activeProductService.get(1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + @Test + void 브랜드_삭제된_상품이면_예외() { + // given + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + Brand brand = Brand.register("나이키"); + brand.delete(); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(brandRepository.findById(10L)).willReturn(Optional.of(brand)); + + // when & then + assertThatThrownBy(() -> activeProductService.get(1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.UNAVAILABLE.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java b/domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java new file mode 100644 index 000000000..a9beb3a2d --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandDeleteServiceTest { + + @InjectMocks + private BrandDeleteService brandDeleteService; + + @Mock + private BrandRepository brandRepository; + + @Mock + private ProductRepository productRepository; + + @Test + void 브랜드_삭제_시_소속_상품_연쇄_삭제() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + + // when + brandDeleteService.delete(brandId); + + // then + verify(productRepository).softDeleteByBrandId(brandId); + } + + @Test + void 브랜드_삭제_시_브랜드_deletedAt_설정() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + + // when + brandDeleteService.delete(brandId); + + // then + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + void 존재하지_않는_브랜드_삭제_시_예외() { + // given + Long brandId = 999L; + given(brandRepository.findById(brandId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandDeleteService.delete(brandId)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java b/domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java new file mode 100644 index 000000000..2527264b7 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java @@ -0,0 +1,160 @@ +package com.loopers.domain.catalog.brand; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandTest { + + @Test + void 브랜드_등록_성공() { + // given + String name = "나이키"; + + // when + Brand brand = Brand.register(name); + + // then + assertThat(brand.hasName(name)).isTrue(); + } + + @Test + void 이름_1자_등록_성공() { + // when + Brand brand = Brand.register("A"); + + // then + assertThat(brand.hasName("A")).isTrue(); + } + + @Test + void 이름_100자_경계값_등록_성공() { + // given + String name = "a".repeat(100); + + // when + Brand brand = Brand.register(name); + + // then + assertThat(brand.hasName(name)).isTrue(); + } + + @Test + void 빈_이름으로_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Brand.register("")) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void null_이름으로_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Brand.register(null)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 공백만_있는_이름_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Brand.register(" ")) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 이름_길이_초과_시_예외() { + // given + String longName = "a".repeat(101); + + // when & then + assertThatThrownBy(() -> Brand.register(longName)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 이름_수정_성공() { + // given + Brand brand = Brand.register("나이키"); + + // when + brand.updateName("아디다스"); + + // then + assertThat(brand.hasName("아디다스")).isTrue(); + } + + @Test + void 수정_시_빈_이름_예외() { + // given + Brand brand = Brand.register("나이키"); + + // when & then + assertThatThrownBy(() -> brand.updateName("")) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 수정_시_이름_길이_초과_예외() { + // given + Brand brand = Brand.register("나이키"); + + // when & then + assertThatThrownBy(() -> brand.updateName("a".repeat(101))) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 삭제_시_deletedAt_설정() { + // given + Brand brand = Brand.register("나이키"); + + // when + brand.delete(); + + // then + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + void 삭제_시_이름에_deleted_접미사_추가() { + // given + Brand brand = Brand.register("나이키"); + + // when + brand.delete(); + + // then + assertThat(brand.hasNameStartingWith("나이키_deleted_")).isTrue(); + } + + @Test + void 이미_삭제된_브랜드_재삭제_시_예외() { + // given + Brand brand = Brand.register("나이키"); + brand.delete(); + + // when & then + assertThatThrownBy(brand::delete) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + + @Test + void 이미_삭제된_브랜드_수정_시_예외() { + // given + Brand brand = Brand.register("나이키"); + brand.delete(); + + // when & then + assertThatThrownBy(() -> brand.updateName("아디다스")) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java new file mode 100644 index 000000000..70fc4b9bd --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java @@ -0,0 +1,152 @@ +package com.loopers.domain.catalog.product; + +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + @Test + void 상품_등록_성공() { + // when + Product product = Product.register("티셔츠", "기본 티셔츠", Money.of(10000L), Stock.of(100L), 1L); + + // then + assertThat(product.hasName("티셔츠")).isTrue(); + } + + @Test + void 상품_등록_시_brandId_설정() { + // when + Product product = Product.register("티셔츠", "기본 티셔츠", Money.of(10000L), Stock.of(100L), 1L); + + // then + assertThat(product.belongsToBrand(1L)).isTrue(); + } + + @Test + void 상품_등록_시_description_null_허용() { + // when + Product product = Product.register("티셔츠", null, Money.of(10000L), Stock.of(100L), 1L); + + // then + assertThat(product.hasDescription()).isFalse(); + } + + @Test + void 빈_이름_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Product.register("", "설명", Money.of(10000L), Stock.of(100L), 1L)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 이름_길이_초과_시_예외() { + // when & then + assertThatThrownBy(() -> Product.register("a".repeat(101), "설명", Money.of(10000L), Stock.of(100L), 1L)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 상품_수정_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.update("맨투맨", "새 설명", Money.of(20000L), Stock.of(50L)); + + // then + assertThat(product.hasName("맨투맨")).isTrue(); + } + + @Test + void 재고_차감_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.decreaseStock(Quantity.of(30L)); + + // then + assertThat(product.hasStock(70L)).isTrue(); + } + + @Test + void 재고_부족_시_차감_예외() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(10L), 1L); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(Quantity.of(11L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Stock.INSUFFICIENT_STOCK.message()); + } + + @Test + void 삭제된_상품_수정_시_예외() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + product.delete(); + + // when & then + assertThatThrownBy(() -> product.update("맨투맨", "새 설명", Money.of(20000L), Stock.of(50L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + + @Test + void 좋아요수_증가_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.increaseLikesCount(); + + // then + assertThat(product.hasLikesCount(1L)).isTrue(); + } + + @Test + void 좋아요수_감소_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + product.increaseLikesCount(); + + // when + product.decreaseLikesCount(); + + // then + assertThat(product.hasLikesCount(0L)).isTrue(); + } + + @Test + void 좋아요수_0_미만_불가() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.decreaseLikesCount(); + + // then + assertThat(product.hasLikesCount(0L)).isTrue(); + } + + @Test + void 삭제된_상품_재고_차감_시_예외() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + product.delete(); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(Quantity.of(1L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java new file mode 100644 index 000000000..5af2e5ac5 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MoneyTest { + + @Test + void 가격_양수_생성_성공() { + // when + Money money = Money.of(10000L); + + // then + assertThat(money.getValue()).isEqualTo(10000L); + } + + @Test + void 가격_0_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Money.of(0L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Price.INVALID_PRICE.message()); + } + + @Test + void 가격_음수_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Money.of(-1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Price.INVALID_PRICE.message()); + } + + @Test + void 같은_값이면_동등하다() { + // given + Money money1 = Money.of(10000L); + Money money2 = Money.of(10000L); + + // then + assertThat(money1).isEqualTo(money2); + } + + @Test + void 다른_값이면_동등하지_않다() { + // given + Money money1 = Money.of(10000L); + Money money2 = Money.of(20000L); + + // then + assertThat(money1).isNotEqualTo(money2); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java new file mode 100644 index 000000000..0b78017be --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java @@ -0,0 +1,33 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class QuantityTest { + + @Test + void 수량_양수_생성_성공() { + // when + Quantity quantity = Quantity.of(10L); + + // then + assertThat(quantity.getValue()).isEqualTo(10L); + } + + @Test + void 수량_0_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Quantity.of(0L)) + .isInstanceOf(CoreException.class); + } + + @Test + void 수량_음수_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Quantity.of(-1L)) + .isInstanceOf(CoreException.class); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java new file mode 100644 index 000000000..8b25f5465 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java @@ -0,0 +1,100 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StockTest { + + @Test + void 재고_0이상_생성_성공() { + // when + Stock stock = Stock.of(100L); + + // then + assertThat(stock.getValue()).isEqualTo(100L); + } + + @Test + void 재고_0_생성_성공() { + // when + Stock stock = Stock.of(0L); + + // then + assertThat(stock.getValue()).isEqualTo(0L); + } + + @Test + void 재고_음수_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Stock.of(-1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Stock.INVALID_STOCK.message()); + } + + @Test + void 재고_차감_성공_새_객체_반환() { + // given + Stock stock = Stock.of(100L); + + // when + Stock decreased = stock.decrease(Quantity.of(30L)); + + // then + assertThat(decreased.getValue()).isEqualTo(70L); + } + + @Test + void 재고_차감_시_원본_불변() { + // given + Stock stock = Stock.of(100L); + + // when + stock.decrease(Quantity.of(30L)); + + // then + assertThat(stock.getValue()).isEqualTo(100L); + } + + @Test + void 재고_부족_시_차감_예외() { + // given + Stock stock = Stock.of(10L); + + // when & then + assertThatThrownBy(() -> stock.decrease(Quantity.of(11L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Stock.INSUFFICIENT_STOCK.message()); + } + + @Test + void 재고_충분_여부_확인_true() { + // given + Stock stock = Stock.of(100L); + + // then + assertThat(stock.isEnough(Quantity.of(100L))).isTrue(); + } + + @Test + void 재고_충분_여부_확인_false() { + // given + Stock stock = Stock.of(10L); + + // then + assertThat(stock.isEnough(Quantity.of(11L))).isFalse(); + } + + @Test + void 같은_값이면_동등하다() { + // given + Stock stock1 = Stock.of(100L); + Stock stock2 = Stock.of(100L); + + // then + assertThat(stock1).isEqualTo(stock2); + } +} diff --git a/domain/src/test/java/com/loopers/domain/like/LikeTest.java b/domain/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..0e0c7b700 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeTest { + + @Test + void 본인_확인_성공() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isOwnedBy(1L)).isTrue(); + } + + @Test + void 본인_아니면_false() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isOwnedBy(99L)).isFalse(); + } + + @Test + void 대상_확인_성공() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isForSubject(LikeSubjectType.PRODUCT, 100L)).isTrue(); + } + + @Test + void 대상_아니면_false() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isForSubject(LikeSubjectType.PRODUCT, 999L)).isFalse(); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/MemberTest.java b/domain/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..52bffb024 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,87 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.*; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class MemberTest { + + private final PasswordEncryptor encryptor = new FakePasswordEncryptor(); + + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(2001, 2, 9); + + private LoginId validLoginId() { + return LoginId.of("hello1234"); + } + + private Password validPassword() { + return Password.of("Password1!", VALID_BIRTH_DATE, encryptor); + } + + private MemberName validName() { + return MemberName.of("홍길동"); + } + + private Email validEmail() { + return Email.of("test@example.com"); + } + + @Test + void 회원가입_성공() { + // given + + // when + + // then + assertDoesNotThrow(() -> + Member.register(validLoginId(), validPassword(), validName(), VALID_BIRTH_DATE, validEmail()) + ); + } + + @Nested + class 생년월일_유효성_검증 { + + @Test + void 미래_날짜는_생년월일_등록_불가() { + // given + LocalDate futureDate = LocalDate.now().plusDays(1); + + // when + + // then + assertThatThrownBy(() -> Member.register(validLoginId(), validPassword(), validName(), futureDate, validEmail())) + .hasMessage(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } + + @Nested + class 비밀번호_수정_정책_검증 { + + @Test + void 새_비밀번호가_현재_비밀번호와_같으면_예외() { + // given + String currentPassword = "oldPassword1!"; + Password password = Password.of(currentPassword, VALID_BIRTH_DATE, encryptor); + Member member = Member.register(validLoginId(), password, validName(), VALID_BIRTH_DATE, validEmail()); + + // when & then + assertThatThrownBy(() -> member.updatePassword(currentPassword, encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + void 새_비밀번호에_생년월일이_포함되면_예외() { + // given + Member member = Member.register(validLoginId(), validPassword(), validName(), VALID_BIRTH_DATE, validEmail()); + + // when & then + assertThatThrownBy(() -> member.updatePassword("pass20010209!", encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java b/domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java new file mode 100644 index 000000000..920a0fac5 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,45 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class EmailTest { + + @Test + void 유효한_이메일로_생성_성공() { + // given + String validEmail = "test@example.com"; + + // when + + // then + assertDoesNotThrow(() -> Email.of(validEmail)); + } + + @Test + void 이메일_기본_형식을_준수해야_함() { + // given + String wrongEmail = "test#example.com"; + + // when + + // then + assertThatThrownBy(() -> Email.of(wrongEmail)) + .hasMessage(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + + @Test + void 이메일은_255자를_초과할_수_없음() { + // given + String longEmail = "a".repeat(250) + "@test.com"; + + // when + + // then + assertThatThrownBy(() -> Email.of(longEmail)) + .hasMessage(MemberExceptionMessage.Email.TOO_LONG.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java b/domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java new file mode 100644 index 000000000..1b4d22afa --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java @@ -0,0 +1,81 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class LoginIdTest { + + @Test + void 유효한_아이디로_생성_성공() { + // given + String validId = "hello1234"; + + // when + + // then + assertDoesNotThrow(() -> LoginId.of(validId)); + } + + @Test + void 아이디는_영문이_아닌_한글이_들어갈_수_없음() { + // given + String wrongId = "한글입slek"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + + @Test + void 아이디는_영문이_아닌_특수문자가_들어갈_수_없음() { + // given + String wrongId = "@apgl!#"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + + @Test + void 아이디는_숫자만_존재할_수_없음() { + // given + String wrongId = "12345678"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + + @Test + void 아이디의_길이_6자_미만_불가() { + // given + String wrongId = "ap245"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + + @Test + void 아이디의_길이_20자_초과_불가() { + // given + String wrongId = "apapeisname1234ppap56"; // 21글자 + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java b/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java new file mode 100644 index 000000000..ea0e8d04b --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java @@ -0,0 +1,40 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberIdTest { + + @Test + void null_값으로_생성_시_예외() { + // given + Long value = null; + + // when & then + assertThatThrownBy(() -> MemberId.of(value)) + .isInstanceOf(CoreException.class); + } + + @Test + void 같은_값이면_동등하다() { + // given + MemberId id1 = MemberId.of(1L); + MemberId id2 = MemberId.of(1L); + + // when & then + assertThat(id1).isEqualTo(id2); + } + + @Test + void 다른_값이면_동등하지_않다() { + // given + MemberId id1 = MemberId.of(1L); + MemberId id2 = MemberId.of(2L); + + // when & then + assertThat(id1).isNotEqualTo(id2); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java b/domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java new file mode 100644 index 000000000..9ee3b1d0c --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class MemberNameTest { + + @Test + void 유효한_이름으로_생성_성공() { + // given + String validName = "홍길동"; + + // when + + // then + assertDoesNotThrow(() -> MemberName.of(validName)); + } + + @Test + void 이름은_2자_미만일_수_없음() { + // given + String shortName = "홍"; + + // when + + // then + assertThatThrownBy(() -> MemberName.of(shortName)) + .hasMessage(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + + @Test + void 이름은_40자를_초과할_수_없음() { + // given + String longName = "가".repeat(41); + + // when + + // then + assertThatThrownBy(() -> MemberName.of(longName)) + .hasMessage(MemberExceptionMessage.Name.TOO_LONG.message()); + } + + @Test + void 이름에_숫자가_포함될_수_없음() { + // given + String nameWithDigit = "홍길동1"; + + // when + + // then + assertThatThrownBy(() -> MemberName.of(nameWithDigit)) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + + @Test + void 이름에_특수문자가_포함될_수_없음() { + // given + String nameWithSpecial = "John@"; + + // when + + // then + assertThatThrownBy(() -> MemberName.of(nameWithSpecial)) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java b/domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java new file mode 100644 index 000000000..4e6780e51 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.FakePasswordEncryptor; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.PasswordEncryptor; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class PasswordTest { + + private final PasswordEncryptor encryptor = new FakePasswordEncryptor(); + private final LocalDate birthDate = LocalDate.of(2001, 2, 9); + + @Test + void 유효한_비밀번호로_생성_성공() { + // given + String validPassword = "Password1!"; + + // when + + // then + assertDoesNotThrow(() -> Password.of(validPassword, birthDate, encryptor)); + } + + @Test + void 비밀번호_길이는_8자_미만일_수_없음() { + // given + String shortPassword = "pap1234"; // 7글자 + + // when + + // then + assertThatThrownBy(() -> Password.of(shortPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + @Test + void 비밀번호_길이는_16자_초과일_수_없음() { + // given + String longPassword = "qwer1234tyui5678a"; // 17글자 + + // when + + // then + assertThatThrownBy(() -> Password.of(longPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + @Test + void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() { + // given + String wrongPassword = "한글password123"; + + // when + + // then + assertThatThrownBy(() -> Password.of(wrongPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + + @Test + void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() { + // given + String wrongPassword = "pwd20010209!"; + + // when + + // then + assertThatThrownBy(() -> Password.of(wrongPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @Test + void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() { + // given + String wrongPassword = "pass010209!"; + + // when + + // then + assertThatThrownBy(() -> Password.of(wrongPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java b/domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java new file mode 100644 index 000000000..4fb74ea75 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderLineSnapshotTest { + + @Test + void 스냅샷_생성_성공() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(snapshot.getProductName()).isEqualTo("에어맥스"); + } + + @Test + void 스냅샷_생성_시_가격_보존() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(snapshot.getPrice()).isEqualTo(100000L); + } + + @Test + void 스냅샷_생성_시_브랜드명_보존() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(snapshot.getBrandName()).isEqualTo("나이키"); + } + + @Test + void 스냅샷_description_null_허용() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", null, 100000L, "나이키"); + + // then + assertThat(snapshot.getProductDescription()).isNull(); + } +} diff --git a/domain/src/test/java/com/loopers/domain/order/OrderLineTest.java b/domain/src/test/java/com/loopers/domain/order/OrderLineTest.java new file mode 100644 index 000000000..7552363db --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/order/OrderLineTest.java @@ -0,0 +1,36 @@ +package com.loopers.domain.order; + +import com.loopers.domain.catalog.product.vo.Quantity; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderLineTest { + + @Test + void 주문항목_생성_성공_상품ID() { + // when + OrderLine line = OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(line.belongsToProduct(1L)).isTrue(); + } + + @Test + void 주문항목_생성_성공_수량() { + // when + OrderLine line = OrderLine.of(1L, Quantity.of(3L), "에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(line.hasQuantity(3L)).isTrue(); + } + + @Test + void 주문항목_생성_시_스냅샷_자동_생성() { + // when + OrderLine line = OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(line.hasSnapshot()).isTrue(); + } +} diff --git a/domain/src/test/java/com/loopers/domain/order/OrderTest.java b/domain/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..cbfcaeacb --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.order; + +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + @Test + void 수락_주문() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when + Order order = Order.place(10L, lines, OrderStatus.ACCEPTED); + + // then + assertThat(order.isAccepted()).isTrue(); + } + + @Test + void 거절_주문() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when + Order order = Order.place(10L, lines, OrderStatus.REJECTED); + + // then + assertThat(order.isAccepted()).isFalse(); + } + + @Test + void 단일_상품_주문_성공() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when & then + assertThat(Order.place(10L, lines, OrderStatus.ACCEPTED).isAccepted()).isTrue(); + } + + @Test + void 다중_상품_주문_성공() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"), + OrderLine.of(2L, Quantity.of(1L), "조던", "설명2", 200000L, "나이키"), + OrderLine.of(3L, Quantity.of(3L), "뉴발란스 993", "설명3", 150000L, "뉴발란스") + ); + + // when & then + assertThat(Order.place(10L, lines, OrderStatus.ACCEPTED).isAccepted()).isTrue(); + } + + @Test + void 본인_확인_성공() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + Order order = Order.place(10L, lines, OrderStatus.ACCEPTED); + + // when & then + assertThat(order.isOwnedBy(10L)).isTrue(); + } + + @Test + void 본인_아니면_false() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + Order order = Order.place(10L, lines, OrderStatus.ACCEPTED); + + // when & then + assertThat(order.isOwnedBy(99L)).isFalse(); + } + + @Test + void 빈_주문_예외() { + // when & then + assertThatThrownBy(() -> Order.place(10L, List.of(), OrderStatus.ACCEPTED)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.EMPTY_ORDER_LINES.message()); + } + + @Test + void 중복_상품_예외() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"), + OrderLine.of(1L, Quantity.of(1L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when & then + assertThatThrownBy(() -> Order.place(10L, lines, OrderStatus.ACCEPTED)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.DUPLICATE_PRODUCT.message()); + } +} diff --git a/domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java new file mode 100644 index 000000000..44db8c5e6 --- /dev/null +++ b/domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -0,0 +1,34 @@ +package com.loopers.support.error; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CoreExceptionTest { + @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") + @Test + void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + // arrange + ErrorType[] errorTypes = ErrorType.values(); + + // act & assert + for (ErrorType errorType : errorTypes) { + CoreException exception = new CoreException(errorType); + assertThat(exception.getMessage()).isEqualTo(errorType.getMessage()); + } + } + + @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.") + @Test + void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + // arrange + String customMessage = "custom message"; + + // act + CoreException exception = new CoreException(ErrorType.INTERNAL_ERROR, customMessage); + + // assert + assertThat(exception.getMessage()).isEqualTo(customMessage); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java b/domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java new file mode 100644 index 000000000..88f5f6e8b --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java @@ -0,0 +1,14 @@ +package com.loopers.domain.catalog.brand; + +public class BrandFixture { + + public static final String DEFAULT_NAME = "나이키"; + + public static Brand create() { + return Brand.register(DEFAULT_NAME); + } + + public static Brand create(String name) { + return Brand.register(name); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java b/domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java new file mode 100644 index 000000000..1fa9c7992 --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java @@ -0,0 +1,18 @@ +package com.loopers.domain.catalog.product; + +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; + +public class ProductFixture { + + public static final String DEFAULT_NAME = "기본 티셔츠"; + public static final Long DEFAULT_BRAND_ID = 1L; + + public static Product create() { + return Product.register(DEFAULT_NAME, "상품 설명", Money.of(10000L), Stock.of(100L), DEFAULT_BRAND_ID); + } + + public static Product create(String name, Long brandId) { + return Product.register(name, "상품 설명", Money.of(10000L), Stock.of(100L), brandId); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java b/domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java new file mode 100644 index 000000000..06b4ab745 --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java @@ -0,0 +1,15 @@ +package com.loopers.domain.like; + +public class LikeFixture { + + public static final Long DEFAULT_MEMBER_ID = 1L; + public static final Long DEFAULT_SUBJECT_ID = 100L; + + public static Like create() { + return Like.mark(DEFAULT_MEMBER_ID, LikeSubjectType.PRODUCT, DEFAULT_SUBJECT_ID); + } + + public static Like create(Long memberId, Long subjectId) { + return Like.mark(memberId, LikeSubjectType.PRODUCT, subjectId); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java b/domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java new file mode 100644 index 000000000..0a5930388 --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java @@ -0,0 +1,15 @@ +package com.loopers.domain.member; + + +public class FakePasswordEncryptor implements PasswordEncryptor { + + @Override + public String encode(String rawPassword) { + return rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return rawPassword.equals(encodedPassword); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java b/domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java new file mode 100644 index 000000000..bde0a18ae --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java @@ -0,0 +1,43 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.*; + +import java.time.LocalDate; + +public class MemberFixture { + + private static final PasswordEncryptor ENCRYPTOR = new FakePasswordEncryptor(); + + public static final String DEFAULT_RAW_PASSWORD = "Password1!"; + public static final LocalDate DEFAULT_BIRTH_DATE = LocalDate.of(2001, 2, 9); + + public static Member create() { + return Member.register( + LoginId.of("hello1234"), + Password.of(DEFAULT_RAW_PASSWORD, DEFAULT_BIRTH_DATE, ENCRYPTOR), + MemberName.of("홍길동"), + DEFAULT_BIRTH_DATE, + Email.of("test@example.com") + ); + } + + public static Member create(LoginId loginId) { + return Member.register( + loginId, + Password.of(DEFAULT_RAW_PASSWORD, DEFAULT_BIRTH_DATE, ENCRYPTOR), + MemberName.of("홍길동"), + DEFAULT_BIRTH_DATE, + Email.of("test@example.com") + ); + } + + public static Member create(LoginId loginId, Password password) { + return Member.register( + loginId, + password, + MemberName.of("홍길동"), + DEFAULT_BIRTH_DATE, + Email.of("test@example.com") + ); + } +} diff --git a/modules/jpa/build.gradle.kts b/infrastructure/jpa/build.gradle.kts similarity index 95% rename from modules/jpa/build.gradle.kts rename to infrastructure/jpa/build.gradle.kts index 941d8c272..e823af645 100644 --- a/modules/jpa/build.gradle.kts +++ b/infrastructure/jpa/build.gradle.kts @@ -4,6 +4,8 @@ plugins { } dependencies { + // domain + api(project(":domain")) // jpa api("org.springframework.boot:spring-boot-starter-data-jpa") // querydsl diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java b/infrastructure/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java rename to infrastructure/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/infrastructure/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java rename to infrastructure/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java b/infrastructure/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java rename to infrastructure/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..a0758f054 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.catalog.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BrandJpaRepository extends JpaRepository { + + boolean existsByName_Value(String name); + + List findAllByDeletedAtIsNull(); + + List findAllByIdIn(List ids); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..58a728748 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public boolean existsByName(String name) { + return brandJpaRepository.existsByName_Value(name); + } + + @Override + public List findAllByDeletedAtIsNull() { + return brandJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAll() { + return brandJpaRepository.findAll(); + } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..d51a4f56f --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeSubjectType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + Optional findByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + List findByMemberIdAndSubjectType(Long memberId, LikeSubjectType subjectType); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..535564c7a --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeSubjectType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public boolean existsByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId) { + return likeJpaRepository.existsByMemberIdAndSubjectTypeAndSubjectId(memberId, subjectType, subjectId); + } + + @Override + public Optional findByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId) { + return likeJpaRepository.findByMemberIdAndSubjectTypeAndSubjectId(memberId, subjectType, subjectId); + } + + @Override + public List findByMemberIdAndSubjectType(Long memberId, LikeSubjectType subjectType) { + return likeJpaRepository.findByMemberIdAndSubjectType(memberId, subjectType); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..95fe7e2a1 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + boolean existsByLoginId_Value(String loginId); + + Optional findByLoginId_Value(String loginId); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..906ee7654 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId_Value(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId_Value(loginId); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..4d6104e80 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findByMemberId(Long memberId); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java new file mode 100644 index 000000000..9216b5e23 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLine; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderLineJpaRepository extends JpaRepository { + + List findByOrderId(Long orderId); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java new file mode 100644 index 000000000..e943138a0 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLine; +import com.loopers.domain.order.OrderLineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OrderLineRepositoryImpl implements OrderLineRepository { + + private final OrderLineJpaRepository orderLineJpaRepository; + + @Override + public OrderLine save(OrderLine orderLine) { + return orderLineJpaRepository.save(orderLine); + } + + @Override + public List saveAll(List orderLines) { + return orderLineJpaRepository.saveAll(orderLines); + } + + @Override + public List findByOrderId(Long orderId) { + return orderLineJpaRepository.findByOrderId(orderId); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java new file mode 100644 index 000000000..6287b22cf --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLineSnapshot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderLineSnapshotJpaRepository extends JpaRepository { + + List findByOrderLineIdIn(List orderLineIds); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java new file mode 100644 index 000000000..f90f8d739 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLineSnapshot; +import com.loopers.domain.order.OrderLineSnapshotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OrderLineSnapshotRepositoryImpl implements OrderLineSnapshotRepository { + + private final OrderLineSnapshotJpaRepository orderLineSnapshotJpaRepository; + + @Override + public OrderLineSnapshot save(OrderLineSnapshot snapshot) { + return orderLineSnapshotJpaRepository.save(snapshot); + } + + @Override + public List saveAll(List snapshots) { + return orderLineSnapshotJpaRepository.saveAll(snapshots); + } + + @Override + public List findByOrderLineIdIn(List orderLineIds) { + return orderLineSnapshotJpaRepository.findByOrderLineIdIn(orderLineIds); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..a974d7134 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findByMemberId(Long memberId) { + return orderJpaRepository.findByMemberId(memberId); + } + + @Override + public List findAll() { + return orderJpaRepository.findAll(); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..5420afd57 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.catalog.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + + List findAllByIdIn(List ids); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..0e362cc09 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,73 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.domain.catalog.product.QProduct; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + private static final QProduct product = QProduct.product; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAllActive(ProductSortType sortType) { + return queryFactory + .selectFrom(product) + .where(product.deletedAt.isNull()) + .orderBy(toOrderSpecifier(sortType)) + .fetch(); + } + + @Override + public List findAll() { + return productJpaRepository.findAll(); + } + + @Override + public List findAllByIdIn(List ids) { + return productJpaRepository.findAllByIdIn(ids); + } + + @Override + public void softDeleteByBrandId(Long brandId) { + queryFactory + .update(product) + .set(product.deletedAt, ZonedDateTime.now()) + .where( + product.brandId.eq(brandId), + product.deletedAt.isNull() + ) + .execute(); + } + + private OrderSpecifier toOrderSpecifier(ProductSortType sortType) { + return switch (sortType) { + case LATEST -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case LIKES_DESC -> product.likesCount.desc(); + }; + } +} diff --git a/modules/jpa/src/main/resources/jpa.yml b/infrastructure/jpa/src/main/resources/jpa.yml similarity index 100% rename from modules/jpa/src/main/resources/jpa.yml rename to infrastructure/jpa/src/main/resources/jpa.yml diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/infrastructure/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java similarity index 100% rename from modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java rename to infrastructure/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/infrastructure/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java similarity index 100% rename from modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java rename to infrastructure/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java diff --git a/modules/kafka/build.gradle.kts b/infrastructure/kafka/build.gradle.kts similarity index 100% rename from modules/kafka/build.gradle.kts rename to infrastructure/kafka/build.gradle.kts diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/infrastructure/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java similarity index 99% rename from modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java rename to infrastructure/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java index a73842775..ce5b10871 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/infrastructure/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java @@ -1,4 +1,4 @@ -package com.loopers.confg.kafka; +package com.loopers.config.kafka; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; diff --git a/modules/kafka/src/main/resources/kafka.yml b/infrastructure/kafka/src/main/resources/kafka.yml similarity index 100% rename from modules/kafka/src/main/resources/kafka.yml rename to infrastructure/kafka/src/main/resources/kafka.yml diff --git a/modules/redis/build.gradle.kts b/infrastructure/redis/build.gradle.kts similarity index 100% rename from modules/redis/build.gradle.kts rename to infrastructure/redis/build.gradle.kts diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java b/infrastructure/redis/src/main/java/com/loopers/config/redis/RedisConfig.java similarity index 100% rename from modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java rename to infrastructure/redis/src/main/java/com/loopers/config/redis/RedisConfig.java diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java b/infrastructure/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java similarity index 100% rename from modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java rename to infrastructure/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java b/infrastructure/redis/src/main/java/com/loopers/config/redis/RedisProperties.java similarity index 100% rename from modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java rename to infrastructure/redis/src/main/java/com/loopers/config/redis/RedisProperties.java diff --git a/modules/redis/src/main/resources/redis.yml b/infrastructure/redis/src/main/resources/redis.yml similarity index 100% rename from modules/redis/src/main/resources/redis.yml rename to infrastructure/redis/src/main/resources/redis.yml diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/infrastructure/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java similarity index 100% rename from modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java rename to infrastructure/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java diff --git a/modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java b/infrastructure/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java similarity index 100% rename from modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java rename to infrastructure/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java diff --git a/infrastructure/security/build.gradle.kts b/infrastructure/security/build.gradle.kts new file mode 100644 index 000000000..bdc106f69 --- /dev/null +++ b/infrastructure/security/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":domain")) + implementation("org.springframework.security:spring-security-crypto") +} diff --git a/infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java b/infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java new file mode 100644 index 000000000..77d390d26 --- /dev/null +++ b/infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.security; + +import com.loopers.domain.member.PasswordEncryptor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BCryptPasswordEncryptor implements PasswordEncryptor { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return encoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encoder.matches(rawPassword, encodedPassword); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java deleted file mode 100644 index d15a9c764..000000000 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.loopers.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import lombok.Getter; -import java.time.ZonedDateTime; - -/** - * 생성/수정/삭제 정보를 자동으로 관리해준다. - * 재사용성을 위해 이 외의 컬럼이나 동작은 추가하지 않는다. - */ -@MappedSuperclass -@Getter -public abstract class BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private final Long id = 0L; - - @Column(name = "created_at", nullable = false, updatable = false) - private ZonedDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private ZonedDateTime updatedAt; - - @Column(name = "deleted_at") - private ZonedDateTime deletedAt; - - /** - * 엔티티의 유효성을 검증한다. - * 이 메소드는 PrePersist 및 PreUpdate 시점에 호출된다. - */ - protected void guard() {} - - @PrePersist - private void prePersist() { - guard(); - - ZonedDateTime now = ZonedDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - private void preUpdate() { - guard(); - - this.updatedAt = ZonedDateTime.now(); - } - - /** - * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) - */ - public void delete() { - if (this.deletedAt == null) { - this.deletedAt = ZonedDateTime.now(); - } - } - - /** - * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) - */ - public void restore() { - if (this.deletedAt != null) { - this.deletedAt = null; - } - } -} diff --git a/presentation/commerce-api/build.gradle.kts b/presentation/commerce-api/build.gradle.kts new file mode 100644 index 000000000..f8e09ddd9 --- /dev/null +++ b/presentation/commerce-api/build.gradle.kts @@ -0,0 +1,28 @@ +apply(plugin = "org.springframework.boot") + +dependencies { + // layers + implementation(project(":domain")) + implementation(project(":application:commerce-service")) + implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:redis")) + implementation(project(":infrastructure:security")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":domain"))) + testImplementation(testFixtures(project(":infrastructure:jpa"))) + testImplementation(testFixtures(project(":infrastructure:redis"))) +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java new file mode 100644 index 000000000..9027b51bf --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -0,0 +1,22 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceApiApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(CommerceApiApplication.class, args); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java new file mode 100644 index 000000000..ce6d3ead0 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.example; + +import com.loopers.domain.example.ExampleModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExampleJpaRepository extends JpaRepository {} diff --git a/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java new file mode 100644 index 000000000..37f2272f0 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ExampleRepositoryImpl implements ExampleRepository { + private final ExampleJpaRepository exampleJpaRepository; + + @Override + public Optional find(Long id) { + return exampleJpaRepository.findById(id); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java new file mode 100644 index 000000000..a926b7d24 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -0,0 +1,139 @@ +package com.loopers.interfaces.api; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +public class ApiControllerAdvice { + @ExceptionHandler + public ResponseEntity> handle(CoreException e) { + log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); + return failureResponse(e.getErrorType(), e.getCustomMessage()); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { + String name = e.getName(); + String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown"; + String value = e.getValue() != null ? e.getValue().toString() : "null"; + String message = String.format("요청 파라미터 '%s' (타입: %s)의 값 '%s'이(가) 잘못되었습니다.", name, type, value); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { + String name = e.getParameterName(); + String type = e.getParameterType(); + String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { + String errorMessage; + Throwable rootCause = e.getRootCause(); + + if (rootCause instanceof InvalidFormatException invalidFormat) { + String fieldName = invalidFormat.getPath().stream() + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); + + String valueIndicationMessage = ""; + if (invalidFormat.getTargetType().isEnum()) { + Class enumClass = invalidFormat.getTargetType(); + String enumValues = Arrays.stream(enumClass.getEnumConstants()) + .map(Object::toString) + .collect(Collectors.joining(", ")); + valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]"; + } + + String expectedType = invalidFormat.getTargetType().getSimpleName(); + Object value = invalidFormat.getValue(); + + errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s", + fieldName, value, expectedType, valueIndicationMessage); + + } else if (rootCause instanceof MismatchedInputException mismatchedInput) { + String fieldPath = mismatchedInput.getPath().stream() + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); + errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath); + + } else if (rootCause instanceof JsonMappingException jsonMapping) { + String fieldPath = jsonMapping.getPath().stream() + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); + errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s", + fieldPath, jsonMapping.getOriginalMessage()); + + } else { + errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."; + } + + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(ServerWebInputException e) { + String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); + if (!missingParams.isEmpty()) { + String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams); + return failureResponse(ErrorType.BAD_REQUEST, message); + } else { + return failureResponse(ErrorType.BAD_REQUEST, null); + } + } + + @ExceptionHandler + public ResponseEntity> handleNotFound(NoResourceFoundException e) { + return failureResponse(ErrorType.NOT_FOUND, null); + } + + @ExceptionHandler + public ResponseEntity> handle(Throwable e) { + log.error("Exception : {}", e.getMessage(), e); + return failureResponse(ErrorType.INTERNAL_ERROR, null); + } + + private String extractMissingParameter(String message) { + Pattern pattern = Pattern.compile("'(.+?)'"); + Matcher matcher = pattern.matcher(message); + return matcher.find() ? matcher.group(1) : ""; + } + + private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { + HttpStatus httpStatus = toHttpStatus(errorType); + return ResponseEntity.status(httpStatus) + .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); + } + + private HttpStatus toHttpStatus(ErrorType errorType) { + return switch (errorType) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case FORBIDDEN -> HttpStatus.FORBIDDEN; + case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java new file mode 100644 index 000000000..33b77b529 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api; + +public record ApiResponse(Metadata meta, T data) { + public record Metadata(Result result, String errorCode, String message) { + public enum Result { + SUCCESS, FAIL + } + + public static Metadata success() { + return new Metadata(Result.SUCCESS, null, null); + } + + public static Metadata fail(String errorCode, String errorMessage) { + return new Metadata(Result.FAIL, errorCode, errorMessage); + } + } + + public static ApiResponse success() { + return new ApiResponse<>(Metadata.success(), null); + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(Metadata.success(), data); + } + + public static ApiResponse fail(String errorCode, String errorMessage) { + return new ApiResponse<>( + Metadata.fail(errorCode, errorMessage), + null + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java new file mode 100644 index 000000000..f55526ea1 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java @@ -0,0 +1,56 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.service.BrandService; +import com.loopers.interfaces.api.brand.dto.BrandApiResponse; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.brand.dto.BrandUpdateApiRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 브랜드 관리 API (관리자) + */ +@RestController +@RequestMapping("/api/admin/brands") +@RequiredArgsConstructor +public class AdminBrandController { + + private final BrandService brandService; + + /** 브랜드 생성 */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void create(@RequestBody BrandCreateApiRequest request) { + brandService.create(request.toCommand()); + } + + /** 브랜드 단건 조회 */ + @GetMapping("/{id}") + public BrandApiResponse getById(@PathVariable Long id) { + return BrandApiResponse.from(brandService.getById(id)); + } + + /** 브랜드 전체 조회 */ + @GetMapping + public List getAll() { + return brandService.getAll().stream() + .map(BrandApiResponse::from) + .toList(); + } + + /** 브랜드 수정 */ + @PutMapping("/{id}") + public void update(@PathVariable Long id, @RequestBody BrandUpdateApiRequest request) { + brandService.update(id, request.toCommand()); + } + + /** 브랜드 삭제 */ + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + brandService.delete(id); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 000000000..541d5addf --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.service.BrandService; +import com.loopers.interfaces.api.brand.dto.BrandApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 브랜드 API (사용자) + */ +@RestController +@RequestMapping("/api/brands") +@RequiredArgsConstructor +public class BrandController { + + private final BrandService brandService; + + /** 활성 브랜드 목록 조회 */ + @GetMapping + public List getActiveBrands() { + return brandService.getActiveBrands().stream() + .map(BrandApiResponse::from) + .toList(); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java new file mode 100644 index 000000000..16ccbba50 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java @@ -0,0 +1,12 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.service.dto.BrandInfo; + +public record BrandApiResponse( + Long id, + String name +) { + public static BrandApiResponse from(BrandInfo info) { + return new BrandApiResponse(info.id(), info.name()); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java new file mode 100644 index 000000000..06c67ab8b --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.service.dto.BrandCreateCommand; + +public record BrandCreateApiRequest( + String name +) { + public BrandCreateCommand toCommand() { + return new BrandCreateCommand(name); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java new file mode 100644 index 000000000..523544198 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.service.dto.BrandUpdateCommand; + +public record BrandUpdateApiRequest( + String name +) { + public BrandUpdateCommand toCommand() { + return new BrandUpdateCommand(name); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java new file mode 100644 index 000000000..219e3101e --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") +public interface ExampleV1ApiSpec { + + @Operation( + summary = "예시 조회", + description = "ID로 예시를 조회합니다." + ) + ApiResponse getExample( + @Schema(name = "예시 ID", description = "조회할 예시의 ID") + Long exampleId + ); +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java new file mode 100644 index 000000000..917376016 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleFacade; +import com.loopers.application.example.ExampleInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/examples") +public class ExampleV1Controller implements ExampleV1ApiSpec { + + private final ExampleFacade exampleFacade; + + @GetMapping("/{exampleId}") + @Override + public ApiResponse getExample( + @PathVariable(value = "exampleId") Long exampleId + ) { + ExampleInfo info = exampleFacade.getExample(exampleId); + ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java new file mode 100644 index 000000000..4ecf0eea5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleInfo; + +public class ExampleV1Dto { + public record ExampleResponse(Long id, String name, String description) { + public static ExampleResponse from(ExampleInfo info) { + return new ExampleResponse( + info.id(), + info.name(), + info.description() + ); + } + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 000000000..6313e2144 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.service.LikeService; +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.LikeRegisterCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.interfaces.api.product.dto.ProductApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 좋아요 API + */ +@RestController +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + private final MemberService memberService; + + /** 좋아요 등록 */ + @PostMapping("/api/products/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + public void like( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + likeService.like(new LikeRegisterCommand(member.memberId(), productId)); + } + + /** 좋아요 취소 */ + @DeleteMapping("/api/products/{productId}/likes") + public void unlike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + likeService.unlike(member.memberId(), productId); + } + + /** 내 좋아요 목록 조회 */ + @GetMapping("/api/likes") + public List getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return likeService.getMyLikes(member.memberId()).stream() + .map(ProductApiResponse::from) + .toList(); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java new file mode 100644 index 000000000..b4fe0b60b --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.PasswordUpdateCommand; +import com.loopers.interfaces.api.member.dto.MemberApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +/** + * 회원 API + */ +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** 회원 가입 */ + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public void register(@RequestBody MemberRegisterCommand request) { + memberService.register(request); + } + + /** 내 정보 조회 */ + @GetMapping("/me") + public MemberApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + return MemberApiResponse.from(memberService.getMyInfo(loginId, password)); + } + + /** 비밀번호 변경 */ + @PatchMapping("/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @RequestBody PasswordUpdateCommand request + ) { + memberService.updatePassword(loginId, currentPassword, request.newPassword()); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java new file mode 100644 index 000000000..7b52ec2c7 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member.dto; + +import com.loopers.application.service.dto.MemberInfo; + +import java.time.LocalDate; + +public record MemberApiResponse( + String loginId, + String name, + LocalDate birthdate, + String email +) { + + public static MemberApiResponse from(MemberInfo info) { + return new MemberApiResponse( + info.loginId().getValue(), + maskName(info.name().getValue()), + info.birthdate(), + info.email().getValue() + ); + } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java new file mode 100644 index 000000000..b8f3ffc64 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.service.OrderService; +import com.loopers.interfaces.api.order.dto.OrderApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 주문 관리 API (관리자) + */ +@RestController +@RequestMapping("/api/admin/orders") +@RequiredArgsConstructor +public class AdminOrderController { + + private final OrderService orderService; + + /** 주문 전체 조회 */ + @GetMapping + public List getAll() { + return orderService.getAll().stream() + .map(OrderApiResponse::from) + .toList(); + } + + /** 주문 단건 조회 */ + @GetMapping("/{id}") + public OrderApiResponse getById(@PathVariable Long id) { + return OrderApiResponse.from(orderService.getByIdForAdmin(id)); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 000000000..2e471ca51 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.OrderService; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.interfaces.api.order.dto.OrderApiResponse; +import com.loopers.interfaces.api.order.dto.OrderCreateApiRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 주문 API + */ +@RestController +@RequestMapping("/api/orders") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + private final MemberService memberService; + + /** 주문 생성 */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public OrderApiResponse create( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody OrderCreateApiRequest request + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return OrderApiResponse.from(orderService.create(request.toCommand(member.memberId()))); + } + + /** 내 주문 목록 조회 */ + @GetMapping + public List getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return orderService.getByMemberId(member.memberId()).stream() + .map(OrderApiResponse::from) + .toList(); + } + + /** 주문 단건 조회 */ + @GetMapping("/{id}") + public OrderApiResponse getById( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long id + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return OrderApiResponse.from(orderService.getById(id, member.memberId())); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java new file mode 100644 index 000000000..ca6058595 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.service.dto.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderApiResponse( + Long id, + Long memberId, + String status, + ZonedDateTime createdAt, + List orderLines +) { + public static OrderApiResponse from(OrderInfo info) { + return new OrderApiResponse( + info.orderId(), + info.memberId(), + info.status().name(), + info.createdAt(), + info.orderLines().stream() + .map(OrderLineApiResponse::from) + .toList() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java new file mode 100644 index 000000000..f34c6c4e8 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.service.dto.OrderCreateCommand; +import com.loopers.application.service.dto.OrderLineRequest; + +import java.util.List; + +public record OrderCreateApiRequest( + List orderLines +) { + public OrderCreateCommand toCommand(Long memberId) { + return new OrderCreateCommand( + memberId, + orderLines.stream() + .map(item -> new OrderLineRequest(item.productId(), item.quantity())) + .toList() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java new file mode 100644 index 000000000..6c56be6a5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.service.dto.OrderLineInfo; + +public record OrderLineApiResponse( + Long id, + Long productId, + long quantity, + String productName, + String productDescription, + long price, + String brandName +) { + public static OrderLineApiResponse from(OrderLineInfo info) { + return new OrderLineApiResponse( + info.orderLineId(), + info.productId(), + info.quantity(), + info.productName(), + info.productDescription(), + info.price(), + info.brandName() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java new file mode 100644 index 000000000..c2190d439 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java @@ -0,0 +1,7 @@ +package com.loopers.interfaces.api.order.dto; + +public record OrderLineItemRequest( + Long productId, + long quantity +) { +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java new file mode 100644 index 000000000..da8c3fb15 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java @@ -0,0 +1,56 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.service.ProductService; +import com.loopers.interfaces.api.product.dto.ProductApiResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductUpdateApiRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 상품 관리 API (관리자) + */ +@RestController +@RequestMapping("/api/admin/products") +@RequiredArgsConstructor +public class AdminProductController { + + private final ProductService productService; + + /** 상품 생성 */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void create(@RequestBody ProductCreateApiRequest request) { + productService.create(request.toCommand()); + } + + /** 상품 단건 조회 */ + @GetMapping("/{id}") + public ProductApiResponse getById(@PathVariable Long id) { + return ProductApiResponse.from(productService.getById(id)); + } + + /** 상품 전체 조회 */ + @GetMapping + public List getAll() { + return productService.getAll().stream() + .map(ProductApiResponse::from) + .toList(); + } + + /** 상품 수정 */ + @PutMapping("/{id}") + public void update(@PathVariable Long id, @RequestBody ProductUpdateApiRequest request) { + productService.update(id, request.toCommand()); + } + + /** 상품 삭제 */ + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + productService.delete(id); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 000000000..b9728439a --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.service.ProductService; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.interfaces.api.product.dto.ProductApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 상품 API (사용자) + */ +@RestController +@RequestMapping("/api/products") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + /** 활성 상품 목록 조회 */ + @GetMapping + public List getActiveProducts( + @RequestParam(defaultValue = "LATEST") ProductSortType sort + ) { + return productService.getActiveProducts(sort).stream() + .map(ProductApiResponse::from) + .toList(); + } + + /** 상품 단건 조회 */ + @GetMapping("/{id}") + public ProductApiResponse getById(@PathVariable Long id) { + return ProductApiResponse.from(productService.getById(id)); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java new file mode 100644 index 000000000..0cda61ac5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.service.dto.ProductInfo; + +public record ProductApiResponse( + Long id, + String name, + String description, + long price, + long stock, + long likesCount, + String brandName +) { + public static ProductApiResponse from(ProductInfo info) { + return new ProductApiResponse( + info.id(), + info.name(), + info.description(), + info.price(), + info.stock(), + info.likesCount(), + info.brandName() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java new file mode 100644 index 000000000..543427785 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.service.dto.ProductCreateCommand; + +public record ProductCreateApiRequest( + String name, + String description, + long price, + long stock, + Long brandId +) { + public ProductCreateCommand toCommand() { + return new ProductCreateCommand(name, description, price, stock, brandId); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java new file mode 100644 index 000000000..e866253a5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.service.dto.ProductUpdateCommand; + +public record ProductUpdateApiRequest( + String name, + String description, + long price, + long stock +) { + public ProductUpdateCommand toCommand() { + return new ProductUpdateCommand(name, description, price, stock); + } +} diff --git a/presentation/commerce-api/src/main/resources/application.yml b/presentation/commerce-api/src/main/resources/application.yml new file mode 100644 index 000000000..484c070d0 --- /dev/null +++ b/presentation/commerce-api/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java b/presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java new file mode 100644 index 000000000..a485bafe8 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java @@ -0,0 +1,14 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CommerceApiContextTest { + + @Test + void contextLoads() { + // 이 테스트는 Spring Boot 애플리케이션 컨텍스트가 로드되는지 확인합니다. + // 모든 빈이 올바르게 로드되었는지 확인하는 데 사용됩니다. + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java new file mode 100644 index 000000000..b4830fbf9 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -0,0 +1,184 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncryptor; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncryptor passwordEncryptor; + + private static final LocalDate BIRTH_DATE = LocalDate.of(2001, 2, 9); + + private void 회원을_등록한다(String loginId, String password) { + memberService.register(new MemberRegisterCommand( + loginId, password, "공명선", BIRTH_DATE, "test@loopers.com")); + } + + @Test + void 회원가입_성공() { + // given + String inputId = "integrationId123"; + MemberRegisterCommand request = new MemberRegisterCommand( + inputId, "Pass!1234", "공명선", BIRTH_DATE, "test@loopers.com"); + + // when + memberService.register(request); + + // then + assertThat(memberRepository.existsByLoginId(inputId)).isTrue(); + } + + @Test + void 회원가입_시_중복_아이디_사용_불가() { + // given + String duplicateId = "existingId"; + 회원을_등록한다(duplicateId, "encodedPw1!"); + + MemberRegisterCommand request = new MemberRegisterCommand( + duplicateId, "NewPass!123", "신규유저", LocalDate.of(2000, 1, 1), "new@test.com"); + + // when & then + assertThatThrownBy(() -> memberService.register(request)) + .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + @Test + void 내_정보_조회_성공_loginId_반환() { + // given + String loginId = "tester123"; + String password = "password123!"; + 회원을_등록한다(loginId, password); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(LoginId.of(loginId)); + } + + @Test + void 내_정보_조회_성공_이름_반환() { + // given + String loginId = "tester123"; + String password = "password123!"; + 회원을_등록한다(loginId, password); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.name()).isEqualTo(MemberName.of("공명선")); + } + + @Test + void 내_정보_조회_성공_이메일_반환() { + // given + String loginId = "tester123"; + String password = "password123!"; + 회원을_등록한다(loginId, password); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.email()).isEqualTo(Email.of("test@loopers.com")); + } + + @Test + void 내_정보_조회_실패_비밀번호_불일치() { + // given + String loginId = "tester123"; + 회원을_등록한다(loginId, "password123!"); + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(loginId, "wrongPassword1!")) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + void 내_정보_조회_실패_존재하지_않는_아이디() { + // given + String unknownId = "nobody12"; + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(unknownId, "anyPassword1!")) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + void 비밀번호_수정_성공() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + String newPw = "newPass5678@"; + 회원을_등록한다(loginId, currentPw); + + // when + memberService.updatePassword(loginId, currentPw, newPw); + + // then + assertThat(memberRepository.findByLoginId(loginId).orElseThrow() + .matchesPassword(newPw, passwordEncryptor) + ).isTrue(); + } + + @Test + void 비밀번호_수정_실패_현재_비밀번호와_동일() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + 회원을_등록한다(loginId, currentPw); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, currentPw)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + void 비밀번호_수정_실패_생년월일_포함() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + 회원을_등록한다(loginId, currentPw); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, "pass20010209!")) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @Test + void 비밀번호_수정_실패_현재_비밀번호_불일치() { + // given + String loginId = "tester123"; + 회원을_등록한다(loginId, "correct123!"); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrong123!!", "newPass123!")) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java new file mode 100644 index 000000000..c61c6a346 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java @@ -0,0 +1,132 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.brand.dto.BrandUpdateApiRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class BrandE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void 브랜드_생성_성공_201() throws Exception { + // when & then + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest("나이키")))) + .andExpect(status().isCreated()); + } + + @Test + void 브랜드_중복_생성_409() throws Exception { + // given + 브랜드를_생성한다("나이키"); + + // when & then + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest("나이키")))) + .andExpect(status().isConflict()); + } + + @Test + void 브랜드_단건_조회_200() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(get("/api/admin/brands/{id}", brandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("나이키")); + } + + @Test + void 활성_브랜드_목록_조회_200() throws Exception { + // given + 브랜드를_생성한다("나이키"); + 브랜드를_생성한다("아디다스"); + + // when & then + mockMvc.perform(get("/api/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void Admin_전체_브랜드_목록_조회_삭제_포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + 브랜드를_생성한다("아디다스"); + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)); + + // when & then + mockMvc.perform(get("/api/admin/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void 브랜드_수정_200() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(put("/api/admin/brands/{id}", brandId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandUpdateApiRequest("아디다스")))) + .andExpect(status().isOk()); + } + + @Test + void 브랜드_삭제_204() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)) + .andExpect(status().isNoContent()); + } + + @Test + void 삭제된_브랜드는_활성_목록에_미포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + 브랜드를_생성한다("아디다스"); + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)); + + // when & then + mockMvc.perform(get("/api/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + private void 브랜드를_생성한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + 브랜드를_생성한다(name); + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java new file mode 100644 index 000000000..752d6a898 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java @@ -0,0 +1,220 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class LikeE2ETest { + + private static final String LOGIN_ID = "liketest123"; + private static final String PASSWORD = "Like!1234"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private Long brandId; + private Long productId; + + @BeforeEach + void setUp() throws Exception { + 회원을_등록한다(); + brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + } + + @Test + void 좋아요_등록_201() throws Exception { + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isCreated()); + } + + @Test + void 좋아요_등록_시_likesCount_증가() throws Exception { + // given + 좋아요를_등록한다(productId); + + // when & then + mockMvc.perform(get("/api/products/{productId}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likesCount").value(1)); + } + + @Test + void 좋아요_중복_등록_시_400() throws Exception { + // given + 좋아요를_등록한다(productId); + + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + void 삭제된_상품에_좋아요_시_404() throws Exception { + // given + 상품을_삭제한다(productId); + + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isNotFound()); + } + + @Test + void 브랜드_삭제된_상품에_좋아요_시_400() throws Exception { + // given + 브랜드를_삭제한다(brandId); + + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + void 좋아요_취소_200() throws Exception { + // given + 좋아요를_등록한다(productId); + + // when & then + mockMvc.perform(delete("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + } + + @Test + void 좋아요_취소_시_likesCount_감소() throws Exception { + // given + 좋아요를_등록한다(productId); + 좋아요를_취소한다(productId); + + // when & then + mockMvc.perform(get("/api/products/{productId}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likesCount").value(0)); + } + + @Test + void 좋아요하지_않은_상품_취소_시_400() throws Exception { + // when & then + mockMvc.perform(delete("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + void 내_좋아요_목록_조회_200() throws Exception { + // given + Long productId2 = 상품을_생성하고_ID를_반환한다("조던", 200000, 30, brandId); + 좋아요를_등록한다(productId); + 좋아요를_등록한다(productId2); + + // when & then + mockMvc.perform(get("/api/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void 삭제된_상품은_좋아요_목록에서_제외() throws Exception { + // given + Long productId2 = 상품을_생성하고_ID를_반환한다("조던", 200000, 30, brandId); + 좋아요를_등록한다(productId); + 좋아요를_등록한다(productId2); + 상품을_삭제한다(productId2); + + // when & then + mockMvc.perform(get("/api/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + private void 회원을_등록한다() throws Exception { + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new MemberRegisterCommand(LOGIN_ID, PASSWORD, "테스터", LocalDate.of(2000, 1, 1), "like@test.com")))); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } + + private Long 상품을_생성하고_ID를_반환한다(String name, long price, long stock, Long brandId) throws Exception { + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest(name, "설명", price, stock, brandId)))); + + String response = mockMvc.perform(get("/api/admin/products")) + .andReturn().getResponse().getContentAsString(); + + var products = objectMapper.readTree(response); + for (var product : products) { + if (product.get("name").asText().equals(name)) { + return product.get("id").asLong(); + } + } + return products.get(0).get("id").asLong(); + } + + private void 좋아요를_등록한다(Long productId) throws Exception { + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)); + } + + private void 좋아요를_취소한다(Long productId) throws Exception { + mockMvc.perform(delete("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)); + } + + private void 상품을_삭제한다(Long productId) throws Exception { + mockMvc.perform(delete("/api/admin/products/{id}", productId)); + } + + private void 브랜드를_삭제한다(Long brandId) throws Exception { + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java new file mode 100644 index 000000000..557fa6f8a --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java @@ -0,0 +1,123 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.PasswordUpdateCommand; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class MemberE2ETest { + + private static final String LOGIN_ID = "loopers123"; + private static final String INITIAL_PW = "Initial!1234"; + private static final String NEW_PW = "Updated!5678"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("회원가입 성공 시 201 Created를 반환한다") + void 회원가입_성공() throws Exception { + // given + MemberRegisterCommand request = createRegisterRequest(); + + // when & then + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("내 정보 조회 시 마스킹된 이름이 반환된다") + void 내_정보_조회_마스킹된_이름_반환() throws Exception { + // given + registerMember(); + + // when & then + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW)) + .andExpect(jsonPath("$.name").value("공명*")); + } + + @Test + @DisplayName("비밀번호 변경 성공 시 204 No Content를 반환한다") + void 비밀번호_변경_성공() throws Exception { + // given + registerMember(); + + // when & then + mockMvc.perform(patch("/api/members/password") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new PasswordUpdateCommand(NEW_PW)))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("변경된 비밀번호로 내 정보 조회가 성공한다") + void 변경된_비밀번호로_조회_성공() throws Exception { + // given + registerMember(); + changePassword(); + + // when & then + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", NEW_PW)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("비밀번호 변경 후 기존 비밀번호로 조회하면 401 Unauthorized를 반환한다") + void 기존_비밀번호로_조회_실패() throws Exception { + // given + registerMember(); + changePassword(); + + // when & then + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW)) + .andExpect(status().isUnauthorized()); + } + + private MemberRegisterCommand createRegisterRequest() { + return new MemberRegisterCommand( + LOGIN_ID, INITIAL_PW, "공명선", LocalDate.of(2001, 2, 9), "test@loopers.com" + ); + } + + private void registerMember() throws Exception { + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRegisterRequest()))); + } + + private void changePassword() throws Exception { + mockMvc.perform(patch("/api/members/password") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new PasswordUpdateCommand(NEW_PW)))); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java new file mode 100644 index 000000000..6464ccc26 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java @@ -0,0 +1,200 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateApiRequest; +import com.loopers.interfaces.api.order.dto.OrderLineItemRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class OrderE2ETest { + + private static final String LOGIN_ID = "ordertest123"; + private static final String PASSWORD = "Order!1234"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private Long brandId; + private Long productId1; + private Long productId2; + + @BeforeEach + void setUp() throws Exception { + 회원을_등록한다(); + brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + productId1 = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + productId2 = 상품을_생성하고_ID를_반환한다("조던", 200000, 30, brandId); + } + + @Test + void 주문_생성_수락_201() throws Exception { + // when & then + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new OrderCreateApiRequest(List.of( + new OrderLineItemRequest(productId1, 2), + new OrderLineItemRequest(productId2, 1) + ))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("ACCEPTED")); + } + + @Test + void 주문_생성_거절_재고_부족_201() throws Exception { + // when & then + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new OrderCreateApiRequest(List.of( + new OrderLineItemRequest(productId1, 999) + ))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + + @Test + void 주문_생성_시_스냅샷_포함() throws Exception { + // when & then + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new OrderCreateApiRequest(List.of( + new OrderLineItemRequest(productId1, 1) + ))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.orderLines[0].productName").value("에어맥스")) + .andExpect(jsonPath("$.orderLines[0].brandName").value("나이키")); + } + + @Test + void 내_주문_내역_조회_200() throws Exception { + // given + 주문을_생성한다(List.of(new OrderLineItemRequest(productId1, 1))); + + // when & then + mockMvc.perform(get("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + void 주문_상세_조회_200() throws Exception { + // given + Long orderId = 주문을_생성하고_ID를_반환한다(List.of(new OrderLineItemRequest(productId1, 2))); + + // when & then + mockMvc.perform(get("/api/orders/{id}", orderId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.orderLines.length()").value(1)); + } + + @Test + void 관리자_전체_목록_200() throws Exception { + // given + 주문을_생성한다(List.of(new OrderLineItemRequest(productId1, 1))); + 주문을_생성한다(List.of(new OrderLineItemRequest(productId2, 1))); + + // when & then + mockMvc.perform(get("/api/admin/orders")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void 관리자_상세_조회_200() throws Exception { + // given + Long orderId = 주문을_생성하고_ID를_반환한다(List.of(new OrderLineItemRequest(productId1, 1))); + + // when & then + mockMvc.perform(get("/api/admin/orders/{id}", orderId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId)); + } + + private void 회원을_등록한다() throws Exception { + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new MemberRegisterCommand(LOGIN_ID, PASSWORD, "테스터", LocalDate.of(2000, 1, 1), "order@test.com")))); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } + + private Long 상품을_생성하고_ID를_반환한다(String name, long price, long stock, Long brandId) throws Exception { + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest(name, "설명", price, stock, brandId)))); + + String response = mockMvc.perform(get("/api/admin/products")) + .andReturn().getResponse().getContentAsString(); + + var products = objectMapper.readTree(response); + for (var product : products) { + if (product.get("name").asText().equals(name)) { + return product.get("id").asLong(); + } + } + return products.get(0).get("id").asLong(); + } + + private void 주문을_생성한다(List items) throws Exception { + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new OrderCreateApiRequest(items)))); + } + + private Long 주문을_생성하고_ID를_반환한다(List items) throws Exception { + String response = mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new OrderCreateApiRequest(items)))) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get("id").asLong(); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java new file mode 100644 index 000000000..39861a9aa --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java @@ -0,0 +1,131 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductUpdateApiRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ProductE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void 상품_생성_성공_201() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest("에어맥스", "설명", 100000, 50, brandId)))) + .andExpect(status().isCreated()); + } + + @Test + void 상품_상세_조회_브랜드명_포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + + // when & then + mockMvc.perform(get("/api/products/{id}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.brandName").value("나이키")) + .andExpect(jsonPath("$.likesCount").value(0)); + } + + @Test + void 활성_상품_목록_조회_정렬() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + 상품을_생성한다("에어맥스", 100000, 50, brandId); + 상품을_생성한다("조던", 200000, 30, brandId); + + // when & then + mockMvc.perform(get("/api/products").param("sort", "PRICE_ASC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value("에어맥스")); + } + + @Test + void 상품_수정_200() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + + // when & then + mockMvc.perform(put("/api/admin/products/{id}", productId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductUpdateApiRequest("에어맥스2", "새설명", 120000, 60)))) + .andExpect(status().isOk()); + } + + @Test + void 상품_삭제_204() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + + // when & then + mockMvc.perform(delete("/api/admin/products/{id}", productId)) + .andExpect(status().isNoContent()); + } + + @Test + void 삭제된_상품은_활성_목록에_미포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + 상품을_생성한다("조던", 200000, 30, brandId); + mockMvc.perform(delete("/api/admin/products/{id}", productId)); + + // when & then + mockMvc.perform(get("/api/products")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } + + private void 상품을_생성한다(String name, long price, long stock, Long brandId) throws Exception { + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest(name, "설명", price, stock, brandId)))); + } + + private Long 상품을_생성하고_ID를_반환한다(String name, long price, long stock, Long brandId) throws Exception { + 상품을_생성한다(name, price, stock, brandId); + String response = mockMvc.perform(get("/api/admin/products")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java new file mode 100644 index 000000000..bbd5fdbe1 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.example; + +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ExampleServiceIntegrationTest { + @Autowired + private ExampleService exampleService; + + @Autowired + private ExampleJpaRepository exampleJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("예시를 조회할 때,") + @Nested + class Get { + @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange + ExampleModel exampleModel = exampleJpaRepository.save( + new ExampleModel("예시 제목", "예시 설명") + ); + + // act + ExampleModel result = exampleService.getExample(exampleModel.getId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), + () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), + () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) + ); + } + + @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsException_whenInvalidIdIsProvided() { + // arrange + Long invalidId = 999L; // Assuming this ID does not exist + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + exampleService.getExample(invalidId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java new file mode 100644 index 000000000..1bb3dba65 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -0,0 +1,114 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.interfaces.api.example.ExampleV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ExampleV1ApiE2ETest { + + private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; + + private final TestRestTemplate testRestTemplate; + private final ExampleJpaRepository exampleJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ExampleV1ApiE2ETest( + TestRestTemplate testRestTemplate, + ExampleJpaRepository exampleJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.exampleJpaRepository = exampleJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/examples/{id}") + @Nested + class Get { + @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange + ExampleModel exampleModel = exampleJpaRepository.save( + new ExampleModel("예시 제목", "예시 설명") + ); + String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), + () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) + ); + } + + @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenIdIsNotProvided() { + // arrange + String requestUrl = "/api/v1/examples/나나"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsException_whenInvalidIdIsProvided() { + // arrange + Long invalidId = -1L; + String requestUrl = ENDPOINT_GET.apply(invalidId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/presentation/commerce-batch/build.gradle.kts b/presentation/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..852a06dd3 --- /dev/null +++ b/presentation/commerce-batch/build.gradle.kts @@ -0,0 +1,23 @@ +apply(plugin = "org.springframework.boot") + +dependencies { + // add-ons + implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":infrastructure:jpa"))) + testImplementation(testFixtures(project(":infrastructure:redis"))) +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..e5005c373 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/presentation/commerce-batch/src/main/resources/application.yml b/presentation/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/presentation/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/presentation/commerce-streamer/build.gradle.kts b/presentation/commerce-streamer/build.gradle.kts new file mode 100644 index 000000000..eaa862431 --- /dev/null +++ b/presentation/commerce-streamer/build.gradle.kts @@ -0,0 +1,25 @@ +apply(plugin = "org.springframework.boot") + +dependencies { + // add-ons + implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:redis")) + implementation(project(":infrastructure:kafka")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":infrastructure:jpa"))) + testImplementation(testFixtures(project(":infrastructure:redis"))) + testImplementation(testFixtures(project(":infrastructure:kafka"))) +} diff --git a/presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java new file mode 100644 index 000000000..ea4b4d15a --- /dev/null +++ b/presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceStreamerApplication { + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(CommerceStreamerApplication.class, args); + } +} + + diff --git a/presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java new file mode 100644 index 000000000..df5122d5a --- /dev/null +++ b/presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.config.kafka.KafkaConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DemoKafkaConsumer { + @KafkaListener( + topics = {"${demo-kafka.test.topic-name}"}, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void demoListener( + List> messages, + Acknowledgment acknowledgment + ){ + System.out.println(messages); + acknowledgment.acknowledge(); + } +} diff --git a/presentation/commerce-streamer/src/main/resources/application.yml b/presentation/commerce-streamer/src/main/resources/application.yml new file mode 100644 index 000000000..0651bc2bd --- /dev/null +++ b/presentation/commerce-streamer/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - kafka.yml + - logging.yml + - monitoring.yml + +demo-kafka: + test: + topic-name: demo.internal.topic-v1 + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a2c303835..6e52eb3f6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,15 +1,19 @@ rootProject.name = "loopers-java-spring-template" include( - ":apps:commerce-api", - ":apps:commerce-streamer", - ":apps:commerce-batch", - ":modules:jpa", - ":modules:redis", - ":modules:kafka", + ":domain", + ":application:commerce-service", + ":presentation:commerce-api", + ":presentation:commerce-batch", + ":presentation:commerce-streamer", + ":infrastructure:jpa", + ":infrastructure:redis", + ":infrastructure:kafka", + ":infrastructure:security", ":supports:jackson", ":supports:logging", ":supports:monitoring", + ":supports:error", ) // configurations diff --git a/supports/error/build.gradle.kts b/supports/error/build.gradle.kts new file mode 100644 index 000000000..d461d4b44 --- /dev/null +++ b/supports/error/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + `java-library` +} + +// 순수 Java 모듈 — Spring 의존 없음 diff --git a/supports/error/src/main/java/com/loopers/support/error/CoreException.java b/supports/error/src/main/java/com/loopers/support/error/CoreException.java new file mode 100644 index 000000000..0cc190b6b --- /dev/null +++ b/supports/error/src/main/java/com/loopers/support/error/CoreException.java @@ -0,0 +1,19 @@ +package com.loopers.support.error; + +import lombok.Getter; + +@Getter +public class CoreException extends RuntimeException { + private final ErrorType errorType; + private final String customMessage; + + public CoreException(ErrorType errorType) { + this(errorType, null); + } + + public CoreException(ErrorType errorType, String customMessage) { + super(customMessage != null ? customMessage : errorType.getMessage()); + this.errorType = errorType; + this.customMessage = customMessage; + } +} diff --git a/supports/error/src/main/java/com/loopers/support/error/ErrorType.java b/supports/error/src/main/java/com/loopers/support/error/ErrorType.java new file mode 100644 index 000000000..774cfedbf --- /dev/null +++ b/supports/error/src/main/java/com/loopers/support/error/ErrorType.java @@ -0,0 +1,18 @@ +package com.loopers.support.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorType { + INTERNAL_ERROR("Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST("Bad Request", "잘못된 요청입니다."), + NOT_FOUND("Not Found", "존재하지 않는 요청입니다."), + CONFLICT("Conflict", "이미 존재하는 리소스입니다."), + UNAUTHORIZED("Unauthorized", "인증에 실패했습니다."), + FORBIDDEN("Forbidden", "접근 권한이 없습니다."); + + private final String code; + private final String message; +}