프로젝트를 진행하며 팔로워 수를 증가하거나 줄일때 동시성 문제에 대해 고민했던 내용입니다. 학습과정에서 작성되었기 때문에 잘못된 내용이 있을 수 있으며, 잘못된 내용에 대한 지적이나 피드백은 언제든 환영입니다.
1. 동시성 문제
프로젝트에서 팔로우/언팔로우 기능을 구현하며 동시성 이슈를 만나게 되었습니다. 현재 팔로우/언팔로우 기능을 구현하는 로직은 아래와 같은데요, 팔로잉/언팔로우을 하게되면 회원 테이블이 가지고 있는 팔로잉 컬럼의 값이 1 증가하거나 감소합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Service
@RequiredArgsConstructor
public class FollowCommandService {
private final FollowCommandRepository followCommandRepository;
@Transactional
public void updateFollow(
FollowHistory followHistory,
Member source,
Member target
) {
if (followHistory.exist()) {
// 팔로우 히스토리가 있다면 unfollow
followCommandRepository.unfollow(
source.getMemberIdAsValue(),
target.getMemberIdAsValue()
);
updateUnfollow(source, target);
return;
}
// 팔로우 히스토리가 없다면 follow
followCommandRepository.follow(new Follow(source, target));
updateFollow(source, target);
}
// 팔로워 수 감소
private void updateUnfollow(Member source, Member target) {
source.decreaseFollowingCount();
target.decraseFollowerCount();
}
// 팔로워 수 증가
private void updateFollow(Member source, Member target) {
source.increaseFollowingCount();
target.increaseFollowerCount();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 회원 테이블에서 팔로우 수 관리
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
......
@Embedded
private FollowerCount followerCount;
@Embedded
private FollowingCount followingCount;
......
}
여기서 왜 회원 테이블이 팔로잉 카운트를 가지고 있는지에 대해 의문이 생길 수 있는데, 이는 조회의 편의성을 위해 이렇게 설계했습니다. 한 회원의 몇명의 팔로잉/팔로워 수를 알기 위해 서는 조인(Join)으로 데이터를 찾아와 갯수를 세거나 데이터베이스에 카운트(Count) 쿼리를 날려야 하는데, 이렇게 매번 조인/카운트 쿼리를 날리는 것은 데이터가 많아졌을경우 데이터베이스에 부담을 줄 수 있다고 판단했기 때문입니다.
메시와 같은 유명인사들은 수 천만, 억 단위의 팔로워가 있는데 이를 조인, 카운트 쿼리로 찾아오는 것은 불가능 합니다.
실제 인스타그램 API를 보더라도 비슷하게 문제를 해결했습니다. 아래는 한 회원의 정보를 불러오는 API 인데, 회원 테이블에서 팔로잉/팔로워 수를 가지고 있습니다.
다시 문제로 돌아와서 이슈가 발생했던 과정을 살펴보겠습니다. Jun의 팔로워 수는 10명이고 A, B가 Jun을 동시에 팔로잉 했을 때, 결과가 12명이 되어야 할 것 같지만 결과는 11명일 수도, 12명일 수도 있습니다. 이는 트랜잭션 A, B가 동시에 Jun의 같은 값(10)을 읽고, 자신이 읽어온 값(10)을 기준으로 업데이트 하기 때문입니다.
2. 해결
동시성 문제를 해결하기 위해서는 순서를 보장하는 락(lock)이 필요하겠다는 생각이 들었습니다. 고려했던 방법은 아래 네 가지인데, 저는 첫 번째 방법으로 문제를 해결했습니다. 데이터베이스를 이용해 락(낙관적/비관적)을 걸려면 엔티티에 @Version 컬럼이 들어가며, RDB로 락을 거는 것은 조회 시 병목점이 될 수 있다고 판단했기 때문입니다.
1. 팔로워 수를 업데이트할때 데이터베이스가 아닌 레디스로 락을 잡는다.
2. 팔로워 수를 조인으로 찾은 후 개수를 센다.
3. 데이터베이스에 count쿼리를 날려 개수를 찾는다.
4. 낙관적/비관적 락을 사용한다.
@Version 칼럼이 나쁜 것은 아니지만 가능한 도메인(엔티티)을 순수하게 유지시키고 싶었습니다.
따라서 레디스를 통해 팔로잉 업데이트 시 락을 잡아 문제를 해결했는데, 이때 RedissonClient를 사용했습니다. RedissonClient가 어떻게 동작하는지 간략하게 살펴보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component
@RequiredArgsConstructor
public class FollowCommandFacade {
private final FollowQueryService followQueryService;
private final FollowCommandService followCommandService;
private final MemberQueryService memberQueryService;
private final RedissonClient redissonClient;
@Transactional
public void updateFollow(MemberId sourceId, MemberId targetId) {
RLock lock = redissonClient.getLock(targetId.getMemberId().toString());
try {
boolean available = lock.tryLock(3, 1, TimeUnit.SECONDS);
if (!available) {
Thread.sleep(1000);
}
Member source = memberQueryService.findById(sourceId)
.orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND_EXCEPTION));
Member target = memberQueryService.findById(targetId)
.orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND_EXCEPTION));
FollowHistoryExistResponse followHistoryResponse = followQueryService.findFollowHistoryById(
source.getMemberIdAsValue(),
target.getMemberIdAsValue()
);
followCommandService.updateFollow(followHistoryResponse.getFollowHistory(), source, target);
} catch (InterruptedException e) {
lock.unlock();
}
}
}
Redisson은 스핀 락이 아닌 pubsub 방식을 사용함으로써 레디스에 부담을 덜 주는데, 이는 락이 해제될 때마다 subscriber에게 알림을 주기 때문입니다. 스핀락과 달리 일일이 레디스에 요청을 보내 락의 획득가능여부를 체크하지 않아도 되기 때문에 오버헤드가 비교적 적습니다. 즉, 스핀락처럼 while문을 돌면서 기다리지 않고 메시지가 오는 순간 알림을 받고 다음 작업을 진행하기 때문에 레디스에 부하를 덜 주는 것입니다. RedissonClient는 Lua 스크립트를 통해 락을 구현하고 있는데요, 스크립트까지 가기 위해서는 아래의 과정을 거치며 최종 잠금은 원자성을 보장합니다.
이때 수동으로 잠금의 만료 시간을 설정하지 않으면 기본 잠금 시간은 30초입니다. 만료 시간을 설정하지 않으면 불필요하게 락을 잡고 있는데, 예를들어 작업 시간은 1초인데 락을 해제하지 않으면 뒤에 있는 스레드는 불필요하게 30초간 대기하기 때문에 기본 시간을 반드시 지정해 주는 것이 좋습니다. 여튼 이후 unlock unlockAsync를 호출하고 스레드 ID를 전달한 후 락을 해제합니다.
RedissonClient는 위와 같이 RDB가 아닌 매개변수와 레디스를 이용해 록을 걸기 때문에 RDB에 부담이 가지 않아 이를 채택하게 되었습니다. 물론 지금처럼 사용자가 적을 때는 이 방식이 괜찮을 것 같지만 만약 사용자가 많아진다면 이 방식이 유용할지는 솔직히 잘 모르겠습니다. 이 부분에 대해서는 조금 더 고려해 봐야 할 것 같습니다.
레디스 서버가 다운되면 이에 대한 조치를 취해야 하기 때문에 별도의 방안이 필요합니다.
3. 정리
조회의 편의성을 위해 반대편 테이블에서 팔로워 수를 관리했기 때문에 발생한 동시성 문제를 살펴보았습니다. 이를 해결하는 방법으로 RDB를 통한 락, 매번 조인/count 쿼리, RedissonClient의 선택지가 있었는데 이 중 RedissonClient를 사용했습니다. 하지만 사용자가 많아졌을 때, 레디스 서버가 다운됐을 때를 아직 겪지 못해 완벽히 해결한 것은 아닌데요, 이 부분은 별도로 학습을 통해 포스팅을 추가할 예정입니다.




