본문 바로가기
개발/개발

[Java / Spring Boot] 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!" - 5편

by 글쓰는 개발자 2026. 3. 6.

좋아요 카운트 로직을 짜다가 문제를 발견했다.

"약간의 오차는 괜찮다"고 판단하고 비동기로 처리했는데, 코드를 들여다보니 수십 개씩 틀어질 수 있는 구조였다.

실제로 운영 중에 이런 상황이 생기면 어떻게 될까? 가상의 브랜드 담당자에게 "좋아요 수가 갑자기 수십 개씩 줄었다가 돌아와요"라는 연락을 받는다고 상상해보자. 처음엔 식은땀이 날 것 같다. "약간의 오차는 괜찮다"고 했는데, "약간의 오차"와 "수십 개씩 튀는 것"은 달랐다.

원인을 찾아보자.


정규화 vs 비정규화

좋아요 수를 보여주는 가장 자연스러운 방법은 이거다.

SELECT COUNT(*) FROM likes WHERE product_id = 1

이게 정규화 방식이다. 데이터를 중복 없이 한 곳에만 저장하고, 필요할 때 계산한다.

정규화:
장점 → 데이터 정합성 보장 (likes 테이블 하나만 관리)
단점 → 조회할 때마다 COUNT(*) 비용

인기 상품에 좋아요가 100만 개 쌓이면, 상품 상세 페이지를 조회할 때마다 100만 행을 스캔해야 한다. 동시에 1000명이 조회하면 100만 행 스캔이 1000번 동시에 일어난다. 인덱스가 있어도 COUNT는 조건에 맞는 행을 전부 읽어야 하기 때문에 DB 부하가 커진다.

그래서 like_count를 따로 저장한다. 이게 비정규화다. 성능을 위해 중복을 허용하고 여러 곳에 저장하는 것이다.

비정규화:
장점 → 조회 빠름 (like_count 바로 읽기)
단점 → 두 곳을 동기화해야 함 → 정합성 관리가 어려움

이게 트레이드오프다. 하나를 얻으면 하나를 잃는다. 비정규화는 성능을 얻는 대신 정합성 관리 부담을 진다. 브랜드 담당자의 전화는 바로 이 트레이드오프의 대가였다.

likes 테이블:        실제 좋아요 데이터 저장 (정확한 원본)
products.like_count: 좋아요 수를 미리 계산해서 저장 (빠른 조회용)

좋아요 등록 시 like_count를 어떻게 업데이트하냐면, 가장 직관적인 방법은 이거다.

@Transactional
public void like(Long userId, Long productId) {
    likeRepository.save(new Like(userId, productId));

    Product product = productRepository.findById(productId);
    product.setLikeCount(product.getLikeCount() + 1); // 읽고 → +1 → 저장
}

근데 이게 바로 문제의 시작이다.


Eventual Consistency(최종적 일관성)를 선택한 이유

좋아요는 재고랑 다르다.

재고: 틀리면 돈 문제 → 절대 틀리면 안 됨 → Strong Consistency
좋아요: 약간 틀려도 됨 → 결국엔 맞아지면 됨 → Eventual Consistency(최종적 일관성)

그래서 비동기로 처리했다.

좋아요 등록 → likes INSERT (동기, 즉시)
         → LikeCreatedEvent 발행
         → 컨슈머가 like_count++ (비동기, 나중에)

이벤트 발행은 Redis나 Kafka 없이 Spring 내부 이벤트로 구현할 수 있다. 단일 서버에서는 이걸로 충분하다.

// 이벤트 발행
@Service
public class LikeService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void like(Long userId, Long productId) {
        likeRepository.save(new Like(userId, productId));
        eventPublisher.publishEvent(new LikeCreatedEvent(productId)); // Spring 내부 이벤트
    }
}

// 이벤트 수신
@Component
public class LikeCountConsumer {

    @Async                                                    // 새 스레드에서 실행
    @Transactional                                            // 새 트랜잭션 시작
    @TransactionalEventListener(phase = AFTER_COMMIT)         // 원본 트랜잭션 커밋 후 실행
    public void handle(LikeCreatedEvent event) {
        productRepository.incrementLikeCount(event.getProductId());
    }
}

@TransactionalEventListener는 likes INSERT 트랜잭션이 커밋된 후에 이벤트를 처리한다. 트랜잭션이 롤백되면 이벤트도 처리되지 않는다.

일반 @EventListener와 비교하면 차이가 명확하다.

// @EventListener: 이벤트 발행 즉시 실행
@EventListener
public void handle(LikeCreatedEvent event) {
    productRepository.incrementLikeCount(event.getProductId());
}
// likes INSERT 실패 → 롤백
// 근데 like_count는 이미 증가한 상태 💥
// likes는 없는데 like_count만 올라감

