📌 개요
이번 포스팅에서는 다양한 Lock 기법을 활용하여 동시성을 제어하는 방법들에 대해 다뤄보고자 합니다.
순서는 다음과 같습니다.
1. Java (Server) 수준에서 동시성을 제어하는 방법
2. Database 수준에서 동시성을 제어하는 방법
3. Redis 로 동시성을 제어하는 방법
❓ Race Condition 이란
동시성을 제어하기 위한 이유로는 Race Condition을 방지하기 위함임을 먼저 인지 해야 합니다.
Race Condition이란 2개 이상의 스레드가 하나의 공유 자원을 가져갈 수 있고, 동시에 변경을 하려 할 때 발생하는 문제입니다. 예를들어, 100개라는 자원이 존재하고 두 개의 스레드가 하나씩 가져가는 상황이 된다면 총 98 이라는 결과를 기대하지만, 100 일 때의 자원을 동시에 가져가고 둘 다 99로 갱신시켜서 하나의 스레드의 작업이 덮어 씌워 지는 문제로 예시를 들 수 있습니다.
🔍 대응책
이를 해결하려면, 하나의 스레드가 변경 작업이 이루어질 때 다른 스레드가 접근하지 못하도록 막아야합니다
📌 JAVA(서버) 수준에서의 첫 번째 해결 방법 : Synchronized
먼저, Database 까지 가지 않고 서버 수준에서의 제어가 가능합니다.
아래와 같이 100개의 상품이 있고 하나씩 재고를 감소시키고자 할 때 , synchronized를 메서드단에 달아주는 것 만으로 해결이 가능합니다.
적용 전
public void decrease(Long id, Long quantity){ //감소시킬 상품과 양
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고를 감소시킨 뒤
stock.decrease(quantity);
// 갱신된 값 저장
stockRepository.saveAndFlush(stock);
}
적용 후
public synchronized void decrease(Long id, Long quantity){ //감소시킬 상품과 양
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고를 감소시킨 뒤
stock.decrease(quantity);
// 갱신된 값 저장
stockRepository.saveAndFlush(stock);
}
synchronized 한계
서버 단에서 제어가 마무리 되면 좋겠지만, 아쉽게도 Database수준의 제어가 아닌 해당 방법으로는 한계가 존재합니다.
1) @Transactional 어노테이션과 호환 불가
- 메서드단에 synchronized를 걸었을 때, @Transactional 과 함께 있으면, 트랜잭션이 끝나기전에 새로운 공유자원 접근이 가능해진다.
2) 다중서버에서의 한계
- synchronized는 하나의 프로세스에서만 적용됨. 다중 서버일 경우, DB에 동시 접근이 가능하기 때문에 아무 소용이 없게 된다.
따라서 synchronized는 DB의 부하를 줄이는 용도나, 1차적인 수준의 제어 방법으로 활용할 뿐 실질적인 해결 방법으로는 Database 수준까지 넘어가야 합니다.
🔥 Database 수준에서의 첫 번째 해결 방법 : Pessimistic Lock (비관적 락)
먼저 Pessimistic Lock 방법이며 실제로 데이터에 Lock 을 걸어서 정합성을 맞추는 방법입니다.
exclusive lock 을 걸게되며 다른 트랜잭션에서는 lock 이 해제되기전에 데이터를 가져갈 수 없게됩니다. 이는 데드락이 걸릴 수 있기때문에 주의하여 사용하여야 합니다.
✅ 장점
- 만일, 충돌이 빈번하게 일어난다면 Pessimistic Lock 을 거는것이 Optimistic Lock을 거는 것 보다 더욱 성능이 좋다.
- Lock을 통해 Update를 제어하기 때문에, 데이터 정합성이 보장된다.
❌ 단점
- 결국 별도의 Lock을 잡기때문에 성능이 저하될 수 있다.
🔥 Database 수준에서의 두 번째 해결 방법 : Optimistic Lock ( 낙관적 락 )
다음으로는 실제로 Lock 을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다.
먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 합니다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은후에 작업을 수행해야 합니다.
별도의 재실행 로직이 추가로 필요합니다.
✅ 장점
- 별도의 Lock을 잡지 않기 때문에, 동시 작업이 가능하여 Pessimistic Lock 보다 성능상의 이점이있다.
❌ 단점
- Update가 실패 했을 때 재실행하는 로직을 개발자가 직접 정의해야하는 번거로움이 있다.
- 충돌이 빈번하게 일어난다면 오히려 재실행으로 인해 성능이 더 안좋다.
🔥 Database 수준에서의 세 번째 해결 방법 : Named Lock
마지막으로는 이름을 가진 metadata locking 방법 입니다.
이름을 가진 lock 을 획득한 후 해제할때까지 다른 세션은 이 lock 을 획득할 수 없도록 합니다.
주의할점으로는 transaction 이 종료될 때 lock 이 자동으로 해제되지 않습니다. 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제됩니다.
NamedLock 은 주로, 분산락을 구현할 때 사용합니다. 또한 Pessimistic Lock 은 Time out 을 구현하기 힘들지만, Named Lock은 MySQL 기준 `getLock`을 통해 Lock을 획득하고, `release`를 통해 쉽게 해제가 가능합니다.
여기서, Pessimistic Lock은 Stock이라는 행에 직접 Lock을 걸어줬다면, Named Lock은 별도의 공간에서 Lock을 걸어준다.
✅ 장점
- Pessimistic Lock은 Time out을 구현하기 힘들지만, NamedLock은 time out 을 구현하기 쉽다.
- 데이터 삽입시에 데이터 정합성을 맞춰야하는 경우에도 NamedLock을 사용할 수 있다.
❌ 단점
- Transaction 종료시에 Lock해제, Session 관리등을 잘 해줘야하기 때문에 주의해서 사용해야한다.
- 실제로 사용할 때에는 구현 방법이 복잡하다.
🔍 실무에서는?
- 실무에서는 데이터 소스를 따로 분리해서 사용하는 것을 추천합니다.
- 만일, 같은 데이터 소스를 사용하게 된다면, Connection pool이 부족해지는 현상으로 인해서 다른 서비스에도 영향을 끼칠 수 있습니다.
마지막으로는 Database의 부하를 줄이기 위해, Redis로 Lock을 구현하는 방법입니다.
Lettuce 방식과 Redisson 방식 2가지가 존재하는데, 먼저 Lettuce 방식부터 말씀드리겠습니다.
🔨 Redis 를 활용한 첫 번째 해결 방법 : Lettuce ( setnx - 스핀락 )
- setnx 명령어를 활용하여 분산락 구현이 가능합니다.
- Set if not exist의 약자로 key-value 가 없을 때 사용합니다
- spin lock 방식입니다.
- 실패 했을 때 reTry 로직을 개발자가 직접 구현 해야합니다.
Setnx 를 통해 키 설정
- key가 없으면 삽입 성공
- key가 있으면 삽입 실패
=> Named Lock 과 방식 동일 (다른점은 redis를 통해 관리, Session을 신경 안써도 됨)
✅ 장점
- 구현이 간단하다
- spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 된다.
❌ 단점
- spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있다.
🔨 Redis 를 활용한 두 번째 해결 방법 : Redisson ( pub - sub 기반)
Redisson은 Redis의 외부 라이브러리로, pub-sub 기반으로 Lock 구현이 가능합니다.
해당 방법을 소개하자면, redis는 자신이 lock을 해제할 때 원하는 채널에 메시지를 보내줌으로서 lock을 해제했다는 표시가 가능합니다. 그러면, 구독하고 있는 채널에 메시지가 도착하게 되면 다른 스레드들은 해당 메시지를 기반으로 lock 획득이 가능힌 방식입니다.
✅ 장점
- Lettuce는 지속적으로 lock을 확인하는 반면에, redisson은 메시지를 받은 한 번(몇 번)만 확인하면 된다.
- redis의 부하를 줄일 수 있다.
- 락 획득 재시도를 기본으로 제공한다.
❌ 단점
- lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 한다. (별도의 라이브러리를 사용해야 한다.)
- 구현이 조금 복잡하다.
🔍 실무에서는 ?
- 재시도가 필요하지 않은 lock 은 lettuce 활용합니다.
- 재시도가 필요한 경우에는 redisson 를 활용합니다
📌 결론 : Database 수준의 제어 VS Redis를 활용한 제어?
- Database
- 이미 RDBMS를 사용하고 있다면 별도의 비용없이 사용가능하다.
- 어느정도의 트래픽까지는 문제없이 활용이 가능하다.
- Redis보다는 성능이 좋지 않다.
- Redis
- 활용중인 Redis가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
- RDBMS 보다 성능이 좋다.
💪 마무리
이번 포스팅에서는 다양한 Lock 기법을 통한 동시성 제어를 간단히 알아보았습니다.
실무에선 다양한 방법들이 상호작용하며 동시성 제어가 이루어진다고 하는데요. 그만큼 공유되는 자원에 대해서 엄격하면서도, 성능을 위한 유동성이 공존하는 것 같습니다.
이상 해당 포스팅을 읽어주셔서 감사합니다. 🙇♀️
📃 참고자료
'[Spring]' 카테고리의 다른 글
Spring - @Controller 와 @RestController 차이 (0) | 2025.03.01 |
---|---|
Spring Boot JPA : @Entity를 사용할 때 @NoArgsConstructor를 하지 않으면 에러가 나는 이유 (6) | 2024.10.20 |
Bean을 등록하는 2가지 방법 (@Component / @Bean + @Configuration) (0) | 2024.08.04 |
Spring Security로 로그인 구현해보기 (0) | 2024.07.25 |
Lombok 롬복의 @Builder, @NoArgsConstructor 와의 충돌 이유 (0) | 2024.07.08 |