
안녕하세요! 빠르게 다음 글로 찾아왔습니다
이번에는 선수 선택 횟수에서 동시성을 고려한 경험을 풀어보고자 합니다
저희 MY BASEBALL ✪ ALL STAR에서는 팀 점수 계산 시
선수 경기 기록뿐만 아니라 유저들의 선택 횟수로 가중치를 부여하고 있는데요
이러한 선택 횟수는 유저가 12명의 선수 선택을 완료하여 경기를 진행하는 시점에 업데이트 되고 있습니다
그러나 이 로직은 다수의 유저가 동시에 동일한 선수 선택을 완료하는 상황에서 동시성 이슈가 발생할 수 있는 구조입니다 예를 들어, 두 개 이상의 트랜잭션이 동일한 선수에 대한 선택 횟수를 동시에 읽고 수정하는 경우, 마지막에 커밋된 값만 반영되어 중간에 수행된 증가 연산이 손실될 수 있습니다 이는 대표적인 Race Condition의 사례로, 선수의 선택 횟수가 실제보다 적게 반영되는 결과를 초래할 수 있습니다!
선수 선택 횟수 업데이트 로직에 있어 동시성 이슈를 방지하기 위해 다양한 전략을 검토하였어요
제가 적절한 락을 위해 낙관적 락, 비관적 락, 원자적 증가 연산, 네임드 락, 분산 락 등의 다양한 락을 학습하였고 어떤 사고를 통해 최종적으로 선정하였는지 지금부터 설명드리겠습니다!
각각은 적용 환경과 목적에 따라 장단점이 뚜렷하게 나뉘기 때문에, 현재 로직의 특성에 맞춰 신중히 판단하고자 하였습니다
비관적 락은 반대로 충돌 가능성이 높다고 보고, 데이터에 접근하는 시점부터 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식입니다
데이터 일관성을 강하게 보장할 수 있다는 장점이 있지만, 락 경합이 발생하면 대기 시간이 길어지고 성능 저하로 이어질 수 있으며, 경우에 따라 데드락이 발생할 위험도 있습니다
무엇보다 현재의 선택 로직은 그렇게까지 strict한 일관성을 요구하는 상황은 아니기 때문에, 굳이 무거운 락을 적용할 필요는 없다고 판단하였습니다
원자적 증가 연산은 SQL에서 UPDATE ... SET count = count + 1 형태로 처리되어 구현이 간단하고, 락 없이도 동시성 문제가 발생하지 않는다는 점에서 성능적으로 매우 뛰어난 방식입니다
단일 필드의 카운팅 로직만을 포함하고, 해당 값이 다른 도메인 로직과 독립적으로 존재할 경우에는 이상적인 선택이 될 수 있습니다
예를 들어 게시글의 조회 수, 특정 버튼의 클릭 수처럼 단순 통계성 데이터에서는 매우 효율적입니다.
하지만 현재의 선수 선택 로직은 단순한 수치 증가 이상의 의미를 담고 있습니다
선택 횟수 증가는 유저가 12명의 선수를 모두 선택하고 팀 구성을 마무리짓는 시점에 발생하며, 이 과정에서 선수 목록과 선택 횟수를 기반으로 TeamRoaster라는 도메인 객체를 생성합니다
다시 말해, 선택 횟수 증가는 다른 도메인 객체 조회 및 비즈니스 로직과 긴밀하게 엮여 있으며, 전체 트랜잭션 안에서 일관된 상태를 유지해야 할 필요가 있습니다
이처럼 수치 증가 결과가 즉시 조회되어 다른 연산에 활용되는 구조에서는 단순한 원자적 증가만으로는 정합성을 완전히 보장하기 어렵습니다 커밋 타이밍, 읽기 일관성 수준, 동시성 충돌 등의 요소로 인해 실제로 증가된 값과 그에 기반한 후속 연산 간에 오차가 생길 가능성이 존재합니다
네임드 락은 DB 내부에서 지정한 이름 기반의 명시적 락을 활용해 동시성을 제어할 수 있는 방법입니다. 트랜잭션 단위로 정교한 락 제어가 가능하다는 점은 장점이지만, 락이 제대로 해제되지 않거나 충돌이 많을 경우 시스템 병목의 원인이 될 수 있고, DB마다 구현과 제약이 상이하여 관리와 유지보수 부담이 존재합니다
분산 락은 Redis나 ZooKeeper 같은 외부 시스템을 통해 인스턴스 간 동기화를 처리하는 방식입니다 대규모 서비스나 마이크로서비스 아키텍처에서 유용하게 활용되며, 수평 확장 구조에서도 안정적인 락 처리를 보장할 수 있다는 점이 강점입니다 하지만 락 시스템 자체에 대한 운영 비용이 존재하고, 현재와 같은 단일 서버에는 과한 구조일 수 있습니다
마지막으로, 낙관적 락은 충돌 가능성이 낮다고 가정하고, 트랜잭션 커밋 시점에만 데이터의 버전을 비교하여 충돌 여부를 확인하는 방식입니다
이 방법은 별도의 DB 락을 점유하지 않기 때문에 동시 요청이 많은 환경에서도 시스템 자원에 부담을 주지 않는다는 점이 가장 큰 장점입니다
특히 JPA의 @Version 기능을 활용하면 비교적 간단하게 구현이 가능하며, 트랜잭션 내 여러 로직과 함께 처리할 수 있어 전체적인 일관성 확보에도 유리합니다
다만, 충돌이 발생할 경우 예외가 발생하고 재시도가 필요하다는 점은 고려해야 합니다
하지만 현재 로직은 유저가 12명의 선수를 모두 선택한 뒤 단 한 번만 카운트를 갱신하기 때문에, 충돌 가능성이 상대적으로 낮다고 판단하였고, 이러한 구조에 낙관적 락은 효율적이고 가벼운 선택지라고 생각했습니다
이러한 여러 고려사항을 종합했을 때,
저는 낙관적 락이 현재 로직 구조와 가장 잘 맞고, 구현과 운영 측면 모두에서 효율적인 해결책이 될 수 있다고 판단하여 이를 적용하게 되었습니다.

