💭 들어가며
진행하던 프로젝트에서 위와 같이 응원 횟수를 여러번 클릭하면 이에 맞게 횟수가 증가되는 로직을 구현해야 했다.
한 명의 사용자는 여러번 응원 횟수를 증가시킬 수 있다.
이와 관련해 어떻게 동시성 문제를 해결할 수 있을지에 대한 고민을 시작했다.
✅ 동시성 문제란?
- 동시성 문제가 발생하지 않는 상황
pk가 1인 응원 데이터의 응원 횟수가 90인 상태에서 사용자 A 가 3번 응원 횟수를 증가시키고 사용자 B 가 10번 응원 횟수를 증가시켰다고 가정해보자.
사용자 A | 응원 횟수 | 사용자 B |
pk가 1인 응원 데이터를 찾는다 >> 아 응원 횟수는 90이구나! | 90회 | |
pk 가 1인 응원 데이터의 횟수를 3회 증가시킨다. | 93회 | |
93회 | pk가 1인 응원 데이터를 찾는다 >> 아 응원 횟수는 93이구나! | |
103회 | pk 가 1인 응원 데이터의 횟수를 10회 증가시킨다. |
위처럼 정상적으로 요청이 반영되어 각각 3회, 10회를 더한 103회가 결과로 나올 것으로 기대한다.
- 동시성 문제가 발생하는 상황
사용자 A | 응원 횟수 | 사용자 B |
pk가 1인 응원 데이터를 찾는다 >> 아 응원 횟수는 90이구나! | 90회 | |
90회 | pk가 1인 응원 데이터를 찾는다 >> 아 응원 횟수는 90이구나! | |
pk 가 1인 응원 데이터의 횟수를 3회 증가시킨다. | 93회 | |
pk 가 1인 응원 데이터의 횟수를 10회 증가시킨다. | ||
100회 |
그러나, 동시성 문제가 고려되지 않는다면 위처럼 일부 횟수가 누락될 가능성이 존재한다.
✅ 동시성 문제의 원인 - Race Condition
- Race condition 이란?
이는 두개 이상의 쓰레드가 공유 데이터에 액세스가 가능한 동시에 변경을 하고자 하면 발생하는 문제이다.
Race Condition 에 의해서 데이터의 결과는 공용 데이터에 대한 접근에 대한 순서에 의해 달라지게 된다.
위의 예시로 다르게 이야기하자면, 사용자 A 와 사용자 B 가 순서대로 접근하게 되면 정상적으로 결과가 반영되지만 동시에 접근하게 되면 잘못된 결과가 나오는 것이다.
❌ 문제가 발생한 코드
- request body
// 기존의 request body
{
gameTeamId:1,
cheerCount:101
}
- Repository 코드
@Modifying
@Query("UPDATE GameTeam t SET t.cheerCount = :cheerCount WHERE t.id = :gameTeamId")
void updateCheerCount(@Param("gameTeamId") Long gameTeamId, @Param("cheerCount") int cheerCount);
- 테스트 코드
@Test
void 동시에_응원_요청을_보낼_경우에도_정상적으로_요청이_반영된다() throws InterruptedException {
// given
ExecutorService executor = Executors.newFixedThreadPool(100);
CountDownLatch latch = new CountDownLatch(100);
long gameTeamId = 3L;
// when
for (int i = 0; i < 100; i++) {
final int j = i;
executor.execute(() -> {
try {
gameTeamService.updateCheerCount(2L, new GameTeamCheerRequestDto(gameTeamId, j + 1));
} finally {
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
// then
assertThat(gameTeamFixtureRepository.findById(gameTeamId))
.map(GameTeam::getCheerCount)
.get()
.isEqualTo(101);
}
동시성 문제가 발생했던 코드는 위와 같았다.
클라이언트 측에서 기존의 횟수에 증가시킬 횟수를 더해서 요청을 보내면, 이를 반영하도록 했었다.
즉, 기존 횟수가 100회이고 증가시킬 횟수가 1이라면 클라이언트 측에서 101을 요청으로 보냈던 것이다.
테스트 결과는 다음과 같다.
기대했던 횟수는 1에서 100번 증가시킨 101 회였다.
그러나, 실제 반영된 데이터는 81회임을 확인할 수 있었다.
👀 어떻게 해결했을까
- request body
// 기존의 request body
{
gameTeamId:1,
cheerCount:101
}
// 수정된 request body
{
gameTeamId:1,
cheerCount:1
}
클라이언트 측에서 직접 기존의 횟수에 새로 증가할 횟수를 더해서 요청을 보냈던 것에서, 증가할 횟수만을 요청으로 보내도록 수정했다.
기존의 request body 에는 기존의 횟수가 100회이고 증가시킬 횟수가 1이라면 101을 보냈지만, 수정된 방식에서의 request body 에는 1만을 보내는 것이다.
@Modifying
@Query("UPDATE GameTeam t SET t.cheerCount = t.cheerCount + :cheerCount WHERE t.id = :gameTeamId")
void updateCheerCount(@Param("gameTeamId") Long gameTeamId, @Param("cheerCount") int cheerCount);
위의 쿼리를 보면 알겠지만, 요청이 들어왔을 때 그 시점에서의 응원횟수에 새로운 cheerCount 를 더한다.
이렇게 수정하니, 언제 쓰레드가 데이터를 읽었는지와는 관계가 없어지므로 즉, race condition 문제가 자연스레 해결되어 동시성 문제를 해결할 수 있었다.
👀 아쉬웠던 점
사실 위의 방식은, 함께 프로젝트에 참여하고 있는 동료분께 추천을 받아 반영했던 방법이었다.
따라서 동시성 문제에 대한 해결방안이 어떤 것들이 더 있을지에 대해 공부하고, 학습하여 추후에 어떻게 개선해보면 좋을지에 대해 고민해보고 싶었다.
📝 동시성 문제 해결 방안
다음 강의를 참고해서 작성했습니다 :)
💥 Synchronized
✔️ synchronized란?
여러 요청이 들어왔을 때 문제가 발생하는 이유는 스레드 간의 동기화가 되지 않기 때문이다.
따라서 synchronized 는 thread-safe 를 위해서 여러개의 스레드가 한개의 자원을 사용하고자 할 때, 현재 데이터를 사용하고 있는 스레드를 제외하고는 데이터에 접근할 수 없도록 막는 것이다.
이는 변수와 함수에 사용 가능하다.
✔️ 사용 예시
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
✔️ 단점
1. @Transactional 어노테이션의 프록시 객체
synchronized 는 스프링부트의 @Transactional 어노테이션과 함께 사용하면 사실상 효력이 없다.
왜냐하면, @Transactional 어노테이션은 AOP 이기 때문에 프록시 객체가 만들어지며 동작하는데 이 때 프록시 객체는 메서드의 시그니처들만 복사해오기 때문에 synchronized 는 누락된다.
2. 하나의 프로세스 안에서만 보장된다.
한 대의 서버 내부에서는 synchornized 키워드가 의미가 있다.
그러나, 서버가 두대 이상인 경우에는 프로세스 내부에서의 접근만을 막는 synchronized 가 의미가 없어지게 된다.
따라서 이는 실무에서 주로 사용되지 않는다.
💥 MySQL 의 Pessimistic Lock (비관적 락)
✔️ Pessimistic Lock 이란?
이는 데이터에 락을 걸어서 정합성을 맞추는 방법이다.
이는 트랜잭션 시작 시에 락을 걸고 시작한다.
다른 트랜잭션에서는 락이 해제되기 이전에 데이터를 가져갈 수 없다.
이와 같이, 트랜잭션 1에서 데이터베이스로 접근하게 되면 다른 트랜잭션은 대기해야 한다.
이렇게 되면 위에서 설명한 Race Condition 을 해결할 수 있다. 락에 의해 동시에 데이터가 공유되지 못하기 때문이다.
✔️ 사용 예시
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
✔️ 주의할 점
데드락을 주의해야 한다.
💥 MySQL 의 Optimistic Lock (낙관적 락)
✔️ Optimistic Lock 이란?
실제로 락을 거는 것이 아니라, 버저닝을 이용해 해결한다.
트랜잭션 1이 커밋할 때의 쿼리는 다음과 같을 것이다.
update set version = version+1, quantity = quantity-10
from stock
where version = 1 and id = 1
트랜잭션 2가 커밋할 때의 쿼리는 다음과 같을 것이다.
update set version = version+1, quantity = quantity-5
from stock
where version = 1 and id = 1
그러나, 앞선 트랜잭션 1의 커밋으로 인해 version 은 1에서 2로 증가했다.
따라서 트랜잭션 2가 업데이트하고자 하는 버전이 1인 동시에 pk 가 1인 데이터는 더 이상 존재하지 않는다.
이 경우에 조회를 다시 하고, 그 이후에 업데이트를 하게 된다.
✔️ 사용 예시
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
private Long version;
// 중략 ..
}
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
@Component
public class OptimisticLockStockFacade {
public void decrease(Long id, Long quantity) throws InterruptedException {
// 업데이트 실패 시의 재시도
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
낙관적 락을 사용하는 경우에는 트랜잭션 2의 경우처럼, 조회했던 당시의 version 과 업데이트하고자 할 때의 version 이 일치하지 않으면 다시 조회가 이뤄지는 로직이 필요하다.
따라서 OptimisticLockStockFacade 에서 감소 로직을 시도하고, 이가 성공하기까지 반복적으로 시도한다.
✔️ 장점
1. 트랜잭션이 커밋되기 전까지는 락을 걸지 않는다.
커밋되는 시점 (업데이트 되는 시점)에만 락이 걸리고 이전까지는 아무런 조치를 취하지 않는 것과 같다.
2. 비관적 락에 비해 성능상의 이점을 지닌다.
비관적 락은 단순히 조회하고자 하는 트랜잭션의 접근마저 막지만, 낙관적 락은 커밋 이전까지는 락을 걸지 않기에 단순 조회하고자 하는 트랜잭션의 접근을 허용한다.
✔️ 단점
1. 실패 시 재시도에 소요되는 시간이 존재한다.
2. 실패 시 재시도 로직을 직접 작성해줘야 한다. (OptimisticLockStockFacade의 decrease)
💥 MySQL 의 Named Lock
✔️ Named Lock 이란?
이름을 가진 락이다.
예를 들어, 특정 데이터의 pk 를 락의 이름으로 지정한다면 해당 pk 를 가진 데이터에 접근하고자 하는 트랜잭션은 해당 pk 를 이름으로 하는 락이 걸려있는지, 걸려있지 않은지 여부를 확인하고 접근할 수 있는 것이다.
여기서 Lock 은 entity 가 아닌 별도의 공간에 저장된다. 해당 엔티티가 특정 작업을 수행하는 동안에만 사용 가능한 락을 별도의 이름이나 식별자로 설정한다.
✔️ 사용 예시
@Component
public class NamedLockStockFacade {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 30000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key, 30000)", nativeQuery = true)
void releaseLock(String key);
}
Named Lock 은 트랜잭션 종료 시에 자동으로 락이 해제되지 않기 때문에 별도의 명령어로 직접 해제해줘야 한다.
💡@Transactional(propagation = Propagation.REQUIRES_NEW)
위의 코드를 살펴보면, @Transactional(propagation = Propagation.REQUIRES_NEW) 가 있는 것이 보인다.
이는 항상 decrease 메서드를 실행 할 때는 새로운 트랜잭션이 실행되도록 하는 것이다.
이는 상위의 (이전의) 트랜잭션과 해당 메서드를 실행할 때의 트랜잭션을 분리하는 것이다. 즉, 하나의 트랜잭션이었던 것이 서로 독립적으로 실행되게 되는 것이다.
왜 다른 락을 구현할 때는 필요하지 않았던 락이 Named Lock 을 구현할 때만 필요할까?
- 비관적 락 : 비관적 락은 데이터를 읽기 시작할 때부터 무조건 락을 거는 방식이기 때문에 트랜잭션을 분리하지 않아도 된다.
- 즉, 동일한 데이터에 동시에 접근하더라도 이미 락이 걸려 있기 때문에 트랜잭션을 분리할 필요가 없는 것이다.
- 낙관적 락 : 낙관적 락은 여러 트랜잭션이 동일한 데이터를 읽고 업데이트를 하더라도, 충돌은 데이터 변경 시점에만 발생한다.
- 낙관적 락은 동시에 여러 트랜잭션이 같은 데이터를 읽는 것을 막지 않는다.
- 충돌은 데이터 변경 시점에만 발생하게 되므로 굳이 트랜잭션을 분리하지 않아도 된다.
- Named Lock : Named Lock 은 전역적으로 락을 관리한다. 따라서 여러 트랜잭션이 하나의 락을 읽고자 하면 문제가 발생할 수 있기에 트랜잭션은 분리되어야 한다.
✔️ 장점
- 타임아웃을 쉽게 구현할 수 있다.
-- 'my_named_lock'이라는 이름의 락을 얻기 위해 최대 10초까지 대기
SELECT GET_LOCK('my_named_lock', 10);
위와 같이 10초를 설정해두면, 락을 10초간 얻지 못하면 타임아웃이 되는 기능이 MySQL 에는 내장되어 있다.
✔️ 단점
- 트랜잭션 종료 시에 락이 해제되지 않기 때문에 별도의 명령어로 해제해줘야 한다.
- 실제로 구현할 시에는 데이터 소스를 분리하는 것이 좋다.
💥 Redis 의 Lettuce
✔️ Redis 의 Lettuce 방식이란?
위와 같이, 쓰레드 1이 key 가 1인 데이터에 대해 lock 을 얻고자 한다면 현재 걸려 있는 lock 이 없기에 이를 획득하게 되고 성공이 반환된다. 쓰레드 2 역시도 key 가 1인 데이터에 대해 lock 을 얻고자 한다면 이미 걸려 있는 lock 으로 인해 실패가 반환된다.
이때, 락 획득에 실패한 쓰레드의 경우에는 spin lock 방식으로 대기한다. 이는 계속 돌고 돌며 락을 대기하다가 획득하는 방식이다.
💡setnx
redis 의 명령어 중 하나로, "SET if Not eXists" 의 약자이다.
이는 주어진 키에 대한 값을 설정하는데, 만약 해당 키가 이미 존재한다면 아무 작업도 수행하지 않지만 키가 존재하지 않는다면 지정된 값으로 키와 값을 설정한다.
redis의 cli 를 이용해 확인해보자!
❯ docker exec -it 043d5ca6952f redis-cli
127.0.0.1:6379> setnx 1 value
(integer) 1
127.0.0.1:6379> setnx 1 value
(integer) 0
127.0.0.1:6379> del 1
(integer) 1
127.0.0.1:6379> setnx 1 value
(integer) 1
127.0.0.1:6379>
- setnx 1 value 를 하니, 기존에 1이 없기에 성공 (1 반환)
- 이후에 setnx 1 value 를 하니, 기존에 1이 있으므로 실패 (0 반환)
- 이후에 del 1 (key 1을 삭제) ⇒ 성공 (1 반환)
- 다시 setnx 1 value 를 하니, 1 이 삭제된 이후에 시도한 것이므로 성공 (1 반환)
✔️ 사용 예시
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@Component
public class RedisLockRepository {
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
// key 는 stock 의 id
// value 는 "lock" 이라는 문자열
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate
.delete(generateKey(key));
}
public String generateKey(Long key) {
return key.toString();
}
}
@Component
public class LettuceLockStockFacade {
public void decrease(Long id, Long quantity) throws InterruptedException {
// 락이 획득할 때까지 계속해서 시도를 하고, 실패하면 쓰레드를 100 밀리세컨동안 잠재운다.
while (!repository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
repository.unlock(id);
}
}
}
앞서 설명했던 것과 같이, Lettuce 방식에서는 spin lock 방식으로 락을 얻기를 대기하기 때문에 이에 대한 로직을 개발자가 직접 작성해줘야 한다.
while 문에서 볼 수 있듯, Lock 을 획득하면 StockService의 decrease 메서드를 실행하고, 해당 lock 을 해제한다.
그러나 Lock 을 획득하지 못하는 경우에는 쓰레드를 100 ms 정도 중지했다가, 계속해서 시도를 한다.
✔️ 장점
- 구현이 간단하다.
- named lock 과 거의 유사하지만, 세션 관리를 신경쓰지 않아도 된다.
- 기본 라이브러리이기 때문에 별도의 라이브러리가 필요하지 않다.
✔️ 단점
- spin lock 방식이기에 쓰레드가 여러개 대기 중이라면 레디스에 부하가 갈 수도 있다.
💥 Redis 의 Redisson
✔️ Redis 의 Redisson 방식이란?
Redisson 방식은 pub-sub 구조이다. 따라서 대기 중인 쓰레드들이 계속해서 락 획득을 시도하지 않아도 된다.
왜냐하면, 획득을 시도했던 락이 해제되었을 때 락 획득을 시도할 수 있도록 메시지를 전달하기 때문이다.
redis-cli 로 살펴보자.
127.0.0.1:6379> subscribe ch1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "ch1"
3) (integer) 1
1) "message"
2) "ch1"
3) "hello"
127.0.0.1:6379> publish ch1 hello
(integer) 1
위와 같이, ch1 를 subscribe 해두고 다른 쪽에서 ch1 에 publish 를 하면 subscribe 를 해둔 쪽에 메시지를 전달한다.
✔️ 사용 예시
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.25.2'
@Component
public class RedissonLockStockFacade {
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
Redisson 방식에서는 락 해제와 획득을 위한 코드를 따로 구현하지 않고, 라이브러리에 내장된 메서드를 이용하면 된다.
반복적으로 락 획득을 요청하기 위한 코드도 필요 없다.
✔️ 장점
- 락 획득 / 재시도를 기본으로 제공한다.
- pub / sub 방식이기 때문에 부하가 덜 간다.
✔️ 단점
- 별도의 라이브러리를 추가해야 한다.
- 락을 라이브러리 차원에서 관리하기 때문에 이에 대한 러닝 커브가 존재한다.
재시도가 필요하지 않으면 lettuce, 필요하지 않으면 redisson 을 주로 이용한다.
📚 현재의 방식과 비교해보자!
@Modifying
@Query("UPDATE GameTeam t SET t.cheerCount = t.cheerCount + :cheerCount WHERE t.id = :gameTeamId")
void updateCheerCount(@Param("gameTeamId") Long gameTeamId, @Param("cheerCount") int cheerCount);
현재는 위와 같이 update 쿼리를 사용하고 있다.
이는, 업데이트 할 때 한번 더 응원 횟수를 조회하고 update 를 할 때는 기본적으로 해당 row 에 락이 걸리게 되기 때문에 동시성 이슈가 발생하지 않는다.
다른 방법들과 비교해보자!
1. update 쿼리 vs 비관적 락
비관적 락을 이용하면 해당 row 전체에 락이 걸리게 된다.
이로 인해 다른 쪽에서 해당 row 를 단순 조회 이외의 수정이나, 락을 걸고 싶은 경우에 대기를 해야 한다.
따라서 비교적 좁게 update 시에만 락이 걸리는 현재의 쿼리가 낫다.
2. update 쿼리 vs 낙관적 락
둘 다 update 쿼리를 이용하므로 사실상 수정하는 시점에 락이 걸리는 것은 같다.
그러나, 낙관적 락의 경우에는 실패 시에 재시도 로직과 버전에 대한 관리가 필요하다.
실패 시에 재시도 로직이 필요하다는 점에서 비교적 락이 자주 걸리지 않을 것으로 기대되는 경우에 낙관적 락을 사용한다.
그러나, 응원 횟수의 경우에는 게임 진행 중에는 늘 락이 걸릴 것으로 예상해야 하기에 현재의 update 쿼리가 낫다.
3. update 쿼리 vs named lock
우선, named lock을 이용하기 위해서는 별도의 리소스가 필요하다.
또한, 이는 락의 이름을 이용해서 특정 조건에 맞게 세밀한 락을 설정할 수 있다.
그러나, 현재는 세밀한 조정이 필요하지도 않을 뿐더러 락의 범위가 커질 경우도 없기에 named lock 을 도입하는 마땅한 이유가 없다.
4. update 쿼리 vs 레디스
현재 응원 횟수의 경우에는 치명적인 비지니스 로직이 아니다.
횟수 몇 번이 누락된다고 해서 사용자의 경험이나, 데이터 상의 영향을 주지 않는다.
단순 엔터테인 요소에 가깝기 때문에 응원 횟수의 동시성 이슈를 위해 새로운 데이터베이스 스택을 도입하는 것은 무리라고 판단된다.
💬 참고 링크
https://seunghyunson.tistory.com/11
https://zzang9ha.tistory.com/443
https://sabarada.tistory.com/175
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 이벤트 기반 아키텍처를 알아보고 스프링부트의 이벤트를 구현해보자 (0) | 2024.03.01 |
---|---|
[SpringBoot] Spring REST Docs 로 API 명세를 문서화 하자 (1) | 2024.01.23 |
[SpringBoot] N+1 을 고려하여 페이징 쿼리 작성하기 (1) | 2024.01.01 |
[SpringBoot] 확장성을 고려하여 OAuth2.0 로 Kakao 소셜 로그인 구현하기 (0) | 2023.09.27 |
[SpringBoot] @ConfigurationProperties 로 프로퍼티들을 바인딩하기 (0) | 2023.09.08 |