팔로우 기능은 관계 하나 저장하면 끝나 보이지만, 실제로 먼저 깨지는 것은 팔로워 수 같은 집계 값입니다. 같은 사용자를 여러 요청이 동시에 팔로우하는 순간, 관계 데이터는 저장됐는데 카운트는 하나만 증가하는 식의 불일치가 발생할 수 있습니다.

이 글은 그 상황을 기준으로, 어떤 문제는 트랜잭션과 락으로 막아야 하고 어떤 문제는 캐시로 풀면 안 되는지 정리한 글입니다.

문제는 팔로우 관계보다 카운트에서 먼저 드러납니다

팔로우 기능에서 가장 먼저 깨지는 것은 보통 관계 테이블 자체가 아니라, 사용자 프로필에 보이는 팔로워 수입니다. 이유는 대부분의 구현이 다음 순서를 따르기 때문입니다.

  1. 현재 팔로워 수를 읽습니다.
  2. 애플리케이션 메모리에서 값을 1 증가시킵니다.
  3. 증가한 값을 다시 저장합니다.

이 흐름은 요청이 하나일 때는 문제 없습니다. 하지만 동시에 두 요청이 들어오면 같은 값을 읽고, 같은 결과를 써버리는 경쟁 상태가 생깁니다.

위 시나리오에서는 두 사용자가 모두 팔로우를 성공했는데, 최종 팔로워 수는 12가 아니라 11이 됩니다. 관계는 두 건이 저장될 수 있지만, 카운트는 한 번만 증가한 것처럼 남을 수 있습니다.

즉, 이 문제의 핵심은 “동시에 같은 사용자를 팔로우할 수 있다”가 아니라, READ -> MODIFY -> WRITE 흐름을 여러 요청이 동시에 통과한다는 점입니다.

java
public void follow(Long followerId, Long followingId) {
    User follower = userRepository.findById(followerId).orElseThrow();
    User following = userRepository.findById(followingId).orElseThrow();

    following.increaseFollowerCount();
    followerRepository.save(new Follower(follower, following));
    userRepository.save(following);
}

이 코드는 단일 요청에서는 자연스럽지만, 동시에 실행되면 카운트 값이 쉽게 어긋납니다.

그래서 트랜잭션부터 다시 봐야 했습니다

이 문제를 처음 해결하려고 할 때 가장 먼저 떠올리는 것은 @Transactional입니다. 맞는 방향입니다. 적어도 팔로우 관계 저장과 카운트 증가가 한 단위로 묶여야 하기 때문입니다.

트랜잭션이 필요한 이유는 단순합니다.

  • 팔로우 관계는 저장됐는데 카운트는 증가하지 않는 상태를 막아야 합니다.
  • 카운트는 증가했는데 팔로우 관계가 저장되지 않는 상태도 막아야 합니다.

트랜잭션은 이 두 연산을 하나의 경계 안에 넣어줍니다. 하지만 이것만으로는 충분하지 않습니다. 서로 다른 트랜잭션이 같은 사용자를 동시에 수정할 수 있기 때문입니다.

java
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void follow(Long followerId, Long followingId) {
    User follower = userRepository.findById(followerId).orElseThrow();
    User following = userRepository.findById(followingId).orElseThrow();

    following.increaseFollowerCount();
    follower.increaseFollowingCount();

    followerRepository.save(new Follower(follower, following));
    userRepository.save(following);
    userRepository.save(follower);
}

여기서 중요한 질문은 “트랜잭션을 쓰는가”가 아니라, “동시에 실행되는 트랜잭션끼리 어떤 수준까지 서로를 보게 할 것인가”입니다.

격리 수준은 성능과 일관성의 경계였습니다

격리 수준은 여러 트랜잭션이 동시에 실행될 때 어느 정도까지 충돌을 허용할지 정하는 장치입니다.

