9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타프로그래밍

지민규

프로그래밍 언어 시리즈

  1. 9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론
  2. 9가지 프로그래밍 언어로 배우는 개념: 2편 - 다형성
  3. 9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타프로그래밍
  4. 9가지 프로그래밍 언어로 배우는 개념: 4편 - 하이 레벨 언어와 동적 타입 언어
  5. 9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍

서론

안녕하세요, 킹덤 서버 소프트웨어 엔지니어 지민규입니다. 다형성을 아무리 잘 쓰더라도 메타프로그래밍 없이는 최소한의 보일러플레이트가 생깁니다. 인터페이스/타입클래스를 통해 다형성을 이루더라도 최소한 해당 인터페이스/타입클래스가 요구하는 함수들을 구현해야하며 제네릭을 사용하더라도 타입을 선언한 이상 최소한 해당 타입을 생성하는 코드를 구현해야합니다. 어떤 면에서는 이렇게 매번 추가적인 보일러플레이트를 구현하는 것은 마땅히 해야할 일로 받아들일 수도 있습니다. 최대한의 확장성을 가질 수 있는 코드는 아무런 암묵적 처리 없이 우리가 모든 것을 직접 수동적으로 조절할 수 있는 코드겠죠.

하지만 저는 확장성이라는 것은 주관적이라고 생각합니다. 모든 프로젝트와 도메인은 개별적인 요구 사항이 있고 요구 사항이 중간에 변경되어 확장성을 요구하는 상황이 오더라도 요구 사항이 변하는 방향은 정해져있다는 것입니다. 그렇다면 확장성이라는 것도 프로젝트와 도메인에 따라 의미가 있는 확장성과 의미가 없는 확장성이 나눠질 수 있습니다. 메타프로그래밍(Metaprogramming)은 이러한 의미 없는 확장성을 포기하고 보일러플레이트 코드를 줄일 수 있을 때 가장 유용합니다. 코드를 경직화하는 대신 반복적인 코드를 없애는 것입니다. 비용에 비해 이러한 이점이 너무나 강력해서 메타프로그래밍을 활용하지 않는 프레임워크는 찾기 어려울 정도입니다.

메타프로그래밍은 언제나 다형성을 보조하는 기능으로서만 사용해야합니다. 메타프로그래밍 코드는 더 복잡할 뿐만이 아니라 이러한 확장성 비용도 있고 성능 혹은 컴파일 타임의 비용이 함께 다가옵니다. 그러므로 메타프로그래밍은 비용보다 이점이 더 클 때에만 사용해야합니다. 이번 포스트는 메타프로그래밍을 두려워하지 않고 필요에 따라 사용할 수 있도록 다양한 메타프로그래밍 기법들을 소개합니다.

타입 성찰

하위 타입에서 상위 타입으로 캐스팅하는 것은 업캐스팅이라고 부릅니다. 그리고 상위 타입에서 하위 타입으로 캐스팅하는 것은 다운캐스팅이라고 부릅니다. C++에서는 다운캐스팅을 다음과 같이 합니다.

// C++ 코드
// 상위 타입 parent
class Parent {};

// 하위 타입 Child, Parent를 상속받음
class Child : public Parent {};

// Child 타입을 Parent 타입으로 업캐스팅한다. 그냥 대입하면 끝.
Parent* parent = new Child(); 

// Parent 타입을 Child 타입으로 다운캐스팅한다. dynamic_cast를 사용해야한다.
Child* child = dynamic_cast<Child*>(parent);

다운캐스팅은 업캐스팅과 다르게 dynamic_cast라는 특별한 키워드를 사용합니다. 이는 다운캐스트가 유효하지 않을 수도 있기 때문입니다. parent에 담긴 타입이 Child가 아닐경우 dynamic_cast는 null을 내립니다.

// C++ 코드
class Parent {};

// 2개의 하위 타입
class ChildA : public Parent {}
class ChildB : public Parent {}

// ChildA 타입을 업캐스팅한다.
Parent* parent = new ChildA();

// (ChildA가 있는) Parent 타입을 ChildB로 다운캐스팅한다.
// ChildA 타입은 ChildB 타입이 아니므로 null이 내려온다.
ChildB* child = dynamic_cast<ChildB*>(parent);