저는 낙관적 락을 효과적으로 적용하기 위해, 선수의 선택 횟수 데이터를 기존 선수 테이블과 분리하여 별도의 PlayerChoiceCount 테이블로 관리하는 구조를 설계하였습니다 이렇게 분리함으로써 선수 정보와 선택 횟수라는 서로 다른 관심사를 명확히 구분할 수 있었고, 특히 낙관적 락을 적용할 때 필요한 버전 관리 필드를 이 테이블에 집중시켜 충돌 감지와 처리 로직을 단순화할 수 있었습니다
이 구조는 선수 선택 횟수 업데이트 작업이 선수 정보 조회와 독립적으로 수행될 수 있게 하여 트랜잭션 범위를 좁히는 데도 도움이 되었습니다 즉, PlayerChoiceCount 테이블에만 락이 걸리고 관리되므로, 불필요하게 선수 기본 정보에 락이 걸리는 것을 방지해 시스템 전체의 동시 처리 성능을 향상시킬 수 있었습니다 또한, 분리된 테이블을 통해 선택 횟수 관련 작업만 집중적으로 최적화하거나 확장하는 것도 용이해졌습니다

@Retryable 애노테이션을 사용하여, ObjectOptimisticLockingFailureException 예외 발생 시 최대 10회까지 재시도하도록 설정하였습니다 각 재시도 간에는 100밀리초의 지연을 두어 동시에 발생하는 충돌 상황을 완화하고, 잠시 후 다시 시도할 기회를 확보하도록 하였습니다
이와 같은 재시도 로직을 도입한 이유는 낙관적 락 방식에서 동시성 충돌이 발생할 가능성이 존재하기 때문입니다 낙관적 락은 트랜잭션 커밋 시점에 데이터 버전을 검사하여 충돌을 감지하는데, 다수의 유저가 동시에 같은 선수 선택 횟수를 증가시키려 할 경우, 충돌 예외가 발생할 수 있습니다 이때 재시도 로직이 없으면 해당 트랜잭션은 즉시 실패하게 되어 사용자 경험 저하나 데이터 불일치 문제가 생길 수 있습니다
따라서, 재시도 메커니즘을 통해 충돌이 일시적인 경쟁 상태에서 발생했음을 감안하고 자동으로 다시 시도함으로써, 동시성 문제를 자연스럽게 해결하고 안정적인 서비스 흐름을 유지할 수 있도록 하였습니다
재시도 횟수를 최대 10회로 설정한 것은, 일반적으로 낙관적 락 충돌이 발생했을 때 짧은 재시도 반복 내에서 대부분의 경쟁 상황이 해소된다는 경험적 근거에 기반합니다 너무 적은 재시도는 일시적 충돌 해소 실패로 인한 예외 발생 빈도를 높이고, 너무 많은 재시도는 시스템 자원 낭비 및 지연을 초래할 수 있어 적절한 균형점으로 판단했습니다
또한 재시도 간 지연 시간을 100밀리초로 설정한 이유는, 재시도 직후 즉시 다시 시도할 경우 경쟁이 지속되어 충돌이 반복될 가능성이 높기 때문입니다 100밀리초 정도의 짧은 대기 시간은 다른 트랜잭션이 작업을 완료하고 잠금을 해제할 시간을 주어, 재시도 성공 확률을 높이는 동시에 전체 시스템에 과도한 부하를 주지 않는 선에서 결정된 값입니다

