동시성 및 스레드 안전

동시성과 스레드 안전에 대해 생각하는 것은 개발자로서 정말 중요한 작업입니다. 서비스가 성장함에 따라 멀티스레딩이 절실히 필요할 것입니다. 성능을 위해 멀티스레딩을 강제했지만 동시성 문제로 버그가 생긴다면 골치 아플 것이다.

이번에는 동시성 문제, Thread-safe, Java에서 동시성 문제를 해결하는 방법에 대해 살펴보겠습니다.

동시성 문제

동시성 문제가 있는 코드는 한눈에 파악하기 쉽지 않습니다. 오히려 동시성 문제를 일으키는 패턴을 외우고 패턴이 나타날 때 해결하는 것이 더 합리적이라는 것을 알기가 쉽지 않습니다.

프로젝트를 진행하면서 동시성 문제에 대해 심각하게 생각하고 코딩해본 적은 없지만 어떤 상황에서 동시성 문제가 발생하는지 대략적으로 알고 있습니다.

이 장에서는 타임라인을 살펴보고 동시성 문제를 일으킬 수 있는 상황에 대해 자세히 알아봅니다.

public class BasicObservableClass {
   public interface Observer {
     void onObservableChanged();
   }
   private Set<Observer> mObservers;
   public void registerObserver(Observer observer) {
     if (observer == null) {
       return;
     }
     if (mObservers == null) {
       mObservers = new HashSet<>(1);
     }
     mObservers.add(observer);
   }
   public void unregisterObserver(Observer observer) {
      if (mObservers != null && observer != null) {
         mObservers.remove(observer);
      }
  }
  private void notifyObservers() {
     if (mObservers == null) {
        return;
     }
     for (Observer observer : mObservers) {
        observer.onObservableChanged();
     }
   }
}

코드는 적당히 길지만 자세히 살펴봐야 할 것은 Set이 멤버 변수로 선언되고 registerObserver 메서드를 통해 HashSet 구현으로 동적으로 할당된다는 점입니다.

unregisterObserver 및 notifyObservers 코드는 우리가 살펴볼 동시성 문제와 직접적인 관련이 없으므로 위에서 언급한 두 가지에만 주의하면 됩니다.

위의 코드가 생성된 상황에서 두 쓰레드 A와 B가 동시에 registerObserver에 접근한다고 가정해보자. 그럼 어떻게 될까요?

  1. 스레드 A는 registerObserver에 액세스합니다.
  2. Thread A는 매개변수로 전달된 옵저버가 null인지 확인하고 선언한 mObservers도 null이면 동적으로 HashSet으로 할당합니다.
  3. 스레드 B가 실행 중인 새 연산자에 액세스하면 OS는 스레드 A의 작업을 일시적으로 중단하고 스레드 B가 실행되도록 합니다.
  4. 스레드 B는 1단계와 2단계를 거쳐 mObserver를 HashSet으로 생성합니다.
  5. 스레드 A가 아직 mObservers를 생성하지 않은 동안 스레드 B는 새 Set 생성에 성공하고 mObservers에 대한 참조를 저장합니다.
  6. 스레드 B에 새 세트가 생성됩니다.
  7. OS는 스레드 A의 작업을 다시 실행합니다.
  8. 스레드 A는 새 집합을 만들고 mObservers에 대한 참조를 재정의합니다.
  9. 스레드 A는 새로운 세트를 다시 생성했습니다.

타임라인에서 알아봤습니다. 이 상황에서 어떤 문제가 발생합니까?

즉시 Thread B가 생성한 Set은 Thread A가 생성한 Set을 덮어씁니다. OS 용어로 이러한 상황을 ‘경합 상태 진입’이라고 합니다. 경쟁 조건에 대해 간단히 설명하고 계속 진행하겠습니다.

경쟁 조건

레이스 컨디션은 서로 다른 두 사람이 모든 형광등이 연결된 스위치를 켜고 끄는 것으로 설명할 수 있을 것 같습니다.

모든 형광등이 스위치에 연결된 경우 스위치를 켜면 모든 형광등이 켜지고 스위치를 내리면 모든 형광등이 꺼집니다.

위의 예제 코드에서 한 사람은 스위치를 내리려고 하고 다른 사람은 스위치를 올리려고 합니다. 그렇다면 끄거나 켜는 상황은 단 하나일 것입니다. 그렇다면 그 상황을 원하지 않았던 한 사람이 있다.


위의 예에서 두 사람이 순차적으로 읽기 및 저장 작업을 수행하는 과정을 볼 수 있습니다. 프로세스 1은 읽고 쓰고, 곧이어 프로세스 2가 읽고 씁니다. 정상적으로 작동합니다.


이 상황은 어떻습니까? 공정 1과 공정 2가 겹치면서 일이 꼬이는 상황이다. 이러한 상황을 경쟁 조건이라고 합니다.

동시성 문제를 해결하는 방법

우선 동시성 문제가 발생하는 몇 가지 패턴을 아는 것이 좋습니다. 제가 아는 패턴을 소개하겠습니다.

  1. 멤버 변수(글로벌 변수) 선언 시
  2. 스레드로부터 안전하지 않은 클래스를 사용하는 경우
  3. ++, — 등의 증가/감소 연산자를 사용하는 경우
  4. 여러 스레드가 동시에 메서드에 액세스하는 경우

대충 이렇게 정리하시면 됩니다. 이제 Java에서 동시성 문제를 해결하는 방법을 살펴보겠습니다.

