시작하기 전에
싸다9는 2023년 8월부터 11월까지 진행했던 프로젝트로 자취생을 위한 할인 판매 서비스이다. 과도한 트래픽을 처리해보는 경험을 하고 싶어 오후 9시부터 여러 자취생품을 80% 할인해서 선착순으로 판매하자는 전략을 세웠다. 결과는 1분 안에 모든 재고가 다 팔릴 정도로 인기가 많았으며 단시간에 매우 많은 요청이 들어오게 하는 데 성공하였다.
이 프로젝트를 다시 개발해보면서 Spring 지식, 트래픽 처리를 위한 Lock 개념, AWS를 활용한 서버와 DB 세팅, 프런트 스킬까지 되돌아보려고 한다.
I. Item 엔티티
우선 상품을 나타내는 Item 엔티티의 도메인, 레포지토리, 서비스를 먼저 구성하였다. domain 패키지 안에 Item.java, repository 안에 ItemRepository.java, service 안에 ItemService.java 파일을 생성한다. 이후에 exception도 사용할 것이라 NotEnoughStockException도 만들어놓자.
domain / Item.java
package powersell.cheapat9.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
@Entity
@Getter @Setter
public class Item {
@Id @GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
private int originalPrice;
private int price; // Discounted price
private int discountRate;
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;
}
- 이후에 다른 코드에서도 자유롭게 받아오고 변경할 수 있게 @Getter와 @Setter 어노테이션을 설정하였다.
- 상품명, 원가, 할인가격, 할인율, 재고, 판매 시작 시간, 판매 종료 시간을 저장할 변수를 추가하였다.
- Spring Data JPA에서 @Id와 @GeneratedValue는 데이터베이스의 기본 키(primary key)를 생성하는 방식을 지정해준다. @GeneratedValue는 기본 키 값을 자동으로 생성하도록 설정하는 어노테이션이다.
- DateTimeFormat은 입력 시 문자열(string) 형식의 'yyyy-MM-dd HH:mm:ss'를 LocalDateTime 객체로 변환한다. 하지만 저 패턴으로 전달하지 않는다고 오류를 발생시키지는 않는다.
repository / ItemRepository.java
package powersell.cheapat9.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import powersell.cheapat9.domain.Item;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class ItemRepository {
private final EntityManager em;
public void save(Item item) {
if (item.getId() == null) {
em.persist(item);
} else {
em.merge(item);
}
}
public Item findOne(Long id) { return em.find(Item.class, id, LockModeType.PESSIMISTIC_WRITE); }
public List<Item> findAll() {
return em.createQuery("select i from Item i", Item.class).getResultList();
}
}
- @RequiredArgsConstructor: Lombok에서 제공하는 어노테이션이며 EntityManager em;이 final이므로 자동으로 생성자를 생성해준다. 만약 해당 어노테이션이 없다면 EntityManager em; 아래에 public ItemRepository(EntityManager em) { this.em = em; } 코드를 추가해 생성자를 넣어줘야 한다.
- EntityManager: JPA의 핵심 개체로, 데이터베이스의 모든 작업을 처리하는 중앙 관리 객체다. 트랜잭션 단위로 데이터를 관리하며, 영속성 컨텍스트(Persistence Context)를 통해 엔티티를 관리한다. 중요한 개념이 나왔으므로 잠깐 멈춰서 정리하고 가자.
- LockModeType.PESSIMISTIC_WRITE: 마찬가지로 중요한 개념이라 정리하고 가자.
트랜잭션(Transaction)이란?
트랜잭션은 데이터베이스의 하나의 작업을 의미한다. 즉, 여러 개의 작업을 하나로 묶어서 실행하고, 모든 작업이 성공해야만 실제로 반영(commit)되는 방식이다. 예를 들어보자.
public void buyItem(Long itemId, int quantity) {
Item item = itemRepository.findOne(itemId);
item.setStockQuantity(item.getStockQuantity() - quantity); // 재고 감소
itemRepository.save(item);
}
위 코드는 트랜잭션을 사용하지 않은 경우로,
- findOne(itemId)로 아이템을 조회
- 재고 수량 감소
- save(item)으로 데이터 저장
만약 2번에서 오류가 발생하면, 1번에서 조회한 데이터는 그대로 남지만 변경된 데이터는 반영되지 않아 데이터 불일치 문제가 발생할 가능성이 있다.
@Transactional
public void buyItem(Long itemId, int quantity) {
Item item = itemRepository.findOne(itemId);
item.setStockQuantity(item.getStockQuantity() - quantity); // 재고 감소
itemRepository.save(item);
}
반면 트랜잭션을 사용한 경우, @Transactional이 메서드를 감싸서 하나의 트랜잭션으로 실행된다. 모든 코드가 정상적으로 실행되면 DB에 반영되기 때문에 도중에 에러가 발생하면 자동으로 rollback되어 데이터가 변경되지 않는다.
즉, 트랜잭션을 사용하면 해당 메서드가 트랜잭션 단위로 실행되어, 예외 발생 시 롤백이 자동으로 수행되어 데이터 오류를 방지할 수 있다.
영속성 컨텍스트를 통해 엔티티를 관리한다?
영속성 컨텍스트란, JPA가 엔티티 객체를 관리하는 메모리 공간으로, 캐시 개념이다. EntityManager를 통해 데이터를 조회하면 영속성 컨텍스트에 저장된다. 이렇게 하면 데이터베이스와 직접 연결되는 것이 아니라, JPA가 중간에서 엔티티를 관리하고 변경 사항을 감지해서 자동으로 반영한다. 예를 들어보자.
@Transactional
public void example() {
Item item = itemRepository.findOne(1L); // 1. DB에서 조회
item.setName("New Item Name"); // 2. 엔티티 값 변경
// 3. 트랜잭션이 끝나면 자동으로 변경 사항이 반영됨 (dirty checking)
}
- findOne(1L)을 실행하면, DB에서 데이터를 가져오고 영속성 컨텍스트에 저장된다.
- item.setName("New Item Name")을 하면, JPA는 이 변경 사항을 자동으로 감지한다.
- 트랜잭션이 끝날 때 변경된 필드가 자동으로 UPDATE 쿼리로 반영되며, 이걸 Dirty Checking이라고 한다.
JPA에서 엔티티 객체는 4가지 상태를 가질 수 있다.
- 비영속 (Transient): JPA가 관리하지 않는 상태 (DB와 관련 없음)
- 영속 (Persistent): JPA가 관리하는 상태 (트랜잭션이 끝날 때 자동 반영)
- 준영속 (Detached): JPA가 관리하지 않음 (DB에 반영되지 않음)
- 삭제 (Removed): JPA가 삭제 상태로 인식, 트랜잭션이 끝나면 DELETE 실행
따라서 위 코드에서 나온 것처럼 em.persist(item)을 하면 비영속 -> 영속 상태로 변경되는 것이고,
자주 사용되는 em.remove(item)을 하면 영속 -> 삭제 상태로 변경되는 것이다.
결론적으로 JPA의 트랜잭션과 영속성 컨텍스트를 활용하면 데이터 관리가 훨씬 편리해진다.
따라서 EntityManager의 기능?
EntityManager에서 하는 주요 기능은 크게 5가지가 있다.
- persist(Object entity): 엔티티를 영속성 컨텍스트에 저장 (INSERT 실행)
- merge(Object entity): 기존 엔티티를 수정 (UPDATE 실행)
- find(Class<T> entityClass, Object primaryKey): ID를 통해 엔티티 조회
- remove(Object entity): 엔티티 삭제
- createQuery(String qlString, Class<T> resultClass): JPQL 쿼리 실행
Locking이란?
엔티티를 조회할 때, 해당 데이터가 동시에 수정되지 않도록 잠금을 거는 방식이다.
종류에는 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)이 있다.
- PESSIMISTIC_WRITE: 데이터 수정 시 강제 락 (동시 수정 불가능)
- PESSIMISTIC_READ: 읽기 전용 락 (다른 트랜잭션이 수정 못함, 조회 가능)
- OPTIMISTIC: 버전 체크를 통해 충돌 방지
그렇다면 언제 사용하는 게 좋을까?
여러 사용자가 동시에 같은 데이터를 수정할 가능성이 높은 경우 사용하면 좋다.
싸다9로 예를 들면, 단시간에 많은 주문 요청이 들어왔고 재고가 다 떨어지면(설정해둔 상품 개수와 주문 개수가 같아지면) 더 이상 주문 요청이 들어오지 않도록 품절 표시를 해야 했다. 그러나 만약 한 트랜잭션이 데이터를 수정할 동안 다른 트랜잭션이 데이터를 수정할 수 있다면 품절 표시를 해야 함을 인식하기 전에 주문 요청이 받아지는 것이다. 재고를 20개로 설정해뒀는데 주문 요청이 30개가 들어올 수 있는 것이다. 실제로 이러한 문제가 발생했다.
중요한 개념이니 꼭 알아두도록 하자. 다시 ItemService로 돌아가보자.
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.exception.NotEnoughStockException;
import powersell.cheapat9.repository.ItemRepository;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
/**
* 상품 등록
*/
@Transactional
public Long saveItem(Item item) {
itemRepository.save(item);
return item.getId();
}
/**
* 상품 검색
*/
public List<Item> findItems() { return itemRepository.findAll(); }
public Item findOne(Long id) { return itemRepository.findOne(id); }
/**
* 상품 수정
*/
@Transactional
public void update(Long id, String name, int originalPrice, int price, int stockQuantity, String startDate, String endDate) {
Item item = itemRepository.findOne(id);
item.setName(name);
item.setOriginalPrice(originalPrice);
item.setPrice(price);
item.setStockQuantity(stockQuantity);
LocalDateTime start = LocalDateTime.parse(startDate,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
item.setStartDate(start);
LocalDateTime end = LocalDateTime.parse(endDate,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
item.setEndDate(end);
}
/**
* 상품 재고 수정
*/
@Transactional
public void updateStock(Item item, int quantity) {
if (item.getStockQuantity() - quantity < 0) {
throw new NotEnoughStockException("Error: Not Enough Stock");
}
int changedQuantity = item.getStockQuantity() - quantity;
item.setStockQuantity(changedQuantity);
}
}
- @Transactional(readOnly = true): 트랜잭션을 읽기 전용 모드로 실행하도록 설정하는 옵션이다. 기본적으로 트랜잭션은 읽기와 쓰기가 가능한데, 읽기 전용 모드로 변경하면 읽기 성능을 최적화하여 불필요한 리소스 낭비를 방지할 수 있다. 여러 방법으로 이해할 수 있다. INSERT, UPDATE, DELETE 같은 데이터 변경이 차단되는 것이다. JPA가 변경 감지를 하지 않는다. 이는 Dirty Checking이 비활성화되었다는 의미이기도 하다.
- @Transactional: 읽기 전용 모드로 변경하였을 때 메서드(saveItem, update, ...)에 @Transactional 어노테이션을 추가하여 해당 메서드는 readOnly = false로 오버라이드한다.
- LocalDateTime.parse(): 문자열 형태로 된 날짜·시간 데이터를 LocalDateTime 객체로 변환한다.
exception / NotEnoughStockException.java
주문 요청이 들어와 updateStock 메서드가 작동하는 시점에서 재고가 부족할 때 예외를 발생시키고자 exception 하나를 정의했다.
package powersell.cheapat9.exception;
public class NotEnoughStockException extends RuntimeException {
public NotEnoughStockException(String message) {
super(message);
}
}
코드는 간단하며 super(message)를 통해 RunTimeException의 기본 기능을 활용하여 오류 메시지를 저장하고 출력할 수 있다.
II. Item 테스트
Item 엔티티의 도메인, 레포지토리, 서비스를 구축했으니 테스트를 해보자. Java는 애플리케이션을 실제로 실행하지 않고도 테스트할 수 있다는 장점이 있다. test -> java -> {GroupName}.{ProjectName} 안에 service 패키지를 생성하고 ItemServiceTest.java 파일을 생성한다.
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.domain.Item;
import powersell.cheapat9.exception.NotEnoughStockException;
import powersell.cheapat9.repository.ItemRepository;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
public class ItemServiceTest {
@Autowired ItemService itemService;
@Autowired ItemRepository itemRepository;
/**
* 1. 상품 저장 테스트
*/
@Test
public void saveItemTest() {
//given
Item item = new Item();
item.setName("Egg");
item.setOriginalPrice(4000);
item.setPrice(800);
item.setDiscountRate(80);
item.setStockQuantity(20);
item.setStartDate(LocalDateTime.now());
item.setEndDate(LocalDateTime.now().plusDays(10));
// when
Long savedItemId = itemService.saveItem(item);
Item savedItem = itemService.findOne(savedItemId);
// then
assertNotNull(savedItem);
assertEquals("Egg", savedItem.getName());
}
/**
* 2. 상품 조회 테스트
*/
@Test
public void findOneTest() {
// given
Item item = new Item();
item.setName("CupRice");
item.setOriginalPrice(5000);
item.setPrice(1000);
item.setStockQuantity(25);
itemRepository.save(item);
// when
Item foundItem = itemService.findOne(item.getId());
// then
assertNotNull(foundItem);
assertEquals("CupRice", foundItem.getName());
}
@Test
public void findAllTest() {
// given
Item item1 = new Item();
item1.setName("Bread");
item1.setOriginalPrice(3000);
item1.setPrice(2500);
item1.setStockQuantity(50);
itemRepository.save(item1);
Item item2 = new Item();
item2.setName("Butter");
item2.setOriginalPrice(7000);
item2.setPrice(6500);
item2.setStockQuantity(30);
itemRepository.save(item2);
// when
List<Item> items = itemService.findItems();
// then
assertEquals(2, items.size());
}
/**
* 3. 상품 수정 테스트
*/
@Test
public void updateItemTest() {
// given
Item item = new Item();
item.setName("Cheese");
item.setOriginalPrice(10000);
item.setPrice(8000);
item.setStockQuantity(10);
item.setStartDate(LocalDateTime.now());
item.setEndDate(LocalDateTime.now().plusDays(7));
itemRepository.save(item);
// when
itemService.update(item.getId(), "Premium Cheese", 12000, 10000, 5,
"2025-02-12 10:00:00", "2025-02-20 10:00:00");
// then
Item updatedItem = itemService.findOne(item.getId());
assertEquals("Premium Cheese", updatedItem.getName());
assertEquals(12000, updatedItem.getOriginalPrice());
assertEquals(10000, updatedItem.getPrice());
assertEquals(5, updatedItem.getStockQuantity());
}
/**
* 4. 상품 재고 수정 테스트
*/
@Test
public void updateStockTest() {
// given
Item item = new Item();
item.setName("Yogurt");
item.setOriginalPrice(4500);
item.setPrice(4000);
item.setStockQuantity(20);
itemRepository.save(item);
// when
itemService.updateStock(item, 5);
// then
Item updatedItem = itemService.findOne(item.getId());
assertEquals(15, updatedItem.getStockQuantity());
}
/**
* 5. 재고 부족 예외 테스트
*/
@Test
public void updateStock_throwsExceptionWhenNotEnoughStock() {
// given
Item item = new Item();
item.setName("Chocolate");
item.setOriginalPrice(5500);
item.setPrice(5000);
item.setStockQuantity(5);
itemRepository.save(item);
// then
assertThrows(NotEnoughStockException.class, () -> {
itemService.updateStock(item, 10); // 재고 부족 테스트
});
}
}
ItemService에서 정의한 4개의 메서드(상품 저장, 상품 조회, 상품 수정, 상품 재고 수정)와 재고 부족 예외 케이스까지 테스트했다. 그 후 ItemServiceTest를 실행했을 때 모든 메서드에 대한 테스트가 잘 작동하는 것을 확인할 수 있다.
'개발 > 프로젝트 재완성' 카테고리의 다른 글
[프로젝트 재완성] 싸다9 - 5부: Item, Order 구조 공사 (0) | 2025.02.15 |
---|---|
[프로젝트 재완성] 싸다9 - 4부: Feedback 데이터 처리, Controller 추가 (0) | 2025.02.13 |
[프로젝트 재완성] 싸다9 - 3부: Order 데이터 처리해보기 (+N+1 문제) (0) | 2025.02.13 |
[프로젝트 재완성] 싸다9 - 1부: 환경 및 도메인·컨트롤러 세팅 (0) | 2025.02.12 |