DEVLOG

동시 요청 100건 중 12건만 반영됐다

JPA 변경 감지로 구현한 조회수에서 Lost Update를 확인하고, 여러 대안을 비교해 DB 원자적 UPDATE로 개선한 과정을 기록했습니다.

TRIPFEED 피드 게시글 상세 화면

앞선 글에서 TRIPFEED의 유효 조회 기준을 정했습니다. 다음 과제는 그 조회를 실제 숫자에 빠짐없이 반영하는 일이었습니다. 처음에는 게시글 엔티티의 조회수를 증가시키고 JPA 변경 감지로 저장했지만, 동시 요청을 보내자 예상과 전혀 다른 결과가 나왔습니다.

처음 구현한 방식

초기 구현은 게시글을 조회한 뒤 엔티티의 값을 하나 올리는 단순한 형태였습니다.

public void increaseViewCount() {
    this.viewCount++;
}

서비스 메서드에 트랜잭션을 적용했기 때문에 커밋 시점에 JPA가 변경을 감지하고 UPDATE 쿼리를 실행합니다. 단일 요청에서는 조회수가 정상적으로 증가했고 코드도 자연스러워 보였습니다.

문제는 여러 요청이 동시에 같은 게시글을 조회할 때 나타났습니다.

100개의 요청을 동시에 보내보기

동일한 게시글의 조회수를 0으로 만든 뒤 xargs -P 100을 사용해 상세 조회 요청 100개를 동시에 보냈습니다. 모든 요청은 정상 응답했지만 최종 조회수는 100이 아니었습니다.

xargs로 게시글 상세 조회 요청 100개를 동시에 실행한 터미널
동시 실행 수를 100으로 지정해 게시글 상세 API를 호출했습니다.

결과는 12였습니다. 100번의 증가 중 88번이 사라진 것입니다.

변경 감지12 / 100 반영88 유실
원자적 UPDATE100 / 100 반영0 유실

왜 증가분이 사라졌을까

변경 감지 방식에서는 값을 읽고, 애플리케이션에서 증가시키고, 다시 저장하는 과정이 나뉩니다. 여러 트랜잭션이 같은 초기 값을 읽으면 각자 다른 엔티티를 가지고 있더라도 동일한 결과를 저장할 수 있습니다.

요청 A: SELECT view_count = 0
요청 B: SELECT view_count = 0

요청 A: 0 + 1 → UPDATE view_count = 1
요청 B: 0 + 1 → UPDATE view_count = 1

두 요청이 모두 증가 연산을 수행했지만 데이터베이스에 남는 값은 2가 아니라 1입니다. 실제 로그에도 서로 다른 요청이 같은 값을 읽어 0 → 1, 1 → 2로 반복해서 변경하는 모습이 남았습니다. 이것이 Read-Modify-Write 과정에서 발생한 Lost Update였습니다.

여러 요청이 같은 조회수 값을 읽어 반복해서 갱신한 애플리케이션 로그
서로 다른 요청들이 같은 초기 값을 읽고 동일한 결과로 갱신하고 있었습니다.

해결 방법들의 비교

synchronized

한 JVM 안에서는 메서드 진입을 직렬화할 수 있지만 서버가 여러 대라면 다른 JVM의 요청을 막지 못합니다. 조회 요청 전체가 줄을 서게 되는 것도 조회수 기능에 비해 큰 제약이라고 판단했습니다.

비관적 락과 낙관적 락

비관적 락은 읽는 시점부터 다른 트랜잭션을 기다리게 하므로 충돌이 잦은 카운터에 적용하기에는 대기 비용이 큽니다. 낙관적 락은 충돌을 감지할 수 있지만, 조회수가 동시에 많이 증가하는 상황에서는 실패와 재시도 로직이 반복될 수 있습니다.

Redis에서 증가한 뒤 DB에 반영하기

Redis의 원자적 증가 연산을 사용하면 매 조회마다 MySQL에 쓰는 부담을 줄일 수 있습니다. 하지만 Redis의 값을 DB에 반영하는 주기와 실패 복구, 중복 반영 방지까지 새롭게 설계해야 합니다. 현재 규모에서는 실제로 확인되지 않은 병목을 위해 운영 복잡도를 먼저 늘릴 필요는 없다고 판단했습니다.

예를 들어 DB 반영 전에 Redis가 중단되면 아직 옮기지 못한 증가분을 잃을 수 있고, DB 반영에는 성공했지만 Redis의 증가분을 정리하지 못하면 다음 동기화에서 같은 값이 다시 반영될 수 있습니다.

DB 원자적 UPDATE

조회수는 현재 값에 1을 더하는 단일 연산입니다. 값을 애플리케이션으로 가져와 계산할 이유가 없기 때문에 데이터베이스에서 다음 연산을 한 번에 수행하는 방식이 문제에 가장 직접적으로 맞았습니다.