1. 변경 불가능한 객체를 사용하십시오.

Java에서 불변 객체를 사용하면 동시성 문제가 해결됩니다. 불변 객체는 한번 생성되면 절대 변하지 않는 특성을 가지고 있습니다. 예를 들어 불변 객체인 String은 동시성 문제를 겪지 않습니다.

2. 최종 변수를 사용하십시오.

최종 변수는 스레드로부터 안전하기 때문에 Java에서 매우 널리 사용됩니다. 최종변수에 대해 잘 모르시겠다면 아래 링크를 확인해주세요.

https://coding-review.156

최종 키워드

Java에서 키워드 final은 상수를 표현하기 위한 예약어입니다. ‘마지막’이라는 말의 뜻처럼 선언한 대로 쓰라는 뜻이다. 변수, 메소드, 클래스가 모두 사용되며, 이제부터는 각각

코딩리뷰.tistory.com

3. 읽기 전용으로 사용하십시오.

읽기만 필요한 경우 읽기 전용을 사용하는 것도 좋은 생각입니다.

4. synchronized 키워드를 사용하십시오.

synchronized 키워드는 액세스 시 하나의 스레드만 액세스할 수 있도록 하는 키워드입니다. 동기화된 블록을 사용하면 잠금을 획득한 스레드만 액세스할 수 있으므로 스레드는 스레드가 떠날 때까지 뒤에서 기다릴 수밖에 없습니다.

5. 정적 변수를 사용하지 마십시오.

정적 변수는 동기화하기 어렵습니다. 정적 변수에 여러 쓰레드가 접근하면 엄청난 버그가 발생할 수 있으므로 변하지 않는 값만 정적 변수로 설정하는 것을 권장한다.

6. java.util.concurrent 클래스를 사용합니다.

동시 클래스는 스레드로부터 안전함을 보장하는 클래스입니다. 동시성 클래스는 내부적으로 Segment라는 개념을 사용하여 Reentrant를 잠가 스레드 안전성을 보장합니다. 쉽게 말해 쓰레드마다 세그먼트를 할당하고 변수를 세그먼트별로 병렬로 관리하기 때문에 동시성 문제가 없음을 알 수 있다.

Concurrent 클래스에 대해 더 알고 싶다면 아래 링크를 확인하세요!

https://coding-review.278

ConcurrentMap, ConcurrentHashMap

이 게시물은 스레드로부터 안전한 것으로 알려진 기존 Map 인터페이스의 구현인 ConcurrentMap입니다. 이전 게시물의 지도에 대한 기본 정보와 지금 작성하려는 이 콘텐츠는 지식의 깊이입니다.

코딩리뷰.tistory.com

7. 멤버 변수(지역 변수)를 사용하지 마십시오.

멤버 변수를 사용할 필요가 없다면 사용하지 않는 것도 동시성 문제를 해결하는 방법 중 하나입니다. 그렇다면 로컬 변수는 스레드로부터 안전합니까?

로컬 변수는 각 스레드가 스택 스토리지에 로컬 변수를 저장하기 때문에 다른 스레드에서 액세스할 수 없으며 이 스택 메모리는 각 스레드가 가지고 있는 스토리지입니다.

8. 멤버 변수를 사용해야 하는 경우 ThreadLocal을 사용합니다.

ThreadLocal은 내부적으로 Map을 사용하여 각 변수에 고유한 값을 유지합니다. ThreadLocal에 대한 자세한 내용은 아래 링크를 확인해주세요!

https://coding-review.92

동시성 및 스레드 로컬

이 게시물은 Infran 김영한의 Advanced Spring Core Principles를 각색한 것입니다. 자세한 내용은 강의를 확인해주세요.

코딩리뷰.tistory.com

9. Volatile 키워드는 제한된 범위에서 동시성 문제를 해결합니다.

volatile 키워드는 모든 상황에서 스레드로부터 안전한 것은 아닙니다. 그러나 매우 제한된 방식으로 동시성 문제를 해결하는 경우가 많습니다. 예를 들어 두 개의 스레드가 있는 경우 하나는 읽기만 하고 다른 하나는 쓰기만 하면 동시성 문제가 해결됩니다.

결론

지금까지 동시성 문제와 스레드 안전을 살펴보고 이를 해결하는 방법도 알아냈습니다. 동시성은 개발자가 항상 생각해야 하는 문제이므로 일단 알고 나면 쓸 내용이 있다는 것을 기억하는 것이 좋습니다.

언젠가 동시성 문제를 심각하게 고려하게 되기를 바랍니다. 긴 글 읽어주셔서 감사합니다. 좋은 하루 되세요!

원천

https://gowthamy.medium.com/concurrent-programming-fundamentals-thread-safety-6b44c026bd2a

동시 프로그래밍 기본 사항 – 스레드 안전성

스레드 안전성이란 무엇입니까?

gowthamy.medium.com

=> 스레드 안전에 대한 전반적인 이해 및 솔루션

https://www.techtarget.com/searchstorage/definition/race-condition

경쟁 조건이란 무엇입니까?

컴퓨터 과학 및 프로그래밍에 경쟁 조건이 무엇인지, 작동 방식, 원인이 되는 보안 취약점 및 이를 방지할 수 있는 방법에 대해 알아보세요.

www.techtarget.com

=> 경쟁 조건의 전반적인 개념

https://www.baeldung.com/java-volatile-variables-thread-safety

=> 휘발성 키워드가 스레드로부터 안전한지 여부에 대한 문서