3. 언어 사양과 API의 진화

김리원·2021년 9월 9일
0

Java 17로의 전환

목록 보기
3/6

이번에는 Java 17에서의 클래스 확장 및 더 충실해진 라이브러리에 대해서 알아봅시다.

Java 8에서 Java 17까지의 언어사양과 API의 방향

Java 사양 변경은 모두 CSR(Compatibility & Specification Review, Java 스펙변경 검토 프로세스)에서 관리되고 있습니다. 이는 버그와 마찬가지로 JBS에서 Issue Type이 CSR에 설정되어 관리되고 있습니다. 이를 JDK를 큰 기능변경(추가 및 삭제 포함) JEP로 CSR과는 별도로 논의됩니다. 이는 Python의 PEP(Python Enhancement Proposals)를 모델로 참고한 것으로 보여집니다.

수정버전이 Java 9에서 Java 17로 설정되 CSR은 이 글을 시점 기준(2021년 9월 1일)으로 1271건이 존재합니다. Java 8에 비해 많은 변화가 포함되어 있는 것은 이 숫자로도 증명이 됩니다. JEP만 해도 190건이상이 등록되어 있습니다. JEP 전부를 설명하기는 어렵고 그 중에서 비교적 JDK의 큰 변화를 아래 표를 통해 정리했습니다.

JEP에서 확인된 JDK 주요 기능 변경

Record - 데이터의 그릇을 나타내는 클래스

Java는 객체지향 언어이며 기본적으로 모든 것이 객체로 존재합니다. 그 중에서도 값의 집합을 나타내는 클래스는 쉽게 표현하는 방법이 Record입니다.

데이터의 그릇 클래스의 장점

Record는 JEP 359에서 JDK 14에서 미리보기 기능으로 처음 소개된 이후, 커뮤니티 피드백을 받으면서 JDK 16에서 정식 기능으로 올라가게 됩니다. 이것이 나오게된 배경을 설명하려고 합니다.

데이터의 그릇이란?

프로그램을 작성하고 단순한 데이터 그릇을 준비하고 싶은 경우가 있습니다. 예를 들어, 파일이나 소켓에서 입출력 데이터를 처리하기 위한 클래스와 데이터베이스에 넣는 데이터를 처리하는 클래스입니다. Record는 그런 용도에 맞게 설계되어 있습니다.

Record 등장배경

Java에서 이런 그릇을 만들 때, 코드가 중복되기 쉽습니다. C언어 구조체처럼 그냥 값을 유지할뿐이었다고 해도 값을 설정하는 setter, 취득하는 getter를 만들고 컬렉션 클래스로 변경하는 경우에는 equals()와 hasCode()등 모든 슈퍼 클래스인 Object에서 무시하고 정의해야 하는 경우가 적지 않습니다. 이 중복은 Java의 단점 중 하나가 되어 버렸습니다. 이를 해결하기 위해 IDE(Integrated Development Environment, 통합개발환경) 지원 기능과 Lombok과 같은 라이브러리를 사용하는 경우도 있지만, 코드 중복의 근본적인 해결을 한 것이라고 하기는 애매합니다.

그래서 나온 것이 바로 Record입니다. 이 클래스는 값을 유지하는 불변(불별 객체) 클래스를 쉽게 정의할 수 있는 것을 목표로 개발되었습니다. 이제 구체적인 사용법을 알아봅시다.

Record 작성하기

Record를 작성하는 것은 상당히 간단합니다. 간단한만큼, 정규 클래스에 비해 수는 적지만, 어느정도의 기능을 확장할 수 있습니다.

간단하게 Record 작성하기

Record의 가장 간단한 정의는 아래 예제를 살펴봅시다. 일반 클래스처럼 정의하며 다른 것은 class 대신 record를 넣는 정도입니다.

record Bike(String model, int disp, boolean big){}

