1단계 (필수)
1. 제품 등록과 구매는 회원만 가능합니다.
2. 비회원은 등록된 제품의 목록조회와 상세조회만 가능합니다.
3. 등록된 제품에는 "제품명", "가격", "예약상태"가 포함되어야하고, 목록조회와 상세조회시에 예약상태를 포함해야합니다.
4. 제품의 상태는 "판매중", "예약중", "완료" 세가지가 존재합니다.
5. 구매자가 제품의 상세페이지에서 구매하기 버튼을 누르면 거래가 시작됩니다.
6. 판매자와 구매자는 제품의 상세정보를 조회하면 당사자간의 거래내역을 확인할 수 있습니다.
7. 모든 사용자는 내가 "구매한 용품(내가 구매자)"과 "예약중인 용품(내가 구매자/판매자 모두)"의 목록을 확인할 수 있습니다.
8. 판매자는 거래진행중인 구매자에 대해 '판매승인'을 하는 경우 거래가 완료됩니다.
2단계 (선택)
1. 제품에 수량이 추가됩니다. 제품정보에 "제품명", "가격", "예약상태", "수량"이 포함되어야합니다.
2. 다수의 구매자가 한 제품에 대해 구매하기가 가능합니다. (단, 한 명이 구매할 수 있는 수량은 1개뿐입니다.)
3. 구매확정의 단계가 추가됩니다. 구매자는 판매자가 판매승인한 제품에 대해 구매확정을 할 수 있습니다.
4. 거래가 시작되는 경우 수량에 따라 제품의 상태가 변경됩니다.
- 추가 판매가 가능한 수량이 남아있는 경우 - 판매중
- 추가 판매가 불가능하고 현재 구매확정을 대기하고 있는 경우 - 예약중
- 모든 수량에 대해 모든 구매자가 모두 구매확정한 경우 - 완료
5. "구매한 용품"과 "예약중인 용품" 목록의 정보에서 구매하기 당시의 가격 정보가 나타나야합니다.
- 예) 구매자 A가 구매하기 요청한 당시의 제품 B의 가격이 3000원이었고 이후에 4000원으로 바뀌었다 하더라도 목록에서는 3000원으로 나타나야합니다.
요구사항을 정리해보면
등록하고, 구매자가 제품을 구매하면, 판매자가 판매승인을 하면, 구매자가 구매확정하는 API판매자가 제품 등록을 할 때 Product 객체가 생성되고, 구매자가 구매하기를 할 때 Orders 객체가 생성되도록 구현했다.
Product 객체
: 제품의 정보를 관리하기 위해 제품명, 가격, 예약상태, 수량 등의 속성을 포함한다.
이는 제품 등록, 목록 조회, 상세 조회에서 사용된다.
Order 객체
: 주문 정보를 관리하기 위해 구매자, 주문 상태, 주문 당시의 가격 등을 포함한다.
이는 구매하기, 내 구매 내역 확인, 내 판매 내역 확인, 판매 승인, 구매 확정에서 사용된다.
Orders 테이블에 구매자와 판매자 아이디를 각각 저장하는 것에 대해 고민이 많았다.
결론은 한 멤버가 동시에 구매자와 판매자가 될 수 있기 때문에 각 거래에서 구매자와 판매자를 별도로 저장해서 역할을 명확히 구분하고 쉽게 식별할 수 있도록 하였다. 또한 거래 내역 조회 시에도 쉽게 필터링 할 수 있고, 추후 확장성을 고려했을 때 각각 저장하는 것이 좋다고 판단했다.
물론, 구매자와 판매자의 정보를 동시에 처리해야 할 때 쿼리가 복잡해진다는 단점이 있다. 따라서 MemberId로만 저장했을 때의 단순성을 보장해주어야 하나 고민이 되었다. 그러나 한 거래 내에서 멤버의 역할이 명확하지 않아 데이터 무결성 문제가 발생할 가능성이 커지게 되기 때문에 각각 저장하는 방식을 선택하였다.

