코드 리뷰 중 final을 언제 붙일까? 라는 고민이 들었다.

프로젝트 구성원과 논의한 내용은 '일단 다 붙이자', '마음대로 하자', '그게 뭐임? 신경 써야함?'와 같이 역시 다양했다.

개인적으로는 가능하다면 '일단 다 붙이자'라는 생각이었다. IntelliJ IDEA에서 변수 추출 시 자동으로 final을 붙여주기 때문에 별도의 타이핑도 필요 없었다. 일단 붙인다면 적어도 컴파일 시점에 개발자의 실수를 최소화 할 수 있다고 생각하였다. 하지만 꼭 final을 명시하는 것이 좋은 것은 아니라는 생각이 들었다.

현재까지 final 사용에 대한 고민은 다음과 같다. 정답은 아니지만 사람들이 고민하고 결정에 참고 할 수 있도록 정리해 보았다.

final 은 무엇인가?

위키백과(참고[1])에서 설명하는 final (Java) 정의와 예제는 다음과 같다.

the final keyword is used in several contexts to define an entity that can only be assigned once.

final 키워드는 엔티티를 한 번만 할당한다. 즉, 두 번 이상 할당하려 할 때 컴파일 오류가 발생한다.

Final classes

public final class MyFinalClass {...}

public class ThisIsWrong extends MyFinalClass {...} // forbidden

MyFinalClass는 final 지시어를 통해서 상속하지 못하도록 정의되었다. ThisIsWrong와 같이 MyFinalClass를 상속하면 컴파일 오류가 발생한다. 대부분의 IDE라면 실시간으로 해당 오류를 확인 가능하다.

대표적인 final class는 String이 있다. 왜 String을 final로 정의하였는지는 StackOverFlow(참고[2])을 참고하면 Immutable object로 얻을 수 있는 이점 때문이다.

  1. Immutable objects: You can share duplicates by pointing them to a single instance.
  2. Security: The system can hand out sensitive bits of read-only information without worrying that they will be altered
  3. Performance: Immutable data is very useful in making things thread-safe.

위의 기준은 클래스 디자인 시 final로 선언할지 결정하는데 고려 요소로 생각된다.

Final methods

public class Base
{
    public       void m1() {...}
    public final void m2() {...}

    public static       void m3() {...}
    public static final void m4() {...}
}

public class Derived extends Base
{
    public void m1() {...}  // OK, overriding Base#m1()
    public void m2() {...}  // forbidden

    public static void m3() {...}  // OK, hiding Base#m3()
    public static void m4() {...}  // forbidden
}

final 이 선언된 메소드는 자식 클래스에서 재정의하려 할 때 컴파일 오류가 발생한다. 클래스를 구현 시 명시적으로 override method를 막고 싶을 때 사용하면 좋겠다.

Final variables

final long ONE_MINUTE = 60000 Java에서는 상수를 선언할 때 final을 이용하여 변경하지 못하도록 Read-Only 값을 정의한다.

private final Logger log; LogManager 등의 클래스가 아닌 이상 Logger를 사용하는 모든 인스턴스들은 단순 호출(log.info, log.error)만을 수행하기 때문에 final로 선언한다. 개인적으로는 고객의 요구사항에 항상 로그를 Job 별로 다르게 작성해달라는 요청이 많았기 때문에 위와 같이 디자인을 많이 하였다.

하지만 많은 Java 프로젝트 최상단에는 다음과 같이 작성되어 있다.
private static final Logger log = Logger.getManager("LUBANG DEV"); 해당 인스턴스들의 Logger를 모두 동일하게 선언하는 디자인이다. (Lombok을 쓰면 @Slf4j로 해결이 가능하지만 Lombok은 Lombok이니깐 패스합니다 ^^;)

위 3가지 케이스 모두 선언한 변수를 수정하지 못하도록 제한하기 위해서 final을 사용했다. 즉, Read-only Instance로 선언이 되었다고 표현 가능하다.

그럼 final 언제 사용할까?

사실 final을 안 쓰더라도 잘 기존 코드 이해하고 작성하면 문제없이 코딩이 가능하다. 하지만 이런 일은 전설 속의 선배들만 가능한 일이고 언제 final을 써야 다른 사람들에게 도움을 줄까를 고민하였다.

클린 코드(참고[3])에서는 기본적으로 코드 가독성을 해치지 않고 명시가 필요한 부분에 사용하라고 정의되어 있다. 역시 가장 중요한 것은 같이 일하는 사람들과 합의가 필요하다. 코드 리뷰를 진행한다면 자연스럽게 논의가 되며 합의점을 찾을 수 있을 것이라고 생각된다. (개인 프로젝트라면 나 혼자 결정 👌).

그리고 전반적인 final 사용에 관한 지침은 Is that your final answer?(참고[4])에 매우 잘 정리되어 있다. 이러한 정보들을 바탕으로 개인적으로 생각하는 final 사용 기준은 다음과 같다.

  • 개발 의도(변수, 함수, 클래스의 명시적 제한)
  • 코드 가독성

