Spring, JPA, MySQL 동시성 문제 해결하기 - 카운터 값 업데이트 시 발생하는 문제

@melonturtle · March 14, 2024 · 26 min read

저번 글에서 살펴본 건 데드락 문제였다. 락에 대한 더 자세한 내용은 저번 글을 한 번 읽고 오면 좋다.

이번에 찾은 문제는 race condition 문제이다.

프로젝트에 횟수를 세는 속성이 많다. 먼저 프로젝트를 간단히 설명하자면 작성자는 게시글로 Topic을 만들고 투표할 수 있는 두 가지 선택지를 만든다. 그리고 다른 사용자들은 토픽에 투표, 댓글을 달 수 있다. 또 댓글엔 좋아요와 싫어요를 할 수 있다.

이 때 투표수, 댓글수, 좋아요, 싫어요수를 모두 성능을 위해 반정규화된 상태로 voteCount, commentCount, likeCount, hateCount 등이 각각 엔티티의 attribute로 존재한다.

여기서 어떤 문제가 발생할 수 있을까?

Race Condition

아주 예전에 운영체제 수업에서 배웠던 가장 기초적인 race condition 상황과 비슷하다. 동시성 문제에 대해 가장 먼저 접했던 문제 중 n개의 쓰레드가 전역변수 0에 각각 1씩 더하면, 모든 쓰레드의 작업이 끝나고 변수값이 n이 될까? 이런 문제가 있었다. 현재 프로젝트에서 사용하는 count값들도 비슷한 문제가 발생할 수 있다.

DB에서는 어떤 문제가 있을까?

우선 현재 프로젝트에선 MySQL을 사용하고 있고, 스토리지 엔진으로 InnoDB를 사용하며 Transaction Isolation Level은 REPEATABLE READ로 사용하고 있다. 이는 다 디폴트 세팅이기 때문에 MySQL을 사용한다면 같은 환경일 것이다.

이런 환경에선 위 예시처럼 각 트랜잭션이 한 row의 컬럼 값을 1씩 더한다고 했을 때, row의 값을 읽어오기 위해 SELECT 를 한다면 각 트랜잭션이 자신의 스냅샷에서 값을 읽고 UPDATE 시에 테이블에서 수정하게 된다. 즉 초기값이 0일때, 모든 트랜잭션이 동시에 시작한다면 모두 0 → 1로 업데이트하여 최종적으로 1이 될 수가 있다.

이렇게 되면 또 문제는, 좋아요, 싫어요 취소를 할 때 값을 1씩 감소시키는데, 1을 증가시키는 걸 실패했음에도 취소가 되는 문제가 발생할 수 있다.

현재 프로젝트에서 사용하는 count 값들도 모두 이런 race condition 상태에 빠질 수가 있다.

정말 그런지 한 번 테스트 코드를 작성해보자.

테스트

우선 동시성 테스트를 위한 거니까 각 투표를 쓰레드를 나눠서 동시에 진행해야 한다. 따라서 ExecutorService를 이용해준다.

// given
final int COUNT = 10;
final ExecutorService executorService = Executors.newFixedThreadPool(COUNT);

그리고 투표자들이 투표를 할 수 있도록 투표할 토픽을 만들어준다.

Member topicAuthor = createRandomMember();
em.persist(topicAuthor);
Topic topic = createRandomTopicByMemberWithChoices(
		topicAuthor, TopicSide.TOPIC_A, ChoiceOption.CHOICE_A, ChoiceOption.CHOICE_B);
em.persist(topic);

투표자들을 만들어주자.

List<Member> voters = IntStream.range(0, COUNT)
		.mapToObj(__ -> {
			Member voter = createRandomMember();
			em.persist(voter);
			return voter;
		})
		.toList();

그리고 각자 쓰레드에서 투표를 하고, 최종적으로 투표수가 투표자수와 동일한지 확인한다

// when
long votedAt = System.currentTimeMillis() / 1000;
    final CountDownLatch latch = new CountDownLatch(COUNT);
    voters.forEach(voter -> {
        executorService.execute(() -> {
            topicService.voteForTopicByMember(
                    topic.getId(), voter.getId(), new VoteRequest(ChoiceOption.CHOICE_A, votedAt));
            latch.countDown();
        });
    });
