DB 병목 현상을 해결해 보자

지송·2025년 7월 23일

MY BASEBALL ✪ ALL STAR

목록 보기
4/4

안녕하세요! 오늘은 DB LOCK으로 인해서 서버가 먹통이 되어버린 경험을 풀어보려고 합니다

저희의 서비스는 위와 같이 유저들이 원하는 선수들을 선택하고 팀을 구성하면,
각각 선수의 점수를 바탕으로 팀 점수를 계산하고 있습니다
(해당 방식은 이전의 게시물에 상세히 서술해 두었으니 참고해 주세요!)

이를 위해 저희는 주기적으로 선수 기록지를 크롤링하여 개인의 점수를 업데이트 하는 과정을 거치고 있는데요
크롤링을 하고, 이를 저장하는 과정에서 아래와 같은 문제가 발생하게 됩니다

해당 로그를 자세히 보면 락을 기다리는 시간이 초과하여 에러가 발생한 것을 확인할 수가 있어요

이를 해결하기 위해 검색해 보니 보통 초과 시간을 늘리는 방안을 많이 사용하더라고요
하지만 저는 이것이 근본적인 원인이 아닐 것 같아 이를 분석해 보고자 하였습니다

문제를 파악해 보자

먼저 가장 초기의 코드를 보여드립니다
이때에 구현 일정이 엄청나게 타이트했으므로
해당 부분을 구현한 친구가 리팩토링을 추후 업무로 넘기고 작동할 수만 있는 코드를 작성하였어요
아래 글까지 읽어보면 크롤링과 트랜잭션을 분리하니 지금은 넘겨 주세요

이렇게 스케줄에 따라 크롤링을 실행하게 되고

아래와 같이 크롤링 매서드 내부에서 해당 포지션에 해당하는 선수를 읽은 다음
그 선수들의 점수를 업데이트 하는 로직을 거치게 됩니다

그리고 이 과정은 짧으면 30분 길면 2시간까지도 소요되는 작업이에요 해당 과정에서 위와 같은 문제가 발생하였습니다

테스트를 진행해 보자

대부분의 Spring Data JPA 환경에서는, 엔티티를 수정하면 해당 변경 사항이 곧바로 데이터베이스에 반영된다고 오해하기 쉽습니다 하지만 실제로는 그렇지 않습니다 JPA는 변경 감지(dirty checking) 기반으로 동작하기 때문에, 필드 값을 수정한 순간에는 아직 데이터베이스에 아무런 쿼리도 전송되지 않습니다 이처럼 눈에 보이지 않게 동작하는 내부 메커니즘을 정확히 이해하는 것이 매우 중요합니다

다시 돌아가, JPA 트랜잭션 안에서 어떤 플레이어 엔티티를 조회한 뒤 player.updateScore(score)처럼 필드 값을 변경한 후의 상황에서 보겠습니다 이때 우리가 save(player) 같은 메서드를 명시적으로 호출하지 않더라도, Spring Data JPA는 트랜잭션 커밋 시점에 자동으로 해당 변경 사항을 감지하고 UPDATE 쿼리를 생성하여 데이터베이스에 반영합니다

이것이 가능한 이유는, JPA가 트랜잭션이 시작될 때 영속성 컨텍스트(Persistence Context)에 해당 엔티티의 스냅샷을 보관해두고, 커밋 직전에 현재 상태와 비교하는 변경 감지(Dirty Checking) 를 수행하기 때문입니다 이 비교를 통해 어떤 필드가 바뀌었는지 판단하고, 그에 따라 flush() 단계에서 필요한 SQL 쿼리를 생성합니다 다시 말해, 개발자가 명시적으로 save()를 호출하지 않더라도 트랜잭션 내에서의 변경은 자동으로 반영됩니다

따라서 반드시 커밋 시점에 해당 사항이 반영되게 되고 120개의 쿼리가 동시에 날아간다고 해도,
해당 상황에서는 동시에 같은 데이터를 수정할 일이 없으며 DB 단에서 충분히 커버 가능한 양의 쿼리이기 때문에 락 문제가 발생할 가능성이 없습니다

따라서 왜 이러한 문제가 발생하는지 이유를 도무지 찾지 못했습니다

혹시나 제가 인지하고 있는 사항이 다를까 봐 아래와 같은 테스트를 진행하였습니다
필드의 값을 바꾼 후에 트랜잭션을 유지하게 되고 그 사이에 read와 같은 호출이 오는 시나리오였습니다

제 예상과 같이 update 이후 대기가 있음에도 불구하고 락은 걸리지 않았기 때문에 read 작업은 잘 수행되는 것을 볼 수 있습니다

그럼에도 트랜잭션은 짧게 가져가야 한다

사실 명확한 이유를 파악하지 못했지만 트랜잭션 내에서 크롤링의 업무까지 하는 것은 상당히 합리적이지 않습니다 왜냐하면 트랜잭션은 가능한 한 짧은 시간 내에 처리되어야 안정성과 성능을 보장할 수 있는데, 크롤링은 외부 네트워크나 페이지 로딩 시간에 따라 수 초에서 수 분까지 지연이 발생할 수 있는 작업이기 때문입니다 이러한 느린 작업이 트랜잭션 안에서 수행되면 트랜잭션 유지 시간이 과도하게 길어지고, 그에 따라 데이터베이스의 락 유지, 리소스 점유, 영속성 컨텍스트 메모리 증가 등의 문제가 발생하여 전체 시스템에 부하를 줄 수 있습니다

크롤링이라는 일은 꼭 트랜잭션이 필요한 일이 아니기 때문에 분리해야 적절합니다

playerRepository.saveAll(pitchers);