달리 말하자면 ChildA 타입이 Parent 타입으로 업캐스팅 되었어도 본래의 타입을 기억하고 있다는 것입니다. 그리고 중요한 점은 이것이 컴파일 타임이 아닌 런타임에 존재하는 정보라는 것입니다. 이를 런타임 타입 정보(Run-time type information, RTTI)라고 합니다. C++에서 런타임 타입 정보는 컴파일러 옵션으로 끌 수 있으며 끄게되면 더 이상 dynamic_cast를 사용할 수 없게 되죠. 더 높은 유연성을 위해 직접 타입 정보 시스템을 구현하는 몇몇 프레임워크는 런타임 타입 정보를 끄고 컴파일하기도 합니다.

C++에서 런타임 타입 정보는 타입으로부터 typeid 키워드를 이용해 type_info 클래스도 가져올 수 있게 해줍니다.

// C++ 코드
// type_info의 대략적인 정의
class type_info {
public:
  // 복사 생성 불가
  type_info(const type_info& rhs) = delete;

  // 타입의 해쉬
  size_t hash_code() const;

  // 타입간의 비교 연산자
  bool operator==(const type_info& rhs) const;

  // 복사 불가
  type_info& operator=(const type_info& rhs) = delete;

  // 이름을 가져오는 함수, 컴파일러마다 결과가 다름
  const char* name() const;
};

// A 클래스 정의
class A {};

// A 클래스 생성
A a = new A();

// a의 type_info 가져오기
type_info info = typeid(a);

이러한 type_info 클래스를 이용해서 만들어진 type_index 클래스를 이용하면 타입을 unordered_map(해쉬 테이블, 딕셔너리의 C++ 버전)의 키 값으로 사용할 수 있습니다. 이를 이용하면 C++의 타입들에 추가적인 정보들을 담을 수 있습니다.

// C++ 코드
// 각 타입들을 생성한 횟수 
unordered_map<type_index, int> call_count;

// 타입의 인스턴스를 생성하는 함수, 타입별로 생성 횟수를 센다.
template<typename T>
T create() {
  creation_count[type_index(typeid(T))]++;
  return T();
}

Java를 비롯한 다른 JVM 계열 언어들에도 이러한 타입 정보를 가져올 수 있습니다. Class<T>가 이에 해당합니다. Java의 모든 오브젝트는 getClass() 메소드로 Class<T>를 가지고 있습니다. 다만 다형성 편에서 다뤄졌듯, Java 제네릭의 한계로 인해 위 C++의 템플릿처럼 제네릭한 생성 함수를 만들 수 없기에 제네릭 없이 Object로 받은 인수에 getClass()를 해서 횟수를 세어보겠습니다.

// Java 코드
// Class는 Invariant하기 때문에 `Class<?>`로 키타입을 설정해야한다.
// 여기서 ?는 타입이 일관적이지 않음을 의미한다.
HashMap<Class<?>, Integer> call_count = new HashMap<>();

// 인수로 받은 object를 타입별로 세는 함수
void increaseCallCount(Object object) {
  Integer integer = call_count.getOrDefault(object.getClass(), 0);
  call_count.put(object.getClass(), integer + 1);
}

이렇게 타입에 대한 정보를 가져오는 기능들을 타입 성찰(Type Introspection)이라고 부릅니다. 언어마다 타입 성찰은 개별적으로 구현되어있으며 어떤 면에서는 언어들이 가지는 가장 구별되는 특징중 하나라고 볼 수도 있습니다.

타입 정보를 이렇게만 사용하는 것은 사실 좀 심심하긴 합니다. 다행히도, C++와 다르게, Java의 타입 정보는 좀 더 많은 정보를 가지고 있습니다.

리플렉션

Java의 Class<T>의 정의를 확인해봅시다.

// Java 코드
// Class<T>의 대략적인 정의
public final class Class<T> {
  // 타입 이름을 가져오는 메소드
  public String getTypeName() {
    /* ... */
  }

  // 필드 정보들을 가져오는 메소드
  public Field[] getFields() {
    /* ... */
  }

  // 메소드 정보들을 가져오는 메소드
  public Method[] getDeclaredMethods() {
    /* ... */
  }