latch.await();

// then
assertThat(topic.getVoteCount()).isEqualTo(COUNT);
전체 코드
@Transactional
@SpringBootTest
public class TopicServiceConcurrencyTest {
    // ...
    @Test
    void voteForTopicByMember() throws InterruptedException {
        // given
        final int COUNT = 10;
        final ExecutorService executorService = Executors.newFixedThreadPool(COUNT);

        // 토픽 생성
        Member topicAuthor = createRandomMember();
        em.persist(topicAuthor);
        Topic topic = createRandomTopicByMemberWithChoices(
                topicAuthor, TopicSide.TOPIC_A, ChoiceOption.CHOICE_A, ChoiceOption.CHOICE_B);
        em.persist(topic);

        // 투표자 COUNT만큼 생성
        List<Member> voters = IntStream.range(0, COUNT)
                .mapToObj(__ -> {
                    Member voter = createRandomMember();
                    em.persist(voter);
                    return voter;
                })
                .toList();

        // when
        long votedAt = System.currentTimeMillis() / 1000;
        final CountDownLatch latch = new CountDownLatch(COUNT);
        voters.forEach(voter -> {
            executorService.execute(() -> {
                topicService.voteForTopicByMember(
                        topic.getId(), voter.getId(), new VoteRequest(ChoiceOption.CHOICE_A, votedAt));
                latch.countDown();
            });
        });
        latch.await();

        // then
        assertThat(topic.getVoteCount()).isEqualTo(COUNT);
    }
}

그럼 어떻게 동작할까?

우선 assert 문에 가기도 전에 exception이 발생하여 실패한다.

테스트 트랜잭션 커밋

exception을 살펴보자.

Exception in thread "pool-2-thread-2" life.offonoff.ab.exception.MemberByIdNotFoundException: 존재하지 않는 회원 입니다.
	...

execption이 발생하는 곳을 살펴보면, voteForTopicByMember에서 Member를 찾을 수 없다고 나온다. 이미 위에서 voterspersist했는데 왜 못 찾는 걸까?

executorService에서 쓰레드를 시작하면서 새로운 트랜잭션이 만들어지기 때문이다.

트랜잭션이 생성되는 로그를 살펴보기 위해 yml에서 로깅 레벨을 아래와 같이 설정해보자.

logging:
  level:
    root: debug

그리고 테스트를 실행하여 로그를 보면 아래와 같은 로그가 반복적으로 나온다.

DEBUG 41249 --- [    Test worker] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [life.offonoff.ab.application.service.TopicServiceConcurrencyTest.voteForTopicByMember]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

로그를 살펴보면 쓰레드의 개수 + 1개만큼 트랜잭션이 생성되는데, 이는 가장 처음 테스트의 트랜잭션(테스트 클래스가 @Transactional로 어노테이션되어 있으니 테스트 트랜잭션이 실행된다.) + 각 쓰레드에서의 트랜잭션이 생성된 것임을 알 수 있다.

각 쓰레드의 투표 메서드가 새로운 트랜잭션에서 실행되기 때문에, 아직 커밋되지 않은 테스트 트랜잭션의 수정사항을 읽을 수가 없다. 만약 테스트 트랜잭션의 변화를 flush로 반영한다음, 각 쓰레드의 투표 메서드 트랜잭션이 READ UNCOMMITTED 레벨어서 실행된다면 변경사항을 읽을 수 있을 것이다.

하지만 이는 실제 운영상황과 맞지 않으므로, 각 쓰레드의 트랜잭션이 시작되기 전에, 테스트 트랜잭션에서 발생한 수정사항을 db에 커밋시키는 편이 나을 것이다.

아래 코드로 현재 테스트 트랜잭션을 커밋시킬 수 있다.

TestTransaction.flagForCommit();
TestTransaction.end();
// 다음 테스트 코드를 위해 다시 시작
TestTransaction.start();

