Groovy 트레이트(Traits)

Groovy 트레이트(Traits)

이 장에서는 Groovy의 트레이트에 대해 다뤄요.

⚠️ 원문 페이지(groovy-lang.org/traits.html)는 404로 바뀌어 있고, 트레이트 내용은 현재 Object orientation 문서의 한 섹션으로 통합되어 있어요. 이 글은 그 섹션을 기준으로 번역했답니다.

출처: Groovy 공식 문서

본문

5. 트레이트(Traits)

트레이트는 언어의 구조적 구성물로서 다음을 가능하게 해 줍니다.

  • 동작의 합성(composition of behaviors)
  • 런타임에서의 인터페이스 구현
  • 동작 오버라이딩
  • 정적 타입 검사/컴파일과의 호환성

트레이트는 기본 구현상태를 함께 담고 있는 인터페이스로 볼 수 있어요. 트레이트는 trait 키워드로 정의합니다.

trait FlyingAbility {                           (1)
        String fly() { "I'm flying!" }          (2)
}

(1) — 트레이트 선언

(2) — 트레이트 안의 메서드 선언

그리고 나서 일반 인터페이스처럼 implements 키워드로 사용할 수 있어요.

class Bird implements FlyingAbility {}          (1)
def b = new Bird()                              (2)
assert b.fly() == "I'm flying!"                 (3)

(1) — Bird 클래스의 능력에 트레이트 FlyingAbility를 추가해요

(2) — 새 Bird를 인스턴스화해요

(3) — Bird 클래스가 자동으로 FlyingAbility 트레이트의 동작을 갖게 돼요

트레이트는 단순한 합성에서 테스트에 이르기까지 광범위한 능력을 제공하는데, 이 섹션에서 자세히 설명할게요.

5.1. 메서드

5.1.1. 공개 메서드

트레이트에서 메서드를 선언하는 건 클래스의 일반 메서드 선언과 똑같이 할 수 있어요.

trait FlyingAbility {                           (1)
        String fly() { "I'm flying!" }          (2)
}

(1) — 트레이트 선언

(2) — 트레이트 안의 메서드 선언

5.1.2. 추상 메서드

그에 더해 트레이트는 추상 메서드도 선언할 수 있어요. 이런 메서드는 트레이트를 구현하는 클래스에서 구현해야 합니다.

trait Greetable {
    abstract String name()                              (1)
    String greeting() { "Hello, ${name()}!" }           (2)
}

(1) — 구현하는 클래스는 name 메서드를 선언해야 해요

(2) — 구상 메서드와 섞을 수 있어요

그러면 트레이트는 이렇게 사용할 수 있어요.

class Person implements Greetable {                     (1)
    String name() { 'Bob' }                             (2)
}

def p = new Person()
assert p.greeting() == 'Hello, Bob!'                    (3)

(1) — 트레이트 Greetable을 구현해요

(2) — name이 추상 메서드였으므로 구현이 필요해요

(3) — 그러면 greeting을 호출할 수 있어요

5.1.3. 비공개 메서드

트레이트는 비공개(private) 메서드도 정의할 수 있어요. 이런 메서드는 트레이트 계약 인터페이스에는 나타나지 않습니다.

trait Greeter {
    private String greetingMessage() {                      (1)
        'Hello from a private method!'
    }
    String greet() {
        def m = greetingMessage()                           (2)
        println m
        m
    }
}
class GreetingMachine implements Greeter {}                 (3)
def g = new GreetingMachine()
assert g.greet() == "Hello from a private method!"          (4)
try {
    assert g.greetingMessage()                              (5)
} catch (MissingMethodException e) {
    println "greetingMessage is private in trait"
}

(1) — 트레이트에 비공개 메서드 greetingMessage를 정의해요

(2) — 공개 greet 메시지가 기본적으로 greetingMessage를 호출해요

(3) — 트레이트를 구현하는 클래스를 만들어요

(4) — greet은 호출할 수 있어요

(5) — 하지만 greetingMessage는 호출할 수 없어요

트레이트는 공개(public) 메서드와 비공개(private) 메서드만 지원해요. protected나 패키지 비공개 스코프는 지원되지 않습니다.

5.1.4. final 메서드

트레이트를 구현하는 클래스가 있을 때, 개념적으로 트레이트 메서드의 구현은 클래스로 "상속"됩니다. 하지만 실제로는 그런 구현을 담은 기반 클래스가 존재하지 않아요. 대신 그 구현이 클래스에 직접 직조(woven)됩니다. 메서드의 final 수식어는 그저 직조된 메서드가 어떤 수식어를 갖게 될지를 나타내요. 같은 시그니처를 가진 메서드를 final과 non-final 변형을 섞어 상속·오버라이드하거나 다중 상속하는 건 좋지 않은 스타일로 여겨질 수 있지만, Groovy는 이 시나리오를 금지하지 않아요. 일반적인 메서드 선택 규칙이 적용되고, 결과 메서드에 따라 수식어가 결정됩니다. 오버라이드할 수 없는 트레이트 구현 메서드를 원한다면, 원하는 트레이트(들)를 구현하는 기반 클래스를 만드는 것도 고려해 볼 수 있어요.

5.2. this의 의미

this는 트레이트를 구현하는 인스턴스를 나타내요. 트레이트를 수퍼클래스라고 생각하면 돼요. 즉 다음과 같이 작성하면

trait Introspector {
    def whoAmI() { this }
}
class Foo implements Introspector {}
def foo = new Foo()

다음처럼 호출했을 때

foo.whoAmI()

같은 인스턴스를 반환합니다.

assert foo.whoAmI().is(foo)

5.3. 인터페이스

트레이트는 인터페이스를 구현할 수 있어요. 그 경우 인터페이스는 implements 키워드로 선언합니다.

interface Named {                                       (1)
    String name()
}
trait Greetable implements Named {                      (2)
    String greeting() { "Hello, ${name()}!" }
}
class Person implements Greetable {                     (3)
    String name() { 'Bob' }                             (4)
}