실제 팔로우 기능에서는 다음 네 가지를 이해하는 것이 중요했습니다.

  1. 더티 리드: 커밋되지 않은 값을 읽는 문제입니다.
  2. 논리피터블 리드: 같은 트랜잭션 안에서 재조회 값이 달라지는 문제입니다.
  3. 팬텀 리드: 범위 조회 결과 집합이 달라지는 문제입니다.
  4. 갭 락: 인덱스 사이 구간을 잠가 삽입을 제어하는 방식입니다.

팔로우 기능에서 항상 가장 높은 격리 수준이 정답은 아닙니다. 필요한 것은 모든 충돌을 막는 수준이 아니라, 우리가 실제로 수정하는 데이터가 어긋나지 않는 수준입니다.

보통 이 문제에서는 READ COMMITTEDREPEATABLE READ가 먼저 검토 대상이 됩니다. 중요한 것은 격리 수준을 올리는 것 자체보다, 어떤 데이터 충돌을 막고 싶은지 먼저 정리하는 일입니다.

결국 핵심은 락 전략이었습니다

카운트를 동시에 수정하는 문제는 결국 같은 사용자 레코드를 여러 요청이 동시에 건드리는 상황입니다. 여기서 실질적인 선택지는 비관적 락과 낙관적 락입니다.

비관적 락은 충돌을 먼저 막습니다

비관적 락은 수정 전에 레코드를 잠가 다른 트랜잭션의 접근을 제한합니다. 충돌이 자주 발생하고 카운트 일관성이 중요한 경우에는 가장 직관적인 방법입니다.

java
@Transactional
public void followWithPessimisticLock(Long followerId, Long followingId) {
    User follower = userRepository.findById(followerId).orElseThrow();
    User following = userRepository.findByIdWithPessimisticLock(followingId).orElseThrow();

    following.increaseFollowerCount();
    followerRepository.save(new Follower(follower, following));
    userRepository.save(following);
}

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :id")
Optional findByIdWithPessimisticLock(@Param("id") Long id);

장점은 명확합니다. 충돌을 감지하는 것이 아니라 애초에 막습니다. 반면 요청이 몰릴수록 대기 시간이 길어질 수 있고, 락 순서가 불안정하면 데드락 가능성도 함께 올라갑니다.

낙관적 락은 충돌을 나중에 감지합니다

낙관적 락은 대부분의 요청이 충돌하지 않는다고 가정하고, 수정 시점에 버전이 바뀌었는지만 확인합니다.

java
@Entity
public class User {
    @Id
    private Long id;

    private int followerCount;

    @Version
    private Long version;
}

이 방식은 락 대기가 적고 처리량을 더 높일 수 있습니다. 대신 충돌이 발생하면 롤백과 재시도가 필요합니다. 즉, 데이터베이스가 아니라 애플리케이션이 재시도 비용을 부담하게 됩니다.

데드락은 락 종류보다 락 순서에서 더 자주 생깁니다

팔로우 기능처럼 두 사용자를 함께 수정하는 경우에는 데드락 방지도 중요합니다. 예를 들어 서로를 동시에 팔로우하는 요청이 들어오면, 락 순서가 뒤섞이면서 서로가 가진 락을 기다리는 상황이 생길 수 있습니다.

그래서 중요한 것은 락을 쓰는가보다, 항상 같은 순서로 락을 잡도록 강제하는 것입니다.

java
@Transactional
public void safeFollow(Long followerId, Long followingId) {
    Long smallerId = Math.min(followerId, followingId);
    Long largerId = Math.max(followerId, followingId);

    User user1 = userRepository.findByIdWithLock(smallerId);
    User user2 = userRepository.findByIdWithLock(largerId);
}

이런 규칙이 없으면, 락을 도입한 뒤에 오히려 운영이 더 어려워질 수 있습니다.

Redis는 동시성 해결책이 아니라 조회 부하 완화책이었습니다

팔로우 기능에서 Redis를 떠올리면 흔히 “DB 동시성 문제를 Redis로 해결할 수 있지 않을까?”라고 생각하게 됩니다. 하지만 대부분의 경우 Redis는 동시성의 본질적인 해법이 아니라, 조회 부담을 줄이기 위한 도구에 가깝습니다.

