한빛출판네트워크

IT/모바일

프로그래밍 언어에도 결이 있다? (자바와 코틀린을 기준으로)

한빛미디어

|

2023-02-15

by 덩컨 맥그레거, 냇 프라이스

나무와 마찬가지로 프로그래밍 언어에도 결이 있다. 

 

목공과 마찬가지로 프로그래밍에서도 결대로 하면 모든 일이 부드럽게 진행된다. 하지만 결을 거스르면 작업이 어려워진다. 

 

프로그래밍 언어의 결을 거스르면 원래 필요한 양보다 코드를 더 많이 작성해야 하고, 성능이 나빠지며, 실수를 하기 쉽다. 편리하게 사용할 수 있는 기본 구현 대신 직접 구현을 해야 하며, 작업을 진행하는 모든 과정에서 언어가 제공하는 도구와 싸워야만 한다.

 

tree-g13907873c_600.jpg

 

결을 거스르면 끊임없는 노력이 들어가지만 그에 따른 결과는 보장할 수 없다. 예를 들어, 자바 코드를 함수형 스타일로 작성할 수 있지만 자바 8 이전에 이런 시도를 하는 프로그래머는 거의 없었다. 이런 현상에는 타당한 이유가 있다. 

 

다음은 덧셈 연산자를 사용해 리스트를 접어서(fold) 리스트에 들어있는 모든 수의 합계를 계산하는 코틀린 코드다.

 

 

val sum = numbers.fold(0, Int::plus)

 

 

자바 1.0에서 똑같은 일을 하기 위해 필요한 코드와 이 코드를 비교해 보려고 한다. 잠시 눈을 감는다. 짙은 안개가 여러분을 감싸고 1995년으로 돌아간다...

 

자바 1.0에는 일급 시민(first-class citizen)인 함수가 없었다. 따라서 함수를 객체로 구현하고, 타입이 다른 함수에 대해서는 직접 인터페이스를 작성해야 했다. 예를 들어 덧셈 함수는 인자를 2개 받기 때문에 인자가 2개인 함수를 나타내는 타입을 정의해야만 했다.

 

 

public interface Function2 {

Object apply(Object arg1, Object arg2);

}

 

 

그 후 fold 고차 함수를 작성해야만 했다. 이 함수는 Vector 클래스에 대해 필요한 이터레이션과 상태 변경을 감춰준다(1995년은 자바 표준 라이브러리에 컬렉션 프레임워크가 포함되기 전이다).

 

 

public class Vectors {

public static Object fold(Vector l, Object initial, Function2 f) {

Object result = initial;

for (int i = 0; i < l.size(); i++) {

result = f.apply(result, l.get(i));

}

 return result;

}

  ... 그 외 벡터에 대한 연산

}

 

 

이 fold 함수에 넘기는 모든 함수에 대해 클래스를 따로 작성해야 한다. 덧셈 연산자를 값처럼 주고받을 수 없고... 1995년의 자바 언어에는 메서드 참조나 클로저도 없었고, 심지어 내부 클래스(inner class)도 없었다. 

 

게다가 자바 1.0에는 제네릭스(generics)나 자동 박싱도 없었다. 우리는 인자를 원하는 타입으로 직접 캐스팅하고 참조 타입과 원시 타입을 변환하는 박싱 코드를 직접 작성해야 했다.

 

 

public class AddIntegers implements Function2 {

public Object apply(Object arg1, Object arg2) {

int i1 = ((Integer) arg1).intValue();

int i2 = ((Integer) arg2).intValue();

return new Integer(i1 + i2);

}

}

 

 

이렇게 하고 나서야 이 모든 것을 활용해 합계를 계산할 수 있다.

 

 

int sum = ((Integer) Vectors.fold(counts, new Integer(0), new AddIntegers()))

.intValue();

 

 

(네, 그렇습니다.) 2020년의 주류 언어에서는 한 줄로 되는 일을 과거에는 엄청나게 많은 노력을 기울여야 할 수 있었다. 하지만 그게 함수형 프로그래밍을 자바로 하지 않는 이유의 전부인 것은 아니다. 

 

자바에는 표준 함수 타입이 없으므로 함수형 스타일로 작성한 여러 라이브러리를 쉽게 조합할 수 없다. 우리는 여러 라이브러리에 정의된 함수 타입간의 변환을 수행하는 어댑터(adapter) 클래스를 직접 작성해야 한다. 

 