def p = new Person()
assert p.greeting() == 'Hello, Bob!'                    (5)
assert p instanceof Named                               (6)
assert p instanceof Greetable                           (7)

(1) — 일반 인터페이스 선언

(2) — 구현하는 인터페이스 목록에 Named를 추가해요

(3) — 트레이트 Greetable을 구현하는 클래스를 선언해요

(4) — 빠진 name 메서드를 구현해요

(5) — greeting 구현은 트레이트에서 오는 거예요

(6) — PersonNamed 인터페이스를 구현하는지 확인해요

(7) — PersonGreetable 트레이트를 구현하는지 확인해요

5.4. 프로퍼티

트레이트는 다음 예시처럼 프로퍼티를 정의할 수 있어요.

trait Named {
    String name                             (1)
}
class Person implements Named {}            (2)
def p = new Person(name: 'Bob')             (3)
assert p.name == 'Bob'                      (4)
assert p.getName() == 'Bob'                 (5)

(1) — 트레이트 안에 프로퍼티 name을 선언해요

(2) — 트레이트를 구현하는 클래스를 선언해요

(3) — 프로퍼티가 자동으로 보이게 돼요

(4) — 일반 프로퍼티 접근자로 접근할 수 있어요

(5) — 또는 일반 getter 문법으로 접근할 수 있어요

5.5. 필드

5.5.1. 비공개 필드

트레이트가 비공개 메서드를 허용하므로, 상태를 저장하려고 비공개 필드를 쓰는 것도 유용해요. 트레이트는 그렇게 할 수 있게 해 줍니다.

trait Counter {
    private int count = 0                   (1)
    int count() { count += 1; count }       (2)
}
class Foo implements Counter {}             (3)
def f = new Foo()
assert f.count() == 1                       (4)
assert f.count() == 2

(1) — 트레이트 안에 비공개 필드 count를 선언해요

(2) — 카운터를 증가시키고 반환하는 공개 메서드 count를 선언해요

(3) — Counter 트레이트를 구현하는 클래스를 선언해요

(4) — count 메서드가 비공개 필드를 사용해 상태를 유지할 수 있어요

이는 Java 8의 가상 확장 메서드(virtual extension methods)와의 큰 차이점이에요. 가상 확장 메서드는 상태를 담지 못하지만, 트레이트는 상태를 담을 수 있죠. 게다가 Groovy의 트레이트는 그 구현이 가상 확장 메서드에 의존하지 않기 때문에 Java 6부터 지원됩니다. 즉 트레이트가 Java 클래스에서는 일반 인터페이스로 보이지만, 그 인터페이스에는 기본 메서드가 없고 추상 메서드만 있을 거예요.

5.5.2. 공개 필드

공개 필드는 비공개 필드와 같은 방식으로 동작하지만, 다이아몬드 문제를 피하기 위해 필드 이름이 구현 클래스에서 다시 매핑(remap)됩니다.

trait Named {
    public String name                      (1)
}
class Person implements Named {}            (2)
def p = new Person()                        (3)
p.Named__name = 'Bob'                       (4)

(1) — 트레이트 안에 공개 필드를 선언해요

(2) — 트레이트를 구현하는 클래스를 선언해요

(3) — 그 클래스의 인스턴스를 만들어요

(4) — 공개 필드는 사용 가능하지만 이름이 바뀌어 있어요

필드의 이름은 트레이트의 완전한 이름에 따라 달라져요. 패키지의 모든 점(.)은 밑줄(_)로 바뀌고, 최종 이름에는 이중 밑줄이 포함됩니다. 예를 들어 필드 타입이 String이고 패키지 이름이 my.package, 트레이트 이름이 Foo, 필드 이름이 bar라면, 구현 클래스에서 공개 필드는 다음과 같이 나타나요.

String my_package_Foo__bar

트레이트가 공개 필드를 지원하긴 하지만, 사용을 권장하지 않고 나쁜 관행으로 간주해요.

5.6. 동작의 합성

트레이트는 통제된 방식으로 다중 상속을 구현하는 데 사용할 수 있어요. 예를 들어 다음 트레이트들을 가질 수 있어요.

trait FlyingAbility {                           (1)
        String fly() { "I'm flying!" }          (2)
}
trait SpeakingAbility {
    String speak() { "I'm speaking!" }
}

그리고 두 트레이트를 모두 구현하는 클래스를 만들 수 있죠.

class Duck implements FlyingAbility, SpeakingAbility {} (1)

def d = new Duck()                                      (2)
assert d.fly() == "I'm flying!"                         (3)
assert d.speak() == "I'm speaking!"                     (4)

(1) — Duck 클래스는 FlyingAbilitySpeakingAbility를 모두 구현해요

(2) — Duck의 새 인스턴스를 만들어요

(3) — FlyingAbilityfly 메서드를 호출할 수 있어요

(4) — SpeakingAbilityspeak 메서드도 호출할 수 있어요

트레이트는 객체들 사이에서 능력의 재사용과, 기존 동작의 합성을 통한 새 클래스 생성 을 장려합니다.

5.7. 기본 메서드 오버라이딩

트레이트는 메서드의 기본 구현을 제공하지만, 구현 클래스에서 이를 오버라이드할 수 있어요. 예를 들어 꽥꽥 우는 오리를 만들기 위해 위 예시를 조금 바꿔 볼게요.

class Duck implements FlyingAbility, SpeakingAbility {
    String quack() { "Quack!" }                         (1)
    String speak() { quack() }                          (2)
}

def d = new Duck()
assert d.fly() == "I'm flying!"                         (3)
assert d.quack() == "Quack!"                            (4)
assert d.speak() == "Quack!"                            (5)

(1) — Duck 고유의 quack 메서드를 정의해요

(2) — speak의 기본 구현을 오버라이드해 quack을 쓰도록 해요

(3) — 오리는 여전히 기본 구현으로 날 수 있어요