이 때 주의할점은, @Transactional 사용 시 트랜잭션에서 발생했던 수정사항들을 메서드가 끝나고 롤백시켜주는 건 현재 TestTransaction에서의 변경 사항들을 롤백시켜주므로, 만약 수동으로 TestTransaction 을 커밋시켰다면 이미 DB에 반영이 됐으므로 롤백할 수 없을 것이다.

또 한가지 더, 새로운 쓰레드에선 새로운 트랜잭션이 실행되기 때문에 그 트랜잭션에서 생긴 변경사항 또한 롤백되지 않으므로 이 점도 주의해야 한다.

따라서 테스트 메서드 후에 수동으로 db에 커밋된 데이터를 지워줘야할 것이다. @AfterEach 같은 걸 사용할 수 있겠다.

Spring: Test-managed Transactions

Specifically, Spring’s testing support binds transaction state to the current thread (via a java.lang.ThreadLocal variable) before the current test method is invoked. If a testing framework invokes the current test method in a new thread in order to support a preemptive timeout, any actions performed within the current test method will not be invoked within the test-managed transaction. Consequently, the result of any such actions will not be rolled back with the test-managed transaction. On the contrary, such actions will be committed to the persistent store — for example, a relational database — even though the test-managed transaction is properly rolled back by Spring.

문서에선 새로운 쓰레드에서 테스트를 실행하는 프레임워크에 대해 언급했지만, 현재 상황처럼 새 쓰레드를 실행하는 것도 이 상황과 같을 것이다.

스프링의 테스팅 서포트는 현재 쓰레드의 트랜잭션 상태에 적용되므로, 새로운 쓰레드의 트랜잭션에서의 동작들은 테스트가 관리하는 트랜잭션 내에서 롤백되지 않을 것이다.

그리고 다른 트랜잭션에서 Topic을 업데이트하므로, 업데이트된 값을 읽어오기 위해 단언문에서 TopicvoteCount를 얻어올때 DB에서 새로 조회해야한다.

Topic updatedTopic = em.find(Topic.class, topic.getId());
// 투표수는 COUNT와 동일해야함
assertThat(updatedTopic.getVoteCount()).isEqualTo(COUNT);

그럼 이제 괜찮을까? 코드를 실행해보자.

😂 또 단언문까지 가기도 전에 실패했다. 데드락이 발생했다.

org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update topic set ... ];

테스트에서 커밋된 데이터 지우기

다음으로 넘어가기 전에 잠깐 데이터를 지워주는 클래스를 만들고 활용해보자. 위에 언급한 것처럼, 스프링이 트랜잭션을 롤백해주는 기능을 이용할 수 없고 직접 지워야 한다. 이 테스트 클래스 말고 다른 클래스에서도 사용할 수 있도록 클래스를 만들어줬다.

참고: https://mangkyu.tistory.com/264

@RequiredArgsConstructor
@Service
public class Cleaner {
    private final JdbcTemplate jdbcTemplate;
    private List<String> truncateQueries;

	@PostConstruct
	public void loadTruncateQueries(){
        truncateQueries = jdbcTemplate.queryForList(
                "SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES " +
                        "WHERE TABLE_SCHEMA IN ('PUBLIC', 다른 스키마 이름들)",
                String.class);
    }

    @Transactional
    public void cleanTables() {
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
        truncateQueries.forEach(jdbcTemplate::execute);
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
    }
}
  • H2에서 실행할 때는 PUBLIC 스키마만 적용하면 되고, 나의 경우는 로컬 MySQL에도 연결하고 있으므로 그 이름을 추가해서 작성해줬다.
  • 테스트 클래스에서 이 Cleaner 객체를 주입받고 활용해주면 된다.
@Autowired
private Cleaner cleaner;

@AfterEach
void tearDown() {
    cleaner.cleanTables();
}

데드락

저번 글에서 적은 것과 마찬가지인 상태다. 투표시엔 Vote 객체를 만들고 Topic의 투표 수를 증가시키는데, Votetopic_id를 외래키로 갖고 있으므로, Vote 엔티티 저장 시에 해당하는 Topic에 대한 S락을 갖고 있다가 나중에 Topic의 투표 수를 증가시킬 때 X락이 필요해서 서로가 갖고 있는 락의 해제를 기다리게 되면서 데드락이 발생한다.

