박종훈 기술블로그

1장 최신 자바 소개 (1)

Well-Grounded Java Developer - 2nd edition


2021년 9월에 최신 LTS 릴리즈인 Java 17이 나왔습니다.

2022년을 기준으로 대부분의 팀들은 Java 11이나 Java 8을 사용하고 있습니다.
클라우드에 배포하는 팀들은 Java 11을 쓰고 있지만 그렇게 되기까지에도 오랜 시간이 걸렸습니다.

이 책은 Java 11을 중심으로 진행합니다.
a) 시장 점유율이 가능 큰 LTS 버전이고
b) 아직 Java 17이 눈에 띄게 적용되지 않았기 때문입니다.

그러나 Java 17의 기능들도 소개합니다.

이 논의들이 업그레이드를 꺼리는 일부 팀과 관리자를 설득하는데 도움이 되기를 바랍니다.

현대 자바의 핵심에 있는 언어와 플랫폼의 이중성에 대해 논의 하면서 시작해봅시다.

1.1 언어와 플랫폼

Java 라는 단어는 두 가지로 이해될 수 있습니다.

언어로서의 Java, 플랫폼으로서의 Java (JVM) 

여러 개별 사양이 Java 시스템을 관리합니다.

가장 중요한 스펙은 JLS(Java 언어 스펙) 및 VMSpec(JVM 스펙)입니다.
이 두 스펙은 서로 분리되었습니다. (VMSpec은 더 이상 JLS를 직접 참조하지 않습니다.)
이 부분은 최신 자바에서 중요한 포인트 입니다.

[Note] 실제로 최신 JVM은 범용적이며 특정 언어에 국한받고 있지 않습니다. 이것이 스펙이 분리된 이유 중 하나입니다.   

그러면 언어와 플랫폼을 어떻게 연결 할 수 있을까요?

언어와 플랫폼의 연결점은 클래스 파일 형식(.class)의 공유 정의입니다.

그림에서 볼 수 있듯이 Java 코드는 사람이 읽을 수 있는 Java 소스로 부터 시작해서 javac에 의해 .class 파일로 컴파일되고 JVM에 로드됩니다.

대부분의 Java 프레임워크들은 클래스를 로드 할 때

인스트루먼테이션(instrumentation, 오류를 진단하거나 추적 정보를 쓰기 위해 제품의 성능 정도를 모니터하거나 측정하는 기능(wiki))이나 alternative lookup(?) 같은 동적 행동들을 주입하기 위해 위해 클래스를 변환합니다.

[Note] 클래스 로딩에 대해서는 4장에서 구체적으로 다룹니다.

Java는 컴파일 언어 일까요 인터프리터 언어일까요?

Java는 JVM에서 실행되기 전 .class 파일로 컴파일되는 언어입니다. 하지만 결국은 JIT 컴파일러를 통해 JVM에 의해 바이트코드가 해석(interpreted)됩니다.

사실 JVM 바이트코드는 사람이 읽을 수 있는 소스와 기계 코드 사이의 중간 지점에 가깝습니다. 바이트코드는 실제로 기계 코드가 아니라 중간 언어(IL, intermediate language)의 한 형태입니다.

따라서 Java 소스를 바이트코드로 변환하는 프로세스는 C++ 또는 Go 에서 이야기하는 컴파일과는 다르며 javac는 gcc 와는 다릅니다. 결론적으로 Java 시스템에서의 컴퍼일러는 JIT 컴파일러 입니다.

그렇기 때문에 몇몇 사람들은 Java 시스템을 “동적으로 컴파일된” 시스템이라고 설명합니다.

[Note] 소스코드 컴파일러인 javac의 존재로 인해 많은 개발자들은 Java를 정적 컴파일 언어로 생각하게 됩니다. 사실은 런타임에 Java 환경은 매우 동적입니다. 약간 숨겨져 있을 뿐입니다.

그래서 결론은 무엇인가요?

“Java는 컴파일 언어 일까요 인터프리터 언어일까요?”

답은 “둘 다” 입니다.

1.2 새로운 Java 릴리즈 모델

Java 는 2006년에 GPLv2+CE 라이센스로 공개되었습니다.