(4) — quackDuck 클래스에서 와요

(5) — speak는 더 이상 SpeakingAbility의 기본 구현을 쓰지 않아요

5.8. 트레이트 확장

5.8.1. 단순 상속

트레이트는 다른 트레이트를 확장할 수 있어요. 이때는 extends 키워드를 써야 합니다.

trait Named {
    String name                                     (1)
}
trait Polite extends Named {                        (2)
    String introduce() { "Hello, I am $name" }      (3)
}
class Person implements Polite {}
def p = new Person(name: 'Alice')                   (4)
assert p.introduce() == 'Hello, I am Alice'         (5)

(1) — Named 트레이트는 name 프로퍼티 하나를 정의해요

(2) — Polite 트레이트는 Named 트레이트를 extends 해요

(3) — Polite는 수퍼 트레이트의 name 프로퍼티에 접근할 수 있는 새 메서드를 추가해요

(4) — name 프로퍼티는 Polite를 구현하는 Person 클래스에서 보여요

(5) — introduce 메서드도 마찬가지예요

5.8.2. 다중 상속

또는 트레이트가 여러 트레이트를 확장할 수도 있어요. 그 경우 모든 수퍼 트레이트를 implements 절에 선언해야 합니다.

trait WithId {                                      (1)
    Long id
}
trait WithName {                                    (2)
    String name
}
trait Identified implements WithId, WithName {}     (3)

(1) — WithId 트레이트는 id 프로퍼티를 정의해요

(2) — WithName 트레이트는 name 프로퍼티를 정의해요

(3) — IdentifiedWithIdWithName 둘 다 상속하는 트레이트예요

5.9. 덕 타이핑과 트레이트

5.9.1. 동적 코드

트레이트는 일반 Groovy 클래스처럼 동적 코드를 호출할 수 있어요. 즉 메서드 본문에서, 구현 클래스에 존재하기로 예정된 메서드를 인터페이스에 명시적으로 선언하지 않고 호출할 수 있다는 뜻이에요. 이는 트레이트가 덕 타이핑과 완전히 호환된다는 것을 의미합니다.

trait SpeakingDuck {
    String speak() { quack() }                      (1)
}
class Duck implements SpeakingDuck {
    String methodMissing(String name, args) {
        "${name.capitalize()}!"                     (2)
    }
}
def d = new Duck()
assert d.speak() == 'Quack!'                        (3)

(1) — SpeakingDuckquack 메서드가 정의되어 있으리라 기대해요

(2) — Duck 클래스는 methodMissing으로 그 메서드를 구현해요

(3) — speak 메서드를 호출하면 quack 호출이 일어나고, 이를 methodMissing이 처리해요

5.9.2. 트레이트 안의 동적 메서드

트레이트가 methodMissing이나 propertyMissing 같은 MOP 메서드를 구현하는 것도 가능해요. 그 경우 구현 클래스는 트레이트에서 그 동작을 상속받아요. 다음 예시를 볼게요.

trait DynamicObject {                               (1)
    private Map props = [:]
    def methodMissing(String name, args) {
        name.toUpperCase()
    }
    def propertyMissing(String name) {
        props.get(name)
    }
    void setProperty(String name, Object value) {
        props.put(name, value)
    }
}

class Dynamic implements DynamicObject {
    String existingProperty = 'ok'                  (2)
    String existingMethod() { 'ok' }                (3)
}
def d = new Dynamic()
assert d.existingProperty == 'ok'                   (4)
assert d.foo == null                                (5)
d.foo = 'bar'                                       (6)
assert d.foo == 'bar'                               (7)
assert d.existingMethod() == 'ok'                   (8)
assert d.someMethod() == 'SOMEMETHOD'               (9)

(1) — 여러 MOP 메서드를 구현하는 트레이트를 만들어요

(2) — Dynamic 클래스는 프로퍼티를 정의해요

(3) — Dynamic 클래스는 메서드를 정의해요

(4) — 존재하는 프로퍼티를 호출하면 Dynamic의 메서드가 호출돼요

(5) — 존재하지 않는 프로퍼티를 호출하면 트레이트의 메서드가 호출돼요

(6) — 트레이트에 정의된 setProperty가 호출돼요

(7) — 트레이트에 정의된 getProperty가 호출돼요

(8) — Dynamic에 존재하는 메서드를 호출해요

(9) — 하지만 존재하지 않는 메서드는 트레이트의 methodMissing 덕분에 호출돼요

5.10. 다중 상속 충돌

5.10.1. 기본 충돌 해결

클래스가 여러 트레이트를 구현하는 건 가능해요. 어떤 트레이트가 다른 트레이트의 메서드와 같은 시그니처의 메서드를 정의하면 충돌이 생깁니다.

trait A {
    String exec() { 'A' }               (1)
}
trait B {
    String exec() { 'B' }               (2)
}
class C implements A,B {}               (3)

(1) — 트레이트 AString을 반환하는 exec 메서드를 정의해요

(2) — 트레이트 B는 똑같은 메서드를 정의해요

(3) — 클래스 C는 두 트레이트를 모두 구현해요

이 경우 기본 동작은 implements 절에서 마지막에 선언된 트레이트의 메서드가 이기는 거예요. 여기서는 A 다음에 B가 선언되었으므로 B의 메서드가 선택됩니다.

def c = new C()
assert c.exec() == 'B'

5.10.2. 사용자 충돌 해결

이 동작이 원하는 게 아니라면, Trait.super.foo 문법으로 호출할 메서드를 명시적으로 선택할 수 있어요. 위 예시에서 이렇게 작성하면 트레이트 A의 메서드가 호출되도록 할 수 있어요.

class C implements A,B {
    String exec() { A.super.exec() }    (1)
}
def c = new C()
assert c.exec() == 'A'                  (2)

(1) — 트레이트 Aexec을 명시적으로 호출해요

(2) — 기본 해결(B의 버전) 대신 A의 버전을 호출해요