Look-Aside는 읽기 성능을 개선합니다

팔로워 목록, 팔로잉 목록처럼 자주 조회되는 데이터는 캐시에 먼저 두고, 없으면 DB에서 읽는 패턴이 효과적입니다.

java
public List getFollowers(Long userId) {
    String cacheKey = "followers:" + userId;
    List followers = redisTemplate.opsForList().range(cacheKey, 0, -1);

    if (followers == null || followers.isEmpty()) {
        followers = followerRepository.findFollowersByUserId(userId);
        redisTemplate.opsForList().rightPushAll(cacheKey, followers);
        redisTemplate.expire(cacheKey, 1, TimeUnit.HOURS);
    }

    return followers;
}

이 패턴은 읽기 지연을 줄이는 데는 유용합니다. 하지만 카운트 정합성을 해결해 주지는 않습니다. 동시성 문제는 여전히 원본 데이터가 어디서 어떻게 갱신되는지에서 결정됩니다.

Write-Back은 쓰기 부하를 줄일 수 있지만, 팔로우 기능엔 더 조심해야 합니다

변경 사항을 캐시에 모아 두었다가 나중에 DB에 반영하는 Write-Back 패턴도 있습니다.

이 방식은 쓰기 부하를 줄일 수 있지만, 소셜 그래프처럼 사용자에게 즉시 노출되는 데이터에는 주의가 필요합니다. 캐시와 DB가 잠시 어긋나는 것을 허용해야 하기 때문입니다.

팔로우 관계나 카운트처럼 사용자 프로필과 화면 상태에 직접 드러나는 값이라면, 먼저 일관성을 확보하고 그다음 캐시를 얹는 편이 더 안전합니다.

스키마와 인덱스는 마지막이 아니라 처음부터 중요했습니다

동시성 문제는 락과 트랜잭션만으로 끝나지 않습니다. 중복 팔로우 자체를 막는 제약이 있어야 하고, 조회가 느려져 락 점유 시간이 길어지지 않도록 인덱스도 갖춰야 합니다.

sql
CREATE TABLE follow_relationship (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    follower_id BIGINT NOT NULL,
    following_id BIGINT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY unique_follow (follower_id, following_id),
    INDEX idx_follower (follower_id),
    INDEX idx_following (following_id)
);

여기서 중요한 것은 두 가지입니다.

  • (follower_id, following_id) 유니크 제약으로 중복 팔로우를 막습니다.
  • 조회 경로에 맞는 인덱스를 두어 불필요한 락 점유 시간을 줄입니다.

복합 인덱스는 단순한 조회 성능 문제가 아니라, 동시성 제어의 기반이기도 합니다.

정리

팔로우 기능의 동시성 문제는 “여러 요청이 동시에 온다”는 사실 자체보다, 같은 데이터를 어떤 순서로 읽고 쓰는지에서 시작됩니다.

이 문제를 다룰 때 기준은 명확합니다.

  • 트랜잭션은 관계 저장과 카운트 갱신을 하나로 묶어야 합니다.
  • 격리 수준은 필요한 충돌만 막는 방향으로 골라야 합니다.
  • 락 전략은 충돌 빈도와 재시도 비용을 기준으로 선택해야 합니다.
  • Redis는 동시성 해결책이 아니라, 주로 조회 성능 최적화 도구로 봐야 합니다.
  • 유니크 제약과 인덱스는 맨 마지막 최적화가 아니라 기본 설계입니다.

결국 중요한 것은 개별 기술의 이름이 아니라, 어떤 데이터가 동시에 수정될 수 있고 그 수정이 어떻게 어긋나는지를 먼저 명확히 보는 것입니다. 그래야 락, 트랜잭션, 캐시 중 무엇을 어디까지 써야 하는지도 자연스럽게 결정할 수 있습니다.