요약

동기화는 배타적 실행 뿐만 아니라 스레드 간 안정적인 통신을 보장하기 때문에 공유 중인 가변 데이터는 동기화해 사용해야 한다.

그렇지 않으면 응답 불가 상태에 빠지거나 안전 실패(프로그램이 잘못된 결과를 계산해내는 오류)로 이어질 수 있다.

 

동기화의 두 가지 기능

  1. 배타적 실행

배타적 실행은 한 스레드가 객체의 상태를 변경하고 있을 때, 상태가 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 막는 것을 의미한다. 따라서, 동기화를 사용하면 항상 일관적인 객체 상태를 보장할 수 있다.

  1. 스레드 사이의 안정적 통신 보장

동기화를 사용하면 하나의 스레드에서 수행한 작업의 결과를 다른 스레드가 접근해 상태를 확인할 수 있다.

 

원자적 데이터

long, double 외의 type 데이터를 읽고 쓰는 동작은 원자적

→ 여러 스레드가 동기화 없이 수정하는 중에도 다른 스레드가 저장한 값을 읽어올 수 있다.

→ BUT, 원자적 데이터로 만들고 동기화를 하지 말아야겠다는 생각 NO

→ 자바에서는 스레드가 필드를 읽을 때 항상 수정이 완전하게 반영된 값을 얻지만, 이는 다른 이 값을 다른 스레드가 볼 수 있는지 보장하지는 못한다(자바 메모리 모델이 원인)

실패 예시

  • 로직
    • stopRequested = false로 초기화
    • 첫번째 스레드가 stopRequested를 폴링하다가 stopRequested == true 일 때 loop 멈춤
    • 다른 스레드에서 스레드를 멈추려고 할 때 stopRequest = true
  • 예상 : 1초만에 실행 종료 예상
  • 실제 : 무한 루프
public class StopThread {
    private static boolean stopRequested;

    // 동기화를 사용하지 않아 무한루프를 도는 코드
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) { // background 스레드
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true; // 메인 스레드 
    }
}

원인

  • 동기화를 하지 않았기 때문 → 안정적 통신을 보장한다
  • 동기화를 하지 않아 main 스레드가 수정한 값을 background 스레드가 언제쯤에 보게 될지 보증할 수 없다
  • 동기화가 빠지면 VM이 다음과 같이 최적화를 수행할 수도 있다
    • 이는 OpenJDK 서버 VM이 실제로 적용하는 끌어올리기(hosting)라는 최적화 기법으로, 그 결과 프로그램은 응답 불가(liveness failure) 상태가 되어 더이상 진전이 없다
  • // 원래 코드 while (!stopRequested){ i++; } // 최적화한 코드 if (!stopRequested) { while(true) { i++; } }

해결

public class StopThread {
    private static boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested()) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}
  • 쓰기/읽기 메서드 모두 동기화함
  • 여기서 동기화는 스레드간 통신 목적으로만 사용됨

 

volatile

위 코드의 stopRequested 필드를 volatile으로 선언하면 동기화를 생략해도 된다.

public class StopThread {
    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

사용시 주의할 점

volatile은 동기화가 아니라 가장 최근에 기록된 값 읽기를 보장하기 때문에 값 읽기에 문제가 발생할 가능성이 있다.

private static volatile int nextSerialNumber = 0;
  public static int generateSerialNumber() {
      return nextSerialNumber++;
}
  • 의도
    • 2^32을 넘지 않을 경우, 매번 고유한 값 반환하는 것
  • 문제
    • nextSerialNumber가 int 타입으로 원자적으로 접근할 수 있어서 동기화하지 않아도 불변식을 보호할 수 있어 보이지만, 실제로는 올바르게 동작하지 않는다
  • 원인 - 증가 연산자(++)
    • 이 연산자는 nextSerialNumber에 두 번 접근하기 때문에 문제가 발생한다
    • 만약 두번째 스레드가 첫 번째와 두 번째 사이에 접근한다면, +1된 값이 아닌 첫번째 스레드와 똑같은 값을 돌려받게 되는 문제가 발생한다
  • 해결 방법
    1. generateSerialNumber()에 synchronized 설정하기
      1. 여러 스레드가 동시에 호출해도 서로 간섭하지 않으며, 이전 호출이 변경한 값을 읽을 수 있게됨
      2. 이때 nextSerialNumber 앞에 volatile은 제거해야 한다
      3. ➕ int 타입 → long 타입 || nextSerialNumber가 최댓값 도달 시 예외 던지기
      4. ➕ java.util.concurrent.atomic 패키지 클래스(AtomicLong) 사용하기
      • AtomicLong을 활용한 최적화 코드
      • public class StopThreadAtomic { private static final AtomicLong nextSerialNum = new AtomicLong(); public static long generateSerialNumber() { return nextSerialNum.getAndIncrement(); } }

 

애초부터 동기화 문제를 피하는 방법