포스트맨 : https://zrr.kr/VR7d
| Domain | Method | URI | Description |
|---|---|---|---|
| Member | POST | /auth/sign-in | 로그인 |
| POST | /api/v1/members/sign-up | 회원가입 | |
| POST | /api/v1/members/reissue | 토큰 재발급 | |
| Product | POST | /api/v1/products | 제품 등록 |
| GET | /api/v1/productsList | 제품 목록 조회 | |
| GET | /api/v1/productsList/{productId} | 제품 상세(단건) 조회 | |
| Order | POST | /api/v1/orders | 구매하기 |
| GET | /api/v1/orders/my-purchases | 구매자 거래 내역 확인 : 내 구매 내역 상태별 조회 | |
| GET | /api/v1/orders/my-sales | 판매자 거래 내역 확인 : 판매 승인을 위한 내역 조회 | |
| PATCH | /api/v1/orders/{orderId} | 판매 승인 | |
| PATCH | /api/v1/orders/{orderId}/confirm | 구매 확정 |
1. 회원가입 / 로그인 : Spring Security + JWT + Redis
• 이전 포스트 참고 - https://zrr.kr/8rc9
2. 제품 등록 및 목록 조회
• 등록시 productStatus(Defalt :판매중)으로 설정했다.{ "name" : "제품1", "price" : "1000", "quantity" : "2" }
a. 제품 등록 및 목록 조회
• RegisterRequestDto를 사용해서 필수 입력값의 유효성 처리를 했다.
@Getter
@Setter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RegisterRequestDto {
@NotBlank(message = "제품 이름은 필수입니다.")
private String name;
@Min(value = 1, message = "가격은 0보다 커야 합니다.")
private int price;
@Min(value = 1, message = "수량은 0보다 커야 합니다.")
private int quantity;
public static Product from(RegisterRequestDto registrationDto, Member member) {
return Product.builder()
.name(registrationDto.getName())
.price(registrationDto.getPrice())
.quantity(registrationDto.getQuantity())
.productStatus(ProductStatus.판매중)
.member(member)
.build();
}
}
• 목록 조회의 경우 JPA의 Pageable 을 사용해서 페이징 처리를 하였고, 이 부분에 대해서는 거래 내역 조회에서 구현 방법을 정리하였다.
3. 구매하기
• 다수의 최대 구매 요청 횟수는 제품 수량을 한도로 정하였다.
• 제품 수량을 기준으로 이를 초과하여 구매 요청이 있다면제품상태를 판매중 → 예약중으로 업데이트
• 제품 수량보다 적게 구매 요청이 있다면제품상태를 판매중 유지
a. 구매하기 요청이 들어오면 Orders 객체를 생성한다.
• @PathVariable로 Id만 가져오려다 추후 결제 모듈을 붙이게 될 가능성을 열어두고 DTO로 받아왔다.
@Getter
@Setter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderRequestDto {
@NotNull(message = "제품 아이디는 필수입니다.")
private Long productId;
public static Orders from(OrderRequestDto dto, Member buyer, Member seller, Product product) {
return Orders.builder()
.orderPrice(product.getPrice())
.orderStatus(OrderStatus.거래시작)
.buyer(buyer)
.seller(seller)
.product(product)
.build();
}
}
b. 구매 요청 검증 | 다수의 최대 구매 요청 횟수 제한
• 현재 제품에 대한 거래 요청 수와 제품의 총 수량을 비교해서 최대 한도를 정했다.
ex. 제품 총 수량은 5개인데, 제품 요청 수가 7개일 경우 2명은 판매취소를 받게 되는데, 사용자 관점에서 구매하기를 할 때 현재 거래 진행 중임을 알려주는 것이 더 맞다고 판단했다.
• 이 때, 개인의 최대 구매 요청 횟수는 1번 이다.
• 예외 처리 : 판매자가 자신의 제품을 구매할 수 없도록 하고, 이미 구매 요청을 한 경우 중복 구매를 방지하며, 현재 구매 가능한 제품 수량이 없거나 모두 거래 진행 중인 경우
// OrderServiceImpl의 createOrder 메소드 내부
validateOrderRequest(buyer, product);
private void validateOrderRequest(Member buyer, Product product) {
if (product.getMember().equals(buyer)) {
throw new SelfPurchaseException("판매자는 자신의 제품을 구매할 수 없습니다.");
}
// 현재 제품에 대한 거래 요청 수
long pendingOrderCount = orderRepository.countByProductAndOrderStatusNot(product, OrderStatus.거래확정);
// 제품의 총 수량과 거래 요청 수를 비교
if (product.getQuantity() - pendingOrderCount <= 0) {
throw new ProductUnavailableException("현재 구매 가능한 제품 수량이 없거나 모두 거래 진행 중입니다.");
}
if (orderRepository.existsByBuyerAndProduct(buyer, product)) {
throw new DuplicatePurchaseException("이미 구매한 제품은 중복 구매할 수 없습니다.");
}
}
c. 제품 상태 변경
• 다수의 사용자가 하나의 제품에 구매를 하는 상황이기에 제품 수량을 기준으로 제품 상태가 변경되는 것을 적용해야했다. 이를 통해 제품이 실제로 구매 가능한 상태인지, 아니면 모든 수량이 예약 중인지를 정확히 판단할 수 있다.
→ 현재 구매하기 단계에서는 제품 상태가 거래확정일 경우가 없기 때문에 이 경우를 제외하여 count하고 예외 처리를 하였다.
• 제품 상태는 updateProductStatusOnOrderCreation를 통해 업데이트 하였다.
// OrderServiceImpl의 createOrder 메소드 내부
long orderCount = orderRepository.countByProductAndOrderStatusNot(product, OrderStatus.거래확정);
product.updateProductStatusOnOrderCreation(orderCount);
// Products
public void updateProductStatusOnOrderCreation(long orderCount) {
if (this.quantity == orderCount) {
this.updateProductStatus(ProductStatus.예약중);
} else {
this.updateProductStatus(ProductStatus.판매중);
}
}
4. 판매자의 판매승인
• 구매 확정이 된 것이 아니기 때문에 판매승인이 이루어져도 제품 수량에 변동은 없다.
• 판매 승인 시,주문상태를 판매승인으로 변경했다.
a. 판매 승인
• 판매자인지, 중복 승인은 하지 않았는지 유효성 체크를 하고, 해당 주문에 대해 주문상태 updateOrderStatus 로 업데이트하였다.
→ 판매 승인 할 때 제품 수량도 변경해주어야 하는지 고민이 많았는데 구매확정까지가 거래의 최종 단계이고, 예약중 상태를 관리할 때 복잡성이 더 증가하여 판매 승인 시는 주문상태만 업데이트 해주었다.
// Orders
public void updateOrderStatus(OrderStatus orderStatus) {
this.orderStatus = orderStatus;
}
5. 구매자의 구매확정
• 구매 확정 시, 제품 수량을 감소시켰고
•주문상태가 모두 거래 확정일 때제품상태는 예약중 → 완료로
a. 제품 수량 감소
• 구매 확정 시 제품 수량decreaseQuantity 로 감소시켰다.
// OrderServiceImpl의 confirmPurchase 메소드 내부
Product product = order.getProduct();
product.decreaseQuantity(1);
// Products
public void decreaseQuantity(int amount) {
if (this.quantity < amount) {
throw new NotEnoughQuantityException("현재 남은 수량이 없습니다.");
}
this.quantity -= amount;
}
b. 주문 상태 업데이트
• 구매 확정 시 주문 상태를 updateOrderStatus를 통해 거래확정으로 업데이트하였다.
// OrderServiceImpl의 confirmPurchase 메소드 내부
order.updateOrderStatus(OrderStatus.거래확정);
// Orders
public void updateOrderStatus(OrderStatus orderStatus) {
this.orderStatus = orderStatus;
}
c. 제품 상태 업데이트
• 구매 확정 시 제품 상태를 updateProductStatus를 통해 모든 주문이 거래확정 상태인지, 제품의 남은 수량이 있는지, 대기 중인 주문이 있는지를 확인한다.
• updateProductStatusOnOrders 를 호출하여 제품 상태를 완료, 예약중, 판매중 중 하나로 업데이트하여 상태를 변경한다.
// OrderServiceImpl의 confirmPurchase 메소드 내부
updateProductStatus(product);
// OrderServiceImpl
private void updateProductStatus(Product product) {
// 모든 주문이 거래확정 상태인지 확인
boolean allOrdersConfirmed = orderRepository.countByProductAndOrderStatus(product, OrderStatus.거래확정) == orderRepository.countByProduct(product);
// 제품의 남은 수량 확인
boolean isQuantityAvailable = product.getQuantity() > 0;
// 대기중인 주문이 있거나, 남은 수량이 있을 경우
boolean hasPending = orderRepository.existsByProductAndOrderStatus(product, OrderStatus.판매승인) || orderRepository.existsByProductAndOrderStatus(product, OrderStatus.거래확정);
product.updateProductStatusOnOrders(allOrdersConfirmed, isQuantityAvailable, hasPending);
}
// Product
public void updateProductStatusOnOrders(boolean allOrdersConfirmed, boolean isQuantityAvailable, boolean hasPending) {
if (allOrdersConfirmed && !isQuantityAvailable) {
this.updateProductStatus(ProductStatus.완료);
} else if (isQuantityAvailable || hasPending) {
this.updateProductStatus(hasPending ? ProductStatus.예약중 : ProductStatus.판매중);
}
}
6. 거래내역 조회
• 구매자 : 내 구매 내역 상태별 조회 (주문 상태: 거래시작 , 판매승인 , 거래확정 )
• 판매자 : 판매 승인을 위한 내역 조회 (내가 판매 중인 제품의 구매 요청이 온 내역)
a. 제품 상태 업데이트
• 내 구매 내역은 주문상태(거래시작, 판매승인, 거래확정)에 따라 조회할 수 있고, BuyerPurchaseHistoryResponseDto 에 담아 페이징 처리하여 전송하였다.
• 내 판매 내역은 판매승인한 목록이 아닌, 구매 요청이 온 내역들만 SalesApprovalListResponseDto에 담아 페이징 처리하여 전송하였다.
b. 페이징 처리
페이징 처리를 제네릭을 사용해서 해보았다.
• Spring Data JPA와 Spring Web의 기능을 활용하여 페이징 처리를 구현하였다. Pageable 인터페이스와 @PageableDefault 어노테이션을 사용하여 기본 페이지 크기를 설정하였다.
• 제네릭을 사용하였더니 응답 코드가 훨씬 간결해진 것을 확인할 수 있었다.
@Getter
@Setter
public class PageResponse<T> {
private List<T> content;
private int pageNumber;
private int pageSize;
private int totalPages;
private long totalElements;
public PageResponse(Page<T> page) {
this.content = page.getContent();
this.pageNumber = page.getNumber() + 1;
this.pageSize = page.getSize();
this.totalPages = page.getTotalPages();
this.totalElements = page.getTotalElements();
}
}
• 제네릭 도입하기 전
{
"content": [
{
"orderId": 5,
"orderPrice": 200,
"orderStatus": "거래시작"
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 5,
"sort": {
"empty": false,
"unsorted": false,
"sorted": true
},
"offset": 0,
"unpaged": false,
"paged": true
},
"last": true,
"totalPages": 1,
"totalElements": 1,
"first": true,
"size": 5,
"number": 0,
"sort": {
"empty": false,
"unsorted": false,
"sorted": true
},
"numberOfElements": 1,
"empty": false
}
• 제네릭 도입 후
{
"content": [
{
"orderId": 5,
"orderPrice": 200,
"orderStatus": "거래시작"
}
],
"pageNumber": 1,
"pageSize": 5,
"totalPages": 1,
"totalElements": 1
}
이렇게 주어진 시간 내에 사전 과제를 수행하며 거래 상태를 구현해 보았는데, 아쉬운 부분이 있었다.
정적 팩토리 메서드의 위치에 대해, 그리고 전역에서 예외 처리하던 것도 좀 더 최적화된 방법이 있는지, 쿼리가 복잡하다 보니 무한 루프가 돌아 JOIN FETCH으로 해결했던 점에 대해 다음 포스트에서 다루면서 코드를 개선해 볼 계획이다.
무엇보다 테스트 코드를 작성해서 동시성 테스트를 우선적으로 진행해 볼 것이다.