그리고 자바 가상 머신(Java Virtual Machine, JVM)이 아직은 JIT를 제공하지 않았고 아주 단순한 가비지 컬랙터(garbage collector)만 있었기 때문에, 우리가 작성한 함수형 코드는 다음의 명령형 대안에 비해 성능이 더 나빴다.

 

 

int sum = 0;

for (int i = 0; i < counts.size(); i++) {

sum += ((Integer)counts.get(i)).intValue();

}

 

 

1995년에는 자바를 함수형 스타일로 작성하려는 노력을 정당화할 만한 이점이 '그냥' 없었을 뿐이다. 자바 프로그래머는 컬렉션에 대한 이터레이션을 수행하면서 상태를 변경하는 명령형 코드를 작성하는 편이 훨씬 더 쉽다는 사실을 알았다.

 

그러니까. 함수형 코드를 작성하는 것은 자바 1.0의 결에 거스르는 일이다. 

 

언어 설계자와 사용자들이 언어의 특성이 상호 작용하는 방식에 대해 공통으로 이해한 내용이, 이런 사람들이 작성한 여러 프로그램의 기초가 되는 라이브러리 안에 코드화되어 쌓이면서 언어의 결이 자라난다. 

 

이런 결은 프로그래머가 해당 언어로 코드를 작성하는 방식에 영향을 끼친다. 언어, 라이브러리, 프로그래밍 도구의 진화에도 영향을 끼치면서 언어의 결이 달라진다. 

 

이렇게 바뀐 결은 다시 프로그래머가 해당 언어를 사용해 코드를 작성하는 방식을 바꾸고, 이런 과정에 피드백을 거치면서 진화하는 연속적인 사이클을 이루게 된다.

 

예를 들자면 이렇다. 시간이 지남에 따라 자바 1.1 언어에 익명 내부 클래스가 추가됐고, 자바 2에서 표준 라이브러리에 컬렉션 프레임워크가 추가됐다. 익명 내부 클래스를 사용하면 더 이상 fold 함수에 전달할 함수로 사용하기 위한 클래스에 이름을 붙이지 않아도 된다. 하지만 익명 클래스를 사용하는 코드가 더 읽기 쉬운지에 대해서는 논란의 여지가 있다.

 

 

int sum = ((Integer) Lists.fold(counts, new Integer(0),

new Function2() {

public Object apply(Object arg1, Object arg2) {

int i1 = ((Integer) arg1).intValue();

int i2 = ((Integer) arg2).intValue();

return new Integer(i1 + i2);

}

})).intValue();

 

 

함수형 프로그래밍의 기본 숙어들은 여전히 자바 2의 결을 거스르는 방식이다. 2004년으로 시간을 돌려보면, 자바 5가 배포되면서 언어에 큰 변화가 있었다. 

 

자바 5에는 제네릭스(generics)가 추가됐고, 이로 인해 타입 안전성이 좋아지고 불필요한 준비 코드(보일러 플레이트, boiler plate)가 줄어들었다.

 

 

public interface Function2<A, B, R> {

R apply(A arg1, B arg2);

}

int sum = Lists.fold(counts, 0,

new Function2<Integer, Integer, Integer>() {

@Override

public Integer apply(Integer arg1, Integer arg2) {

return arg1 + arg2;

}

});

 

 

자바 개발자들이 구글 구아바(Guava) 라이브러리를 사용해 컬렉션에 대해 사용할 수 있는 공통 고차 함수를 추가하는 경우가 종종 있었다(다만 fold는 구아바에 들어있지 않다). 하지만 구아바를 작성한 이들조차 성능도 좋고 가독성도 좋다는 이유를 들어 기본적으로는 명령형 코드를 작성하라고 권장했다.

 

함수형 프로그래밍은 여전히 자바 5의 결을 거스르는 방식이었지만, 함수형 프로그래밍이라는 트렌드가 시작되는 움직임이 보여지기도 했다. 

 

자바 8에는 익명 함수(즉 람다 식)와 메서드 참조가 추가됐고, 표준 라이브러리에 스트림 API도 추가됐다. 컴파일러와 가상 머신이 람다를 최적화해서 익명 내부 클래스의 성능상 부가비용을 피할 수 있었다. 

 

