안녕하세요, 저는 게임 "쿠키런: 킹덤"의 서버 파트에서 근무하고 있는 지민규라고 합니다. 시작하기에 앞서 어째서 이 글을 쓰게 되었는지를 말씀드리고 싶은데요, 흔히 언어는 우리의 사고를 형성한다는 말이 있습니다. 언어가 가진 문장 구조과 발성, 그리고 단어들의 형태에 따라 세상에 대한 시각이 바뀐다고 합니다. 저는 다양한 프로그래밍 언어를 배우면서 비슷한 경험을 했습니다. 첫 프로그래밍 언어로 C++를 배웠을 때 C++가 가진 한계점이 제 프로그래밍 방식의 한계점이 되었었고, Go를 배웠을 때 Go가 가진 언어의 구조가 프로그래밍하기에 충분하다고 생각했었습니다. 하지만 이후 더 많은 프로그래밍 언어들을 배우면서 생각이 더 넓어지고 프로그래밍 언어들 사이의 공통점이 보이기 시작했으며, 어떻게 하면 더욱 깨끗한 코드를 구현할 수 있을지 깨달아가게 되었습니다. 다양한
프로그래밍 언어를 배워야하는 이유는 단순히 프로그래밍 언어들을 적재적소에 사용할 수 있어서가 아닙니다. 다양한 프로그래밍 언어를 배우면 아키텍처 디자인을 할 때 좀 더 넓은 시각을 가질 수 있게 되며, 어떠한 코드가 이상적인 코드인지 좀 더 알게 되고 좀 더 깊은 고민을 하며 구현할 수 있게 된다고 생각합니다. 이 글은 주요 프로그래밍 언어들 중 하나를 깊게 이해하고 있는 분들을 대상으로 썼습니다. 그런 분들이 좀 더 다양한 시각을 가질 수 있도록 돕기위해 제가 프로그래밍 언어들을 배우고 사용하면서 알게된 것들을 조금이나마 나눠드리고자 합니다. 가장 유명하면서도 가장 끔찍한 프로그래밍 언어 기능 중 하나를 꼽으라고 한다면 null이라고 자신있게 말할 수 있습니다. null이라는 값을 데이터에 사용하면 “없음”을 표현할 수 있습니다. null 참조는 “10억 달러짜리 실수”라는 악명이 붙어있습니다. 이는 전세계 IT회사들 통틀어 null을 참조하려다 생겨난 버그로 인해 10억 달러어치의 손실을 낼 정도로 많은 버그를 일으켰다는 관점이 있기 때문입니다. 실수로 null이 들어있는 데이터를 참조하면 값이 존재하지 않아서 프로그램을 진행할 수 없어 런타임 에러를 내게 됩니다.
Java에서는 코드에서 null을 참조할 가능성이 있어도 컴파일을 막지 않습니다. 그렇기에 null 에러를 피하려면 모든 필드와 함수 인수를 문서화하여 null이 들어갈 가능성이 있는 값을 표기해야 합니다. 만약 문서가 잘못되었거나 null 체크하는 것을 잊었다면 곧바로 에러로 이어질 수도 있습니다.
이러한 문제들을 컴파일 타임에 잡기 위해서는 IDE의 도움을 받거나 null이 들어간 필드들의 타입을 감싸야 합니다. 많은 프로그래밍 언어들은 null을 담을 수 있는 타입을 뚜렷하게 구별하고 있으며 Java도 Java 8에서부터
이를 nullable 타입이라고 부릅니다. nullable 타입은 프로그래밍에 광범위하게 쓰이는 만큼 C#과 Kotlin은 nullable 타입 전용 문법을 추가하였습니다.
다만 Java, C#, Kotlin, Rust, Scala의 nullable 타입 구현 방식은 서로간에 차이가 있습니다.
여기서 특이하게도 Scala와 Rust는 null에 해당하는
Scala, Rust, Typescript가 이러한 공통점을 보여주는 이유는 세 언어 모두 타입 이론의 합 타입을 기반으로 nullable 타입을 지원하고 있기 때문입니다. 타입 이론데이터는 표현합니다. 하지만 표현 가능한 정보의 종류와 수는 타입에 따라 결정됩니다. 타입 이론은 데이터의 타입을 어떠한 방식으로 확장시킬 수 있는지를 정립합니다. 타입 이론은 작은 타입들로 더 큰 타입을 만들어내는 방식으로 타입을 확장시키는데, 여기서 가장 기초가 되는 개념은 곱 타입(Product Type)과 합 타입(Sum Type)입니다. 곱 타입은 한 데이터에 두 가지 정보를 모두 저장하여 두 정보를 함께 표현합니다. 많은 언어들의 struct, DTO, record들이 이에 해당합니다.
위 예시로 들자면 사람에게 이름 정보에 더불어 나이 정보도 함께 표현하게 되므로 훨씬 많은 표현을 할 수 있게 됩니다. 이로써 사람이라는 정보가 가지는 경우의 수는 이름이 가지는 경우의 수와 나이가 가지는 경우의 수의 곱입니다. 이번에는 합타입을 살펴봅시다. nullable 타입은 데이터가 “없음”이라는 정보를 추가적으로 표현할 수 있게 됩니다. 이는 기존 데이터가 가진 표현력(Expressiveness)에서 null이라는 표현을 “더한” 것이기 때문입니다. 위 예시에서는 수많은 별명들을 표현할 수 있는 것에 더불어 별명이 없다는 것도 추가적으로 표현할 수 있게 됩니다.
합 타입은 null 타입을 더하는 것에 국한되지 않으며 세 개 이상의 타입들을 합할 수도 있습니다.
이처럼 곱 타입은 많은 언어들이 지원하고 있지만, 합 타입은 일부 프로그래밍 언어들만이 지원하고 있습니다. C/C++, C#은 합 타입을 지원하지 않으며 합 타입을 미약하게나마 구현하려면 추상 클래스나 인터페이스를 이용하여 제한적으로 구현하거나 타입 캐스팅을 하여서 하위 타입을 확정하여 접근해야 합니다. 상속의 한계아래와 같은 요구사항으로 합 타입을 묘사했다고 해봅시다.
여기서
위 방법으로 개별 필드들을 노출하면 각 필드가 어떤 하위 타입에서 오는지 파악하기가 어렵습니다. 또한 유지보수로 인해 하위 타입이 바뀌게 될 경우 메소드는 삭제되지 않고 남아있기 때문에 유지보수성을 해치고 의미 없는 코드로 인해 코드 가독성 또한 낮아집니다. 그렇다면 타입 캐스팅은 어떨까요?
타입 캐스팅을 통해 타입들을 명시할 경우 세 가지 문제가 발생합니다.
여기서 1번 문제는 enum과 switch-case를 이용하여 어느정도 해결이 가능합니다. 하위 타입에서 현재 타입이 어떤 타입인지 enum으로 먼저 알려주고 나서 타입 캐스팅을 하면 됩니다.
이 경우 하위 타입이 늘어나더라도 switch-case는 한 번만 실행되므로 성능적인 면에서는 손해를 보지 않습니다. 이러한 방식의 데이터 타입은 Tagged Union이라고 부릅니다. 사실 Rust와 Scala의 합 타입들은 마찬가지로 일종의 Tagged Union으로 구현되어 있습니다.
이렇게 Scala와 Rust의 Java는 Java 16에서 패턴 매칭을 추가되었고 Java 17에서 sealed 키워드가 추가되어 합타입이 정식적으로 지원되고 있습니다. C#은 C# 7.0부터 패턴 매칭을 추가했지만 아직 합 타입이 없기 때문에 완전 패턴 매칭이 불가능합니다. 합 타입 없이는 완전 패턴 매칭이 불가능한 이유는 인터페이스나 클래스는 소스 코드 어디서든 상속 받고 구현할 수 있기 때문입니다. 예를들어 라이브러리 코드 내부에서 완전 패턴 매칭을 했다고 합시다. 애플리케이션 코드에서 해당 타입에 하위 타입을 추가하게 되면 라이브러리 코드에는 해당 하위 타입에 대한 경우가 추가되어 있지 않아 오류가 발생합니다. Rust의 합 타입은 하나의 enum 정의라서 하나의 파일에 모든 하위 타입이 정의되어있고, Scala, Kotlin, Java의 경우 합 타입에 이처럼 합 타입과 곱 타입 위주로 데이터를 정의해가며 더 큰 타입을 만들어내는 방식을 컴포지션(Composition)이라고 부릅니다. 상속은 잘못된 데이터 설계를 불러일으키기 때문에 Go와 Rust는 상속을 지원하지 않고 컴포지션 위주의 데이터 설계를 유도합니다. 에러 처리와 타입 이론합 타입과 곱 타입은 자주 쓰이는 만큼 익명으로(anonymously) 정의할 수 있는 경우가 많습니다. 그 중에서도 가장 널리 알려진 것은 곱 타입의 익명 타입인 튜플(Tuple)입니다. 튜플은 임의의 타입들을 곱 타입으로 묶습니다.
튜플은 함수에서 여러 값을 함께 리턴해야 할 때 유용합니다.
합 타입도 익명 타입 버전이 존재합니다. Scala, Rust, Typescript가 익명적 합 타입을 지원하고 있지만, 3개 이상의 타입을 묶는 것은 Scala 3나 Typescript에서만 가능합니다.
익명적 합 타입도 함수 출력에 쓰이는 경우가 많습니다. 다만 보통 여러 데이터를 한번에 내려주는 용도가 아닌, 오류 또는 실행 성공 결과 값을 내려주는 용도로 사용합니다. 이렇게 사용하는 경우가 너무 많아서 Rust에서는 익명적 합 타입의 이름이 Scala에서는 성공 타입을 오른쪽 타입 파라미터에 넣는 경향이 있으나 Rust에서는 성공 값은 왼쪽 타입 파라미터에 넣습니다.
전통적 프로그래밍 언어들은 이러한 출력을 통한 에러 처리가 아닌 예외를 던지고 잡아서 처리하는 경우가 많습니다. 어째서 Rust와 Scala는 어째서 예외를 선택하지 않았을까요? 이는 예외가 가진 문제점들을 살펴보아야 합니다. 주요 프로그래밍 언어들 중 C++에서 가장 처음으로 예외를 추가하였는데, 가장 눈에 띄는 문제점은 함수 선언부를 확인해도 해당 함수가 예외를 던질지 말지를 알 수 없다는 것입니다.
만약 예외가 발생하면 안되는 곳에서 이 함수를 호출하면 곧바로 프로그램 크래쉬로 이어집니다. 또한 어떤 예외를 던질지 몰라서 어느 예외를 잡아야할지 불분명해집니다. 만약 해당 함수가 수정되어 더 이상 예외를 던지지 않게 되어도 try-catch는 남아있을 것이며 다른 협업자들은 여전히 예외를 고려하며 함수를 호출할 겁니다. null 문제와 비슷하게 C++의 경우 이 문제를 해결하기 위해 Java는 반드시 선언부에 예외들을 기록하도록 강제하였습니다. 다만 모든 예외를 기록해야하는 것은 아니고
하지만 Java의 Checked Exception 또한 많은 문제점을 내포하고 있습니다. 이렇게 선언부에 추가해 try-catch를 강제하더라도 부주의한 개발자들이 Checked Exception을 잡아서
이러한 코드를 유지 보수하는 것은 지뢰밭을 건너는 것과 같습니다. 프로젝트와 코드 베이스에 대한 현저한 이해 없이 수정하는 것은 가능하면 피해야 합니다. 또한 선언부에 Exception이 추가될 때마다 그 함수를 호출하는 다른 모든 함수에도 마찬가지로 Exception을 추가해야하는 것이 대부분입니다. 그리고 코드를 읽어도 언제 어디서 예외가 던져지는지 파악하기가 어렵습니다. Rust의 경우 다음과 같이 완화됩니다.
이외에도 여러가지 문제점들이 있지만 결론적으로는 Checked Exception을 실패한 개념으로 여기는 것이 정론이며 차세대 OOP 언어들은 Checked Exception을 지원하지 않는 경우가 많습니다. 그렇기 때문에 Scala와 Rust는 예외의 문제점들을 피하기 위해 에러와 출력 값을 Sum Type으로 컴포지션하여 해결했습니다. 비슷하게 Go도 exception 대신 error를 다중 출력하는 방향으로 언어가 디자인 되었습니다.
이렇게 언어들이 어떠한 방식으로 타입 이론의 기초적인 요소들을 활용하였는지를 알아봤습니다. 하지만 합 타입과 곱 타입만으로는 아직 이해하기 쉽고 확장하기 쉬운 소프트웨어 아키텍처를 구현할 수 없습니다. 다음 편은 코드의 재사용성을 높이는 다형성(Polymorphism)이 어떠한 방식으로 이러한 문제를 해결하는지 알아볼 것입니다. 참고자료
|