본문 바로가기
Spring

@Transactional 의 Lost Update 문제 해결기 (Redis 분산락)

by 自强不息 개발자 2025. 5. 9.
반응형

제가 참여하고 있는 케플 프로젝트에서 좋아요와 댓글의 개수를 엔티티 자체에 저장하는 케이스가 존재했습니다. 최근에 해당 데이터를 다루는 과정에서 동시성 문제가 생겨 해결해 나간 경험을 공유하고자 합니다.

그래서 무엇이 문제인가?

@Transactional 의 Lost Update 문제

스프링을 이용해 서버를 구현하였다면 익히 @Transactional 어노테이션을 사용하고 있을것입니다.

@Transactional 어노테이션을 사용하는 이유는 상황에 따라 다르겠지만, 가장 유용하게 사용하는 이유는 Dirty Checking 방식의 변경 감지에 따른 update 쿼리 발생일것입니다.

@Transactional 어노테이션이 실행되는 방식을 간단히 들여다 보면, @Transactional 어노테이션 내에서 DB로부터 조회한 엔티티가 메모리(영속성 컨텍스트) 상에 존재하면서 조회 이후 변경이 된 데이터가 존재한다면 update 쿼리를 발생시켜 DB에 적용을 해주게 됩니다.

그렇게 때문에, 트랜잭션 내에서 엔티티를 읽고 비즈니스 로직을 처리하는 동안 다른 트랜잭션이 같은 엔티티를 수정할 수 있기 때문에 Lost Update 문제가 발생할 수 있습니다.

따라서 동시성 제어 없이 단순히 @Transaction 을 사용한다면 데이터 정합성이 깨질 가능성이 존재합니다.

Lost Update란? 여러 트랜잭션이 같은 데이터를 읽고, 각각의 결과로 update를 하면 마지막에 update한 값으로 덮어써져 중간에 반영된 변경이 사라지는 현상

동시성 문제 해결 방법은?

동시성 문제 테스트 코드

동시성 문제 해결 방법을 알아보기 전에 동시성 문제 상황을 테스트코드로 만들어 이를 해결해봤습니다.

아래 테스트 코드와 같이 여러 스레드에서 공유 자원에 대한 수정으로 동시성 문제를 테스트를 할 수 있습니다.

@DisplayName("동시성 테스트 ")
public class ConcurrentTest {
    static int num = 0;

    @Test
    void concurrent() throws InterruptedException {
        int numberOfThreads = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    int tmp = num;
                    Thread.sleep(10);
                    num = tmp + 1;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        assertThat(num).isEqualTo(numberOfThreads);
    }
}

이런 방식으로 동일한 자원을 사용하는 여러 작업자가 동시에 기존의 상태를 기반으로 데이터의 변경을 수행할 때, 아래와 같이 동시성 문제로 정상적인 결과를 얻지 못하는 경우가 발생합니다.

Lock을 사용하는 여러 방법

Lock을 사용하는 여러 방법이 있지만, 확장성을 위해 Redis 를 이용한 분산락을 활용하기로 결정하였습니다.

자바에서 Redis를 이용하여 Lock을 사용하는 방법은 주로 두가지가 있습니다.

Lettuce Library를 사용하는 방법

Lettuce는 spring-boot-starter-data-redis 라이브러리 내에서 사용하고 있는 redis 클라이언트입니다.

Lettuce는 직접적으로 Lock을 제공하지는 않습니다. 아래 테스트 코드와 같이 Lettuce의 Strings 데이터타입을 활용하여 Lock을 사용하는 방식으로 구현할 수 있습니다. 이 방식은 Spin Lock 방식으로 Lock 취득을 시도합니다. 따라서 Redis에 부하가 생길수 밖에 없어 여러 서비스 로직에서 사용하게 된다면 싱글스레드 방식의 레디스의 사용방법으로 바람직한 방식은 아니라고 판단됩니다.

@DisplayName("Lock 테스트 ")
public class LockTest {
    static int num = 0;
    private static String redisHost;
    private static Integer redisPort;
    private static Integer redisDatabase;