스트림 API는 함수형 숙어를 전적으로 포용했으며, 마침내 다음과 같은 코드를 작성할 수 있게 됐다.

 

 

int sum = counts.stream().reduce(0, Integer::sum);

 

 

하지만 이 과정이 순풍에 돛을 단 것처럼 이뤄지지는 않는다. 우리는 덧셈 연산자를 스트림의 reduce 함수에 전달할 수 없다. 대신, 표준 라이브러리에서 같은 일을 하는 Integer::sum 함수를 제공한다. 

 

자바의 타입 시스템은 참조와 원시 타입을 구분하기 때문에 여전히 (함수형 프로그래밍을 할 때) 이상한 경우가 생겨나곤 한다. 스트림 API에는 일반적인 함수형 언어에서 (심지어는 함수형이 아닌 루비 언어조차도) 찾아볼 수 있는 몇몇 공통 고차 함수가 빠져있다. 

 

체크 예외(checked exception)는 스트림 API, 더 일반적으로는 함수형 프로그래밍과 함께 잘 엮이지 않는다. 그리고 값처럼 쓰일 수 있는 불변 클래스를 만들려면 여전히 성가신 준비 코드를 많이 작성해야 한다. 

 

하지만 자바 8부터는 근본적으로 함수형 스타일이 작동할 수 있는 스타일의 언어로 바뀌었고, 함수형 프로그래밍은 자바의 결을 따른다고 말하기는 어렵지만 적어도 결을 거스르는 프로그래밍 방식은 아니게 됐다.

 

자바 8 이후의 배포에는 보다 함수형 스타일인 숙어를 지원하는 다양한 작은 변화들이 포함됐다. 하지만 이 어떤 변화도 작성했던 합계 계산 코드를 바꿔주지는 못한다. 그리고 오늘날을 맞이했다. 

 

자바의 경우 언어의 결, 그리고 프로그래머가 언어를 적용하는 방식에서 몇 가지 서로 다른 프로그래밍 스타일을 따라 진화해 왔다.

 

코틀린의 결

코틀린은 얼마 되지 않은 언어이지만, 자바와 결이 다르다.

 

코틀린 홈페이지의 ‘왜 코틀린인가(why Kotlin)’ 파트에는 간결성, 안전성, 상호 운용성, 도구 친화성이라는 네 가지 목표가 적혀있다. 코틀린 언어와 표준 라이브러리 설계자들은 이런 설계 목표에 기여할 수 있도록 암시적으로 선호도를 코드화했다. 선호도는 다음과 같다.

 

| 코틀린은 가변 상태를 변경하는 것보다 불변 데이터를 변환하는 쪽을 더 선호한다 |

데이터 클래스를 사용하며 값 의미론을 제공하는 새로운 타입을 쉽게 정의할 수 있다. 표준 라이브러리를 활용하면 루프를 돌면서 가변 데이터를 메모리에서 갱신하는 것보다 불변 데이터로 이뤄진 컬렉션의 변환을 훨씬 더 쉽고 간결하게 활용할 수 있다.

 

| 코틀린은 동작을 명시적으로 작성하는 쪽을 더 선호한다 |

예를 들어, 코틀린에는 암시적인 타입 변환이 없다. 심지어 더 작은 데이터 타입을 더 큰 데이터 타입으로 자동 변환해 주지도 않는다. 

 

자바는 정보 손실이 없으므로 int를 long으로 암시적으로 변환해 준다. 하지만 코틀린에서는 Int.toLong()을 명시적으로 호출해야 한다. 

 

명시성을 선호하는 경향은 흐름 제어에서 더 강하다. 직접 작성한 타입에서 산술 연산이나 비교 연산을 오버로드할 수는 있지만, 쇼트서킷(short circuit) 연산자(&&와 ||)에 대한 오버로드는 불가능하다. 이런 연산자를 오버로드할 수 있게 허용하면 제어 흐름이 달라질 수 있기 때문이다.

 

| 코틀린은 동적 바인딩보다 정적 바인딩을 더 선호한다 |

코틀린은 타입이 안전한, 합성적인 코딩 스타일을 장려한다. 확장 함수는 정적으로 바인딩된다. 

 

