이번 게시글에서는 저번 [공부정리] 동시성 문제 테스트에서 @Transactional과 Thread 문제에 이어서 동시성 문제를 해결하기 위한 과정에 대해서 기술하겠다.
챗봇 서비스를 개발하는 프로젝트에서 사용자가 네이버 웹툰 쿠키와 같은 배터리를 구매하고, 이를 이용하여 챗봇을 구매하는 기능을 구현하고 있다. 아래는 해당 코드이다.
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class ChatBotService {
private final MemberRepository memberRepository;
@Transactional
public void purchaseChatBot(final ChatBotPurchaseRequest chatBotPurchaseRequest,
final CustomOAuth2User customOAuth2User) {
final ChatBotItem chatBotItem = ChatBotItem.findByName(chatBotPurchaseRequest.getChatBotItemName())
.orElseThrow(ChatBotNotFoundException::new);
final MemberEntity memberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(MemberNotFoundException::new);
memberEntity.subtractBatteries(chatBotItem.getPrice());
memberEntity.addChatBot(chatBotItem);
}
}
ChatBotService 클래스에서 purchaseChatBot 메서드는 특정 사용자가 원하는 챗봇을 구매할 수 있도록 한다. 먼저, 챗봇 이름으로 ChatBotItem을 찾고, 사용자의 정보를 가져온다. 그 후, 배터리 수를 차감하고 챗봇을 추가한다.
public void subtractBatteries(final int count) {
if (this.batteryCount < count) {
throw new BatteryInsufficientException();
}
this.batteryCount -= count;
}
public void addChatBot(final ChatBotItem chatBotItem) {
if (this.chatBots.contains(chatBotItem)) {
throw new ChatBotAlreadyOwnedException();
}
this.chatBots.add(chatBotItem);
}
MemberEntity 클래스의 subtractBatteries 메서드는 사용자의 배터리 수를 차감하고, addChatBot 메서드는 사용자가 소유한 챗봇 리스트에 새로운 챗봇을 추가한다.
기존에 구현한 로직에서 동시성 문제가 발생할 것으로 예상하여 테스트 코드를 작성하였다. 아래는 해당 코드이다.
@SpringBootTest
@ActiveProfiles("test")
public class ChatBotServiceConcurrencyTest {
@Autowired
private ChatBotService chatBotService;
@Autowired
private MemberRepository memberRepository;
/**
* 테스트에서 @Transactional과 ExecutorService가 생성한 스레드의 Transactional이 다름. ExecutorService에서 초기 데이터를 조회하지 못함.
* 테스트에서@Transactional를 사용하지 않고 명시적으로 데이터베이스 초기화.
*/
@Autowired
private DatabaseCleaner databaseCleaner;
private CustomOAuth2User customOAuth2User;
@BeforeEach
void setUp() {
databaseCleaner.clean();
MemberEntity memberEntity = MemberEntity.builder()
.name("testUser")
.oAuth2(OAuth2Type.OAUTH2_GITHUB)
.username("testUser")
.profileImage("testProfileImage")
.role(RoleType.ROLE_USER)
.build();
memberEntity.addBatteries(100);
MemberEntity savedMemberEntity = memberRepository.save(memberEntity);
customOAuth2User = new CustomOAuth2User(savedMemberEntity.getId(), savedMemberEntity.getRole(),
savedMemberEntity.getOAuth2());
}
@Test
void testSequentialPurchaseRequests() {
ChatBotPurchaseRequest purchaseRequest1 = new ChatBotPurchaseRequest("아저씨 챗봇");
ChatBotPurchaseRequest purchaseRequest2 = new ChatBotPurchaseRequest("아줌마 챗봇");
ChatBotPurchaseRequest purchaseRequest3 = new ChatBotPurchaseRequest("어린이 챗봇");
try {
chatBotService.purchaseChatBot(purchaseRequest1, customOAuth2User);
chatBotService.purchaseChatBot(purchaseRequest2, customOAuth2User);
chatBotService.purchaseChatBot(purchaseRequest3, customOAuth2User);
} catch (Exception e) {
System.out.println(e.getMessage());
}
MemberEntity updatedMemberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(() -> new IllegalStateException("Member not found"));
System.out.println("Remaining batteries: " + updatedMemberEntity.getBatteryCount());
System.out.println("Purchased chatbots: " + updatedMemberEntity.getChatBots());
}
@Test
void testConcurrentPurchaseRequests() throws InterruptedException {
final int numberOfThreads = 3;
final ExecutorService executorService = Executors.newFixedThreadPool(3);
final CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
ChatBotPurchaseRequest purchaseRequest1 = new ChatBotPurchaseRequest("아저씨 챗봇");
ChatBotPurchaseRequest purchaseRequest2 = new ChatBotPurchaseRequest("아줌마 챗봇");
ChatBotPurchaseRequest purchaseRequest3 = new ChatBotPurchaseRequest("어린이 챗봇");
ChatBotPurchaseRequest[] purchaseRequests = {purchaseRequest1, purchaseRequest2, purchaseRequest3};
for (ChatBotPurchaseRequest purchaseRequest : purchaseRequests) {
executorService.submit(() -> {
try {
chatBotService.purchaseChatBot(purchaseRequest, customOAuth2User);
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
MemberEntity updatedMemberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(() -> new IllegalStateException("Member not found"));
System.out.println("Remaining batteries: " + updatedMemberEntity.getBatteryCount());
System.out.println("Purchased chatbots: " + updatedMemberEntity.getChatBots());
}
}
testSequentialPurchaseRequests 메서드는 순차적으로 챗봇 구매를 테스트하고, testConcurrentPurchaseRequests 메서드는 병렬적으로 챗봇 구매를 테스트한다.
순차적으로 요청을 보낼 경우, 정상적으로 배터리 개수가 차감되고, 챗봇을 보유한다.
테스트 1
테스트 2
병렬적으로 요청을 보낼경우 마지막으로 반영된 구매 요청만 반영된다. 즉, 확률적으로 챗봇이 구매되는 상황이다.
자바에서는 synchronized 키워드를 통해 데이터에 하나의 스레드만 접근이 가능하도록 만들어준다. 따라서 synchronized를 사용하면 간단하게 문제를 해결할 수 있다.
@Transactional
public synchronized void purchaseChatBot(final ChatBotPurchaseRequest chatBotPurchaseRequest,
final CustomOAuth2User customOAuth2User) {
final ChatBotItem chatBotItem = ChatBotItem.findByName(chatBotPurchaseRequest.getChatBotItemName())
.orElseThrow(ChatBotNotFoundException::new);
final MemberEntity memberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(MemberNotFoundException::new);
memberEntity.subtractBatteries(chatBotItem.getPrice());
memberEntity.addChatBot(chatBotItem);
}
메소드 선언부에 synchronized 키워드를 붙여주면 된다.
자 이제 해결 됐겠지? 테스트 해보자.
여전히 마지막으로 반영된 구매 요청만 반영된다..
이유는 간단하다. @Transactional 때문이다. synchronized는 메서드를 기준으로 쓰레드에 접근을 막아주는데, Transactional은 메소드가 종료되면 DB에 변경된 정보를 반영해주기 때문이다. @Transactional을 제거하면 잘 작동될 것이다.
@RequiredArgsConstructor
@Service
public class ChatBotService {
private final MemberRepository memberRepository;
public synchronized void purchaseChatBot(final ChatBotPurchaseRequest chatBotPurchaseRequest,
final CustomOAuth2User customOAuth2User) {
final ChatBotItem chatBotItem = ChatBotItem.findByName(chatBotPurchaseRequest.getChatBotItemName())
.orElseThrow(ChatBotNotFoundException::new);
final MemberEntity memberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(MemberNotFoundException::new);
memberEntity.subtractBatteries(chatBotItem.getPrice());
memberEntity.addChatBot(chatBotItem);
memberRepository.save(memberEntity);
}
}
open-in-view 옵션이 꺼져있고 @Transactional도 제거해주었기 때문에 memberEntity는 조회 이후에 비영속 상태이다. 따라서 명시적으로 save를 호출해주어서 업데이트시켜주어야 한다.
이제 모든 요청이 잘 반영 된 모습이다.
이제 끝, 쉬자! 라고 하면 안 된다. synchronized의 단점은 명확하다. 나처럼 open-in-view 옵션이 꺼져있다면, @Transactional을 사용하지 못함으로써 발생하는 더티체킹, Lazy Loading 등 문제점들이 엄청날 것이다. 또한 멀티 프로세서 환경에서는 synchronized는 근본적으로 동시성 문제를 해결해주지 못한다.
그래서 이제부터는 DB를 이용해보자.
데이터베이스 격리 수준(Isolation Level)을 설정하여 트랜잭션 간의 동시성 문제를 해결해보자. 트랜잭션 격리 수준은 트랜잭션이 실행되는 동안 다른 트랜잭션의 영향을 얼마나 받을지를 결정하는 설정이다.
대표적인 격리 수준으로는 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE이 있다. 이 중 SERIALIZABLE은 가장 높은 격리 수준으로, 트랜잭션 간의 충돌을 최소화하고 일관성을 보장한다.
다음과 같이 SERIALIZABLE 격리 수준을 적용한 트랜잭션을 설정할 수 있다:
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class ChatBotService {
private final MemberRepository memberRepository;
@Transactional(isolation = Isolation.SERIALIZABLE)
public void purchaseChatBot(final ChatBotPurchaseRequest chatBotPurchaseRequest,
final CustomOAuth2User customOAuth2User) {
final ChatBotItem chatBotItem = ChatBotItem.findByName(chatBotPurchaseRequest.getChatBotItemName())
.orElseThrow(ChatBotNotFoundException::new);
final MemberEntity memberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(MemberNotFoundException::new);
memberEntity.subtractBatteries(chatBotItem.getPrice());
memberEntity.addChatBot(chatBotItem);
}
}
SERIALIZABLE 격리 수준을 사용하여 트랜잭션이 직렬화되도록 보장한다.
요청을 제대로 처리하지 못하는 모습이다.
이유를 찾기 위해 로그를 확인해보자.
2024-07-19T04:51:29.134+09:00 WARN 2040 --- [bamboo-forest_backend] [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: 40001
2024-07-19T04:51:29.134+09:00 WARN 2040 --- [bamboo-forest_backend] [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: 40001
2024-07-19T04:51:29.134+09:00 ERROR 2040 --- [bamboo-forest_backend] [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: could not serialize access due to concurrent update
2024-07-19T04:51:29.134+09:00 ERROR 2040 --- [bamboo-forest_backend] [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: could not serialize access due to concurrent update
could not execute statement [ERROR: could not serialize access due to concurrent update] [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]; SQL [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]
could not execute statement [ERROR: could not serialize access due to concurrent update] [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]; SQL [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]
- SQL Error: 0, SQLState: 40001: SQL 상태 코드 40001은 직렬화 실패를 나타낸다. 이는 트랜잭션이 직렬화 격리 수준에서 충돌하여 완료되지 못했음을 의미한다.
- could not serialize access due to concurrent update: 이 메시지는 두 개 이상의 트랜잭션이 동시에 같은 데이터를 업데이트하려고 할 때 발생하는 오류이다. PostgreSQL은 이러한 충돌을 감지하고 트랜잭션을 실패시킨다.
- could not execute statement: 트랜잭션이 직렬화 오류로 인해 실패하면서 해당 SQL 업데이트 문이 실행되지 못했다는 것을 나타낸다.
해당 로그가 발생하는 이유는 PostgreSQL의 동작 방식에 문제이다. PostgreSQL는 Serializable 상태에서 SELECT 하면서 LOCK을 거는 것이 아닌, Update 하면서 Lock을 걸기 때문이다. 자세한 내용은 아래 블로그를 참고하자.
이러한 문제점들을 해결하기 위해서 블로그에서 추천한대로 이제부터 DB의 Lock을 사용할 것이다.
Pessimistic Lock을 Spring Data JPA에서 적용하는 방법은 매우 간단하다. 아래와 같이, Repository에서 Lock 관련 어노테이션을 사용해주면 된다.
@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, Long>, QuerydslMemberRepository {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from MemberEntity m where m.id = :id")
Optional<MemberEntity> findByIdWithLock(@Param("id") Long id);
}
findByIdWithLock 메서드는 Pessimistic Write Lock을 적용하여 특정 회원을 조회한다.
Pessimistic Lock을 이용해도 모든 요청이 잘 반영된 모습이다.
로그도 확인해보자.
SELECT ... FOR NO KEY UPDATE를 이용해서 행을 읽으면서 동시에 해당 행에 대한 잠금을 설정한다. 참고로 FOR NO KEY UPDATE는 FOR UPDATE 와 비슷하게 동작하지만, 더 약한 수준이라고 한다. Lock이 걸려 있는 행에 대해 SELECT FOR KEY SHARE 명령어는 즉시 실행 된다.
위에서 한 번에 3번의 SELECT ... FOR NO KEY UPDATE를 실행하였긴 하지만, 결국 조회를 완료하는 시점은 이전 요청이 마무리되고 조회를 완료한다. 따라서 요청은 순차적으로 처리된다.
이제 끝, 쉬자! 라고 하면 될까? 이번에도 안 된다. 근본적으로 문제를 분석해보자. 한 사용자가 챗봇 구매를 동시에 요청해서 race condition이 발생할 확률이 높을까? 웹 UI를 조작하는 상황에서는 흔하지 않다고 생각한다. 따라서 이런 경우에는 Pessimistic Lock이 아닌, 동시성 문제가 발생하지 않겠지? 라고 낙관적으로 생각하는 Optimistic Lock이 더 적합하다.
그 전에, PESSIMISTIC_WRITE가 아닌 PESSIMISTIC_READ를 사용하면 어떤 결과가 발생할까?
참고로 H2는 PESSIMISTIC_READ를 지원하지 않는다. 아래 내용을 참고하자.
읽기 락을 보여주기 위해서는 테스트를 PostgreSQL로 전환해야 합니다. 왜냐하면 H2 데이터베이스는 오직 배타적 락만 제공하기 때문입니다.
Spring Data JPA에서 PESSIMISTIC_READ를 사용하는 방법은 간단하다. 아래와 같이, Repository에서 Lock 관련 어노테이션을 PESSIMISTIC_WRITE 가 아닌 PESSIMISTIC_READ를 사용해주면 된다.
@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, Long>, QuerydslMemberRepository {
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select m from MemberEntity m where m.id = :id")
Optional<MemberEntity> findByIdWithLock(@Param("id") Long id);
}
findByIdWithLock 메서드는 Pessimistic Read Lock을 적용하여 특정 회원을 조회한다.
정삭적인 결과가 나오지 않는다.
로그를 분석해보자.
2024-07-19T04:11:20.883+09:00 ERROR 5492 --- [bamboo-forest_backend] [pool-2-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: deadlock detected
Detail: Process 830 waits for ShareLock on transaction 86559; blocked by process 828.
Process 828 waits for ShareLock on transaction 86560; blocked by process 830.
Hint: See server log for query details.
Where: while updating tuple (0,1) in relation "member"
could not execute statement [ERROR: deadlock detected
Detail: Process 830 waits for ShareLock on transaction 86559; blocked by process 828.
Process 828 waits for ShareLock on transaction 86560; blocked by process 830.
Hint: See server log for query details.
Where: while updating tuple (0,1) in relation "member"] [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]; SQL [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]
2024-07-19T04:11:21.880+09:00 WARN 5492 --- [bamboo-forest_backend] [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: 40P01
2024-07-19T04:11:21.880+09:00 ERROR 5492 --- [bamboo-forest_backend] [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: deadlock detected
Detail: Process 828 waits for ShareLock on transaction 86561; blocked by process 829.
Process 829 waits for ShareLock on transaction 86559; blocked by process 828.
Hint: See server log for query details.
Where: while updating tuple (0,1) in relation "member"
could not execute statement [ERROR: deadlock detected
Detail: Process 828 waits for ShareLock on transaction 86561; blocked by process 829.
Process 829 waits for ShareLock on transaction 86559; blocked by process 828.
Hint: See server log for query details.
Where: while updating tuple (0,1) in relation "member"] [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]; SQL [update member set battery_count=?,chat_bots=?,modified_at=?,name=?,o_auth2=?,profile_image=?,role=?,username=? where member_id=?]
두 프로세스가 서로의 ShareLock을 기다리면서 데드락이 발생했다. 각 프로세스는 서로가 잠금 해제를 기다리고 있어 진행되지 않는다고 한다.
데드락이 발생하는 이유는 다음과 같다
그리고 여기에서 Postgresql이 자동으로 Deadlock을 인지하고 임의 트랜잭션을 취소하여 해결한 것이다.
따라서, 데이터의 수정을 위해서 PESSIMISTIC_READ을 사용하는 것은 부적절하다.
Optimistic Lock은 동시성 문제가 발생하지 않는다고 가정하고, 문제가 발생하면 예외를 던지는 방식이다. 이를 적용하기 위해 MemberEntity에 @Version 어노테이션을 추가하고, Repository를 사용하는 곳에서 LockModeType.OPTIMISTIC을 사용한다.
@Version
private Long version;
@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, Long>, QuerydslMemberRepository {
@Lock(LockModeType.OPTIMISTIC)
@Query("select m from MemberEntity m where m.id = :id")
Optional<MemberEntity> findByIdWithLock(@Param("id") Long id);
}
MemberEntity 클래스에 @Version 어노테이션을 추가하여 버전 필드를 추가한다. 또한 MemberRepository 인터페이스에서 @Lock(LockModeType.OPTIMISTIC) 을 사용하여 낙관적 락을 설정한다.
위에 설정을 하였으나, 요청을 제대로 반영하지 못한 모습이다.
로그를 분석해보자.
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [org.jungppo.bambooforest.member.domain.entity.MemberEntity#1]
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [org.jungppo.bambooforest.member.domain.entity.MemberEntity#1]
Version이 맞지 않다는 뜻이다. 즉, 다른 트랜잭션이 엔티티를 수정했음을 의미한다.
이러한 로그가 발생한 이유는 Optimistic Lock의 특징 때문이다. Pessimistic Lock과는 다르게 Version 불일치 시 처리를 어플리케이션 레벨에서 담당하게 된다.
이는 버전 충돌로 인해 요청 처리에 실패할 경우, 직접 예외를 처리하여 재시도하는 로직을 구현해야 함을 뜻한다.
아래와 같이 간단하게 재시도 로직을 구현하면 된다. 참고로 새로운 클래스를 만들어 챗봇 구매 메서드를 실행하게 한 이유는 재시도 로직을 트랜잭션 외부에서 처리하기 위함이다.
@RequiredArgsConstructor
@Service
public class OptimisticLockChatBotService {
private static final int MAX_RETRIES = 3;
private final ChatBotService chatBotService;
public void optimisticLockPurchaseChatBot(final ChatBotPurchaseRequest chatBotPurchaseRequest,
final CustomOAuth2User customOAuth2User) throws InterruptedException {
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
chatBotService.purchaseChatBot(chatBotPurchaseRequest, customOAuth2User);
} catch (OptimisticLockingFailureException e) {
if (attempt == MAX_RETRIES - 1) {
throw e;
}
Thread.sleep(50);
}
}
}
}
OptimisticLockingFailureException이 발생하면 재시도 횟수를 체크하고, 최대 재시도 횟수에 도달하면 예외를 던진다.
테스트 로직에서는 OptimisticLockChatBotService를 이용하여 테스트를 수행한다.
Optimistic Lock을 적용한 경우에도 모든 요청이 잘 반영된 모습을 확인할 수 있다.
이제 진짜 마지막 개선 하나만 더 해보자. 현재 OptimisticLock 실패시 재시도하는 로직이 마음에 들지 않는다.
Spring에는 재시도를 위해 spring-retry를 제공해준다. Spring은 없는게 없다.
Spring Retry는 재시도 로직을 간단하게 구현할 수 있는 프레임워크이다. AOP(Aspect-Oriented Programming) 방식을 사용하여, 메서드 호출 시 예외가 발생하면 자동으로 재시도하도록 구성된다. 이를 통해 코드의 간결성을 유지하면서도 재시도 로직을 쉽게 적용할 수 있다.
spring-retry를 사용하는 방법은 굉장히 간단하다.
implementation 'org.springframework.retry:spring-retry'
build.gradle 파일에 Spring Retry 의존성을 추가한다.
@Configuration
@EnableRetry
public class RetryConfig {
}
Spring Retry를 사용하기 위해 @EnableRetry 어노테이션을 사용하여 설정 클래스를 추가한다.
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class ChatBotService {
private final MemberRepository memberRepository;
@Retryable(retryFor = {OptimisticLockingFailureException.class})
@Transactional
public void purchaseChatBot(final ChatBotPurchaseRequest chatBotPurchaseRequest,
final CustomOAuth2User customOAuth2User) {
final ChatBotItem chatBotItem = ChatBotItem.findByName(chatBotPurchaseRequest.getChatBotItemName())
.orElseThrow(ChatBotNotFoundException::new);
final MemberEntity memberEntity = memberRepository.findByIdWithLock(customOAuth2User.getId())
.orElseThrow(MemberNotFoundException::new);
memberEntity.subtractBatteries(chatBotItem.getPrice());
memberEntity.addChatBot(chatBotItem);
}
}
ChatBotService 클래스의 purchaseChatBot 메서드에 @Retryable 어노테이션을 추가하여 재시도 로직을 적용한다. 이를 통해 OptimisticLockingFailureException 예외가 발생할 때 자동으로 재시도하게 된다.
이제 purchaseChatBot 메서드가 OptimisticLockingFailureException 예외를 발생시키면 자동으로 재시도하게 된다. @Retryable 어노테이션은 AOP 방식으로 동작하며, @Transactional 보다 먼저 적용되기 때문에 트랜잭션 내에서 재시도 로직이 올바르게 동작한다.
자세한 설명은 아래 README를 참고하는 것을 추천한다. 굉장히 친절하게 설명해주고 있다.
Spring Retry를 적용한 이후, 테스트 코드에서 테스트 해보자.
spring-retry를 사용해도 모든 요청이 잘 반영된 모습을 확인할 수 있다.
이번 프로젝트에서는 동시성 문제를 해결하기 위해 다양한 접근 방식을 시도하였다.
기존 로직 구현: 단순한 배터리 차감 및 챗봇 구매 로직을 구현했으나, 동시성 문제를 고려하지 않은 상태였다.
동시성 테스트 : 순차적 요청과 병렬 요청을 통해 동시성 문제를 테스트하였고, 병렬 요청 시 동시성 문제가 발생함을 확인했다.
Synchronized 사용: synchronized 키워드를 사용하여 동시성 문제를 해결하려 했으나, @Transactional과의 충돌로 인해 제대로 작동하지 않았다.
데이터베이스 격리 수준 (SERIALIZABLE) 사용 : 트랜잭션 격리 수준을 SERIALIZABLE로 설정하여 동시성 문제를 해결하려 했으나, PostgreSQL의 직렬화 실패로 인해 문제가 발생했다.
Pessimistic Lock 사용 : Pessimistic Lock을 사용하여 동시성 문제를 해결했다. 하지만, 이는 성능 저하를 유발할 수 있다.
Optimistic Lock 사용: @Version 어노테이션을 통해 Optimistic Lock을 적용하고, 버전 충돌 시 재시도 로직을 구현했다. 성능 저하를 최소화하면서 동시성 문제를 해결할 수 있었다.
Spring Retry 적용:Spring Retry를 사용하여 재시도 로직을 간단하게 구현했다. @Retryable 어노테이션을 사용하여 OptimisticLockingFailureException 발생 시 자동으로 재시도하도록 설정했다.
이 밖에도 분산 Lock 등 여러 가지 방법들이 존재하지만, 현재 프로젝트 규모에서 더 나아가는 것은 오버엔지니어링이라고 판단하였다.
지금 현재 상태에서는 최적의 해결책으로 생각하고 있는 Optimistic Lock과 Spring Retry를 사용하여 동시성 문제를 해결했다.