본문 바로가기
Dev/Spring

Multi-Thread 환경에서 Spring Bean의 Thread Safe 문제 해결

by 돈코츠라멘 2019. 9. 2.

증상

Netty Server로 받아온 전문을 파싱하는 과정에서 시작점이 자꾸 바뀌어 정상적으로 파싱이 되지 않고 Exception이 발생한다. Single Thread로 설정하여 테스트하면 이러한 현상이 발생하지 않으며 Multi Thread 환경에서 테스트하면 함께 진행되는 다른 Thread의 값과 묘하게 연관 있게 바뀐다.

로그

  • nioEventLoopGroup7-7 & nioEventLoopGroup-7-8
[nioEventLoopGroup-7-7] Parse item key = MESSAGE_1, type=String, from=220, len=2, value=ne
[nioEventLoopGroup-7-8] Parse item key = message_1, type=Long, from=0, len=9, value=281
[nioEventLoopGroup-7-7] Parse item key = MESSAGE_2, type=String, from=2, len=2, value=00
[nioEventLoopGroup-7-8] Parse item key = message_2, type=String, from=11, len=36, value=09308T0007220161018163700015002002SK
[nioEventLoopGroup-7-7] Parse item key = MESSAGE_3, type=String, from=13, len=2, value=30
[nioEventLoopGroup-7-8] Parse item key = message_3, type=String, from=49, len=1, value=T
[nioEventLoopGroup-7-7] Parse item key = MESSAGE_4, type=String, from=51, len=3, value=000
[nioEventLoopGroup-7-8] Parse item key = message_4, type=String, from=52, len=10, value=0001kDJw60
  • nioEventLoopGroup-7-7
Parse item key = MESSAGE_1, type=String, from=220, len=2, value=ne
Parse item key = MESSAGE_2, type=String, from=2, len=2, value=00
Parse item key = MESSAGE_3, type=String, from=13, len=2, value=30
Parse item key = MESSAGE_4, type=String, from=51, len=3, value=000
  • nioEventLoopGroup-7-8
Parse item key = message_1, type=Long, from=0, len=9, value=281
Parse item key = message_2, type=String, from=11, len=36, value=09308T0007220161018163700015002002SK
Parse item key = message_3, type=String, from=49, len=1, value=T
Parse item key = message_4, type=String, from=52, len=10, value=0001kDJw60

관련 코드

요약

  • Server bossThreadCount=1, workerThreadCount=8
  • Netty Server Handler: ServerFrameHandler extends SimpleChannelInboundHandler<Object>
  • ServerFrameHandler는 멤버변수로 Parser 객체를 가지고 있음

Netty Server Handler

public class ServerFrameHandler extends SimpleChannelInboundHandler<Object> {

  public ServerFrameHandler() throws Exception {
    super();
    Map<String, Object> properties = (Map<String, Object>)SpringContainerHelper.getBean(this.getHandlerBeanName());
    // ...
    parser = (MCCEParser) properties.get("parser");
  }

  @Override
  protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
    long startTime = System.currentTimeMillis();
    byte[] buf = null;
    try {
      ByteBuf inBuf = (ByteBuf) msg;
      buf = new byte[inBuf.readableBytes()];
      inBuf.readBytes(buf);
      execute(ctx, buf); // Parser로 파싱 실행
  }

  // ...

}

Bean Configuration

<bean id="FdsServerFrameHandler" class="java.util.HashMap" scope="prototype">
 <constructor-arg>
   <map key-type="java.lang.String" value-type="java.lang.Object">
     <entry key="parser" value-ref="Parser"/>
     <!-- .. -->
   </map>
 </constructor-arg>
</bean>

<bean id="Parser" class="com.kcb.kronos.impl.ParserImpl">
  <!-- .. -->
</bean>

Handler(ServerFrameHandler 클래스)에서 getBean(beanName) 메소드를 이용해서 FdsServerFrameHandler bean을 가지고 온다. 이때 Handler Bean의 scope는 prototype으로 애플리케이션에서 요청할 때 (getBean()) 마다 스프링이 새 인스턴스를 생성한다. 또한 Handler의 멤버 변수인 Parser의 scope는 지정 돼 있지 않으므로 default인 singleton으로 지정된 상태이다.