    @BeforeAll
    static void beforeAll() {
        YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean();
        yamlFactory.setResources(new ClassPathResource("application-test.yml"));
        Properties props = yamlFactory.getObject();
        redisHost = props.getProperty("spring.data.redis.host");
        redisPort = Integer.valueOf(props.getProperty("spring.data.redis.port"));
        redisDatabase = Integer.valueOf(props.getProperty("spring.data.redis.database"));
    }

    @Test
    @DisplayName("Lettuce 사용방식")
    void concurrentLettuce() throws InterruptedException {
        int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(redisHost, redisPort);
        connectionFactory.setDatabase(redisDatabase);
        connectionFactory.afterPropertiesSet();

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.afterPropertiesSet();

        long waitTime = 1L, leaseTime = 120L;
        AtomicInteger failedCnt = new AtomicInteger(0);

        String key = "test-key";

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    boolean locked;
                    int retryCnt = 0;
                    while ((locked = Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(key, "lock", leaseTime, TimeUnit.SECONDS)))) {
                        Thread.sleep(Duration.ofSeconds(waitTime));
                        if (++retryCnt == 10) {
                            break;
                        }
                    }
                    if (!locked) {
                        int tmp = num;
                        Thread.sleep(10);
                        num = tmp + 1;
                        redisTemplate.delete(key);
                    } else {
                        failedCnt.incrementAndGet();
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        System.out.println("success : " + num);
        System.out.println("failed : " + failedCnt.get());
        System.out.println("total : " + numberOfThreads);

        assertThat(num + failedCnt.get()).isEqualTo(numberOfThreads);
    }
}

Redisson Library를 사용하는 방법

Redisson 라이브러리는 spring-boot-starter-data-redis 라이브러리 내에 존재하지 않으므로 build.gradle 파일에 아래와 같이 redisson 라이브러리를 추가해주어야 합니다.

implementation 'org.redisson:redisson-spring-boot-starter:3.46.0'

Redisson 클라이언트를 이용한 방식은 아래 테스트 코드와 같이 Lettuce와 다르게 getLock 메서드를 이용하여 RLock 인터페이스를 불러와 Lock 취득을 시도합니다. Redisson Lock 은 내부적으로 pub/sub 방식을 이용하여 구현되어 있어 Lettuce를 이용하는 방식보다 Redis에 주는 부하를 완화할 수 있습니다.

아래 테스트 코드와 같이 RLock 인터페이스를 활용하여 lock의 흐름을 제어할 수 있습니다.

@DisplayName("Redisson 의 ")
public class RedissonLockTest {
    static int num = 0;
    private static String redisHost;
    private static Integer redisPort;
    private static Integer redisDatabase;

    @BeforeAll
    static void beforeAll() {
        YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean();
        yamlFactory.setResources(new ClassPathResource("application-test.yml"));
        Properties props = yamlFactory.getObject();
        redisHost = props.getProperty("spring.data.redis.host");
        redisPort = Integer.valueOf(props.getProperty("spring.data.redis.port"));
        redisDatabase = Integer.valueOf(props.getProperty("spring.data.redis.database"));
    }

