💡 팔로우/팔로잉 기능, 단순해 보이지만 그 뒤엔 복잡한 데이터베이스 기술이 숨어 있습니다. 동시성 문제, 트랜잭션, 락 등 핵심 개념과 이를 해결하는 전략을 지금 바로 알아보아요!

동시성 문제의 이해
경쟁 상태(Race Condition)와 그 위험성
팔로우 기능 구현에서 가장 흔히 마주치는 문제는 여러 사용자가 동시에 같은 대상을 팔로우 할 때 발생하는 경쟁 상태입니다.
예를 들어, 다음과 같은 시나리오를 생각해 봅시다.

- 사용자 A가 사용자 J의 팔로워 수(현재 10명)를 읽음
- 동시에 사용자 B도 사용자 J의 팔로워 수(현재 10명)를 읽음
- 사용자 A가 J를 팔로우하고 팔로워 수를 11명으로 업데이트
- 사용자 B가 J를 팔로우하고 팔로워 수를 11명으로 업데이트 (원래는 12명이 되어야 함)
결과적으로 두 명의 사용자가 팔로우했음에도 팔로워 수는 하나만 증가 하게 됩니다. 이는 READ-MODIFY-WRITE 패턴(읽고 수정한뒤 변경된 값을 다시 쓰는 방식으로 다중 쓰레드 환경에서, 한 쓰레드가 데이터를 수정하기 전에 다른 쓰레드가 동일한 데이터를 읽어오면 데이터 정합성 문제가 발생)에서 흔히 발생하는 동시성 문제입니다.
동시성 문제의 실제 사례
실제 프로젝트에서도 이와 같은 문제가 자주 발생합니다. 한 트랜잭션이 팔로워 수를 읽고 업데이트하는 동안 다른 트랜잭션이 동일한 데이터에 접근하여 변경하면, 데이터 정합성이 깨질 수 있습니다.
// 동시성 문제를 발생시키는 코드 예시
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);
}
위 코드에서 두 사용자가 동시에 같은 사용자를 팔로우하면 팔로워 수가 정확하게 반영되지 않을 수 있습니다.
트랜잭션과 격리 수준
트랜잭션의 중요성

트랜잭션은 데이터베이스의 상태를 변화시키는 작업의 단위로, 팔로우 기능 구현에 있어 데이터 일관성을 보장하는 핵심 요소입니다. 트랜잭션은 ACID 속성(원자성, 일관성, 격리성, 지속성)을 통해 안정적인 데이터 처리를 지원합니다.
격리 수준(Isolation Level)과 선택 기준
트랜잭션 격리 수준은 동시에 실행되는 트랜잭션들 사이의 상호작용을 제어하는 방식입니다. 팔로우 기능에 적용할 수 있는 주요 격리 수준은 다음과 같습니다.
트랜잭션 격리 수준을 이해하려면 알아야 할 4가지 핵심 현상과 메커니즘
- 더티 리드(Dirty Read) 다른 트랜잭션이 커밋되지 않은 데이터를 읽는 현상입니다. 예를 들어 A가 티켓 수를 4→3으로 변경했지만 결제 실패로 롤백된 상황에서, B가 변경 중인 3을 읽으면 존재하지 않았던 "더티 데이터"를 읽게 됩니다.
- 논리피터블 리드(Non-Repeatable Read) 한 트랜잭션 내에서 동일 데이터를 재조회 시 값이 변경되는 현상입니다. 은행 계좌 조회 시 처음 5,000원을 읽은 후 다른 트랜잭션이 +5,000원을 입금하고 커밋하면, 재조회 시 10,000원으로 달라지는 경우가 대표적입니다.
- 팬텀 리드(Phantom Read) 범위 조회 시 새 행이 추가/삭제되어 결과 집합이 바뀌는 현상입니다. 17세 이상 사용자 조회 시 처음엔 2명이 검색됐다가 다른 트랜잭션이 26세 사용자를 추가한 후 재조회 시 3명이 나타나는 경우가 대표적입니다.
- 갭 락(Gap Lock) MySQL DB가 인덱스 레코드 사이의 간격에 거는 잠금입니다. ID 3과 5 사이에 새로운 행(ID=4)이 삽입되는 것을 방지하며, REPEATABLE READ 수준에서 팬텀 리드를 99% 차단하는 핵심 메커니즘으로 작동합니다.