5.11. 트레이트의 런타임 구현

5.11.1. 런타임에서 트레이트 구현하기

Groovy는 런타임에 트레이트를 동적으로 구현하는 것도 지원해요. 트레이트로 기존 객체를 "장식(decorate)"할 수 있게 해 주죠. 예시로 이 트레이트와 다음 클래스부터 시작해 볼게요.

trait Extra {
    String extra() { "I'm an extra method" }            (1)
}
class Something {                                       (2)
    String doSomething() { 'Something' }                (3)
}

(1) — Extra 트레이트는 extra 메서드를 정의해요

(2) — Something 클래스는 Extra 트레이트를 구현하지 않아요

(3) — SomethingdoSomething 메서드만 정의해요

그런 다음 이렇게 하면:

def s = new Something()
s.extra()

SomethingExtra를 구현하지 않으므로 extra 호출은 실패해요. 다음 문법으로 런타임에 할 수 있어요.

def s = new Something() as Extra                        (1)
s.extra()                                               (2)
s.doSomething()                                         (3)

(1) — as 키워드를 사용해 객체를 런타임에 트레이트로 강제 변환해요

(2) — 그러면 객체에서 extra를 호출할 수 있어요

(3) — 그리고 doSomething은 여전히 호출 가능해요

객체를 트레이트로 강제 변환하면 그 연산의 결과는 같은 인스턴스가 아니에요. 강제 변환된 객체가 트레이트와 원래 객체가 구현하는 인터페이스 둘 다를 구현한다는 건 보장되지만, 결과는 원래 클래스의 인스턴스가 아니에요.

5.11.2. 여러 트레이트를 한 번에 구현하기

여러 트레이트를 한 번에 구현해야 한다면 as 키워드 대신 withTraits 메서드를 쓸 수 있어요.

trait A { void methodFromA() {} }
trait B { void methodFromB() {} }

class C {}

def c = new C()
c.methodFromA()                     (1)
c.methodFromB()                     (2)
def d = c.withTraits A, B           (3)
d.methodFromA()                     (4)
d.methodFromB()                     (5)

(1) — CA를 구현하지 않으므로 methodFromA 호출은 실패해요

(2) — CB를 구현하지 않으므로 methodFromB 호출은 실패해요

(3) — withTraitscAB를 구현하는 어떤 것으로 감싸요

(4) — dA를 구현하므로 methodFromA는 이제 통과해요

(5) — dB를 구현하므로 methodFromB는 이제 통과해요

객체를 여러 트레이트로 강제 변환하면 그 연산의 결과는 같은 인스턴스가 아니에요. 강제 변환된 객체가 트레이트와 원래 객체가 구현하는 인터페이스 둘 다를 구현한다는 건 보장되지만, 결과는 원래 클래스의 인스턴스가 아니에요.

5.12. 동작 연결(Chaining behavior)

Groovy는 스택 가능한 트레이트(stackable traits) 라는 개념을 지원해요. 핵심 아이디어는, 현재 트레이트가 메시지를 처리할 수 없으면 다른 트레이트로 위임하는 거예요. 이를 설명하기 위해 다음처럼 메시지 핸들러 인터페이스를 상상해 볼게요.

interface MessageHandler {
    void on(String message, Map payload)
}

그러면 작은 동작들을 적용해 메시지 핸들러를 합성할 수 있어요. 예를 들어 트레이트 형태의 기본 핸들러를 정의해 볼게요.

trait DefaultHandler implements MessageHandler {
    void on(String message, Map payload) {
        println "Received $message with payload $payload"
    }
}

그러면 어떤 클래스든 트레이트를 구현해 기본 핸들러의 동작을 상속받을 수 있어요.

class SimpleHandler implements DefaultHandler {}

이제 기본 핸들러에 더해 모든 메시지를 로깅하고 싶다면 어떻게 할까요? 한 가지 방법은 이렇게 쓰는 거예요.

class SimpleHandlerWithLogging implements DefaultHandler {
    void on(String message, Map payload) {                                  (1)
        println "Seeing $message with payload $payload"                     (2)
        DefaultHandler.super.on(message, payload)                           (3)
    }
}

(1) — on 메서드를 명시적으로 구현해요

(2) — 로깅을 수행해요

(3) — DefaultHandler 트레이트에 위임하며 계속돼요

이건 동작하지만 단점이 있어요.

  • 로깅 로직이 "구상" 핸들러에 묶여 있어요
  • on 메서드에 DefaultHandler에 대한 명시적 참조가 있어서, 클래스가 구현하는 트레이트를 바꾸면 코드가 깨질 수 있어요

대안으로, 책임이 로깅에만 한정된 다른 트레이트를 쓸 수 있어요.

trait LoggingHandler implements MessageHandler {                            (1)
    void on(String message, Map payload) {
        println "Seeing $message with payload $payload"                     (2)
        super.on(message, payload)                                          (3)
    }
}

(1) — 로깅 핸들러는 그 자체로 핸들러예요

(2) — 받은 메시지를 출력해요

(3) — 그다음 super가 호출을 체인상의 다음 트레이트에 위임해요

그러면 우리 클래스는 이렇게 다시 쓸 수 있어요.

class HandlerWithLogger implements DefaultHandler, LoggingHandler {}
def loggingHandler = new HandlerWithLogger()
loggingHandler.on('test logging', [:])

그러면 이렇게 출력됩니다.

Seeing test logging with payload [:]
Received test logging with payload [:]

우선순위 규칙상 LoggingHandler가 마지막에 선언되었으므로 이기고, on 호출은 LoggingHandler의 구현을 사용해요. 그런데 후자에는 super 호출이 있어서 체인상의 다음 트레이트로 이어져요. 여기서 다음 트레이트는 DefaultHandler이므로 둘 다 호출됩니다.

세 번째 핸들러를 추가하면 이 접근법의 장점이 더 분명해져요. say로 시작하는 메시지를 처리하는 핸들러예요.