  /* ... */
}

많은 메소드가 존재하지만 대표적인 메소드들만 추려낸 정의인데요, Java의 Class<T>에는 단순히 타입 이름뿐만이 아니라 클래스 내 메소드들과 필드들 또한 가져올 수 있습니다. 각각의 필드와 메소드 정보에는 타입, 이름등의 정보가 담겨져 있지요. 이러한 타입 정보들은 일반적으로는 컴파일할 때 모두 최적화되어 런타임에서는 접근할 수 없는 경우가 많지만, JVM의 경우 타입에 대한 정보를 남겨두어 런타임에서도 접근할 수 있게 해줍니다. 이렇게 컴파일 타임에만 존재하던 정보를 런타임에서도 제공해주는 것을 리플렉션(Reflection)이라고 합니다.

리플렉션을 활용하면 Object를 필드에 따라 JSON으로 직렬화 할 수 있는 함수도 만들 수 있습니다.

// Java 코드
// Json으로 직렬화하는 정말 대략적인 함수
public static String jsonify(Object object) {
  Class<?> clazz = object.getClass();
  Field[] fields = clazz.getFields();
  StringBuilder stringBuilder = new StringBuilder();

  stringBuilder.append("{\n");
  for (int i = 0; i < fields.length; i++) {
    Field field = fields[i];
    stringBuilder.append("  \"");
    stringBuilder.append(field.getName());
    stringBuilder.append("\": ");

    // 필드가 Integer 타입일 때
    if (field.getType().equals(Integer.class))  {
      stringBuilder.append(field.get(object));

    // 필드가 String 타입일 때
    } else if (field.getType().equals(String.class)) {
      stringBuilder.append("\"");
      stringBuilder.append(field.get(object));
      stringBuilder.append("\"");
    }
    // 마지막 필드가 아닐 때는 콤마 달기
    if (i != fields.length - 1) {
      stringBuilder.append(',');
    }
    stringBuilder.append('\n');
  }
  stringBuilder.append("}");

  return stringBuilder.toString();
}

// Item 클래스 정의
public class Item {
  public String itemTag;
  public int amount;
}

// Item 생성
Item item = new Item();
item.tag = "Rare";
item.amount = 5;

// Item json화
String result = jsonify(item);
/* result에는 다음 데이터가 담겨진다
{
  "tag": "Rare",
  "amount": 5
}
*/

기존 프로그램에서는 타입과 필드를 추가하면 웬만해서는, 타입과 필드를 활용해야만 프로그램의 동작이 달라집니다. 메타프로그래밍을 사용하면 타입과 필드를 추가하는 것 만으로도 프로그램이 다른 동작을 하게 됩니다. 반복적으로 작성해야하는 보일러플레이트 “활용” 코드를 작성할 필요 없이 최소한의 코드 작업으로도 새로운 기능을 추가할 수 있습니다.

다만 메타프로그래밍은 언제나 단점이 존재합니다. 리플렉션의 경우 보일러플레이트 코드를 작성하는 것에 비해 런타임에 해야할 작업이 더 많기 때문에 성능이 더 떨어지고, 타입 정보가 프로그램에 추가되어야하기 때문에 프로그램의 크기가 더 커집니다. 무엇보다 런타임에서 처리가 이루어지기 때문에 오류가 있어도 컴파일 타임에 잡을 수 없다는 것입니다.

가끔은 타입 정보만으로는 원하는 리플렉션 코드를 구현하기엔 부족할 때가 있는데요, 그런 경우에는 어노테이션(Annotation)이라는 기능으로 좀 더 많은 정보를 주어 좀 더 많은 일을 할 수 있습니다.

어노테이션

Class<T>를 소개할 때 생략한 메소드가 하나 있습니다.

// Java 코드
// Class<T>의 대략적인 정의
public final class Class<T> {
  /* ... */

  // 어노테이션 정보들을 가져오는 메소드
  public Annotation[] getAnnotations() {
    /* ... */
  }

  /* ... */
}