    @Test
    @DisplayName("RLock 테스트")
    void concurrentRedisson() throws InterruptedException {
        int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        Config config = new Config();
        config.useSingleServer().setAddress(String.format("redis://%s:%s", redisHost, redisPort)).setDatabase(redisDatabase);
        RedissonClient redissonClient = Redisson.create(config);

        long waitTime = 1L, leaseTime = 30L;
        AtomicInteger failedCnt = new AtomicInteger(0);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    RLock rLock = redissonClient.getLock("test-key");
                    if (rLock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) {
                        int tmp = num;
                        Thread.sleep(10);
                        num = tmp + 1;
                        rLock.unlock();
                    } else {
                        failedCnt.incrementAndGet();
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        System.out.println("success : " + num);
        System.out.println("failed : " + failedCnt.get());
        System.out.println("total : " + numberOfThreads);

        assertThat(num + failedCnt.get()).isEqualTo(numberOfThreads);
    }
}

Lettuce vs Redisson

두가지 방식을 사용하여 Lock을 구현해본 결과 Redisson을 이용하는 것이 Redis에 주는 부하를 완화할 수 있다고 판단이 되어 Redisson을 이용하여 Spring에서 비즈니스 코드에 적용하기로 결정하였습니다.

Spring에서 Redisson을 사용하는 방법

Redisson 초기 설정

앞서 사용을 결정한 Redisson을 Spring에서 사용하기 위해서 @Configuration 어노테이션을 통해 RedissonClient를 아래와 같이 Bean으로 등록해줍니다.

@Configuration
public class RedissonConfig {
    @Value("${spring.data.redis.host}")
    private String redisHost;
    @Value("${spring.data.redis.port}")
    private String redisPort;
    @Value("${spring.data.redis.database}")
    private Integer redisDatabase;
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort).setDatabase(redisDatabase);
        return Redisson.create(config);
    }
}

커스텀 어노테이션을 활용한 분산락 AOP 설정

분산락을 사용하는 메서드를 명시적으로 구분하기 위해서 아래와 같이 분산락에 필요한 몇가지 변수를 받아오는 커스텀 어노테이션 DistributedLock을 만들어 줍니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String value();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 3L;

    long retryCount() default 10L;
}

어노테이션을 붙여준 메서드의 프록시 객체를 만들어 Lock을 적용하기 위해, AspectJ DistributedLock 어노테이션이 붙은 메서드를 호출할때 프록시 메서드로 Redisson Lock을 구현해줍니다.

@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK-";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.server.capple.global.utils.distributedLock.DistributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock annotation = method.getAnnotation(DistributedLock.class);

        String key = REDISSON_LOCK_PREFIX + getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());
        RLock rLock = redissonClient.getLock(key);

        int tryCount = 0;
        try {
            boolean isLocked = false;
            while (!isLocked) {
                isLocked = rLock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit());
                if (!isLocked) {
                    Thread.sleep((long) (Math.random() * 100));
                }
                tryCount++;
                if (tryCount >= annotation.retryCount()) {
                    return false;
                }
            }

            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            rLock.unlock();
        }
    }

    private Object getDynamicValue(String[] parameterNames, Object[] args, String value) {
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }
        return (new SpelExpressionParser()).parseExpression(value).getValue(context);
    }
  }

Lock을 획득하지 못한다면 어노테이션으로 받은 Retry 횟수만큼 재시도를 하고 모두 실패한다면 false를 반환합니다.

또한 Annotation 으로 value 문자열을 받고 getDynamicValue() 메서드에서 SpEL 문법을 활용해 lock의 실제 key로 변환합니다.

lock을 획득할 경우 @Transaction 어노테이션으로 새로운 transaction을 열어 변경감지를 통한 데이터 변경을 수행하도록 합니다.

@Component
class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

@DistributedLock 어노테이션을 활용하는 메서드에서는 새로운 Transaction 단위를 사용하는 것인 만큼 기존 메서드의 트랜잭션에서 조회한 엔티티 데이터로 변경 감지에 사용할 수 없습니다. 따라서 아래와 같이 메서드 내에서 새롭게 조회를 할 필요가 있습니다.

@Service
@RequiredArgsConstructor
public class BoardConcurrentService {
    private final BoardRepository boardRepository;

    @DistributedLock("'board::' + #board.id")
    public Boolean setHeartCount(Board board, boolean isLiked) {
        board = boardRepository.findById(board.getId()).get();
        board.setHeartCount(isLiked);
        return true;
    }
  }

이와 같이 사용하게 되면 엔티티 id 단위로 lock 을 획득하여 동시성 문제를 해결할 수 있습니다.

그래도 생기는 동시성 문제!

엔티티에 존재하는 정수 정보를 변경시에 Lock을 사용하도록 변경해줬지만, 여전히 정합성이 맞지 않는 현상이 발생했습니다.