trait SayHandler implements MessageHandler {
    void on(String message, Map payload) {
        if (message.startsWith("say")) {                                    (1)
            println "I say ${message - 'say'}!"
        } else {
            super.on(message, payload)                                      (2)
        }
    }
}

(1) — 핸들러 특유의 선행 조건

(2) — 선행 조건이 충족되지 않으면 체인상의 다음 핸들러로 메시지를 넘겨요

그러면 최종 핸들러는 다음과 같아져요.

class Handler implements DefaultHandler, SayHandler, LoggingHandler {}
def h = new Handler()
h.on('foo', [:])
h.on('sayHello', [:])

즉 다음을 의미해요.

  • 메시지는 먼저 로깅 핸들러를 통과해요
  • 로깅 핸들러는 super를 호출해 다음 핸들러, 즉 SayHandler에 위임해요
  • 메시지가 say로 시작하면 핸들러가 메시지를 소비해요
  • 아니면 say 핸들러가 체인상의 다음 핸들러에 위임해요

이 접근법은 서로를 모르는 핸들러들을 원하는 순서로 결합할 수 있게 해 주는 아주 강력한 방법이에요. 예를 들어 이 코드를 실행하면 이렇게 출력됩니다.

Seeing foo with payload [:]
Received foo with payload [:]
Seeing sayHello with payload [:]
I say Hello!

하지만 로깅 핸들러를 체인에서 두 번째로 옮기면 출력이 달라져요.

class AlternateHandler implements DefaultHandler, LoggingHandler, SayHandler {}
h = new AlternateHandler()
h.on('foo', [:])
h.on('sayHello', [:])

출력:

Seeing foo with payload [:]
Received foo with payload [:]
I say Hello!

이유는 이제 SayHandlersuper를 호출하지 않고 메시지를 소비하므로, 로깅 핸들러가 더 이상 호출되지 않기 때문이에요.

5.12.1. 트레이트 안의 super 의미론

클래스가 여러 트레이트를 구현하고 자격 없는(unqualified) super 호출이 발견되면:

  • 클래스가 다른 트레이트를 구현하면, 호출은 체인상의 다음 트레이트에 위임돼요
  • 체인에 남은 트레이트가 없으면, super는 구현 클래스의 수퍼클래스를 가리켜요(this)

예를 들어 이 동작 덕분에 final 클래스를 장식하는 것도 가능합니다.

trait Filtering {                                       (1)
    StringBuilder append(String str) {                  (2)
        def subst = str.replace('o','')                 (3)
        super.append(subst)                             (4)
    }
    String toString() { super.toString() }              (5)
}
def sb = new StringBuilder().withTraits Filtering       (6)
sb.append('Groovy')
assert sb.toString() == 'Grvy'                          (7)

(1) — 런타임에 StringBuilder에 적용하기 위한 Filtering 트레이트를 정의해요

(2) — append 메서드를 재정의해요

(3) — 문자열에서 모든 'o’를 제거해요

(4) — 그다음 super에 위임해요

(5) — toString이 호출되면 super.toString에 위임해요

(6) — StringBuilder 인스턴스에 Filtering 트레이트를 런타임 구현해요

(7) — 추가된 문자열에 더 이상 o 문자가 없어요

이 예시에서 super.append를 만나면 대상 객체가 구현하는 다른 트레이트가 없으므로, 호출되는 메서드는 원래의 append 메서드, 즉 StringBuilder의 메서드예요. toString에도 같은 기법이 쓰여서, 생성된 프록시 객체의 문자열 표현이 StringBuilder 인스턴스의 toString에 위임합니다.

5.13. 고급 기능

5.13.1. SAM 타입 강제 변환

트레이트가 단일 추상 메서드를 정의하면, 그것은 SAM(Single Abstract Method) 타입 강제 변환의 후보가 돼요. 예를 들어 다음 트레이트를 상상해 볼게요.

trait Greeter {
    String greet() { "Hello $name" }        (1)
    abstract String getName()               (2)
}

(1) — greet 메서드는 추상이 아니고 추상 메서드 getName을 호출해요

(2) — getName은 추상 메서드예요

getNameGreeter 트레이트의 유일한 추상 메서드이므로, 이렇게 쓸 수 있어요.

Greeter greeter = { 'Alice' }               (1)

(1) — 클로저가 getName 단일 추상 메서드의 구현이 "되어"요

또는 이렇게도 가능해요.

void greet(Greeter g) { println g.greet() } (1)
greet { 'Alice' }                           (2)

(1) — greet 메서드는 SAM 타입 Greeter를 매개변수로 받아요

(2) — 클로저로 바로 호출할 수 있어요

5.13.2. Java 8 기본 메서드와의 차이

Java 8에서는 인터페이스가 메서드의 기본 구현을 가질 수 있어요. 클래스가 인터페이스를 구현하면서 기본 메서드에 구현을 제공하지 않으면, 인터페이스의 구현이 선택됩니다. 트레이트도 동일하게 동작하지만 큰 차이가 하나 있어요. 클래스가 인터페이스 목록에 트레이트를 선언 하고 구현을 제공하지 않으면, 수퍼클래스가 구현을 제공하더라도 트레이트의 구현이 항상 사용됩니다.

이 기능은 이미 구현된 메서드의 동작을 오버라이드하고 싶을 때 동작을 아주 정밀하게 합성하는 데 쓸 수 있어요.

개념을 설명하기 위해 이 간단한 예시부터 시작해 볼게요.

import groovy.test.GroovyTestCase
import groovy.transform.CompileStatic
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import org.codehaus.groovy.control.customizers.ImportCustomizer

class SomeTest extends GroovyTestCase {
    def config
    def shell

    void setup() {
        config = new CompilerConfiguration()
        shell = new GroovyShell(config)
    }
    void testSomething() {
        assert shell.evaluate('1+1') == 2
    }
    void otherTest() { /* ... */ }
}

