자바에서의 CRTP(Curiously Recurring Template Pattern)

최근 빌더 패턴을 구현하다 CRTP(Curiously Recurring Template Pattern) 패턴을 알게 되었는데, 처음 접하는 패턴이라는 점과 제네릭을 통해 구현된 다소 복잡해 보이는 패턴이라 공부하게 되었습니다. 이번 글을 통해 CRTP 패턴이 무엇인지와 장단점에 대해 소개드립니다.

 

CRTP란?


CRTP는 원래 C++에서 주로 사용되는 패턴으로 클래스가 자신의 서브클래스 타입을 제네릭 매개변수로 사용하는 디자인 패턴입니다.

 

자바에서는 주로 빌더 패턴에서 자식 클래스의 타입을 부모 클래스에 전달하여 메서드 체이닝 시 자식 클래스의 메서드를 사용할 수 있게 합니다. (제네릭을 이용한 재귀적 타입 제한)

 

즉, 빌더 패턴에서 주로 이루어지는 메서드 체이닝에서 자식 클래스 타입을 유지함으로 컴파일 시점에 타입 오류를 방지할 수 있다는 장점이 있습니다.

 

간단하게 CRTP 패턴이 무엇인지 알아보았는데 조금 더 자세히 어떤 장점과 단점을 갖고 있는지 예제 코드와 함께 소개드립니다.

 

VehicleBuilder 코드

public abstract class VehicleBuilder<T extends VehicleBuilder<T>> {

    protected String color;
    protected int speed;

    public T color(String color) {
        this.color = color;
        return self();
    }

    public T speed(int speed) {
        this.speed = speed;
        return self();
    }

    // 자식클래스에서 자신의 인스턴스를 반환하도록 추상 메서드 정의
    protected abstract T self();

    public Vehicle build() {
        return new Vehicle(color, speed);
    }
}

 

위 코드와 같이 VehicleBuilder 클래스를 상속받고 있는 자식 클래스를 T(제네릭 매개변수)로 사용하고 있는 형태를 CRTP 패턴이라 합니다.

자식 클래스를 제네릭 매개변수로 사용함으로써 다음과 같은 이점을 갖게 됩니다.

  1. 타입 안전성
  2. 확장성(코드 재사용성)

예제 코드를 통해 알아보겠습니다.

 

장점

 

1. 타입 안정성


VehicleBuilder 클래스를 상속받고 있는 CarBuilder 클래스를 통해 Car 객체를 만든다고 해보겠습니다.

 

public class CarBuilder extends VehicleBuilder<CarBuilder> {

    private int numberOfWheel;

    public CarBuilder numberOfWheel(int numberOfWheel) {
        this.numberOfWheel = numberOfWheel;
        return this;
    }

    @Override
    protected CarBuilder self() {
        return this;
    }

    @Override
    public Car build() {
        return new Car(color, speed, numberOfWheel);
    }
}

public class Vehicle {
    private final String color;
    private final int speed;

    public Vehicle(String color, int speed) {
        this.color = color;
        this.speed = speed;
    }
}


public class Car extends Vehicle {

    private final int numberOfWheel;

    public Car(String color, int speed, int numberOfWheel) {
        super(color, speed);
        this.numberOfWheel = numberOfWheel;
    }
}

 

이전에 보여준 VehicleBuilder 클래스는 color(), speed() 메서드가 정확히 CarBuilder 클래스를 반환하도록 보장합니다.

그 이유는 CarBuilder 클래스가 VehicleBuilder 클래스를 상속받고 자신의 타입을 제네릭 매개변수로 사용(CarBuilder extends VehicleBuilder <CarBuilder>)하였기 때문입니다.

 

Car car = new CarBuilder()
      .color("Red")
      .speed(180)
      .numberOfWheel(4)
      .build();

 

그래서 위 CarBuilder를 통해 Car 객체를 만들 때 color(), speed() 메서드를 사용하더라도 정확한 타입(여기서 CarBuilder)이 반환되므로 타입 변환이 필요 없어집니다.

 

이러한 타입 안정성 덕분에 컴파일 시점에 타입 오류를 방지하여 런타임 오류를 줄입니다.

 

2. 확장성(코드 재사용성)


Car 객체를 만드는 CarBuilder 뿐만 아니라 Bike 객체를 만드는 BikeBuilder를 만든다고 해보겠습니다.

public class BikeBuilder extends VehicleBuilder<BikeBuilder>{

    private boolean hasHelmet;

    public BikeBuilder hasHelmet(boolean hasHelmet) {
        this.hasHelmet = hasHelmet;
        return this;
    }

    @Override
    protected BikeBuilder self() {
        return this;
    }

    @Override
    public Vehicle build() {
        return new Bike(color, speed, hasHelmet);
    }
}

 

탈것을 만드는 공통 로직(색상, 스피드)은 추상 클래스에 두어 재사용하고, 자식 클래스에서는 개별적인 설정을 추가로 가능하도록 하였습니다.

 

즉, 각각의 빌더 클래스에서 공통적인 코드를 불필요하게 작성하지 않고 추상 클래스에 작성함으로써 코드를 재사용할 수 있고 자식 클래스에서는 추가적으로 확장을 통한 기능 구현이 가능합니다.

 

위와 같은 특징 덕분에 CRTP는 복잡한 객체 생성이나 추가 로직을 유연하고 타입 안전하게 구현하는 데 유용합니다.

 

단점

 

CRTP 을 구현했을 때 제가 생가한 단점은 아래와 같습니다.

  1. 복잡성 증가
  2. 디버깅 난이도 증가

 

1. 복잡성 증가


처음 느꼈던 그대로 CRTP는 제네릭과 상속을 결합한 패턴이라, 구조가 다소 복잡해 보일 수 있고 특히 제네릭에 대한 이해도가 떨어진다면 이해하기 어려운 패턴이라고 생각하였습니다.

 

추가적으로 제네릭 타입 매개변수와 추상 메서드의 조합으로 인해 코드가 복잡해지고, 가독성이 떨어지는 느낌을 받았습니다.

복잡성이 증가함에 따라 유지보수도 어려워질 수 있다고 생각했습니다.

 

추상 클래스를 상속받는 자식 클래스가 증가하게 되었을 때, 제네릭과 상속을 결합한 복잡한 패턴이 오히려 유지보수를 어렵게 만들 수 있습니다.

 

2. 디버깅 난이도 증가


복잡성이 증가함에 따라 디버깅 난이도도 함께 증가하는 것을 체감할 수 있었습니다.

자바는 제네릭 타입을 런타임 시점에 소거하기 때문에, 복잡한 제네릭 구조에서 발생하는 오류는 컴파일 타임에만 확인 가능하며, 디버깅 과정에서 타입 정보를 확인하기가 어렵습니다.

 

즉, 해당 패턴을 이용해 타입 안정성이 컴파일 시점에 타입을 보장함과 코드 재사용성을 줄이는 이점을 얻을 수 있지만 복잡성 증가와 함께 가져오는 오류 발생 시 디버깅에 대한 난이도 증가는 어느 정도 트레이드오프를 해야 합니다.