동시성 제어의 정확성을 검증하기 위해 위와 같이 10개의 스레드를 동시에 실행하는 동시성 테스트를 작성하였습니다 각 스레드는 동일한 선수 ID에 대해 선택 횟수를 증가시키는 increasePlayerChoiceCount 메서드를 호출하며, 모든 작업이 완료된 후 최종 선택 횟수가 정확히 10회로 증가했는지를 검증합니다
현실적으로 저희의 서비스에서 동시 접속자 수가 10명이 넘어가지 않음을 예측하고 스레드 수를 10개로 설정하였습니다 당연하게도 모든 접속자가 선수 선택 횟수를 증가시키는 일을 수행하지 않을 것이기에 실제 감당 가능한 동시 접속사 수는 더 클 것입니다
이 테스트를 통해 실제로 낙관적 락 충돌이 발생할 수 있는 환경에서 재시도 로직이 정상 작동하며, 최종 결과가 기대한 대로 정확하게 반영됨을 확인할 수 있었습니다
또한, 테스트 결과를 바탕으로 재시도 횟수와 재시도 간 지연 시간을 조정하였습니다 즉, 너무 짧거나 긴 지연 시간은 충돌 해결 효율과 시스템 응답성에 영향을 미치므로, 테스트에서 관찰된 실행 시간과 충돌 빈도를 참고하여 현재의 10회 재시도와 100밀리초 지연 시간으로 설정하였습니다
저는 같은 선수들을 고르더라도 늘 같은 결과가 나오지 않기를 원했어요!
선택 횟수의 오차로 인해 다양한 결과가 나오는 게
실제 야구 경기를 표방하는 것 같아 더 재미있게 느껴질 것 같기도 하였습니다
저희 시스템은 선택 횟수를 기반으로 팀 점수를 계산하거나 추천에 활용하기 때문에, 시점 간 정합성이 중요한 구조였습니다
따라서 낙관적 락을 통해 충돌을 감지하고, 최신 데이터를 기준으로 다시 시도함으로써 안정적으로 동시성을 제어할 수 있었습니다 👍
다들 이러한 배경을 고려하여 더 재미있게 MY BASEBALL ✪ ALL STAR를 즐겨 주셨으면 좋겠습니다 🏆