투표하는 함수에서 voteCount가 업데이트 되는 부분과 Vote가 저장되는 부분을 봐보자.

Vote vote = new Vote(choiceOption, votedAt);
// associate 안에서 voteCount가 증가됨
vote.associate(member, topic);
voteRepository.save(vote);

코드 상에선 voteCount가 먼저 증가되어 Topic이 먼저 수정된 후에 Vote가 저장되는 것 같지만, Topic은 더티체킹시에 업데이트되므로 나중에 업데이트 된다.

이를 해결하는 방법은 또 저번 글과 비슷하다. 여기선 가장 간단하게 해결하는 방법은 save전에 flush를 해서 변경사항을 먼저 반영하는 것이다. 그러면 업데이트를 먼저 하면서 X락을 바로 얻게 된다. 그러므로 모두가 S락을 들고 있다가 X락을 원하는 데드락은 발생하지 않을 것이다.

Vote vote = new Vote(choiceOption, votedAt);
vote.associate(member, topic);

voteRepository.flush(); // flush!
voteRepository.save(vote);

이제 실행해보자. 데드락이 발생하지 않는다!

하지만 역시 테스트는 실패했다.

Expected :10
Actual   :2

몇몇 트랜잭션은 이미 voteCount0 → 1로 업데이트된 후에 실행된 모양이다. 그 트랜잭션이 1 → 2로 업데이트한 값만이 남아있다.

해결 방법

이건 어떻게 해결할 수 있을까?

Locking Read

REPEATABLE READ에선 그냥 SELECT를 할 때는 자신의 스냅샷에서 읽기 때문에 락을 통한 locking read를 해야 최근 커밋된 값을 얻어올 수 있다. 그럼 Topic을 잠금을 얻으면서 조회를 하는 경우를 생각해볼 수 있다.

S락을 얻으면서 조회를 하는 경우를 살펴보자. 이 때는 현재 트랜잭션이 실행된 후에 커밋된 값도 읽을 수 있지만, 읽는 건 역시 커밋된 값이므로, 현재 트랜잭션에서 커밋된 값을 읽어오고 다시 업데이트하는 과정에서 다른 트랜잭션에서 값을 바꾼 건 읽어올 수 없다. 또, 위 데드락 예시처럼 또 X락을 얻으려다가 데드락이 발생한다.

그럼 X락을 사용하여 조회를 한다면 어떨까? X락을 얻으면 그 사이에 다른 트랜잭션이 값을 바꾸지 못하므로 항상 최신 값을 읽을 수밖에 없다. 하지만 역시 X락을 얻기 때문에, 그 사이에 다른 트랜잭션이 Topic에 대해 S락도 못 얻게 된다. Topic을 참조하는 엔티티가 많은데, 그 엔티티들이 외래키에 대한 S락이 필요할 때 모두 기다려야 한다.

물론 flush를 하면서 업데이트를 하므로 어차피 X락을 얻기는 한다. 하지만 처음 Topic을 조회할 때부터 락을 먼저 얻는 건 불필요하게 좀 더 오래 락을 갖게 되니까 다른 더 좋은 방법이 있으면 좋을 것이다.

Query 이용해서 DB에서 바로 UPDATE

위에 S락을 얘기하면서 했던 말을 다시 생각해보자. (1) 값을 먼저 읽어오고 (2) 다시 업데이트 하기 때문에 (1)과 (2) 사이 업데이트한 값이 있다면 업데이트할때 이미 오래된 데이터 기준 업데이트를 하는게 문제가 되는 것이다. 그럼 그냥 애초에 DB에 현재 값을 기준으로 1만 더하는 SQL문을 실행하면 되지 않을까?

Topic의 엔티티 속성인 voteCount에 1을 더하고 더티체킹을 이용하는게 아니라, DB 값을 기준으로 1을 더해달라는 SQL문을 따로 실행하는 것이다.

// TopicRepository
@Modifying
@Query("update Topic t set t.voteCount = t.voteCount + 1 where t.id = :topicId")
void increaseVoteCountOfTopic(Long topicId);

