Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

# .properties 는 IDE 기본값이 ISO-8859-1 이라 한글 주석이 깨져 보일 수 있다.
# 명시적으로 UTF-8 로 읽고/쓰도록 강제한다.
[*.properties]
charset = utf-8

[*.{yml,yaml}]
charset = utf-8

[*.{java,gradle}]
indent_style = space
indent_size = 4
66 changes: 39 additions & 27 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,36 +1,48 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.0'
id 'io.spring.dependency-management' version '1.1.7'
// 루트에서는 버전만 등록하고, 실제 적용은 이제부터 각 모듈에서 한다.
id 'org.springframework.boot' version '4.1.0' apply false
id 'io.spring.dependency-management' version '1.1.7' apply false
}

group = 'com.growmighty.lectures.firstday'
version = '0.0.1-SNAPSHOT'
allprojects {
group = 'com.growmighty.lectures.firstday'
version = '0.0.1-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
repositories {
mavenCentral()
}
}

repositories {
mavenCentral()
}
subprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'

dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

// common처럼 Boot 플러그인 대상이 아닌 모듈도 Spring Boot BOM으로
// 의존성 버전을 통일해서 관리한다. 즉, Boot 플러그인 대상이 아니더라도
// 우리가 정의해 둔 BOM은 나중에 딴소리 말고 꼭 알아둬라 라는 의미이다.
// BOM은 검증된 버전 목록을 하나의 표로 만들어 둔 것이다.
dependencyManagement {
imports {
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
}
}

dependencies {
// 모듈이 공통으로 쓰는 것만 여기에 넣는다. 롬복같은 것들이 있겠다.
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
useJUnitPlatform()
tasks.named('test') {
useJUnitPlatform()
}
}
15 changes: 15 additions & 0 deletions cart-service/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨)
}