어노테이션은 타입, 필드 등에 추가적인 정보들을 추가해서 메타프로그래밍을 좀 더 조절 할 수 있게 해줍니다. 엄연히 말하면 어노테이션은 리플렉션만을 위한 기능은 아닙니다. 어노테이션을 통해서 컴파일러의 동작을 바꾸거나 하는 경우도 있는데요, 이번 포스트에서는 어노테이션을 메타프로그래밍을 중심으로 다뤄볼 것 입니다.

Java에서 직접 어노테이션을 선언하는 방법을 확인해봅시다.

// Java 코드
enum CasingType { camelCase, snake_case }

@Target(ElementType.Field)
@Rentention(RententionPolicy.RUNTIME)
public @interface Casing {
  CasingType casingType default CasingType.camelCase;
}

가장 먼저 눈에 띄는 것은 @Target(ElementType.Field)입니다. 이는 해당 어노테이션이 어디에 붙을 수 있는지를 정합니다. 여기에서는 필드에 붙을 수 있음을 지정하고 있습니다. 두번째는 @Rentention(RententionPolicy.RUNTIME)으로, 해당 어노테이션 정보가 언제까지 존재하는지를 표시하는데, RententionPolicy에는 총 3가지 값이 있습니다. SOURCE는 해당 어노테이션이 프로그램 바이너리로 들어가지 않아 컴파일 타임에만 머문다는 것을 의미하고 CLASS는 프로그램 바이너리로 들어가지만 리플렉션으로 제공되지 않는다는 것을 의미하며, RUNTIME은 리플렉션으로 제공된다는 것을 의미합니다.

이렇게 정의한 어노테이션을 다음과 같이 사용할 수 있습니다.

//Java 코드
public class Item {
  @Casing(CasingType.snake_case)
  public String itemTag;
  public int amount;
}

이러한 어노테이션을 활용하여 리플렉션 코드에서 조회를 하면 좀 더 다양한 상황에 맞추어 사용할 수 있습니다. Typescript, Go에도 JVM의 어노테이션에 해당하는 기능은 일반적으로 지원해주고 있습니다.

// JVM 계열 언어인 Kotlin, Scala는 생략

// C# 코드
// attribute라고 부른다.
enum ECasingType { CamelCase, snake_case }
public class CasingTypeAttribute : System.Attribute {
  public ECasingType casingType;
}

// Typescript 코드
// Decorator라고 부른다. 2022년 7월 기준 experimental
type CasingType = "camelCase" | "snake_case"
function casing(casingType: CasingType) {
  return Reflect.metadata(Symbol("casingType"), casingType);
}

// Go 코드
// Struct tag라고 부른다.
type Item struct {
  itemTag string `casing:"snake_case"`
  amount int
}

Go의 struct tag에 대해서 유의해야할 것은 리플렉션으로 넘길 수 있는 데이터는 단순한 String 하나뿐이라는 것입니다. 따라서 casing:"snake_case"와 같은 형태로 넘기는 것이 컨벤션으로 잡혀 있습니다. 또한 좀 더 범용적으로 사용할 수 있는 어노테이션과 다르게, struct tag는 필드에만 달 수 있습니다.

템플릿 메타프로그래밍

런타임에만 메타프로그래밍할 수 있는 것이 아닙니다. 메타프로그래밍은 코드 그 자체를 이용하여 프로그래밍하는 만큼, 컴파일 타임에도 코드를 이용하여 메타프로그래밍을 할 수 있습니다.

다형성 편에서 C++의 템플릿에 대해 간략하게 소개드렸습니다. C++의 템플릿은 제네릭을 구현하는 것 뿐만 아니라 메타프로그래밍 또한 가능합니다. 이는 C++의 템플릿은 컴파일타임에 지정된 타입으로 코드를 틀로 찍어내듯 복사하기 때문입니다.

다형성 편에서 이미 템플릿을 통해 필드 접근을 일반화시킬 수 있다는 것을 보여드렸습니다.

// C++ 코드
template<typename T>
void printValue(T t) {
  cout << t.value << endl;
}

// A.value는 int 타입
struct A {
  int value;
};

// B.value는 float 타입
struct B {
  float value;
};

A a = { 5 };
B b = { 10.5f };
printValue(a);
printValue(b);