// TopicService
Vote vote = new Vote(choiceOption, votedAt);
vote.associate(member, topic);
// 엔티티의 voteCount를 1 증가시킨 게 아니므로 flush는 필요 없다.
topicRepository.increaseVoteCountOfTopic(topic.getId());
voteRepository.save(vote);

이 상태에서 실행해보면 테스트가 통과한다!

InnoDB는 기본적으로 조회 시엔 consistent read, 즉 스냅샷을 통해 일관된 읽기를 제공해주지만, DML, 즉 UPDATE, INSERT, DELETE같은 경우 그렇지 않다. 그리고 이 때 현재 트랜잭션에서 DML으로 영향을 준 값은 그냥 조회시에도 반영되어 보여진다.

즉, 위 상황처럼 트랜잭션 시작시 생긴 voteCount가 0이었어도, UPDATE를 통해 증가시킨 다음엔 1이 아닌 2, 3, 4일 수도 있는 것이다. 그리고 수정된 값을 트랜잭션에서 볼 수 있게 된다. 하지만 엔티티의 값이 트랜잭션에 맞춰서 자동으로 업데이트되는 게 아니기 때문에, 엔티티에도 수정된 값을 반영하려면 refresh를 해야한다.

// 이 때 0이었다면
topic.getVoteCount();

topicRepository.increaseVoteCountOfTopic(topic.getId());

// 이 때도 0
topic.getVoteCount();

em.refresh(topic);

// 이 때는 업데이트한 값에 따라 1, 2, 3, 4...
topic.getVoteCount();

이 방법을 사용할 때가 DB 관점에서는 가장 좋은 것 같다. 하지만 원래 코드에선 DB를 신경쓰지않고 객체만 있는 것처럼 Topic 엔티티의 voteCount를 증가시켜도 됐었는데, 이 방법을 사용하면 좀 더 DB 관점이 되는 것 같아 아쉽다. 또 엔티티만을 사용한 유닛테스트도 영향을 받게 된다. 엔티티가 자신의 voteCount를 증가시키는 방법이 아닌 외부에서 증가시키기 때문이다. Service 계층에서 테스트할 수 밖에 없다.

성능과 코드 관점에서의 트레이드 오프가 아닐까..?

혹은 또 다른 방법도 있다. X락 말고 JPA의 낙관적 락을 이용하는 것이다.

JPA의 낙관적 락 이용하기

비관적 락은 위에서 봤던 S, X락을 조회 시에 사용하는 방법과 같다. 이번엔 낙관적 락 방식을 생각해보자.

낙관전 락은 일반적으로 트랜잭션이 서로 영향을 줄 리가 없다고 가정하고, 락 없이 진행한다. 그러다가 커밋 시에 다른 트랜잭션이 데이터를 변경하지 않았음을 확인하고, 만약 변경되었다면 롤백한다.

JPA는 version이나 timestamp 기반 방식의 낙관적 락을 지원한다. 낙관적 락을 사용하기 위해서는 @Version을 속성에 추가하면 된다.

Topic에 추가해보자.

// Topic
@Version
private Integer version;

이제 entity manager는 충돌되는 업데이트를 이를 이용해 감지하게 된다.

기본적으로 엔티티의 어떤 속성이 변하든 version 값이 증가하게 된다. 만약 특정 속성들은 버전에 영향을 안 주도록 하고 싶다면, 해당 속성을 Hibernate의 @OptimisticLock으로 표시해주면 된다.

이 상태에서 테스트 코드를 실행해보자.

