본문 바로가기
생각

동시성 제어의 원초적 접근법, 애플리케이션 락은 유효한가요?

by 6161990 2024. 12. 14.

 

거의 모든 서비스는 애플리케이션 당 N 대의 Pod 를 가동합니다. 동시다발적으로 각 Pod 에서 공유자원에 접근할 수 있으므로, race condition 을 고려하기 위해 DB 락,  레디스 락 등이 거론됩니다. 나를 비롯한 대부분의 서버 개발자들이 분산 락 개념들을 인지하고 있어야하는 이유입니다. 근데 문득 이런 생각이 들었습니다. Pod 가 한 대라면 이런 것들을 알아야할까? 애플리케이션 락으로만 컨트롤 할 수 없을까? 단 하나의 Pod 만 운용하는 사례는 극히 드물다 하더라도, 궁금했습니다. 이런 저런 분산 락 도구에 가려 잊혀진 애플리케이션 락, synchronized 에 대해서 다시 한 번 고찰해보는 시간을 가져보았습니다.


synchronizedJava 멀티스레딩 환경에서 동기화를 제공하는 키워드로, 주로 스레드 간의 동시성 문제를 해결하기 위해 사용됩니다. synchronized를 사용하면 여러 스레드가 동시에 특정 코드 블록이나 메서드에 접근하지 못하도록 락(Lock)을 거는 역할을 합니다. 이게 어떻게 가능한 걸까요? 

Java 의 각 객체는 인스턴스를 생성하면서 모니터(monitor) 라고 불리는 lock을 가지게 됩니다. 이 모니터 락은 db락이 수행하는 핵심 역할을 동일하게 담당합니다. 동시다발적인 접근을 차단할 수 있습니다. 한번 직접 해보았습니다.

1000원이 들어있는 계좌에 동시 접근해보는 테스트 입니다. race condition 되는 상황을 만들어보았습니다.

 

 

멀티스레드 환경에서 은행 계좌에서 출금 작업을 수행하며 발생할 수 있는 동시성 문제를 보여주는 예제입니다. thread1thread2가 각각 출금 작업을 시작합니다. 서로 다른 시점에 잔액 조건(balance >= amount)을 확인하고 잔액을 차감합니다. 두 스레드가 동시에 출금 작업을 수행하면, 어느 한 스레드가 잔액이 0 또는 음수가 될 가능성 존재합니다. 찰나의 순간 두 스레드 모두 조건을 통과하여 간발의 차이로 출금을 수행하면서 잔고가 마이너스 되어버리는 현상입니다. 

 


synchronized 를 이용해보겠습니다. 

 


뒤에 수행되는 thread2 가 잔고가 모자라, 출금하지 못하게 됩니다. 그림으로 파악하면 다음과 같습니다. 

 

 


thread1 이 먼저 수행되면서 withdraw() 에 접근합니다. withdraw()synchronized 메소드이므로 락이 필요합니다. 해당 락을 가지고있는 스레드가 존재하지 않으므로 thread1 이 락을 선점합니다. 뒤에 접근하려하는 thread2 는 인스턴스 락을 획득할 수 없으므로 thread1이 락을 반환할 때까지 대기합니다. thread1 이 공유자원을 locked 하여 thread2blocked 된 상황이라고 볼 수 있습니다. 


이렇게만 보면 공유자원에 대한 lock 이 간결해집니다. 그런데 실제 운영 애플리케이션에서 본 적은 드물었습니다. synchronized 를 사용하지 않는 이유를 생각해보았습니다.

우선, Pod 한 대만 띄우는건 위험합니다. 만일의 상황에 Pod 가 죽으면 곧 서비스도 죽게 되는 셈이니까요. 실제 운영 환경에서는 단일 Pod 를 사용하는 경우는 드뭅니다. 많이 또 크게 운용하기 위해 여러 Pod를 띄워놓습니다. 가용성과 확장성의 확보라고 볼 수 있습니다. synchronized 는 단일 JVM 내에서만 유요한 동기화 메커니즘입니다. 3개의 애플리케이션 서버를 띄워놓은 상황이라면 synchronized 메소드에 3개의 창구가 생기는 셈입니다. 단순 셈을 해보면 모니터락도 3개.. 그러면 잔고가 마이너스가 되는 상황은 역시 발생합니다. 

높은 가용성을 확보해야하는 시점이 있을 때, 예를 들면 이벤트같은 도메인에서요. synchronized 를 이용해 구현한다면 병목 구간은 그냥 바로 그 곳입니다. synchronized 는 lock 획득과 해제 프로세스로 동작하기 때문에 추가적인 시간이 필요합니다. 나름 병목 현상을 해결할 수 있긴 합니다. 1차선 도로로 좁혀지는 구간을 짧게 가져가는 방식으로요. 다음과 같습니다.



synchronized 메소드 대신 synchronized block 으로 핸들링해보았습니다. 이게 성능 향상에 큰 도움이 된다 할지라도 병목은 병목입니다. 여기에 한 숟갈 더해보면, deadlock 발생도 높습니다. 만약 스레드가 이상하게 종료되거나, 코드에 문제가 생겨 락을 해제하지 못하는 상황이 발생하면, 다른 스레드는 영원히 대기 상태로 존재하게 됩니다. 

synchronized 는 순서 보장 또한 되지않습니다. 선착순 이벤트를 진행한다면 특히 순서가 중요할 텐데 synchronized 는 이를 보장하지 않습니다. lock 을 대기하는 thread 2 가 가장 대기 시간이 길더라도, 제일 나중에 온 thread 10가 락을 획득하기도 합니다. 만약이런 경우 순서 보장이 필요한 또 다른 해결법을 동행해야합니다. 


저는 이제껏 실제 현실과 맞지않는 조건, 성능, 순서보장 등등의 이유로 synchronized 를사용해보지도, 발견하지도 못했던 겁니다. 이런 문제점과 한계를 java가 인지하고 만든 것이 바로 java.util.concurrent 패키지 입니다. ReentrantLock 을 사용하면 락에 대한 소유시간을 세팅할 수 있더라구요. 그럼 deadLock 에 빠지는 확률도 희소해질수도 있습니다. 아무리 그렇다하더라도 대용량 트래픽에서 다양한 분산 락을 구현하고 의지하는 이유가 있을텐데요. java.util.concurrent 이 해결하지 못하는 것들은 뭘까요? "분산 환경의 특수성" 에 초점을 맞춰 생각해보면, JVM 은 다중 노드 간 lock 관리가 불가능합니다. 네트워크 지연이나 노드 실패도 고려 대상인데, 단일 JVM 으로는 커버하지 못합니다. 자바의 동시성 제어의 원초적 접근법을 살펴본 건데 왠지 redisZooKeeper 의 가치를 알게 된 시간이었습니다.