Java

[Effective Java] 아이템 76 : 가능한 한 실패 원자적으로 만들라

jwKim96 2023. 3. 27. 22:53

0. 들어가며

이 아이템이 말하는 바는 짧고 명확해서, 아이템 이름만 봐도 명확하게 보입니다.

저는 “가능한실패 원자적으로 만들라” 라고 핵심 키워드를 꼽았습니다.

  • 가능한 : 반드시 해야한다는건 아니지만,
  • 실패 원자적 : 실패 상황에도 정상적인 흐름으로 흘러가게 하라.

그런데 실패 원자적이라는 말이 확 와닿지는 않는데요.
이를 책에서는 다음과 같이 말합니다.

호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다.

어떻게 원자적으로 만들 수 있는지, 해야하는 상황과 아닌 상황은 어떻게 구분하는지 아래에서 알아보겠습니다.

1. 실패 원자적으로 만드는 방법

1.1 불변

사실, 불변객체인 경우에는 실패원자적으로 만들기가 쉽습니다.

왜냐하면, 불변 객체인 경우에는 상태를 변경할때 새 객체를 생성하기 때문입니다.

그래서 실패하더라도 현재 객체는 변경되지 않기 때문에, 자연스럽게 실패 원자성을 가지게 되죠.

record ImmutablePerson(String name, int age) {

    public ImmutableName changeAge(int newAge) {
        if (newAge <= 0) throw new IllegalArgumentException();
        return new ImmutableName(this.name, newAge);
    }
}

위와 같은 불변 객체는 상태를 변경할때 새로운 객체를 반환합니다.

만약 changeAge를 호출할때 0이하의 나이를 주는 경우에 예외가 발생하는데요.

이 경우에는 해당 메서드 자체만 실패하는 것이지, 기존 객체는 정상적인 상태가 유지됩니다.

1.2 가변

가변인 경우는 조금 더 복잡할 수 밖에 없는데요.

미리 검증을 통해 상태 변경을 막거나, 변경을 되돌리는 작업이 있어야 하기 때문입니다.

그래서 책에서는 제시하는 가변 객체 실패 원자성 유지 방법은 아래 4가지 입니다.

  1. 상태 변경에 앞서 매개변수 유효성을 검사하는 것
    • 아이템 49와 연관있음
  2. 실패 가능성이 있는 코드를, 상태 변경 로직보다 앞에 배치하는 것
  3. 복사본에서 작업 수행 후, 성공하면 원래 객체와 교체하는 방법
  4. 실패를 가로채는 복구코드를 작성하여, 작업 전 상태로 되돌리는 방법
    • DB트랜잭션 등

1) 상태 변경에 앞서 매개변수 유효성을 검사하는 것

이 항목은 사실 위의 불변 예제와 거의 비슷할 것 입니다.

다만, 차이점은 새로운 객체를 반환하는것이 아니라 자기 자신의 상태를 변경한다는 점이겠죠.

class MutablePerson {
    private String name;
    private int age;

    public MutableName(String name, int age) { 
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return this.name;
    }

    public String getAge() {
        return this.age;
    }

    public void changeAge(int newAge) {
        if (newAge <= 0) throw new IllegalArgumentException();

        this.age = newAge;
    }
}

위 처럼, 상태 변경 전에 매개변수 유효성을 검증하고 예외를 발생시켜 원자성을 유지할 수 있습니다.

2) 실패 가능성이 있는 코드를, 상태 변경 로직보다 앞에 배치하는 것

class Processor {
    // ...

    public void process(UserCommand command) {
        this.status = Status.SUCCESS;

        log.info("[{}] run command", command.getUser()) // command 가 null 이라면??
        this.run(command.getCommand());
    }

    private void run(Command command) {
        // ...
    }
}

-----
// 이렇게 바꾸면 이전보다는 나음

class Processor {
    // ...

    public void process(UserCommand command) {
        log.info("[{}] run command", command.getUser())
        this.run(command.getCommand());

        this.status = Status.SUCCESS;
    }

    private void run(Command command) {
        // ...
    }
}

위쪽 예제는 command가 null일때 NullPointerException이 발생합니다.

그러면 상태는 SUCCESS인 상태로 멈춰버리게 되는데요.

하지만, 상태 변경로직보다 앞에 둔다면 실패했는데 SUCCESS라고 되진 않을 것 입니다.

2. 실패 원자성 구현은 필수?

하지만, 항상 실패 원자성을 구현해야하는건 아니라고 하는데요.

2.1 할 수 없는 경우 & 안해도 되는 경우

만약 멀티 스레드에서의 경쟁 상태(Race condition)에 놓였을 경우에는 해결하기 힘들겠죠.

image
  • 하나만 남기고 취소해야하나?
    • 그럼 어떤 기준으로 누굴 취소하지?
  • 둘다 취소해야하나?
    • 데이터가 어떻게 변했는지 보고 복원 해야하나?

여러 경우의 수를 고려해야하겠지만, 위와 같이 대상이 많지 않은 경우라면 괜찮을 것 입니다.

하지만 아래와 같이 복잡하고 대상이 많은 경우라면 어떨까요?

image

이런 상황에서 원자성 달성을 위한 로직을 개발하는건 비용이 크고 복잡할 것 입니다.

그리고 책에서는 문제가 무엇인지 아록 나면 실패 원자성을 공짜로 얻을 수 있는 경우가 있다고 하는데요.

위 상황에서의 문제는 여러 스레드가 한 데이터에 접근하면서 발생한 문제입니다.

이와 같은 경우, ‘임계 구역’을 설정하면 해결될 수 있는 상황이기도 하죠.

이렇게 하면 큰 변경없이도 실패 원자성을 얻을 수 있게 됩니다.

3. 실패 원자성 명세

실패 원자성 여부는 특정 메서드를 사용하는 입장에서 중요한 정보입니다.

그래서 책에서는 메서드 명세에 기술되어있는 예외는 발생하더라도 원자성을 지켜줘야한다고 합니다.

그리고 원자성을 지켜주지 못한다면, 예외 발생시 어떤 상태가 되는지를 명시해야한다고 하는데요.

하지만 이런건 이상적인 얘기일 뿐, 잘 지켜지지는 않는다고 합니다.