이 예시에서는 두 프로퍼티(configshell)를 사용하고 그것을 여러 테스트 메서드에서 사용하는 간단한 테스트 케이스를 만들어요. 이제 다른 컴파일러 설정으로 같은 것을 테스트하고 싶다고 상상해 봐요. 한 가지 방법은 SomeTest의 서브클래스를 만드는 거예요.

class AnotherTest extends SomeTest {
    void setup() {
        config = new CompilerConfiguration()
        config.addCompilationCustomizers( ... )
        shell = new GroovyShell(config)
    }
}

동작하지만, 테스트 클래스가 실제로 여러 개 있을 때 그 모든 테스트 클래스에 새 설정을 테스트하려면 어떻게 할까요? 그럼 각 테스트 클래스마다 별도의 서브클래스를 만들어야 해요.

class YetAnotherTest extends SomeTest {
    void setup() {
        config = new CompilerConfiguration()
        config.addCompilationCustomizers( ... )
        shell = new GroovyShell(config)
    }
}

여기서 두 테스트의 setup 메서드가 같다는 걸 볼 수 있어요. 그렇다면 아이디어는 트레이트를 만드는 거예요.

trait MyTestSupport {
    void setup() {
        config = new CompilerConfiguration()
        config.addCompilationCustomizers( new ASTTransformationCustomizer(CompileStatic) )
        shell = new GroovyShell(config)
    }
}

그런 다음 서브클래스에서 사용하면 돼요.

class AnotherTest extends SomeTest implements MyTestSupport {}
class YetAnotherTest extends SomeTest2 implements MyTestSupport {}
...

이렇게 하면 상용구 코드를 크게 줄일 수 있고, 설정을 바꾸기로 했을 때 setup 코드를 바꾸는 것을 잊을 위험도 줄여줘요. setup이 수퍼클래스에 이미 구현되어 있어도, 테스트 클래스가 인터페이스 목록에 트레이트를 선언하므로 동작은 트레이트 구현에서 가져오게 됩니다!

이 기능은 특히 수퍼클래스 소스 코드에 접근할 수 없을 때 유용해요. 메서드를 목킹하거나 서브클래스에서 특정 메서드 구현을 강제하는 데 쓸 수 있죠. 오버라이드한 로직을 단일 트레이트에 유지하고, 그 트레이트를 구현하는 것만으로 새 동작을 상속받도록 코드를 리팩터링할 수 있게 해 줍니다. 물론 대안은 새 코드를 쓰는 모든 곳에서 메서드를 오버라이드하는 거예요.

런타임 트레이트를 사용하면 트레이트의 메서드가 항상 프록시된 객체의 메서드보다 우선한다는 점을 기억해 두는 게 좋아요.

class Person {
    String name                                         (1)
}
trait Bob {
    String getName() { 'Bob' }                          (2)
}

def p = new Person(name: 'Alice')
assert p.name == 'Alice'                                (3)
def p2 = p as Bob                                       (4)
assert p2.name == 'Bob'                                 (5)

(1) — Person 클래스는 getName 메서드를 낳는 name 프로퍼티를 정의해요

(2) — BobgetNameBob을 반환하도록 정의한 트레이트예요

(3) — 기본 객체는 Alice를 반환해요

(4) — p2는 런타임에서 pBob으로 강제 변환해요

(5) — getName트레이트에서 가져오므로 Bob을 반환해요

다시 말하지만, 동적 트레이트 강제 변환은 원래 인터페이스와 트레이트만 구현하는 별개의 객체를 반환한다는 걸 잊지 마세요.

5.14. 믹스인(Mixins)과의 차이

Groovy에서 사용 가능한 믹스인과는 몇 가지 개념적 차이가 있어요. 여기서는 런타임 믹스인을 말하는 거고, 트레이트를 위해 폐기된 @Mixin 애노테이션을 말하는 게 아니에요.

먼저, 트레이트에 정의된 메서드는 바이트코드에서 보입니다.

  • 내부적으로 트레이트는 인터페이스(기본 메서드나 정적 메서드가 없는)와 여러 헬퍼 클래스로 표현돼요
  • 즉 트레이트를 구현하는 객체는 실제로 인터페이스를 구현한다는 뜻이에요
  • 그 메서드들은 Java에서 보여요
  • 타입 검사와 정적 컴파일과 호환돼요

반대로 믹스인으로 추가된 메서드는 런타임에서만 보입니다.

class A { String methodFromA() { 'A' } }        (1)
class B { String methodFromB() { 'B' } }        (2)
A.metaClass.mixin B                             (3)
def o = new A()
assert o.methodFromA() == 'A'                   (4)
assert o.methodFromB() == 'B'                   (5)
assert o instanceof A                           (6)
assert !(o instanceof B)                        (7)

(1) — 클래스 AmethodFromA를 정의해요

(2) — 클래스 BmethodFromB를 정의해요

(3) — B를 A에 믹스인해요

(4) — methodFromA를 호출할 수 있어요

(5) — methodFromB도 호출할 수 있어요

(6) — 객체는 A의 인스턴스예요

(7) — 하지만 B의 인스턴스는 아니에요

마지막 점은 실제로 매우 중요하며, 믹스인이 트레이트보다 유리한 지점을 보여줘요. 인스턴스가 수정되지 않아서, 어떤 클래스를 다른 클래스에 믹스인해도 세 번째 클래스가 생성되지 않고, A에 응답하는 메서드는 믹스인된 후에도 계속 A에 응답해요.

5.15. 정적 메서드, 프로퍼티, 필드

다음 지침은 주의가 필요해요. 정적 멤버 지원은 아직 진행 중이며 여전히 실험적이에요. 아래 정보는 5.0.5에만 유효해요.

