8 min read

신입 개발자가 만난 동시성 문제들 1

현업 4개월차 운영체제 공룡 책에서 듣기만 하고 보기만한 동시성 문제들에 대한 썰을 풀어보겠다. 전역 변수에 동시에 접근할 때 만났던 문제와, Race Condition이 발생할 수 있는 지점임을 예상하여 수정해본 문제까지 얘기해본다.총 2개의 포스팅으로 이루어질 예정이다.

동시성 문제? 그전에 multi-thread

우선 동시성 문제를 알기 전에 multi-thread 환경에 대한 이해가 필요하다.현업에 들어오기 전에는 굉장히 거창해 보여 덜컥 겁부터 나던 주제였는데,자주 보다보니 좀 더 쉽게 다가가는것 같다.업무와 관련된 두 가지 상황을 간단하게 소개하고 본론으로 들어가겠다.

서로 다른 쓰레드간의 전역 변수 접근

회사 서비스는 Application(지금부터 응용이라 칭함)에서 들어오는 캐싱 요청을 단 하나의 IO 쓰레드를 통해 처리하여 비동기를 보장한다.IO 쓰레드에는 실제 캐시 서버와 연결되는 논리적인 단위인 Connection이 존재한다. 한개의 IO 쓰레드가 생성될 때, Factory를 통해 한개의 Connection 인스턴스도 생성된다. DBConnection 개념을 생각하면 된다.이러한 IO 쓰레드를 생성하는 쓰레드들이 동시에 사용하는 전역 변수가 존재 한다면 어떻게 될까? 만약 여러개의 IO 쓰레드를 응용에서 생성 요청한다면 race-condition이 발생할 수도 있다.첫번째 주제는 이와 같은 상황에서 발생한 동시성 문제이다.

worker와 IO 간의 경쟁

그러면 위에서 언급된 IO 쓰레드에게 캐싱 요청을 건네주는 작업은 누가 수행할까? 이는 바로 응용의 worker thread이다.주로 이 둘간의 race-condition은 드물지만 발생 가능하다.특히 우리 서비스의 경우 사용자에게 Future 구현체를 리턴한다.내부적으로 IO 쓰레드는 연산을 완료 시키는 그 찰나에 만약 응용의 worker-thread가 Future의 cancel() 메서드를 호출하면 드물지만 동시성 문제가 발생할 수 있지 않을까? 이러한 문제를 두번째 주제에서 다룬다.

전역 변수로 인해 발생한 문제

회사에서는 캐시 서버에 zookeeper를 사용한다.znode의 Key에는 아래처럼 javaClient의 version이 적혀있다.이슈로 할당 받은 문제는 이와 같은 version이 NONE으로 찍힌다는 문제가 발생한다는 것이였다.

우선 javaClient에서 version 정보를 추출해오는 로직부터 분석하고 디버깅을 시작해본다. 해당 로직은 대략 아래와 같다.참고로 아래의 ArcusClient라는 클래스는 응용에서 캐시를 사용하기 위해 생성하는 인스턴스라고 이해하면 된다.해당 클래스 내의 statica 메서드인 getVersion()을 호출해서 버전 정보를 가져온다.

public class ArcusClient {
	private static String VERSION;

	public static String getVersion() {
    if (VERSION == null) {
      VERSION = "NONE";

      //Version 파싱 로직 수행
      } catch (Exception e) {
        // Failed to get version.
      }
    }
    return VERSION;
  }

}

코드를 보면 VERSION이라는 전역 변수의 값이 null인 경우에는 version 정보를 가져오고 null이 아닌 경우에는 곧바로 해당 VERSION 값을 리턴해주는 로직이다.

아래와 같이 Thread A와 ThreadB가 동시에 getVersion() 로직을 수행한다고 가정해보자.

ThreadA가 먼저 로직을 수행하고 null 조건을 만족하여 내부에서 “NONE”을 대입하고 버전 정보 파싱 로직을 수행한다. 동시에 ThreadB에서 getVersion()이 호출되고 null 조건을 검사한다.이때 null이 아닌 “NONE”의 참조값이 VERSION 전역변수에 대입되었기에 조건을 만족하지 못한다.즉, 조건을 만족하지 않고 곧바로 해당하는 VERSION을 리턴하며 ThreadB의 getVersion() 로직은 종료된다.

