1. 발생한 문제
@Transactional
public void testbookorder(Long bookid, Integer quantity, Member member) {
final String lockname = bookid + ":lock";
final RLock lock = redissonClient.getLock(lockname);
try {
if (!lock.tryLock(3, 1, TimeUnit.SECONDS)) {
throw new RuntimeException("Rock fail");
}
Thread.sleep(10);
Book book = bookRepository.findById(bookid).orElseThrow(
() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
);
Long inven = book.getInventory() - quantity;
if (inven < 0) {
throw new CustomException(ErrorCode.INVALIDATION_NOT_ENOUGH);
} else {
book.batchBook(inven);
jumoonRepository.save(new Jumoon(member, book, quantity));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
- lock을 점거한뒤 lock을 해제하고 난 뒤에 트랜잭션이 종료된다.
- 즉 lock을 해제하고 다른 쓰레드가 lock을 점거하고 book을 조회하는 동안 transaction이 마무리하지 못한다면 동시성 에러가 발생할 것이다.
- 이를 해결하기 위해선 위 와 같이 단순히 Thread.sleep 을 통해 시간을 벌어서 해결 할 수 있다.
- 하지만 서버 부하로 인한 지연 등 다양한 경우를 고려 했을 때 매우 위험한 방법이다.
- 그렇다고 과도하게 sleep을 잡으면 처리속도가 낮아지고, 대기중이던 스레드 들이 실패할 수 있을 것이다.
- Thread.sleep을 통해 시간을 벌면서 문제를 해결했기에 원인을 lock해제/점거 타이밍과 transaction 종료 타이밍이 어긋난 것으로 생각하고 있으나 좀더 찾아봐야 확실해 질 것 이다.
- 찾았다 : 레디스 락의 경우 메서드 끝 부분에서 unlock메서드를 통해 풀지만, RDB commit은 메서드가 모두 끝난 후 실행된다. 이는 스프링의 트랜잭션이 AOP를 기반으로 동작하기 때문인데, 이 때문에 데이터가 꼬일 가능성이 아주 희박하게나마 있다 > 참조 확인
2. 해결책1
- 메서드 전체에 transaction이 걸리는게 아니라 try 문 내에 있는 서비스 로직에 transaction이 걸려야 할 것 이다.
- 그래서 아래와 같이 코드를 바꾸어 주었다.
@Transactional
public void testbookorder(Long bookid, Integer quantity, Member member) {
final String lockname = bookid + ":lock";
final RLock lock = redissonClient.getLock(lockname);
try {
if (!lock.tryLock(3, 1, TimeUnit.SECONDS)) {
throw new RuntimeException("Rock fail");
}
Book book = bookRepository.findById(bookid).orElseThrow(
() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
);
intrymethod(book,quantity,member);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
@Transactional
public void intrymethod(Book book, Integer qqq, Member member){
Long inven = book.getInventory() - qqq;
if (inven < 0) {
throw new CustomException(ErrorCode.INVALIDATION_NOT_ENOUGH);
} else {
book.batchBook(inven);
bookRepository.save(book);
jumoonRepository.save(new Jumoon(member, book, qqq));
}
}
- 근데 여전히 동시성 문제가 발생했다.
- 추측하는 이유(찾아봐야 확실함) : transaction 안에 transaction이 있다. 그래서 최종 커밋 시점이 바깥 transacion으로 바뀌어서 코드를 바꾸기 전과 결과적으로는 똑같은 것 같다.
- 찾았다(chatgpt) : 기본적으로 Spring에서 @Transactional 어노테이션이 적용된 메서드 안에서 다른 @Transactional 어노테이션이 적용된 메서드를 호출하면, 내부 메서드 호출은 외부 메서드와 동일한 트랜잭션 내에서 실행됩니다. 이를 호출하는 쪽에서 오류가 발생하지 않는 한, 내부 메서드의 데이터 변경 내용은 외부 트랜잭션과 함께 커밋됩니다. 따라서 최종 커밋 시점은 외부 트랜잭션이 끝났을 때로 통일됩니다. 만약 내부 메서드에서 예외가 발생한다면, 외부 트랜잭션도 롤백됩니다. 이를 통해 여러 개의 @Transactional 어노테이션이 적용된 메서드를 적절히 사용하여 트랜잭션 경계를 관리하면 데이터 일관성을 유지할 수 있습니다.
- "Spring은 중첩된 @Transactional 메서드 호출을 처리할 때 일종의 '사물함' 메커니즘을 사용합니다. Spring 트랜잭션 서비스가 트랜잭션을 시작하면 트랜잭션을 관리하는 데이터 구조(사물함)를 생성합니다. 중첩된 @Transactional 메서드가 호출될 때마다, 각 메서드는 자체적으로 트랜잭션을 처리하지 않고, 대신 호출 측의 트랜잭션을 사용합니다. 메서드에서 수행된 작업은 독립적인 데이터 구조(사물함)에 저장되며, 그 구조는 호출자의 구조 안에 저장됩니다. 메서드 호출이 완료되면, Spring 트랜잭션 서비스는 데이터 구조(사물함)를 폐기합니다. 트랜잭션이 롤백될 때, 폐기되지 않은 모든 데이터 구조(사물함)를 롤백합니다."
2. 해결책2
- 바깥 트랜잭션을 제거하고 테스트를 해보자
public void testbookorder(Long bookid, Integer quantity, Member member) {
final String lockname = bookid + ":lock";
final RLock lock = redissonClient.getLock(lockname);
try {
if (!lock.tryLock(3, 1, TimeUnit.SECONDS)) {
throw new RuntimeException("Rock fail");
}
Book book = bookRepository.findById(bookid).orElseThrow(
() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
);
intrymethod(book,quantity,member);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
@Transactional
public void intrymethod(Book book, Integer qqq, Member member){
Long inven = book.getInventory() - qqq;
if (inven < 0) {
throw new CustomException(ErrorCode.INVALIDATION_NOT_ENOUGH);
} else {
book.batchBook(inven);
bookRepository.save(book);
jumoonRepository.save(new Jumoon(member, book, qqq));
}
}
- 문제가 해결되었다.
- 실험 테스트 결과와 시나리오는 아래에 정리해 놓음
3. 실험 시나리오와 결과
- 재고량이 20권인 책에 3권씩 주문을 20개 넣는다. 재고는 2권이 남아야하며 주문은 6개가 되어야한다.
- 앞서 문제가 발생한 최초의 코드
- 6개이상의 과주문이 지속적으로 발생하였다. (10번의 테스트 중 9~10번 발생)
- 바깥 트랜잭션과 내부 트랜잭션 2개가 존재하는 경우
- 6개이상의 과주문이 지속적으로 발생하였다. (10번의 테스트 중 9~10번 발생)
- 바깥 트랜잭션을 제거 한 이후
- 모든 경우에서 테스트가 성공하였다.
- 바깥 트랜잭션을 남기고 내부 트랜잭션을 없앴을 때는 당연히 실패하였다
4. 참조
1)Redisson과 트랜잭션 꼬임 : https://mslim8803.tistory.com/m/74?category=1005542
5. 테스트 코드 정리(개인적으로 정리한 거여서 안봐도 됩니다)
1) 테스트 코드
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ConcurrencyOrderTest {
@Autowired
MemberRepository memberRepository;
@Autowired
BookRepository bookRepository;
@Autowired
JumoonRepository jumoonRepository;
@Autowired
JumoonService jumoonService;
@RepeatedTest(10)
void testConnection() throws InterruptedException {
// 방법1: Book 생성자를 만들어 주어야 한다.
Book book = new Book("큰카테", "작은카테", "제목스", 9999, 9
, "작가스", "출판스", null, 1993, 5, 20L);
bookRepository.save(book);
// 방법2: 기존 DB에 있는 책을 이용하고 재고량을 20으로 설정하고 시작한다.
// Book book = bookRepository.findById(1L).orElseThrow(
// ()-> new CustomException(ErrorCode.BOOK_NOT_FOUND)
// );
// book.inventoryChangeBook(20L);
List<Member> members = new ArrayList<>();
for (int i = 0; i < 1; i++) {
String email = "testman" + i + "@ntest.com";
SigninDto signinDto = new SigninDto();
signinDto.setEmail(email);
signinDto.setPassword("111111");
signinDto.setSex(1);
signinDto.setAge(1993);
Member member = new Member(signinDto);
memberRepository.save(member);
members.add(member);
}
CountDownLatch countDownLatch = new CountDownLatch(20);
List<JumoonWorker> workers = Stream.
generate(() -> new JumoonWorker(members.get(0), book, 3, countDownLatch))
.limit(20)
.collect(Collectors.toList());
workers.forEach(worker -> new Thread(worker).start());
countDownLatch.await(1, TimeUnit.SECONDS);
Book updatebook = bookRepository.findById(book.getId()).orElseThrow(
() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
);
List<Jumoon> jumoons = jumoonRepository.findAllByBook_Id(updatebook.getId());
System.out.println("jumoons.size() = " + jumoons.size());
assertEquals(6, jumoons.size());
assertEquals(2, updatebook.getInventory());
}
private class JumoonWorker implements Runnable {
private Member member;
private Book book;
private int quantity;
private CountDownLatch countDownLatch;
public JumoonWorker(Member member, Book book, int quantity, CountDownLatch countDownLatch) {
this.member = member;
this.book = book;
this.quantity = quantity;
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
jumoonService.testbookorder(book.getId(), quantity, member);
countDownLatch.countDown();
}
}
}
2) 서비스 코드
@Service
@RequiredArgsConstructor
public class JumoonService {
private final JumoonRepository jumoonRepository;
private final EntityManager entityManager;
private final BookRepository bookRepository;
protected static final Logger orderLogger = LoggerFactory.getLogger("OrderLog");
private final RedissonClient redissonClient;
// @Transactional
public void testbookorder(Long bookid, Integer quantity, Member member) {
final String lockname = bookid + ":lock";
final RLock lock = redissonClient.getLock(lockname);
try {
if (!lock.tryLock(3, 1, TimeUnit.SECONDS)) {
throw new RuntimeException("Rock fail");
}
// Thread.sleep(10);
Book book = bookRepository.findById(bookid).orElseThrow(
() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
);
intrymethod(book,quantity,member);
// Long inven = book.getInventory() - quantity;
//
// if (inven < 0) {
// throw new CustomException(ErrorCode.INVALIDATION_NOT_ENOUGH);
//
// } else {
// book.batchBook(inven);
// jumoonRepository.save(new Jumoon(member, book, quantity));
// }
} catch (Exception e) {
e.printStackTrace();
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
@Transactional
public void intrymethod(Book book, Integer qqq, Member member){
Long inven = book.getInventory() - qqq;
if (inven < 0) {
throw new CustomException(ErrorCode.INVALIDATION_NOT_ENOUGH);
} else {
book.batchBook(inven);
bookRepository.save(book);
jumoonRepository.save(new Jumoon(member, book, qqq));
}
}
}