시작하기 전에
싸다9는 2023년 8월부터 11월까지 진행했던 프로젝트로 자취생을 위한 할인 판매 서비스이다. 과도한 트래픽을 처리해보는 경험을 하고 싶어 오후 9시부터 여러 자취생품을 80% 할인해서 선착순으로 판매하자는 전략을 세웠다. 결과는 1분 안에 모든 재고가 다 팔릴 정도로 인기가 많았으며 단시간에 매우 많은 요청이 들어오게 하는 데 성공하였다.
이 프로젝트를 다시 개발해보면서 Spring 지식, 트래픽 처리를 위한 Lock 개념, AWS를 활용한 서버와 DB 세팅, 프런트 스킬까지 되돌아보려고 한다.
I. Item 공사
저번 글 마지막에서 이렇게 깨달았다.
이렇게 Controller를 만들다 보니 RestController로 만들면 복잡하게 만들 필요가 없다는 점이 기억났다. Controller를 RestController로 바꾸다 보니, Repository에서도 JPARepository를 extend해서 받으면 CRUD(Create, Read, Update, Delete) 요청에 대해 코드를 따로 작성할 필요가 없다는 점도 기억났다. 아무래도 전체 구조에 공사가 필요하겠다. 다음 글에서 해보자.
Item부터 구조를 효율적으로 수정해보자.
domain / Item.java
package powersell.cheapat9.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.format.annotation.DateTimeFormat;
import powersell.cheapat9.exception.NotEnoughStockException;
import java.time.LocalDateTime;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자 보호
public class Item {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int originalPrice;
private int price; // 할인된 가격
private int stockQuantity;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endDate;
/**
* 상품 생성 메서드
*/
public static Item createItem(String name, int originalPrice, int price, int stockQuantity,
LocalDateTime startDate, LocalDateTime endDate) {
Item item = new Item();
item.name = name;
item.originalPrice = originalPrice;
item.price = price;
item.stockQuantity = stockQuantity;
item.startDate = startDate;
item.endDate = endDate;
return item;
}
/**
* 상품 수정 메서드
*/
public void updateItem(String name, int originalPrice, int price, int stockQuantity,
LocalDateTime startDate, LocalDateTime endDate) {
this.name = name;
this.originalPrice = originalPrice;
this.price = price;
this.stockQuantity = stockQuantity;
this.startDate = startDate;
this.endDate = endDate;
}
/**
* 재고 수정 메서드
*/
public void decreaseStock(int quantity) {
if (this.stockQuantity - quantity < 0) {
throw new NotEnoughStockException("재고가 부족합니다.");
}
this.stockQuantity -= quantity;
}
}
- NoArgsConstructor(access = AccessLevel.PROTECTED): 무엇을 PROTECTED로 설정하며, 왜 이렇게 해야 하는지에 집중하자. 기본 생성자(파라미터 없는 생성자)를 protected로 설정하여 외부에서 직접 호출을 막는다. Public 생성자로 두면, 아무데서나 new Item()을 통해 객체를 만들 수 있어 유지보수성이 떨어진다. Protected로 제한하면 JPA는 내부적으로 접근 가능하지만, 개발자가 직접 호출할 수 없도록 제한된다.
- @GeneratedValue(strategy = GenerationType.IDENTITY): 기본 키(primary key, PK)를 자동으로 생성하도록 설정하며, GenerationType.IDENTITY는 AUTO_INCREMENT 방식으로 생성하는 것을 의미한다.
- 상품 생성, 상품 수정, 재고 수정, 할인율 계산 메서드를 도메인에서 정의하는 이유
- 상품 생성 메서드: 다른 파일에서 객체를 생성할 때 new Item()을 직접 호출할 수 없도록 한다.
- 상품 수정 메서드: 이전에는 ItemService에서 직접 item.setName(...) 등 Setter를 사용했다. 현재 방식으로 updateItem() 메서드를 도메인에서 정의하면 나중에는 한 번에 모든 값을 업데이트할 수 있어 유지보수성이 향상된다.
repository / ItemRepository.java
package powersell.cheapat9.repository;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import powersell.cheapat9.domain.Item;
import java.util.List;
public interface ItemRepository extends JpaRepository<Item, Long> {
// 특정 ID로 상품 조회 (LOCK 사용 - 동시성 제어)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Item i WHERE i.id = :id")
Item findByIdWithLock(@Param("id") Long id);
// 모든 상품 조회 (페치 조인 추가 가능)
List<Item> findAll();
}
- JpaRepository: 이걸 상속하면 기본적인 CRUD 메서드를 자동으로 제공해준다.
- @Query: JPQL로 직접 쿼리를 작성할 수 있다. 테이블(item)이 아니라 도메인 엔티티(item)을 기준으로 작성하는 것이기 때문에 테이블 이름과 컬럼명이 바뀌더라도 쿼리를 직접 수정할 필요가 없다.
dto / item / ItemRequestDto.java
package powersell.cheapat9.dto.item;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter
public class ItemRequestDto {
private String name;
private int originalPrice;
private int price;
private int stockQuantity;
private String startDate;
private String endDate;
}
SpringBoot에서 DTO(Data Transfer Object)를 사용하는 이유
- 보안 문제 및 네트워크 성능 최적화: 도메인 엔티티(Item)를 그대로 반환하면 불필요한 정보(예, 비밀번호)가 노출될 가능성이 있다. 하지만 DTO를 사용하면 필요한 데이터만 클라이언트에 반환 가능하다. 또한 필요한 데이터만 전송할 수 있다면 네트워크 부하도 줄일 수 있다.
- 엔티티 변경으로 인한 API 변화 방지: 도메인 엔티티를 그대로 반환하면 Item 엔티티 구조가 바뀌었을 때 이를 직접 사용하는 모든 API도 영향을 받게 된다. 하지만 DTO를 사용하면 엔티티의 변경이 API 응답 데이터에 직접적인 영향을 주지 않는다.
- 요청과 응답 분리: ItemRequestDto는 클라이언트에서 서버로 데이터를 보낼 때 사용하며, ItemResponseDto는 서버에서 클라이언트로 데이터를 반환할 때 사용함으로써 API 구조를 깔끔하게 유지할 수 있다.
dto / item / ItemResponseDto.java
package powersell.cheapat9.dto.item;
import lombok.Getter;
import powersell.cheapat9.domain.Item;
@Getter
public class ItemResponseDto {
private Long id;
private String name;
private int originalPrice;
private int price;
private int stockQuantity;
private String startDate;
private String endDate;
private int discountRate;
public ItemResponseDto(Item item) {
this.id = item.getId();
this.name = item.getName();
this.originalPrice = item.getOriginalPrice();
this.price = item.getPrice();
this.stockQuantity = item.getStockQuantity();
this.startDate = item.getStartDate().toString();
this.endDate = item.getEndDate().toString();
this.discountRate = calculateDiscountRate(item.getOriginalPrice(), item.getPrice());
}
private int calculateDiscountRate(int originalPrice, int price) {
if (originalPrice == 0) return 0;
return ((originalPrice - price) * 100) / originalPrice;
}
}
service / ItemService.java
package powersell.cheapat9.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import powersell.cheapat9.domain.Item;
import powersell.cheapat9.dto.item.ItemRequestDto;
import powersell.cheapat9.dto.item.ItemResponseDto;
import powersell.cheapat9.exception.NotEnoughStockException;
import powersell.cheapat9.repository.ItemRepository;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ItemService {
private final ItemRepository itemRepository;
/**
* 상품 등록
*/
@Transactional
public Long saveItem(ItemRequestDto requestDto) {
Item item = Item.createItem(
requestDto.getName(),
requestDto.getOriginalPrice(),
requestDto.getPrice(),
requestDto.getStockQuantity(),
LocalDateTime.parse(requestDto.getStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
LocalDateTime.parse(requestDto.getEndDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
);
itemRepository.save(item);
return item.getId();
}
/**
* 상품 수정
*/
@Transactional
public void updateItem(Long id, ItemRequestDto requestDto) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Item not found"));
item.updateItem(
requestDto.getName(),
requestDto.getOriginalPrice(),
requestDto.getPrice(),
requestDto.getStockQuantity(),
LocalDateTime.parse(requestDto.getStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
LocalDateTime.parse(requestDto.getEndDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
);
}
/**
* 상품 재고 수정
*/
@Transactional
public void updateItemStock(Long itemId, int quantity) {
Item item = itemRepository.findById(itemId)
.orElseThrow(() -> new IllegalArgumentException("상품을 찾지 못했습니다."));
item.decreaseStock(quantity);
}
/**
* 전체 상품 조회
*/
public List<ItemResponseDto> findAllItems() {
return itemRepository.findAll().stream()
.map(ItemResponseDto::new)
.collect(Collectors.toList());
}
/**
* 개별 상품 조회
*/
public ItemResponseDto findItem(Long id) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("상품을 찾지 못했습니다."));
return new ItemResponseDto(item);
}
}
- findAllItems(): findAll()은 List<Item>을 반환한다. .stream()을 사용하여 List<Item>을 Stream으로 반환한다. .map(ItemResponseDto::new)는 ItemResponseDto 생성자를 호출하여 Item 객체를 ItemResponseDto로 변환한다. .collect(Collectors.toList())는 변환된 데이터를 다시 List<ItemResponseDto>로 수집한다.
Stream이란, Java에서 컬렉션(List, Set, Map 등) 데이터를 처리하는 기능을 제공하는 API이다. 기존 for문을 사용하는 방식보다 더 간결하고 가독성이 좋은 코드를 작성할 수 있다는 장점이 있다.
controller / ItemController.java
package powersell.cheapat9.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import powersell.cheapat9.dto.item.ItemRequestDto;
import powersell.cheapat9.dto.item.ItemResponseDto;
import powersell.cheapat9.service.ItemService;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/items")
public class ItemController {
private final ItemService itemService;
/**
* 전체 상품 조회
*/
@GetMapping
public ResponseEntity<List<ItemResponseDto>> getItems() {
return ResponseEntity.ok(itemService.findAllItems());
}
/**
* 개별 상품 조회
*/
@GetMapping("/{id}")
public ResponseEntity<ItemResponseDto> getItem(@PathVariable Long id) {
return ResponseEntity.ok(itemService.findItem(id));
}
/**
* 상품 추가
*/
@PostMapping
public ResponseEntity<Long> createItem(@RequestBody @Valid ItemRequestDto requestDto) {
return ResponseEntity.ok(itemService.saveItem(requestDto));
}
/**
* 상품 수정
*/
@PutMapping("/{id}")
public ResponseEntity<Void> updateItem(@PathVariable Long id, @RequestBody @Valid ItemRequestDto requestDto) {
itemService.updateItem(id, requestDto);
return ResponseEntity.ok().build();
}
/**
* 상품 재고 수정
*/
@PatchMapping("/{id}/stock")
public ResponseEntity<Void> updateItemStock(@PathVariable Long id, @RequestParam int quantity) {
itemService.updateItemStock(id, quantity);
return ResponseEntity.ok().build();
}
}
- @RestContoller: SpringBoot에서 RESTful API를 만들 때 사용하는 어노테이션이다. JSON 응답을 자동으로 반환하며, HTML 뷰가 아닌 데이터만 반환한다.
- @RequestMapping: 클래스 또는 메서드에 공통된 URL 경로를 매핑한다.
- ResponseEntity.ok().build(): ResponseEntity란, SpringBoot에서 HTTP 응답을 다루는 클래스다. ResponseEntity.ok()는 HTTP 200 OK 응답을 의미한다. build()는 본문(Body) 없이 응답하는 것이다.
- @PatchMapping: HTTP PATCH 요청을 처리하는 어노테이션이며, 자원의 일부만 변경할 떄 사용한다. 비슷한 개념인 @PutMapping은 상품 정보를 전체 수정한다.
service / ItemServiceTest.java
package powersell.cheapat9.service;
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 powersell.cheapat9.dto.item.ItemRequestDto;
import powersell.cheapat9.dto.item.ItemResponseDto;
import powersell.cheapat9.exception.NotEnoughStockException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(properties = "admin.password=test1234") // 비밀번호 테스트가 잘 작동하지 않아서
@Transactional
public class ItemServiceTest {
@Autowired ItemService itemService;
/**
* 1. 상품 저장 테스트
*/
@Test
public void saveItemTest() {
// given
ItemRequestDto requestDto = createItemRequestDto();
// when
Long savedItemId = itemService.saveItem(requestDto);
// then
ItemResponseDto foundItem = itemService.findItem(savedItemId);
assertNotNull(foundItem);
assertEquals("Test Item", foundItem.getName());
assertEquals(10000, foundItem.getOriginalPrice());
assertEquals(8000, foundItem.getPrice());
}
/**
* 2. 상품 수정 테스트
*/
@Test
public void updateItemTest() {
// given
Long savedItemId = itemService.saveItem(createItemRequestDto());
ItemRequestDto updateRequestDto = new ItemRequestDto();
updateRequestDto.setName("Updated Test Item");
updateRequestDto.setOriginalPrice(12000);
updateRequestDto.setPrice(10000);
updateRequestDto.setStockQuantity(5);
updateRequestDto.setStartDate("2025-02-15 21:00:00");
updateRequestDto.setEndDate("2025-02-15 23:00:00");
// when
itemService.updateItem(savedItemId, updateRequestDto);
// then
ItemResponseDto updatedItem = itemService.findItem(savedItemId);
assertEquals("Updated Test Item", updatedItem.getName());
assertEquals(12000, updatedItem.getOriginalPrice());
assertEquals(10000, updatedItem.getPrice());
assertEquals(5, updatedItem.getStockQuantity());
// LocalDateTime을 포맷 변경 없이 그대로 가져오기
String actualStartDate = updatedItem.getStartDate().replace("T", " ") + ":00";
String actualEndDate = updatedItem.getEndDate().replace("T", " ") + ":00";
// 기대하는 날짜 값
String expectedStartDate = "2025-02-15 21:00:00";
String expectedEndDate = "2025-02-15 23:00:00";
// 문자열 그대로 비교
assertEquals(expectedStartDate, actualStartDate);
assertEquals(expectedEndDate, actualEndDate);
}
/**
* 3. 상품 조회 테스트
*/
@Test
public void findItemTest() {
// given
Long savedItemId = itemService.saveItem(createItemRequestDto());
// when
ItemResponseDto foundItem = itemService.findItem(savedItemId);
// then
assertNotNull(foundItem);
assertEquals("Test Item", foundItem.getName());
assertEquals(10000, foundItem.getOriginalPrice());
assertEquals(8000, foundItem.getPrice());
}
/**
* 4. 전체 상품 조회 테스트
*/
@Test
public void findAllItemsTest() {
// given
itemService.saveItem(createItemRequestDto("Item 1"));
itemService.saveItem(createItemRequestDto("Item 2"));
// when
List<ItemResponseDto> items = itemService.findAllItems();
// then
assertEquals(2, items.size());
assertEquals("Item 1", items.get(0).getName());
assertEquals("Item 2", items.get(1).getName());
}
/**
* 5. 상품 재고 수정 테스트
*/
@Test
public void updateItemStockTest() {
// given
Long savedItemId = itemService.saveItem(createItemRequestDto());
// when
itemService.updateItemStock(savedItemId, 3);
ItemResponseDto updatedItem = itemService.findItem(savedItemId);
// then
assertEquals(7, updatedItem.getStockQuantity());
}
/**
* 6. 재고 부족 예외 테스트
*/
@Test
public void updateItemStock_throwsExceptionWhenNotEnoughStock() {
// given
Long savedItemId = itemService.saveItem(createItemRequestDto());
// then
assertThrows(NotEnoughStockException.class, () -> {
itemService.updateItemStock(savedItemId, 15); // 재고 부족 테스트
});
}
/**
* 테스트에서 중복되는 ItemRequestDto 생성 메서드
*/
private ItemRequestDto createItemRequestDto() {
return createItemRequestDto("Test Item");
}
private ItemRequestDto createItemRequestDto(String name) {
ItemRequestDto requestDto = new ItemRequestDto();
requestDto.setName(name);
requestDto.setOriginalPrice(10000);
requestDto.setPrice(8000);
requestDto.setStockQuantity(10);
requestDto.setStartDate("2025-02-12 21:00:00");
requestDto.setEndDate("2025-02-12 23:00:00");
return requestDto;
}
}
Test 결과
II. Order 공사
Order 엔티티의 구조도 효율적으로 수정해보자.
domain / Order.java
package powersell.cheapat9.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
private int count; // 주문량
private int orderPrice;
private String name;
private String number; // 전화번호 (10, 11 digits)
private String zipcode;
private String address;
private String dongho;
private String pw; // 비밀번호 (4자리)
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime orderDate;
}
domain / OrderStatus.java
package powersell.cheapat9.domain;
public enum OrderStatus {
WAITING, DELIVERING, ARRIVED, CANCELED // 입금대기, 배송중, 도착, 취소
}
repository / OrderRepository.java
package powersell.cheapat9.repository;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import powersell.cheapat9.domain.Order;
import java.util.List;
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
/**
* 특정 ID로 주문 조회
*/
@Query("SELECT o FROM Order o JOIN FETCH o.item WHERE o.id = :id")
Order findByIdWithItem(@Param("id") Long id);
/**
* 모든 주문 조회 (상품과 함께 페치 조인)
*/
@Query("SELECT o FROM Order o JOIN FETCH o.item")
List<Order> findAllWithItems();
/**
* 전화번호로 주문 조회
*/
@Query("SELECT o FROM Order o JOIN FETCH o.item WHERE o.number = :number")
List<Order> findAllByNumber(@Param("number") String number);
}
dto / order / OrderRequestDto.java
package powersell.cheapat9.dto.order;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class OrderRequestDto {
@NotNull(message = "상품 ID는 필수입니다.")
private Long itemId;
@NotNull(message = "주문 수량은 필수입니다.")
private int count; // 주문량
@NotEmpty(message = "이름 입력은 필수입니다.")
private String name;
@NotEmpty(message = "전화번호 입력은 필수입니다.")
private String number;
@NotEmpty(message = "우편번호 입력은 필수입니다.")
private String zipcode;
@NotEmpty(message = "주소 입력은 필수입니다.")
private String address;
@NotEmpty(message = "동호수 입력은 필수입니다.")
private String dongho;
@NotEmpty(message = "비밀번호 입력은 필수입니다.")
private String pw;
}
OrderRequestDto에는 사용자가 주문 시 입력해야 할 정보들만 담아두면 된다.
dto / order / OrderResponseDto.java
package powersell.cheapat9.dto.order;
import lombok.Getter;
import powersell.cheapat9.domain.Order;
import powersell.cheapat9.domain.OrderStatus;
import java.time.LocalDateTime;
@Getter
public class OrderResponseDto {
private Long id;
private String itemName;
private int count;
private int orderPrice;
private String name;
private String number;
private String zipcode;
private String address;
private String dongho;
private String pw;
private OrderStatus status;
private LocalDateTime orderDate;
public OrderResponseDto(Order order) {
this.id = order.getId();
this.itemName = order.getItem().getName();
this.count = order.getCount();
this.orderPrice = order.getOrderPrice();
this.name = order.getName();
this.number = order.getNumber();
this.zipcode = order.getZipcode();
this.address = order.getAddress();
this.dongho = order.getDongho();
this.pw = order.getPw();
this.status = order.getStatus();
this.orderDate = order.getOrderDate();
}
}
service / OrderService.java
package powersell.cheapat9.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import powersell.cheapat9.domain.Item;
import powersell.cheapat9.domain.Order;
import powersell.cheapat9.domain.OrderStatus;
import powersell.cheapat9.dto.order.OrderRequestDto;
import powersell.cheapat9.dto.order.OrderResponseDto;
import powersell.cheapat9.repository.OrderRepository;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final ItemService itemService;
/**
* 주문 저장
*/
@Transactional
public Long saveOrder(OrderRequestDto requestDto) {
Item item = itemService.getItem(requestDto.getItemId());
Order order = Order.createOrder(
item,
requestDto.getCount(),
requestDto.getName(),
requestDto.getNumber(),
requestDto.getZipcode(),
requestDto.getAddress(),
requestDto.getDongho(),
requestDto.getPw()
);
orderRepository.save(order);
itemService.updateItemStock(item.getId(), requestDto.getCount());
return order.getId();
}
/**
* 전체 주문 조회
*/
public List<OrderResponseDto> findAllOrders() {
return orderRepository.findAllWithItems().stream()
.map(OrderResponseDto::new)
.collect(Collectors.toList());
}
/**
* 개별 주문 조회
*/
public OrderResponseDto findOrder(Long id) {
Order order = orderRepository.findByIdWithItem(id);
return new OrderResponseDto(order);
}
/**
* 전화번호로 주문 조회
*/
public List<OrderResponseDto> findOrdersByNumber(String number) {
return orderRepository.findAllByNumber(number).stream()
.map(OrderResponseDto::new)
.collect(Collectors.toList());
}
/**
* 주문 상태 변경
*/
@Transactional
public void updateOrderStatus(Long id, OrderStatus orderStatus) {
Order order = orderRepository.findByIdWithItem(id);
order.updateOrderStatus(orderStatus);
}
/**
* 주문 정보 수정
*/
@Transactional
public void updateOrder(Long id, OrderRequestDto requestDto) {
Order order = orderRepository.findByIdWithItem(id);
order.updateOrder(
requestDto.getCount(),
requestDto.getName(),
requestDto.getNumber(),
requestDto.getZipcode(),
requestDto.getAddress(),
requestDto.getDongho(),
requestDto.getPw()
);
}
}
security / SecurityConfig.java
package powersell.cheapat9.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/* Password 암호화 */
@Value("${admin.password}")
private String password;
/**
* 비밀번호 암호화 설정
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 임시 관리자 계정 생성
*/
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(
User.withUsername("admin")
.password(passwordEncoder.encode(password))
.roles("ADMIN")
.build()
);
return manager;
}
/**
* 보안 설정
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.disable()) // CORS 비활성화
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/") // 로그아 성공시 이동할 URL
.invalidateHttpSession(true) // 세션 무효화
);
return http.build();
}
}
- 사용자가 본인이 주문했던 상품들의 목록과 배송 상태를 확인할 수 있는 페이지를 생각하게 되었고, 비밀번호 시스템을 만들게 되었다.
- 비밀번호 암호화 설정: Spring Security에서 비밀번호를 BCrypt 방식으로 암호화하기 위해 사용한다. 사용자의 비밀번호를 데이터베이스에 평문 저장하는 것이 아닌, 해시화된 값으로 저장할 수 있다.
- 임시 관리자 계정 생성: 메모리 기반 사용자 관리(InMemoryUserDetailsManager)를 활용하여 관리자 계정을 생성한다. "admin" 계정을 만들고, @Value("${admin.password}")에 저장된 비밀번호를 암호화하여 저장한다. roles("ADMIN")으로 ADMIN 권한을 부여하여 /api/admin/** 경로로 접근이 가능하도록 한다.
- 보안 설정
- CORS(Cross-Origin Resource Sharing) 비활성화: API 서버와 클라이언트가 다른 도메인에서 동작하는 경우 CORS 정책이 필요하지만, 여기서는 비활성화한다.
- CSRF(Cross-Site Request Forgery) 비활성화: 일반적인 REST API 서버에서는 CSRF 보호가 필요하지 않다.
- .authorizeHttpRequests(...): .requestMatchers(...).hasRole("ADMIN")은 /api/admin/** 경로로 오는 요청은 "ADMIN" 역할을 가진 사용자만 접근 가능하도록 한다. .anyRequest().permitAll()은 그 외 모든 요청은 인증 없이 허용하도록 한다.
- .formLogin(...): /login으로 접근하면 Spring Security의 기본 로그인 페이지 대신 별도로 구현한 로그인 페이지가 사용된다.
- .logout(...): /logout으로 요청하면 로그아웃을 수행한다. 로그아웃 후 "/"(홈 페이지)로 이동한다. invalidateHttpSession(true)를 통해 로그아웃 시 세션을 무효화한다.
src / main / application-secret.yml
admin:
password: "{Password}"
application-secret.yml 파일에 원하는 password를 입력해주고 .gitignore를 통해 해당 파일을 Git에 업로드하지 않도록 한다.
controller / OrderController.java
package powersell.cheapat9.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import powersell.cheapat9.domain.OrderStatus;
import powersell.cheapat9.dto.order.OrderRequestDto;
import powersell.cheapat9.dto.order.OrderResponseDto;
import powersell.cheapat9.exception.NotEnoughStockException;
import powersell.cheapat9.service.OrderService;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
private final PasswordEncoder passwordEncoder; // 비밀번호 매칭 위함
/**
* 주문 생성
*/
@PostMapping
public ResponseEntity<Long> createOrder(@RequestBody @Valid OrderRequestDto requestDto) {
Long orderId = orderService.saveOrder(requestDto);
return ResponseEntity.ok(orderId);
}
/**
* 주문 상태 변경
*/
@PatchMapping("/{id}/status")
public ResponseEntity<Void> updateOrderStatus(@PathVariable Long id, @RequestParam String status) {
OrderStatus orderStatus = OrderStatus.valueOf(status);
orderService.updateOrderStatus(id, orderStatus);
return ResponseEntity.ok().build();
}
/**
* 개별 주문 조회 (비밀번호 검증 포함)
*/
@PostMapping("/detail")
public ResponseEntity<List<OrderResponseDto>> getOrdersByNumber(@RequestBody @Valid OrderRequestDto requestDto) {
List<OrderResponseDto> orders = orderService.findOrdersByNumber(requestDto.getNumber())
.stream()
.filter(order -> passwordEncoder.matches(requestDto.getPw(), order.getPw()))
.toList();
return ResponseEntity.ok(orders);
}
/**
* 전체 주문 조회 (관리자용)
*/
@GetMapping("/admin")
public ResponseEntity<List<OrderResponseDto>> getAllOrders() {
return ResponseEntity.ok(orderService.findAllOrders());
}
/**
* 예외 처리 (재고 부족)
*/
@ExceptionHandler(NotEnoughStockException.class)
public ResponseEntity<String> handleNotEnoughStockException(NotEnoughStockException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
service / OrderServiceTest.java
package powersell.cheapat9.service;
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 powersell.cheapat9.dto.item.ItemRequestDto;
import powersell.cheapat9.dto.order.OrderRequestDto;
import powersell.cheapat9.dto.order.OrderResponseDto;
import powersell.cheapat9.domain.OrderStatus;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(properties = "admin.password=test1234") // 비밀번호 테스트가 잘 작동하지 않아서
@Transactional
public class OrderServiceTest {
@Autowired private OrderService orderService;
@Autowired private ItemService itemService;
/**
* 1. 주문 저장 테스트
*/
@Test
public void saveOrderTest() {
// given
Long itemId = itemService.saveItem(createItemRequestDto());
OrderRequestDto requestDto = createOrderRequest(itemId, "01012345678");
// when
Long orderId = orderService.saveOrder(requestDto);
// then
OrderResponseDto savedOrder = orderService.findOrder(orderId);
assertNotNull(savedOrder);
assertEquals(OrderStatus.WAITING, savedOrder.getStatus());
assertEquals("01012345678", savedOrder.getNumber());
}
/**
* 2. 주문 검색 테스트
*/
@Test
public void findAllOrdersTest() {
// given
Long itemId = itemService.saveItem(createItemRequestDto());
orderService.saveOrder(createOrderRequest(itemId, "01098765432"));
orderService.saveOrder(createOrderRequest(itemId, "01012345678"));
// when
List<OrderResponseDto> orders = orderService.findAllOrders();
// then
assertTrue(orders.size() >= 2);
}
@Test
public void findOrdersByNumberTest() {
// given
Long itemId = itemService.saveItem(createItemRequestDto());
orderService.saveOrder(createOrderRequest(itemId, "01077777777"));
orderService.saveOrder(createOrderRequest(itemId, "01087654321"));
// when
List<OrderResponseDto> result = orderService.findOrdersByNumber("01077777777");
// then
assertEquals(1, result.size());
assertEquals("01077777777", result.get(0).getNumber());
}
/**
* 3. 주문 상태 수정 테스트
*/
@Test
public void updateOrderStatusTest() {
// given
Long itemId = itemService.saveItem(createItemRequestDto());
Long orderId = orderService.saveOrder(createOrderRequest(itemId, "01088889999"));
// when
orderService.updateOrderStatus(orderId, OrderStatus.DELIVERING);
// then
OrderResponseDto updatedOrder = orderService.findOrder(orderId);
assertEquals(OrderStatus.DELIVERING, updatedOrder.getStatus());
}
/**
* 4. 주문 추가 및 재고 수정 테스트
*/
@Test
public void createOrderAndModifyStockTest() {
// given
Long itemId = itemService.saveItem(createItemRequestDto());
OrderRequestDto orderRequest = createOrderRequest(itemId, "01099998888");
// when
Long orderId = orderService.saveOrder(orderRequest);
// then
OrderResponseDto savedOrder = orderService.findOrder(orderId);
assertNotNull(savedOrder);
assertEquals("01099998888", savedOrder.getNumber());
assertEquals(OrderStatus.WAITING, savedOrder.getStatus());
assertEquals(2, savedOrder.getCount());
// 재고 감소 검증
assertEquals(8, itemService.findItem(itemId).getStockQuantity()); // 원래 30개였으므로 10개 감소 확인
}
/**
* 테스트에서 중복되는 ItemRequestDto 생성 메서드
*/
private ItemRequestDto createItemRequestDto() {
ItemRequestDto requestDto = new ItemRequestDto();
requestDto.setName("Water");
requestDto.setOriginalPrice(3000);
requestDto.setPrice(600);
requestDto.setStockQuantity(10);
requestDto.setStartDate("2025-02-12 21:00:00");
requestDto.setEndDate("2025-02-12 23:00:00");
return requestDto;
}
/**
* 테스트에서 중복되는 OrderRequestDto 생성 메서드
*/
private OrderRequestDto createOrderRequest(Long itemId, String number) {
OrderRequestDto requestDto = new OrderRequestDto();
requestDto.setItemId(itemId);
requestDto.setCount(2);
requestDto.setName("John Doe");
requestDto.setNumber(number);
requestDto.setZipcode("12345");
requestDto.setAddress("Seoul, Korea");
requestDto.setDongho("101-202");
requestDto.setPw("1234");
return requestDto;
}
}
Test 결과
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation' // NotNull, NotEmpty 위함
implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security 위함
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
과정에서 validation, security를 사용하기 때문에 build.gradle 파일에 추가하였다.
'Study & Review > Project Refinement' 카테고리의 다른 글
[프로젝트 재완성] 싸다9 - 4부: Feedback 데이터 처리, Controller 추가 (0) | 2025.02.13 |
---|---|
[프로젝트 재완성] 싸다9 - 3부: Order 데이터 처리해보기 (+N+1 문제) (0) | 2025.02.13 |
[프로젝트 재완성] 싸다9 - 2부: Item 데이터 처리해보기 (+Transaction, Lock) (0) | 2025.02.12 |
[프로젝트 재완성] 싸다9 - 1부: 환경 및 도메인·컨트롤러 세팅 (0) | 2025.02.12 |