원인

의심 1. Spring Bean 멤버 변수들은 Thread Safe 하지 않다.

Netty Handler는 Thread Safe 하다. 하지만 Spring Bean의 멤버 변수들은 Thread Safe 하지 않다. JVM에서 각각의 Thread는 자신의 Stack 영역을 가지고 있지만, Code, Heap, Data 영역은 Thread들끼리 공유한다.

  • PC Register: Thread 간 독립적인 영역이다.
  • Stack 영역: Primitive type에 해당하는 local variable의 데이터 값이 저장되는 공간으로 parameter, return value, Object reference도 저장되어 있다. Thread 각각이 가지고 있는 독립적인 영역이다.
  • Heap 영역: Reference Type(참조형)에 해당하는 객체(인스턴스), 배열 등이 저장된다. 모든 Thread가 접근 가능한 공유영역이다.
  • Data, Method 영역: Thread 간 공유되는 영역이다.

여기서 실제 데이터를 가지고 있는 Heap 영역의 참조값(reference value 또는 HashCode)을 Stack 영역의 객체가 가지고 있는 형태이다. 따라서 singleton으로 선언되어 있으며 Heap 영역에 저장된 Parser의 상태는 여러 Thread에 의해 계속 바뀌고 있다.

의심 2. Scope가 singleton으로 정의된 Bean은 하나의 공유된 인스턴스로 관리된다.

scope: singleton vs prototype

When a bean is a singleton, only one shared instance of the bean will be managed, and all requests for beans with an id or ids matching that bean definition will result in that one specific bean instance being returned by the Spring container.

Singleton Bean인 경우, 하나의 공유된 인스턴스만 관리되며, 해당 Bean 정의와 같은 id를 가진 Bean에 대한 모든 요청은 Spring Container에 의해 반환된다.

The non-singleton, prototype scope of bean deployment results in the creation of a new bean instance every time a request for that specific bean is made (that is, it is injected into another bean or it is requested via a programmatic getBean() method call on the container). As a rule of thumb, you should use the prototype scope for all beans that are stateful, while the singleton scope should be used for stateless beans.

Prototype(Non-singleton) scope에서는 특정 Bean에 대한 요청이 이루어질 때마다(즉, 다른 Bean에 주입되거나 getBean()가 호출될 때마다) 새로운 Bean 인스턴스를 생성한다. 경험적으로 상태 저장이 많은(stateful) Bean은 prototype, 상태 저장이 많지 않은(stateless) Bean은 singleton을 사용하는 것이 좋다.

확인

  • singleton으로 Parser를 선언하였을 때 Thread 별 Handler와 Parser 객체의 HashCode
    결과: Handler의 HashCode는 다르지만, Parser는 모두 같은 HashCode 값을 가지고 있다.
Thread Handler HashCode Parser HashCode
nioEventLoopGroup-7-1 1175522974 373210183
nioEventLoopGroup-7-3 358958159 373210183
nioEventLoopGroup-7-4 933231667 373210183
  • prototype으로 Parser를 선언하였을 때 Thread 별 Handler와 Parser 객체의 HashCode
    결과: Handler와 Parser 모두 다른 HashCode 값을 가지고 있다.
Thread Handler HashCode Parser HashCode
nioEventLoopGroup-7-1 443698764 584690611
nioEventLoopGroup-7-3 1483030703 849377978
nioEventLoopGroup-7-4 1930680761 907102655

해결

<bean id="FdsServerFrameHandler" class="java.util.HashMap" scope="prototype">
 <constructor-arg>
   <map key-type="java.lang.String" value-type="java.lang.Object">
     <entry key="parser" value-ref="Parser"/>
     <!-- .. -->
   </map>
 </constructor-arg>
</bean>

<bean id="Parser" class="com.kcb.kronos.impl.ParserImpl" scope="prototype">
  <!-- .. -->
</bean>

Parser의 scopeprototype으로 변경하였다. 이로 인해 각 Thread 별로 다른 Handler를 가진 상태에서 Handler마다 다른 Parser 인스턴스를 가지고 있을 수 있게 된다.




Referance

댓글