  1. 가변 데이터는 단일 스레드에서만 쓰도록 하자
  2. 사용하려는 프레임워크와 라이브러리를 깊게 이해하자
  3. 필요한 부분만 부분 동기화 하자
    1. 한 스레드가 데이터를 다 수정한 후, 다른 스레드에 공유할 때 해당 객체에서 공유하는 부분만 동기화하는 것
    2. 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드를 동기화 없이 자유롭게 값을 읽어갈 수 있다
    3. 이를 사실상 불변(effectively immutable)이라고 한다
    4. 이런 사실상 불변 객체를 다른 스레드에게 전달하는 것을 안전 발행(safe publication)이라 한다

 

참고

자바 메모리 모델 이해하기

요약

예외 발생 원인과 관련된 정보를 필요한 것만 자세히 담아서 알려주도록 하자.

  • 스택 추적에 꼭 넣어야 하는 정보
    • 예외가 발생한 파일 이름/줄번호
    • 스택에서 호출한 다른 메서드들의 파일 이름/줄번호

 

스택 추적(Stack Trace)

  • 스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열
  • 보통은 예외의 클래스 이름 뒤에 상세 메시지가 붙는 형태
  • 실패 원인을 분석하기 위해 얻을 수 있는 유일한 정보

→ 예외의 toString 메서드에 실패 원인에 관한 정보를 꼭 필요한 것만 자세하게 예외의 상세 메시지에 담는 것이 중요하다.

스택 추적에 넣어야하는 정보

  • 예외가 발생한 파일 이름/줄번호
  • 스택에서 호출한 다른 메서드들의 파일 이름/줄번호

 

예시 - IndexOutOfBoundsException

IndexOutOfBoundsException의 상세 메시지는 범위의 최솟값/최댓값, 그 범위를 벗어났다는 인덱스 값을 담아야 한다.

예외 발생하는 상황

  • 인덱스 < 최솟값
  • 인덱스 ≥ 최댓값
  • 최솟값 > 최댓값 (내부 불변식이 심각히 깨진 경우)

위 현상들의 원인은 모두 다르므로, 최솟값/최댓값/인덱스 값 정보를 제공하면 원인을 분석하는데 도움이 될 것이다.

 

예외 생성자에서 상세 메시지 미리 생성하기

실패를 적절하게 포착하는 방법으로 예외 생성자에서 상세 메시지를 미리 생성하는 방법도 있다.

  • 예시 코드
    • 이 코드는 상세 메시지를 만들어내는 코드를 예외 클래스 안으로 모아주는 효과도 있어 클래스 사용자가 메시지를 만드는 작업을 중복하지 않아도 되는 효과도 있다
    public class IndexOutOfBoundsException extends RuntimeException {
        private int lowerBound;
        private int upperBound;
        private int index;
    
        /**
         * IndexOutOfBoundsException을 생성한다. 
         * 
         * @param lowerBound 인덱스의 최솟값
         * @param upperBound 인덱스의 최댓값 + 1
         * @param index 인덱스의 실제값
         */
        public IndexOutOfBoundsException(int lowerBound,
                                         int upperBound,
                                         int index
        ) {
            // 실패를 포착하는 상세 메시지를 생성한다.
            super(String.format(
                    "최솟값 : %d, 최댓값 : %d, 인덱스 : %d",
                    lowerBound, upperBound, index));
    
            // 프로그램에서 이용할 수 있도록 실패 정보를 저장해둔다.
            this.lowerBound = lowerBound;
            this.upperBound = upperBound;
            this.index = index;
        }
    }
    
    • 길이가 5인 배열, arr[5]; → 예외 발생
    • 예외 메시지 : “최솟값 : 0, 최댓값 : 4, 인덱스 : 5”
  • 참고
    • 자바 9에서 IndexOutOfBoundsException의 생성자는 정수 인덱스를 받는다
    public IndexOutOfBoundsException(String  s){ }
    

요약

되도록이면 표준 예외를 재사용 하자. 이유는

  1. 내가 작성한 API가 다른 사람이 익히고 사용하기 쉬워진다
  2. 코드를 읽기 쉬워진다
  3. 표준 예외를 사용하면 예외 클래스 수가 적어지므로, 메모리 사용량과 클래스 적재 시간이 감소된다

 

표준 예외를 사용하자

 

이점

표준 예외를 사용하면 다음과 같은 이점이 있다.

  1. 내가 작성한 API가 다른 사람이 익히고 사용하기 쉬워진다
  2. 코드를 읽기 쉬워진다
  3. 표준 예외를 사용하면 예외 클래스 수가 적어지므로, 메모리 사용량과 클래스 적재 시간이 감소된다

 

주의할 점

  1. API 문서를 참고해 상황과 맥락에 맞는 예외를 선택해 재사용하자
  2. Exception, RuntimeException, Throwable, Error는 직접 재사용하지 말자
    1. 위 클래스들은 여러 성격의 예외들을 포괄하는 클래스이므로, 안정적으로 테스트할 수 없기 때문이다
  3. 더 많은 정보를 제공하길 원한다면 표준 예외를 확장가능하지만, 예외는 직렬화할 수 있어 많은 부담이 따른다
    1. 따라서, 되도록이면 커스텀 예외는 만들지 않도록 하자

 

표준 예외 재사용 예시

