내일배움캠프

[내일배움캠프 사전캠프] 동시성 이슈 해결하기

munsik22 2026. 4. 3. 16:47

📦 상품 재고 차감 기능 구현

  • 컬럼명 수정: 명세서에 부합하도록 상품의 개수 컬럼 이름을 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);
    }

stock이 1일 때는 stock이 1 차감 되면서 주문이 생성되었다.
stock이 0인 경우 주문이 생성되지 않았다.

🧰 동시성 이슈 해결하기

동시성 이슈 확인하기

재고가 하나 남은 상품에 대해 정말 우연히도 동시에 주문 요청이 들어오면 어떻게 될까?

  • 테스트 코드 작성
    더보기
    <!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개의 주문 요청이 수락되는 이슈가 발생했다.

동시성 이슈를 해결하는 방법들

  1. @Transactional
    • 여러 작업을 하나의 묶음으로 묶어서 처리할 수 있게 하는 어노테이션이다.
    • "상품 조회 → 재고 확인 → 재고 감소 → 주문 생성"을 하나의 묶음으로 처리하면 중간에 실패했을 때 앞에서 했던 작업도 같이 되돌릴 수 있다.
    • 재고를 1 줄였는데 주문 저장에서 오류가 난 경우 재고만 줄고 주문은 안 생기는 이상한 상태가 되면 안 되니까, 전부 같이 성공하거나 전부 같이 취소되게 만드는 것이다.
    • 하지만 A 요청도 트랜잭션, B 요청도 트랜잭션 안에서 움직이더라도 둘이 동시에 같은 재고 1을 읽는 것 자체는 여전히 가능하기 때문에 @Transactional만으로 동시성 문제를 해결할 수는 없다.
  2. 비관적 락 (Pessimistic Lock)
    • 충돌이 날 것이라고 보고 미리 잠가 버리는 방식
    • 다른 트랜잭션과 충돌할 가능성을 전제로 데이터를 읽은 뒤 사용이 끝날 때까지 잠그는 전략이다.
    • 재고가 1인 상품에 A, B 두 요청이 동시에 들어오면
      • A가 상품을 읽으면서 락을 건다
      • B는 상품에 접근하려고 하지만 락 때문에 기다린다
      • A가 재고를 0으로 만들고 주문을 저장한 뒤 락을 푼다
      • B가 상품을 읽는데 재고가 0이라서 실패한다
  3. 낙관적 락 (Optimistic Lock)
    • 보통은 안 부딪힐 거라고 보고 일단 진행한 뒤, 마지막에 충돌했는지만 확인하는 방식
    • 여러 트랜잭션이 동시에 진행될 수 있다고 가정하고 커밋 전에 다른 쪽이 값을 바꿨는지 검사하는 전략이다.
    • 일단 동시에 작업하게 둔 다음, 마지막 저장 시점에 충돌이 있었는지 확인하는 방식이다.
    • JPA/Hilbernate에서는 일반적으로 @Version 컬럼으로 구현한다.
      • 처음 상품 상태가 stock = 1, version = 5
      • 요청 A와 B가 동시에 읽으면 둘 다 version 5를 본다
      • A가 먼저 저장하면 DB 상태가 version 6이 된다
      • B도 저장하려고 하는데 자신이 읽은 버전과 현재 DB 버전이 달라서 실패한다
  4. 조건부 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);
    }
  • 실행 결과: 한 요청에 대한 주문만 생성되었고, 다른 요청의 경우에는 성공적으로 실패했다.