그래서 아래와 같이 Lock을 사용하는 데이터 수정 메서드와 그렇지 않은 데이터 수정 메서드를 함께 사용하는 테스트 코드를 작성 해봤습니다.

    @Test
    @DisplayName("좋아요 개수, 댓글 개수, 컨텐츠 수정 동시성 통합 테스트")
    void boardUpdateConcurrentTest() throws InterruptedException {
        // given
        int initialHeartCount = 1000;
        int initialCommentCount = 1000;
        int updateBoardContent = 300; // lock을 사용하지 않는 메서드 사용
        int likeCount = 100;
        int increaseCommentCount = 100;
        int hateCount = 100;
        int decreaseCommentCount = 100;
        Member writer = Member.builder()
            .nickname("writer")
            .email("email")
            .sub("sub")
            .role(ROLE)
            .build();
        memberRepository.save(writer);
        Board board = Board.builder()
            .writer(writer)
            .boardType(FREEBOARD)
            .content("content")
            .heartCount(initialHeartCount)
            .commentCount(initialCommentCount)
            .isReport(false)
            .build();
        boardRepository.save(board);
        final Board finalBoard = board;

        AtomicInteger increaseHeartFailedCnt = new AtomicInteger(0);
        AtomicInteger decreaseHeartFailedCnt = new AtomicInteger(0);
        AtomicInteger increaseCommentFailedCnt = new AtomicInteger(0);
        AtomicInteger decreaseCommentFailedCnt = new AtomicInteger(0);

        int numberOfThreads = likeCount + hateCount + updateBoardContent + increaseCommentCount + decreaseCommentCount;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        // when
        for (int n = 0; n < 10; n++) {
            for (int i = 0; i < updateBoardContent / 10; i++) {
                int finalI = i;
                executorService.submit(() -> {
                    try {
                        boardService.updateBoard(writer, finalBoard.getId(), Integer.toString(finalI));
                    } finally {
                        latch.countDown();
                    }
                });
            }
            for (int i = 0; i < likeCount / 10; i++) {
                executorService.submit(() -> {
                    try {
                        if (!boardConcurrentService.setHeartCount(finalBoard, true))
                            increaseHeartFailedCnt.incrementAndGet();
                    } finally {
                        latch.countDown();
                    }
                });
            }
            for (int i = 0; i < increaseCommentCount / 10; i++) {
                executorService.submit(() -> {
                    try {
                        if (!boardConcurrentService.increaseCommentCount(finalBoard))
                            increaseCommentFailedCnt.incrementAndGet();
                    } finally {
                        latch.countDown();
                    }
                });
            }
            for (int i = 0; i < hateCount / 10; i++) {
                executorService.submit(() -> {
                    try {
                        if (!boardConcurrentService.setHeartCount(finalBoard, false))
                            decreaseHeartFailedCnt.incrementAndGet();
                    } finally {
                        latch.countDown();
                    }
                });
            }
            for (int i = 0; i < decreaseCommentCount / 10; i++) {
                executorService.submit(() -> {
                    try {
                        if (!boardConcurrentService.decreaseCommentCount(finalBoard))
                            decreaseCommentFailedCnt.incrementAndGet();
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }
        latch.await();

        // then
        board = boardRepository.findById(board.getId()).get();
        assertThat(board.getHeartCount())
            .isEqualTo(initialHeartCount
                       + (likeCount - increaseHeartFailedCnt.get())
                       - (hateCount - decreaseHeartFailedCnt.get())
            );
        assertThat(board.getCommentCount())
            .isEqualTo(initialCommentCount
                       + (increaseCommentCount - increaseCommentFailedCnt.get())
                       - (decreaseCommentCount - decreaseCommentFailedCnt.get())
            );
    }

테스트 코드를 실행해보면, 역시 동시성 문제가 생기는 것을 확인할 수 있습니다.

 

그 이유는 @Transactional 어노테이션을 활용하여 데이터 변경을 수행할때 하나의 엔티티의 모든 칼럼의 데이터에 대해 update를 수행하기 때문이었습니다.

아래와 같이 update 쿼리가 전체 칼럼에 대해서 수정이 수행되고 있음을 확인 할 수 있습니다. 따라서 현재의 구현 상황에 따르면 어떤 칼럼의 데이터를 수행하든 lock을 획득한 후 수정을 해야 마땅합니다.

이는 좋아요, 댓글 생성, 게시글 수정 요청이 서로 상관관계가 존재하여 영향을 미칠 수 있다는 것으로 좋지 않은 구현 상황으로 보입니다.

Hibernate: update board set board_type=?,comment_count=?,content=?,deleted_at=?,heart_count=?,is_report=?,updated_at=?,member_id=? where id=?

@DynamicUpdate 어노테이션을 Entity 클래스 상단에 붙여주어 아래와 같이 update 쿼리가 수정이 발생하는 칼럼에 대해서만 발생할 수 있도록 개선할 수 있었습니다.

Hibernate: update board set heart_count=?,updated_at=? where id=?

동시에 lock의 key 또한 엔티티 기준으로 획득하지 않고 엔티티의 수정 칼럼을 기준으로 획득하도록 수정해주었습니다.

@DistributedLock("'board::' + #board.id")
@DistributedLock("'board::heartCount::' + #board.id")
@DistributedLock("'board::commentCount::' + #board.id")

이렇게 Lock획득 기준과 update 쿼리의 범위를 수정해줌으로서 아래와 같이 테스트코드를 통과할 수 있었습니다.

비즈니스 로직과의 통합

다음으로 기존의 비즈니스 로직의 메서드들과 통합을 진행 했습니다.

테스트는 Locust를 이용하여 진행해줬습니다.

아래와 테스트 결과와 같이 5명의 사용자가 지속적으로 수정 요청을 보낼 경우는 정상적으로 수행이 되는 모습이지만, 초당 10개의 수정 요청으로 초당 요청 개수를 높이자 불안정한 응답 속도와 TPS를 기록하는것을 볼 수 있습니다.

에러 로그를 확인해보면 아래와 같이 HikariPool에서 Connection을 가져오려고 했지만 실패했으며, lock을 수행한 thread가 아닌 다른 스레드가 lock을 해제하려 한다는 로그를 확인 할 수 있습니다.

25-05-06 13:46:14.476  WARN ---  [http-nio-8080-exec-3] org.hibernate.engine.jdbc.spi.SqlExceptionHelper - SQL Error: 0, SQLState: null
25-05-06 13:46:14.479 ERROR  ---  [http-nio-8080-exec-3] org.hibernate.engine.jdbc.spi.SqlExceptionHelper - HikariPool-1 - Connection is not available, request timed out after 30021ms.
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 250d66bb-2ecb-4162-9fab-7693e3d416bf thread-id: 212
	at org.redisson.RedissonBaseLock.lambda$unlockAsync0$2(RedissonBaseLock.java:172)
	at java.base/java.util.concurrent.CompletableFuture.uniHandle(CompletableFuture.java:934)
	at java.base/java.util.concurrent.CompletableFuture$UniHandle.tryFire(CompletableFuture.java:911)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:510)
	at java.base/java.util.concurrent.CompletableFuture.complete(CompletableFuture.java:2179)
	at org.redisson.command.CommandAsyncService.lambda$evalAsync$11(CommandAsyncService.java:606)
	at java.base/java.util.concurrent.CompletableFuture.uniWhenComplete(CompletableFuture.java:863)
	at java.base/java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(CompletableFuture.java:841)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:510)
	at java.base/java.util.concurrent.CompletableFuture.complete(CompletableFuture.java:2179)
	at org.redisson.command.RedisExecutor.handleSuccess(RedisExecutor.java:679)
	at org.redisson.command.RedisExecutor.handleResult(RedisExecutor.java:655)
	at org.redisson.command.RedisExecutor.checkAttemptPromise(RedisExecutor.java:632)
	at org.redisson.command.RedisExecutor.lambda$execute$5(RedisExecutor.java:211)
	...

이는 DistributedLock 의 leaseTime(lock을 가지고 있는 최대 시간)이 hikariPool의 connection pool에서 connection을 가져오는 최대 대기시간보다 lock의 시간이 짧아 lock이 다른 스레드에게 넘어갔지만 lock을 해제하려고 시도하는 현상에 의해 발생한 것입니다.

따라서 아래와 같이 DistributedLock  leaseTime 을 hikaripool의 connection request timed out 시간인 30000ms 보다 큰 40초로 설정 해주어 해결 할 수 있었습니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String value();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 40L;

    long retryCount() default 10L;
}

여전히 존재하는 HikariPool Connection request timed out 에러

lock의 점유 시간을 늘려 준 후 다시 locust로 테스트를 해봤습니다. 3분 정도동안 에러가 발생하지 않고 response time이 안정적으로 반환됨을 확인할 수 있었습니다. 하지만 곧바로 에러와 함께 response time이 치솟는 현상이 발생했습니다.

에러 로그를 확인해보면 lock과 관련된 에러는 다행히도 없었지만, HikariPool의 Connection request timed out 에러가 여전히 발생하는것을 확인할 수 있었습니다.

25-05-08 15:25:43.547 ERROR  ---  [http-nio-8080-exec-5] org.hibernate.engine.jdbc.spi.SqlExceptionHelper - HikariPool-1 - Connection is not available, request timed out after 30010ms.
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
	at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:466)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.startTransaction(AbstractPlatformTransactionManager.java:531)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.handleExistingTransaction(AbstractPlatformTransactionManager.java:452)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:384)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:610)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:765)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:717)
	at com.server.capple.global.utils.distributedLock.AopForTransaction$$SpringCGLIB$$0.proceed(<generated>)
	at com.server.capple.global.utils.distributedLock.DistributedLockAop.lock(DistributedLockAop.java:47)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
	at java.base/java.lang.reflect.Method.invoke(Method.java:578)

