경쟁조건을 해결하거나 완화하기 위한 다양한 기술들

반응형

여러 요청이 동시에 같은 데이터를 수정하려고 하면, 실행 순서에 따라 결과가 달라질 수 있습니다. 이러한 문제를 경쟁 조건이라고 합니다. 가장 대표적인 해결 방법은  을 사용하는 것입니다. 락을 사용하면 하나의 요청이 공유 자원을 사용하는 동안 다른 요청들은 해당 자원에 접근하지 못하고 대기하게 됩니다. 이후 현재 작업이 모두 끝나고 락이 해제되면 다음 요청이 자원을 사용할 수 있으므로 경쟁 조건을 방지할 수 있습니다. 하지만 락이 항상 좋은 해결책인 것은 아닙니다. 하나의 요청이 작업을 수행하는 동안 다른 요청들은 모두 대기해야 하므로 동시성이 낮아지고 응답 시간이 증가할 수 있습니다. 또한 요청이 많아질수록 대기 시간이 길어져 전체 처리량이 감소하는 문제도 발생할 수 있습니다. 그렇다면 경쟁 조건을 해결하기 위해 락만이 유일한 방법일까요? 락의 단점을 보완하면서도 경쟁 조건을 해결할 수 있는 다른 방법은 없을까요? 이번 글에서는 이러한 해결 방법에 대해 알아보겠습니다.

경쟁조건이 발생한다는건 어떤 의미일까?

경쟁 조건은 여러 스레드나 여러 서버가 동시에 동일한 자원에 접근하는 환경에서 발생할 수 있습니다. 특히 분산 환경에서는 여러 서버가 동일한 데이터를 동시에 수정할 수 있기 때문에, 경쟁 조건을 더욱 주의해야 합니다.

예를 들어 재고가 100개인 상품이 있다고 가정해 보겠습니다. 100개의 스레드가 동시에 재고를 1개씩 차감한다면, 정상적인 결과는 재고가 0개가 되는 것입니다.

하지만 경쟁 조건이 발생하면 여러 스레드가 동일한 재고 값을 동시에 읽고 수정하면서 일부 차감 연산이 반영되지 않을 수 있습니다. 실제로 테스트해 보면 재고가 87개와 같이 예상과 다른 결과가 나오는 경우도 발생합니다.

이러한 현상을 경쟁 조건(Race Condition) 이라고 합니다. 여러 스레드나 서버가 동시에 동일한 자원에 접근하면서 서로의 작업이 충돌하여, 예상했던 결과와 다른 값이 발생하는 것입니다.

이러한 경쟁 조건을 해결하기 위해 가장 널리 사용되는 방법 중 하나인 Lock을 적용해보겠습니다.

하지만 Lock을 사용하면 하나의 요청이 작업을 수행하는 동안 다른 요청들은 대기해야 하므로, 동시성이 낮아지고 성능이 저하될 수 있습니다. 요청 수는 10만건으로 테스트를 진행해보겠습니다. 

10만 건의 요청을 기준으로 테스트한 결과, 전체 처리 시간은 59.97초였으며 처리량은 약 1,667 req/s를 기록했습니다. 또한 평균 응답 시간은 0.600ms로 측정되었습니다.

아직까지는 Lock이 실제로 성능을 얼마나 저하시켰는지는 판단하기 어려웠습니다. 다만 10만 건의 요청을 처리하는 데 약 1분이 소요된 것을 보면, 개선의 여지가 있지 않을까 하는 생각이 들었습니다.

Lua script

Lock만이 경쟁 조건을 해결하는 유일한 방법은 아닙니다. Lock을 사용하지 않고도 경쟁 조건을 해결하거나 완화할 수 있는 방법이 있으며, 그중 하나가 Lua Script입니다. 이번에는 Lock 대신 Lua Script를 적용하여 성능과 동작 방식을 비교해보겠습니다.

다행히 경쟁조건은 해결한듯합니다.