이 때가 Java 6이 출시될 무렵이었기 때문에 Java 7이 오픈소스 소프트웨어 라이센스로 개발된 최초의 Java 버전이였습니다.

이후 OpenJDK에서 오픈소스 Java 플랫폼 개발이 진행되었습니다.

전체적인 코드베이스를 포함하여 프로젝트에 대한 많은 토론이 OpenJDK의 메일링 리스트에서 다뤄지고 있습니다. 

[Note] Sun Microsystems는 Java 7이 출시되기 직전에 Oracle에 인수되었습니다. 따라서 Oracle의 모든 Java 릴리스는 오픈 소스 코드 베이스를 기반으로 합니다.

Java의 오픈 소스 릴리즈는 이전에는 기능 중심 릴리즈 주기로 자리 잡았었습니다. (ex. Java 8의 람다, Java 9의 모듈).

그러나 Java 10 부터는 시간 주기 기반으로 릴리즈 하는 것으로 변경되었습니다.
즉, OpenJDK는 메인라인 개발 모델(Mainline Development Model)을 따르며 이는 아래의 규칙을 따릅니다.

  • 새로운 기능은 브랜치에서 개발이 되며 기능 구현이 완료 되었을 경우에만 merge 됩니다.
  • 릴리즈는 시간 주기에 따라 진행됩니다.
  • 기능 구현이 늦어질 경우 딜레이를 미루지 않습니다. 다음 릴리즈 때 배포됩니다.
  • 메인 브랜치의 HEAD는 항상 릴리즈 가능합니다. (이론적으로는)
  • 필요할 경우, 긴급 패치는 어느시점에든 가능합니다.
  • 별도의 Open JDK 프로젝트를 구성하여 장기적인 탐색과 연구를 통해 앞으로의 JDK 방향을 정하도록 합니다.

새로운 버전의 Java는 6개월마다 릴리즈 됩니다.

[Note] LTS 간격을 3년에서 2년으로 줄이기 위한 논의가 진행중입니다.

첫 번째 LTS 릴리즈는 Java 11이었으며 Java 8은 소급하여 LTS 릴리즈 세트에 포함되었습니다.
6개월 마다 주요 릴리즈가 진행되었고 이후 나온 LTS 버전은 Java 17(2021년 9에 릴리즈 됨) 입니다.

Java 11은 최근 점유율이 50% 이상을 차지하고 있습니다.
Java 17의 채택은 Java 8에서 Java 11로 이동하는 것보다 훨씬 빠를 것으로 예상됩니다.
(모듈 시스템 적용 및 기타 큰 제약들이 Java 11로 마이그레이션 하면서 해결됨)

Oracle은 배포판에 대한 라이선스를 변경했습니다.

JDK 11부터 오라클은 각 버전에 대해 6개월 동안만 지원 및 업데이트를 제공합니다.

따라서 오라클의 지원을 받기위해서 비용을 지불하거나 다른 배포판을 사용해야 합니다.

[Note] AdoptOpen JDK(adoptium.net 참조)는 공급업체 중립적인 고품질의 무료 오픈소스 Java 바이너리 배포판을 빌드 및 릴리즈합니다. 

LTS 릴리즈는 커뮤니티(주요 공급업체 포함)에서 유지 관리되고 있으며 정기적인 보안 업데이트를 받고 있습니다.

보안 및 버그 수정 외에도 최소한의 변경만 허용되는데 예상 수명 동안 올바르게 동작하는데 필요한 수정 사항들이 포함됩니다.

  • 새로운 일본 연호 추가
  • Time zone 데이터베이스 업데이트
  • TLS 1.3
  • 최신 대규모 워크로드를 위한 low-pause GC인 Shenandoah 추가
  • 최신 버전의 Xcode 에서 동작할 수 있도록 macOS용 빌드 스크립트 업데이트

1.3 향상된 유형 추론

일반적으로 자바는 장황한(verbose) 언어로 인식되고 있습니다. (쓸데 없이 붙여야 하는 것들이 많다는 의미인 것으로 보임)

자바 최신 버전에서는 유형 추론을 점점 더 많이 사용할 수 있도록 언어가 발전했습니다. 소스코드 컴파일러의 유형 추론 기능을 통해 프로그램의 일부 type 정보를 자동으로 처리 할 수 있게 되었습니다.

