동시성 이슈가 발생하는 이유와 해결방법(내부자원, 외부자원)
동시성 이슈는 백엔드 개발자라면 알아야 하는 중요한 개념 중 하나입니다.
동시성 이슈를 해결하는 방법은 무수히 많기 때문에 이를 정리하고자 해당 글을 작성하게 되었습니다.
동시성 이슈란?
동시성 이슈란 하나의 자원에 대해서 여러 스레드가 동시에 접근하여 수정하는 경우 발생하는 문제입니다.
이는 곧 스레드의 안정성이 깨졌다고 말할 수 있습니다.
동시성 이슈가 발생하는 예시
조회수 증가 프로그램을 통해 동시성 이슈가 발생하는 상황을 확인해 보겠습니다.
해당 코드를 실행시켰을 때 100*100인 10000 조회수 값이 나오기를 기대하지만 기대와는 다른 결과 값인 9800이 나오게 됩니다.
이는 동시성 이슈가 발생했기 때문에 기대와 다른 결과 값이 나오는 것입니다.
🤔 그럼 동시성 이슈는 왜 발생하는 것일까요?
Context Switching
위에서 테스트해 봤던 조회 수 증가 프로그램의 increaseViews() 메서드는 views++ 동작을 진행하는데, 이 동작은 3가지의 과정을 거치게 됩니다.
- 데이터를 조회하는 LOAD
- 데이터를 증가하는 INC
- 데이터를 저장하는 STORE
만약 스레드 1에서 데이터를 저장하기 전 Context Switching이 일어나 스레드 2에서 LOAD, INC, STORE의 작업 마친 후 스레드 1에서 남은 작업인 STORE를 끝내는 경우, views 변수에 2가 저장될 것을 기대했지만 1이 저장되었습니다.
이처럼 하나의 공유자원에 대해 다수의 스레드가 공유할 때 나타나는 동시성 문제를 Race Condition이라고 합니다.
💡 Race Condition이란?
두 개 이상의 프로세스나 스레드가 공유자원에 동시 접근해 값을 변경하는 경우, 접근 순서에 따라 실행 결과가 달라지는 현상
🤔 그럼 동시성 이슈를 해결하는 방법에는 무엇이 있을까요?
Race Condition이 발생한 원인을 보면 스레드가 increaseViews() 메서드를 수행하고 있는 도중에 다른 스레드에게 제어권이 넘어갔기 때문에 문제가 발생하는 것입니다. 따라서 이러한 동시성 이슈 해결 방법을 내부자원과 외부자원의 각 경우를 나누어 알아보겠습니다.
1️⃣ 코드 레벨 동시성 이슈 해결(메모리 안에서 제어)
✔️ Synchronized
자바는 synchronized키워드를 사용하여 해당 메서드를 임계영역으로 설정할 수 있습니다.
임계영역이란 공유 데이터를 사용하는 코드 영역을 의미하며, lock을 획득한 단 하나의 스레드만 임계영역에 진입할 수 있습니다.
lock은 하나의 스레드가 특정 작업을 끝내기 전까지 다른 스레드에 영향을 받지 않도록 도와주는 장치라고 할 수 있습니다.
예시는 아래와 같습니다.
public class Stock {
private int views; // 조회수
public synchronized void increaseViews() {
this.views++;
}
public int getViews() {
return this.views;
}
}
✔️ Concurrent 패키지
synchronized의 임계영역은 프로그램의 성능을 좌우하기 때문에 최소화해야 합니다. 따라서 Java 5부터 synchronized를 사용하지 않고 동기화를 할 수 있는 java.util.concurrent 패키지가 추가되었습니다. 그중 Atomic 클래스와 ConcurrentHashMap을 확인해 보겠습니다.
Atomic 클래스
public class Stock {
private AtomicInteger views; // 조회수
public void increaseViews() {
this.views.getAndAdd(+1);
}
public int getViews() {
return this.views.get();
}
}
views 변수를 AtomicInteger로 변환하여 적용했을 때, increaseViews() 메서드도 AtomicInteger 타입의 getAndAdd() 메서드를 적용해야 합니다.
[🤔 그럼 동시성 이슈를 해결하는 방법에는 무엇이 있을까요?] ➡️ 위의 예시에서는 조회와 쓰기 작업 중 다른 스레드가 진입할 수 있었습니다. 하지만 getAndAdd() 메서드는 조회와 쓰기가 하나의 동작으로 이루어져 다른 스레드가 진입할 수 없게 됩니다. 이는 곧 원자성을 보장한다고 할 수 있습니다.
Atomic변수는 CAS(Compare and swap) 알고리즘을 사용하여 멀티 스레드 환경에서 원자성을 보장합니다.
CAS 알고리즘은 값을 변경하는 시점에 스레드가 알고 있는 Stock.views 값과 메모리 상의 Stock.views 값을 비교하여 일치하면 동작을 그대로 진행시키고, 다르다면 동작을 재수 행하는 알고리즘입니다.
ConcurrentHashMap
- ConcurrentHashMap의 get() 메서드
- ConcurrentHashMap의 put() 메서드
get() 메서드는 synchronized가 존재하지 않는 반면, put() 메서드에서는 부분적으로 synchronized가 존재합니다.
이처럼 ConcurrentHashMap은 모든 버킷에 락을 거는 것이 아닌 접근하는 버킷에 대해서만 락을 거는 Lock Striping 기법을 사용합니다.
✔️ 불변객체(Immutable Object)
불변객체는 String, Integer와 같이 객체 생성 후 해당 객체의 내부상태가 변하지 않는 객체를 의미합니다.
불변객체의 기본 규칙은 final을 사용하고 setter를 사용하지 않는 것으로 lock을 걸 필요가 없습니다.
위에서 확인해 본 코드 레벨에서의 해결 방법들은 단일 서버의 동시성 이슈만 해결할 수 있기 때문에 다중 서버에서는 해결할 수 없다는 문제가 있습니다.
따라서 이번에는 다중 서버에서의 동시성 이슈 해결 방법을 확인해 보겠습니다.
2️⃣ 다중 서버에서의 동시성 이슈 해결(외부자원 제어)
분산락
분산락은 멀티스레드 환경에서 공유 자원에 접근하는 경우 데이터의 정합성을 지키기 위해 사용하는 기술입니다.
✔️ 분산락 구현 방법(DBMS)
- 낙관적락(Optimistic Lock)
- 트랜잭션 대부분은 충돌하지 않을 것이라고 낙관적으로 가정하는 방법입니다.
- 실제 Lock을 사용하지 않고, 버전을 통해 데이터의 정합성을 맞춥니다. (= 데이터를 읽을 때 Lock을 사용하지 않고, update 시 내가 읽은 버전이 맞는지 충돌 여부를 확인하여 처리합니다.)
- 비관적락(Pessimistic Lock)
- 트랜잭션 충돌이 발생할 것이라고 비관적으로 가정하는 방법입니다.
- 실제 데이터에 Lock을 걸어서 데이터의 정합성을 맞춥니다.
- 데드락의 위험성이 존재합니다.
- Named Lock(MySQL USER-LEVEL LOCK)
- GET_LOCK(str, timeout)
- 입력받은 이름(str)으로 timeout 초 동안 잠금 획득을 시도하며, timeout에 음수를 입력하면 잠금을 획득할 때까지 무한대로 대기하게 됩니다.
- 한 session에서 잠금을 유지하고 있는 동안에는 다른 session에서 동일한 이름의 잠금을 획득할 수 없습니다.
- GET_LOCK()의 결과 값은 1(잠금을 획득하는 데 성공했을 때), 0(timeout 초 동안 잠금 획득에 실패했을 때), null(잠금획득 중 에러가 발생했을 때, ex) Out Of Memory, 현재 스레드가 강제로 종료 됐을 때)을 반환합니다.
- IS_FREE_LOCK(str)
- 입력한 이름(str)에 해당하는 잠금이 획득 가능한지 확인합니다.
- 결과 값으로 1(입력한 이름의 잠금이 없을 때), 0(입력한 이름의 잠금이 있을 때), null(에러발생 시, ex) 잘못된 인자)을 반환합니다.
- IS_USED_LOCK(str)
- 입력한 이름(str)의 잠금이 사용 중인지 확인합니다.
- 입력받은 이름의 잠금이 존재하면 connection id를 반환하고, 없으면 null null을 반환합니다.
- RELEASE_ALL_LOCKS()
- 현재 세션에서 유지되고 있는 모든 잠금을 해제하고 해제한 잠금 개수를 반환합니다.
- RELEASE_LOCK(str)
- 입력받은 이름(str)의 잠금을 해제합니다.
- 결괏값으로 1(잠금을 성공적으로 해제했을 때), 0(잠금이 해제되지는 않았지만, 현재 스레드에서 획득한 잠금이 아닌 경우), null(잠금이 존재하지 않을 때)을 반환합니다.
이는 일시적인 락에 대한 정보가 테이블에 따로 저장되어 무거워질 수 있다는 단점이 존재합니다.
+) 레디스를 통해 분산락을 구현했을 때는 락에 대한 정보는 휘발성이 있고 메모리에서 락을 획득하고 해제하기 때문에 상대적으로 가볍습니다.
✔️ 분산락 구현 방법(Redis)
- Redis
- Lettuce
- Setnx 명령어를 활용하여 분산락을 구현합니다.
- Setnx: Set if not Exist의 줄임말로 레디스에 특정 key 값이 존재하지 않을 경우 세팅하는 명령어
- Spin Lock 방식을 사용하여, retry 로직을 개발자가 직접 작성해야 합니다.
- Spin Lock: Lock을 획득하려는 스레드가 Lock을 획득할 수 있는지 확인하면서 반복적으로 시도하는 방법
요청이 많을수록 레디스에 많은 부하를 가하게 됩니다.
- Spin Lock: Lock을 획득하려는 스레드가 Lock을 획득할 수 있는지 확인하면서 반복적으로 시도하는 방법
- spring -data-redis 의존성 추가 시 기본적으로 Lettuce 기반의 Redis Client가 제공되므로 별도의 라이브러리를 사용하지 않아도 됩니다.
- Lock의 타임아웃이 지정되어있지 않습니다.
- Setnx 명령어를 활용하여 분산락을 구현합니다.
- Redisson
- pub-sub 기반의 Lock 구현을 제공합니다.
- pub-sub 방식: 채널을 하나 만들고 lock을 점유 중인 스레드가 다음 Lock을 점유하려는 스레드에게 점유가 끝났음을 알려주면서 Lock을 주고받는 방식
- pub-sub 방식: 채널을 하나 만들고 lock을 점유 중인 스레드가 다음 Lock을 점유하려는 스레드에게 점유가 끝났음을 알려주면서 Lock을 주고받는 방식
- Spin Lock 방식을 사용하지 않으므로 별도의 Retry 로직을 작성하지 않아도 됩니다.
- Reddison에 대한 추가적인 의존성을 설정해주어야 합니다.
- Lock의 타임아웃이 구현되어 있습니다.
- pub-sub 기반의 Lock 구현을 제공합니다.
- Lettuce
// RedissonLock의 tryLock 메소드 시그니쳐
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
- waitTime: 락 획득을 대기할 타임아웃
- leaseTime: 락이 만료되는 시간
Lettuce와 Redission을 둘 다 사용하여 동시성 이슈를 해결하기도 합니다. (재시도가 필요하지 않은 Lock은 Lettuce를 사용, 재시도가 필요한 Lock은 Redission을 사용)
결론
동시성 이슈를 해결하기 위해서는 아래 3가지 중 하나라도 없애면 됩니다.
1. 동일한 공유 자원
2. 두 가지 이상의 스레드가 동시에 접근
3. 상태를 변경하는 행위
참고
https://github.com/redisson/redisson
GitHub - redisson/redisson: Redisson - Easy Redis Java client and Real-Time Data Platform. Valkey compatible. Sync/Async/RxJava/
Redisson - Easy Redis Java client and Real-Time Data Platform. Valkey compatible. Sync/Async/RxJava/Reactive API. Over 50 Redis based Java objects and services: Set, Multimap, SortedSet, Map, List,...
github.com
https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html
MySQL :: MySQL 8.4 Reference Manual :: 14.14 Locking Functions
This section describes functions used to manipulate user-level locks. Table 14.19 Locking Functions GET_LOCK(str,timeout) Tries to obtain a lock with a name given by the string str, using a timeout of timeout seconds. A negative timeout value means infin
dev.mysql.com
https://cookie-dev.tistory.com/29
동시성 이슈 해결 1탄 - 코드 레벨 동시성 이슈 (Synchronized, Atomic)
재고 처리 기능을 개발하는 과정에서 동시성 이슈가 발생하여 원인과 해결 방법에 대해 정리해 봤습니다. 내용은 "요구사항 구현" - "동시성 이슈 재현" - "원인 분석" - "해결 방법" 순서로 구성했
cookie-dev.tistory.com
https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html
레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현
레디스를 활용한 분산 락에 대해 알아봅니다. 그리고 성능을 높이고 일관성을 보장하는 방법에 대해 알아봅니다.
hyperconnect.github.io