이때 타입 Tvalue를 요구한다는 것을 표시하지 않아도 됩니다.. C++ 템플릿에서는 일단 지정된 타입을 대입한 코드를 생성하고 이후에 해당 코드를 컴파일하기 때문에 만약 value 필드가 없는 타입을 넘긴다면 생성된 코드에 대한 컴파일 에러 메시지가 나타납니다.

// C++ 코드
struct C {};

C c;
printValue(c);
// C++ 컴파일러(clang++) 에러 메시지
error: no member named 'value' in 'C'
                cout << t.value << endl;
                        ~ ^

이는 곧 컴파일러가 템플릿 코드를 생성할 때 제공된 타입 T의 필드들과 그 이름, 그리고 필드들의 타입을 알고 있고 그러한 정보들을 이용해 컴파일이 성공하는지 확인하며 코드를 생성한다는 것을 알 수 있습니다.

이때 필드를 이름으로 조회하는 것을 일반화 시킬 수 있다는 점에서 리플렉션과 유사합니다. 차이점은 리플렉션은 런타임에서 타입 정보를 가져와 처리하고 템플릿은 컴파일 타임에 타입 정보를 가져와 코드를 생성합니다. 그렇기 때문에 유효하지 않은 타입을 넘겼을 때 에러가 컴파일 타임에 발생하기 때문에 에러를 좀 더 빠르게 발견하고 수정할 수 있습니다.

C++의 템플릿을 이용하면 좀 더 특이한 기능도 구현할 수 있습니다. 예를들어 std::is_base_of는 템플릿을 이용해 특정 타입이 다른 타입의 상위 타입인지 알려줍니다.

// C++ 코드
class A {};

// B의 상위 타입은 A다
class B : A {};

// result는 true
bool result = std::is_base_of<A, B>::value;

다른 언어에서는 이러한 기능은 컴파일러에 내장된 기능으로 제공해주는 경우가 많습니다. 어떻게 C++은 이것을 어떻게 템플릿만으로 구현할 수 있을까요? 해답은 템플릿 특별화(Template Specialization)에 있습니다. 먼저 특별화가 무엇인지 알아보아야 합니다.

// C++ 코드
// 구현 1
template <typename Base>
bool is_convertible(Base* base) {
  return true;
}

// 구현 2
// 구현 1을 적용할 수 없을 경우 사용된다
template <typename>
bool is_convertible(void* unknown) {
  return false;
}

class A {};

class B : A {};
B* b = new B();
// A는 B의 상위 타입이기에 A*에 B*를 대입할 수 있다
// 구현 1을 적용해도 문제없기에 is_convertible<A>(b)은 구현 1을 사용한다
// 따라서 true가 출력된다
is_convertible<A>(b);

class C {};
C* c = new C();
// A는 C의 상위 타입이 아니기에 A*에 C*를 대입할 수 없어
// 구현 1을 적용하면 컴파일 에러가 나기에 is_convertible<A>(b)은 구현 2을 사용한다
// 따라서 false가 출력된다
is_convertible<A>(c);

템플릿 특별화는 템플릿 함수 또는 타입을 사용했을 때 가장 적절한 구현을 자동으로 사용하는 기능입니다. 위 예시에서는 주어진 타입에 따라 컴파일 에러나는 함수 구현과 모든 타입을 사용할 수 있는 함수 구현 둘다 제공하여 상황에 맞는 구현을 사용하게 됩니다. is_convertible에 부모 타입을 넣었을 때 특별한 구현을 사용하는 것입니다.

이러한 특별화 기능은 다른 프로그래밍 언어들에게서는 쉽게 찾을 수 없는 기능입니다. C++가 강력한 언어라고 불리는 이유중 하나가 바로 이 템플릿 특별화에서 기인합니다. C++에는 이러한 템플릿 메타 프로그래밍을 활용한 함수들을 모아놓은 <type_traits> 라이브러리가 존재합니다. C++20에서는 이러한 템플릿 활용을 더 강화하기 위해 Concepts라는 개념을 추가하기도 하였죠.

다음은 리플렉션과 템플릿의 코드 생성을 비교한 표입니다.

리플렉션코드 생성
성능 저하런타임컴파일 타임
에러 발생 시점런타임컴파일 타임
바이너리 크기상대적으로 작음상대적으로 큼

