장미정원
싱글톤 빈 동시성 문제.. 내가 당할 줄은 몰랐지.. 본문
들어가며
교내 입학지원시스템인 Hello, GSM 서버 개발 중에 겪었던 문제점들 중 하나였던 싱글톤 빈 동시성 문제와 해결과 개선 방안에 대해 소개드리겠습니다.
간단하게 Hello, GSM 서비스에 대해 소개하자면, 광주소프트웨어마이스터고등학교 입학 원서를 제출, 합격자 관리, 합격자 조회 등등 여러 편리한 기능을 제공하는 서비스입니다.
성적 계산식 이슈
릴리즈를 2주 정도 남겨둔 상황에서 상당히 크리티컬한 이슈가 발생했습니다. 입학지원시스템에서 가장 중요하다고 할 수 있는 성적 계산식에서 발생한 문제였습니다. 비즈니스상 성적 환산점 만점은 300점이지만, 무슨 이유에서인지 300점을 초과하는 case가 발생했습니다.
하지만 400줄이 넘어가는 계산식 클래스와 비즈니스 요구사항이 덕지덕지 뭍어있는 코드더비 사이에서 문제를 디버깅하는 것은 쉽지만은 않았습니다.
테스트를 여러 차례 진행해본 결과 특정 상황에서만 발생하며, 이전에 계산된 결과과 다음 요청에 영향을 미친다는 것을 알게 되었습니다.
간단하게 자세한 상황을 설명하자면 졸업자는 3-2 학기 환산점이 계산됩니다. 하지만 졸업예정자는 3-2 학기 점수를 입력 받지 않기 때문에 3-2 학기 환산점이 계산될 수 없습니다. 하지만 졸업자 성적 계산 이후 졸업예정자가 성적 계산을 한다면 이전에 계산된 3-2 학기 환산점수가 총점에 포함되는 것을 확인했습니다.
- 정리하자면 값이 들어있어야 하지 않는 필드가 이전에 채워진 값이 남아있는 문제를 확인했습니다.
이렇게 문제가 발생하는 case를 알게 되니 해당 값을 담고 있는 자료형인 BigDecimal 관련 문제이지 않을까 고민하였지만, 이내 답을 알게 되었습니다.
스프링 빈 스코프
스프링 빈은 스프링 컨테이너에서 생명주기가 관리됩니다. 이러한 스프링 빈은 존재할 수 있는 범위, 생명주기에 대한 스코프가 존재하며 기본적으로 스프링 빈은 싱글톤 스코프로 생성되게 됩니다.
이외에도 스프링 빈은 프로토타입, request, session, application 등 다양한 스코프를 제공합니다.
싱글톤 패턴
싱글톤 패턴이란 특정 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 패턴입니다. 즉 최초 호출 시에만(구현에 따름) 새로운 인스턴스가 생성되며 이후 호출시에는 이미 생성해둔 인스턴스를 재사용합니다.
스프링 빈은 기본적으로 싱글톤 패턴이 적용된 빈 스코프를 사용하며, 스프링 빈은 컨테이서 생성시 인스턴스화 되며 이후에 빈 인스턴스를 주입 받는다면 최소 생성된 빈을 모든 client가 재사용하게 됩니다.
이러한 싱글톤 패턴을 사용할 때는 주의할 점이 있습니다. 바로 싱글톤 클래스 필드의 동시성 문제입니다. 싱글톤 패턴은 모든 client가 동일한 인스턴스 참조하므로 싱글톤 클래스의 필드는 마치 공유자원과 같은 형태가 됩니다.
불변한 필드라면 상관이 없지만 해당 필드를 수정할 수 있다면 여러 client가 의도치 않은 결과를 얻을 수 있습니다.
원인파악
문제의 원인은 싱글톤 빈의 필드 동시성 문제였습니다.
성적 계산 클래스는 @Service 어노테이션으로 빈 등록을 하였고, 따로 설정해주지 않아 기본 스코프인 싱글톤 스코프로 적용되었습니다. 그 이후 계산에 사용될 각 학기 환산점을 필드로 갖고 있습니다.
해당 필드들은 계산 메서드에서 읽기도 하고 수정도 하는 일이 빈번하게 일어나게 됩니다.
문제 상황을 예시 상황으로 설명해보겠습니다. ThreaA가 원서저장 요청을 보냈습니다. 원서 저장 service는 성적 계산 클래스를 DI받아 성적 계산을 진행합니다. 이때 계산된 환산점은 성적 계산 필드에 저장하고 있는 상태입니다. 계산이 끝난 후 원서 저장 요청이 종료되었습니다.
그 이후 ThreadB가 원서저장 요청을 보냈습니다. 똑같이 성적 계산 클래스를 DI 받아 성적 계산을 진행합니다. 하지만 이전 계산시 사용한 필드 상태가 남아있습니다. (score1_1, score1_2, score2_1)
ThreadB는 score2_1 점수를 보내지 않습니다. 하지만 성적 계산 클래스는 싱글톤 스코프이므로 이전에 저장된 필드 값이 남아있어 예상하지 못한 값인 50이 나오게 됩니다. (예상: 40)
- (쉬운 설명을 위해 예제를 단순히 하였습니다.)
해결
원인파악 후 계산 메서드 맨 위에 현재 필드의 값을 초기화 하도록 코드를 추가하여 문제를 해결하였습니다. PullRequest
하지만 이러한 대처는 비슷한 경우의 동시성 문제를 야기할 수 있어 불안전한 형태였습니다.
개선 1
운영을 잘 마치고 난 이후 다른 방법으로 이러한 문제를 개선해볼 수 있을 것이라 생각하였습니다.
ThreadLocal
ThreadLocal는 자바에서 지원하는 Thread Safe한 기술로 멀티 스레딩 환경에서 각각의 스레드의 별도의 저장공간을 할당해주는 기술입니다.
위의 문제 상황과 같은 상황에서 여러 스레드가 성적 계산 빈을 접근한다고 할 때 모든 스레드들이 클래스 필드를 공유하게 됩니다.
이러한 상황에서 동시성 문제가 발생합니다. 동시성 문제는 여러 스레드가 동시에 공유하는 인스턴스의 필드, 공유자원에 접근할 때 발생하게 됩니다.
스레드 로컬은 현재 스레드에서만 접근할 수 있는 저장소입니다. 스레드 로컬은 물건보관소와 유사한 개념입니다. 여러 사람들이 물건보관소에 물건을 맡기고 찾으러 가지만 각자 본인의 물건보관함에서만 물건을 맡기고 찾을 수 있습니다.
스레드 로컬은 내부에서 현재 스레드의 정보와 데이터를 key/value 형태로 저장하고 있기 때문에 현재 스레드에 저장된 정보는 현재 스레드만 접근할 수 있는 것이 보장됩니다.
이러한 ThreadLocal 기술을 도입한다면 각각의 스레드마다 각 학기 성적 환산값을 가질 수 있게 되어 동시성 문제를 완전히 해결할 수 있어 보입니다.
- 여담으로 스프링 프래임워크들의 여러 기능들도 이 ThreadLocal 기능을 사용합니다. ex. TransactionManager, SpringSecurityContext 등등..
적용방법
적용 방법은 간단합니다. 적용하고자 하는 필드 타입에 ThreadLocal<> 타입으로 감싸주면 됩니다.
사용방법
이렇게 적용한 ThreadLocal을 사용하는 것도 간편합니다. get(), set() 메서드로 값 조회, 수정을 할 수 있고 다 사용한 ThreadLocal은 remover() 메서드로 메모리를 해제해주어야합니다.
주의사항
ThreadLocal을 Spring과 같은 Web Application에서 사용할 때 주의사항이 있습니다. 꼭 다 사용한 ThreadLocal은 해제해주어야 하는데요.
tomcat에서는 thread-pool을 사용해서 스레드를 재사용합니다. 그렇기 때문에 만약 메모리를 해제하지 않은 ThreadLocal이 thread-pool에 반환되고, 그 스레드를 할당 받은 요청이 ThreadLocal의 값을 조회한다면 이전 값이 남아있는 동시성 문제가 발생할 가능성이 있습니다.
ThreadLocal 적용
이렇게 ThreadLocal을 적용하여 코드를 개선하게 되었습니다. ThreadLocal을 적용한다면 기존 코드를 변경하기는 해야하지만, 큰 변경 없이 적용할 수 있기 때문에 도입에 큰 어려움은 없었습니다.
개선 2
하지만 스프링 빈 클래스에 상태가 변경하는 필드를 가지고 있는 구조 자체가 본질적인 문제였습니다.
애초부터 이러한 필드를 지역 변수로 두고 DTO 객체를 두어 계산을 진행했다면 동시성 문제는 발생하지 않습니다.
결국 가장 좋은 개선은 해당 필드들은 지역변수로 옮기는 것이지만 구조를 많이 변경해야한다는 단점이 있다고 생각하여 구조를 변경하지 않으면서 동시성 문제를 해결할 수 있는 ThreadLocal을 적용해봤지만 ThreadLocal을 적용할만한 상황은 아니라고 판단하였습니다.
추후에는 해당 필드들을 지역 변수로 두고 DTO 객체를 사용하여 계산을 하도록 구조를 변경하였습니다.
'Back-end' 카테고리의 다른 글
분산락도 락이다. (Redisson, MSA 환경 동시성 제어 테스트) (0) | 2025.02.10 |
---|---|
Spring Webflux, MVC + 성능 테스트 (0) | 2025.01.31 |
[DB] 파티셔닝, 샤딩, 레플리케이션 (0) | 2024.12.16 |
✨ MSA 환경에서 분산 트랜잭션 (2PC, SAGA) (1) | 2024.12.02 |
✨ 스프링에서 트랜잭션을 처리하는 방법 (0) | 2024.09.09 |