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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ repositories {
}

dependencies {
implementation ("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")

developmentOnly("org.springframework.boot:spring-boot-devtools")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testCompileOnly ("org.projectlombok:lombok")
testAnnotationProcessor ("org.projectlombok:lombok")
}

tasks.withType<Test> {
Expand Down
48 changes: 48 additions & 0 deletions src/main/java/com/example/demo/application/CartService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.demo.application;

import com.example.demo.exception.CartNotFoundException;
import com.example.demo.exception.ProductNotFoundException;
import com.example.demo.model.Cart;
import com.example.demo.model.LineItemId;
import com.example.demo.model.ProductId;
import com.example.demo.model.ProductOption;
import com.example.demo.repository.CartRepository;
import com.example.demo.repository.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class CartService {

private final CartRepository cartRepository;
private final ProductRepository productRepository;

public CartService(CartRepository cartRepository, ProductRepository productRepository) {
this.cartRepository = cartRepository;
this.productRepository = productRepository;
}

public void addItemToCart(String userId, ProductId productId, ProductOption option, int quantity) {
Copy link
Member

Choose a reason for hiding this comment

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

본격적으로 하실 거면 UserId VO를 잡아주세요.

Cart cart = getCart(userId);
Copy link
Member

Choose a reason for hiding this comment

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

특별한 경우가 아니면 다른 곳과 마찬가지로 Repository에서 바로 얻을 겁니다. 별도의 메서드로 얻을 수 있는 혜택이 적고, 추상화 수준도 안 맞습니다.

if (!productRepository.existsById(productId)) {
throw new ProductNotFoundException(productId);
}
cart.addProduct(productId, option, quantity);
}
Comment on lines +26 to +32
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

수량(quantity)에 대한 추가 검증이 필요합니다.
quantity가 0 이하일 경우 예외 처리가 이루어지지 않으므로, 도메인 상태가 잘못될 위험이 있습니다. quantity가 유효 범위(예: 1 이상)인지 확인하는 로직을 추가해 주세요.

 public void addItemToCart(String userId, ProductId productId, ProductOption option, int quantity) {
     Cart cart = getCart(userId);
     if (!productRepository.existsById(productId)) {
         throw new ProductNotFoundException(productId);
     }
+    if (quantity <= 0) {
+        throw new IllegalArgumentException("수량은 0보다 커야 합니다.");
+    }
     cart.addProduct(productId, option, quantity);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void addItemToCart(String userId, ProductId productId, ProductOption option, int quantity) {
Cart cart = getCart(userId);
if (!productRepository.existsById(productId)) {
throw new ProductNotFoundException(productId);
}
cart.addProduct(productId, option, quantity);
}
public void addItemToCart(String userId, ProductId productId, ProductOption option, int quantity) {
Cart cart = getCart(userId);
if (!productRepository.existsById(productId)) {
throw new ProductNotFoundException(productId);
}
if (quantity <= 0) {
throw new IllegalArgumentException("수량은 0보다 커야 합니다.");
}
cart.addProduct(productId, option, quantity);
}


public void removeLineItem(String userId, LineItemId lineItemId) {
Cart cart = getCart(userId);
cart.removeLineItem(lineItemId);
}
Comment on lines +34 to +37
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

LineItem이 존재하지 않을 경우의 예외 처리 확인이 필요합니다.
removeLineItem 실행 시 해당 lineItemId가 장바구니에 없을 경우 예외 처리가 어떻게 이뤄지는지 확인해 주세요. 도메인 내부에서 예외를 발생시키지 않는다면, 적절한 예외를 던지는 로직을 추가하는 것을 고려해 보세요.


🏁 Script executed:

#!/bin/bash
# LineItemNotFoundException 같은 맞춤 예외가 정의되어 있는지 검색
rg "LineItemNotFoundException"

Length of output: 30


위 스크립트의 실행 결과를 공유해 주시거나 수동으로 해당 메서드 내부에 예외 처리를 위한 코드(예: throw 문)가 포함되어 있는지 확인해 주세요.


🏁 Script executed:

#!/bin/bash
# Cart 클래스 내의 removeLineItem 메서드 구현부에서 예외 처리(throw 등) 여부를 확인하기 위한 검색
rg -A 15 "public void removeLineItem" -n .

Length of output: 1883


예외 처리 추가 필요

현재 Cart.removeLineItem(LineItemId lineItemId) 메서드는 존재하지 않는 LineItem에 대해 예외 처리를 하지 않고, 단순히 removeIf로 처리하고 있습니다. 이로 인해, removeLineItem 실행 시 해당 LineItem이 존재하지 않더라도 명시적으로 예외를 던지지 않아 애플리케이션의 다른 로직에서 문제 상황 인지가 어려울 수 있습니다.

  • 파일: src/main/java/com/example/demo/model/Cart.java
    • removeLineItem 메서드가 LineItem 부재에 대해 예외를 발생시키는 로직이 없습니다.
  • 파일: src/main/java/com/example/demo/application/CartService.java
    • 서비스 계층에서 getCart 호출 시 CartNotFoundException은 발생하지만, LineItem 부재에 대한 별도의 예외 확인 로직은 없습니다.

도메인 규칙에 따라 LineItem이 존재하지 않을 경우 예외를 발생시켜야 한다면, 해당 메서드에 적절한 예외 처리 로직(예: LineItemNotFoundException과 같은 맞춤 예외 추가)를 구현하는 것을 고려해 주세요.


public void clearCart(String userId) {
Cart cart = getCart(userId);
cart.clearItems();
}

public Cart getCart(String userId) {
return cartRepository.findByUserId(userId)
.orElseThrow(CartNotFoundException::new);
}
}
55 changes: 53 additions & 2 deletions src/main/java/com/example/demo/controllers/CartController.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,65 @@
package com.example.demo.controllers;

import com.example.demo.application.CartService;
import com.example.demo.controllers.dto.CartResponseDto;
import com.example.demo.controllers.dto.LineItemResponseDto;
import com.example.demo.model.Cart;
import com.example.demo.util.AuthorizationUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static com.example.demo.util.AuthorizationUtils.extractUserIdFromAuthorization;

@RestController
@RequestMapping("/cart")
public class CartController {

private final CartService cartService;

public CartController(CartService cartService) {
this.cartService = cartService;
}


@GetMapping
String detail() {
return "";
public ResponseEntity<CartResponseDto> getCart(
@RequestHeader(name = "Authorization", required = false)
Copy link
Member

Choose a reason for hiding this comment

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

아주 특수한 경우가 아니면 Controller에서 ReqeustHeader를 쓸 일이 없습니다. 대개는 더 앞에서 Filter나 Intercept로 처리합니다. 인증/인가는 매우 보편적이라 보다 앞에서 공통으로 처리하는 게 필요합니다.

지금은 임시 구현을 너무 구체적으로 하려고 하는데, 이도 저도 아닌 힘 빼기가 될 위험이 있습니다. 설계에 더 집중해 주세요. 결론보다 과정이 더 중요합니다. 말 없이 코드만 계속 바꾸는 건 안 좋은 신호예요.

String authorization) {

String userId = extractUserIdFromAuthorization(authorization);
Copy link
Member

Choose a reason for hiding this comment

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

이 부분은 인증을 도입하지 않은 상태에서 어정쩡하게 가져가는 게 문제입니다.

  1. Bearer엔 Access Token이 들어가지 User ID가 들어가지 않습니다.
  2. 인증 처리가 먼저 이뤄지고, Controller에선 이렇게 복잡하게 처리하지 않습니다.

실제로 인증 처리를 하고 싶다면 인증을 넣어야 하고, 그게 아니면 설계를 고려해서 임의의 코드를 써줘야 합니다. 제가 기대했던 건 후자에 대해 다음 둘 중 하나를 하는 거였습니다.

  1. UI Layer에서 User ID를 하드코딩.
  2. Application Layer에서 Cart ID를 하드코딩.

애매하게 UI Layer에서 Cart ID를 하드코딩하면 나중에 진짜 구현이 들어왔을 때 다 갈아엎어야 해서 그 부분을 이야기하고 싶었어요.

마찬가지로, 지금 코드는 인증이 들어오면 싹 다 갈아엎어야 합니다. 앞으로 변화에 대해 최소한의 수정으로 대응하는 것, 유지보수성이 높아지는 것, 이런 게 관심사의 분리가 주는 혜택이죠.

Copy link
Member

Choose a reason for hiding this comment

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

인증이 없는 상태에서는 그 상황을 고려해서 코드를 써야 하는데, 이 부분은 대개 Filter나 Intercept를 써서 처리하고, Contoroller 코드는 이런 모양이 됩니다. 꼭 이 모양이어야 하는 건 아니고, 여러 방법이 있지만 그냥 눈에 띄는 거 하나 아무거나 가져온 겁니다.

Screenshot

다시 강조하지만, 여기서 인증 처리가 필요한 건 아닙니다. 각 Layer의 책임 범위를 명확히 하고, 유지보수성이 높은 설계와 코드를 작성하는 게 필요하다는 겁니다. 하드코딩을 하더라도 최소한의 수정으로 처리하려면, 올바르게 배치 = 설계하는 게 필요합니다. 구현은 그 다음 문제죠.

구현부터 가면 계속 늪으로 빠질 수 있으니 고민하고 있는 부분을 먼저 공유해 주세요. 이미 많은 사람들이 만든 베스트 프랙티스가 많기 때문에 독자연구로 빠질 필요가 없습니다.

Cart cart = cartService.getCart(userId);

CartResponseDto responseDto = new CartResponseDto(
cart.getTotalQuantity(),
cart.getLineItems().stream()
.map(lineItem -> new LineItemResponseDto(
lineItem.getProductId().id(),
lineItem.getProductOption().color(),
lineItem.getProductOption().size(),
lineItem.getQuantity()
)
).toList()
);

return new ResponseEntity<>(responseDto, HttpStatus.OK);
Copy link
Member

Choose a reason for hiding this comment

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

ResponseEntity는 영원히 안 써도 됩니다. 아주 옛날에는 특수한 상황에서 쓸 일이 있기도 했지만, 지금은 안 쓰는 걸 강력히 추천합니다.

}


@DeleteMapping()
public ResponseEntity<Void> deleteCart(
@RequestHeader(name = "Authorization", required = false)
String authorization) {

String userId = extractUserIdFromAuthorization(authorization);

cartService.clearCart(userId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}


}
49 changes: 47 additions & 2 deletions src/main/java/com/example/demo/controllers/LineItemController.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,62 @@
package com.example.demo.controllers;

import com.example.demo.application.CartService;
import com.example.demo.controllers.dto.LineItemRequestDto;
import com.example.demo.exception.UnauthorizedException;
import com.example.demo.model.Cart;
import com.example.demo.model.LineItemId;
import com.example.demo.model.ProductId;
import com.example.demo.model.ProductOption;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import static com.example.demo.util.AuthorizationUtils.extractUserIdFromAuthorization;


@RestController
@RequestMapping("/cart/line-items")
public class LineItemController {

public LineItemController(CartService cartService) {
this.cartService = cartService;
}

private final CartService cartService;

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
void create() {
//
public void create(
@Valid @RequestBody LineItemRequestDto requestDto,
@RequestHeader("Authorization") String authorization) {

String userId = extractUserIdFromAuthorization(authorization);

cartService.addItemToCart(
userId,
new ProductId(requestDto.productId()),
new ProductOption(requestDto.color(), requestDto.size()),
requestDto.quantity());

}

@DeleteMapping("/{lineItemId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteLineItem(
@PathVariable String lineItemId,
@RequestHeader("Authorization") String authorization) {

String userId = extractUserIdFromAuthorization(authorization);

cartService.removeLineItem(
userId,
new LineItemId(lineItemId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.demo.controllers.dto;

import java.util.List;

public record CartResponseDto(
int totalQuantity,
List<LineItemResponseDto> lineItems
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.demo.controllers.dto;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

public record LineItemRequestDto(

@NotBlank(message = "상품 ID는 필수입니다.")
String productId,

@NotBlank(message = "색상은 필수입니다.")
String color,

@NotBlank(message = "사이즈는 필수입니다.")
String size,

@Min(value = 1, message = "최소 수량은 1개입니다.")
@Max(value = 20, message = "최대 수량은 20개입니다.")
int quantity
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.demo.controllers.dto;

public record LineItemResponseDto(
String productId,
String color,
String size,
int quantity
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.demo.exception;

public class CartNotFoundException extends RuntimeException {
public CartNotFoundException() {
super("장바구니를 찾을 수 없습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.demo.exception;

public class CartQuantityLimitException extends RuntimeException {
public CartQuantityLimitException() {
super("장바구니에 담을 수 있는 수량을 초과했습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.demo.exception;


public record ErrorResponseDto(
String code,
String message
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.example.demo.exception;


import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(CartNotFoundException.class)
public ResponseEntity<ErrorResponseDto> handleCartNotFound(CartNotFoundException e) {
ErrorResponseDto responseDto = new ErrorResponseDto("CART_NOT_FOUND", e.getMessage());
return new ResponseEntity<>(responseDto, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ErrorResponseDto> handleProductNotFound(ProductNotFoundException e) {
ErrorResponseDto responseDto = new ErrorResponseDto("PRODUCT_NOT_FOUND", e.getMessage());
return new ResponseEntity<>(responseDto, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(CartQuantityLimitException.class)
public ResponseEntity<ErrorResponseDto> handleCartQuantityLimit(CartQuantityLimitException e) {
ErrorResponseDto responseDto = new ErrorResponseDto("CART_QUANTITY_LIMIT", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(responseDto);
}

@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorResponseDto> handleUnauthorized(UnauthorizedException e) {
ErrorResponseDto responseDto = new ErrorResponseDto("UNAUTHORIZED", e.getMessage());
return new ResponseEntity<>(responseDto, HttpStatus.UNAUTHORIZED);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.demo.exception;

import com.example.demo.model.ProductId;

public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(ProductId productId) {
super("상품을 찾을 수 없습니다. ID: " + productId.id());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.demo.exception;

public class UnauthorizedException extends RuntimeException {
public UnauthorizedException() {
super("인증이 필요합니다.");
}
}
Loading