하지만 C++의 템플릿에는 한계점이 있습니다. 리플렉션과 다르게 필드들의 목록을 가져올 방법이 없습니다. C++의 템플릿은 코드만을 생성하고 유효한지 검증해줄 뿐, 타입의 모든 정보를 가져오지는 못합니다. 리플렉션을 소개할때 작성한 임의의 데이터 타입을 JSON 형식으로 변환시키는 jsonify함수를 만들었었지만 C++에서는 템플릿만을 이용해서는 구현할 수 없습니다.

매크로

C++ 에서의 메타프로그래밍은 템플릿만이 있는 것은 아닙니다. 매크로라는 템플릿보다 더욱 강력한 메타프로그래밍 기능이 존재합니다. 여기서는그중에서도 코드 일부를 치환해서 생성할 수 있는 매크로 함수에 대해서 다룹니다. 매크로 함수는 코드를 생성할 때 부분적으로 치환할 수 있다는 점에서 템플릿과 비슷합니다. 위 value 필드를 출력하는 템플릿을 매크로로 구현하면 다음과 같습니다.

// C++ 코드
// 매크로 함수 선언
#define DEF_PRINT_VALUE(T)    \
  void printValue(T t) {      \
    cout << t.value << endl;  \
  }

// A.value는 int 타입
struct A {
  int value;
};

// B.value는 float 타입
struct B {
  float value;
};

// A와 B 버전에 대한 오버로딩 함수 생성
DEF_PRINT_VALUE(A)
DEF_PRINT_VALUE(B)

A a = { 5 };
B b = { 10.5f };
printValue(a);
printValue(b);

만약 유효하지 않은 타입으로 생성할 경우, 함수 호출 코드를 작성한 시점이 아닌 함수를 생성하는 시점에 에러가 발생합니다.

// C++ 코드
struct C {};

DEF_PRINT_VALUE(C)
// C++ 컴파일러(clang++) 에러 메시지
error: no member named 'value' in 'C'
DEF_PRINT_VALUE(C)
^~~~~~~~~~~~~~~~~~
note: expanded from macro 'DEF_PRINT_VALUE'
                        cout << t.value << endl;  \

C++의 템플릿에 비해 더 나은 점이 있는가 싶을 수도 있습니다. 하지만 매크로의 진가는 타입만을 치환시키는 것이 아닌, 코드의 모든 부분들을 치환시킬 수 있다는 것입니다.

// C++ 코드
// C/C++ 매크로 정의
#define DEF_PRINT_FIELD(T, fieldName)  \
  void print_##fieldName(T t) {        \
    cout << t.fieldName << endl;       \
  }

struct Human {
  string name;
  int age;
  string role; 
};

DEF_PRINT_FIELD(Human, name)
// 아래 코드가 생성된다.
//  void print_name(T t) {
//    cout << t.name << endl;
//  }

DEF_PRINT_FIELD(Human, age)
// 아래 코드가 생성된다.
//  void print_age(T t) {
//    cout << t.age << endl;
//  }

DEF_PRINT_FIELD(Human, role)
// 아래 코드가 생성된다.
//  void print_role(T t) {
//    cout << t.role << endl;
//  }

Human human = { "mango", 23, "software engineer" };

print_name(human);
print_age(human);
print_role(human);

역설적이게도 이렇게 강력하기 때문에 C/C++의 매크로를 기피하시는 분들도 많습니다. 강력한만큼 잘못된 방식으로 매크로를 사용하면 어떠한 동작을 할 지 예상하기 어려워져 더더욱 읽기 어려워지고 관리하기 어려워집니다.

하지만 리플렉션이 없고 컴파일 타임에서도 타입 정보가 부족한 C++에서는 보일러플레이트 코드를 줄이기 위해서는 매크로는 필수부가결합니다. 매크로의 단점을 감수하고 많은 C++ 프레임워크는 매크로를 사용합니다.

추상 구문 트리 매크로

Scala와 Rust에도 매크로가 존재합니다. 다만 C/C++의 매크로와는 차이점이 많습니다. 먼저 Scala 부터 살펴봅시다. Scala의 경우 매크로 실행할 때 소스의 모든 타입 정보들이 제공되며 추상 구문 트리(Abstract Syntax Tree, AST)에 접근하고 수정할 수 있습니다.