이를 이해하기 위해서 HikariPool과 Lock의 교착생태가 발생하게 된 이유를 자세히 알아 볼 필요가 있습니다. 그를 위해서 다음 두가지 사항을 고려하며 상황을 볼 필요가 있습니다.

  1. 기본적으로 HikariPool의 Connection 개수를 설정하지 않았다면 최대 10개의 Connection을 생성할 수 있습니다.
  2. 현재 Transaction을 사용하고 있는 형태는 기존의 서비스 메서드와 다른 새로운 Transaction을 만들어 데이터 수정을 수행합니다. (새로운 데이터베이스 커넥션이 필요함)

이를 토대로 위의 상황이 발생한 원인을 차례대로 알아봅니다.

  1. A 스레드가 서비스 메서드에서 Transaction 어노테이션으로 HikariPool 에서 Connection을 할당 받아 점유합니다.
    (HikariPool의 사용중인 Connection 1개, lock X)
  2. A 스레드가 수정할 자원에 대한 Lock을 획득합니다.
    (HikariPool의 사용중인 Connection 2개, lock O)
  3. A 스레드가 데이터 수정을 위해 Transaction 어노테이션으로 HikariPool 에서 Connection을 새롭게 할당 받아 추가적으로 점유합니다.
    (HikariPool의 사용중인 Connection 2개, lock O)
  4. 다른 8개의 스레드가 서비스 메서드에서 Transaction 어노테이션으로 HikariPool 에서 Connection을 할당 받아 점유합니다.
    (HikariPool의 사용중인 Connection 10개, lock O)
  5. A 스레드가 수정 사항을 데이터베이스로 update 쿼리를 발생시키고 HikariPool로 Connection을 반환합니다.
    (HikariPool의 사용중인 Connection 9개, lock O)
  6. Controller 메서드에서 서비스 메서드로의 진입을 대기중이었던 스레드중 하나의 스레드가 HikariPool에서 Connection을 할당받아 점유합니다.
    (HikariPool의 사용중인 Connection 10개, lock O)
  7. A 스레드가 lock을 해제합니다.
    (HikariPool의 사용중인 Connection 10개, lock X)
  8. lock을 획득하기를 기다리고 있던 10개의 스레드중 한개의 스레드인 B 스레드에서 lock을 획득합니다.
    (HikariPool의 사용중인 Connection 10개, lock O)
  9. B 스레드가 HikariPool에서 Connection을 획득하기 위해서 대기합니다.
    (DEAD_LOCK)