결론적으로 ThreadB가 사용할 버전 정보는 “NONE”으로 설정된 채 znode를 생성하는 로직을 수행한다.

대표적인 동시성 문제라고 봐도 무방하다. 이번 이슈의 경우 간단하게 synchronized block을 통해 해결할 수 있다.

public class ArcusClient {
	private static String VERSION = "INIT";

	public static String getVersion() {
    if (!VERSION.equals("INIT")) {
      return VERSION;
    }
    synchronized (VERSION) {
      if (VERSION.equals("INIT")) {
		// ...
        try {
              // ...version 값 파싱 수행
              if (version != null) {
                VERSION = version;
                break;
              }
            }
          }
        } catch (Exception e) {
          // Failed to get version.
        } finally {
          if (VERSION.equals("INIT")) {
            VERSION = "NONE";
          }
        }
      }
    }
    return VERSION;
  }
}

VERSION이라는 전역 변수를 인자로 삼아 synchronized 블럭을 통해 스레드가 동시에 접근할 수 있는 임계영역에 대한 제한을 둔다.내부에 진입한 스레드가 VERSION 값을 변경하고 Lock을 반납할 경우 대기하던 스레드가 진입하고 임계 영역의 로직을 수행한다.

추가적으로 VERSION 정보를 가져오지 못하고 Exception이 발생할 경우에는 NONE이라는 값을 설정해준다.

위와 같이 구현할 경우 동작의 정확성에는 문제가 없지만 synchronized 블럭 내부적으로 완전히 임계영역의 진입에 대한 통제를 할 수 없는 상황이 발생할 수 있다.

예를 들어 설명한다.Thread A가 VERSION 변수의 참조 값 0xAA에 대해 lock을 획득하고 내부 로직을 수행한다.Thread A가 임계영역에 진입해 있는 동안 다른 쓰레드들은 진입하지 못하고 Blocking 된다.최종적으로 Thread A가 VERSION 변수에 새로운 참조값 0xBB을 할당하며 synchronized 블럭을 빠져나오면 lock을 release한다.

만약 여기서 Thread A가 lock을 release하기 전에 Thread B가 VERSION에 대한 lock을 얻으려고 시도한다면 어떻게 될까? 내부적으로 임계영역에 두개의 Thread가 진입가능하다.이해를 위해 그림을 그려보면 아래와 같다.

synchronized 블럭 내부에서 synchronized의 인수로 사용되는 참조값이 변경될 경우 위와 같은 부정확한 동작이 발생한다.물론 현재 작성된 로직의 경우 내부에서 if (VERSION.equals("INIT")) 라는 조건문에 의해서 Thread B가 version 정보를 구하는 로직을 수행하지는 않는다.

정리해보면 다음과 같다.Thread가 without locking으로 조건검사를 진행한뒤 with locking인 상태에서 한번더 조건검사를 진행하여 로직이 중복으로 진행되지 않도록 만드는 방식이다.

만약 완벽한 상태의 임계영역 제어를 구현한다면 아래와 같이 “INIT”이라는 String의 참조를 가지는 새로운 인스턴스를 생성하여 synchronized의 인수로 사용하면 된다.

	public class ArcusClient {
	private static final String INIT = "INIT";
  	private static String VERSION = INIT;

	public static String getVersion() {
    if (!VERSION.equals(INIT)) {
      return VERSION;
    }
    synchronized (INIT) {
      if (VERSION.equals(INIT)) {
		// ...
        try {
              // ...version 값 파싱 수행
              if (version != null) {
                VERSION = version;
                break;
              }
            }
          }
        } catch (Exception e) {
          // Failed to get version.
        } finally {
          if (VERSION.equals("INIT")) {
            VERSION = "NONE";
          }
        }
      }
    }
    return VERSION;
  }
}

this를 통해 synchronized 블럭의 인수를 설정해도 되지 않을까라는 의문을 가질 수도 있다.이 경우 getVersion() 메세드가 static 메서드이기 때문에 this의 참조로 설정해줄수 없다.this의 경우 클래스를 통해 생성된 인스턴스의 참조값인데 static 메서드의 경우 인스턴스가 없이 호출될 수 있는 메서드이기 때문에 당연한 논리이다.

이번 포스팅에서는 synchronized를 다루는 기초적인 수준에서의 동시성 이슈에 대해 다루어보았다.다음 포스팅에서는 비동기 툴인 Future와 관련된 동시성 이슈에 대해 알아보자.