[Note] 유형 추론의 목적은 보일러플레이트(boilerplate, 문법상 어쩔 수 없이 반복되는 부분) 컨텐츠와 중복을 제거하여 보다 간결하고 읽기 쉬운 코드가 될 수 있도록 하는 것입니다.

이러한 추세는 제네릭 메서드가 도입된 Java 5에서 시작되었습니다.
제네릭 메서드는 제네릭 형식 인수에 대한 매우 제한된 형식의 형식 유추를 허용하므로 정확한 형식을 명시적으로 제공하지 않아도 되도록 합니다.

List<Integer> empty = Collections.<Integer>emptyList();

제네릭 형식 매개 변수는 다음과 같이 오른쪽에서 생략할 수 있습니다.

List<Integer> empty = Collections.emptyList();

유형 추론을 통해 불필요한 보일러플레이트 컨텐츠를 제거하게 되었습니다.

유형 추론과 관련된 Java의 다음 중요 개선 사항은 Java 7 에서 진행되었습니다.

Java 7 이전에는 다음과 같은 코드를 보는 것이 일반적이었습니다.

Map<Integer, Map<String, String>> usersLists =
    new HashMap<Integer, Map<String, String>>();

Java 7 부터는 다음과 같이 작성할 수 있습니다.

Map<Integer, Map<String, String>> usersLists = new HashMap<>();

[Note] 축약형 선언이 다이아몬드처럼 보이기 때문에 이 형식을 “다이아몬드 구문” 이라고도 합니다.

Java 8에서는 람다 표현식의 도입을 지원하기 위해 더 많은 유형 유추가 추가 되었습니다.

Function<String, Integer> lengthFn = s -> s.length() ;

최신 Java에서는 var 이라고 하는 LVTI(Local Variable Type Inference, 로컬 변수 타입 추론) 가 도입되면서 유형 유추가 한 단계 더 진행되었습니다. 이 기능은 Java 10에 추가되었으며 다음과 같이 유형을 유추할 수 있습니다.

var names = new ArrayList<String>(); 

[Note] var 는 소스 코드 컴파일러 (javac) 에서 처리되기 때문에 런타임이나 성능에는 전혀 영향을 미치지 않습니다.

컴파일러가 우형을 유추할 수 있으려면 프로그래머가 충분한 정보를 제공해야 합니다.

예를 들어 아래 코드에서는 fn의 유형을 추론할 수 있는 충분한 정보가 없으므로 컴파일 되지 않습니다.

var fn = s -> s.length();

타입 추론은 메력적으로 보일 수 있지만 다른 모든 것과 마찬가지로 트레이드 오프가 있습니다.
복잡도가 높을수록 컴파일 시간이 길어지고 추론을 실패할 가능성이 높아집니다.

다른 언어는 트레이드 오프를 선택할지 모르겠지만 Java는 명확하게 하는 것을 선택하였습니다.
선언 시에만 타입을 추론합니다.

지역 변수 타입 추론은 아무때나 사용하는 것이 아니라 코드를 명확하게 만드는 데 필요한 경우에만 사용해야 합니다. 
LVTI 사용 기준에 대한 간단한 가이드 라인은 다음과 같습니다.

  • 초기화 할 때 생성자 또는 정적 팩토리 메소드를 호출할 경우
  • 타입 명시를 제거하였을 때 반복되거나 중복되는 정보가 삭제되는 경우
  • 변수에 이미 해당 유형을 나타내는 이름이 있을 경우
  • 지역 변수가 좁은 영역에서 간단하게 사용될 경우

추가적으로 nondenotable(표시할 수 없는?) 이라는 var의 고급 사용법이 있습니다.

var duck = new Object() {
    void quack() {
        System.out.println("Quack!");
    }
}

duck.quack();

변수 duck은 Object 이지만 quack()이라는 메서드를 가진 상태로 확장되었습니다.

개체가 quack 메소드를 사용할 수 있지만 해당 유형에는 이름이 없으므로 해당 유형을 메서드 매개 변수나 반환 유형으로는 사용할 수 없습니다. 

fin.