본문 바로가기

카테고리 없음

도서 주문 관리 서비스 동시성 문제 해결하기

개인적인 생각이 들어갔기 때문에 잘못된 정보가 있을 수 있습니다. 😥 (피드백 환영합니다.)

https://github.com/rhdtn311/book-sales-service

 

GitHub - rhdtn311/book-sales-service

Contribute to rhdtn311/book-sales-service development by creating an account on GitHub.

github.com

 

 

현재 프로젝트에는 주문을 할 때 동시성 문제가 있다.

@Transactional
public Long save(OrderDTO.Req orderDtoReq) {
    Customer customer = DtoConverter.convertOrderDtoToCustomer(orderDtoReq);
    Long customerId = customerRepository.save(customer);

    Order order = new Order(customerId, LocalDateTime.now(), DeliveryStatus.READY, orderDtoReq.totalPrice());
    Long orderId = orderRepository.save(order);

    orderDtoReq.books().forEach(book -> {
        Book findBook = bookRepository
                .findById(book.id())
                .orElseThrow(BookNotFoundException::new);

        if (checkBookAmount(findBook, book.count())) {
            bookRepository.updateAmount(findBook.getId(), findBook.getAmount() - book.count());
            orderBookRepository.save(new OrderBook(orderId, book.id(), book.count()));
        }
    });

    return orderId;
}

위 로직은 다음과 같은 과정을 거친다.

  1. Customer를 저장한다.
  2. Order를 저장한다.
  3. Book을 조회한다.
  4. 조회한 Book의 전체 수량(Amount)을 사용자가 요청한 수량만큼 감소시킨다.
  5. 커밋한다.

 