// @TransactionalEventListener: 커밋 후 실행
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handle(LikeCreatedEvent event) {
    productRepository.incrementLikeCount(event.getProductId());
}
// likes INSERT 실패 → 롤백 → 이벤트 실행 자체가 취소 ✅
// likes가 실제로 저장된 게 확정된 다음에 like_count 증가
@EventListener:              트랜잭션 상관없이 즉시 실행
@TransactionalEventListener: 트랜잭션 커밋 후 실행, 롤백되면 이벤트도 취소

서버가 여러 대로 늘어나면 서버 간 이벤트 전달이 필요해서 Kafka 같은 메시지 브로커를 고려하게 된다. 지금은 단일 서버니까 Spring 내부 이벤트로 해보자.

비동기의 장점은 두 가지다.

성능: 좋아요 등록 요청이 likes INSERT만 하고 바로 응답
      → 사용자가 기다리는 시간 짧아짐

유연성: 나중에 배치로 맞출 수 있음
        → 약간의 오차는 허용, 결국엔 맞아짐

근데 "결국엔 맞아진다"가 보장되지 않으면 Eventual Consistency(최종적 일관성)가 아니라 그냥 "틀린 데이터"다.

실제로 어떤 상황에서 틀어지는지 원인을 하나씩 살펴보자.


원인 1: Lost Update

컨슈머가 여러 인스턴스로 뜨면 이런 일이 생긴다.

@Transactional
public void handle(LikeCreatedEvent event) {
    Product product = productRepository.findById(event.getProductId());
    product.setLikeCount(product.getLikeCount() + 1); // 읽고 → +1 → 저장
}
Consumer A: like_count = 100 읽음
Consumer B: like_count = 100 읽음
Consumer A: like_count = 101 저장
Consumer B: like_count = 101 저장 ← A 결과 덮어씀

결과: 2번 증가해야 하는데 101

2편에서 본 Lost Update와 똑같은 패턴이다.

해결: DB 원자적 연산

@Modifying
@Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id")
void incrementLikeCount(@Param("id") Long id);

@Modifying
@Query("UPDATE Product p SET p.likeCount = GREATEST(p.likeCount - 1, 0) WHERE p.id = :id")
void decrementLikeCount(@Param("id") Long id);

@Modifying은 JPA에서 UPDATE, DELETE 쿼리를 실행할 때 붙이는 어노테이션이다. JPA는 기본적으로 @Query를 SELECT로 가정하기 때문에, 데이터를 바꾸는 쿼리엔 반드시 명시해줘야 한다.

// SELECT → @Modifying 없어도 됨
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findById(@Param("id") Long id);

// UPDATE → @Modifying 필수 (없으면 에러)
@Modifying
@Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id")
void incrementLikeCount(@Param("id") Long id);

@Modifying에는 clearAutomatically, flushAutomatically 옵션이 있다. @EventListener / @TransactionalEventListener와는 완전히 별개다.

@EventListener / @TransactionalEventListener
→ 이벤트를 언제 처리할지 (즉시 vs 커밋 후)

clearAutomatically / flushAutomatically
→ @Modifying 쿼리 실행 시 1차 캐시를 어떻게 할지

두 옵션은 동기 리스너 + 같은 트랜잭션 안에서 EntityManager 상태를 제어할 때 쓴다. 나는 사실 저 clearAutomatically, flushAutomatically를 써야 한다고 생각했는 데, 공부를 하면서 같은 트랜잭션에서 쓸 수 있다는 걸 상기하게 됐다.

EntityManager란

JPA가 DB랑 소통하는 핵심 도구다. 개발자가 save()를 안 불러도 트랜잭션이 끝나면 변경사항이 DB에 반영되는 게 EntityManager 덕분이다.

트랜잭션 시작 → HikariCP에서 커넥션 꺼냄 → EntityManager 생성 (1차 캐시 = 비어있음)
    ↓
findById(1L) 호출
    ↓
EntityManager: "1차 캐시에 id=1 있나?" → 없음 → DB에 SELECT 쿼리 전송 → 결과를 1차 캐시에 저장
    ↓
findById(1L) 또 호출
    ↓
EntityManager: "1차 캐시에 id=1 있나?" → 있음 → DB 안 가고 캐시에서 바로 반환 (쿼리 없음)
    ↓
setLikeCount(100) → 1차 캐시의 값만 변경 (DB 반영 아직 X)
    ↓
트랜잭션 커밋 직전 → Dirty Checking → 달라진 거 있으면 UPDATE 쿼리 자동 생성
    ↓
