좋아요 카운트 로직을 짜다가 문제를 발견했다.
"약간의 오차는 괜찮다"고 판단하고 비동기로 처리했는데, 코드를 들여다보니 수십 개씩 틀어질 수 있는 구조였다.
실제로 운영 중에 이런 상황이 생기면 어떻게 될까? 가상의 브랜드 담당자에게 "좋아요 수가 갑자기 수십 개씩 줄었다가 돌아와요"라는 연락을 받는다고 상상해보자. 처음엔 식은땀이 날 것 같다. "약간의 오차는 괜찮다"고 했는데, "약간의 오차"와 "수십 개씩 튀는 것"은 달랐다.
원인을 찾아보자.
정규화 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 | 주문 트랜잭션과 묶여 이중 방어 |
| 좋아요 카운트 | 원자적 연산 + 배치 복구 | 성능 우선, 오차 범위 관리 |
시리즈 목차
- @Transactional 붙이면 동시성이 다 해결될 줄 알았다
- 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일
- 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나
- 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
- 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!" ← 현재 글
'개발 > 개발' 카테고리의 다른 글
| 포인트는 왜 안 쌓였을까 — @TransactionalEventListener 함정 파헤치기 (0) | 2026.03.27 |
|---|---|
| 상품 목록 조회 기능 개선 — 인덱스 → 캐시 → Redis 적용 과정 (1) | 2026.03.13 |
| [Java / Spring Boot] 쿠폰 사용에 대한 처리는 어떤 락이 적절할까 - 4편 (0) | 2026.03.06 |
| [Java / Spring Boot] 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나 - 3편 (0) | 2026.03.06 |
| [Java / Spring Boot] 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일-2편 (1) | 2026.03.06 |