  1. IllegalArgumentException
    1. 허용하지 않는 값이 인수가 건네졌을 때 사용하는 예외
    2. null로 넘어온 것은 NullPointerException으로 따로 처리한다
  2. IllegalStateException
    1. 대상 객체의 상태가 호출된 메서드를 수행하기 적합하지 않을 때 사용하는 예외
    2. 주로 초기화되지 않은 객체를 사용하려 할 때 던질 수 있다
  3. NullPointerException
    1. 메서드 인수로 null 값이 넘어왔을 때 사용
  4. IndexOutOfBoundsException
    1. 어떤 시퀀스의 허용 범위를 넘는 값을 건넬 때 사용하는 예외
  5. ConcurrentModificationException
    1. 단일 스레드에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때 사용하는 예외
    2. 동시 수정을 확실히 검출할 수 있는 안정된 방법은 없으니, 문제가 생길 가능성을 알려주는 정도의 역할
  6. UnSupportedOperationException
    1. 클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때 사용하는 예외
    2. 예시) List 구현체에 remove() 호출 할 때 UnSupportedOperationException이 발생

0. 요약

자바에서는 문제 상황을 알리는 타입(throwable)으로 검사 예외, 런타임 예외, 에러 를 제공한다. 각각 어떤 상황에 사용해야하는지는 다음과 같다.

  • 검사 예외 → 복구할 수 있는 상황, 자원 고갈 상황 복구 가능
  • 비검사 예외 → 프로그래밍 오류, 자원 고갈 상황 복구 불가능
  • 만약 복구여부를 확실하게 알 수 없다면 비검사 예외를 던지자

 

1. 검사 예외를 사용해야하는 상황

만약 호출하는 쪽에서 복구할 수 있는 상황이라고 여겨지면 검사 예외를 사용하는 것이 좋다.

  • 검사 예외 메서드를 사용하면 사용자 측에서 예외 처리 방법
    • catch로 잡아서 처리하기
    • 더 바깥으로 전파하도록 강제하기
  • 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API사용자에게 알려주고, 해결하라고 요구

 

2. 런타임 예외 vs 에러

둘 다 비검사 throwable에 해당하고, 동작방식은 다음과 같다.

  1. 프로그램에서 잡을 필요가 없는 경우
  2. 통상적으로 잡지 말아야하는 경우

프로그램에서 비검사 예외나 에러를 던졌다는 것은 복구가 불가능하거나 더 실행해봐야 득보다는 실이 많다는 뜻이다.

 

3. 비검사 예외(런타임 예외)를 사용해야하는 상황

프로그래밍 오류를 나타낼 때는 비검사 예외를 사용하자. 런타임 예외의 대부분은 전제조건을 위배했을 때 발생하기 때문에 복구할 필요가 없다.

검사 예외 vs 비검사 예외

Q: 만약 복구할 수 있는 상황인지 아닌지 판단하기 애매하다면 어떻게 해야할까?

A : 보통 복구할 수 있는 상황 여부를 판단하기가 어려울 수 있다. 예를 들어 자원 고갈 문제가 매우 큰 배열을 할당해 생긴 프로그래밍 오류일 수도 있고, 진짜로 자원이 부족해서 발생한 문제일 수도 있다. 따라서 API 설계자가 상황을 판단해 복구 가능하다면 검사 예외를, 그렇지 않다면 런타임 예외를 사용하는 것이 좋다. 확신하기 어렵다면 비검사 예외를 선택하는 편이 낫다.

 

4. 에러를 사용해야하는 상황

에러는 보통 JVM이 자원 부족, 불변식 깨짐 등 더이상 수행을 계속할 수 없는 상황을 나타낼 때 사용한다.

  • 되도록이면 하지 말아야 할 것들
    • Error 클래스를 상속한 하위 클래스 생성은 하지 말자
    • Error를 throw 문으로 직접 던지는 것도 하지 말자

 

5. 커스텀 throwable은 정의하지도 말자

Exception, RuntimeException, Error를 상속하지 않는 throwable을 만들 수 있기는 하지만, 만들지 말자.

  • 이유
    • 정상적인 검사 예외보다 나을게 하나도 없다
    • API 사용자만 헷갈리게 할 뿐이다

예외의 메서드

예외의 메서드는 주로 그 예외를 일으킨 상황에 관한 정보를 코드 형태로 전달하는데 쓰인다.

이런 메서드가 없다면 프로그래머들은 오류 메시지를 파싱해 정보를 가져와야 하는데, 이 방식에는 다음과 같은 문제점이 있다.

  • 포맷이 버전별로 다를 수 있기에 데이터가 쉽게 깨지기 쉽다
  • 특정 환경에서의 의존도가 높으므로 다른 환경에서 동작하지 않을 가능성이 높다

+ Recent posts