Exception in thread "pool-2-thread-5" org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [life.offonoff.ab.domain.topic.Topic#14]

락을 얻진 않지만 업데이트시 다른 트랜잭션에 의해 업데이트 되는 걸 확인하고 exception이 발생하는 걸 볼 수 있다. 낙관적 락을 사용한다면, 메서드를 다시 시도해볼 수도 있을 것이다.

하지만 내 생각엔 서버와 클라이언트 사이에 약속된 exception으로 감싸서 상황을 알리고 클라이언트 코드에서 다시 시도하거나, 사용자에게 토스트로 다시 시도해달라는 메시지를 보여주는게 어떨까 한다.

서버에서 자동으로 다시 시도한다면, 총 몇 번을 다시 시도할지 정하기도 어렵고, 바꾸기도 어려울 것이다. 그리고 사실 이 상황은 유저가 적을 때는 많이 발생하지 않을 상황이므로, 일단 우리 프로젝트에선 유저가 적기 때문에 서버에 약간의 대비를 해놓는 정도가 더 좋은 것 같다.

그래서 Exception 을 하나 정의해주고 아래와 같이 사용해줬다.

// TopicService
Vote vote = new Vote(choiceOption, votedAt);
vote.associate(member, topic);
try {
    topicRepository.flush();
} catch (ObjectOptimisticLockingFailureException e) {
    log.warn(e.getMessage());
    throw new VoteConcurrencyException();
}
voteRepository.save(vote);

flush로 데이터베이스에 값을 반영 시에 값이 달라졌다면 그 때 Exception 이 발생할 것이므로 try-catch로 감싸줬다. 아예 @RestControllerAdvice같은 글로벌 익셉션 핸들러에서 모든 ObjectOptimisticLockingFailureException 을 받아서 커스텀 exception으로 바꿔서 처리해준다면 일반적인 코드단에선 신경쓸 필요가 없으므로 더 편할 수도 있을 것 같다. 하지만 어떤 상황에서 발생했는지 더 잘 파악하기 위해 각 상황 별 ConcurrencyException을 만들 수 있도록 여기서 처리해줬다. 다만 예상치 못한 상황에서 낙관적 락이 발생할 걸 대비해서 글로벌 익셉션 핸들러에 하나 추가해주는 것도 좋겠다.

이제 테스트에선 낙관적 락이 실패하는 경우가 아닐 때는 투표가 잘 되는지 확인해보자.

// given
// ...

// when
final CountDownLatch latch = new CountDownLatch(COUNT);
// 실패 횟수 측정 추가
AtomicInteger failureCounter = new AtomicInteger(0);
voters.forEach(voter -> {
    executorService.execute(() -> {
        try {
            topicService.voteForTopicByMember(
                    topic.getId(), voter.getId(), new VoteRequest(ChoiceOption.CHOICE_A, votedAt));
        } catch (VoteConcurrencyException e) {
	        // 커스텀 익셉션 잡은 만큼 실패 횟수 측정
            failureCounter.incrementAndGet();
        }
        latch.countDown();
    });
});
latch.await();

// then
Topic updatedTopic = em.find(Topic.class, topic.getId());
// 성공해야하는 횟수만큼 투표 됐는지 확인
int successfulVoteCount = COUNT - failureCounter.get();
assertThat(updatedTopic.getVoteCount()).isEqualTo(successfulVoteCount);

테스트가 잘 성공한다. 실행해보니 2번의 투표가 성공했다.

단점

낙관적 락을 사용할 때의 단점은, 다른 원인으로 인해 엔티티가 수정되어도 역시 exception이 발생한다는 점이다. TopicvoteCount, commentCount, 등등이 수정될 수가 있다. 그 전에 본 방법을 이용하면 다른 필드의 업데이트가 영향을 미치지 않지만, @VersionTopic 엔티티 자체가 수정될 때마다 사용되므로 투표할 때 동시에 투표가 발생한게 아니고 댓글이 달려도 낙관적 락 exception이 발생할 수 있다. 현재 프로젝트는 댓글수, 투표수 외에 크게 변화될게 없고 사용자도 적기 때문에 이런 단점이 있어도 괜찮을 것 같다.

또 낙관적 락을 사용할 때 가정이 애초에 '동시에 다른 트랜잭션이 영향을 줄 일이 없다'기 때문에, 이런 상황이 빈번히 일어나는 경우 적절하지 못할 듯 하다.

결론

우선 현재 프로젝트는 낙관적 락을 이용한 방법으로도 괜찮을 것 같다. 아직 race condition이 발생할 정도로 유저가 많은 것도 아니기 때문에 대비하는 정도로 괜찮아보인다. 만약 사용자가 많아져서 자주 발생한다면 그 때 DB에서 바로UPDATE하는 방식을 사용하는 것도 좋겠다!

@melonturtle
공부하면서 남긴 기록입니다. 문제가 있다면 언제든지 알려주세요!