Bike는 String의 model과 int disp, boolean의 big 3개를 저장하는 레코드 형식입니다. 괄호 안에 형식과 필드명을 정의합니다. 인스턴스화하는 경우, 클래스뿐문 아니라 아래와 같이 new를 사용합니다. 또한, 유지시키는 값은 생성자에서 지정해야 합니다.

var cbr = new Bike("600 F4i", 599, true);

레코드 타입 정의시 매개변수명이 그대로 getter하기 때문에 프로그램에서 매개변수명을 메소드로 호출할 수 있습니다.

System.out.println(cbr.model());

사용자 정의

아래 코드에서 브레이스 기호({ })내용은 비어 있습니다. 이는 Bike생성자는 인수에 주어진 값을 그대로 필드에 넣습니다. 만약, 값의 검증을 수행하려면, 아래와 같이 생성자에서 직접 정의할 수 있습니다.

public record Bike(String model, int disp, boolean big) {
	public Bike(String model, int disp, boolean big){
		if(disp > 400 && !big) {
			throw new IllegalArgumentException(
			model + " should be big");
		} else if(disp <= 400 && big) {
			throw new IllegalArgumentException(
			model + " shouldn't be big");
		}
		this.model = model;
		this.disp = disp;
		this.big = big;
	}
}

위에서는 자전거 배기량(disp)와 그것이 큰 자전거(big)의 조합을 확인하여 충돌이 발생하는 경우 IllegalArgumentException을 발생시킬 수 있습니다. 이것은 검증 예제이지만, 물론 값을 가공하여 설정도 가능합니다. 또한, 다음과 같이 직접 메소드를 추가할 수 있습니다. 따라서 보유하고 있는 값을 계산하여 리턴하는 일반클래스와 같은 방법도 가능합니다.

public float dispAsLiter() {
	return disp/1000.0f;
}

Record 제약

보기보다 편리한 Record이지만, 사용시 주의가 필요합니다. 가장 주의해야할 부분은 "불변"입니다. 이는 Record는 setter가 존재하지 않기 때문에 나중에 필드값을 덮어쓸수가 없습니다.
예를 들어, 데이터베이스에서 검색하여 나온 데이터를 가공하여 설정하고 이를 파일로 출력하는 것은 안됩니다. 새로운 값을 설정하려면 그것을 생성자에게 넘긴 Record를 다시 만들 필요가 있습니다.

Record 구현 확인하기

Record도 실제로는 클래스입니다. 작성이 단순화된 클래스 (암시적으로 정의되는 것이 많은 클래스)로 볼 수 있습니다.

Record 컴파일 결과

Record도 javac를 사용하여 컴파일합니다. 따라서 궁극적으로는 클래스파일이 존재합니다. 위 예제 Record를 JDK에서 제공되는 클래스파일분석기인 javap로 확인해 봅시다.

$ javap Bike
Compiled from "Bike.java"
final class Bike extends java.lang.Record {
	Bike(java.lang.String, int, boolean);
	public final java.lang.String toString();
	public final int hashCode();
	public final boolean equals(java.lang.Object);
	public java.lang.String model();
	public int disp();
	public boolean big();
}

Bike의 정의에 class를 가진것만으로도 Record로 정의한 Bike도 실체는 Java 클래스임을 알 수 있습니다. java.lang.Record클래스를 상속하여 각 필드의 getter와 공통 코드 생성자가 정의되어 있습니다.

제약 실제로 확인하기

다음 -private를 추가하여 javap로 확인해 봅시다. 아래와 같이 3개 필드가 보입니다.

private final java.lang.String model;
private final int disp;
private final boolean big;

final필드가 있습니다. 즉, 나중에 덮어쓸수가 없습니다. 이는 Record의 불변 제약 토대가 되고 있는 부분입니다.

암시적으로 재정의된 메소드

앞에서도 말했듯, Record는 java.lang.Record를 상속한 클래스입니다. Javadoc은 지금부터 설명하는 3가지 abstract메소드로 정의되어 있으며, record선언한 필드에서 암시적으로 파생됩니다. 또한, 이런 메소드는 런타임에서 동적으로 생성되므로 javap등으로 정적으로 확인하는 것이 어려워지고 있습니다.