이러한 순서로 풀 고갈로 인한 교착 상태가 발생하게 되어 서비스 전체가 멈취버리는 현상이 생기게 됩니다.

이를 Connection Pool을 늘려주는 방식으로 해결 할 수 있었습니다.

Connection Pool을 다음과 같은 공식으로 설정함으로 교착상태를 방지할 수 있습니다.

(Connection Pool) = (원하는 동시에 수정 가능한 Connection Pool 개수) * (Connection을 새롭게 요구하는 중첩 메서드 개수 + 1)

저는 커넥션 풀을 여유롭게 사용하기 위해서 100개로 늘려주었습니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 100

그 결과로 아래와 같이 사용자가 늘더라도 안정적인 서비스를 제공하도록 수정할 수 있었습니다.

오류를 모두 처리한 후 테스트 결과

TPS 30까지 받는 모습

한계치 테스트

더보기

실제 운영서버가 아닌 제가 가진 노트북(M1 Pro)에서 하드웨어가 아닌 소프트웨어 상의 문제점이 존재하는지 체크하기 위해서 추가적인 테스트를 해봤습니다.

TPS 50

TPS 50 까지는 잠깐의 Response time의 spike가 존재했지만, 오류도 발생하지 않고 안정적인 처리가 되고 있음을 확인할 수 있습니다.

 