팔로우 기능에는 일반적으로 REPEATABLE READ나 READ COMMITTED 수준이 적합합니다. 이는 데이터 일관성과 성능 사이의 좋은 균형점을 제공합니다.
트랜잭션 경계 설정
트랜잭션 경계 설정은 트랜잭션이 어디서 시작하고 종료할지를 결정하는 것입니다. 팔로우 기능 구현 시 고려해야 할 중요한 부분입니다.
Spring에서는 @Transactional 어노테이션을 통해 선언적으로 트랜잭션 경계를 설정할 수 있습니다.
@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);
}
락 메커니즘과 데드락 방지
비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)
동시성 문제를 해결하기 위한 두 가지 주요 접근법은 비관적 락과 낙관적 락입니다.

비관적 락 (Pessimistic Lock)
비관적 락은 데이터를 수정하기 전에 해당 데이터에 대한 접근을 미리 제한하는 방식입니다. 다른 트랜잭션의 간섭을 원천적으로 차단하여 데이터 일관성을 강력하게 보장합니다.
// JPA에서 비관적 락 사용 예시
@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);
}
// Repository 메서드
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :id")
Optional<User> findByIdWithPessimisticLock(@Param("id") Long id);
비관적 락은 데이터 일관성이 매우 중요하고 충돌이 자주 발생할 것으로 예상되는 환경에 적합합니다. 하지만 락을 획득하기 위한 대기 시간이 길어질 수 있어 성능에 부정적인 영향을 미칠 수 있습니다.
낙관적 락 (Optimistic Lock)
낙관적 락은 충돌이 드물게 발생한다고 가정하고, 데이터 수정 시 버전 번호나 타임스탬프를 확인하여 충돌을 감지하는 방식입니다.
// JPA에서 낙관적 락 사용 예시
@Entity
public class User {
@Id
private Long id;
private String username;
private int followerCount;
@Version
private Long version;
public void increaseFollowerCount() {
this.followerCount++;
}
}
@Transactional
public void followWithOptimisticLock(Long followerId, Long followingId) {
try {
User follower = userRepository.findById(followerId).orElseThrow();
User following = userRepository.findById(followingId).orElseThrow();
following.increaseFollowerCount();
followerRepository.save(new Follower(follower, following));
userRepository.save(following);
} catch (OptimisticLockException e) {
// 충돌 발생 시 재시도 로직
retryFollow(followerId, followingId);
}
}
낙관적 락은 동시성을 높이고 시스템 성능을 개선할 수 있지만, 충돌이 발생했을 때 작업을 롤백하고 재시도해야 하는 부담이 있습니다.
데드락(Deadlock) 방지 전략
데드락은 두 개 이상의 트랜잭션이 서로가 점유한 자원을 기다리며 무한정 대기하는 상황입니다.
팔로우 기능 구현 시 데드락을 방지하기 위한 주요 전략은 다음과 같습니다.
- 일관된 락 획득 순서 유지: 모든 트랜잭션에서 테이블이나 레코드에 접근하는 순서를 동일하게 유지합니다.
// 데드락 발생 가능성 있는 코드
@Transactional
public void riskyFollow(Long followerId, Long followingId) {
// followerId > followingId인 경우와 followerId < followingId인 경우에
// 락 획득 순서가 달라져 데드락 발생 가능
User user1 = userRepository.findByIdWithLock(followerId);
User user2 = userRepository.findByIdWithLock(followingId);
}
// 데드락 방지 코드
@Transactional
public void safeFollow(Long followerId, Long followingId) {
// 항상 ID가 작은 사용자부터 락 획득
Long smallerId = Math.min(followerId, followingId);
Long largerId = Math.max(followerId, followingId);
User user1 = userRepository.findByIdWithLock(smallerId);
User user2 = userRepository.findByIdWithLock(largerId);
}
- 트랜잭션 시간 최소화: 트랜잭션 실행 시간을 짧게 유지하여 락 경합 가능성을 줄입니다.
- 적절한 격리 수준 선택: 필요 이상으로 높은 격리 수준을 사용하지 않습니다.
- 데드락 발생 시 재시도 메커니즘 구현: 데드락이 감지되면 랜덤한 시간 후에 트랜잭션을 재시도합니다.
캐싱 전략으로 성능 최적화
Redis를 활용한 캐싱 패턴
팔로우 기능의 성능을 향상시키기 위해 Redis와 같은 인메모리 데이터베이스를 사용한 캐싱 전략을 적용할 수 있습니다.
Look-Aside 패턴 (읽기 최적화)
사용자의 팔로워/팔로잉 목록을 조회할 때,
먼저 캐시에서 확인하고 없을 경우에만 데이터베이스에서 조회하는 방식
입니다.