equals (Object)

Record의 각 필드 결과를 평가하고 모두 동일한 경우에만 true를 리턴합니다. 평가는 각 형태에 대응하는 equals()를 사용합니다.

hashCode()

Record의 모든 필드의 해시값을 hashCode()를 통해 계산한 해시값으로 리턴합니다.

toString()

< 레코드명>[<필드명>=<설정 >, ...] 의 형태로 Record 내용을 String형으로 리턴합니다. 아래는 출력결과를 보여줍니다.

jshell> cbr.toString()
$3 ==> "Bike[model=600 F4i, disp=599, big=true]"

HttpClient - 글로벌트렌드에 맞춘 HTTP클라이언트

Java SE API만으로 HTTP클라이언트를 구현하게 되면 HttpURLConnection등을 이용하는 것이 일반적인 방법이었습니다. 단, 기대하는 동작을 하기 위해 속성을 많이 설정하기나, 본질적으로 비동기 처리에 대응하지 않거나 정교한 서비스를 만들려고 해도 좀처럼 프로그램을 어렵습니다. 따라서 Web API 클라이언트 등, HTTP접근하는 측의 프로그램을 작성하는 타사 라이브러리를 이용하는 경우도 있습니다. 그래서 HTTP클라이언트 기능을 강화한 모듈이 JEP 321에 의해 공식적으로 JDK 11부터 지원하였습니다. 그것이 바로 HttpClient입니다.

HttpClient에 의해 지원하는 웹기술

HttpsClient는 기존 Java SE APi만으로는 실현하기 어려웠던 HTTP통신에 관한 다양한 기능이 포함되어 있습니다.

HTTP/2

HTTP/2를 지원합니다. HttpURLConnection은 HTTP/1.1까지만 대응하고 있습니다. 물론 ALPN(Application-Layer Protocol Negotiation)애플리케이션계층에서의 프로토콜 사용이 가능하게 하는 TLS(Trnasport Layer Security)확장도 대응하고 있습니다.

동기/비동기

HttpClient는 동기화된 통신뿐만 아니라, 비동기 통신을 지원합니다. 응답은 CompletableFuture를 받을 수 있기 때문에 응답수신 후, 처리를 비동기전첼 작성할 수 있습니다.

Reactive Stream

HttpClient를 Flow API를 이용한 Reative Stream에서 처리할 수 있습니다. 그러나, 이용할 경우 Flow 구현 또는 Akka Streams와 같은 구현클래스와 같이 사용해야 합니다.

HttpClient의 기본적인 사용법

HttpClient와 같이 사용하는 클래스(HttpRequest)는 Builder패턴에서 인스턴화할 수 있습니다. Builder패턴은 인스턴스화에 필요한 매개변수를 메소드 호출로 연결하여 마지막에 그것 모두를 반영한 인스턴스를 얻게 됩니다. 기본적으로 HttpClient와 Http요청을 추상화하는 HttpRequest를 Builder패턴으로 만들고 그것을 HttpResponse에서 받습니다.
이번 코로라19 감염대책에 관한 각종 데이터중에서 감염자 수를 얻을 수 있는 Web API에서 정보 취득을 HttpClient로 보냅니다.

HttpClient 만들기

우선 할 것이 HTTP 클라이언트를 추상화하는 HttpClient인스턴스 생성합니다.

var client = HttpClient.newBuilder()
	.version(HttpClient.Version.HTTP_2) // (1)
	.followRedirects(HttpClient.Redirect.NORMAL) // (2)
	.connectTimeout(Duration.ofSeconds(3)) // (3)
	.build(); // (4)

프로토콜 버전 HTTP/2를 전제로 (1) 서버에서 리디렉션 지시가 HTTP(300번대 응답)이 있었을 경우는 기본적으로 그 지시에 따라 (2) 연결 제한 시간을 3초로 설정합니다. (3) 그리고 그 설정한 인스턴스를 가져옵니다. (4)

