📦 상품 재고 차감 기능 구현
- 컬럼명 수정: 명세서에 부합하도록 상품의 개수 컬럼 이름을
stock으로 변경했다.
package com.example.springorder.Product;
import jakarta.persistence.*;
import lombok.*;
@Setter @Getter
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int price;
private int stock;
public Product() {}
@Builder
public Product(Long id, String name, int price, int stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
}
ALTER TABLE PRODUCTS RENAME COLUMN quantity TO stock;
OrderController:create()에서stock이 0 이하인 경우 주문이 불가하도록 하는 기능을 추가했다.
/* 주문 등록 */
@PostMapping
public Order create(@RequestBody OrderRequest orderRequest) {
Product product = productRepository.findById(orderRequest.getProductId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
int stock = product.getStock();
if (stock <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
product.setStock(stock - 1);
Order order = Order.builder().product(product).build();
return orderRepository.save(order);
}
- ProductController: 상품 재고를 수정하는 PATCH 메서드를 추가했다.
/* 상품 이름 변경 */
@PatchMapping("/{id}/name") // ← API 엔드포인트 변경
public Product changeName(@PathVariable Long id, @RequestBody ChangeNameRequest changeNameRequest) {
Product new_product = productRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
new_product.setName(changeNameRequest.getName());
return productRepository.save(new_product);
}
/* 상품 재고 변경 */
@PatchMapping("/{id}/stock") // ← 신규 추가
public Product changeStock(@PathVariable Long id, @RequestBody ChangeStockRequest changeStockRequest) {
Product new_product = productRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
new_product.setStock(changeStockRequest.getStock());
return productRepository.save(new_product);
}


🧰 동시성 이슈 해결하기
동시성 이슈 확인하기
재고가 하나 남은 상품에 대해 정말 우연히도 동시에 주문 요청이 들어오면 어떻게 될까?
- 테스트 코드 작성
더보기<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>spring-order 동시 주문 테스트</title> <style> body { font-family: Arial, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; line-height: 1.5; } input, button { font-size: 16px; padding: 8px 12px; margin-right: 8px; } pre { background: #f5f5f5; padding: 16px; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; } </style> </head> <body> <h1>동시 주문 요청 테스트</h1> <div> <label for="baseUrl">서버 주소</label> <input id="baseUrl" type="text" value="http://localhost:8080" /> </div> <div style="margin-top: 12px;"> <label for="productId">상품 ID</label> <input id="productId" type="number" value="1" min="1" /> <button id="sendBtn">POST /orders 2번 동시에 보내기</button> </div> <h2>결과</h2> <pre id="result">아직 요청하지 않았습니다.</pre> <script> const sendBtn = document.getElementById("sendBtn"); const result = document.getElementById("result"); const baseUrlInput = document.getElementById("baseUrl"); const productIdInput = document.getElementById("productId"); async function createOrder(baseUrl, productId) { const response = await fetch(`${baseUrl}/orders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productId: productId }) }); let body; try { body = await response.json(); } catch { body = await response.text(); } return { ok: response.ok, status: response.status, body }; } sendBtn.addEventListener("click", async () => { const baseUrl = baseUrlInput.value.trim(); const productId = Number(productIdInput.value); result.textContent = "요청 중..."; try { const [res1, res2] = await Promise.all([ createOrder(baseUrl, productId), createOrder(baseUrl, productId) ]); result.textContent = JSON.stringify( { requestedAt: new Date().toISOString(), requestBody: { productId }, firstResponse: res1, secondResponse: res2 }, null, 2 ); } catch (error) { result.textContent = `에러 발생: ${error.message}`; } }); </script> </body> </html>
- 테스트 시행 결과:
stock는 1이었지만 2개의 주문 요청이 수락되는 이슈가 발생했다.

동시성 이슈를 해결하는 방법들
@Transactional- 여러 작업을 하나의 묶음으로 묶어서 처리할 수 있게 하는 어노테이션이다.
- "상품 조회 → 재고 확인 → 재고 감소 → 주문 생성"을 하나의 묶음으로 처리하면 중간에 실패했을 때 앞에서 했던 작업도 같이 되돌릴 수 있다.
- 재고를 1 줄였는데 주문 저장에서 오류가 난 경우 재고만 줄고 주문은 안 생기는 이상한 상태가 되면 안 되니까, 전부 같이 성공하거나 전부 같이 취소되게 만드는 것이다.
- 하지만 A 요청도 트랜잭션, B 요청도 트랜잭션 안에서 움직이더라도 둘이 동시에 같은 재고 1을 읽는 것 자체는 여전히 가능하기 때문에
@Transactional만으로 동시성 문제를 해결할 수는 없다.
- 비관적 락 (Pessimistic Lock)
- 충돌이 날 것이라고 보고 미리 잠가 버리는 방식
- 다른 트랜잭션과 충돌할 가능성을 전제로 데이터를 읽은 뒤 사용이 끝날 때까지 잠그는 전략이다.
- 재고가 1인 상품에 A, B 두 요청이 동시에 들어오면
- A가 상품을 읽으면서 락을 건다
- B는 상품에 접근하려고 하지만 락 때문에 기다린다
- A가 재고를 0으로 만들고 주문을 저장한 뒤 락을 푼다
- B가 상품을 읽는데 재고가 0이라서 실패한다
- 낙관적 락 (Optimistic Lock)
- 보통은 안 부딪힐 거라고 보고 일단 진행한 뒤, 마지막에 충돌했는지만 확인하는 방식
- 여러 트랜잭션이 동시에 진행될 수 있다고 가정하고 커밋 전에 다른 쪽이 값을 바꿨는지 검사하는 전략이다.
- 일단 동시에 작업하게 둔 다음, 마지막 저장 시점에 충돌이 있었는지 확인하는 방식이다.
- JPA/Hilbernate에서는 일반적으로
@Version컬럼으로 구현한다.- 처음 상품 상태가 stock = 1, version = 5
- 요청 A와 B가 동시에 읽으면 둘 다 version 5를 본다
- A가 먼저 저장하면 DB 상태가 version 6이 된다
- B도 저장하려고 하는데 자신이 읽은 버전과 현재 DB 버전이 달라서 실패한다
- 조건부 UPDATE
- SQL문을 사용해서 DB가 직접 성공/실패를 판단하게 하는 방식
- 예를 들어
update product set stock = stock - 1 where id = ? and stock > 0는 재고가 있을 때만 성공한다.- A와 B 요청이 위 쿼리를 날린다
- 둘 중 하나만 성공해서 stock이 0이 된다
- 나머지는 조건이 충족되지 않아 실패한다
동시성 이슈 해결하기
ProductRepository: 락을 잡는 조회 메서드를 추가 (@Lock(...)어노테이션에 의해findByIdForUpdate가 비관적 락으로 설정됨)
package com.example.springorder.Product;
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 java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
OrderController수정:create()에@Transactional추가 및 비관적 락(findByIdForUpdate()) 사용
/* 주문 등록 */
@Transactional // ← 추가
@PostMapping
public Order create(@RequestBody OrderRequest orderRequest) {
Product product = productRepository.findByIdForUpdate(orderRequest.getProductId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
int stock = product.getStock();
if (stock <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
product.setStock(stock - 1);
Order order = Order.builder().product(product).build();
return orderRepository.save(order);
}
- 실행 결과: 한 요청에 대한 주문만 생성되었고, 다른 요청의 경우에는 성공적으로 실패했다.

'내일배움캠프' 카테고리의 다른 글
| [내일배움캠프] IoC, DI, Bean (0) | 2026.04.06 |
|---|---|
| [내일배움캠프] Spring 입문 1주차 (0) | 2026.04.06 |
| [내일배움캠프 사전캠프] 페이지네이션과 N+1 문제 (0) | 2026.04.02 |
| [내일배움캠프 사전캠프] JPA 연관관계 매핑 (0) | 2026.04.01 |
| [내일배움캠프 사전캠프] H2와 JPA (0) | 2026.03.31 |