그렇다면, 10만건을 대상으로 진행하면 어떻게 되는지 Lock과 비교를 해봅시다.

소요 시간이 20배 가까이 빨라졌다는 것을 알 수 있습니다.

그렇다면 왜 이렇게 빠를까요?

분산 락은 일반적으로 락 획득 → 비즈니스 로직 수행 → 락 해제 과정을 거칩니다. 이 과정에서 Redis와 여러 번 통신해야 하므로 네트워크 왕복(RTT)이 반복적으로 발생하고, 락을 획득하기 위해 대기하는 시간도 생길 수 있습니다.

반면 Lua Script는 여러 Redis 명령을 하나의 스크립트로 묶어 원자적으로 실행합니다. 따라서 클라이언트는 Redis와 한 번의 네트워크 왕복(RTT) 만 수행하면 되고, 스크립트 내부에서는 다른 요청이 개입하지 않으므로 경쟁 조건도 방지할 수 있습니다.

또한 Redis를 단일 데이터 저장소로 사용하는 경우에는 DB를 별도로 조회하거나 갱신하는 과정이 줄어들기 때문에 성능 향상에 더욱 큰 영향을 줄 수 있습니다.

하지만 그렇다고 해서 Lua Script가 항상 정답은 아닙니다. Redis가 단일 진실 소스(Single Source of Truth) 가 되는 만큼, 데이터의 영속성이나 감사(Audit)가 필요한 서비스라면 DB와의 동기화 전략도 함께 고려해야 합니다. 예를 들어 CDC(Change Data Capture) 나 비동기 이벤트를 이용하여 Redis와 DB의 데이터를 동기화하는 방식을 선택할 수 있습니다.

그렇다면 Lua Script로 변경하는 것이 항상 좋은 선택일까요?

가장 큰 단점은 경쟁 조건을 해결하는 로직이 애플리케이션이 아닌 Lua Script에 작성된다는 점입니다. 따라서 Java 코드만으로는 전체 동작을 파악하기 어렵고, 디버깅이나 테스트도 상대적으로 까다로워질 수 있습니다.

또한 Redis는 단일 스레드 기반으로 Lua Script를 실행하기 때문에, 스크립트의 실행 시간이 길어질수록 다른 요청들도 함께 대기하게 됩니다. 따라서 복잡한 비즈니스 로직을 Lua Script에 작성하기보다는, 짧고 원자적인 작업만 수행하도록 구성하는 것이 좋습니다.

정렬 집합

Lock이나 Lua Script를 사용하지 않고도 경쟁 조건을 해결하거나 완화할 수 있는 방법은 없을까요? 이번에 살펴볼 방법은 정렬 집합(Sorted Set) 을 활용하는 방식입니다.

기본적인 아이디어는 단순합니다. 요청을 일정한 기준으로 정렬하여 관리함으로써 동일한 자원에 대한 충돌을 최소화하는 것입니다. 이를 통해 복잡한 락을 사용하지 않고도 경쟁 조건을 완화할 수 있으며, 특히 시간 순서가 중요한 데이터나 요청을 처리할 때 효과적으로 활용할 수 있습니다.

예상과 달리 정렬 집합만 했을뿐인데도 재고는 0으로 찍혔다는것을 알 수 있었습니다.

엄청난 성능 향상이 있었던 것은 아니지만, 이전 방식보다 준수한 성능을 보여주었습니다. 특히 10만 건의 요청을 기준으로 했을 때도 Lua Script와의 차이는 약 3초 정도에 불과했습니다.

또한 Lua Script와 달리 순수하게 Java 코드만으로 구현할 수 있다는 점도 큰 장점이라고 생각합니다. 별도의 스크립트를 관리할 필요가 없기 때문에 디버깅과 테스트가 비교적 수월하며, 유지보수 측면에서도 부담이 적습니다.

여기서 한 가지 의문이 생깁니다. 단순히 정렬만 했을 뿐인데 어떻게 경쟁 조건을 해결할 수 있었던 걸까요? 정렬 집합은 어떤 원리로 경쟁 조건을 완화하는지 함께 살펴보겠습니다.

