프로그래밍 언어 시리즈
- 9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론
- 9가지 프로그래밍 언어로 배우는 개념: 2편 - 다형성
- 9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타프로그래밍
- 9가지 프로그래밍 언어로 배우는 개념: 4편 - 하이 레벨 언어와 동적 타입 언어
- 9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍
서론
안녕하세요, 킹덤 서버 소프트웨어 엔지니어 지민규입니다. 이번에는 아키텍처 디자인에 있어 하이 레벨 언어와 동적 타입 언어에 대한 저의 견해들을 나누기 위해 글을 쓰게 되었습니다.
하이 레벨 언어
프로그래밍 언어를 비교할 때, 하이 레벨과 로우 레벨로 나눌 수 있습니다. 다만 이 기준은 상대적인 경우가 많습니다. 가령 어셈블리와 C를 비교했을 때 어셈블리 언어는 로우 레벨, C는 하이 레벨이라고 할 수 있고 C와 Javascript를 비교했을 때는 C는 로우 레벨, Javascript를 하이 레벨이라고 할 수 있죠.
이 글에서는 메모리를 직접적으로 관리하는지 혹은 **가비지 컬렉터(Garbage Collector)**를 사용하여 관리하는지에 따라 로우 레벨, 하이 레벨로 나눕니다. 직접 메모리를 할당하고 풀어줘야하는 C는 로우 레벨 언어이고 이를 가비지 컬렉터가 자동적으로 관리해주는 Javascript은 하이 레벨 언어이죠. 로우 레벨과 메모리 관리의 안전성은 관련 없습니다. C++에서는 **RAII(Resource Acquisition is Initialization)**를 통해서 메모리 관리를 조금 더 안전하게 만들었지만 여전히 로우 레벨 언어로 취급됩니다. Rust는 특유의 레퍼런스 관리 시스템인 **버로우 체커(Borrow Checker)**를 통해 메모리 관리의 위험성을 거의 제거 했음에도 로우 레벨 언어입니다.
로우 레벨 언어와 하이 레벨 언어가 어떠한 차이를 보여주는지는 누구나 잘 알고 있습니다. 로우 레벨 언어는 일반적으로 성능이 더 좋지만 번거롭고 하이 레벨 언어는 일반적으로 성능이 상대적으로 낮은 대신에 메모리 관리에 대해 고민하지 않아도 됩니다. 가비지 컬렉터가 어떠한 장단점이 있는 지도 꽤 재밌는 주제이지만, 이 글에서는 메모리 관리에 대한 번거로움이나 성능이 아닌, 아키텍처 디자인에 있어 하이 레벨 언어들이 어떠한 장점들이 있는지를 집중해보려고 합니다.
모든 것은 레퍼런스
하이 레벨 언어의 가장 큰 특징을 꼽아본다면 대부분의 타입들이 레퍼런스 형태로 저장된다는 것입니다. 흔히 Java에서 “모든 클래스 인스턴스는 레퍼런스로 저장된다”라는 말을 들어보셨을 수도 있습니다.
// Java 코드
class Human {
public String name;
}
// Human 인스턴스에 대한 레퍼런스를 human1에 저장한다
Human human1 = new Human("Mango");
// human1에 저장된 레퍼런스를 복사하여 human2에 저장한다
Human human2 = human1;
// human2에 저장된 레퍼런스를 따라가서 Human 인스턴스의 name 필드를 수정한다
human2.name = "Tango";
// human1에 저장된 레퍼런스를 따라가서 Human 인스턴스의 name 필드를 출력한다
// 여기서 human1에 저장된 레퍼런스와 human2에 저장된 레퍼런스는
// 같은 인스턴스에 대한 레퍼런스 이므로
// Tango가 출력된다
System.out.println(human1.name);
Java에서 클래스 인스턴스를 만들려면 new
를 해야 합니다. 그렇게 만들어진 인스턴스는 변수에 저장할 때 레퍼런스로 저장되죠. 다른 변수에 레퍼런스를 대입할 때는 인스턴스가 복사되는 것이 아닌 레퍼런스가 복사됩니다. 위 예시에서는 human2에 human1을 대입했기 때문에 이제 두 변수는 같은 레퍼런스를 가지게 됩니다. human1을 통해 인스턴스를 수정하는 것과 human2를 통해 인스턴스를 수정하는 것은 같은 작업입니다.
C/C++에서 Struct 변수 대입은 다릅니다. C/C++에서는 기본적으로 인스턴스를 복사하게 됩니다. 이러한 인스턴스 자체를 저장하는 타입을 값 타입이라고 합시다.
// C++ 코드
struct Human {
string name;
};
// Human 인스턴스를 human1에 저장한다
Human human1 = { "Mango" };
// human1에 저장된 인스턴스를 복사하여 human2에 저장한다
Human human2 = human1;
// human2에 저장된 인스턴스의 필드를 수정한다
human2.name = "Tango";
// human1과 human2는 다른 인스턴스를 저장하고 있으므로
// human2의 필드 수정이 human1에 영향을 끼치지 않는다
// 따라서 Mango가 출력된다
cout << human1.name << endl;
C++에서는 레퍼런스를 저장하고 싶다면 레퍼런스 타입을 사용해야 합니다. 위 예시에서 변수의 타입을 Human
에서 Human&
로 바꾸어야 하죠
// C++ 코드
struct Human {
string name;
};
Human& human1 = { "Mango" }; // 사실 Human&&를 써야한다 (C++ rvalue reference 참조)
Human& human2 = human1;
human2.name = "Tango";
// Tango가 출력된다
cout << human1.name << endl;
로우 레벨 언어에서 레퍼런스 타입이 아닌 값 타입을 사용하는 이유는 성능 최적화와 메모리 관리의 간편성 때문입니다. 레퍼런스 타입은 레퍼런스를 “따라가는” 연산이 필요하지만 값 타입은 그러한 작업이 필요 없고 메모리를 할당하고 풀 필요가 없기 때문입니다.
물론 하이 레벨 언어도 C#이나 Go 같이 값 타입을 지원해주는 경우도 있습니다. Java도 int나 float 같은 기초 타입들은 값 타입으로 취급되기도 하고요. 하지만 로우 레벨 언어에 비하면 레퍼런스 타입을 활용하는 빈도가 더 높습니다.
타입 호환 문제
값 타입은 단점이 하나 있습니다. 값 타입 변수들은 제각각 다른 크기의 메모리를 요구합니다.
// C++ 코드, 64비트 환경
cout << sizeof(int) << endl; // int의 값 타입, 4가 출력된다
cout << sizeof(long) << endl; // int의 값 타입, 8이 출력된다
cout << sizeof(int*) << endl; // int의 레퍼런스 타입, 8이 출력된다
cout << sizeof(long*) << endl; // long의 레퍼런스 타입, 8이 출력된다
레퍼런스 타입은 일반적으로 32비트 환경에서 4바이트, 64비트 환경에서 8바이트를 차지합니다. 따라가야 할 인스턴스의 메모리 주소를 저장해야 하기 때문이죠. 대상 인스턴스 타입의 크기가 4바이트든 8바이트든, 몇백 메가바이트가 되든 64비트 환경에서는 메모리 주소를 저장할 8바이트만 있으면 됩니다.
여기서 생겨나는 문제점이 뭘까요? 바로 업캐스팅 및 다운캐스팅에 제약이 걸린다는 것입니다. 다형성 편과 메타프로그래밍 편에서 업캐스팅과 다운캐스팅에 대해 언급했었는데요, 간략하게 설명하자면 타입의 계층 간 타입 캐스팅입니다. 상위(부모) 타입으로 캐스팅하면 업캐스팅이고 하위(자식) 타입으로 캐스팅하면 다운캐스팅입니다. 서로 요구하는 메모리 공간이 다를 수도 있는 타입끼리의 캐스팅이기 때문에 제약이 걸립니다.
// C++ 코드
// 4 바이트 크기의 struct
struct Fruit {
int weight;
};
// 8(4 + 4) 바이트 크기의 struct
struct Apple : public Fruit {
int redness;
};
Apple apple = {280, 7};
// 4 바이트 크기의 변수에 8 바이트 크기의 변수를 대입했다?
// redness 필드는 버린 상태로 복사한다
Fruit fruit = apple;
// 8 바이트 크기의 변수에 4 바이트 크기의 변수를 대입했다?
// redness 필드를 채울 방법이 없으므로 컴파일 에러
Apple apple2 = fruit;
어떻게 보면 당연한 것이기도 합니다. 레퍼런스 타입들은 같은 메모리 주소를 담는 타입들이기에 계층 구조간 캐스팅이 자유롭지만, 값 타입들은 같은 계층 구조에 속하더라도 서로 다른 타입이기에 캐스팅 시 일부 정보를 버려야 하거나 불가능합니다.
여기서 비롯되는 실질적인 문제는 어떤 것들이 있을까요? 바로 다형성 편에서 언급하였던 Variance가 적용되지 않는다는 것입니다. Variance는 업캐스팅을 기반으로 작동하므로 애초에 제네릭 타입 T
가 업캐스팅 될 수 없다면 Variance 규칙을 만족할 수 없겠죠. 그렇기 때문에 값 타입을 사용하였을 경 우 Variance를 활용하기 어렵습니다.
// C# 코드
// Covariant한 T
public interface IEnumerable<out T> {}
// 값 타입
public struct Human {
public string name;
}
// 내부적으로 Human 배열을 가진 List를 인터페이스로 캐스팅한다
IEnumerable<Human> humans = new List<Human>();
// 내부의 Human 배열과 object 배열의 요소 단위 크기가 호환되지 않는다
// object이 Human의 상위 타입이라서 Covariant 규칙을 만족하지만 컴파일 실패한다
IEnumerable<object> objects = humans;
// 레퍼런스 타입인 string은 잘되는데...
IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings;
C#에서 struct
의 상속을 금지하는 이유도 여기에 포함되어있을 겁니다. struct
의 상속을 허용하여 타입 계층 구조를 확장할 수 있도록 한다면 class
의 Variance 규칙과 struct
의 Variance 규칙이 달라져 언어의 일관성이 떨어집니다. 언어의 복잡도가 늘어난다는 것이죠
C#을 자주 다뤄보신 분이라면 뭔가 이상한 점을 눈치챘을지도 모릅니다. struct
타입들은 object
타입에 대입할 수 있거든요. 과연 무슨 일이 일어나는 것일까요?
// C# 코드
// 값 타입
public struct Human {
public string name;
}
Human human = new Human();
// 값 타입인 Human은 다른 타입으로 캐스팅 될 수 없는 거 아니었나?
object humanObject = human;
사실 이건 단순한 캐스팅이 아닙니다. Boxing을 하고 있는 것이죠. Boxing이란 새로운 메모리를 할당한 뒤 기존 메모리를 복사하는 것을 말합니다. 위 예시에서는 Human
값 타입을 위한 메모리 공간을 마련하고 복사한 뒤 그 메모리에 대한 레퍼런스를 object
레퍼런스 타입에 대입한 것입니다. 값 타입을 레퍼런스 타입으로 변화시켜 캐스팅을 가능하게 만든 것이죠.
Rust를 사용해보신 분들이라면 Boxing이라는 단어가 익숙하실 겁니다. 바로 Box<T>
타입이죠.
// Rust 코드
// Box에 대한 정말 대략적인 구현
pub struct Box<T> {
value: *const T,
}
pub struct Human {
name: String,
}
let human: Box<Human> = Box::new(Human { name: "Mango".to_owned() });
let any: Box<dyn Any> = human;
Box<T>
는 T
타입을 요구하지 만 실제로는 필드로써 레퍼런스 타입인 *mut T
를 들고 있고 값 타입인 T
를 가지고 있지 않습니다. 따라서 Box<T>
의 T
는 Variance 규칙이 적용될 수 있습니다. 위 예시에서는 Box<Human>
을 Covariance를 이용하여 Box<dyn Any>
로 바꿀 수 있었습니다.1
값 타입을 자주 활용하는 로우 레벨 언어에서는 Variance를 자주 활용하기 어렵습니다. C++에서는 아예 지원하지 않고 Rust에서는 특정 타입에 대해서만 사용 가능하다거나 많은 제약 조건이 걸려 있습니다. 이 때문에 거의 모든 것을 레퍼런스 타입으로 저장하는 하이 레벨 언어에 비해 표현력이 더 떨어질 수 있습니다. 성능을 위해 값 타입을 사용한 댓가이죠. 그렇기 때문에 현대적인 로우 레벨 언어에서는 C++의 템플릿이나 Rust의 매크로 같은 막강한 메타프로그래밍 기능을 제공하여 대체적인 방안을 제공하는 경우가 많은 것 같습니다.
동적 타입 언어
동적 타입 언어는 변수나 필드의 타입이 고정되어있지 않으며 런타임에 타입이 변경될 수 있는 언어를 가리킵니다. 동적 타입 언어인 Javascript에서는 String이었던 변수를 number, object로 바꿀 수 있습니다.
// Javascript 코드
let value = 'Mango'; // String 대입
value = 7337; // number 대입
value = { name: 'Tango' }; // object 대입
이걸 위의 타입 호환 문제와 엮어서 잘 생각해보시면 뭔가 느낌 오는 것이 있을 겁니다. 그렇습니다, 동적 타입 언어의 모든 타입은 레퍼런스 형태로 저장됩니다. 실제로는 성능 최적화가 적용되었기 때문에 number
와 같은 기초 타입들은 레퍼런스가 아닌 채로 저장되기도 하지만 레퍼런스 타입처럼 동작합니다. 레퍼런스 타입을 많이 활용하는 만큼 대다수의 동적 타입 언어들은 가비지 컬렉터 또한 사용하고 있는데요, 자연스럽게 동적 타입 언어는 하이 레벨 언어의 장단점을 물려받게 됩니다.
동적 타입이 가져오는 문제점은 잘 아실 것이라고 생각합니다. 바로 타입 오류가 런타임에 발생한다는 것과 문서화가 필수라는 것입니다. 이로 인해 null 문제도 발생한다는 문제점도 존재합니다.
// Javascript 코드
// 과연 무슨 타입을 입력 받고 출력할까...
function evaluateGrade(score) {
/* ... */
}
동적 타입 언어에서는 변수나 인수에 모든 타입을 넣을 수 있다고 해서 모든 타입을 넣어도 작 동하도록 함수를 짜지 않습니다. 각 함수나 클래스마다 적절한 타입을 요구하도록 구현합니다. Javascript에서는 함수에서 출력 타입이나 인수 타입을 명시적으로 적지 않기 때문에 오로지 함수 이름과 인수 이름만으로 인수 타입과 출력 타입을 예상해야 합니다.
프로젝트 규모가 작을 때는 큰 문제가 되지 않지만 프로젝트의 규모가 커지고 복잡도가 높아질수록 주석을 통해서 문서화하는 것이 반쯤 필수적입니다. 정적 타입 언어에서는 명시적으로 적는 타입이 그 역할을 했었지만, 동적 타입 언어에서는 코멘트들로 처리해야 합니다. 어느 정도 동적 타입 언어의 장점을 포기하는 일이지만, 다른 방법이 없습니다.
// Javascript 코드
/**
* @param {number} score - 0~100점 대의 점수
* @return {number} 1~5점 대의 등급
*/
function evaluateGrade(score) {
/* ... */
}
만약 잘못된 타입을 넘기게 되면 해당 함수 호출을 실행하기 전까지 버그가 있는지 알 수 없습니다. 이 문제를 완화 시키려면 테스트를 통해 수정 후 실행까지의 간격을 줄여야 합니다.
한 가지 더 아쉬운 점이라면 타입의 흐름을 확인하기 어렵다는 것입니다. 어디에서 생성된 타입이며 어떠한 흐름으로 어떤 함수에 도달하는지 파악하기 어렵다는 것이죠. 이는 디버깅이나 신규 인원이 코드를 파악할 때 좀 더 어렵게 만 드는 요소 중 하나입니다.
이런 문제점들을 넘어간다면 풍부한 표현력을 가진 언어가 됩니다. 타입클래스나 Variance, 메타프로그래밍 지원이 약한 정적 타입 언어들에 비해서는 할 수 있는 것이 많은 언어가 되죠.
합타입
타입 이론 편에서 소개해 드렸던 합 타입에 대해서 생각해봅시다. 당연하게도 모든 타입이 합 타입입니다. 같은 변수에 여러 타입을 대입할 수 있고 컴파일러가 그걸 거부하지 않기 때문이죠.
다만 아쉬운 점은 합 타입과 함께 자주 사용되는 완전 패턴 매칭 또한 사용할 수 없습니다. 이론적으로 매칭의 대상이 되는 값이 모든 타입이 될 수 있는데 모든 타입에 대응되는 케이스들을 작성할 수 없기 때문입니다. 그렇다고 default 케이스를 작성하거나 그냥 무시한다면 합 타입에 새 타입을 추가했을 때 런타임 에러가 발생할 가능성이 높습니다.
// Javascript 코드
function applyDiscount(originalPrice, discount) {
// Javascript에서는 패턴 매칭이 없어서 간접적으로 해야한다
// 여기서는 클래스 이름으로 매칭한다
switch (discount.constructor.name) {
case 'FixedDiscount':
return max(originalPrice - discount.amount, 0);
case 'RatioDiscount':
return max(originalPrice * (1 - discount.ratio), 0);
// 실수로 하나 빠트렸다
// 이로 인해 발생하는 에러는 실행전까지 볼 수 없다
// case 'Voucher':
// return 0
}
}
완전 패턴 매칭이 존재하는 언어에서는 케이스를 빠트리면 컴파일 에러가 납니다. 그렇기 때문에 합 타입에 새 타입을 추가해도 수정이 강제되기 때문에 안전하죠. 그렇기 때문에 동적 타입 언어에서 합타입이 사용 가능하다 한들 부주의하게 사용할 경우 버그로 이어질 가능성이 높습니다.
// Scala 코드
def applyDiscount(originalPrice: Int, discount: Discount) =
// 완전 패턴 매칭
discount match {
case Fixed(amount) => max(originalPrice - amount, 0)
case Ratio(ratio) => max(originalPrice * (1 - ratio), 0)
// 실수로 하나 빠트렸다
// 그렇지만 컴파일 할 때 컴파일 에러가 잡아준다
// case Voucher => 0
}
덕 타이핑
정적 타입 언어에 인터페이스, 타입클래스, 제네릭이 있다면 동적 타입 언어에는 **덕 타이핑(Duck Typing)**이 있습니다. 덕 타이핑은 서로 다른 타입들이 가진 메소드와 필드들이 같다는 것을 이용해 다형성을 이루는 것입니다. 위의 Discount 예시를 덕 타이핑 방식으로 해결한다면 다음과 같습니다.
// Javascript 코드
class FixedDiscount {
applyDiscount(originalPrice) {
return max(originalPrice - this.amount, 0);
}
}
class RatioDiscount {
applyDiscount(originalPrice) {
return max(originalPrice * (1 - this.ratio), 0);
}
}
class VoucherDiscount {
applyDiscount(originalPrice) {
return 0;
}
}
let originalPrice = 15000;
let discount = getDiscount();
discount.applyDiscount(originalPrice);
여기서 중요한 점은 각 Discount 타입들이 공통 부모를 가졌거나 특정 인터페이스를 지정하고 있지 않다는 것 입니다. 그저 메소드가 같은 이름을 공유할 뿐입니다. 실제로 넘겨지는 인수가 어떤 타입인지, 같은 타입을 출력하고 있는지도 신경 쓰지 않습니다. 이 점에서 덕 타이핑은 정적 타입 언어의 인터페이스와 제네릭이 담당하는 다형성 영역을 커버할 수 있습니다.
다형성 편에서 인터페이스의 2가지 한계점에 대해 다뤘습니다. 당시에 제시했던 문제점은 다음 두 가지입니다.
- 자기 자신의 타입을 사용하는 메소드를 선언할 수 없다.
- 정적 메소드를 추상 메소드로 선언할 수 없다.
덕 타이핑은 추상 메소드 선언을 기반으로 하고 있지 않기 때문에 이 두 가지 문제점을 회피할 수 있습니다. 인수 타입이나 출력 타입을 신경 쓰지 않기 때문에 자기 자신 타입이든 그 어떤 타입이든 사용할 수 있으며, 정적 메소드로도 덕 타이핑이 가능하기 때문에 추상 메소드의 기능을 수행할 수 있습니다. 이 점에서 타입클래스의 역할도 수행할 수 있다는 것을 알 수 있습니다.
동적 타입 언어에서 모든 타입은 호환되기 때문에 Variance의 영향 또한 받지 않습니다. 다만 이점은 오히려 단점이라고도 볼 수 있는 것이, Variance 규칙을 만족하지 않는 타입 캐스팅 또한 허용하기 때문에 주의를 기울여서 캐스팅해야 합니다.
이렇게 동적 타입 언어에서는 덕 타이핑 하나로 다형성이 전부 처리됩니다. 이 모든 게 가능한 이유는 동적 타입 언어가 타입 안정성을 가지고 있지 않기 때문입니다. 만약 잘못된 타입으로 덕 타이핑을 시도하면 런타임 에러로 받게 되죠. 인터페이스와 달리 명시적인 타입이 존재하지 않아 덕 타이핑으로 묶인 타입들을 추적하기 어렵다는 단점도 있습니다. 그렇기 때문에 프로젝트의 복잡도가 상승할수록 덕 타이핑은 더욱 조심히 사용해야 합니다.
재밌는 것은 C++ 템플릿으로도 덕 타이핑이 가능하다는 것입니다. 타입을 치환하는 방식으로 처리하는 C++ 템플릿 특성상 함수나 필드를 이름으로 호출하는 것이 가능합니다. 심지어 동적 타입 언어와 다르게 타입 안정성도 가져갈 수 있습니다. 이는 C++ 템플릿이 메타프로그래밍의 특성도 띠고 있기 때문에 가능한 것으로, 다른 언어로도 메타프로그래밍을 활용하면 덕 타이핑을 할 수 있습니다.
리플렉션
동적 타입 언어는 리플렉션 기능이 항상 존재합니다. 동적 타입 언어 그 자체가 리플렉션을 이용하여 다형성 코드들을 실행하기 때문입니다. 동적 타입 언어의 합타입과 덕 타이핑에 대해서 고민해봅시다. 변수에 그 어떤 타입도 대입할 수 있다는 이야기는 최소한 대입된 타입이 무엇인지 런타임에도 알고 있다는 것입니다.
// Javascript 코드
let value;
// 50% 확률로 string이 대입되고 나머지 50% 확률로 object가 대입된다
if (Math.random() < 0.5) value = 'Mango';
else value = { score: 10 };
// 결과적으로 무슨 타입일지 컴파일 타임에 알 방법이 없다
// 그렇다는 것은 타입을 런타임에 저장된 타입을 출력하고 있다는 것이다
console.log(typeof value);
그렇기 때문에 리플렉션의 타입 성찰 조건을 갖추고 있다는 것을 알 수 있습니다. 필드나 메소드는 어떨까요?
// Javascript 코드
let value = {};
// 50% 확률로 name 필드에 대입한다
if (Math.random() < 0.5) value.name = 'Mango';
// 필드의 존재 여부 또한 런타임에 정해진다
// 그렇다는 것은 필드 존재 여부 또한 런타임에 저장되어있는 정보를 이용하는 것이다
console.log(value.name);
그렇습니다. 정적 타입 언어는 리플렉션 기능이 없어도 구현 가능하기 때문에 리플렉션 유무가 갈리지만, 동적 타입 언어는 각종 언어 기능의 작동 원리부터가 리플렉션이기 때문에 리플렉션이 거의 항상 제공됩니다. 각종 필드를 이름으로 찾는 것과 필드들을 순회하는 것은 정적 타입 언어에 비해 매우 쉽습니다.
// Javascript 코드
let human = {
name: 'mango',
getName() {
return this.name;
},
};
// human의 필드를 이름으로 찾는다. mango가 출력된다
console.log(human['name']);
// human의 필드와 메소드들을 순회한다
for (let prop in human) {
// name과 getName이 출력된다
console.log(prop);
}
동적 타입 생성
동적 타입 언어는 런타임에 타입을 생성하고 수정할 수 있습니다. 모든 타입과 메소드들을 컴파일 타임에 알고 있을 필요가 없기 때문입니다. 이를 이용하면 클래스 타입을 특정 로직에 따라 생성하고 마음대로 메소드를 추가하고 조작할 수 있다는 것이죠.
// Javascript 코드
// 주어진 필드명들로 생성자 및 print 메소드들을 추가한 클래스 타입을 만드는 함수
function createClassWithPrinters(...fields) {
// 필드들을 인수로 받는 클래스 타입 생성
let clazz = class {
constructor(...params) {
for (let [index, field] of fields.entries()) {
this[field] = params[index];
}
}
};
// 클래스에 print 메소드 추가하기
for (let field of fields) {
clazz.prototype[`print_${field}`] = function() {
console.log(this[field]);
};
}
return clazz;
}
// 런타임 타입 생성
const Human = createClassWithPrinters('name', 'age', 'role');
let mango = new Human('mango', 23, 'software engineer');
// 생성된 메소드 호출
mango.print_name();
mango.print_age();
mango.print_role();
사실 이 예시는 메타프로그래밍 편에서도 정적 타입 언어로 이미 보여드렸었습니다. 그렇습니다, 동적 타입 생성은 메타프로그래밍에 해당하는 기능입니다. 정적 타입 언어에서는 복잡한 매크로를 사용했었지만 동적 타입 언어에서는 이러한 타입 조작이 훨씬 더 쉽고 자연스럽게 이루어집니다.
매크로가 존재하지도 않아서 타입 생성이 어려운 언어들도 있다는 것을 생각해보면 이렇게 쉽게 타입 생성과 메소드 조작을 할 수 있다는 것은 확실한 장점입니다. 특히 Java나 C#에서 타입에 특정 로직을 따라 임의의 메소드들을 추가하는 것은 꽤나 어렵습니다.
물론 모든 곳에 리플렉션과 동적 타입 생성을 남용해서는 안 됩니다. 먼저 다형성을 우선으로 사용해서 재사용성을 높인 뒤, 다형성으로 해결되지 않는 보일러플레이트에 메타프로그래밍을 사용한다는 규칙은 동적 타입 언어에서도 유지됩니다. 아무리 동적 타입 언어의 메타프로그래밍이 쉽더라도, 일반적인 다형성을 통해 구현하는 방안이 더 직관적입니다.
하나 동적 타입 생성이 아쉬운 점이 있다면, 사실상 리플렉션과 동일한 방식으로 작동하기 때문에, 런타임에 요구되는 연산의 양이 더 늘어납니다. 그렇기 때문에 정적 타입 언어의 코드 생성, 타입 생성에 비해서 좀 더 느립니다.
Typescript에 대해서
Typescript는 Javascript를 기반으로 타입 안정성을 추가하여 만들어진 정적 타입 언어입니다. Typescript 는 일반적으로 Javascript으로 컴파일(트랜스파일)된 뒤 실행됩니다. 그렇기 때문에 동적 타입 언어이기에 가능한 것들의 대다수가 Typescript에서도 가능합니다.
합타입 측면에서는 애초에 타입 호환 문제를 겪고 있지 않기 때문에 자유롭게 가능하면서도 타입 안정성을 챙길 수 있고, 덕 타이핑의 경우 Javascript의 덕 타이핑에 비해 좀 더 제한되었지만 어느 정도 활용할 수 있으며, 리플렉션과 동적 타입 생성 기능 또한 계승됩니다.
그렇기 때문에 다형성 편에서 보여드렸던 언어 기능 표대로, Typescript는 Rust, Scala 못지않게 많은 기능들을 제공하고 있습니다. Javascript를 기반으로 만들어졌기 때문에 계승되는 단점은 동적 타입 언어 수준으로 성능이 낮다는 것입니다.
결론
솔직하게 말씀드리자면 저는 동적 타입 언어를 그다지 선호하지 않습니다. 규모가 작은 프로젝트나 아키텍처의 복잡도가 높지 않거나 스크립팅 용도로 사용하기에는 꽤 나쁘지 않지만, 규모가 커지면 커질수록 유지보수성에 크게 타격을 입으며 버그는 잡기 어려워지고 새 인원이 프로젝트에 참여했을 때 아키텍처를 이해하는데 들어가는 시간이 늘어난다고 생각합니다.
그러나 현대적인 프로그래밍 언어가 아닌, C++, C#, Java와 같이 언어 기능이 약한 언어들보다는 어느 정도 확실한 강점이 있을 것 같습니다. 특히 메타프로그래밍 측면에서 상당히 강력하고 진입장벽이 낮다는 점을 생각해보면, 이러한 언어들로 만들어진 프레임워크들이 어째서 많은지 이해가 가게 됩니다. 이러한 관점들을 나누기 위해서 이 글을 작성하게 되었습니다.
이번 글에서 Javascript가 등장하면서 더 이상 9가지 언어라는 컨셉을 유지할 수 없게 되었습니다. 그렇다고 시리즈 제목을 수정하기도 애매하기 때문에 일단은 그대로 유지했습니다. 사실 다음 편은 다양한 프로그래밍 언어들이 어떤 방식으로 비동기 처리 기능을 제공하는지를 다루고자 하는데, 이 과정에서 여러 가지 프로그래밍 언어들을 소개할 예정이기 때문에 이미 9가지 언어라는 컨셉은 불가능했을지도 모르겠습니다.
1: 사실 Variance가 아닌 Rust의 Coercion을 사용했다고 하는 것이 조금 더 정확한 표현입니다. Rust의 dyn Any
와 같은 dyn
레퍼런스 타입은 메모리 주소 2개로 이루어져 있기 때문에 dyn
레퍼런스 타입과 메모리 주소 1개짜리인 struct
레퍼런스 타입은 호환되지 않습니다. 그렇기 때문에 C#의 Boxing처럼 Rust가 Coercion을 통해 변환 코드를 몰래 삽입한 것에 가깝습니다. Coercion 적용 규칙은 여전히 Variance 규칙을 기반으로 하고 있기 때문에 Variance라고 표현했습니다.