트레이트에 정적 메서드를 정의하는 건 가능하지만, 수많은 제한이 따릅니다.

  • 정적 메서드가 있는 트레이트는 정적으로 컴파일되거나 타입 검사될 수 없어요. 모든 정적 메서드, 프로퍼티, 필드는 동적으로 접근됩니다(JVM의 한계예요)
  • 정적 메서드는 각 트레이트에 대해 생성된 인터페이스에 나타나지 않아요
  • 트레이트는 구현 클래스의 템플릿으로 해석돼요. 즉 각 구현 클래스가 자체 정적 메서드, 프로퍼티, 필드를 갖게 된다는 뜻이에요. 그래서 트레이트에 선언된 정적 멤버는 트레이트가 아니라 그 구현 클래스에 속합니다
  • 일반적으로 같은 시그니처의 정적 메서드와 인스턴스 메서드를 섞으면 안 돼요. 트레이트 적용 규칙(다중 상속 충돌 해결 포함)이 평소대로 적용돼요. 선택된 메서드가 정적인데 어떤 구현 트레이트가 인스턴스 변형을 갖고 있으면 컴파일 오류가 발생해요. 선택된 메서드가 인스턴스 변형이면 정적 변형은 무시됩니다(이 경우 동작은 Java 인터페이스의 정적 메서드와 비슷해요)

간단한 예시부터 시작해 볼게요.

trait TestHelper {
    public static boolean CALLED = false        (1)
    static void init() {                        (2)
        CALLED = true                           (3)
    }
}
class Foo implements TestHelper {}
Foo.init()                                      (4)
assert Foo.TestHelper__CALLED                   (5)

(1) — 정적 필드가 트레이트에 선언돼요

(2) — 정적 메서드도 트레이트에 선언돼요

(3) — 정적 필드가 트레이트 안에서 갱신돼요

(4) — 정적 메서드 init이 구현 클래스에서 사용 가능해져요

(5) — 다이아몬드 문제를 피하려고 정적 필드가 다시 매핑돼요

늘 그렇듯이 공개 필드는 권장하지 않아요. 그렇지만 이것을 원한다면, 다음 코드가 실패한다는 걸 이해해야 해요.

Foo.CALLED = true

왜냐하면 트레이트 자체에는 CALLED라는 정적 필드가 없기 때문이에요. 마찬가지로 서로 다른 구현 클래스 두 개가 있으면 각각 서로 다른 정적 필드를 갖습니다.

class Bar implements TestHelper {}              (1)
class Baz implements TestHelper {}              (2)
Bar.init()                                      (3)
assert Bar.TestHelper__CALLED                   (4)
assert !Baz.TestHelper__CALLED                  (5)

(1) — 클래스 Bar는 트레이트를 구현해요

(2) — 클래스 Baz도 트레이트를 구현해요

(3) — initBar에서만 호출돼요

(4) — Bar의 정적 필드 CALLED는 갱신돼요

(5) — 하지만 Baz의 정적 필드 CALLED는 그렇지 않아요. 서로 다른 필드이기 때문이죠

5.16. 상태 상속의 함정

트레이트는 상태를 갖는다는 걸 우리가 봤어요. 트레이트가 필드나 프로퍼티를 정의하는 건 가능하지만, 클래스가 트레이트를 구현하면 그 필드/프로퍼티를 트레이트별로 받아요. 다음 예시를 살펴볼게요.

trait IntCouple {
    int x = 1
    int y = 2
    int sum() { x+y }
}

트레이트는 두 프로퍼티 x, ysum 메서드를 정의해요. 이제 트레이트를 구현하는 클래스를 만들어 볼게요.

class BaseElem implements IntCouple {
    int f() { sum() }
}
def base = new BaseElem()
assert base.f() == 3

f를 호출한 결과는 3이에요. f가 상태를 가진 트레이트의 sum에 위임하기 때문이죠. 그런데 대신 이렇게 쓰면 어떨까요?

class Elem implements IntCouple {
    int x = 3                                       (1)
    int y = 4                                       (2)
    int f() { sum() }                               (3)
}
def elem = new Elem()

(1) — 프로퍼티 x 오버라이드

(2) — 프로퍼티 y 오버라이드

(3) — 트레이트의 sum 호출

elem.f()를 호출하면 예상 출력은 무엇일까요? 실제로는 다음과 같아요.

assert elem.f() == 3

이유는 sum 메서드가 트레이트의 필드에 접근하기 때문이에요. 그래서 트레이트에 정의된 xy 값을 사용하죠. 구현 클래스의 값을 쓰고 싶다면, 마지막 예시처럼 getter와 setter를 사용해 필드를 역참조해야 해요.

trait IntCouple {
    int x = 1
    int y = 2
    int sum() { getX()+getY() }
}

class Elem implements IntCouple {
    int x = 3
    int y = 4
    int f() { sum() }
}
def elem = new Elem()
assert elem.f() == 7

5.17. 셀프 타입(Self types)

5.17.1. 트레이트의 타입 제약

때로는 특정 타입에만 적용할 수 있는 트레이트를 쓰고 싶을 때가 있어요. 예를 들어 여러분이 통제할 수 없는 다른 클래스를 상속하는 클래스에 트레이트를 적용하고, 그 메서드들을 여전히 호출할 수 있기를 원할 수 있어요. 이를 설명하기 위해 이 예시부터 시작해 볼게요.

class CommunicationService {
    static void sendMessage(String from, String to, String message) {       (1)
        println "$from sent [$message] to $to"
    }
}

class Device { String id }                                                  (2)

trait Communicating {
    void sendMessage(Device to, String message) {
        CommunicationService.sendMessage(id, to.id, message)                (3)
    }
}

class MyDevice extends Device implements Communicating {}                   (4)

def bob = new MyDevice(id:'Bob')
def alice = new MyDevice(id:'Alice')
bob.sendMessage(alice,'secret')                                             (5)

(1) — 여러분이 통제할 수 없는 Service 클래스(라이브러리 안의)가 sendMessage 메서드를 정의해요

(2) — 여러분이 통제할 수 없는 Device 클래스(라이브러리 안의)

(3) — 서비스를 호출할 수 있는 기기를 위한 communicating 트레이트를 정의해요