TPS 80

TPS 80부터는 Response time이 조금 불안정하며 늘어나지만, 오류도 발생하지 않고 안정적인 처리가 되고 있음을 확인할 수 있습니다.

 

TPS 100

TPS 100이 가까워 지며 오류가 지속적으로 발생하며 Response time이 치솟는것을 확인할 수 있습니다.

로그가 너무 많이 발생하여 에러가 발생한 최초의 로그를 찾을 수 없었지만, 아래와 같은 로그가 반복적으로 발생하는것으로 보아 Connection을 획득하지 못해 발생하는 에러로 판단됩니다.

org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
	at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:466)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.startTransaction(AbstractPlatformTransactionManager.java:531)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:405)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:610)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379)
	... more
Caused by: org.hibernate.exception.GenericJDBCException: Unable to acquire JDBC Connection [HikariPool-1 - Interrupted during connection acquisition] [n/a]
	at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:63)
	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:108)
	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:94)
	at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:116)
	at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.getPhysicalConnection(LogicalConnectionManagedImpl.java:143)
	at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.getConnectionForTransactionManagement(LogicalConnectionManagedImpl.java:273)
	at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.begin(LogicalConnectionManagedImpl.java:281)
	at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.begin(JdbcResourceLocalTransactionCoordinatorImpl.java:232)
	at org.hibernate.engine.transaction.internal.TransactionImpl.begin(TransactionImpl.java:83)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.beginTransaction(HibernateJpaDialect.java:176)
	at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:420)
	... 143 more
Caused by: java.sql.SQLException: HikariPool-1 - Interrupted during connection acquisition
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:185)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:146)
	at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
	at org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl.getConnection(DatasourceConnectionProviderImpl.java:122)
	at org.hibernate.internal.NonContextualJdbcConnectionAccess.obtainConnection(NonContextualJdbcConnectionAccess.java:46)
	at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:113)
	... 150 more
Caused by: java.lang.InterruptedException
	at java.base/java.util.concurrent.SynchronousQueue.poll(SynchronousQueue.java:906)
	at com.zaxxer.hikari.util.ConcurrentBag.borrow(ConcurrentBag.java:151)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:164)
	... 155 more