HttpRequest 만들기

다음 HTTP 요청을 추상화하는 HttpRequest 인스턴스 생성합니다.

var request = HttpRequest.newBuilder()
	.uri(JSON_URI) (1)
	.timeout(Duration.ofSeconds(5)) (2)
	.GET() (3)
	.setHeader("Accept", "application/json") (4)
	.build(); (5)

연결하는 URL(URI)를 지정(1)하고 요청 제한 시간을 5초로 설정(2)합니다. GET 요청을 발행(3) 후, 요청헤더의 Accept에 JSON을 지정합니다(4). 그리고 마지막으로 인스턴스를 가져옵니다. (5)

요청 제출

준비가 되면 요청을 전송합니다.

var future = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) (1)
  .thenApply(HttpResponse::body) (2)
  .thenAccept(Shutoko::processJSON); (3)
  System.out.println("Processing..."); (4)
  future.join(); (5)

비동기 전송하려면 (1)처럼 sendAsync()를 사용합니다. 만약, 동기화된 형태로 처리하는 경우, send()입니다. 이때, 제2번째 인수에 응답 핸들러를 지정합니다. 이번에는 InputStream에서 얻을 수 있습니다.
응답결과는 HttpResponse:body에서 지정된 처리기 형식(여기서는 InputStream)에서 얻은(2), (3)처리에 전달됩니다. 요청전송 결과는 CompletableFuture로 리턴하기 위해, (4)처럼 처리중임을 사용자에게 알린 후, join()(5)종료를 처리할 수 있습니다.

응답 처리

예제와 같이 InputStream에서 응답을 받아 요청을 끝까지 읽지 않을 가능성이 있는 경우 리소스 확보를 위해 스트림을 직접 처리해야 합니다. 다음과 같이, try - withresources에서 폐쇄를 보장하는 코드를 작성해두면 좋습니다.