Scala의 매크로를 이용하면 메소드나 클래스를 자동으로 생성하고 타입클래스를 자동으로 구현해주거나 기존에 존재하는 클래스도 수정할 수 있습니다. 위 C++ 매크로를 Scala 방식으로 구현한 것은 다음과 같습니다.

// Scala 코드
// 메소드 생성하는 매크로 어노테이션 정의
class Printer extends StaticAnnotation {
  def macroTransform(annottees: Any*): Any = macro Printer.impl
}

// 매크로 정의
object Printer {
  def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
    import c.universe._

    // 어노테이션을 받은 클래스의 추상 구문 트리를 가져온다
    annottees.map(_.tree) match {
      case (clazz: ClassDef) :: Nil =>
        // 모든 필드들을 조회해 print 메소드들을 생성한다
        val printMethods = clazz.impl.body.collect {
          case valDef: ValDef if valDef.mods.hasFlag(Flag.CASEACCESSOR) =>
            q"""
            def ${TermName(s"print_${valDef.name}")}(): Unit =
              println(${valDef.name})
            """
        }

        // 생성된 print 메소드들을 이어붙힌다
        c.Expr(
          ClassDef(
            clazz.mods,
            clazz.name,
            clazz.tparams,
            Template(
              clazz.impl.parents,
              clazz.impl.self,
              clazz.impl.body ++ printMethods
            )
          )
        )
      case _ =>
        // class가 아닐 경우 에러를 낸다
        c.abort(c.enclosingPosition, "only classes can use @Printer")
    }
  }
}

// 매크로 어노테이션을 적용한 클래스를 정의한다
@Printer
case class Human(
  name: String,
  age: Int,
  role: String
)

// 생성된 메소드들을 마음껏 활용한다
val human = Human("mango", 23, "software engineer");
human.print_name();
human.print_age();
human.print_role();

Rust의 경우 매크로가 타입 정보를 제공해주지는 않습니다만, 여전히 추상 구문 트리에 접근할 수 있게 해주기 때문에 매크로를 적용 받는 타입의 필드 이름이나 필드 타입까지는 가져올 수 있습니다.

이를 이용하면 타입을 추가하거나 메소드를 추가하는 건 쉽습니다. Scala가 매크로로 타입클래스를 자동으로 구현해줄 수 있는 것처럼 Rust도 매크로를 이용하면 trait를 자동으로 구현해줄 수 있습니다. 위 C++ 매크로를 Rust 방식으로 구현한 것은 다음과 같습니다.

// Rust 코드
// attribute 매크로 정의
#[proc_macro_attribute]
pub fn make_printers(
  _attr: proc_macro::TokenStream,
  item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
  // 추상 구문 트리를 분석한다
  let derived_item = syn::parse_macro_input!(item as DeriveInput);
  let item_ident = &derived_item.ident;

  // attribute를 받은 struct의 구조를 가져온다
  let printers = match &derived_item.data {
    // 모든 필드들을 조회해 print 메소드들을 생성한다
    syn::Data::Struct(data) => data.fields.iter().map(|field| {
      let field_ident = field.ident.as_ref().unwrap();
      let printer_ident = proc_macro2::Ident::new(
        &format!("print_{}", field_ident),
        proc_macro2::Span::call_site(),
      );
      quote! {
        pub fn #printer_ident(&self){
          println!("{}", self.#field_ident);
        }
      }
    }),

    // sturct가 아닐 경우 에러를 낸다
    _ => compile_error!("only structs can use make_printers"),
  };

  // 생성된 print 메소드들을 추가한다
  quote! {
    #derived_item
    impl #item_ident {
      #(#printers)*
    }
  }.into()
}

// attribute 매크로를 적용한 struct를 정의한다
#[make_printers]
struct Human {
  pub name: String,
  pub age: i32,
  pub role: String,
}

// 생성된 메소드들을 마음껏 활용한다
let human = Human {
  name: "mango".to_owned(),
  age: 23,
  role: "software engineer".to_owned(),
};

human.print_name();
human.print_age();
human.print_role();