java.lang.RuntimeException: java.lang.InterruptedException
	at java.base/java.util.concurrent.SynchronousQueue.poll(SynchronousQueue.java:906)
	at com.zaxxer.hikari.util.ConcurrentBag.borrow(ConcurrentBag.java:151)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:164)
	... 155 more

우리 서비스에서는 실제 운영 환경에서 겪을 일이 거의 없는 트래픽이지만, 여유있는 설정과 모니터링 툴을 활용한 지속적인 관리를 통해 혹시 모를 상황에 대한 대비가 필요할것입니다.

동시성 문제 테스트 코드 적용 시 유의 사항

테스트 코드는 빈 구성에 따라 여러 개의 ApplicationContext 가 사용될 수 있습니다.

또한 Hikari Connection Pool은 별도의 설정을 하지 않았다면, **minimum-idle**의 기본값은 **maximum-pool-size**와 동일하게 설정되어, 풀 전체를 유휴 커넥션으로 유지하려고 시도합니다.

따라서, Hikari Connection Pool의 maximum-pool-size를 키워 놓은경우 ApplicationContext 별로 새로 만들어질 때마다 별도의 커넥션 풀이 생성됩니다.

데이터베이스의 설정에 따라 문제가 생기지 않을수도 있지만, 데이터베이스에 설정된 max_connections 에 따라 데이터베이스에 connection 요청 시 FATAL: sorry, too many clients already 와 같은 에러를 반환할 가능성도 존재합니다.

이러한 Connection이 부족한 문제가 생기지 않도록 예방하기 위해서는 아래와 같은 세가지 조치를 취할 수 있습니다.

  1. 테스트 환경의 minimum-idle을 명시적으로 설정하여 유휴 상태로 유지할 커넥션의 최소 개수를 줄이도록 설정해줍니다.
  2. spring: datasource: hikari: maximum-pool-size: 100 minimum-idle: 10
  3. maximum-pool-size를 데이터베이스의 max_connections 보다 많지 않도록 설정 해줍니다.
    • 아래의 SQL문을 사용하여 max_connections 을 확인할 수 있습니다. (PostgreSQL 기준)
    • SELECT * FROM pg_settings where name='max_connections';
  4. 공통 추상 부모 클래스를 활용하여 컨텍스트 재사용을 합니다.
    • 컨텍스트를 재사용하여 데이터베이스가 새로운 컨텍스트와 Connection pool을 형성하지 않도록 예방합니다.

결론 및 회고

이번 기회를 통해 Redis 클라이언트 라이브러리인 Redisson 을 활용해 분산 Lock을 구현해봤습니다.
기능의 구현은 어렵지는 않았지만, Transaction의 중첩적인 할당으로 인해 교착상태가 발생하여 HikariPool에 대해 한층 더 잘 이해할 수 있었습니다.
HikariPool의 최대 pool 크기를 10에서 100으로 늘려 놓은만큼 실제 서버에서 운용되며 문제가 없는지 지속적으로 팔로우 하며 저희 서비스에 적절한 값을 찾아야할 것입니다.
이론으로만 접했던 분산락을 직접 구현해보니, 소프트웨어 개발에 대한 열정과 성취감을 더 크게 느낄 수 있었습니다.
이전까지는 프로젝트 도중에 겪은 문제들을 이슈와 PR, github의 Discussion 정도로만 정리 했지만, 블로그로 제대로 다시 정리 해야겠다는 생각을 하게 됐습니다.
더욱 성장한 포스팅으로 돌아오겠습니다. 지금까지 읽어주셔서 감사합니다.

 

함께 볼만한 자료

https://easy-code-yo.tistory.com/38

http://redisgate.kr/redis/command/strings.php

https://baeji-develop.tistory.com/131

https://helloworld.kurly.com/blog/distributed-redisson-lock/

https://backtony.tistory.com/58

 

 

 

 

반응형