UPDATE posts
SET view_count = view_count + 1
WHERE id = :postId;

QueryDSL로 원자적 증가 구현하기

원자적 증가는 JPQL의 @Modifying 쿼리와 QueryDSL 모두로 구현할 수 있습니다. 두 방식의 정합성에 차이가 있어서가 아니라, 프로젝트에서 이미 목록 조회와 커서 페이지네이션에 QueryDSL 커스텀 Repository를 사용하고 있었기 때문에 같은 계층에 증가 메서드를 추가했습니다.

@Override
public long increaseViewCount(Long postId) {
    return queryFactory
        .update(post)
        .set(post.viewCount, post.viewCount.add(1))
        .where(post.id.eq(postId))
        .execute();
}

이 쿼리는 데이터베이스가 현재 값을 기준으로 직접 증가시킵니다. 같은 행에 대한 UPDATE는 순서대로 처리되므로, 앞선 요청이 반영한 값을 다음 요청이 이어서 증가시킬 수 있습니다.

벌크 UPDATE와 영속성 컨텍스트

QueryDSL의 벌크 UPDATE는 영속성 컨텍스트를 거치지 않고 데이터베이스에 직접 실행됩니다. 같은 트랜잭션에서 이미 조회한 게시글 엔티티가 있다면 DB 값은 증가했지만 메모리의 엔티티에는 이전 값이 남을 수 있습니다.

JPQL의 @ModifyingflushAutomatically, clearAutomatically 옵션으로 이 처리를 선언할 수 있지만 QueryDSL에서는 EntityManager를 직접 사용해야 합니다. 현재 흐름에는 UPDATE 전에 저장해야 할 엔티티 변경사항이 없어서 flush()는 생략하고, 오래된 엔티티를 제거하는 clear()만 수행했습니다. 추후 UPDATE 앞에 다른 엔티티 변경 로직이 추가된다면 clear 전에 flush가 필요한지도 다시 확인해야 합니다.

최종 상세 조회 흐름에서는 먼저 게시글과 작성자를 확인한 뒤 유효한 조회일 때만 원자적 UPDATE를 실행합니다. 따라서 UPDATE 이후 영속성 컨텍스트를 비우고, 최신 조회수가 반영된 게시글을 다시 조회하도록 구성했습니다.

if (countableView) {
    postRepository.increaseViewCount(postId);
}

entityManager.clear();
Post latestPost = postRepository.findById(postId)
    .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));

다시 100개의 요청을 보낸 결과

같은 조건으로 요청 100개를 동시에 보낸 결과, 개선 후에는 조회수가 0에서 100까지 모두 반영됐습니다. 로그에서도 각 요청의 증가 결과가 이전 값을 덮어쓰지 않고 100까지 이어지는 것을 확인했습니다.

변경 감지 방식에서 조회수가 12로 남은 데이터베이스 결과
Before100건 중 12건 반영
원자적 업데이트 방식에서 조회수가 100으로 증가한 데이터베이스 결과
After100건 모두 반영
조회수가 83부터 100까지 순서대로 증가한 애플리케이션 로그
원자적 UPDATE 적용 후 증가분이 유실되지 않고 100까지 반영됐습니다.

한 차례의 테스트에서 전체 요청 완료 시간은 변경 감지 방식이 0.292초, 원자적 UPDATE 방식이 0.481초였습니다. 한 번의 측정만으로 성능 차이를 일반화할 수는 없지만, 이번 개선의 우선 목표였던 데이터 정합성은 12%에서 100%로 회복됐습니다.

남은 선택 기준

원자적 UPDATE도 모든 상황의 정답은 아닙니다. 조회마다 MySQL 쓰기가 발생하므로 트래픽이 커져 실제 병목으로 확인된다면 Redis에서 증가분을 관리하고 안전하게 DB와 동기화하는 구조를 다시 검토할 수 있습니다.

이번 문제에서 중요했던 것은 특정 기술을 먼저 선택하는 것이 아니라, 연산이 단순한 증가인지, 충돌 빈도는 어떤지, 현재 운영 규모에서 감당할 복잡도는 어디까지인지 확인하는 일이었습니다. 그리고 JPA 변경 감지 자체가 문제가 아니라, 동시성을 고려하지 않은 Read-Modify-Write가 문제였다는 점도 분명히 구분할 수 있었습니다.

하지만 조회수 증가만 원자적으로 바꿔서는 정한 정책을 구현할 수 없었습니다. 같은 사용자의 짧은 시간 내 재조회를 걸러내는 과정에서도 별도의 경쟁 조건이 생겼습니다. 이 문제는 다음 글에서 이어집니다.