본문 바로가기

Programming/Java & Spring 관련 내용 정리

[Spring] Thread-safe 에 대해서 고찰해 보다.

 

개발 실장님께서 아래 코드에 버그가 있는데 무엇인지 알겠냐고 물어보셨다.

(회사 코드를 그대로 쓸 수 없어 클래스명, 변수명을 변경했다.)

 

 


🔍 이 코드에서 버그를 찾아보자

 

public class Service { // ⚠️ 멤버 변수로 선언되어 있다 private LoginService loginService; public Response request(RequestDto requestDto) { Response response = new Response(); for (LoginProvider provider : providerMap.keySet()) { try { // (1) 컨테이너에서 빈 조회 loginService = (LoginService) ApplicationContextUtil.getApplicationContext() .getBean(provider.getName() + "LoginService"); // (2) 조회한 빈의 init() 실행 loginService.init(provider, providerMap.get(provider)); } catch (NoSuchBeanDefinitionException e) { log.info("loginService {}"); } } return response; } }

 

NaverLoginService, KakaoLoginService, GoogleLoginService 등

"소셜 로그인 제공자 + LoginService" 형식으로 네이밍된 싱글톤 빈들이 존재한다.

 

@Service("NaverLoginService") public class NaverLoginService extends LoginService { public void init(LoginProvider group, ...) { ... } } @Service("KakaoLoginService") public class KakaoLoginService extends LoginService { public void init(LoginProvider group, ...) { ... } }

 

 


💥 버그: 요청마다 바뀌는 값을 싱글톤 빈의 필드에 저장했다

 

loginService멤버 변수로 선언되어 있다.

이 Service 클래스는 싱글톤 빈이므로, 모든 요청 스레드가 같은 인스턴스를 공유한다.

 

스레드 1
loginService = NaverLoginService → 네이버 로그인 준비
스레드 2
loginService = KakaoLoginService로 덮어씌워짐 😱
스레드 1
init() 호출 → 네이버 로그인하려 했는데 카카오 로그인이 실행됨

 

⚠️ 핵심: 요청마다 달라지는 값(loginService)을 싱글톤 빈의 필드에 저장했다.
여러 스레드가 같은 필드를 동시에 읽고 쓰면서 값이 덮어씌워진다.

 

 


✅ 1차 해결: 요청별 참조는 지역 변수로 둔다

 

public Response request(RequestDto requestDto) { Response response = new Response(); for (LoginProvider provider : providerMap.keySet()) { try { // ✅ 지역 변수로 선언 — 각 스레드의 스택에 저장됨 LoginService loginService = (LoginService) ApplicationContextUtil.getApplicationContext() .getBean(provider.getName() + "LoginService"); loginService.init(provider, providerMap.get(provider)); } catch (NoSuchBeanDefinitionException e) { log.info("loginService {}"); } } return response; }

 

지역 변수의 참조는 각 스레드의 스택 프레임에 저장된다.

참조하는 객체는 힙에 생성되지만, 그 참조가 다른 스레드에 공유되지 않으면 요청별로 안전하게 사용할 수 있다.

 

⚠️ 주의: 이것만으로 충분하지 않을 수 있다.

loginService를 지역 변수로 바꿔도,
getBean()이 반환한 LoginService 자체가 싱글톤이라면
init()이 내부 필드를 변경하는 구조일 경우 같은 문제가 다시 발생한다.

예를 들어 NaverLoginService 안에 this.userInfo = userInfo; 같은 코드가 있으면,
여러 스레드가 같은 NaverLoginService 인스턴스의 필드를 동시에 바꾸게 된다.

이런 경우 init 파라미터를 이후 메서드에 명시적으로 전달하거나,
요청별 상태 객체를 따로 만들어 처리해야 한다.

 

 


📐 왜 이런 일이 생기나 — 스택과 힙

 

 

지역 변수 (스택) 멤버 변수 (힙)
저장 위치 각 스레드의 스택 힙 (인스턴스와 함께)
공유 여부 참조가 스레드별 독립 같은 인스턴스를 쓰는 스레드들이 공유
Thread-safe 안전 값이 바뀌면 위험

 

 


🤔 왜 Thread-safe 하다고 착각했을까?

 

첫째, 멀티스레드 환경을 많이 겪어보지 않아서

막연히 "스프링이 알아서 해주겠지"라고 생각했다.

 

둘째, 대부분의 스프링 코드가 이미 Thread-safe한 패턴으로 작성되어 있었기 때문이다.

Repository, 다른 Service를 final 필드로 주입받는 패턴은 상태가 바뀌지 않으므로 안전하다.

그래서 "빈은 원래 안전하다"고 착각하기 쉽다.

 

 


📝 정리

 

스프링 싱글톤 빈은 여러 스레드가 동시에 사용할 수 있다.

스프링이 자동으로 Thread-safe하게 만들어주는 것은 아니다.

 

✅ 안전한 멤버 변수
private final UserRepository userRepository;
private final PaymentClient paymentClient;
private final SomePolicy policy;
불변 의존성은 값이 바뀌지 않으므로 안전
❌ 위험한 멤버 변수
private RequestDto currentRequest;
private LoginService loginService;
private String userId;
요청마다 바뀌는 mutable 상태는 위험

 

💡 한 줄 요약: 멤버 변수 자체가 문제가 아니라,
요청마다 값이 바뀌는 mutable state를 싱글톤 빈의 필드에 두는 것이 문제다.

의존성은 final 필드로 주입해도 괜찮다.
요청별 데이터는 지역 변수메서드 파라미터로 전달해야 한다.

 

이 글이 싱글톤 빈에 mutable state를 두면 안 되는 이유라면,

아래 글은 @Scope(PROTOTYPE)을 붙였는데 왜 같은 객체가 공유됐는지를 다룬다.

함께 보면 스프링 빈 스코프와 Thread-safe 문제를 같이 이해할 수 있다.