코드가 긴 만큼 단점도 명확하게 보일거라고 생각합니다. 일반적으로 이러한 강력한 기능을 제공하는 매크로는 복잡합니다. 이러한 문법 구문 트리를 수정할 수 있게 하는 매크로를 지원하려면 언어 차원에서 제공해야하는 기능이 많고 알아야 할 것도 많으며, 더 많은 코드가 컴파일 타임에 실행되는 만큼 컴파일 속도도 느려집니다. 하지만 그런 만큼 적재적소에 사용할 경우 많은 양의 보일러플레이트 코드를 없앨 수 있습니다.

메타프로그램

사실 모든 프로그래밍 언어에 메타프로그래밍을 사용할 수 있습니다. 툴이나 스크립트로 코드를 읽고 코드를 생성하는 것도 메타프로그래밍이기 때문입니다. 유명 라이브러리와 프레임워크들도 언어에서 자체적으로 지원되는 기능 대신 외부 프로그램으로 코드를 생성하기도 합니다. 이러한 프로그램들을 메타프로그램(Metaprogram)이라고 부릅니다.

Java은 Annotation Processor, Kotlin은 Kotlin Symbol Prcoessor를 통해 코드를 생성할 수 있으며 JVM의 특성을 이용해 기존에 존재하는 클래스도 수정할 수 있고 C# 또한 코드를 생성할 수 있는 다양한 툴이 존재하며 Go는 다형성 기능이 부족한 만큼 메타프로그램에 의존하는 경향이 높습니다. 매크로가 코드의 일부분만을 생성하고 다른 매크로 호출에 간섭할 수 없는 만큼 때로는 메타프로그램이 매크로보다 나은 경우도 있으며 추상 구문 트리에 접근 가능한 매크로가 존재하는 Scala도 파일 단위로 추상 구문 트리에 접근 할 수 있게 해주는 Scalameta라는 라이브러리가 존재합니다.

코드 생성이 대표적인 기능인 라이브러리들로는 GraphQL과 gRPC/Protobuf가 대표적입니다. GraphQL은 언어 라이브러리마다 다르지만 GraphQL 스키마로부터 API 코드를 생성하거나 API 코드로부터 GraphQL 스키마를 생성할 수 있고 gRPC/Protobuf는 API나 데이터 타입을 protobuf 코드로 정의하고 다양한 언어들을 대상으로 코드를 생성할 수 있는 기능이 있습니다.

반드시 라이브러리와 툴을 사용해서 코드를 생성해야만 메타프로그램인 것이 아닙니다. Python과 같은 스크립팅 언어로 소스 파일을 생성해주는 프로그램 또한 메타프로그램입니다. 소스 파일의 추상 구문 트리를 분석하거나 스키마 파일을 읽을 수 있는 툴들에 비해 접근할 수 있는 정보는 적지만 프로젝트에 맞는 소스 코드를 자유롭게 생성할 수 있다는 장점이 있습니다.

결론

그만큼 메타프로그래밍은 큰 규모의 프로젝트에서는 필수적인 기능입니다. 다형성을 통해 코드 재사용성을 늘리는 것이 가장 좋지만 고도화된 아키텍처에서는 도메인의 요구 사항에 맞는 메타프로그래밍이 필요한 순간이 옵니다. 그럴 때 이 글이 더 좋은 아키텍처를 디자인하는데에 조금이라도 도움이 되었으면 합니다.

프로그래밍 언어 시리즈에는 의도적으로 정적 타입 언어들만 포함하고 있었는데요, 이유는 정적 타입 언어와 동적 타입 언어를 정확하게 비교하려면 정적 타입 언어의 다형성, 메타프로그래밍까지 전부 이해하고 있어야 하기 때문입니다. 언젠가 기회가 된다면 정적 타입 언어와 동적 타입 언어를 비교하는 글을 쓸 수 있으면 좋겠습니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

데브시스터즈에서는 능력있는 [쿠키런: 킹덤] 게임 서버 소프트웨어 엔지니어를 찾고 있습니다.
자세한 내용은 채용 사이트를 확인해주세요!
게임서버Scala함수형

© 2024 Devsisters Corp. All Rights Reserved.