최근 우연찮은 기회로 현업 개발자와 동시성 이슈에 대한 얘기를 할 기회를 얻었다. 동시성 이슈에 대해서 고민해보고 어떤 상황에 어떤 방식으로 해결하는 결론을 내렸는 지 정리된 걸 얘기해 보았는데, 결론은 Serializable을 쓸 일은 없었지만 내가 고민한 과정중에 있었고 지식 교정이 필요한 걸 알게 되었다. 이번 포스팅에선 그 점을 다뤄볼까 한다.
DB에 데이터가 공유자원이다. 동시성을 어떻게 해결할거냐?
RDBMS라면 일단 낙관락, 비관락 이런걸 해결책으로 떠올릴 수 있고, 여러 저장소와 글로벌 트랜잭션이 필요하다면 Redis로 공유 락을 잡는 방법도 있다. 이건 모범 답안이고, 이런 방법들을 알기 전에 나는 격리 레벨을 Serializable로 설정하면 데이터에 락이 걸려서 트랜잭션간 동시성이 잡히지 않을까? 라는 생각을 했었다.
결과부터 말하면, 이건 너무나 위험한 생각이다. 포스팅 제목처럼 데드락을 만나기 때문. 이 생각을 말했을 때 돌아온 반응은… 혹시 팬텀 리드 문제가 있었어요? 동시성 고민하는데 왜 Serializable을 써요..? 하는 참 황당하다는 반응이었다.
왜 데드락이 발생할까?
Serializable은 그 트랜잭션 안에서 조회되는 모든 row에 대해서 s-lock을 획득하고 읽어온다. 그리고 그 row를 수정할 땐 x-lock을 획득하고 수정한다. 그리고 s-lock은 여러 트랜잭션이 동시에 획득할 수 있고, 다른 트랜잭션이 s-lock을 획득해 놓은 상태에선 x-lock을 획득하지 못하고 대기해야 한다. 그럼 두 트랜잭션이 동시에 한 row를 읽어오고 수정하려 한다면..?
이런 플로우를 타게 된다. A트랜잭션과 B트랜잭션이 서로 잡아놓은 s-lock 때문에 무한대기가 된다. 보통 데드락은 여러 자원에 락을 걸 경우 발생하는 것이 보편적인데, Serializable같은 경우는 하나의 자원에 락을 걸어도 s-lock과 x-lock이라는 특성때문에 데드락이 발생한다.
직접 학습 테스트로 확인해보자
주변에서 공부를 하는 사람들 중 꽤 많은 사람들이 Serializable이 동시성을 잡는 썩 괜찮은 방법은 아니지만, 잡힌다고 알고 있다. 그래서 저런 이론적인 내용만 가지고 판단을 내릴 수 없었다. 직접 코드를 작성해봤다.
수정할 Row JPA엔티티
@Entity
public class Foo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer amount;
public Foo(final String name, final Integer amount) {
this.name = name;
this.amount = amount;
}
public void addAmount() {
this.amount = amount + 1;
}
// Getters...
}
Spring data JPA 의 JPARepository
public interface FooRepository extends JpaRepository<Foo, Long> {
Optional<Foo> findByName(final String name);
}
Serializable이 적용된 Service 코드
@Autowired
public class SerializableService {
private final FooRepository fooRepository;
public SerializableService(final FooRepository fooRepository) {
this.fooRepository = fooRepository;
}
@Transactional
public Foo save(final String name, final Integer amount) {
return fooRepository.save(new Foo(name, amount));
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public void addAmount(final String name) {
final Foo foo = fooRepository.findByName(name).orElseThrow(); // s - lock 획득
foo.addAmount();
sleep();
} // commit 시 x - lock 획득
private void sleep() {
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
}
}
이름으로 엔티티를 조회한 후에 그 엔티티의 값을 수정할 것이다. 격리 레벨이 Serializable이기 때문에 조회시에 s-lock을 획득할 것이며 JPA엔티티이기 때문에 트랜잭션이 commit 되면서 update문이 나가는데 x-lock을 획득하려 할 것이다. 동시 접근 상황을 발생시키기 위해 일부러 지연시간을 주는 코드를 포함했다.
사용할 테스트 코드 및 설명
@SpringBoopTest
class SerializableTest {
@Autowired
private SerializableService serializableService;
@BeforeEach
void setUp() {
serializableService.save("test", 10);
}
@Test
@DisplayName("격리 레벨이 Serializable인 것만으로도 데드락이 발생한다..?")
void serializableDeadLock() throws InterruptedException {
final ExecutorService executorService = Executors.newFixedThreadPool(2);
final CountDownLatch countDownLatch = new CountDownLatch(2);
for (int i = 0; i < 2; i++) {
executorService.submit(() -> {
serializableService.addAmount("test");
countDownLatch.countDown();
});
}
countDownLatch.await(5000L, TimeUnit.MILLISECONDS);
assertThat(countDownLatch.getCount()).isEqualTo(2);
}
}
일단 row하나를 추가한다.
스레드 2개를 동시에 실행해서 serializableService의 addAmount메서드를 실행할 것이다. addAmount는 지연시키는 로직이 있기 때문에 동시접근 상황이 발생할 것이고 두 트랜잭션이 s-lock을 획득할 것이다.
countDownLatch는 트랜잭션이 정상 작동하면 카운트 다운을 내린다. 만약 두 트랜잭션이 모두 정상작동해서 카운트다운이 0이 되면 await문을 통과하고 5초간 카운트다운이 0이 되지 못하면 강제로 통과시키도록 했다. 그리고 최종적으로 트랜잭션이 정상작동해서 카운트다운이 내려가지 않았는 지 검증한다.
만약 카운트다운이 처음이랑 같다면 데드락이 발생해서 그 어떤 트랜잭션도 5초간 종료되지 못했음을 의미하고, 카운트다운이 내려가서 await문을 통과했다면 데드락이 발생하지 않아서 테스트를 실패하게 할 것이다.
결과는..?
테스트가 성공적으로 통과.
진짜 단 두개의 동시접속 만으로도 데드락이 발생한다. 동시성을 예방하기 위해 Serializable을 걸었다간 이런 데드락을 맞이한다는 걸 이론뿐 아니라 실습으로도 확인했다.
결론
Serializable은 팬텀 리드 문제를 해결하기 위해서만 사용한다.(mysql의 InnoDB 엔진일 경우 Repeatable Read만 되어도 팬텀 리드가 발생하지 않는다.) 사실 거의 사용할 일 없다. 동시성을 잡는다고, Serializable의 락 기능을 이용해보겠다고 그걸 걸었다간… 더 어려운 문제를 맞이할 수 있다.
'DB' 카테고리의 다른 글
커버링 인덱스와 ICP(index condition pushdown)에 대한 고민 (0) | 2023.09.30 |
---|---|
Real MySQL 5장 트랜잭션과 잠금 정리 (0) | 2023.05.07 |
Real MySQL 4장 아키텍처 정리 (0) | 2023.05.07 |
DB의 락 기능을 이용해서 동시성 이슈 해결하기 (feat. 우테코 체스미션) (0) | 2022.05.24 |