dependencies {
implementation project(':common')

implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-h2console'
runtimeOnly 'com.h2database:h2'

testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.growmighty.lectures.firstday.cart;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다.
// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다.
@SpringBootApplication(scanBasePackages = {
"com.growmighty.lectures.firstday.cart",
"com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다.
})
public class CartServiceApplication {

public static void main(String[] args) {
SpringApplication.run(CartServiceApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.growmighty.lectures.firstday.cart.application;

import com.growmighty.lectures.firstday.cart.application.dto.AddCartItemCommand;
import com.growmighty.lectures.firstday.cart.application.dto.CartView;
import com.growmighty.lectures.firstday.cart.domain.Cart;
import com.growmighty.lectures.firstday.cart.domain.CartRepository;
import com.growmighty.lectures.firstday.cart.application.port.ProductPort;
import com.growmighty.lectures.firstday.cart.application.port.dto.ProductSnapshot;
import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class CartService {
private final CartRepository cartRepository;
// ProductPort로 HTTP를 통해 대화하자.
private final ProductPort productPort;

@Transactional
public CartView addItem(AddCartItemCommand command) {
ProductSnapshot product = productPort.getProduct(command.productId());
if (!product.orderable()) {
throw new IllegalStateException("현재 구매할 수 없는 상품입니다. productId = " + command.productId());
}
Cart cart = cartRepository.findByUserId(command.userId())
.orElseGet(() -> Cart.create(command.userId()));
cart.addItem(command.productId(), command.quantity());
return CartView.from(cartRepository.save(cart));
}

@Transactional
public CartView changeQuantity(Long userId, Long productId, int quantity) {
Cart cart = getCartEntity(userId);
cart.changeQuantity(productId, quantity);
return CartView.from(cart);
}

@Transactional
public CartView removeItem(Long userId, Long productId) {
Cart cart = getCartEntity(userId);
cart.removeItem(productId);
return CartView.from(cart);
}

@Transactional
public void clear(Long userId) {
getCartEntity(userId).clear();
}

@Transactional(readOnly = true)
public CartView getCart(Long userId) {
return CartView.from(getCartEntity(userId));
}

private Cart getCartEntity(Long userId) {
return cartRepository.findByUserId(userId)
.orElseThrow(() -> new EntityNotFoundException("장바구니가 비어 있습니다. userId=" + userId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.growmighty.lectures.firstday.cart.application.dto;

public record AddCartItemCommand(Long userId, Long productId, int quantity) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.growmighty.lectures.firstday.cart.application.dto;

import com.growmighty.lectures.firstday.cart.domain.Cart;

import java.util.List;

public record CartView (Long cartId, Long userId, List<Line> items) {
public record Line(Long productId, int quantity) {
}

public static CartView from(Cart cart) {
List<Line> lines = cart.getItems().stream()
.map(item -> new Line(item.getProductId(), item.getQuantity()))
.toList();
return new CartView(cart.getId(), cart.getUserId(), lines);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.growmighty.lectures.firstday.cart.application.port;

import com.growmighty.lectures.firstday.cart.application.port.dto.ProductSnapshot;

// Cart가 쓰니까 Cart의 품으로.
// Cart는 Product 클래스 모른다. 인터페이스로만 상품을 바라보고
// 실제 통신은 infrastructure의 HTTP 클라이언트가 담당한다.
public interface ProductPort {
ProductSnapshot getProduct(Long productId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.growmighty.lectures.firstday.cart.application.port.dto;

// Cart가 필요로 하는 만큼만 담은 상품 스냅샷.
// 장바구니 담기 판단에 필요한 값만 노출한다.
public record ProductSnapshot(
Long productId,
boolean orderable
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.growmighty.lectures.firstday.cart.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

// domain으로 이동
@Entity
@Table(name = "carts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Cart {
public static final int MAX_DISTINCT_ITEMS = 50;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartItem> items = new ArrayList<>();

// User 객체 전체를 가져오는 것이 아니라 id로만 접근한다.
@Column(nullable = false)
private Long userId;

// 정적 팩토리 메서드로 생성
private Cart(Long userId) {
this.userId = userId;
}

public static Cart create(Long userId) {
return new Cart(userId);
}

public void addItem(Long productId, int quantity) {
CartItem existing = findItem(productId);
if (existing != null) {
existing.addQuantity(quantity);
return;
}
if (items.size() >= MAX_DISTINCT_ITEMS) {
throw new IllegalStateException("장바구니에는 최대 " + MAX_DISTINCT_ITEMS + "종류까지 담을 수 있습니다.");
}
CartItem item = CartItem.create(productId, quantity);
item.assignCart(this);
this.items.add(item);
}

public void changeQuantity(Long productId, int newQuantity) {
requireItem(productId).changeQuantity(newQuantity);
}

public void removeItem(Long productId) {
requireItem(productId);
this.items.removeIf(item -> item.hasProduct(productId));
}

public void clear() {
this.items.clear();
}

private CartItem findItem(Long productId) {
return items.stream()
.filter(item -> item.hasProduct(productId))
.findFirst()
.orElse(null);
}

private CartItem requireItem(Long productId) {
CartItem item = findItem(productId);
if (item == null) {
throw new IllegalArgumentException("장바구니에 없는 상품입니다. productId=" + productId);
}
return item;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.growmighty.lectures.firstday.cart.domain;


import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

// Domain으로 이동
@Entity
@Table(name = "cart_items")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CartItem {
public static final int MAX_QUANTITY = 99;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "cart_id")
private Cart cart;

// 역시 Product 자체가 아닌 id로만 접근한다.
@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private Integer quantity;

// 정적 팩토리 메서드로 생성
private CartItem(Long productId, int quantity) {
validateQuantity(quantity);
this.productId = productId;
this.quantity = quantity;
}

public static CartItem create(Long productId, int quantity) {
return new CartItem(productId, quantity);
}

void addQuantity(int amount) {
validateQuantity(this.quantity + amount);
this.quantity += amount;
}

void changeQuantity(int newQuantity) {
validateQuantity(newQuantity);
this.quantity = newQuantity;
}

boolean hasProduct(Long productId) {
return this.productId.equals(productId);
}

void assignCart(Cart cart) {
this.cart = cart;
}

private void validateQuantity(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("수량은 1개 이상이어야 합니다. 입력값: " + quantity);
}
if (quantity > MAX_QUANTITY) {
throw new IllegalArgumentException("한 상품은 최대 " + MAX_QUANTITY + "개까지 담을 수 있습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.growmighty.lectures.firstday.cart.domain;

import java.util.Optional;

// Domain으로 이동, 이때! DIP를 구현하기 위해 JpaRepository를 extends하지 않음.
// 그 과정은 infrastructure에서 진행한다.
public interface CartRepository {
Cart save(Cart cart);

Optional<Cart> findByUserId(Long userId);
}
Loading