정렬 집합은 어떻게 경쟁 조건을 완화할 수 있을까?

엄밀히 말하자면, 정렬 집합만으로 경쟁 조건을 해결할 수는 없습니다. 그럼에도 불구하고 경쟁 조건을 완화하는 데 활용되는 이유는 데이터를 일정한 기준으로 정렬하여 일관성 있게 관리할 수 있기 때문입니다.

예를 들어 시간(timestamp)을 기준으로 정렬한다면, 각 요청은 발생한 시점에 따라 순서가 부여됩니다. 이렇게 되면 요청의 순서를 쉽게 파악할 수 있고, 특정 범위의 데이터를 조회하거나 오래된 데이터를 제거하는 작업도 효율적으로 수행할 수 있습니다.

즉, 정렬 집합은 여러 요청이 하나의 값을 반복해서 수정하는 구조가 아니라, 각 요청을 독립적인 데이터로 관리할 수 있도록 도와주는 자료구조입니다. 이러한 특성 덕분에 데이터 충돌이 발생하기 쉬운 구조를 개선할 수 있으며, 경쟁 조건을 완화하는 데 활용됩니다.

사실, 경쟁 조건을 해결할 수 있었던 것은 정렬 집합이 아니라 Redis의 특성 때문입니다. Redis는 싱글 스레드로 동작합니다. 즉, 하나의 요청을 처리하는 동안 다른 요청은 순차적으로 대기하게 됩니다. 이러한 특성 덕분에 요청이 순차적으로 처리되어 경쟁 조건을 해결할 수 있습니다.

결론

이번 학습을 통해 경쟁 조건은 반드시 Lock만으로 해결해야 하는 문제가 아니라는 것을 알게 되었습니다. 대표적으로 Lua Script정렬 집합(Sorted Set) 을 이용해서도 경쟁 조건을 해결하거나 완화할 수 있었습니다.

이 두 방법의 핵심은 원자성(Atomicity) 입니다. 원자성이란 하나의 작업을 중간에 다른 요청이 개입할 수 없는 하나의 작업 단위로 처리하는 특성을 의미합니다. 예를 들어 추가와 삭제 작업을 하나의 작업 단위로 처리함으로써 경쟁 조건을 방지할 수 있습니다.

성능만 비교한다면 Lua Script가 가장 좋은 결과를 보여주었습니다. 다만 원자성을 보장하는 로직이 Java 코드가 아닌 Lua Script에 작성되기 때문에, 문제가 발생했을 경우 디버깅이나 유지보수가 다소 어려울 수 있습니다. 따라서 Lua Script는 스크립트만으로도 동작을 쉽게 이해할 수 있을 정도의 단순한 로직에 사용하는 것이 적합하다고 생각합니다.

반면 정렬 집합은 Lua Script보다는 성능이 다소 떨어졌지만 충분히 준수한 성능을 보여주었습니다. 또한 Java 코드만으로 구현할 수 있다는 점도 장점이라고 생각합니다. 따라서 구현해야 하는 로직이 다소 복잡하거나 Java 코드 중심으로 관리하고 싶다면 정렬 집합도 충분히 좋은 선택지가 될 수 있다고 생각합니다.

그렇다면 Lock은 사용하지 말아야 할까요? 그렇지는 않습니다. Lock은 경쟁 조건을 가장 확실하게 방지할 수 있는 방법 중 하나입니다. 다만 다른 방식에 비해 성능 저하가 발생할 수 있으므로, 실시간 처리가 중요한 환경에서는 신중하게 사용하는 것이 좋다고 생각합니다. 반대로 배치 작업이나 스케줄링처럼 처리 시간이 다소 길어져도 문제가 없는 환경에서는 Lock이 가장 적합한 선택이 될 수 있다고 생각합니다.

반응형

댓글

Designed by JB FACTORY