0. 작성 이유
기능 개발 중 동시성 문제를 해결해야 하는 문제가 발생하여 이를 해결하기 위해 학습했던 방법을 공유하고 싶어 이렇게 작성합니다.
0-1. 도메인
예시에서 사용할 코드 중 도메인에 대한 이해가 필요한 부분이 있어서 미리 명시합니다.
Tyle이라는 도메인이 존재하고, Team과 Persoanl로 분류할 수 있는데 Team의 경우 방의 개념에 가깝고 개인 Tyle의 경우 프로필과 유사합니다.
1. 기능 요구 사항
- Team Tyle에 참여한다.
- 이미 참여하고 있거나 Tyle이 없다면 throw
- 팀의 최대 참여 인원 수보다 많은 사람들이 참여하려 한다면 throw
기능 코드
@Transactional
public void enterTeamTyle(AuthMember authMember, Long teamTyleId) {
Tyle myTyle = searchService.findMyTyleWithType(authMember, Tyle.Type.PERSONAL);
List<Participant> participants = participantSearchService.findParticipants(teamTyleId);
Tyle teamTyle = participants.get(0).getTeamTyle();
validationParticipants(participants, myTyle);
teamTyle.enterTyle();
tyleRepository.save(teamTyle);
}
(실제 코드에서 약간 변형된 코드)
- 팀 타일에 참여하는 메서드
- 각 서비스에서 내 타일과 참여자들을 조회 후 내가 이미 참여하고 있는지, 최대 참여자를 넘어섰는지 `validationParticipants`에서 확인합니다.
- 이후 참여한 상태로 update를 위해 save를 명시적으로 호출한 코드입니다.
테스트 코드
void enter_tyle_concurrency_test() throws InterruptedException {
// Given
final int PARTICIPANT_COUNT = 100;
final int THREAD_COUNT = 100;
final int TEAM_MAX = 101;
CountDownLatch countDownLatch = new CountDownLatch(PARTICIPANT_COUNT);
ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
List<Tyle> tyles = new ArrayList<>();
List<Member> members = new ArrayList<>();
for (int i = 0; i < PARTICIPANT_COUNT; i++) {
members.add(MemberFixture.member("socialId" + i));
tyles.add(TyleFixture.personalTyle("name" + i, i + 1L));
}
beforeSaveAll(members);
beforeSaveAll(tyles);
Tyle star = TyleFixture.personalTyle("star", PARTICIPANT_COUNT + 1L);
Tyle teamTyle = TyleFixture.teamTyle("teamTyle", TEAM_MAX);
beforeSaveAll(List.of(star, teamTyle));
Participant participant = ParticipantFixture.participant(star, teamTyle);
beforeSave(participant);
// When
Tyle team = tyleRepository.findById(teamTyle.getId()).get();
for (int i = 0; i < THREAD_COUNT; i++) {
AuthMember authMember = OAuth2Fixture.authMember(i + 1L);
executorService.execute(() -> {
tyleService.enterTeamTyle(authMember, team.getId());
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
// Then
Tyle result = tyleRepository.findById(teamTyle.getId()).get();
Assertions.assertThat(result.getCurrentParticipant()).isEqualTo(TEAM_MAX);
}
- 위 코드를 기반으로 100명의 사람이 동시에 참여하기를 하는 테스트 코드이다.
- 테스트를 하기 위한 개인 타일 100개와 팀 타일 1개를 준비하고 팀 타일에 100명이 참여하는 것을 가정한 테스트 코드이다.
테스트 결과
갱신 손실 문제
- 사람 A와 사람 B가 동시에 요청을 보낼 때, 동일한 데이터에 접근을 하게 되는데 같은 값을 가져오면서 현재 참여자가 같은 값이 됐다.
- 이때 사람 A가 업데이트를 하여 현재 참여자가 2가 되었다 하더라도, 이미 사람 B는 조회를 마친 상태로 update를 진행하게 되면서 동일하게 현재 참여자가 2가 된다.
이렇게 사람 A의 갱신자체가 사라지는 문제를 `갱신 손실 문제`라고 한다.
이러한 문제는 결국 공통된 데이터를 동시에 접근하게 하지 못하면 되기 때문에 가장 쉽게 `synchronized`부터 생각할 수 있다.
2. Synchronized
위와 같은 동일한 메서드에 `synchronized`를 붙여 테스트를 하게 되면
@Transactional
public synchronized void enterTeamTyle(AuthMember authMember, Long teamTyleId) {
Tyle myTyle = searchService.findMyTyleWithType(authMember, Tyle.Type.PERSONAL);
List<Participant> participants = participantSearchService.findParticipants(teamTyleId);
Tyle teamTyle = participants.get(0).getTeamTyle();
validationParticipants(participants, myTyle);
teamTyle.enterTyle();
tyleRepository.save(teamTyle);
}
원인 - Synchronized & @Transactional
synchronized를 사용하면 `enterTyle`메서드를 하나의 스레드가 끝날때 까지 다른 쓰레드가 접근하지 못하니, 모든 업데이트가 성공해야 합니다. 하지만 결과는 그렇지 못했는데, 그 이유는 `Spring의 Transaction`과 `Synchronized`의 동작방식 차이에서 오게 됩니다.
Transaction 동작방식
`@Transactional`의 동작방식의 경우 `Spring AOP`를 사용하여 프록시 객체를 생성하게 됩니다. 외부에서 target 객체를 호출하면 프록시 객체가 해당 메서드를 실행하기 전후로 transaction을 시작하고 끝내는 과정을 대신 수행해 주게 됩니다.
Synchronized의 동작방식
반대로 Synchronized의 경우 `모니터`라는 개념을 통해 동기화 과정이 이루어지게 되는데, 한 번에 하나의 스레드만 접근할 수 있도록 제어하는데 사용되는 기술입니다.
쉽게 생각하여,
public method() {
acquire(m);
while (!p) {
wait(m, cv);
}
signal(cv2);
release(m);
}
- acquire: 모니터 락 획득
- while 조건: 실행 조건 확인
- 이후 실행하고
- release: 락 반환
과 같다고 볼 수 있습니다.
이렇게 서로 다른 동작 방식이 존재하는데, `Synchronized`의 경우 해당 메서드에 락을 거는 행위 이므로 트랜잭션은 이미 걸려있을 수 있는 상태이며, 반대로 메서드가 락을 반환해도 트랜잭션은 끝나지 않은 상태일 수 있다는 것입니다.
정리하자면, A 트랜잭션이 커밋이 되기 전 B 트랜잭션이 실행되는 것이고 이로 인해 동시성 문제가 아직 남아있다 볼 수 있습니다.
따라서 위와 같은 문제가 발생하기 때문에 동시성 문제가 남아있는 것입니다.
해결 방법
원인이 보이니 문제 해결은 사실 간단하다. Transaction이 시작하기 전에 `synchronized`를 적용하는 방법과 `@Transactional`을 제거하는 방법이 있다.
`@Transactional`을 제거하면 위와 같이 테스트가 성공하는 것을 볼 수 있다.
Synchronized의 문제점
하지만 위 2방법은 많은 문제가 있는데,
- transaction이 시작하기 전에 `synchronized`를 적용하게 된다면 사실 단일 쓰레드 환경에서 돌리는 것과 같은 방법이다. 따라서 비효율 적이다.
- `@Transactional`을 제거하는 방법은 또한 위와 동일한 문제 및 db의 정합성을 맞추기가 힘들어 진다. 모든 update나 delete가 transaction에 묶이지 않기 때문이다.
- 다중 서버의 경우 `synchronized`가 있으나 마나한 설정이라는 것이다.
따라서 다른 방법을 찾아야 한다.
이를 해결하기 위한 방법으로 `낙관적 락`으로는 어떻게 풀어나갈 수 있을까 보겠습니다.
3. 낙관적 락(Optimistic Lock)
DB의 Lock기능을 사용하지 않고 Version 관리를 통해 애플리케이션단에서 처리하는 방법
대략적인 동작은 위와 같다.
- 조회를 하게 되면 version정보도 같이 알 수 있다.
- 사람 A가 업데이트 후 commit을 하게 되면 version 업데이트와 함께 값도 업데이트가 된다.
- 사람B는 A와 동일한 데이터를 가져왔기에 Version이 1인 상태에서 업데이트를 하려했지만 찾을 수 없어서 `ObjectOptimisticLockingFailureException`이 발생하는 것을 볼 수 있다.
위와 같은 동작 방식으로 최초 커밋만 인정되고, 다음은 커밋은 예외가 발생하기 때문에 `갱신 분실 문제`를 방지할 수 있다.
OPTIMISTIC 특징
- OPTIMISTIC의 경우 `commit`시점에서 버전 정보를 조회하여 버전이 같은지 검증한다.
주의 사항
- 벌크연산시 JPA가 관리하지 않기 때문에 직접 버전을 관리해 줘야 한다.
- 각 엔티티에는 하나의 버전 컬럼만 있어야 한다.
- int, Integer, long, Long, Short, short, Timestamp중 하나의 타입에 가능하다
낙관적 락 적용
낙관적 락을 사용하기 위해서는 몇 가지 추가해 줘야 합니다.
Entity
public class Tyle extends BaseTimeEntity {
...
@Version
private Integer version;
}
- Lock을 사용할 엔티티에 `@Version`을 추가합니다.
- version의 경우 `import jakarta.persistence.Version`을 사용하면 됩니다.
Repository
// JPA
@Lock(LockModeType.OPTIMISTIC)
Optional<Tyle> findById(Long id);
// QueryDSL
public Optional<Tyle> findByMemberIdAndType(Long id, Tyle.Type type) {
return Optional.ofNullable(jpaQueryFactory.selectFrom(tyle)
.where(
tyle.memberId.eq(id),
DynamicQuery.generateEq(type, tyle.type::eq)
)
.setLockMode(LockModeType.OPTIMISTIC)
.fetchOne());
}
- JPA의 경우 `@Lock`을 사용하여 `OPTIMISTIC`을 추가하고
- QueryDSL의 경우 `setLockMode`를 사용하여 넣어줘야 한다.
Service
[사용자가 직접 다시 시도]
@Transactional
public void enterTeamTyle(AuthMember authMember, Long teamTyleId) throws TyleException {
Tyle myTyle = searchService.findMyTyleWithType(authMember, Tyle.Type.PERSONAL);
List<Participant> participants = participantSearchService.findParticipants(teamTyleId);
Tyle teamTyle = searchService.findTyle(teamTyleId);
validationParticipants(participants, myTyle);
teamTyle.enterTyle();
try {
tyleRepository.save(teamTyle);
} catch (ObjectOptimisticLockingFailureException exception) {
throw new TyleException(ErrorMessage.TEAM_ENTER_RETRY);
}
}
- 서비스에서 문제가 발생될 save 부분에 try catch 예외 추가
- 낙관적 락은 Lock을 실제로 사용하는 것이 아니라, Version을 통해 `갱신 손실 문제`를 해결함으로써 동시성 문제를 풀어나간다.
- 따라서 100명이 시도해서 100명이 될 수는 없지만 다시 시도하게 만든다.
- 예외를 처리하는 방식에 따라, 사용자가 직접 다시 시도하게 할 수도, 아니면 직접 비지니스 로직을 재수행하도록 할 수 있다.
따라서 위의 결과는 테스트의 경우 실패가 뜨고 이전 synchronized 테스트에서 실패했던 것과 동일한 결과가 나왔다.
[직접 비지니스 로직 재수행]
@Service
@RequiredArgsConstructor
public class TyleServiceFacade {
public static int THEAD_SLEEP = 20;
public static int MAX_RETRY = 10;
private final TyleService tyleService;
public void enterTyle(AuthMember authMember, Long tyleId) throws InterruptedException {
int retryCount = MAX_RETRY;
while (retryCount > 0) {
try {
tyleService.enterTeamTyle(authMember, tyleId);
} catch (ObjectOptimisticLockingFailureException exception) {
retryCount--;
Thread.sleep(THEAD_SLEEP);
}
}
}
}
- 사용자가 직접 다시 시도하는 코드에서 다시 예외처리를 분리하고 TyleServiceFacade에서 처리한다.
테스트의 경우 제대로 통과하는 것을 볼 수 있다.
장점
- 낙관적 락이라는 말 그대로 동시성 문제가 별로 발생하지 않을 거라는 가정에서 부터 시작한다.
- JPA를 사용한다면 구현 자체가 간편하게 끝낼 수 있다.
단점
- 동시성 문제가 많아지게 된다면, 구현에 따라 다르겠지만 그 만큼 쿼리를 많이 실행하여 부하가 클 수 있습니다.
이번 서비스 성향상, 팀 입장의 경우 동시성 문제가 많이 발생할 것이라 판단하여, 사실 낙관적 락의 경우 사용하기 힘들다 판단하였습니다.
충돌이 가능성이 높은 만큼 비관적 락을 선택하게 되었습니다.
4. 비관적 락
모든 트랜잭션들은 충돌이 많다는 가정을 하여, 처음부터 Lock을 걸고 시작하는 방법
따라서 DB의 Lock을 사용하는데 jpa를 사용하게되면, `PESSIMISTIC_READ`, `PESSIMISTIC_WRITE` 2가지가 있다.
- PESSIMISTIC_READ: Shared Lock(s-lock)
- PESSIMISTIC_WRITE: Exclusive Lock(x-lock)
주의할 점은 x-lock을 거는 순간 다른 트랜잭션은 대기하게 되는데, 이때 `데드락`이 발생하는 경우가 많기 때문에 조심해야 한다.
비관적 락 적용
비관적 락을 적용하는 방법은 JPA에서는 어노테이션을 붙이면 끝이기 때문에 간단하다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select tyle from Tyle tyle where tyle.id = :id")
Optional<Tyle> findByIdWithPessimisticLock(@Param("id") Long id);
위와 같이 `@Lock` 어노테이션을 통해 넣으면 되는데, 비관적 락의 경우 DB Lock을 사용하다보니 생각해야할 것이 있다.
바로 DB의 트랜잭션 격리 수준이다. 현재 MySQL InnoDB 엔진을 사용하다 보니, 기본 트랜잭션 격리 수준의 경우 `Repeatable read`이다.
- 처음으로 read operation을 수행한 시간을 기점으로 snapshot을 찍고 비교하기때문에 commit 되지 않은 값은 보이지 않는다.
- 또한 InnoDB엔진 특성상 일반적으로 Phantom read가 발생하지 않는다.
따라서, 현재 생각해야하는 것은 변경할 데이터에 대한 조회 쿼리 시점에 x-lock을 거는 것이다.
예를들어, 아래와 같은
@Transactional
public void enterTeamTyle(AuthMember authMember, Long teamTyleId) throws TyleException {
Tyle myTyle = searchService.findMyTyleWithType(authMember, Tyle.Type.PERSONAL);
participantSearchService.validateAlreadyExistParticipant(myTyle, teamTyleId);
Tyle teamTyle = searchService.findTyleWithLock(teamTyleId);
validationTeamTyle(teamTyle);
teamTyle.enterTyle();
Participant participant = ParticipantMapper.toParticipant(myTyle, teamTyle);
participantRepository.save(participant);
tyleRepository.save(teamTyle);
}
- findMyTyleWithType: Personal Tyle 조회
- validateAlreadyExistParticipant: Team Tyle id와 Personal Tyle id로 이미 존재하는지 조회
- findTyleWithLock: Update가 되어야 하는 쿼리다 보니 x-lock을 걸었다.
성공적으로 동작한다면,
- 팀 타일에서도 `select ... for update`로 x-lock을 거는 모습을 볼 수 있습니다.
결과를 보면 성공하는 것을 볼 수 있습니다.
지금까지는 서로 다른 인덱스를 가진 데이터를 넣어주면서 동시성이 잘 되는지 확인했었다.
위 경우에도 문제가 발생하는데, 따닥과 같이 동일한 인덱스를 가진 데이터가 동시에 들어간다면 중복된 데이터가 들어간다는 것이다.
위 row들 중 가운데 12, 21 102의 colum들은 각각, `id`, `personal_tyle_id`, `team_tyle_id`이다.
personal과 team의 경우 unique가 아니기 때문에 위와 같이 personal_tyle_id가 21과 23같이 중복된 값이 들어간 것을 볼 수 있다.
이 문제는 여러 트랜잭션에서 Participant 테이블에 insert를 하기전에 이미 참여했는지 검증 로직을 통해 검증을 하는데 격리수준이 `Repeatable read`이다 보니, 트랜잭션이 끝나지 않은 상태에서 validation이 진행되면 이전 값이 그대로 가져오기 때문에 별 다른 예외가 발생하지 않는 것이다.
따라서 Participant에서 값을 조회할 때 값을 변경하지는 않으므로 shared lock을 걸었다.
이때 `validateAlreadyExistParticipant(s-lock)`을 team Tyle조회(x-lock)보다 먼저 한다면 데드락이 발생하는 것을 볼 수 있다. 왜 데드락이 발생할까에 대해 알아보자.
5. 데드락
두 개 이상의 프로세스나 스레드가 서로 자원을 원하는 상태에서, 이미 lock이 걸려있어 얻지 못한 상태로 무한 대기하는 상태
데드락 조건
- 상호 배제 : 자원은 동시에 여러 개의 작업을 사용할 수 없고, 한 번에 하나의 작업만 상요할 수 있다.
- 점유 대기 : 작업은 이미 다른 트랜잭션이 점유한 상태에서, 이 자원을 사용하기 위해 대기하는 상태
- 비선점 : 이미 점유한(lock이 걸린) 자원은 강제로 뺏을 수 없다.
- 순환대기 : 작업 간 자원을 기다리는 순환 형태의 사이클이 형성된다.
그럼 내 상황에서는 왜 데드락이 발생했는지부터 보겠습니다.
TRANSACTION 1
*** (1) TRANSACTION:
TRANSACTION 623945, ACTIVE 0 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s) MySQL thread id 140, OS thread handle 140307999983360, query id 14820 172.23.0.1 root statistics select t1_0.id,t1_0.agency_email,t1_0.avatar_image,t1_0.created_at,t1_0.current_participant,t1_0.deleted_at,t1_0.followers_count,t1_0.following_count,t1_0.location,t1_0.long_intro,t1_0.max_participant,t1_0.member_id,t1_0.name,t1_0.paid_type,t1_0.short_intro,t1_0.type,t1_0.updated_at,t1_0.view from tyle t1_0 where t1_0.id=102 for update
첫 번째 트랜잭션은 tyle에 대한 `select for update`즉 x-lock을 건 것 트랜잭션이다.
*** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 26824 page no 5 n bits 88 index FKkivb03kjlj78kafj9oks679eg of table `tyletest`.`perticipant` trx id 623945 lock mode S locks gap before rec Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
이후 s-lock이 걸리는 것을 볼 수 있습니다. 이때 participant는 `unique 인덱스`에 대한 조회가 아니다 보니 gap lock에 대한 s lock을 가지고 있습니다.
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 26812 page no 4 n bits 176 index PRIMARY of table `tyletest`.`tyle` trx id 623945 lock_mode X locks rec but not gap waiting Record lock, heap no 103 PHYSICAL RECORD: n_fields 20; compact format; info bits 0
트랜잭션은 tyle의 primary에 대한 인덱스 x lock을 얻기 위해 대기하는 로그를 볼 수 있습니다.
TRANSACTION 2
*** (2) TRANSACTION:
TRANSACTION 623942, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 7 lock struct(s), heap size 1128, 4 row lock(s), undo log entries 1 MySQL thread id 147, OS thread handle 140307997869824, query id 14828 172.23.0.1 root update insert into perticipant (created_at,is_manager,personal_tyle_id,team_tyle_id,updated_at) values ('2024-02-22 07:35:50.378806',0,97,102,'2024-02-22 07:35:50.378806')
다른 트랜잭션의 경우 participant를 insert하면서 생기는 트랜잭션입니다.
*** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 26812 page no 4 n bits 176 index PRIMARY of table `tyletest`.`tyle` trx id 623942 lock_mode X locks rec but not gap Record lock, heap no 103 PHYSICAL RECORD: n_fields 20; compact format; info bits 0
여기서는 tyle의 인덱스에 대한 x lock을 가지고 있습니다.
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 26824 page no 5 n bits 88 index FKkivb03kjlj78kafj9oks679eg of table `tyletest`.`perticipant` trx id 623942 lock_mode X locks gap before rec insert intention waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
여기까지 보면, 왜 데드락이 발생했는지 볼 수 있습니다.
participant를 insert하기 위해 x lock이 발생하는데, 이 x lock을 얻기 위해 대기하고 있는 것입니다.
문제는 이 x lock을 얻기 전에 insert intention gap lock을 얻게 되는데 동일한 인덱스에 대해서 여러 트랜잭션이 동일한 insert를 요청하는 것을 막기 위해 table 단위의 lock을 먼저 얻게 됩니다.
다만 다른 트랜잭션에서 이미 participant에 s-lock을 가지고 있다보니 여기서 발생하는 데드락이었습니다.
결국,
A 트랜잭션에서는 participant의 유니크 인덱스에 대한 gap s lock을 가지고 있고,
B 트랜잭션에서는 tyle의 update를 위한 x lock을 가지고 있는 것입니다.
이때,
A 트랜잭션은 tyle update를 위해 x lock이 필요하고,
B 트랜잭션은 participant insert를 위한 x lock이 필요해서
대기하고 있는 상태였습니다.
이 문제를 해결하기 위해 간단한 방법이 있는데, s lock을 건 상태에서 x lock을 걸기 위해 발생한 문제여서 순서를 team Tyle을 먼저 조회하게 된다면
Assertions.assertThat(result.getCurrentParticipant()).isEqualTo(1+TEAM_MAX/2);
테스트가 통과하는 것을 볼 수 있다.
문제점
- lock을 건다는 것 자체가 하나의 자원에 동시 접근을 막는다는 것인데, 반대로 말하면 그 만큼 기다리기 때문에 서비스의 성능이 저하될 수 있다.
- 또한 위의 경우에는 쉽게 해결되었지만, 조금 더 복잡해지면 또 다른 데드락이 발생할 수 있습니다.
- 그리고 분산 DB의 경우 락이 걸리더라도 하나의 DB에만 걸리기 때문에 비관적 락으로는 해결을 할 수 없습니다.
이러한 문제점을 해결하기 위한 방법으로는
- 네임드 락
- Redis를 활용한 분산락
- 메시징 큐
등 다양한 방법이 있습니다.
6. Redis
Redis를 활용하게 된다면 별도의 저장소를 통해 동시성을 해결하다 보니, 분산된 DB나 서버 환경에서 처리하기가 수월하다. 또한 부하를 RDS에서 받는 것이 아니라 redis에서 대신 받다보니 더 효율적이다.
Redis를 사용하는 이유는 결국 분산락을 구현하려는 것이다 보니, `Lettuce`나 `Redisson`중 상황에 따라 구현하면 된다.
6-1. Lettuce
lettuce의 경우 분산락의 기능을 제공하지 않습니다. 따라서 spin lock 방식으로 구현하게 됩니다.
`spin lock`구현 방식의 경우 락을 획득하려는 쓰레드가 락을 사용할 수 있는지 지속적인 시도를 통해 획득을 한다면 서비스를 동작하는 방식입니다.
락을 획득할 수 있는지 확인하는 명령어로는 `setnx key value`명령어를 사용하는데, `setnx`는 SET if Not eXists`의 줄임말로 key가 없다면 value를 set하는 명령어 입니다.
명령어 사용
- setnx: 있으면 생성 없으면 생성 실패
- 생성 성공 : 1
- 생성 실패 : 0
Spin Lock 방식 구현
@Service
@RequiredArgsConstructor
public class TyleServiceFacade {
public static int THEAD_SLEEP = 100;
public static int MAX_RETRY = 10;
private final TyleService tyleService;
private final LockRepository lockRepository;
public void enterTyleWithlock(AuthMember authMember, Long tyleId) throws InterruptedException {
while (Boolean.FALSE.equals(lockRepository.lock(tyleId))) {
Thread.sleep(THEAD_SLEEP);
}
tyleService.enterTeamTyle(authMember, tyleId);
lockRepository.unlock(tyleId);
}
}
- Lock을 지속적으로 setnx명령어로 redis에 요청하면서 결과를 받습니다.
- while문에 sleep과 같은 시간 지연이 없다면, redis에 수 많은 요청을 보내게 되어 많은 부하가 발생한다. 따라서 시간 지연을 추가해줘야 한다.
- 이후 로직 진행하고 lock을 반환(삭제)
@Component
@RequiredArgsConstructor
public class LockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofMillis(3_000));
}
public void unlock(Long key) {
redisTemplate.delete(key.toString());
}
}
- setnx명령어에는 key, value, expire time이 필요하다.
- unlock에서는 key를 삭제해야 한다.
물론 직접 분산락을 구현해야해서 retry횟수나 timeout 처리 기능도 추가해야하고, 예외처리도 추가해야합니다.
또한 Spin lock방식이다 보니 요청이 많을 수록 redis에 반복적인 요청을 하게 되므로 부하가 커지게 됩니다.
따라서 Pub/Sub방식을 이용하여 이미 분산락을 제공하는 Redisson을 사용하기도 합니다.
6-2. Redisson
lettuce와는 반대로 분산락을 직접 구현할 필요없이 이미 기능을 라이브러리로 제공을 한다.
Pub-Sub 기반의 lock을 구현을 했습니다. 다만 단순한 구조로 되어있어서 channel에서 sub이 구독을 하면 pub이 메세지를 던지는데, sub이 구독이 되어있지 않으면 pub이 메세지를 던지더라도 해당 메세지가 사라지게 된다.
따라서 100%전송이 되어야만 하는 기능에서는 사용하기 애매해진다는 단점이 있습니다.
그리고 라이브러리를 사용하다보니, 해당 라이브러리의 사용법을 알아야 한다.
Pub-Sub
Redis의 Pub-Sub동작 방식을 간단하게 보면
- 0: channel을 먼저 구독한다.
- 1: 그 후 구독한 채널(ch)에 메세지(test)를 전송한다.
- 2: 전송한 메세지가 채널을 구독한 sub에게 전송
- 3: 새로운 channel 등록 (ch 채널에 등록된 sub는 2개)
- 4: ch 채널에 메세지(pu)전송
- 5: 구독한 sub들에게 메세지 전송
따라서 동일한 channel을 여러 sub이 구독하게 된다면 동일 동작을 예상보다 과하게 될 수 있다는 생각을 해야한다.
이때 redisson에서는 tryLock을 통해 락을 얻는 것을 통해 여러 쓰레드가 동시에 동작하는 것을 막고 있습니다.
구현
public void enterTyleWithRedission(AuthMember authMember, Long tyleId) {
RLock lock = redissonClient.getLock(parseKey(tyleId));
try {
boolean available = lock.tryLock(10, 3, TimeUnit.SECONDS);
if (!available) {
throw new TyleException(ErrorMessage.GET_LOCK_FAILED);
}
tyleService.enterTeamTyle(authMember, tyleId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
private String parseKey(Long tyleId) {
return LOCK_PREFIX + tyleId;
}
- `getLock`: 특정 이름으로 Lock 정의
- `tryLock`: 10초동안 락을 획득을 시도하며, 획득하게 되면 3초안에 해제한다.
- `catch (InterruptedException)`: InterruptedException이 발생한 쓰레드는 인터럽트를 발생 상태를 초기화 시키므로 다시 예외를 발생시켜야한다.
결과는 테스트가 잘 통과하는 것을 볼 수 있다.
결론
동시성을 해결하기 위해 다양한 방법을 선택할 수 있다. 여러 방법 중 각각이 가지는 장단점이 있기 때문에 어떤 방법을 사용할 지는 현 서비스가 어떤 서비스이고, 어떤 특성을 가졌는지를 먼저 파악하고 이러한 정보드를 바탕으로 선택하는 것이 가장 적절한 판단이 아닐까 싶다.