따라서 트랜잭션 범위를 분리하고, 점수 업데이트 이후에는 위와 같은 함수를 추가하여
변경된 데이터를 수동으로 저장하도록 수정했습니다
이때 saveAll을 사용하여 bulk insert 방식으로 처리함으로써, 성능적인 이점도 함께 얻을 수 있었습니다

다시 돌아와서 문제 파악

저는 해당 문제가 생긴 원인을 알기 위해 다양한 가능성을 재고해 보았으나 도무지 해당되지 않는 부분만 존재하였습니다

  • 같은 데이터에 대한 수정으로 인해 경합이 존재한 경우
    위의 케이스에 대해서는 해당이 없습니다
    저희는 크롤링을 제외하고는 player에 대한 정보를 수정할 수 있는 API가 없고,
    위 함수의 경우 모두 각각의 player의 점수를 업데이트 하기 때문에 같은 데이터를 수정했을 가능성이 없습니다

  • 선수 선택 횟수 업데이트 때문에?
    해당 크롤링을 진행할 당시에 저희는 릴리즈를 앞두고 있어서 다양하게 이것저것 테스트 중이었습니다
    따라서 선수 테이블과 연관된 낙관적 락이 걸려 있는 선수 선택 횟수 테이블도 활발하게 수정되고 있는 상황이었습니다
    혹시 외래키로 인해 해당 과정에서 락 경합이 발생하였을까를 고려하여 테스트를 진행하였습니다

위 이미지와 같이 수정 중에도 문제가 없고 모든 수정이 끝난 후 쿼리가 한번에 플러시 되었습니다

사실 이 모든 게 H2 환경에서만 용인되는 것이었을까?

문득 이런 생각이 들었습니다
위 테스트는 모두 h2 환경에서 실행하였고 h2는 테스트용으로 지원되는 경량화된 데이터베이스입니다
하지만 mySQL은 복잡한 매커니즘을 가지고 있고 이것 때문에 위 테스트를 수행할 때 문제가 되지 않았을 수도 있습니다

따라서 테스트 환경을 mySQL로 바꿔 보겠습니다

도커로 mySQL을 실행하고 다시 테스트를 돌려보겠습니다
노트북이 상당히 느려졌어요

@Test
    void testFkLockBetweenPlayerAndChoiceCount_Massive() throws InterruptedException {
        List<Long> playerIds = LongStream.rangeClosed(1, 120)
                .boxed()
                .collect(Collectors.toList());

        int threadCount = 1 + playerIds.size(); // 트랜잭션 A 1개 + 트랜잭션 B 120개
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);

        // 트랜잭션 A: 선수 점수 업데이트 + 10초 락 유지
        executor.submit(() -> {
            TransactionTemplate tx = new TransactionTemplate(transactionManager);
            tx.executeWithoutResult(status -> {
                List<Player> players = playerRepository.findAllById(playerIds);
                players.forEach(p -> p.updateScore(p.getScore() + 1));
                playerRepository.saveAll(players);

                System.out.println("[A] 120명 점수 업데이트 후 10초 대기 시작");
                try {
                    Thread.sleep(10000); // 락 유지
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("[A] 대기 종료, 트랜잭션 종료");
            });
            latch.countDown();
        });

        // 트랜잭션 B들: player_choice_count 업데이트 시도
        for (Long playerId : playerIds) {
            executor.submit(() -> {
                try {
                    Thread.sleep(1000); // 트랜잭션 A가 먼저 락을 잡도록 지연
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                TransactionTemplate tx = new TransactionTemplate(transactionManager);
                try {
                    tx.executeWithoutResult(status -> {
                        PlayerChoiceCount pcc = playerChoiceCountRepository.findByPlayerId(playerId)
                                .orElseThrow();
                        pcc.increase();
                        System.out.println("[B] playerId: " + playerId + " → count 증가 시도");
                    });
                    System.out.println("[B] playerId: " + playerId + " 트랜잭션 성공");
                } catch (Exception e) {
                    System.out.println("[B] playerId: " + playerId + " 트랜잭션 실패: " + e.getMessage());
                }
                latch.countDown();
            });
        }

        latch.await();
        executor.shutdown();
    }

위와 같은 테스트를 진행해 보았는데 역시나 오류는 없었습니다

결론

사실 문제의 정확한 원인을 단정짓기는 어려웠습니다 테스트 코드 상으로는 락이 걸리지 않았고, 데이터 경합도 없었으며, 외래키로 인한 영향도 재현되지 않았기 때문입니다

하지만 이 경험을 통해 한 가지 중요한 사실을 깨달을 수 있었습니다 바로 서버 환경, 특히 실제 운영 DB의 락 메커니즘은 테스트 환경과는 다르게 동작할 수 있다는 점입니다
즉, 같은 코드라도 운영 환경의 DB 설정이나 트래픽, 연결 수 등에 따라 재현되지 않는 락 문제가 발생할 수 있다는 것이죠

그렇기 때문에 우리는 항상 트랜잭션 범위를 가능한 짧게 유지하고, 외부 I/O 작업(예: 크롤링)은 DB 작업과 분리해야 하며, 실제 운영 환경과 유사한 조건에서 테스트를 진행하는 습관이 필요합니다

서비스가 성장하고 사용자 수가 늘어날수록 이런 작은 락 하나가 전체 서버를 마비시킬 수 있다는 것을 잊지 말아야 합니다

트랜잭션을 짧게 가져가는 것으로도 해당 문제가 재현되지 않았기 때문에 앞으로도 이런 방어적인 코드를 짜는 것에 집중해 보고자 합니다!

하지만 혹시라도 가능성이 있는 시나리오를 아시는 분은 댓글 주세요

profile
💻 늘 공부하고 발전하는 개발자

2개의 댓글

comment-user-thumbnail
2025년 10월 26일

제 생각엔

1개의 답글