private static void processJSON(InputStream in){
try(var reader = new InputStreamReader(in)){
(생략)

Unix 도메인 소켓 - 프로세스간 통신

Unix 도메인 소켓은 BSD소켓 API를 사용하여 프로세스 간 통신을 하는 구조를 말합니다. 주소는 파일 경로를 지정할 수도 있고, 효율적인 통신이 가능합니다. 명칭은 Unix계열의 운영체제 기능이지만, 이 기능이 추가된 JEP 380으 윈도우 10, 윈도우서버 2019에서 Unix 도메인 소켓을 지원한다고 발표되어 있기 때문에 활용도는 높습니다.

Java의 Unix도메인 소켓

소켓통신을 위해 프로토콜 패밀리와 주소정보 설정이 필요하지만,Java에서는 Unix 도메인 소켓용 기능이 추가되어 있지만 몇가지 주의가 필요합니다.

사용가능한 소켓채널만 이용합니다

Unix 도메인 소켓 지원은 SocketChannel과 ServerSocketChannel만 있습니다. Socket과 ServerSocket은 사용할 수 없습니다.

소켓파일 삭제는 직접적인 책임이 필요합니다.

통신에 사용되는 소켓파일은 ServerSocketChannel을 만들지만, 이는 소켓 종료시 삭제되지 않습니다. 애플리케이션에서 삭제할 책임이 있습니다.

사용 예제

그렇다면, Unix도메인 소켓 사용법을 살짝 알아봅시다. 예제2는 서버측, 예제3은 클라이언트측입니다.
서버, 클라이언트 모두 프로토콜 제품은 UNIX를 지정합니다. (2) 주소(파일)은 서버와 클라이언트 모두 UnixDomainSocketAddress::of를 사용하여 파일 경로에서 생성합니다. (3) 서버측에서만 프로그램 종료시, 소켓 파일을 삭제하도록 설정합니다. (1)

예제2 <서버측>

private static void doServer(Path path) throws IOException {
	path.toFile().deleteOnExit(); (1)
	try( var server = ServerSocketChannel.open(
			StandardProtocolFamily.UNIX) (2)
		.bind(UnixDomainSocketAddress.of(path))) { (3)
	try(var sock = server.accept()) {
(생략)

예제3 <클라이언트측>

private static void doClient(Path path, String message) throws IOException {
	try(var sock = SocketChannel.open(
			StandardProtocolFamily.UNIX)) { (2)
	sock.connect(
		UnixDomainSocketAddress.of(path)); (3)
(생략)

WSL 및 Windows 통신 보기

최근 윈도우 환경에서는 WSL(Windows Subsystem for Linux)가 준비되어 있으며, 개발에 활용이 되고 있는 추세입니다. Windows Command Line블로그에서 WSL 및 Windows간의 Unix 도메인 소켓에 의한 상호운용성이 설명되고 있습니다. 위 예제2,3의 Java프로그램을 실행해 봅시다.

WSL1 예

우선 기존부터 존재하는 WSL1입니다. 이 블로그에 설명한것처럼 DrvFS(/mnt/c 등) 소켓파일을 배치하면 WSL, Windows 어떤 서버든 문제없이 통신이 가능합니다.

WSL2 예

Hyper-V기반의 WSL2는 어떻게 사용될까요? WSL2가 클라이언트인 경우, ConnectException이 서버에서 ScoketException이 발생하게 됩니다. 이는 WSL의 문제로 보고 있으며, 이 글을 시점까지(2021년 8월)기준으로 해결되지 않은 상태입니다.

Sealed - 상속을 제한할 수 있는 신규 클래스

Java의 클래스와 인터페이스(이후 "타입"이라고 부름)는 누구나 상속할 수 있거나, final을 선언하여 모두 상속할 지를 0부터 100까지 지정하는 구조였습니다. 이에 대해 명시적으로 지정된 유형에서만 상속을 허용할 수 있는 Sealed가 도입되었습니다.
Sealed는 JEP 360에서 JDK 15미리보기 기능으로 도입된 커뮤니티 의견에 따라 개선을 거쳐 JEP 409에서 JDK 17부터 정식 기능으로 공지되었습니다.

다시 이해하는 상속

Java 언어가 가지는 특징 중 하나가 다형성(polymorphism)이 있습니다. 이 다형성은 하나의 메소드를 호출하면 대상 개체마다 다른 행동을 수행하는 구조의 것을 말합니다. 이 구조를 실현하는 방법 하나로 상속이 있습니다. 상속은 아래와 같이 extends키워드를 사용하여 특정 유형의 특성을 상속하고 새로운 형으로 정의하는 것을 말합니다.

interface subinterface extends superinterface { ... 처리내용 }
class subclass extends superclass { ... 처리내용 }

근원이 되는 형을 슈퍼타입이라고 하고 상속하는 새로운 형의 서브타입이라고 합니다. 서브타입은 슈퍼타입의 메소드와 변수를 모두 보유하고 있으며 subtype과 동일한 메소드를 구현하는 개체별도 다르게 동작할 수 있습니다.

상속의 장점

상속을 이용하는 것으로 서브 클래스에서 슈퍼클래스의 코드를 재사용할 수 있기 때문에 이런 작업을 추상화한 상태로 기능을 추가하기 위한 코드 재사용성과 유지보수성이 향상된다는 장점이 있습니다.
일반적으로 슈퍼타입이 일반적인 기능을 제공하고 서브타입으로는 유즈케이스별로 특화된 구체적인 기능을 제공하여 코드 가독성을 유지하면서 재사용성을 향상시킬 수 있습니다.

Sealed의 장점

이 상속의 장점은 막강하지만, 설계적으로 문서화등으로 공유되어 있지 않으면 사용법에 익숙하지 못하게 되어 코드 가독성과 유지보수성이 크게 손상될 가능성이 큽니다. 따라서 상속되지 않는 클래스의 경우, 클래스를 final로 선언하거나, 생성자를 private로 선언하여 시스템으로 상속되지 않도록 하는 드으이 노력이 요구되는 경우도 있습니다. 이것들이 Sealed하여 슈퍼타입의 생성자가 명시적으로 상속위치를 지정할 수 있게 되었고 시스템으로는 API의 일관성을 지킬 수 있게 되었습니다.

Sealed 클래스 만들기

Sealed작성은 아주 간단합니다.

기본적인 작성법

Sealed의 기본적인 정의 방법은 아래 예제4를 보면 알 수 있습니다. 인터페이스 선언(interface), 클래스 선언(class)의 직전에 sealed를 선언하여 Sealed임을 나타냅니다. 또한, 형명 뒤에 permits를 쉼표로 구분하여 Sealed를 상속하는 것을 허용하는 자료형을 설명합니다.
예제4의 경우 RocketMotor인터페이스 및 MotorBike, Car 클래스는 Motor를 상속 및 구현할 수 있지만, 다른 형은 Motor를 계속하고 하위형이 될 수 없습니다. 예를 들어 아래와 같이 Bicycle클래스가 Motor인터페이스를 구현하면 오류가 발생합니다.

$ cat Bicycle.java
public final class Bicycle implements Motor {}
$ javac Bicycle.java
Cone.java:1 : 오류 : 클래스는 봉인 클래스 Motor를 확장 할 수 없습니다 ('permits'절에 지정되어 있지 않기 때문입니다)
public final class Bicycle implements Motor {}
^
오류 1 개

상속제한된 상속

permits절에 지정된 서브타입의 상속제한은 상속되지 않습니다. 따라서 명시적으로 Sealed인지 여부를 표시해야 합니다. 아래와 같이 서브타입도 Sealed하고 상속을 제한하려면 sealed선언을 하고, Sealed하고 싶지 않는 경우, non-sealed선언을 통해 더이상 상속을 하고 싶지 않다고 final선언을 합니다.

// 서브타입도 상속을 제한하는 경우 sealed
sealed interface RocketMotor extends Motor permits
Rocket {}
// 서브타입은 자유롭게 상속시키고 싶은 경우 non-sealed
non-sealed class Rocket implements RocketMotor {}
non-sealed class Bike implements Motor {}
// 더 이상 상속시키지 않고 싶다면 final (이전 그대로)
final class Car implements Motor {}
// interface는 final 선언이 불가하므로 아래와 같이 선언하면 오류 발생함
final interface RocketMortor extends Motor {}

이렇게 선언하지 않으면 컴파일시 오류가 발생합니다.

예제4 심플한 Sealed 정의

public sealed interface Motor
permits RocketMotor, MotorBike, Car {}
Car.java:1 : 오류 : sealed non-sealed 또는 final 수식이 필요합니다
public class Car implements Motor {
^
오류 1 개

예외적으로 서브타입이 암시적으로 final 클래스인 Record와 Enum클래스인 경우 선언이 없어도 오류는 발생하지 않습니다.

Sealed클래스의 제약

sealed가 편리하지만, permits에서 지정한 형은 동일한 모듈내에 있을 필요가 있다는 것입니다. 모듈화되어 있지 않는 경우, 동일한 패키지에 배치해야 합니다. 즉, 원칙적으로는 Sealed는 상속 대상으로 지정된 서브타입과 같은 관리하에 유지보수를 해야 합니다.

앞으로의 Pattern matching과의 조합

Sealed의 앞으로 미래는 컴파일러가 완전성을 추측가능하게 되었다는 점입니다. 예를 들어, switch문과 형 캐스팅을 이용한 패턴패칭등을 고려할 경우 어떤 문제점이 있으면 구체적으로 컴파일에서 지적이 가능하게 되었다는 것입니다.

Motor mileage(Motor m) {
	return mileage (m) {
		case Car c -> c.mileage();
		//case Bike가 없기 때문에 컴파일 오류가 발생하고 default절이 필요 없음
	};
}
profile
개발자, IT강사, sage.riwon.kim@gmail.com

0개의 댓글