커밋 → EntityManager 닫힘 → 커넥션 반납

같은 트랜잭션 안에서 같은 엔티티를 두 번 조회해도 DB 쿼리는 한 번만 나가는 이유가 바로 1차 캐시 때문이다.

@Modifying으로 직접 UPDATE를 날리면 DB는 바뀌었는데 1차 캐시는 옛날 값 그대로라 불일치가 생긴다. clearAutomatically가 이 1차 캐시를 비워주는 거다.

EntityManager 하나가 커넥션 하나를 들고 있다. EntityManager가 살아있는 동안 커넥션을 점유한다.

@Async 리스너 vs 동기 리스너

커넥션 관점에서 둘의 차이가 명확하다.

@Async 리스너 (지금 구조):
[원본 트랜잭션] 커넥션 꺼냄 → EntityManager 생성 → 커밋 → 닫힘 → 커넥션 반납
                                                         ↓ AFTER_COMMIT 트리거
[새 스레드]                                         시작 (아직 커넥션 없음)
                                                         ↓ 쿼리 실행 시점에
                                                    커넥션 꺼냄 → 새 EntityManager 생성 → 커밋 → 반납
→ 원본 커밋 완료 후 새 스레드 시작 → 커넥션 동시 점유 최소화 ✅

동기 리스너:
[원본 트랜잭션] 커넥션 꺼냄 → EntityManager 생성
    → publishEvent() → handle() 바로 실행 (같은 커넥션, 같은 EntityManager)
    → handle() 끝 → 커밋 → 커넥션 반납
→ 커넥션 1개, EntityManager 1개 공유

동기 리스너는 원본 트랜잭션의 EntityManager를 공유하기 때문에 clearAutomatically / flushAutomatically가 의미 있다. @Async 리스너는 새 EntityManager를 쓰기 때문에 적용 대상이 없다.

@Async 리스너:  원본 커밋 후 새 스레드 시작, EntityManager 2개 → clearAutomatically 의미 없음
동기 리스너:    커넥션 1개, EntityManager 1개 공유 → clearAutomatically 의미 있음

flushAutomatically = true: 리스너 실행 전에 1차 캐시를 DB에 반영

// BEFORE_COMMIT + 동기 리스너
@TransactionalEventListener(phase = BEFORE_COMMIT, flushAutomatically = true)
public void handle(OrderCreatedEvent event) {
    // flush 덕분에 이 시점에 OrderEntity 변경사항이 DB에 반영된 상태
    // 예: 감사 로그 테이블에 INSERT할 때 Order의 최신 상태가 필요한 경우
}

clearAutomatically = true: 리스너 실행 후 1차 캐시를 초기화

@TransactionalEventListener(phase = BEFORE_COMMIT, clearAutomatically = true)
public void handle(SomeEvent event) {
    // 리스너에서 대량 bulk UPDATE 수행
    // → 이후 원본 트랜잭션에서 같은 엔티티 조회 시
    //   오염된 1차 캐시 대신 DB에서 최신값을 읽어옴
}

옵션 효과 주로 쓰는 phase

flushAutomatically 리스너 실행 전 flush BEFORE_COMMIT
clearAutomatically 리스너 실행 후 clear BEFORE_COMMIT

둘 다 @Async 없는 동기 + BEFORE_COMMIT 조합에서 의미 있다. 지금 좋아요 카운트처럼 @Async + AFTER_COMMIT 구조에서는 적용 대상이 없다.

처음엔 이렇게 생각하기 쉽다.

"@Modifying으로 UPDATE를 날리니까
 clearAutomatically = true를 붙여야 1차 캐시 불일치를 막을 수 있지 않나?"

맞는 생각이다. 근데 핵심은 어느 트랜잭션의 EntityManager에 작용하냐는 거다.

clearAutomatically가 작용하는 대상:
→ @Modifying을 호출한 트랜잭션의 EntityManager

지금 구조에서 @Modifying을 호출하는 곳:
→ @Async로 뜬 새 스레드의 새 트랜잭션

원본 트랜잭션은 이미 커밋이 끝나고 EntityManager도 닫힌 상태
→ clearAutomatically가 작용할 대상 자체가 없음

같은 트랜잭션 안에서 @Modifying을 쓸 때는 붙여야 한다.

// 동기 + 같은 트랜잭션 → clearAutomatically 의미 있음
@Transactional
public void like(Long userId, Long productId) {
    likeRepository.save(new Like(userId, productId));
    // 같은 트랜잭션, 같은 EntityManager

    productRepository.incrementLikeCount(productId);
    // clearAutomatically = true → 이 EntityManager 캐시 비움 ✅

    productRepository.findById(productId); // DB에서 최신값 읽음
}