기본적으로 클래스는 확장될 수 없고, 메서드는 다형적이지 않다. 명시적으로 다형성과 상속을 활성화해야 한다. 리플렉션을 사용하고 싶으면 플랫폼별로 다른 리플렉션 라이브러리를 의존 관계에 추가해야만 한다. 

 

코틀린은 동적으로 코드를 분석해 프로그래머를 안내해 주고, 코드 내비게이션을 자동화하며, 프로그램을 자동으로 변환할 수 있는 코틀린 언어를 잘 아는 IDE와 함께 사용하도록 만들어졌다.

 

| 코틀린은 특별한 경우를 좋아하지 않는다 |

자바와 달리 코틀린에는 예측할 수 없는 방식으로 작동하는 특별한 경우가 더 적다. 원시 타입과 참조 타입 사이에 구분이 없다. 반환 시 아무 값도 돌려주지 않는 함수에 대한 void 타입도 없다. 

 

코틀린 함수는 값을 반환하거나 아무것도 반환하지 않거나 둘 중 하나다. 확장 함수를 사용하면 기존 타입에 새로운 연산을 추가할 수 있고, 호출하는 쪽의 코드에서는 기존 연산과 확장 함수를 구분할 수 없다. 

 

인라인 함수를 사용해 새로운 제어 구조를 작성할 수도 있다. 그리고 break, continue, return 문은 이런 제어 구조 내부에서도 기본 내장된 제어 구조 내부에서와 똑같이 작동한다.

 

| 코틀린은 마이그레이션을 쉽게 하기 위해 자신의 규칙을 깬다 |

코틀린 언어에는 숙어처럼 사용하는 자바와 코틀린 코드가 동시에 존재하도록 허용하기 위한 기능이 들어있다. 이런 기능 중 일부는 타입 검사기가 보장하는 안전성을 없애기 때문에 기존 자바 코드와 함께 사용할 때만 사용해야 한다. 

 

예를 들어, lateinit은 타입 시스템에 구멍을 만들기 때문에, 객체를 리플렉션을 통해 초기화하는 자바 의존 관계 주입 프레임워크는 컴파일러가 일반적으로 강제하는 캡슐화 경계를 간단히 무시하고 값을 주입할 수 있다. 

 

어떤 프로퍼티를 lateinit var로 선언하면 이 값을 읽기 전에 초기화할 책임이 작성자 본인에게 있다. 컴파일러는 이런 실수를 감지할 수 없다.

 

코틀린을 배우기 시작한 가장 초기에 코틀린으로 작성했던 코드를 다시 살펴보니, 코틀린 문법이라는 옷을 입은 자바 코드처럼 보였다. 우리는 오랫동안 수많은 자바 코드를 작성해 왔기 때문에 각인된 습관이 코틀린 코드를 작성하는 방식에도 영향을 끼쳤다. 

 

쓸데없는 보일러플레이트를 사용하고, 표준 라이브러리를 제대로 활용하지 못했고, 타입 검사가 널 안전성을 강제하는 기능을 잘 활용하지 못해서 널을 무조건 피했다. 

 

팀에 있던 스칼라 개발자들은 반대 방향으로 너무 멀리 나갔다. 그들이 작성한 코드는 스칼라를 흉내 내고 싶어 하면서 하스켈 코스프레를 하는 코틀린 코드처럼 보였다. 당시에는 팀원 누구도 코틀린의 결을 따라 작업할 때 얻을 수 있는 최적점(스위트 스폿)을 찾지 못했었다.

 

자연스러운 코틀린에 이르는 길은 당시 우리가 사용해야만 했던 자바 코드 때문에 더 복잡해졌다. 

 

실전에서는 코틀린을 배우는 것만으로 충분하지 않다. 자바와 코틀린 양쪽의 서로 다른 결을 모두 다룰 수 있어야 했고, 자바에서 코틀린으로 나가는 과정에서 두 언어 모두에 공감해야만 했다.

 

 


 

이 글은 <자바에서 코틀린으로> 도서 내용 중 일부를 편집하여 작성되었습니다. 기존 자바 코드를 코틀린 코드로 변환하는 과정을 통해 효율적으로 코틀린을 활용하는 방법이 궁금하다면, 하기 도서 링크에서 보다 자세한 정보를 확인할 수 있습니다.

 

M_044_1_300.jpg

 자바에서 코틀린으로』

댓글 입력