public List<User> getFollowers(Long userId) {
String cacheKey = "followers:" + userId;
// 캐시에서 팔로워 목록 조회
List<User> followers = redisTemplate.opsForList().range(cacheKey, 0, -1);
if (followers == null || followers.isEmpty()) {
// 캐시에 없으면 DB에서 조회
followers = followerRepository.findFollowersByUserId(userId);
// 조회 결과를 캐시에 저장 (TTL 설정)
redisTemplate.opsForList().rightPushAll(cacheKey, followers);
redisTemplate.expire(cacheKey, 1, TimeUnit.HOURS);
}
return followers;
}
Write-Back 패턴 (쓰기 최적화)
팔로우/언팔로우 작업 발생 시 변경사항을 바로 데이터베이스에 반영하지 않고,
캐시에 임시로 저장
했다가 일정 주기로 데이터베이스에 반영하는 방식입니다.

@Service
public class FollowService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private FollowerRepository followerRepository;
public void follow(Long followerId, Long followingId) {
// 캐시에 팔로우 정보 추가
String followingKey = "following:" + followerId;
String followerKey = "followers:" + followingId;
redisTemplate.opsForSet().add(followingKey, followingId);
redisTemplate.opsForSet().add(followerKey, followerId);
// 변경 사항을 DB 동기화 큐에 추가
redisTemplate.opsForSet().add("pendingFollows", followerId + ":" + followingId);
}
// 일정 주기로 실행되는 스케줄러 메서드
@Scheduled(fixedDelay = 60000) // 1분마다 실행
public void syncFollowsToDatabase() {
Set<String> pendingFollows = redisTemplate.opsForSet().members("pendingFollows");
for (String pendingFollow : pendingFollows) {
String[] ids = pendingFollow.split(":");
Long followerId = Long.parseLong(ids[0]);
Long followingId = Long.parseLong(ids[3]);
// DB에 팔로우 관계 저장
User follower = userRepository.findById(followerId).orElseThrow();
User following = userRepository.findById(followingId).orElseThrow();
followerRepository.save(new Follower(follower, following));
// 처리 완료된 항목 제거
redisTemplate.opsForSet().remove("pendingFollows", pendingFollow);
}
}
}
이 패턴은 빈번한 팔로우/언팔로우 작업 시 데이터베이스 부하를 줄일 수 있지만, 캐시와 데이터베이스 간 일시적인 불일치가 발생할 수 있습니다.
데이터베이스 스키마 설계 및 인덱싱
효율적인 팔로우 관계 테이블 설계
팔로우 관계를 저장하기 위한 데이터베이스 스키마는 일반적으로 다음과 같이 설계합니다.
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),
FOREIGN KEY (follower_id) REFERENCES users(id),
FOREIGN KEY (following_id) REFERENCES users(id)
);
이 설계는 중복 팔로우를 방지하고, 특정 사용자의 팔로워나 팔로잉 목록을 효율적으로 조회할 수 있도록 합니다.
인덱스 최적화 전략
팔로우 기능의 성능을 향상시키기 위한 인덱스 최적화 전략은 다음과 같습니다.
1. 복합 인덱스 활용: 자주 함께 검색되는 컬럼들에 대해 복합 인덱스를 생성합니다.
💡 복합 인덱스를 사용하여 팔로워와 팔로잉 각각의 아이디를 단일로 조회하지 않고 한번에 두개의 아이디를 조회함으로써 성능을 향상시킬 수 있다!
CREATE INDEX idx_follower_following ON follow_relationship(follower_id, following_id);
2. 선택도 높은 컬럼 우선 배치: 인덱스 컬럼 순서 결정 시 선택도(고유한 값의 비율)가 높은 컬럼을 앞에 배치합니다.
동시성 문제는 현대 애플리케이션 개발에서 피할 수 없는 도전 과제입니다. 이를 해결하기 위해 적절한 데이터베이스 설계, 락 활용, 그리고 애플리케이션 수준에서의 동기화 전략을 결합하는 것이 중요합니다. 최적의 방법은 시스템 환경과 요구사항에 따라 달라질 수 있으므로, 다양한 접근법을 이해하고 상황에 맞게 적용하는 것이 핵심입니다. 지속적인 학습과 실험을 통해 더욱 안정적이고 효율적인 시스템을 구축하시길 바랍니다!