지금 좋아요 카운트 구조는 다르다.

// 원본 트랜잭션
@Transactional
public void like(Long userId, Long productId) {
    likeRepository.save(new Like(userId, productId)); // SQL INSERT, 아직 커밋 X
    eventPublisher.publishEvent(new LikeCreatedEvent(productId)); // 이벤트 등록만, 트랜잭션 아직 열려있음
} // ← 메서드 return → @Transactional AOP가 커밋 → AFTER_COMMIT 트리거

// 완전히 다른 스레드, 새로운 EntityManager
@Async
@TransactionalEventListener(phase = AFTER_COMMIT) // 커밋 후 실행
public void handle(LikeCreatedEvent event) {
    productRepository.incrementLikeCount(event.getProductId());
    // clearAutomatically = true → 원본 EntityManager에 clear 시도
    // 원본은 이미 닫혔고, 여기는 새 EntityManager → 아무 효과 없음 ❌
}

여기서 save()는 커밋이 아니다. JPA 영속성 컨텍스트에 저장하고 SQL을 flush하는 것이고, 커밋은 like() 메서드가 완전히 return될 때 AOP 프록시가 수행한다. publishEvent() 호출 시점엔 트랜잭션이 여전히 열려있고, 리스너는 커밋이 완료된 뒤에 실행된다.

메서드 시작 → @Transactional AOP가 트랜잭션 열기

  likeRepository.save()        → SQL INSERT, 아직 커밋 X
  eventPublisher.publishEvent() → "커밋되면 이거 실행해줘" 등록만

메서드 return → @Transactional AOP가 커밋
                      ↓
               AFTER_COMMIT 트리거
                      ↓
               @Async → 새 스레드 → handle() 실행

publishEvent()가 호출되면 Spring이 LikeCreatedEvent를 처리할 리스너를 탐색하고, phase = AFTER_COMMIT이므로 "커밋 후에 실행"으로 예약해둔다. like() 메서드가 return되면 AOP가 커밋하고, 그때 예약된 리스너가 실행된다.

@TransactionalEventListener의 phase는 네 가지다.

phase 실행 시점

AFTER_COMMIT 커밋 성공 후
AFTER_ROLLBACK 롤백 후
AFTER_COMPLETION 커밋/롤백 무관하게 완료 후
BEFORE_COMMIT 커밋 직전

AFTER_COMMIT을 쓰는 이유가 바로 여기 있다. likes INSERT가 DB에 실제로 커밋된 게 확실할 때만 like_count를 올려야 한다. 롤백됐는데 카운트가 올라가면 안 되니까.

clearAutomatically / flushAutomatically는 @Modifying을 호출한 트랜잭션의 EntityManager에 작용한다. @Async + @TransactionalEventListener(AFTER_COMMIT) 구조에서 리스너는 새 스레드의 새 EntityManager로 실행되기 때문에, 두 옵션 모두 적용 대상이 없어 의미가 없다.

DB 안에서 읽고 쓰는 게 한 번에 처리되니까 Lost Update가 없다. GREATEST(likeCount - 1, 0)은 좋아요 수가 음수가 되는 것을 막는다.


원인 2: 이벤트 순서 역전

비동기 이벤트는 발행 순서와 처리 순서가 다를 수 있다.

t=1: 좋아요 등록 이벤트 발행 → 처리 지연 (네트워크 지연 등)
t=2: 좋아요 취소 이벤트 발행 → 즉시 처리 → like_count--
t=3: 좋아요 등록 이벤트 뒤늦게 처리 → like_count++

결과: 취소했는데 카운트가 올라간 상태

원자적 연산을 써도 순서가 바뀌면 최종값이 틀린다.

해결: 배치 정합성 복구

주기적으로 실제 COUNT(*)로 덮어쓴다.

@Scheduled(cron = "0 30 * * * *") // 매시 30분
@Transactional
public void reconcileLikeCount() {
    productRepository.findAllIds().forEach(productId -> {
        long actual = likeRepository.countByProductId(productId);
        productRepository.updateLikeCount(productId, actual);
    });
}

배치가 겹쳐서 두 번 실행돼도 COUNT(*)로 덮어쓰는 거라 결과가 같다. 멱등하다.

배치가 꼭 필요한 건 아니다. 서비스 상황에 따라 다르다.

트래픽 적은 서비스: 이벤트 순서 역전이 드물게 일어남
                   → 원자적 연산만으로 충분할 수 있음

트래픽 많은 서비스: 이벤트 순서 역전이 자주 일어남
                   → 배치로 주기적으로 맞춰주는 게 안전