개발 의도(변수, 함수, 클래스의 명시적 제한)

개발 의도는 반드시 본 변수, 함수, 클래스는 final로 제한되어야 함을 보여준다. 클래스와 메소드를 제한함으로써 Override로 인한 실수를 최소화 하고 버그를 줄이기 위해 선언한다.

개인적으로 많이 사용하는 final 선언 패턴은 다음과 같다.

클래스 및 생성자 의존성에 대한 final

다음 예제의 EventStreamId는 Event Stream에 대한 고유 ID 객체로 언제나 동일한 인스턴스를 바라보도록 디자인이 필요하였다. 그래서 final class 으로 상속이 불가능하도록 선언하고 생성 시 전달받은 인자를 final member variables로 유지하도록 구현하였다.

간단하게 EventStreamId 객체 내에서 streamName, version 변경되지 않기를 원했다.

public final class EventStreamId {
    private final String streamName;
    private final long version;

    public EventStreamId(String streamName, long version) {
        this.streamName = streamName;
        this.version = version;
    }

    public EventStreamId(String streamName) {
        this(streamName, 1L);
    }

    public EventStreamId withVersion(long version) {
        return new EventStreamId(this.getStreamName(), version);
    }
  • github.com/lubang/drived 의 EventStreamId.java 중 일부
  • 본 클래스는 도메인 주도 개발(참고[5])를 기반으로 구현

이 디자인은 Stream name, version은 전달받은 인자를 기반으로 Immutable object를 생성한다. 새로운 version의 EventStreamId가 필요한 경우에는 withVersion 메소드를 이용하여 새로운 인스턴스로 반환한다. 이는 SRP(Single Responsibility Principle)를 준수하여 EventStreamId의 생성자로만 멤버 변수의 값을 설정하고 EventStreamId의 생성자 호출이라는 일관성 있는 메소드 수준을 제공한다.

함수에 대한 final

함수에 대한 final은 단순하다. 상속되면 절대 안되는 경우 final을 정의한다.

사실 ISP(Interface Segregation Principle)에 따라 인터페이스를 선언하여 구현한다면 final을 사용하는 경우가 매우 한정적이다.

  • Interface에서 정의한 변수는 final 선언과 같이 재정의가 불가능하다.
  • Interface에서 Method를 final로 선언할 수 없다. (당연하다고 생각된다)

결국 Interface로 정의 후 상속받은 구현체의 메소드에 final을 선언하여 추가적인 재정의를 막는 경우에 final 사용이 가능하다고 생각된다. 개인적으로는 함수에 대해서는 final을 선언한 기억이 없다. (프로젝트 구성원님들이 잘 해주신 덕분에 버그가 없었는지도...)

코드 가독성

개발 의도가 가장 중요하지만 그 다음으로는 가독성을 고려한다. 읽기 좋은 코드가 버그가 적다!

// final 이 있는 코드
@Test
public void apply_events_when_music_artist_releases_an_album() {
    final MusicArtistId id = MusicArtistId.createUniqueId();
    final MusicArtist musicArtist = new MusicArtist(id,
            "Red Velvet",
            ZonedDateTime.parse("2014-08-01T00:00:00+09:00"));
    musicArtist.releaseAlbum(
        "The Red",
        ZonedDateTime.parse("2015-09-09T00:00:00+09:00"));

    // Assert 생략
}

// final 이 없는 코드
@Test
public void apply_events_when_music_artist_releases_an_album() {
    MusicArtistId id = MusicArtistId.createUniqueId();
    MusicArtist musicArtist = new MusicArtist(id,
            "Red Velvet",
            ZonedDateTime.parse("2014-08-01T00:00:00+09:00"));
    musicArtist.releaseAlbum(
        "The Red",
        ZonedDateTime.parse("2015-09-09T00:00:00+09:00"));

    // Assert 생략
}

위와 같이 지역 변수를 생성해서 처리하는 경우는 실수할 일이 매우 낮기 때문에 가독성을 확보하는 것이 더 좋다고 생각된다. 예제는 짧아서 final의 영향이 미비하지만 조금 복잡한 로직, 특히 수식을 구현한 알고리즘으로 들어가면 final 단 5글자이지만 수식을 이해하는데 큰 불편함을 준다.

결국 final은!

결국 Java에서의 final은 Immutable/Read-only 속성을 선언하는 지시어이다.

클래스, 함수, 변수가 변하지 못하도록 의도하고 싶다면 final로 선언하자.

🐳사설
왜 Scala 처럼 var, val 이 없는 것인가? Javascript도 let, const 로 이제 구분하는데...
Java도 스펙에 올라올려나? 그럼 가독성도 좋고 항상 final 선언이 가능할텐데 아쉽다.

참고 자료