만약 두 명의 사람이 거의 동시에 같은 책을 주문한다면 다음과 같은 상황이 발생할 수 있을 것이다.

  1. 사용자1이 트랜잭션을 시작하여 id가 1인 Book을 조회한다. (Book = {id:1, amount: 10})
  2. 사용자2가 트랜잭션을 시작하여 id가 1인 Book을 조회한다. (Book = {id:1, amount: 10})
  3. 사용자1이 책을 5권 주문하여 Book의 amount를 5 감소시킨다. (Book = {id:1, amount: 5})
  4. 사용자2가 책을 7권 주문하여 Book의 amount를 7 감소시킨다. (Book = {id:1, amount: 3})
  5. 사용자1의 트랜잭션이 커밋된다. (Book = {id:1, amount: 5})
  6. 사용자2의 트랜잭션이 커밋된다. ((Book = {id:1, amount: 3})

 

즉, 사용자1과 사용자2가 주문한 총 책의 수는 12권이라 10권밖에 없는 책을 정상적으로 주문할 수 없다. 그런데 동시성 문제가 발생하여 정상적으로 책이 주문되었고 심지어 DB에는 책이 3권 남았다.

 

이를 다음과 같이 테스트 해보았다.

Book 테이블

현재 Book 테이블에는 id가 1인 도서 10권이 존재한다.

 

그리고 2개의 스레드가 각각 id가 1인 Book 5개 주문, id가 1인 Book 7개 주문 하도록 하였다.

@Test
@DisplayName("2명이 동시에 같은 책을 주문했을 때 남아있는 책의 양 테스트")
void 동시성_테스트2() throws InterruptedException {

    BookDTO.Req member1OrderBook = new BookDTO.Req(1L, 5);
    BookDTO.Req member2OrderBook = new BookDTO.Req(1L, 7);
    OrderDTO.Req member1Order = new OrderDTO.Req("rhdtn311@naver.com", "왕십리", List.of(member1OrderBook), 50000);
    OrderDTO.Req member2Order = new OrderDTO.Req("rhdtn311@naver.com", "남양주", List.of(member2OrderBook), 70000);

    int threadCount = 10;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(2);

    executorService.submit(() -> {
        try {
            orderService.save(member1Order);
        } catch (Exception e) {
            System.out.println("t1 error: " + e.getMessage());
        } finally {
            latch.countDown();
        }
    });

    Thread.sleep(300);

    executorService.submit(() -> {
        try {
            orderService.save(member2Order);
        } catch (Exception e) {
            System.out.println("t2 error: " + e.getMessage());
        } finally {
            latch.countDown();
        }
    });

    latch.await();

    Book findBook = bookRepository.findById(1L).get();
    System.out.println(findBook);
}

결과는 다음과 같다.

5권을 주문하는 스레드가 먼저 실행되었고, 남은 책의 수량을 update하는 로직도 의도한 대로 순서대로 실행되었다.

Book 테이블

DB의 Book 테이블을 조회했을 때 수량이 3권이 남아있는 것을 확인할 수 있었다.

Order 테이블

2개의 주문도 모두 DB에 반영되었다.

 

비관적 락(Pessimistic Lock)

비관적 락은 자원 요청에 따른 동시성 문제가 발생할 것이라고 가정하고 DB에서 락을 걸어 동시성을 제어하는 방법이다. 하나의 트랜잭션이 자원에 접근할 때 락을 걸고 다른 트랜잭션이 접근하지 못하게한다.

 

현재 두 개의 스레드가 데이터를 수정할 때 사용할 Book을 다른 스레드가 수정하기 전에 조회하기 때문에 발생하는 문제이기 때문에 Book을 조회할 때 for update 구문을 사용하여 배타 락을 건다.

private final static String FIND_BY_ID_SQL 
			= "SELECT * FROM BOOK WHERE id = :id FOR UPDATE";

@Override
public Optional<Book> findById(Long id) {
    try {
        return Optional.ofNullable(template.queryForObject(FIND_BY_ID_SQL, Map.of("id", id), bookRowMapper));
    } catch (DataAccessException e) {
        logger.error("[ERROR] Database error : {}", e.getMessage());
    }
    return Optional.empty();
}

다시 동일한 테스트 코드를 실행해보면

두 번째 주문은 처리되지 못하고 롤백된 것을 확인할 수 있다.

Order 테이블

 

낙관적 락(Optimistic Lock)

낙관적 락은 트랜잭션이 서로 충돌하지 않을 것이라고 가정하고 DB 락을 걸어주지 않는 방식이다. 따라서 애플리케이션 레벨에서 동시성을 제어해주어야 하며 일반적으로 version 컬럼을 추가하는 방법을 사용한다.

 

우선 Book 클래스에 version 필드를 추가했다.

public class Book {
    private Long id;
    private final String title;
    private final long price;
    private final String publisher;
    private final String author;
    private final String plot;
    private final int amount;
    private final Long categoryId;
    private Integer version;

    public Book(Long id, String title, long price, String publisher, String author, String plot, int amount, Long categoryId, Integer version) {
        this.id = id;
        this.title = title;
        this.price = price;
        this.publisher = publisher;
        this.author = author;
        this.plot = plot;
        this.amount = amount;
        this.categoryId = categoryId;
        this.version = version;
    }

    // 생성자, getter(), builder(), toString()
}

그리고 Book의 amount를 수정하는 SQL문을 다음과 같이 변경하였다.

UPDATE BOOK SET amount = :amount, version = version + 1 WHERE id = :id AND version = :version
private final static String UPDATE_AMOUNT_SQL 
		= "UPDATE BOOK SET amount = :amount, version = version + 1 WHERE id = :id AND version = :version";

@Override
public int updateAmount(Long id, int amount, int version) {
    try {
        return template.update(UPDATE_AMOUNT_SQL, Map.of("amount", amount, "id", id, "version", version));
    } catch (DataAccessException e) {
        logger.error("[ERROR] Database error : {}", e.getMessage());
    }
    return 0;
}

JdbcTemplate의 update() 메소드에서 1이 반환되면 수정이 성공적으로 완료된 것이고 0이 반환되면 수정에 실패한 것이기 때문에 수정의 성공 실패 여부를 서비스로 반환하도록 하였다.

private static final int UPDATE_FAIL_VALUE = 0;

@Transactional
public Long save(OrderDTO.Req orderDtoReq) {
    Customer customer = DtoConverter.convertOrderDtoToCustomer(orderDtoReq);
    Long customerId = customerRepository.save(customer);
    Order order = new Order(customerId, LocalDateTime.now(), DeliveryStatus.READY, orderDtoReq.totalPrice());
    Long orderId = orderRepository.save(order);

    orderDtoReq.books().forEach(book -> {
        Book findBook = bookRepository
                .findById(book.id())
                .orElseThrow(BookNotFoundException::new);

        if (checkBookAmount(findBook, book.count())) {
            int updateResult = bookRepository.updateAmount(findBook.getId(), findBook.getAmount() - book.count(), findBook.getVersion());
            if (updateResult == UPDATE_FAIL_VALUE) {
                throw new UpdateFailException("Rollback : Failed to update amount");
            }
            orderBookRepository.save(new OrderBook(orderId, book.id(), book.count()));
        }
    });

    return orderId;
}

서비스에서는 BookRepository에서 수량을 업데이트하는 로직의 결과 값을 보고 0이라면 RuntimeException을 상속받은 UpdateFailException을 발생시켜 트랜잭션이 롤백되도록 하였다.

 

앞서 실행했던 테스트 코드를 실행하니 다음과 같은 결과가 나왔다.

도서를 5권 먼저 주문하고 주문이 완료되기 전 같은 도서를 7권 주문하면 UpdateFailException이 발생하고 해당 트랜잭션이 롤백된다.

주문도 한 건밖에 DB에 저장되지 않은 것을 확인할 수 있다.

 

하지만 위와 같이 처리하는 것은 다음과 같은 상황에서 단점으로 작용할 수 있다.

@Test
@DisplayName("50명이 동시에 같은 책을 주문했을 때 남아있는 책의 양 테스트")
void 동시성_테스트() throws InterruptedException {

    BookDTO.Req book = new BookDTO.Req(1L, 1);
    OrderDTO.Req order = new OrderDTO.Req("rhdtn311@naver.com", "왕십리", List.of(book), 1000);

    // 책의 총 수량은 50권
    int threadCount = 50;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(50);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                orderService.save(order);
            } catch (Exception e) {
                System.out.println("error : " + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Book findBook = bookRepository.findById(1L).get();
    System.out.println(findBook);
}

50명의 사람이 동시에 같은 책을 한 권씩 주문했다(DB에는 해당 책이 50권 저장되어있다.). 이 테스트 코드를 실행하면 의도한 결과는 DB에 해당 책이 0권 남고, 주문이 50번 발생해야 한다.

 

하지만 실행 결과는 다음과 같았다.

Book 테이블
OrderItem 테이블

40개의 요청이 롤백되고 10개의 요청만 정상 처리되었다. 책의 수량이 충분한데도 앞의 트랜잭션이 수정을 완료하기 전에 Book을 조회했기 때문에 version이 맞지 않아 update가 정상 처리되지 않고 롤백되었기 때문이다.

 

위 문제를 해결하기 위해 만약 version이 맞지 않아 트랜잭션을 완료할 수 없는 경우 성공할 때까지 트랜잭션을 재시도 하도록 코드를 변경하였다.

 

우선 OrderService의 save 메소드를 트랜잭션이 성공할 때까지 재시도할 것이기 때문에 OrderService와 컨트롤러 사이에 OrderServiceFacade 클래스를 만들어 새로운 계층을 두었다.

@Component
public class OrderServiceFacade {

    private final OrderService orderService;

    public OrderServiceFacade(OrderService orderService) {
        this.orderService = orderService;
    }

    public Long save(OrderDTO.Req orderDtoReq) throws InterruptedException {

        for (int tryTime = 1; tryTime <= 50; tryTime++) {
            try {
                return orderService.save(orderDtoReq);
            } catch (UpdateFailException e) {
                Thread.sleep(50);
            }
        }

        throw new IllegalArgumentException("잠시 후 다시 시도해주세요.");
    }
}

그리고 OrderServiceFacade에서 OrderSerivce의 save() 메소드가 성공할 때까지 계속 시도해주었는데 while(true)로 반복하기에는 위험하다 생각했고 어느 정도 반복을 한 뒤 “잠시 후 다시 시도해주세요.”라는 메세지와 함께 에러를 남기기로 결정했다.

 

이 어느정도를 몇 번으로 설정할지 고민하다가 도서 구매 사이트에서 특정 도서를 구매하는 트래픽이 동시에 얼마나 몰리는지 전혀 감이 잡히지 않아 1000번 동시에 주문을 했을 때 안정적으로 모든 주문을 처리할 수 있는 횟수를 테스트해보았다.

 

30번 서비스를 반복했을 경우 기본적으로 12~15번 정도 실패하였고, 40번 서비스를 반복했을 경우 모든 요청을 정상적으로 처리하였다. 그래서 더 안정적으로 50번 반복하기로 결정하였다.

 

테스트 코드에서 OrderService 대신 OrderServiceFacade를 주입받아 save() 메소드를 반복 호출하도록 코드를 변경하였다.

@Test
@DisplayName("1000명이 동시에 같은 책을 주문했을 때 남아있는 책의 양 테스트")
void 동시성_테스트() throws InterruptedException {

    BookDTO.Req book = new BookDTO.Req(1L, 1);
    OrderDTO.Req order = new OrderDTO.Req("rhdtn311@naver.com", "왕십리", List.of(book), 1000);

    // 책의 총 수량은 1000권
    int threadCount = 1000;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(1000);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                orderServiceFacade.save(order);
            } catch (Exception e) {
                System.out.println("error : " + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Book findBook = bookRepository.findById(1L).get();
    System.out.println(findBook);
}

1000개의 동시 요청이 정상적으로 처리되었다.

 

비관적 락 vs 낙관적 락

비관적 락은 실제로 충돌이 자주 발생하는 트랜잭션에 적용하는 것이 좋고 낙관적 락은 실제로 충돌이 자주 발생하지 않는다면 DB에 Lock을 거는 것이 아니기 때문에 성능상 더 좋다고 한다. 동시에 하나의 도서를 여러 명이 동시에 주문하는 상황이 적을 것이라고 판단하여 낙관적 락을 사용하도록 결정하였다.