정확도가 중요한 서비스 (브랜드 광고비 정산 등):
                   → 배치 필수

원인 3: 이벤트 중복 처리

Kafka 같은 브로커는 At-Least-Once 전달을 보장한다. 컨슈머가 처리 중 장애가 나면 같은 메시지를 다시 보낸다.

이벤트 처리 중 → 컨슈머 장애
→ 브로커가 재전송
→ 이미 처리된 이벤트를 다시 처리 → like_count 두 번 증가

핵심은 멱등성이다. 같은 이벤트를 두 번 처리해도 결과가 같아야 한다.

// 멱등하지 않은 방식
like_count = like_count + 1  ← 두 번 실행하면 두 번 증가

// 멱등한 방식
like_count = COUNT(*) FROM likes  ← 두 번 실행해도 같은 값

이벤트 ID로 중복 처리를 막는 방법도 있다.

// 이벤트에 고유 ID 부여
public class LikeCreatedEvent {
    private final String eventId; // UUID
    private final Long productId;
}

// 처리된 이벤트 ID 저장
@Transactional
public void handle(LikeCreatedEvent event) {
    if (processedEventRepository.exists(event.getEventId())) {
        return; // 이미 처리된 이벤트 → 무시
    }

    productRepository.incrementLikeCount(event.getProductId());
    processedEventRepository.save(event.getEventId()); // 처리 기록
}

같은 이벤트가 두 번 와도 처리된 기록이 있으면 무시한다.


결국 선택한 전략

세 가지 원인이 있었고, 각각 다른 이유로 다른 전략을 선택했다.

원인 1: Lost Update → DB 원자적 연산

like_count + 1을 읽고 쓰는 두 단계로 처리하면 동시에 여러 컨슈머가 뜰 때 덮어써버린다. DB 안에서 한 번에 처리하는 원자적 연산으로 해결했다. 비관적 락을 걸 수도 있지만, 좋아요는 약간의 오차가 허용되고 성능이 중요해서 락 없이 처리할 수 있는 원자적 연산을 선택했다.

원인 2: 이벤트 순서 역전 → 배치 정합성 복구

원자적 연산으로도 이벤트 순서가 뒤바뀌면 최종값이 틀릴 수 있다. 실시간으로 완벽하게 맞추는 건 비용이 크고, 좋아요는 약간의 오차가 허용되니까 주기적으로 COUNT(*)로 덮어쓰는 배치를 선택했다.

원인 3: 이벤트 중복 처리 → 멱등성

Kafka나 메시지 브로커는 At-Least-Once 전달을 보장해서 같은 이벤트가 두 번 올 수 있다. 이벤트 ID로 중복 체크해서 이미 처리된 이벤트는 무시하도록 했다.

원인 1: Lost Update      → DB 원자적 연산 (락 없이 성능 유지)
원인 2: 이벤트 순서 역전  → 배치 정합성 복구 (주기적으로 COUNT(*))
원인 3: 이벤트 중복 처리  → 멱등성 (이벤트 ID 중복 체크)

이 세 가지를 적용하고 나서 "수십 개씩 튀는" 현상은 없어졌다.


시리즈를 마치며

Eventual Consistency(최종적 일관성)는 "결국 맞아지는 것"이지, "틀려도 되는 것"이 아니다.

비동기 처리를 선택했다면 세 가지를 반드시 챙겨야 한다.

리스크 해결책

Lost Update DB 원자적 연산
이벤트 순서 역전 배치 정합성 복구
이벤트 중복 처리 멱등성 확보

그리고 시리즈 전체를 관통하는 한 가지 교훈이 있다.

동시성 문제는 "락을 걸면 된다"가 아니다. 어떤 자원이 어떤 충돌 패턴을 가지는지 파악한 다음, 그에 맞는 전략을 선택하는 것이 핵심이다.

상황 전략 이유

재고 차감 비관적 락 충돌 빈도 높음, 틀리면 안 됨
쿠폰 중복 발급 DB 유니크 키 애플리케이션 락 없이 DB로 보장
선착순 쿠폰 비관적 락 + 유니크 키 수량 + 중복 둘 다 막아야 함
쿠폰 중복 사용 비관적 락 + 상태 조건 UPDATE 주문 트랜잭션과 묶여 이중 방어
좋아요 카운트 원자적 연산 + 배치 복구 성능 우선, 오차 범위 관리

시리즈 목차

  1. @Transactional 붙이면 동시성이 다 해결될 줄 알았다
  2. 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일
  3. 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나
  4. 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
  5. 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!" ← 현재 글
반응형