(4) — MyDevice를 communicating 기기로 정의해요

(5) — 트레이트의 메서드가 호출되고, id가 해석돼요

여기서 Communicating 트레이트가 Device에만 적용될 수 있다는 건 명확해요. 하지만 이를 나타내는 명시적 계약은 없어요. 트레이트는 클래스를 확장할 수 없기 때문이죠. 그래도 코드는 완벽히 컴파일되고 실행돼요. 트레이트 메서드의 id가 동적으로 해석되기 때문이에요. 문제는 트레이트가 Device아닌 어떤 클래스에 적용되는 것을 막는 게 없다는 거예요. id가 있는 어떤 클래스든 동작하고, id 프로퍼티가 없는 클래스는 런타임 오류를 일으켜요.

트레이트에 타입 검사를 켜거나 @CompileStatic을 적용하려면 문제는 더 복잡해져요. 트레이트는 자기 자신이 Device임을 알지 못하므로, 타입 검사기가 id 프로퍼티를 찾을 수 없다며 불평할 거예요.

한 가지 가능성은 트레이트에 getId 메서드를 명시적으로 추가하는 거지만, 모든 문제를 해결하진 못해요. 메서드가 매개변수로 this를 요구하고, 실제로 Device이길 요구한다면 어떨까요?

class SecurityService {
    static void check(Device d) { if (d.id==null) throw new SecurityException() }
}

트레이트 안에서 this를 호출할 수 있으려면, thisDevice로 명시적으로 캐스팅해야 해요. 이렇게 this에 대한 명시적 캐스팅이 여기저기 있으면 금방 읽기 어려워질 수 있어요.

5.17.2. @SelfType 애노테이션

이 계약을 명시적으로 만들고, 타입 검사기가 자기 자신의 타입을 알게 하기 위해 Groovy는 @SelfType 애노테이션을 제공합니다.

  • 이 트레이트를 구현하는 클래스가 상속하거나 구현해야 하는 타입들을 선언할 수 있게 해 줘요
  • 이런 타입 제약이 충족되지 않으면 컴파일 타임 오류를 던져요

그래서 앞선 예시에서 @groovy.transform.SelfType 애노테이션으로 트레이트를 고칠 수 있어요.

@SelfType(Device)
@CompileStatic
trait Communicating {
    void sendMessage(Device to, String message) {
        SecurityService.check(this)
        CommunicationService.sendMessage(id, to.id, message)
    }
}

이제 이 트레이트를 Device아닌 클래스에 구현하려 하면 컴파일 타임 오류가 발생해요.

class MyDevice implements Communicating {} // forgot to extend Device

오류는 이렇게 나옵니다.

class 'MyDevice' implements trait 'Communicating' but does not extend self type class 'Device'

결론적으로, 셀프 타입은 트레이트 안에 계약을 직접 선언하거나 여기저기 캐스팅을 쓰지 않고도 트레이트에 제약을 선언하는 강력한 방법이며, 관심사의 분리를 아주 긴밀하게 유지할 수 있게 해 줍니다.

5.17.3. Sealed 애노테이션과의 차이(incubating)

@Sealed@SelfType은 둘 다 트레이트를 사용하는 클래스를 제한하지만, 직교하는 방식으로 제한해요. 다음 예시를 살펴볼게요.

interface HasHeight { double getHeight() }
interface HasArea { double getArea() }

@SelfType([HasHeight, HasArea])                       (1)
@Sealed(permittedSubclasses=[UnitCylinder,UnitCube])  (2)
trait HasVolume {
    double getVolume() { height * area }
}

final class UnitCube implements HasVolume, HasHeight, HasArea {
    // for the purposes of this example: h=1, w=1, l=1
    double height = 1d
    double area = 1d
}

final class UnitCylinder implements HasVolume, HasHeight, HasArea {
    // for the purposes of this example: h=1, diameter=1
    // radius=diameter/2, area=PI * r^2
    double height = 1d
    double area = Math.PI * 0.5d**2
}

assert new UnitCube().volume == 1d
assert new UnitCylinder().volume == 0.7853981633974483d

(1) — HasVolume 트레이트의 모든 사용은 HasHeightHasArea 둘 다 구현하거나 확장해야 해요

(2) — UnitCube 또는 UnitCylinder만 트레이트를 사용할 수 있어요

단일 클래스가 트레이트를 구현하는 퇴화된 경우, 예를 들어:

final class Foo implements FooTrait {}

그러면 다음 중 하나로 제약을 표현할 수 있어요.

@SelfType(Foo)
trait FooTrait {}

또는:

@Sealed(permittedSubclasses='Foo') (1)
trait FooTrait {}

(1) — FooFooTrait이 같은 소스 파일에 있으면 그냥 @Sealed만 써도 돼요

일반적으로 둘 중 전자(@SelfType)가 더 선호됩니다.

5.18. 제한 사항(Limitations)

5.18.1. AST 변환과의 호환성

트레이트는 AST 변환과 공식적으로 호환되지 않아요. @CompileStatic 같은 일부 AST 변환은 트레이트 자체에 적용되고(구현 클래스에는 적용되지 않음), 다른 것들은 구현 클래스와 트레이트 둘 다에 적용돼요. AST 변환이 일반 클래스에서처럼 트레이트에서 실행된다는 보장은 전혀 없으니, 사용 시 위험은 스스로 감수해야 해요!

5.18.2. 전위·후위 연산

트레이트 안에서는 트레이트의 필드를 갱신하는 전위·후위 연산이 허용되지 않아요.

trait Counting {
    int x
    void inc() {
        x++                             (1)
    }
    void dec() {
        --x                             (2)
    }
}
class Counter implements Counting {}
def c = new Counter()
c.inc()

(1) — x는 트레이트 안에 정의되어 있어서 후위 증가가 허용되지 않아요

(2) — x는 트레이트 안에 정의되어 있어서 전위 감소가 허용되지 않아요

대신 += 연산자로 우회할 수 있어요.

더 알아보기