메타프로그래밍

메타프로그래밍 (Metaprogramming)

Groovy 언어는 두 가지 종류의 메타프로그래밍을 지원해요. **런타임 메타프로그래밍(runtime)**과 **컴파일 타임 메타프로그래밍(compile-time)**이에요. 전자는 프로그램의 클래스 모델과 행동을 런타임에 바꿀 수 있게 해 주고, 후자는 컴파일 타임에만 일어나요. 둘 다 장단점이 있는데, 이번 장에서 자세히 다룰게요.

출처: Groovy 공식 문서 - Metaprogramming

본문

1. 런타임 메타프로그래밍 (Runtime metaprogramming)

런타임 메타프로그래밍을 쓰면 클래스와 인터페이스의 메서드를 가로채고(intercept), 끼워 넣고(inject), 심지어 합성(synthesize)할지의 결정을 런타임으로 미룰 수 있어요. Groovy의 메타객체 프로토콜(MOP, metaobject protocol)을 제대로 이해하려면 Groovy 객체와 Groovy의 메서드 처리 방식을 알아야 해요. Groovy에서는 세 종류의 객체를 다뤄요. POJO, POGO, 그리고 Groovy Interceptor가 그것이에요. Groovy는 모든 종류의 객체에 대해 메타프로그래밍을 허용하지만, 방식은 서로 달라요.

  • POJO — 클래스가 Java나 JVM을 위한 다른 언어로 작성된 일반 Java 객체예요.

  • POGO — 클래스가 Groovy로 작성된 Groovy 객체예요. java.lang.Object를 확장하고 기본적으로 groovy.lang.GroovyObject 인터페이스를 구현해요.

  • Groovy Interceptorgroovy.lang.GroovyInterceptable 인터페이스를 구현하고 메서드 가로채기 능력을 가진 Groovy 객체예요. 자세한 내용은 GroovyInterceptable 섹션에서 다뤄요.

모든 메서드 호출에 대해 Groovy는 객체가 POJO인지 POGO인지 확인해요. POJO라면 groovy.lang.MetaClassRegistry에서 MetaClass를 가져와 메서드 호출을 위임해요. POGO라면 에한 단계가 더 많아요. 아래 그림을 보면 알 수 있어요.

Figure 1. Groovy 가로채기 메커니즘

1.1. GroovyObject 인터페이스 (GroovyObject interface)

groovy.lang.GroovyObject는 Java에서 Object 클래스가 그러하듯 Groovy의 핵심 인터페이스예요. GroovyObject는 groovy.lang.GroovyObjectSupport 클래스에 기본 구현이 있고, 호출을 groovy.lang.MetaClass 객체로 전달하는 역할을 해요. GroovyObject의 소스는 이렇게 생겼어요.

package groovy.lang;

public interface GroovyObject {

    Object invokeMethod(String name, Object args);

    Object getProperty(String propertyName);

    void setProperty(String propertyName, Object newValue);

    MetaClass getMetaClass();

    void setMetaClass(MetaClass metaClass);
}
1.1.1. invokeMethod

이 메서드는 주로 GroovyInterceptable 인터페이스나 객체의 MetaClass와 함께 쓰여, 모든 메서드 호출을 가로채도록 설계됐어요. 또한 Groovy 객체에 없는 메서드가 호출될 때도 호출돼요. 다음은 invokeMethod()를 오버라이드하는 간단한 예시예요.

class SomeGroovyClass {

    def invokeMethod(String name, Object args) {
        return "called invokeMethod $name $args"
    }

    def test() {
        return 'method exists'
    }
}

def someGroovyClass = new SomeGroovyClass()

assert someGroovyClass.test() == 'method exists'
assert someGroovyClass.someMethod() == 'called invokeMethod someMethod []'

다만, 없는 메서드를 가로채는 목적으로 invokeMethod를 쓰는 것은 권장하지 않아요. 메서드 디스패치가 실패한 경우에만 메서드 호출을 가로채려는 의도라면 대신 methodMissing을 사용하세요.

1.1.2. get/setProperty

현재 객체의 getProperty() 메서드를 오버라이드하면 프로퍼티에 대한 모든 읽기 접근을 가로챌 수 있어요. 간단한 예시를 볼게요.

class SomeGroovyClass {

    def property1 = 'ha'
    def field2 = 'ho'
    def field4 = 'hu'

    def getField1() {
        return 'getHa'
    }

    def getProperty(String name) {
        if (name != 'field3')
            return metaClass.getProperty(this, name) (1)
        else
            return 'field3'
    }
}

def someGroovyClass = new SomeGroovyClass()

assert someGroovyClass.field1 == 'getHa'
assert someGroovyClass.field2 == 'ho'
assert someGroovyClass.field3 == 'field3'
assert someGroovyClass.field4 == 'hu'
  • (1) field3을 제외한 모든 프로퍼티에 대해 요청을 getter로 전달해요.

setProperty() 메서드를 오버라이드하면 프로퍼티에 대한 쓰기 접근도 가로챌 수 있어요.

class POGO {

    String property

    void setProperty(String name, Object value) {
        this.@"$name" = 'overridden'
    }
}

def pogo = new POGO()
pogo.property = 'a'

assert pogo.property == 'overridden'
1.1.3. get/setMetaClass

객체의 metaClass에 접근하거나, 기본 가로채기 메커니즘을 바꾸기 위해 자신만의 MetaClass 구현을 설정할 수 있어요. 예를 들어 MetaClass 인터페이스의 자신만의 구현을 작성해서 객체에 할당해 가로채기 메커니즘을 바꿀 수 있어요.

// getMetaclass
someObject.metaClass

// setMetaClass
someObject.metaClass = new OwnMetaClassImplementation()

Note: GroovyInterceptable 주제에서 추가 예시를 찾을 수 있어요.

1.2. get/setAttribute

이 기능은 MetaClass 구현과 관련돼 있어요. 기본 구현에서는 getter와 setter를 호출하지 않고 필드에 접근할 수 있어요. 아래 예시가 이 접근 방식을 보여 줘요.

class SomeGroovyClass {

    def field1 = 'ha'
    def field2 = 'ho'

    def getField1() {
        return 'getHa'
    }
}

def someGroovyClass = new SomeGroovyClass()

assert someGroovyClass.metaClass.getAttribute(someGroovyClass, 'field1') == 'ha'
assert someGroovyClass.metaClass.getAttribute(someGroovyClass, 'field2') == 'ho'
class POGO {

    private String field
    String property1

    void setProperty1(String property1) {
        this.property1 = "setProperty1"
    }
}

def pogo = new POGO()
pogo.metaClass.setAttribute(pogo, 'field', 'ha')
pogo.metaClass.setAttribute(pogo, 'property1', 'ho')

assert pogo.field == 'ha'
assert pogo.property1 == 'ho'

1.3. methodMissing

Groovy는 methodMissing이라는 개념을 지원해요. 이 메서드는 invokeMethod와 달리, 주어진 이름과/또는 인자에 해당하는 메서드를 찾을 수 없어 메서드 디스패치가 실패한 경우에만 호출돼요.

class Foo {

   def methodMissing(String name, def args) {
        return "this is me"
   }
}

assert new Foo().someUnknownMethod(42l) == 'this is me'

보통 methodMissing을 사용할 때는 같은 메서드를 다음에 호출할 때를 위해 결과를 캐시해 두는 게 가능해요. 예를 들어 GORM의 동적 finder를 생각해 보세요. 그것들은 methodMissing으로 구현돼 있어요. 코드가 대략 이런 식이에요.

class GORM {

   def dynamicMethods = [...] // an array of dynamic methods that use regex

   def methodMissing(String name, args) {
       def method = dynamicMethods.find { it.match(name) }
       if(method) {
          GORM.metaClass."$name" = { Object[] varArgs ->
             method.invoke(delegate, name, varArgs)
          }
          return method.invoke(delegate,name, args)
       }
       else throw new MissingMethodException(name, delegate, args)
   }
}

호출할 메서드를 찾으면 ExpandoMetaClass를 사용해 그 자리에서 새 메서드를 동적으로 등록하는 걸 볼 수 있어요. 그래야 같은 메서드를 다음에 호출할 때 더 효율적이거든요. 이렇게 methodMissing을 쓰면 invokeMethod의 오버헤드가 없고, 두 번째 호출부터는 비용이 크지 않아요.

1.4. propertyMissing

Groovy는 그렇지 않으면 실패할 프로퍼티 해석 시도를 가로채기 위한 propertyMissing 개념을 지원해요. getter 메서드의 경우 propertyMissing은 프로퍼티 이름을 담은 String 인자 하나를 받아요.

class Foo {
   def propertyMissing(String name) { name }
}

assert new Foo().boo == 'boo'

propertyMissing(String) 메서드는 Groovy 런타임이 주어진 프로퍼티에 대한 getter 메서드를 찾을 수 없을 때만 호출돼요. setter 메서드를 위해서는 추가 value 인자를 받는 두 번째 propertyMissing 정의를 추가할 수 있어요.

class Foo {
   def storage = [:]
   def propertyMissing(String name, value) { storage[name] = value }
   def propertyMissing(String name) { storage[name] }
}

def f = new Foo()
f.foo = "bar"

assert f.foo == "bar"

methodMissing과 마찬가지로, 전반적인 조회 성능을 높이기 위해 런타임에 새 프로퍼티를 동적으로 등록하는 것이 모범 사례예요.

1.5. static methodMissing

methodMissing의 정적 변형은 ExpandoMetaClass로 추가하거나, 클래스 레벨에서 $static_methodMissing 메서드로 구현할 수 있어요.

class Foo {
    static def $static_methodMissing(String name, Object args) {
        return "Missing static method name is $name"
    }
}

assert Foo.bar() == 'Missing static method name is bar'

1.6. static propertyMissing

propertyMissing의 정적 변형은 ExpandoMetaClass로 추가하거나, 클래스 레벨에서 $static_propertyMissing 메서드로 구현할 수 있어요.

class Foo {
    static def $static_propertyMissing(String name) {
        return "Missing static property name is $name"
    }
}

assert Foo.foobar == 'Missing static property name is foobar'

1.7. GroovyInterceptable

groovy.lang.GroovyInterceptable 인터페이스는 GroovyObject를 확장하는 마커 인터페이스로, 모든 메서드가 Groovy 런타임의 메서드 디스패처 메커니즘을 통해 가로채져야 함을 Groovy 런타임에 알려 주는 데 사용돼요.

package groovy.lang;

public interface GroovyInterceptable extends GroovyObject {
}

Groovy 객체가 GroovyInterceptable 인터페이스를 구현하면, 어떤 메서드 호출에도 invokeMethod()가 호출돼요. 아래에 이런 타입의 객체에 대한 간단한 예시가 있어요.

class Interception implements GroovyInterceptable {

    def definedMethod() { }

    def invokeMethod(String name, Object args) {
        'invokedMethod'
    }
}

다음 코드는 존재하는 메서드와 존재하지 않는 메서드에 대한 호출이 모두 같은 값을 반환한다는 걸 보여 주는 테스트예요.

class InterceptableTest extends GroovyTestCase {

    void testCheckInterception() {
        def interception = new Interception()

        assert interception.definedMethod() == 'invokedMethod'
        assert interception.someMethod() == 'invokedMethod'
    }
}

Note: println 같은 기본 Groovy 메서드는 모든 Groovy 객체에 주입되므로 그것들도 가로채지게 됩니다. 때문에 이런 기본 메서드를 쓸 수 없어요.

모든 메서드 호출을 가로채고 싶지만 GroovyInterceptable 인터페이스를 구현하고 싶지 않다면, 객체의 MetaClass에 invokeMethod()를 구현하면 돼요. 이 접근은 POGO와 POJO 모두에서 동작해요. 예시를 볼게요.

class InterceptionThroughMetaClassTest extends GroovyTestCase {

    void testPOJOMetaClassInterception() {
        String invoking = 'ha'
        invoking.metaClass.invokeMethod = { String name, Object args ->
            'invoked'
        }

        assert invoking.length() == 'invoked'
        assert invoking.someMethod() == 'invoked'
    }

    void testPOGOMetaClassInterception() {
        Entity entity = new Entity('Hello')
        entity.metaClass.invokeMethod = { String name, Object args ->
            'invoked'
        }

        assert entity.build(new Object()) == 'invoked'
        assert entity.someMethod() == 'invoked'
    }
}

Note: MetaClass에 대한 추가 정보는 MetaClasses 섹션에서 다룹니다.

1.8. 카테고리 (Categories)

통제할 수 없는 클래스에 추가 메서드를 붙여야 하는 상황이 있어요. 이 능력을 위해 Groovy는 Objective-C에서 빌려온 **카테고리(Categories)**라는 기능을 구현했어요. 카테고리는 소위 *카테고리 클래스(category classes)*로 구현돼요. 카테고리 클래스는 확장 메서드를 정의하기 위해 지켜야 하는 사전 정의된 규칙이 있다는 점에서 특별해요. 시스템에는 클래스에 기능을 추가해서 Groovy 환경에서 더 유용하게 쓰이게 만드는 카테고리 몇 개가 포함돼 있어요.

카테고리 클래스는 기본적으로 활성화되지 않아요. 카테고리 클래스에 정의된 메서드를 사용하려면 GDK가 제공하고 모든 Groovy 객체 인스턴스 안에서 사용 가능한 범위 지정 use 메서드를 적용해야 해요.

use(TimeCategory) {
    println 1.minute.from.now       (1)
    println 10.hours.ago

    def someDate = new Date()       (2)
    println someDate - 3.months
}
  • (1) TimeCategory가 Integer에 메서드를 추가해요.
  • (2) TimeCategory가 Date에 메서드를 추가해요.

use 메서드는 첫 번째 파라미터로 카테고리 클래스를, 두 번째 파라미터로 클로저 코드 블록을 받아요. 클로저 안에서는 카테고리 메서드에 접근할 수 있어요. 위 예시에서 볼 수 있듯이 java.lang.Integerjava.util.Date 같은 JDK 클래스조차 사용자 정의 메서드로 풍부하게 만들 수 있어요. 카테고리는 사용자 코드에 직접 노출될 필요가 없어요. 다음 코드도 잘 동작해요.

class JPACategory{
  // Let's enhance JPA EntityManager without getting into the JSR committee
  static void persistAll(EntityManager em , Object[] entities) { //add an interface to save all
    entities?.each { em.persist(it) }
  }
}

def transactionContext = {
  EntityManager em, Closure c ->
  def tx = em.transaction
  try {
    tx.begin()
    use(JPACategory) {
      c()
    }
    tx.commit()
  } catch (e) {
    tx.rollback()
  } finally {
    //cleanup your resource here
  }
}

// user code, they always forget to close resource in exception, some even forget to commit, let's not rely on them.
EntityManager em; //probably injected
transactionContext (em) {
 em.persistAll(obj1, obj2, obj3)
 // let's do some logics here to make the example sensible
 em.persistAll(obj2, obj4, obj6)
}

groovy.time.TimeCategory 클래스를 살펴보면 확장 메서드가 모두 static 메서드로 선언되어 있는 걸 볼 수 있어요. 사실 이것이 카테고리 클래스의 메서드가 use 코드 블록 안의 클래스에 성공적으로 추가되기 위해 충족해야 하는 요구 사항 중 하나예요.

public class TimeCategory {

    public static Date plus(final Date date, final BaseDuration duration) {
        return duration.plus(date);
    }

    public static Date minus(final Date date, final BaseDuration duration) {
        final Calendar cal = Calendar.getInstance();

        cal.setTime(date);
        cal.add(Calendar.YEAR, -duration.getYears());
        cal.add(Calendar.MONTH, -duration.getMonths());
        cal.add(Calendar.DAY_OF_YEAR, -duration.getDays());
        cal.add(Calendar.HOUR_OF_DAY, -duration.getHours());
        cal.add(Calendar.MINUTE, -duration.getMinutes());
        cal.add(Calendar.SECOND, -duration.getSeconds());
        cal.add(Calendar.MILLISECOND, -duration.getMillis());

        return cal.getTime();
    }

    // ...

또 다른 요구 사항은 static 메서드의 첫 번째 인자가 활성화되면 그 메서드가 붙게 될 타입을 정의해야 한다는 것이에요. 나머지 인자들은 그 메서드가 파라미터로 받는 일반 인자들이에요. 파라미터와 static 메서드 관례 때문에 카테고리 메서드 정의는 일반 메서드 정의보다 직관적이지 않을 수 있어요. 대안으로 Groovy는 애노테이션이 붙은 클래스를 컴파일 타임에 카테고리 클래스로 변환하는 @Category 애노테이션을 제공해요.

class Distance {
    def number
    String toString() { "${number}m" }
}

@Category(Number)
class NumberCategory {
    Distance getMeters() {
        new Distance(number: this)
    }
}

use(NumberCategory) {
    assert 42.meters.toString() == '42m'
}

@Category 애노테이션을 적용하면 대상 타입을 첫 번째 파라미터로 사용하지 않고 인스턴스 메서드를 사용할 수 있다는 장점이 있어요. 대상 타입 클래스는 대신 애노테이션의 인자로 주어져요.

Note: @Category에 대한 별도의 섹션이 컴파일 타임 메타프로그래밍 섹션에 있습니다.

1.9. 메타클래스 (Metaclasses)

앞서 설명했듯이 메타클래스(Metaclass)는 메서드 해석에서 중심 역할을 해요. groovy 코드의 모든 메서드 호출에 대해 Groovy는 주어진 객체의 MetaClass를 찾아 groovy.lang.MetaClass#invokeMethod(...)를 통해 메타클래스에 메서드 해석을 위임해요. 이것을 groovy.lang.GroovyObject#invokeMethod(...)와 혼동하면 안 돼요. 후자는 메타클래스가 결국 호출할 수도 있는 메서드일 뿐이거든요.

1.9.1. 기본 메타클래스 MetaClassImpl

기본적으로 객체는 기본 메서드 조회를 구현하는 MetaClassImpl 인스턴스를 얻어요. 이 메서드 조회는 객체 클래스에서 메서드를 찾는 것("일반" 메서드)을 포함하지만, 그런 식으로 메서드를 찾지 못하면 methodMissing을 호출하고 궁극적으로 groovy.lang.GroovyObject#invokeMethod(...)에 의지하게 돼요.

class Foo {}

def f = new Foo()

assert f.metaClass =~ /MetaClassImpl/
1.9.2. 커스텀 메타클래스 (Custom metaclasses)

어떤 객체나 클래스의 메타클래스를 변경해서 groovy.lang.MetaClass의 커스텀 구현으로 대체할 수 있어요. 보통은 MetaClassImpl, DelegatingMetaClass, ExpandoMetaClass, ProxyMetaClass 같은 기존 메타클래스 중 하나를 확장하고 싶을 거예요. 그렇지 않으면 완전한 메서드 조회 로직을 직접 구현해야 하거든요. 새 메타클래스 인스턴스를 사용하기 전에 groovy.lang.MetaClass#initialize()를 호출해야 해요. 그렇지 않으면 메타클래스가 예상대로 동작할 수도 있고 아닐 수도 있어요.

위임 메타클래스 (Delegating metaclass)

기존 메타클래스를 꾸미기만 하면 되는 경우 DelegatingMetaClass가 그 사용 사례를 단순화해 줘요. 기존 메타클래스 구현은 여전히 super로 접근할 수 있어서 입력에 사전 변환을 적용하거나, 다른 메서드로 라우팅하거나, 출력을 후처리하기 쉬워요.

class Foo { def bar() { "bar" } }

class MyFooMetaClass extends DelegatingMetaClass {
  MyFooMetaClass(MetaClass metaClass) { super(metaClass) }
  MyFooMetaClass(Class theClass) { super(theClass) }

  Object invokeMethod(Object object, String methodName, Object[] args) {
     def result = super.invokeMethod(object,methodName.toLowerCase(), args)
     result.toUpperCase();
  }
}

def mc =  new MyFooMetaClass(Foo.metaClass)
mc.initialize()

Foo.metaClass = mc
def f = new Foo()

assert f.BAR() == "BAR" // the new metaclass routes .BAR() to .bar() and uppercases the result
매직 패키지 (Magic package)

시작 시점에 메타클래스에 특별히 만들어진(매직) 클래스 이름과 패키지 이름을 주면 메타클래스를 바꿀 수 있어요. java.lang.Integer의 메타클래스를 바꾸려면 groovy.runtime.metaclass.java.lang.IntegerMetaClass라는 클래스를 클래스패스에 넣기만 하면 돼요. 이 기능은 예를 들어 프레임워크와 작업할 때 코드가 프레임워크에 의해 실행되기 전에 메타클래스 변경을 하고 싶을 때 유용해요. 매직 패키지의 일반적인 형태는 groovy.runtime.metaclass.[package].[class]MetaClass예요. 아래 예시에서 [package]java.lang이고 [class]Integer예요.

// file: IntegerMetaClass.groovy
package groovy.runtime.metaclass.java.lang;

class IntegerMetaClass extends DelegatingMetaClass {
  IntegerMetaClass(MetaClass metaClass) { super(metaClass) }
  IntegerMetaClass(Class theClass) { super(theClass) }
  Object invokeMethod(Object object, String name, Object[] args) {
    if (name =~ /isBiggerThan/) {
      def other = name.split(/isBiggerThan/)[1].toInteger()
      object > other
    } else {
      return super.invokeMethod(object,name, args);
    }
  }
}

위 파일을 groovyc IntegerMetaClass.groovy로 컴파일하면 ./groovy/runtime/metaclass/java/lang/IntegerMetaClass.class가 생성돼요. 아래 예시는 이 새 메타클래스를 사용해요.

// File testInteger.groovy
def i = 10

assert i.isBiggerThan5()
assert !i.isBiggerThan15()

println i.isBiggerThan5()

groovy -cp . testInteger.groovy로 그 파일을 실행하면 IntegerMetaClass가 클래스패스에 있게 되고, 따라서 java.lang.Integer의 메타클래스가 되어 isBiggerThan*() 메서드로의 호출을 가로채게 돼요.

1.9.3. 인스턴스별 메타클래스 (Per instance metaclass)

개별 객체 각각의 메타클래스를 바꿀 수 있어서, 같은 클래스의 여러 객체가 서로 다른 메타클래스를 갖는 게 가능해요.

class Foo { def bar() { "bar" }}

class FooMetaClass extends DelegatingMetaClass {
  FooMetaClass(MetaClass metaClass) { super(metaClass) }
  Object invokeMethod(Object object, String name, Object[] args) {
      super.invokeMethod(object,name,args).toUpperCase()
  }
}

def f1 = new Foo()
def f2 = new Foo()
f2.metaClass = new FooMetaClass(f2.metaClass)

assert f1.bar() == "bar"
assert f2.bar() == "BAR"
assert f1.metaClass =~ /MetaClassImpl/
assert f2.metaClass =~ /FooMetaClass/
assert f1.class.toString() == "class Foo"
assert f2.class.toString() == "class Foo"
1.9.4. ExpandoMetaClass

Groovy에는 ExpandoMetaClass라는 특별한 MetaClass가 있어요. 깔끔한 클로저 문법을 사용해 메서드, 생성자, 프로퍼티, 심지어 static 메서드까지 동적으로 추가하거나 변경할 수 있다는 점이 특별해요. 이런 수정을 적용하는 것은 Testing Guide에서 보여 주듯 mocking이나 stubbing 시나리오에서 특히 유용할 수 있어요. Groovy는 모든 java.lang.Class에 특별한 metaClass 프로퍼티를 제공하는데, 이 프로퍼티가 ExpandoMetaClass 인스턴스에 대한 참조를 줘요. 이 인스턴스로 메서드를 추가하거나 기존 메서드의 동작을 바꿀 수 있어요.

Note: 기본적으로 ExpandoMetaClass는 상속을 하지 않습니다. 활성화하려면 main 메서드나 서블릿 부트스트랩 같은 데서 앱 시작 전에 ExpandoMetaClass#enableGlobally()를 호출해야 합니다.

다음 섹션들에서는 ExpandoMetaClass가 다양한 시나리오에서 어떻게 쓰일 수 있는지 자세히 다뤄요.

메서드 (Methods)

metaClass 프로퍼티에 접근해 ExpandoMetaClass를 얻고 나면, 왼쪽 시프트 << 연산자나 = 연산자로 메서드를 추가할 수 있어요.

Note: 왼쪽 시프트 연산자는 새 메서드를 추가하는 데 사용됩니다. 같은 이름과 파라미터 타입을 가진 public 메서드가 클래스나 인터페이스(수퍼클래스와 수퍼인터페이스에서 상속된 것을 포함하되 런타임에 metaClass에 추가된 것은 제외)에 선언되어 있으면 예외가 던져집니다. 클래스나 인터페이스에 선언된 메서드를 교체하려면 = 연산자를 사용하세요.

연산자는 존재하지 않는 metaClass 프로퍼티에 Closure 코드 블록 인스턴스를 전달하며 적용돼요.

class Book {
   String title
}

Book.metaClass.titleInUpperCase << {-> title.toUpperCase() }

def b = new Book(title:"The Stand")

assert "THE STAND" == b.titleInUpperCase()

위 예시는 metaClass 프로퍼티에 접근하고 <<= 연산자로 Closure 코드 블록을 할당해서 클래스에 새 메서드를 추가하는 방법을 보여 줘요. Closure 파라미터는 메서드 파라미터로 해석돼요. 파라미터 없는 메서드는 {→ …​} 문법으로 추가할 수 있어요.

프로퍼티 (Properties)

ExpandoMetaClass는 프로퍼티를 추가하거나 오버라이드하는 두 가지 메커니즘을 지원해요. 첫째, metaClass의 프로퍼티에 값을 할당하기만 하면 *변경 가능한 프로퍼티(mutable property)*를 선언할 수 있어요.

class Book {
   String title
}

Book.metaClass.author = "Stephen King"
def b = new Book()

assert "Stephen King" == b.author

또 다른 방법은 인스턴스 메서드를 추가하는 표준 메커니즘을 사용해 getter 및/또는 setter 메서드를 추가하는 거예요.

class Book {
  String title
}
Book.metaClass.getAuthor << {-> "Stephen King" }

def b = new Book()

assert "Stephen King" == b.author

위 소스 코드 예시에서 프로퍼티는 클로저에 의해 결정되고 읽기 전용 프로퍼티예요. 동등한 setter 메서드를 추가하는 것도 가능하지만, 그 경우 프로퍼티 값을 나중에 사용하기 위해 저장해야 해요. 다음 예시처럼 하면 돼요.

class Book {
  String title
}

def properties = Collections.synchronizedMap([:])

Book.metaClass.setAuthor = { String value ->
   properties[System.identityHashCode(delegate) + "author"] = value
}
Book.metaClass.getAuthor = {->
   properties[System.identityHashCode(delegate) + "author"]
}

이것이 유일한 기법은 아니에요. 예를 들어 서블릿 컨테이너에서는 값을 현재 실행 중인 요청의 요청 속성으로 저장하는 방법도 있을 거예요(Grails에서 어떤 경우에는 그렇게 하지요).

생성자 (Constructors)

특별한 constructor 프로퍼티를 사용해 생성자를 추가할 수 있어요. <<= 연산자 중 어떤 것으로도 Closure 코드 블록을 할당할 수 있어요. 코드가 런타임에 실행될 때 Closure 인자가 생성자 인자가 돼요.

class Book {
    String title
}
Book.metaClass.constructor << { String title -> new Book(title:title) }

def book = new Book('Groovy in Action - 2nd Edition')
assert book.title == 'Groovy in Action - 2nd Edition'

Note: 다만 생성자를 추가할 때는 조심하세요. 스택 오버플로 문제에 빠지기 매우 쉽습니다.

Static 메서드 (Static Methods)

static 메서드는 인스턴스 메서드와 같은 기법으로 추가할 수 있는데, 메서드 이름 앞에 static 한정자를 붙이면 돼요.

class Book {
   String title
}

Book.metaClass.static.create << { String title -> new Book(title:title) }

def b = Book.create("The Stand")
assert "buy house" == p.buyHouse()
동적 메서드 이름 (Dynamic Method Names)

Groovy는 String을 프로퍼티 이름으로 사용할 수 있게 허용하므로, 런타임에 메서드와 프로퍼티 이름을 동적으로 만들 수 있어요. 동적 이름의 메서드를 만들려면 프로퍼티 이름을 문자열로 참조하는 언어 기능을 쓰면 돼요.

class Person {
   String name = "Fred"
}

def methodName = "Bob"

Person.metaClass."changeNameTo${methodName}" = {-> delegate.name = "Bob" }

def p = new Person()

assert "Fred" == p.name

p.changeNameToBob()

assert "Bob" == p.name

같은 개념은 static 메서드와 프로퍼티에도 적용할 수 있어요. 동적 메서드 이름의 한 적용 사례는 Grails 웹 애플리케이션 프레임워크에서 찾을 수 있어요. "다이나믹 코덱(dynamic codecs)" 개념이 동적 메서드 이름으로 구현되어 있어요.

class HTMLCodec {
    static encode = { theTarget ->
        HtmlUtils.htmlEscape(theTarget.toString())
    }

    static decode = { theTarget ->
    	HtmlUtils.htmlUnescape(theTarget.toString())
    }
}

위 예시는 코덱 구현을 보여 줘요. Grails는 각각 단일 클래스로 정의된 다양한 코덱 구현을 제공해요. 런타임에는 애플리케이션 클래스패스에 여러 코덱 클래스가 있어요. 애플리케이션 시작 시 프레임워크는 특정 메타클래스에 encodeXXXdecodeXXX 메서드를 추가하는데, 여기서 XXX는 코덱 클래스 이름의 첫 부분이에요(예: encodeHTML). 이 메커니즘은 다음 Groovy 의사 코드로 보여 줘요.

def codecs = classes.findAll { it.name.endsWith('Codec') }

codecs.each { codec ->
    Object.metaClass."encodeAs${codec.name-'Codec'}" = { codec.newInstance().encode(delegate) }
    Object.metaClass."decodeFrom${codec.name-'Codec'}" = { codec.newInstance().decode(delegate) }
}

def html = '<html><body>hello</body></html>'

assert '<html><body>hello</body></html>' == html.encodeAsHTML()
런타임 발견 (Runtime Discovery)

런타임에 메서드가 실행되는 시점에 어떤 다른 메서드나 프로퍼티가 존재하는지 아는 것이 종종 유용해요. ExpandoMetaClass는 지금 기준으로 다음 메서드들을 제공해요.

  • getMetaMethod
  • hasMetaMethod
  • getMetaProperty
  • hasMetaProperty

왜 그냥 reflection을 안 쓰는 걸까요? Groovy는 다르기 때문이에요. Groovy에는 "진짜" 메서드인 것과 런타임에만 사용 가능한 메서드가 따로 있어요. 이 메서드들은 (항상은 아니지만) MetaMethod로 표현되곤 해요. MetaMethod는 런타임에 어떤 메서드를 사용할 수 있는지 알려 주므로 코드가 적응할 수 있어요. 이것은 invokeMethod, getProperty, setProperty를 오버라이드할 때 특히 유용해요.

GroovyObject 메서드

ExpandoMetaClass의 또 다른 기능은 invokeMethod, getProperty, setProperty 메서드를 오버라이드할 수 있게 해 준다는 것이에요. 이 세 가지 모두 groovy.lang.GroovyObject 클래스에서 찾을 수 있어요. 다음 예시는 invokeMethod를 오버라이드하는 방법을 보여 줘요.

class Stuff {
   def invokeMe() { "foo" }
}

Stuff.metaClass.invokeMethod = { String name, args ->
   def metaMethod = Stuff.metaClass.getMetaMethod(name, args)
   def result
   if(metaMethod) result = metaMethod.invoke(delegate,args)
   else {
      result = "bar"
   }
   result
}

def stf = new Stuff()

assert "foo" == stf.invokeMe()
assert "bar" == stf.doStuff()

Closure 코드의 첫 단계는 주어진 이름과 인자에 대한 MetaMethod를 찾는 것이에요. 메서드를 찾으면 모든 게 정상이므로 그것으로 위임돼요. 찾지 못하면 더미 값이 반환돼요.

Note: MetaMethod는 런타임에 추가됐든 컴파일 타임에 추가됐든 MetaClass에 존재하는 것으로 알려진 메서드입니다.

같은 로직으로 setPropertygetProperty를 오버라이드할 수 있어요.

class Person {
   String name = "Fred"
}

Person.metaClass.getProperty = { String name ->
   def metaProperty = Person.metaClass.getMetaProperty(name)
   def result
   if(metaProperty) result = metaProperty.getProperty(delegate)
   else {
      result = "Flintstone"
   }
   result
}

def p = new Person()

assert "Fred" == p.name
assert "Flintstone" == p.other

여기서 주목할 중요한 점은 MetaMethod 대신 MetaProperty 인스턴스를 찾는다는 거예요. 그것이 존재하면 delegate를 전달하며 MetaProperty의 getProperty 메서드가 호출돼요.

Static invokeMethod 오버라이드하기

ExpandoMetaClass는 특별한 invokeMethod 문법으로 static 메서드조차 오버라이드할 수 있게 해 줘요.

class Stuff {
   static invokeMe() { "foo" }
}

Stuff.metaClass.'static'.invokeMethod = { String name, args ->
   def metaMethod = Stuff.metaClass.getStaticMetaMethod(name, args)
   def result
   if(metaMethod) result = metaMethod.invoke(delegate,args)
   else {
      result = "bar"
   }
   result
}

assert "foo" == Stuff.invokeMe()
assert "bar" == Stuff.doStuff()

static 메서드를 오버라이드하는 데 쓰는 로직은 앞서 인스턴스 메서드를 오버라이드할 때 본 것과 같아요. 유일한 차이는 metaClass.static 프로퍼티에 접근하고 static MetaMethod 인스턴스를 가져오기 위해 getStaticMethodName을 호출한다는 점이에요.

인터페이스 확장 (Extending Interfaces)

ExpandoMetaClass로 인터페이스에 메서드를 추가하는 것도 가능해요. 다만 그러려면 애플리케이션 시작 전에 ExpandoMetaClass.enableGlobally() 메서드를 사용해 반드시 전역으로 활성화해야 해요.

List.metaClass.sizeDoubled = {-> delegate.size() * 2 }

def list = []

list << 1
list << 2

assert 4 == list.sizeDoubled()

1.10. 확장 모듈 (Extension modules)

1.10.1. 기존 클래스 확장 (Extending existing classes)

확장 모듈(extension module)은 JDK의 클래스처럼 미리 컴파일된 클래스를 포함해 기존 클래스에 새 메서드를 추가할 수 있게 해 줘요. 메타클래스나 카테고리로 정의된 메서드와 달리, 이 새 메서드들은 전역으로 사용 가능해요. 예를 들어 이렇게 쓸 때:

def file = new File(...)
def contents = file.getText('utf-8')

getText 메서드는 File 클래스에 존재하지 않아요. 하지만 Groovy는 ResourceGroovyMethods라는 특별한 클래스에 정의되어 있기 때문에 그것을 알아요.

public static String getText(File file, String charset) throws IOException {
 return IOGroovyMethods.getText(newReader(file, charset));
}

확장 메서드가 헬퍼 클래스(다양한 확장 메서드가 정의되어 있는)의 static 메서드로 정의된다는 걸 눈치챘을 거예요. getText 메서드의 첫 번째 인자는 수신자(receiver)에 해당하고, 추가 파라미터는 확장 메서드의 인자에 해당해요. 그러니 여기서 우리는 첫 번째 인자가 File 타입이므로 File 클래스에 getText라는 메서드를 정의하고 있는 거고, 그 메서드는 인자 하나(인코딩 String)를 파라미터로 받아요. 확장 모듈을 만드는 과정은 간단해요.

  • 위처럼 확장 클래스를 쓴다
  • 모듈 디스크립터 파일을 쓴다

그리고 나서 확장 모듈을 Groovy에서 보이게 만들어야 하는데, 확장 모듈 클래스와 디스크립터를 클래스패스에서 사용 가능하게 하기만 하면 돼요. 이 말은 선택지가 있다는 뜻이에요.

  • 클래스와 모듈 디스크립터를 클래스패스에 직접 제공하거나
  • 재사용을 위해 확장 모듈을 jar로 묶거나

확장 모듈은 클래스에 두 종류의 메서드를 추가할 수 있어요.

  • 인스턴스 메서드 (클래스의 인스턴스에서 호출할)
  • static 메서드 (클래스 자체에서 호출할)
1.10.2. 인스턴스 메서드 (Instance methods)

기존 클래스에 인스턴스 메서드를 추가하려면 확장 클래스를 만들어야 해요. 예를 들어 예외가 던져지지 않을 때까지 클로저를 최대 n번 실행하는 maxRetries라는 메서드를 Integer에 추가하고 싶다고 해 볼게요. 그러려면 이렇게 쓰기만 하면 돼요.

class MaxRetriesExtension {                                     (1)
    static void maxRetries(Integer self, Closure code) {        (2)
        assert self >= 0
        int retries = self
        Throwable e = null
        while (retries > 0) {
            try {
                code.call()
                break
            } catch (Throwable err) {
                e = err
                retries--
            }
        }
        if (retries == 0 && e) {
            throw e
        }
    }
}
  • (1) 확장 클래스
  • (2) static 메서드의 첫 번째 인자는 메시지의 수신자, 즉 확장되는 인스턴스에 해당해요.

그다음 확장 클래스를 선언하고 나면 이렇게 호출할 수 있어요.

int i=0
5.maxRetries {
    i++
}
assert i == 1
i=0
try {
    5.maxRetries {
        i++
        throw new RuntimeException("oops")
    }
} catch (RuntimeException e) {
    assert i == 5
}
1.10.3. Static 메서드 (Static methods)

클래스에 static 메서드를 추가하는 것도 가능해요. 이 경우 static 메서드는 자체 파일에 정의해야 해요. static 인스턴스 확장 메서드와 인스턴스 확장 메서드는 같은 클래스에 둘 다 있을 수 없어요.

class StaticStringExtension {                                       (1)
    static String greeting(String self) {                           (2)
        'Hello, world!'
    }
}
  • (1) static 확장 클래스
  • (2) static 메서드의 첫 번째 인자는 확장되는 클래스에 해당하며 사용되지 않아요.

그 경우 String 클래스에서 직접 호출할 수 있어요.

assert String.greeting() == 'Hello, world!'
1.10.4. 모듈 디스크립터 (Module descriptor)

Groovy가 확장 메서드를 로드할 수 있게 하려면 확장 헬퍼 클래스를 선언해야 해요. META-INF/groovy 디렉터리에 org.codehaus.groovy.runtime.ExtensionModule이라는 이름의 파일을 만들어야 해요.

moduleName=Test module for specifications
moduleVersion=1.0-test
extensionClasses=support.MaxRetriesExtension
staticExtensionClasses=support.StaticStringExtension

모듈 디스크립터는 4개의 키를 요구해요.

  • moduleName : 모듈의 이름
  • moduleVersion: 모듈의 버전. 버전 번호는 같은 모듈을 두 가지 다른 버전으로 로드하지 않는지 확인하는 데만 사용된다는 점을 알아두세요.
  • extensionClasses: 인스턴스 메서드용 확장 헬퍼 클래스의 목록. 쉼표로 구분해 여러 클래스를 제공할 수 있어요.
  • staticExtensionClasses: static 메서드용 확장 헬퍼 클래스의 목록. 쉼표로 구분해 여러 클래스를 제공할 수 있어요.

모듈이 static 헬퍼와 인스턴스 헬퍼를 모두 정의할 필요는 없고, 한 모듈에 여러 클래스를 추가할 수도 있다는 점을 알아두세요. 한 모듈에서 서로 다른 클래스를 확장하는 것도 문제없어요. 단일 확장 클래스에서 다른 클래스를 사용하는 것도 가능하지만, 확장 메서드를 기능 세트별로 클래스에 묶는 것을 권장해요.

1.10.5. 확장 모듈과 클래스패스 (Extension modules and classpath)

확장을 사용하는 코드와 동시에 컴파일된 확장은 사용할 수 없다는 점을 알아둘 만해요. 즉 확장을 사용하려면 확장을 사용하는 코드가 컴파일되기 전에 컴파일된 클래스로서 클래스패스에 있어야 해요. 보통은 test 클래스를 확장 클래스와 같은 소스 유닛에 둘 수 없다는 뜻이에요. 일반적으로 테스트 소스는 일반 소스와 분리되고 빌드의 다른 단계에서 실행되므로, 이건 문제가 되지 않아요.

1.10.6. 타입 검사와의 호환성 (Compatibility with type checking)

카테고리와 달리 확장 모듈은 타입 검사와 호환돼요. 확장 모듈이 클래스패스에 있으면 타입 검사기가 확장 메서드를 인지해서 그것들을 호출해도 불평하지 않아요. 또한 static 컴파일과도 호환돼요.

2. 컴파일 타임 메타프로그래밍 (Compile-time metaprogramming)

Groovy의 컴파일 타임 메타프로그래밍은 컴파일 타임에 코드 생성을 허용해요. 그런 변환은 프로그램의 추상 구문 트리(AST)를 변경하는데, 그래서 Groovy에서는 이것을 **AST 변환(AST transformation)**이라고 불러요. AST 변환을 사용하면 컴파일 과정에 훅을 걸고, AST를 수정하고, 컴파일 과정을 계속 진행해 일반 바이트코드를 생성할 수 있어요. 런타임 메타프로그래밍과 비교하면, 이 방식은 변경 사항이 클래스 파일 자체(즉 바이트코드)에 보이게 한다는 장점이 있어요. 변경 사항을 바이트코드에 보이게 하는 것은 예를 들어 변환을 클래스 계약의 일부로 만들고 싶을 때(인터페이스 구현, 추상 클래스 확장 등), 또는 클래스를 Java(나 다른 JVM 언어)에서 호출할 수 있어야 할 때 중요해요. 예를 들어 AST 변환은 클래스에 메서드를 추가할 수 있어요. 런타임 메타프로그래밍으로 하면 새 메서드는 Groovy에서만 보여요. 컴파일 타임 메타프로그래밍으로 같은 작업을 하면 그 메서드는 Java에서도 보여요. 마지막으로, 컴파일 타임 메타프로그래밍이 (초기화 단계가 필요 없으므로) 성능도 더 좋을 가능성이 커요. 이 섹션에서는 Groovy 배포판에 포함된 다양한 컴파일 타임 변환을 설명하는 것으로 시작할게요. 다음 섹션에서는 자신만의 AST 변환을 구현하는 방법과 이 기법의 단점을 설명할게요.

2.1. 사용 가능한 AST 변환 (Available AST transformations)

Groovy는 다양한 요구를 다루는 여러 AST 변환을 제공해요. 보일러플레이트 줄이기(코드 생성), 디자인 패턴 구현(위임 등), 로깅, 선언적 동시성, 클로닝, 더 안전한 스크립팅, 컴파일 조정, Swing 패턴 구현, 테스팅, 그리고 결국 의존성 관리까지요. 이 AST 변환 중 어떤 것도 요구를 충족하지 못하면 자신만의 AST 변환 개발 섹션에서 보여 주듯 직접 구현할 수 있어요. AST 변환은 두 가지 범주로 나눌 수 있어요.

  • global AST 변환 — 컴파일 클래스패스에서 발견되는 즉시 투명하게, 전역으로 적용돼요.
  • local AST 변환 — 소스 코드에 마커로 애노테이트해서 적용돼요. global AST 변환과 달리 local AST 변환은 파라미터를 지원할 수 있어요.

Groovy는 어떤 global AST 변환도 동봉하지 않지만, 코드에서 사용할 수 있는 local AST 변환 목록은 여기서 찾을 수 있어요.

2.1.1. 코드 생성 변환 (Code generation transformations)

이 범주의 변환에는 보일러플레이트 코드를 없애는 데 도움이 되는 AST 변환이 포함돼요. 보일러플레이트 코드란 보통 써야 하지만 유용한 정보를 담지 않는 코드를 말해요. 이 보일러플레이트를 자동 생성하면 써야 하는 코드는 깔끔하고 간결하게 남고, 그런 보일러플레이트 코드를 틀리게 써서 오류가 생길 가능성도 줄어들어요.

@groovy.transform.ToString

@ToString AST 변환은 클래스의 사람이 읽을 수 있는 toString 표현을 생성해요. 예를 들어 Person 클래스에 아래처럼 애노테이션을 붙이면 toString 메서드가 자동으로 생성돼요.

import groovy.transform.ToString

@ToString
class Person {
    String firstName
    String lastName
}

이 정의로 다음 assertion은 통과해요. 즉 클래스의 필드 값을 가져와 출력하는 toString 메서드가 생성됐다는 뜻이에요.

def p = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p.toString() == 'Person(Jack, Nicholson)'

@ToString 애노테이션은 여러 파라미터를 받아요. 요약해 볼게요.

@ToString(excludes=['firstName'])
class Person {
    String firstName
    String lastName
}

def p = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p.toString() == 'Person(Nicholson)'
@ToString(includes=['lastName'])
class Person {
    String firstName
    String lastName
}

def p = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p.toString() == 'Person(Nicholson)'
@ToString
class Id { long id }

@ToString(includeSuper=true)
class Person extends Id {
    String firstName
    String lastName
}

def p = new Person(id:1, firstName: 'Jack', lastName: 'Nicholson')
assert p.toString() == 'Person(Jack, Nicholson, Id(1))'
@ToString(includeNames=true)
class Person {
    String firstName
    String lastName
}

def p = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p.toString() == 'Person(firstName:Jack, lastName:Nicholson)'
@ToString(includeFields=true)
class Person {
    String firstName
    String lastName
    private int age
    void test() {
       age = 42
    }
}

def p = new Person(firstName: 'Jack', lastName: 'Nicholson')
p.test()
assert p.toString() == 'Person(Jack, Nicholson, 42)'
class Person {
    String name
}

@ToString(includeSuperProperties = true, includeNames = true)
class BandMember extends Person {
    String bandName
}

def bono = new BandMember(name:'Bono', bandName: 'U2').toString()

assert bono.toString() == 'BandMember(bandName:U2, name:Bono)'
class Person {
    protected String name
}

@ToString(includeSuperFields = true, includeNames = true)
@MapConstructor(includeSuperFields = true)
class BandMember extends Person {
    String bandName
}

def bono = new BandMember(name:'Bono', bandName: 'U2').toString()

assert bono.toString() == 'BandMember(bandName:U2, name:Bono)'
@ToString(ignoreNulls=true)
class Person {
    String firstName
    String lastName
}

def p = new Person(firstName: 'Jack')
assert p.toString() == 'Person(Jack)'
@ToString(includePackage=true)
class Person {
    String firstName
    String lastName
}

def p = new Person(firstName: 'Jack', lastName:'Nicholson')
assert p.toString() == 'acme.Person(Jack, Nicholson)'
@ToString(includeNames=true)
class Person {
    String firstName
    String getLastName() { 'Nicholson' }
}

def p = new Person(firstName: 'Jack')
assert p.toString() == 'acme.Person(firstName:Jack, lastName:Nicholson)'
@ToString(cache=true)
class Person {
    String firstName
    String lastName
}

def p = new Person(firstName: 'Jack', lastName:'Nicholson')
def s1 = p.toString()
def s2 = p.toString()
assert s1 == s2
assert s1 == 'Person(Jack, Nicholson)'
assert s1.is(s2) // same instance
@ToString(allNames=true)
class Person {
    String $firstName
}

def p = new Person($firstName: "Jack")
assert p.toString() == 'acme.Person(Jack)'
속성 기본값 설명 예시
excludes 빈 목록 toString에서 제외할 프로퍼티 목록
includes 정의되지 않은 마커 목록(모든 필드를 나타냄) toString에 포함할 필드 목록
includeSuper False toString에 수퍼클래스를 포함할지
includeNames false 생성된 toString에 프로퍼티 이름을 포함할지
includeFields False 프로퍼티에 더해 필드도 toString에 포함할지
includeSuperProperties False toString에 수퍼 프로퍼티를 포함할지
includeSuperFields False toString에 보이는 수퍼 필드를 포함할지
ignoreNulls False null 값인 프로퍼티/필드를 표시할지
includePackage True toString에서 단순 이름 대신 정규화된 전체 클래스 이름을 쓸지
allProperties True 모든 JavaBean 프로퍼티를 toString에 포함할지
cache False toString 문자열을 캐시할지. 클래스가 불변일 때만 true로 설정해야 함
allNames False 내부 이름을 가진 필드 및/또는 프로퍼티를 생성된 toString에 포함할지
@groovy.transform.EqualsAndHashCode

@EqualsAndHashCode AST 변환은 equals와 hashCode 메서드를 생성하는 것을 목표로 해요. 생성된 hashcode는 Josh BlochEffective Java에 설명된 모범 사례를 따르죠.

import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
def p2 = new Person(firstName: 'Jack', lastName: 'Nicholson')

assert p1==p2
assert p1.hashCode() == p2.hashCode()

@EqualsAndHashCode의 동작을 조정할 수 있는 몇 가지 옵션이 있어요.

import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode(excludes=['firstName'])
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
def p2 = new Person(firstName: 'Bob', lastName: 'Nicholson')

assert p1==p2
assert p1.hashCode() == p2.hashCode()
import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode(includes=['lastName'])
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
def p2 = new Person(firstName: 'Bob', lastName: 'Nicholson')

assert p1==p2
assert p1.hashCode() == p2.hashCode()
import groovy.transform.EqualsAndHashCode
import groovy.transform.Immutable

@Immutable
class SlowHashCode {
    static final SLEEP_PERIOD = 500

    int hashCode() {
        sleep SLEEP_PERIOD
        127
    }
}

@EqualsAndHashCode(cache=true)
@Immutable
class Person {
    SlowHashCode slowHashCode = new SlowHashCode()
}

def p = new Person()
p.hashCode()

def start = System.currentTimeMillis()
p.hashCode()
assert System.currentTimeMillis() - start < SlowHashCode.SLEEP_PERIOD
import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode
class Living {
    String race
}

@EqualsAndHashCode(callSuper=true)
class Person extends Living {
    String firstName
    String lastName
}

def p1 = new Person(race:'Human', firstName: 'Jack', lastName: 'Nicholson')
def p2 = new Person(race: 'Human being', firstName: 'Jack', lastName: 'Nicholson')

assert p1!=p2
assert p1.hashCode() != p2.hashCode()
import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode(includeFields=true)
class Person {
    private String firstName

    Person(String firstName) {
        this.firstName = firstName
    }
}

def p1 = new Person('Jack')
def p2 = new Person('Jack')
def p3 = new Person('Bob')

assert p1 == p2
assert p1 != p3
@EqualsAndHashCode(allProperties=true, excludes='first, last')
class Person {
    String first, last
    String getInitials() { first[0] + last[0] }
}

def p1 = new Person(first: 'Jack', last: 'Smith')
def p2 = new Person(first: 'Jack', last: 'Spratt')
def p3 = new Person(first: 'Bob', last: 'Smith')

assert p1 == p2
assert p1.hashCode() == p2.hashCode()
assert p1 != p3
assert p1.hashCode() != p3.hashCode()
import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode(allNames=true)
class Person {
    String $firstName
}
def p1 = new Person($firstName: 'Jack')
def p2 = new Person($firstName: 'Bob')

assert p1 != p2
assert p1.hashCode() != p2.hashCode()
속성 기본값 설명 예시
excludes 빈 목록 equals/hashCode에서 제외할 프로퍼티 목록
includes 정의되지 않은 마커 목록(모든 필드를 나타냄) equals/hashCode에 포함할 필드 목록
cache False hashCode 계산을 캐시할지. 클래스가 불변일 때만 true로 설정해야 함
callSuper False equals와 hashCode 계산에 super를 포함할지
includeFields False 프로퍼티에 더해 필드도 equals/hashCode에 포함할지
useCanEqual True equals가 canEqual 헬퍼 메서드를 호출할지 http://www.artima.com/lejava/articles/equality.html 참고
allProperties False JavaBean 프로퍼티를 equals와 hashCode 계산에 포함할지
allNames False 내부 이름을 가진 필드 및/또는 프로퍼티를 equals와 hashCode 계산에 포함할지
@groovy.transform.TupleConstructor

@TupleConstructor 애노테이션은 생성자를 생성해 줌으로써 보일러플레이트 코드를 없애는 것을 목표로 해요. 각 프로퍼티(그리고 가능하면 각 필드)마다 파라미터 하나를 가진 튜플 생성자(tuple constructor)가 만들어져요. 각 파라미터는 기본 값(프로퍼티가 있으면 초기 값을, 그렇지 않으면 프로퍼티 타입에 따른 Java의 기본 값을 사용)을 가져요.

구현 세부 사항 (Implementation Details)

보통은 생성된 생성자의 구현 세부 사항을 이해할 필요가 없어요. 평범하게 사용하면 돼요. 다만 여러 생성자를 추가하거나, Java 통합 옵션을 이해하거나, 일부 의존성 주입 프레임워크의 요구 사항을 충족해야 한다면 몇 가지 세부 사항이 유용해요. 앞서 언급했듯이 생성된 생성자는 기본 값이 적용돼요. 이후 컴파일 단계에서 Groovy 컴파일러의 표준 기본 값 처리 동작이 적용돼요. 그 결과 클래스의 바이트코드에 여러 생성자가 배치돼요. 이것은 잘 이해된 의미론을 제공하고 Java 통합 목적으로도 유용해요. 예를 들어 다음 코드는 3개의 생성자를 생성해요.

import groovy.transform.TupleConstructor

@TupleConstructor
class Person {
    String firstName
    String lastName
}

// traditional map-style constructor
def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
// generated tuple constructor
def p2 = new Person('Jack', 'Nicholson')
// generated tuple constructor with default value for second property
def p3 = new Person('Jack')

첫 번째 생성자는 인자 없는 생성자로, final 프로퍼티가 없는 한 전통적인 맵 스타일 구성을 허용해요. Groovy는 내부적으로 인자 없는 생성자를 호출한 다음 관련 setter들을 호출해요. 첫 번째 프로퍼티(또는 필드)가 LinkedHashMap 타입이거나, 단일 Map, AbstractMap, HashMap 프로퍼티(또는 필드)가 있으면 맵 스타일 명명 인자를 사용할 수 없게 된다는 점을 알아둘 만해요. 나머지 생성자들은 정의된 순서대로 프로퍼티를 취해 생성돼요. Groovy는 프로퍼티(또는 옵션에 따라 필드) 수만큼 많은 생성자를 생성해요. defaults 속성 설정(사용 가능한 구성 옵션 표 참고)을 false로 하면 정상적인 기본 값 동작이 비활성화되며 다음을 의미해요.

  • 정확히 하나의 생성자가 생성됨
  • 초기 값을 사용하려 하면 오류가 남
  • 맵 스타일 명명 인자를 사용할 수 없음

이 속성은 보통 다른 Java 프레임워크가 정확히 하나의 생성자를 기대하는 상황(예: 주입 프레임워크나 JUnit 파라미터화 러너)에서만 사용돼요.

불변성 지원 (Immutability support)

클래스에 @TupleConstructor 애노테이션과 함께 @PropertyOptions 애노테이션도 있으면, 생성된 생성자는 커스텀 프로퍼티 처리 로직을 포함할 수 있어요. @PropertyOptions 애노테이션의 propertyHandler 속성을 예를 들어 ImmutablePropertyHandler로 설정하면 불변 클래스에 필요한 로직(방어적 복사, 클로닝 등)이 추가돼요. 이것은 보통 @Immutable 메타 애노테이션을 사용할 때 뒤에서 자동으로 일어나요. 일부 애노테이션 속성은 모든 프로퍼티 핸들러에서 지원되지 않을 수 있어요.

커스터마이즈 옵션 (Customization options)

@TupleConstructor AST 변환은 여러 애노테이션 속성을 받아요.

import groovy.transform.TupleConstructor

@TupleConstructor(excludes=['lastName'])
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
def p2 = new Person('Jack')
try {
    // will fail because the second property is excluded
    def p3 = new Person('Jack', 'Nicholson')
} catch (e) {
    assert e.message.contains ('Could not find matching constructor')
}
import groovy.transform.TupleConstructor

@TupleConstructor(includes=['firstName'])
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
def p2 = new Person('Jack')
try {
    // will fail because the second property is not included
    def p3 = new Person('Jack', 'Nicholson')
} catch (e) {
    assert e.message.contains ('Could not find matching constructor')
}
import groovy.transform.TupleConstructor

@TupleConstructor(includeProperties=false)
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')

try {
    def p2 = new Person('Jack', 'Nicholson')
} catch(e) {
    // will fail because properties are not included
}
import groovy.transform.TupleConstructor

@TupleConstructor(includeFields=true)
class Person {
    String firstName
    String lastName
    private String occupation
    public String toString() {
        "$firstName $lastName: $occupation"
    }
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson', occupation: 'Actor')
def p2 = new Person('Jack', 'Nicholson', 'Actor')

assert p1.firstName == p2.firstName
assert p1.lastName == p2.lastName
assert p1.toString() == 'Jack Nicholson: Actor'
assert p1.toString() == p2.toString()
import groovy.transform.TupleConstructor

class Base {
    String occupation
}

@TupleConstructor(includeSuperProperties=true)
class Person extends Base {
    String firstName
    String lastName
    public String toString() {
        "$firstName $lastName: $occupation"
    }
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')

def p2 = new Person('Actor', 'Jack', 'Nicholson')

assert p1.firstName == p2.firstName
assert p1.lastName == p2.lastName
assert p1.toString() == 'Jack Nicholson: null'
assert p2.toString() == 'Jack Nicholson: Actor'
import groovy.transform.TupleConstructor

class Base {
    protected String occupation
    public String occupation() { this.occupation }
}

@TupleConstructor(includeSuperFields=true)
class Person extends Base {
    String firstName
    String lastName
    public String toString() {
        "$firstName $lastName: ${occupation()}"
    }
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson', occupation: 'Actor')

def p2 = new Person('Actor', 'Jack', 'Nicholson')

assert p1.firstName == p2.firstName
assert p1.lastName == p2.lastName
assert p1.toString() == 'Jack Nicholson: Actor'
assert p2.toString() == p1.toString()
import groovy.transform.TupleConstructor

class Base {
    String occupation
    Base() {}
    Base(String job) { occupation = job?.toLowerCase() }
}

@TupleConstructor(includeSuperProperties = true, callSuper=true)
class Person extends Base {
    String firstName
    String lastName
    public String toString() {
        "$firstName $lastName: $occupation"
    }
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')

def p2 = new Person('ACTOR', 'Jack', 'Nicholson')

assert p1.firstName == p2.firstName
assert p1.lastName == p2.lastName
assert p1.toString() == 'Jack Nicholson: null'
assert p2.toString() == 'Jack Nicholson: actor'
import groovy.transform.*

@ToString @TupleConstructor(force=true)
final class Person {
    String name
    // explicit constructor would normally disable tuple constructor
    Person(String first, String last) { this("$first $last") }
}

assert new Person('john smith').toString() == 'Person(john smith)'
assert new Person('john', 'smith').toString() == 'Person(john smith)'
@ToString
@TupleConstructor(defaults=false)
class Musician {
  String name
  String instrument
  int born
}

assert new Musician('Jimi', 'Guitar', 1942).toString() == 'Musician(Jimi, Guitar, 1942)'
assert Musician.constructors.size() == 1
import groovy.transform.*

@ToString @TupleConstructor(useSetters=true)
final class Foo {
    String bar
    void setBar(String bar) {
        this.bar = bar?.toUpperCase() // null-safe
    }
}

assert new Foo('cat').toString() == 'Foo(CAT)'
assert new Foo(bar: 'cat').toString() == 'Foo(CAT)'
import groovy.transform.TupleConstructor

@TupleConstructor(allNames=true)
class Person {
    String $firstName
}

def p = new Person('Jack')

assert p.$firstName == 'Jack'
@TupleConstructor(allProperties=true)
class Person {
    String first
    private String last
    void setLast(String last) {
        this.last = last
    }
    String getName() { "$first $last" }
}

assert new Person('john', 'smith').name == 'john smith'
import groovy.transform.TupleConstructor

@TupleConstructor(pre={ first = first?.toLowerCase() })
class Person {
    String first
}

def p = new Person('Jack')

assert p.first == 'jack'
import groovy.transform.TupleConstructor
import static groovy.test.GroovyAssert.shouldFail

@TupleConstructor(post={ assert first })
class Person {
    String first
}

def jack = new Person('Jack')
shouldFail {
  def unknown = new Person()
}
속성 기본값 설명 예시
excludes 빈 목록 튜플 생성자 생성에서 제외할 프로퍼티 목록
includes 정의되지 않은 목록(모든 필드를 나타냄) 튜플 생성자 생성에 포함할 필드 목록
includeProperties True 튜플 생성자 생성에 프로퍼티를 포함할지
includeFields False 프로퍼티에 더해 필드도 튜플 생성자 생성에 포함할지
includeSuperProperties True 수퍼 클래스의 프로퍼티를 튜플 생성자 생성에 포함할지
includeSuperFields False 수퍼 클래스의 필드를 튜플 생성자 생성에 포함할지
callSuper False 수퍼 프로퍼티를 프로퍼티로 설정하는 대신 부모 생성자 호출 안에서 호출할지
force False 기본적으로 이미 생성자가 정의되어 있으면 변환은 아무것도 하지 않음. 이 속성을 true로 설정하면 생성자가 생성되며, 중복 생성자가 정의되지 않도록 하는 것은 여러분의 책임
defaults True 생성자 파라미터에 기본 값 처리가 활성화됨을 나타냄. false로 설정하면 정확히 하나의 생성자를 얻되 초기 값 지원과 명명 인자가 비활성화됨
useSetters False 기본적으로 변환은 각 프로퍼티의 뒷받침 필드를 해당 생성자 파라미터에서 직접 설정함. 이 속성을 true로 설정하면 생성자가 setter(존재한다면)를 대신 호출함. 생성자 안에서 오버라이드될 수 있는 setter를 호출하는 것은 보통 나쁜 스타일로 간주됨. 그런 나쁜 스타일을 피하는 것은 여러분의 책임
allNames False 내부 이름을 가진 필드 및/또는 프로퍼티를 생성자에 포함할지
allProperties False JavaBean 프로퍼티를 생성자에 포함할지
pre empty 생성된 생성자의 시작 부분에 삽입할 문장을 담은 클로저
post empty 생성된 생성자의 끝 부분에 삽입할 문장을 담은 클로저

defaults 애노테이션 속성을 false로, force 애노테이션 속성을 true로 설정하면, 각 case에 대해 서로 다른 커스터마이즈 옵션을 사용해서(각 case가 다른 타입 시그니처를 갖는다면) 여러 튜플 생성자를 만들 수 있어요. 다음 예시를 볼게요.

class Named {
  String name
}

@ToString(includeSuperProperties=true, ignoreNulls=true, includeNames=true, includeFields=true)
@TupleConstructor(force=true, defaults=false)
@TupleConstructor(force=true, defaults=false, includeFields=true)
@TupleConstructor(force=true, defaults=false, includeSuperProperties=true)
class Book extends Named {
  Integer published
  private Boolean fiction
  Book() {}
}

assert new Book("Regina", 2015).toString() == 'Book(published:2015, name:Regina)'
assert new Book(2015, false).toString() == 'Book(published:2015, fiction:false)'
assert new Book(2015).toString() == 'Book(published:2015)'
assert new Book().toString() == 'Book()'
assert Book.constructors.size() == 4

마찬가지로 includes에 대해 다른 옵션을 쓰는 또 다른 예시가 여기 있어요.

@ToString(includeSuperProperties=true, ignoreNulls=true, includeNames=true, includeFields=true)
@TupleConstructor(force=true, defaults=false, includes='name,year')
@TupleConstructor(force=true, defaults=false, includes='year,fiction')
@TupleConstructor(force=true, defaults=false, includes='name,fiction')
class Book {
    String name
    Integer year
    Boolean fiction
}

assert new Book("Regina", 2015).toString() == 'Book(name:Regina, year:2015)'
assert new Book(2015, false).toString() == 'Book(year:2015, fiction:false)'
assert new Book("Regina", false).toString() == 'Book(name:Regina, fiction:false)'
assert Book.constructors.size() == 3
@groovy.transform.MapConstructor

@MapConstructor 애노테이션은 맵 생성자를 생성해 줌으로써 보일러플레이트 코드를 없애는 것을 목표로 해요. 클래스의 각 프로퍼티가, 프로퍼티 이름을 키로 가진 제공된 맵의 값에 따라 설정되도록 맵 생성자가 만들어져요. 사용법은 다음 예시와 같아요.

import groovy.transform.*

@ToString
@MapConstructor
class Person {
    String firstName
    String lastName
}

def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p1.toString() == 'Person(Jack, Nicholson)'

생성된 생성자는 대략 이렇게 생겼어요.

public Person(Map args) {
    if (args.containsKey('firstName')) {
        this.firstName = args.get('firstName')
    }
    if (args.containsKey('lastName')) {
        this.lastName = args.get('lastName')
    }
}
@groovy.transform.Canonical

@Canonical 메타 애노테이션은 @ToString, @EqualsAndHashCode, @TupleConstructor 애노테이션을 결합해요.

import groovy.transform.Canonical

@Canonical
class Person {
    String firstName
    String lastName
}
def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p1.toString() == 'Person(Jack, Nicholson)' // Effect of @ToString

def p2 = new Person('Jack','Nicholson') // Effect of @TupleConstructor
assert p2.toString() == 'Person(Jack, Nicholson)'

assert p1==p2 // Effect of @EqualsAndHashCode
assert p1.hashCode()==p2.hashCode() // Effect of @EqualsAndHashCode

비슷한 불변 클래스는 대신 @Immutable 메타 애노테이션으로 생성할 수 있어요. @Canonical 메타 애노테이션은 그것이 모은 애노테이션들에서 볼 수 있는 구성 옵션들을 지원해요. 더 자세한 내용은 그 애노테이션들을 참고하세요.

import groovy.transform.Canonical

@Canonical(excludes=['lastName'])
class Person {
    String firstName
    String lastName
}
def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p1.toString() == 'Person(Jack)' // Effect of @ToString(excludes=['lastName'])

def p2 = new Person('Jack') // Effect of @TupleConstructor(excludes=['lastName'])
assert p2.toString() == 'Person(Jack)'

assert p1==p2 // Effect of @EqualsAndHashCode(excludes=['lastName'])
assert p1.hashCode()==p2.hashCode() // Effect of @EqualsAndHashCode(excludes=['lastName'])

@Canonical 메타 애노테이션은 자신의 구성 요소 애노테이션 중 하나 이상을 명시적으로 사용하는 것과 함께 쓸 수도 있어요.

import groovy.transform.Canonical

@Canonical(excludes=['lastName'])
class Person {
    String firstName
    String lastName
}
def p1 = new Person(firstName: 'Jack', lastName: 'Nicholson')
assert p1.toString() == 'Person(Jack)' // Effect of @ToString(excludes=['lastName'])

def p2 = new Person('Jack') // Effect of @TupleConstructor(excludes=['lastName'])
assert p2.toString() == 'Person(Jack)'

assert p1==p2 // Effect of @EqualsAndHashCode(excludes=['lastName'])
assert p1.hashCode()==p2.hashCode() // Effect of @EqualsAndHashCode(excludes=['lastName'])

@Canonical의 적용 가능한 애노테이션 속성은 명시적 애노테이션으로 전달되지만, 명시적 애노테이션에 이미 존재하는 속성이 우선해요.

@groovy.transform.InheritConstructors

@InheritConstructor AST 변환은 수퍼 생성자와 일치하는 생성자를 생성해 주는 것을 목표로 해요. 특히 예외 클래스를 오버라이드할 때 유용해요.

import groovy.transform.InheritConstructors

@InheritConstructors
class CustomException extends Exception {}

// all those are generated constructors
new CustomException()
new CustomException("A custom message")
new CustomException("A custom message", new RuntimeException())
new CustomException(new RuntimeException())

// Java 7 only
// new CustomException("A custom message", new RuntimeException(), false, true)

@InheritConstructor AST 변환은 다음 구성 옵션들을 지원해요.

@Retention(RetentionPolicy.RUNTIME)
@Target([ElementType.CONSTRUCTOR])
public @interface ConsAnno {}

class Base {
  @ConsAnno Base() {}
}

@InheritConstructors(constructorAnnotations=true)
class Child extends Base {}

assert Child.constructors[0].annotations[0].annotationType().name == 'groovy.transform.Generated'
assert Child.constructors[0].annotations[1].annotationType().name == 'ConsAnno'
@Retention(RetentionPolicy.RUNTIME)
@Target([ElementType.PARAMETER])
public @interface ParamAnno {}

class Base {
  Base(@ParamAnno String name) {}
}

@InheritConstructors(parameterAnnotations=true)
class Child extends Base {}

assert Child.constructors[0].parameterAnnotations[0][0].annotationType().name == 'ParamAnno'
속성 기본값 설명 예시
constructorAnnotations False 복사 중에 생성자의 애노테이션을 가져올지
parameterAnnotations False 생성자를 복사할 때 생성자 파라미터의 애노테이션을 가져올지
@groovy.lang.Category

@Category AST 변환은 Groovy 카테고리 생성을 단순화해요. 역사적으로 Groovy 카테고리는 이렇게 작성됐어요.

class TripleCategory {
    public static Integer triple(Integer self) {
        3*self
    }
}
use (TripleCategory) {
    assert 9 == 3.triple()
}

@Category 변환은 static 클래스 스타일이 아니라 인스턴스 스타일 클래스를 사용해 같은 일을 쓸 수 있게 해 줘요. 이렇게 하면 각 메서드의 첫 번째 인자가 수신자일 필요가 없어져요. 카테고리를 이렇게 쓸 수 있어요.

@Category(Integer)
class TripleCategory {
    public Integer triple() { 3*this }
}
use (TripleCategory) {
    assert 9 == 3.triple()
}

혼합된 클래스를 대신 this로 참조할 수 있다는 점을 알아두세요. 카테고리 클래스에 인스턴스 필드를 사용하는 것은 본질적으로 안전하지 않다는 점도 알아둘 만해요. 카테고리는 (트레이트처럼) 상태를 갖지 않거든요.

@groovy.transform.IndexedProperty

@IndexedProperty 애노테이션은 목록/배열 타입의 프로퍼티에 대한 인덱스 getter/setter를 생성하는 것을 목표로 해요. Groovy 클래스를 Java에서 사용하고 싶을 때 특히 유용해요. Groovy는 프로퍼티에 접근하기 위해 GPath를 지원하지만, 그건 Java에서는 사용할 수 없어요. @IndexedProperty 애노테이션은 다음 형태의 인덱스 프로퍼티를 생성해요.

class SomeBean {
    @IndexedProperty String[] someArray = new String[2]
    @IndexedProperty List someList = []
}

def bean = new SomeBean()
bean.setSomeArray(0, 'value')
bean.setSomeList(0, 123)

assert bean.someArray[0] == 'value'
assert bean.someList == [123]
@groovy.lang.Lazy

@Lazy AST 변환은 필드의 지연 초기화(lazy initialization)를 구현해요. 예를 들어 다음 코드는:

class SomeBean {
    @Lazy LinkedList myField
}

다음 코드를 생성해요.

List $myField
List getMyField() {
    if ($myField!=null) { return $myField }
    else {
        $myField = new LinkedList()
        return $myField
    }
}

필드를 초기화하는 데 사용되는 기본 값은 선언 타입의 기본 생성자예요. 다음 예시처럼 프로퍼티 할당의 오른쪽에 클로저를 사용해 기본 값을 정의하는 것도 가능해요.

class SomeBean {
    @Lazy LinkedList myField = { ['a','b','c']}()
}

그 경우 생성된 코드는 이렇게 생겼어요.

List $myField
List getMyField() {
    if ($myField!=null) { return $myField }
    else {
        $myField = { ['a','b','c']}()
        return $myField
    }
}

필드가 volatile로 선언되면 이중 검사 잠금(double-checked locking) 패턴을 사용해 초기화가 동기화돼요. soft=true 파라미터를 사용하면 헬퍼 필드가 대신 SoftReference를 사용해서 캐싱을 구현하는 간단한 방법을 제공해요. 그 경우 가비지 컬렉터가 참조를 수집하기로 결정하면 다음에 필드에 접근할 때 초기화가 일어나요.

@groovy.lang.Newify

@Newify AST 변환은 객체를 구성하는 대체 문법을 제공하는 데 사용돼요.

  • Python 스타일 사용:
@Newify([Tree,Leaf])
class TreeBuilder {
    Tree tree = Tree(Leaf('A'),Leaf('B'),Tree(Leaf('C')))
}
  • 또는 Ruby 스타일 사용:
@Newify([Tree,Leaf])
class TreeBuilder {
    Tree tree = Tree.new(Leaf.new('A'),Leaf.new('B'),Tree.new(Leaf.new('C')))
}

Ruby 버전은 auto 플래그를 false로 설정해 비활성화할 수 있어요.

@groovy.transform.Sortable

@Sortable AST 변환은 보통 여러 프로퍼티로 쉽게 정렬되고 Comparable한 클래스를 작성하는 데 도움을 주는 데 사용돼요. Person 클래스에 애노테이션을 붙이는 다음 예시에서 볼 수 있듯 사용하기 쉬워요.

import groovy.transform.Sortable

@Sortable class Person {
    String first
    String last
    Integer born
}

생성된 클래스는 다음 프로퍼티들을 가져요.

  • Comparable 인터페이스를 구현함
  • first, last, born 프로퍼티의 자연 순서를 기반으로 하는 구현을 가진 compareTo 메서드를 담음
  • comparatorByFirst, comparatorByLast, comparatorByBorn 세 개의 comparator를 반환하는 메서드를 가짐
        return 0
    }
    if (!(obj instanceof Person)) {
        return -1
    }
    java.lang.Integer value = this.first <=> obj.first
    if (value != 0) {
        return value
    }
    value = this.last <=> obj.last
    if (value != 0) {
        return value
    }
    value = this.born <=> obj.born
    if (value != 0) {
        return value
    }
    return 0
}

생성된 comparator의 예시로, comparatorByFirst comparator의 compare 메서드는 이렇게 생겼어요.

public int compare(java.lang.Object arg0, java.lang.Object arg1) {
    if (arg0 == arg1) {
        return 0
    }
    if (arg0 != null && arg1 == null) {
        return -1
    }
    if (arg0 == null && arg1 != null) {
        return 1
    }
    return arg0.first <=> arg1.first
}

Person 클래스는 Comparable이 예상되는 곳 어디에서나, 생성된 comparator들은 Comparator가 예상되는 곳 어디에서나 사용할 수 있어요. 다음 예시들이 보여 주죠.

def people = [
    new Person(first: 'Johnny', last: 'Depp', born: 1963),
    new Person(first: 'Keira', last: 'Knightley', born: 1985),
    new Person(first: 'Geoffrey', last: 'Rush', born: 1951),
    new Person(first: 'Orlando', last: 'Bloom', born: 1977)
]

assert people[0] > people[2]
assert people.sort()*.last == ['Rush', 'Depp', 'Knightley', 'Bloom']
assert people.sort(false, Person.comparatorByFirst())*.first == ['Geoffrey', 'Johnny', 'Keira', 'Orlando']
assert people.sort(false, Person.comparatorByLast())*.last == ['Bloom', 'Depp', 'Knightley', 'Rush']
assert people.sort(false, Person.comparatorByBorn())*.last == ['Rush', 'Depp', 'Bloom', 'Knightley']

보통 모든 프로퍼티가 정의된 우선순위 순서로 생성된 compareTo 메서드에 사용돼요. includes나 excludes 애노테이션 속성에 프로퍼티 이름 목록을 주면 특정 프로퍼티를 생성된 compareTo 메서드에 포함하거나 제외할 수 있어요. includes를 사용하면 주어진 프로퍼티 이름의 순서가 비교할 때 프로퍼티의 우선순위를 결정해요. 설명을 위해 다음 Person 클래스 정의를 생각해 볼게요.

@Sortable(includes='first,born') class Person {
    String last
    int born
    String first
}

이 클래스는 comparatorByFirst와 comparatorByBorn 두 개의 comparator 메서드를 가지게 되고, 생성된 compareTo 메서드는 이렇게 생겼어요.

public int compareTo(java.lang.Object obj) {
    if (this.is(obj)) {
        return 0
    }
    if (!(obj instanceof Person)) {
        return -1
    }
    java.lang.Integer value = this.first <=> obj.first
    if (value != 0) {
        return value
    }
    value = this.born <=> obj.born
    if (value != 0) {
        return value
    }
    return 0
}

이 Person 클래스는 이렇게 사용할 수 있어요.

def people = [
    new Person(first: 'Ben', last: 'Affleck', born: 1972),
    new Person(first: 'Ben', last: 'Stiller', born: 1965)
]

assert people.sort()*.last == ['Stiller', 'Affleck']

@Sortable AST 변환의 동작은 다음 추가 파라미터들로 더 바꿀 수 있어요.

import groovy.transform.*

@Canonical(includeFields = true)
@Sortable(allProperties = true, includes = 'nameSize')
class Player {
  String name
  int getNameSize() { name.size() }
}

def finalists = [
  new Player('Serena'),
  new Player('Venus'),
  new Player('CoCo'),
  new Player('Mirjana')
]

assert finalists.sort()*.name == ['CoCo', 'Venus', 'Serena', 'Mirjana']
import groovy.transform.*

@Canonical(allNames = true)
@Sortable(allNames = false)
class Player {
  String $country
  String name
}

def finalists = [
  new Player('USA', 'Serena'),
  new Player('USA', 'Venus'),
  new Player('USA', 'CoCo'),
  new Player('Croatian', 'Mirjana')
]

assert finalists.sort()*.name == ['Mirjana', 'CoCo', 'Serena', 'Venus']
class Person {
  String name
}

@Canonical(includeSuperProperties = true)
@Sortable(includeSuperProperties = true)
class Citizen extends Person {
  String country
}

def people = [
  new Citizen('Bob', 'Italy'),
  new Citizen('Cathy', 'Hungary'),
  new Citizen('Cathy', 'Egypt'),
  new Citizen('Bob', 'Germany'),
  new Citizen('Alan', 'France')
]

assert people.sort()*.name == ['Alan', 'Bob', 'Bob', 'Cathy', 'Cathy']
assert people.sort()*.country == ['France', 'Germany', 'Italy', 'Egypt', 'Hungary']
속성 기본값 설명 예시
allProperties True JavaBean 프로퍼티(네이티브 프로퍼티 뒤에 정렬됨)를 사용할지
allNames False "내부" 이름을 가진 프로퍼티를 사용할지
includeSuperProperties False 수퍼 프로퍼티도 사용할지(먼저 정렬됨)
@groovy.transform.builder.Builder

@Builder AST 변환은 fluent api 호출로 만들 수 있는 클래스를 작성하는 데 도움이 되도록 사용돼요. 변환은 다양한 사례를 다루는 여러 빌딩 전략(building strategy)을 지원하고, 빌딩 과정을 커스터마이즈하는 여러 구성 옵션이 있어요. AST 해커라면 자신만의 전략 클래스를 정의할 수도 있어요. 다음 표는 Groovy에 동봉된 사용 가능한 전략과 각 전략이 지원하는 구성 옵션을 나열해요.

전략 설명 builderClassName builderMethodName buildMethodName prefix includes/excludes includeSuperProperties allNames
SimpleStrategy 연결 setter n/a n/a n/a 예, 기본 "set" n/a 예, 기본 false
ExternalStrategy 명시적 빌더 클래스, 빌드되는 클래스는 건드리지 않음 n/a n/a 예, 기본 "build" 예, 기본 "" 예, 기본 false 예, 기본 false
DefaultStrategy 중첩 헬퍼 클래스를 만듦 예, 기본 <TypeName>Builder 예, 기본 "builder" 예, 기본 "build" 예, 기본 "" 예, 기본 false 예, 기본 false
InitializerStrategy 타입 안전 fluent 생성을 제공하는 중첩 헬퍼 클래스를 만듦 예, 기본 <TypeName>Initializer 예, 기본 "createInitializer" 예, 기본 "create" (보통 내부에서만 사용) 예, 기본 "" 예, 기본 false 예, 기본 false

SimpleStrategy — SimpleStrategy를 사용하려면 @Builder 애노테이션으로 Groovy 클래스를 애노테이트하고 다음 예시처럼 전략을 지정하면 돼요.

import groovy.transform.builder.*

@Builder(builderStrategy=SimpleStrategy)
class Person {
    String first
    String last
    Integer born
}

그다음 여기 보이는 것처럼 연결 방식으로 setter를 호출하기만 하면 돼요.

def p1 = new Person().setFirst('Johnny').setLast('Depp').setBorn(1963)
assert "$p1.first $p1.last" == 'Johnny Depp'

각 프로퍼티에 대해 이렇게 생긴 setter가 생성돼요.

public Person setFirst(java.lang.String first) {
    this.first = first
    return this
}

다음 예시처럼 prefix를 지정할 수 있어요.

import groovy.transform.builder.*

@Builder(builderStrategy=SimpleStrategy, prefix="")
class Person {
    String first
    String last
    Integer born
}

그리고 연결 setter 호출은 이렇게 생기게 돼요.

def p = new Person().first('Johnny').last('Depp').born(1963)
assert "$p.first $p.last" == 'Johnny Depp'

SimpleStrategy를 @TupleConstructor와 함께 사용할 수 있어요. @Builder 애노테이션에 명시적 includes나 excludes 애노테이션 속성이 없고 @TupleConstructor 애노테이션에는 있다면, @TupleConstructor의 것을 @Builder가 재사용해요. @TupleConstructor를 결합하는 @Canonical 같은 애노테이션 별칭에도 그대로 적용돼요. 구성 과정의 일부로 호출하고 싶은 setter가 있다면 useSetters 애노테이션 속성을 쓸 수 있어요. 자세한 내용은 JavaDoc을 참고하세요. builderClassName, buildMethodName, builderMethodName, forClass, includeSuperProperties 애노테이션 속성은 이 전략에서 지원되지 않아요.

Note: Groovy에는 이미 내장된 빌딩 메커니즘이 있습니다. 내장 메커니즘이 요구를 충족한다면 @Builder로 서두르지 마세요. 몇 가지 예시:

def p2 = new Person(first: 'Keira', last: 'Knightley', born: 1985)
def p3 = new Person().tap {
    first = 'Geoffrey'
    last = 'Rush'
    born = 1951
}

ExternalStrategy — ExternalStrategy를 사용하려면 @Builder 애노테이션으로 Groovy 빌더 클래스를 만들고 애노테이트하고, forClass로 빌더가 대상인 클래스를 지정하고, ExternalStrategy의 사용을 나타내면 돼요. 빌더를 원하는 다음 클래스가 있다고 해 볼게요.

class Person {
    String first
    String last
    int born
}

빌더 클래스를 명시적으로 만들고 이렇게 사용해요.

import groovy.transform.builder.*

@Builder(builderStrategy=ExternalStrategy, forClass=Person)
class PersonBuilder { }

def p = new PersonBuilder().first('Johnny').last('Depp').born(1963).build()
assert "$p.first $p.last" == 'Johnny Depp'

(보통 비어 있는) 제공한 빌더 클래스에 적절한 setter와 build 메서드가 채워진다는 점을 알아두세요. 생성된 build 메서드는 대략 이렇게 생겨요.

public Person build() {
    Person _thePerson = new Person()
    _thePerson.first = first
    _thePerson.last = last
    _thePerson.born = born
    return _thePerson
}

빌더를 만드는 대상 클래스는 일반 JavaBean 관례(인자 없는 생성자와 프로퍼티용 setter)를 따르는 Java 클래스든 Groovy 클래스든 무엇이든 될 수 있어요. Java 클래스를 사용하는 예시를 볼게요.

import groovy.transform.builder.*

@Builder(builderStrategy=ExternalStrategy, forClass=javax.swing.DefaultButtonModel)
class ButtonModelBuilder {}

def model = new ButtonModelBuilder().enabled(true).pressed(true).armed(true).rollover(true).selected(true).build()
assert model.isArmed()
assert model.isPressed()
assert model.isEnabled()
assert model.isSelected()
assert model.isRollover()

생성된 빌더는 prefix, includes, excludes, buildMethodName 애노테이션 속성으로 커스터마이즈할 수 있어요. 다양한 커스터마이즈를 보여 주는 예시가 여기 있어요.

import groovy.transform.builder.*
import groovy.transform.Canonical

@Canonical
class Person {
    String first
    String last
    int born
}

@Builder(builderStrategy=ExternalStrategy, forClass=Person, includes=['first', 'last'], buildMethodName='create', prefix='with')
class PersonBuilder { }

def p = new PersonBuilder().withFirst('Johnny').withLast('Depp').create()
assert "$p.first $p.last" == 'Johnny Depp'

builderMethodName과 builderClassName 애노테이션 속성은 이 전략에 적용되지 않아요. ExternalStrategy를 @TupleConstructor와 함께 사용할 수 있어요. @Builder 애노테이션에 명시적 includes나 excludes 애노테이션 속성이 없고, 빌더를 만드는 대상 클래스의 @TupleConstructor 애노테이션에는 있다면, @TupleConstructor의 것을 @Builder가 재사용해요. @Canonical처럼 @TupleConstructor를 결합하는 애노테이션 별칭에도 그대로 적용돼요.

DefaultStrategy — DefaultStrategy를 사용하려면 @Builder 애노테이션으로 Groovy 클래스를 애노테이트하면 돼요.

import groovy.transform.builder.Builder

@Builder
class Person {
    String firstName
    String lastName
    int age
}

def person = Person.builder().firstName("Robert").lastName("Lewandowski").age(21).build()
assert person.firstName == "Robert"
assert person.lastName == "Lewandowski"
assert person.age == 21

원한다면 builderClassName, buildMethodName, builderMethodName, prefix, includes, excludes 애노테이션 속성을 사용해 빌딩 과정의 다양한 측면을 커스터마이즈할 수 있어요. 그중 일부가 여기 예시에서 사용됐어요.

import groovy.transform.builder.Builder

@Builder(buildMethodName='make', builderMethodName='maker', prefix='with', excludes='age')
class Person {
    String firstName
    String lastName
    int age
}

def p = Person.maker().withFirstName("Robert").withLastName("Lewandowski").make()
assert "$p.firstName $p.lastName" == "Robert Lewandowski"

이 전략은 static 메서드와 생성자 애노테이팅도 지원해요. 이 경우 static 메서드나 생성자 파라미터가 빌딩 목적의 프로퍼티가 되고, static 메서드의 경우 메서드의 반환 타입이 빌드되는 대상 클래스가 돼요. 클래스 안에서(클래스, 메서드, 또는 생성자 위치에서) @Builder 애노테이션을 하나 이상 사용한다면, 생성된 헬퍼 클래스와 팩토리 메서드가 고유한 이름을 갖도록 보장하는 것은 여러분의 몫이에요(즉 둘 이상이 기본 이름 값을 사용할 수 없어요). 메서드와 생성자 사용을 강조하는 예시가 여기 있어요(고유 이름을 위해 필요한 이름 변경도 보여 줍니다).

import groovy.transform.builder.*
import groovy.transform.*

@ToString
@Builder
class Person {
  String first, last
  int born

  Person(){}

  @Builder(builderClassName='MovieBuilder', builderMethodName='byRoleBuilder')
  Person(String roleName) {
     if (roleName == 'Jack Sparrow') {
         this.first = 'Johnny'; this.last = 'Depp'; this.born = 1963
     }
  }

  @Builder(builderClassName='NameBuilder', builderMethodName='nameBuilder', prefix='having', buildMethodName='fullName')
  static String join(String first, String last) {
      first + ' ' + last
  }

  @Builder(builderClassName='SplitBuilder', builderMethodName='splitBuilder')
  static Person split(String name, int year) {
      def parts = name.split(' ')
      new Person(first: parts[0], last: parts[1], born: year)
  }
}

assert Person.splitBuilder().name("Johnny Depp").year(1963).build().toString() == 'Person(Johnny, Depp, 1963)'
assert Person.byRoleBuilder().roleName("Jack Sparrow").build().toString() == 'Person(Johnny, Depp, 1963)'
assert Person.nameBuilder().havingFirst('Johnny').havingLast('Depp').fullName() == 'Johnny Depp'
assert Person.builder().first("Johnny").last('Depp').born(1963).build().toString() == 'Person(Johnny, Depp, 1963)'

forClass 애노테이션 속성은 이 전략에서 지원되지 않아요.

InitializerStrategy — InitializerStrategy를 사용하려면 @Builder 애노테이션으로 Groovy 클래스를 애노테이트하고 다음 예시처럼 전략을 지정하면 돼요.

import groovy.transform.builder.*
import groovy.transform.*

@ToString
@Builder(builderStrategy=InitializerStrategy)
class Person {
    String firstName
    String lastName
    int age
}

클래스는 "완전히 설정된(fully set)" initializer를 받는 단일 public 생성자로 잠기게 돼요. initializer를 만드는 팩토리 메서드도 가지게 돼요. 이 둘은 이렇게 사용돼요.

@CompileStatic
def firstLastAge() {
    assert new Person(Person.createInitializer().firstName("John").lastName("Smith").age(21)).toString() == 'Person(John, Smith, 21)'
}
firstLastAge()

모든 프로퍼티를 설정하지 않는(순서는 중요하지 않지만) initializer 사용 시도는 컴파일 오류가 나요. 이 정도의 엄격함이 필요 없다면 @CompileStatic을 쓸 필요는 없어요. InitializerStrategy를 @Canonical과 @Immutable과 함께 사용할 수 있어요. @Builder 애노테이션에 명시적 includes나 excludes 애노테이션 속성이 없고 @Canonical 애노테이션에는 있다면, @Canonical의 것을 @Builder가 재사용해요. @Builder를 @Immutable과 함께 사용하는 예시가 여기 있어요.

import groovy.transform.builder.*
import groovy.transform.*
import static groovy.transform.options.Visibility.PRIVATE

@Builder(builderStrategy=InitializerStrategy)
@Immutable
@VisibilityOptions(PRIVATE)
class Person {
    String first
    String last
    int born
}

def publicCons = Person.constructors
assert publicCons.size() == 1

@CompileStatic
def createFirstLastBorn() {
  def p = new Person(Person.createInitializer().first('Johnny').last('Depp').born(1963))
  assert "$p.first $p.last $p.born" == 'Johnny Depp 1963'
}

createFirstLastBorn()

구성 과정의 일부로 호출하고 싶은 setter가 있다면 useSetters 애노테이션 속성을 쓸 수 있어요. 자세한 내용은 JavaDoc을 참고하세요. 이 전략은 static 메서드와 생성자 애노테이팅도 지원해요. 이 경우 static 메서드나 생성자 파라미터가 빌딩 목적의 프로퍼티가 되고, static 메서드의 경우 메서드의 반환 타입이 빌드되는 대상 클래스가 돼요. 클래스 안에서(클래스, 메서드, 또는 생성자 위치에서) @Builder 애노테이션을 하나 이상 사용한다면, 생성된 헬퍼 클래스와 팩토리 메서드가 고유한 이름을 갖도록 보장하는 것은 여러분의 몫이에요(즉 둘 이상이 기본 이름 값을 사용할 수 없어요). 메서드와 생성자 사용의 예시는 DefaultStrategy 전략을 사용하는 경우이므로 그 전략의 문서를 참고하세요. forClass 애노테이션 속성은 이 전략에서 지원되지 않아요.

@groovy.transform.AutoImplement

@AutoImplement AST 변환은 수퍼클래스나 인터페이스에서 발견되는 찾은 추상 메서드들에 대해 더미 구현을 제공해요. 더미 구현은 찾은 모든 추상 메서드에 대해 동일하며, 다음 중 하나일 수 있어요.

  • 본질적으로 빈 구현(void 메서드에는 정확히 true이고, 반환 타입이 있는 메서드에는 그 타입의 기본 값을 반환)
  • 지정된 예외(선택적 메시지 포함)를 던지는 문장
  • 사용자 제공 코드

첫 번째 예시는 기본 사례를 보여 줘요. 우리 클래스는 @AutoImplement로 애노테이트되어 있고, 수퍼클래스와 인터페이스 하나를 가져요.

import groovy.transform.AutoImplement

@AutoImplement
class MyNames extends AbstractList<String> implements Closeable { }

Closeable 인터페이스의 void close() 메서드가 제공되고 비어 있게 남아요. 수퍼클래스의 추상 메서드 3개에 대해서도 구현이 제공돼요. get, addAll, size 메서드는 각각 String, boolean, int 반환 타입을 가지며 기본 값은 null, false, 0이에요. 다음 코드로 클래스를 사용(메서드 중 하나의 예상 반환 타입을 확인)할 수 있어요.

assert new MyNames().size() == 0

동등한 생성 코드를 검토하는 것도 가치 있어요.

class MyNames implements Closeable extends AbstractList<String> {

    String get(int param0) {
        return null
    }

    boolean addAll(Collection<? extends String> param0) {
        return false
    }

    void close() throws Exception {
    }

    int size() {
        return 0
    }

}

두 번째 예시는 가장 단순한 예외 사례를 보여 줘요. 우리 클래스는 @AutoImplement로 애노테이트되어 있고, 수퍼클래스를 가지며, "더미" 메서드 중 어떤 것이든 호출되면 IOException이 던져져야 함을 애노테이션 속성이 나타내요.

@AutoImplement(exception=IOException)
class MyWriter extends Writer { }

다음 코드로 클래스를 사용(메서드 중 하나에 대해 예상 예외가 던져지는지 확인)할 수 있어요.

import static groovy.test.GroovyAssert.shouldFail

shouldFail(IOException) {
  new MyWriter().flush()
}

세 개의 void 메서드가 전부 제공되고 모두 주어진 예외를 던지는 동등한 생성 코드를 검토하는 것도 가치 있어요.

class MyWriter extends Writer {

    void flush() throws IOException {
        throw new IOException()
    }

    void write(char[] param0, int param1, int param2) throws IOException {
        throw new IOException()
    }

    void close() throws Exception {
        throw new IOException()
    }

}

세 번째 예시는 메시지가 제공된 예외 사례를 보여 줘요. 우리 클래스는 @AutoImplement로 애노테이트되어 있고, 인터페이스를 구현하며, 제공된 메서드에 대해 Not supported by MyIterator라는 메시지의 UnsupportedOperationException이 던져져야 함을 애노테이션 속성이 나타내요.

@AutoImplement(exception=UnsupportedOperationException, message='Not supported by MyIterator')
class MyIterator implements Iterator<String> { }

다음 코드로 클래스를 사용(메서드 중 하나에 대해 예상 예외가 던져지고 올바른 메시지를 갖는지 확인)할 수 있어요.

def ex = shouldFail(UnsupportedOperationException) {
     new MyIterator().hasNext()
}
assert ex.message == 'Not supported by MyIterator'

세 개의 void 메서드가 전부 제공되고 모두 주어진 예외를 던지는 동등한 생성 코드를 검토하는 것도 가치 있어요.

class MyIterator implements Iterator<String> {

    boolean hasNext() {
        throw new UnsupportedOperationException('Not supported by MyIterator')
    }

    String next() {
        throw new UnsupportedOperationException('Not supported by MyIterator')
    }

}

네 번째 예시는 사용자 제공 코드의 사례를 보여 줘요. 우리 클래스는 @AutoImplement로 애노테이트되어 있고, 인터페이스를 구현하며, 명시적으로 오버라이드된 hasNext 메서드를 가지며, 제공된 메서드에 대한 코드를 담은 애노테이션 속성을 가져요.

@AutoImplement(code = { throw new UnsupportedOperationException('Should never be called but was called on ' + new Date()) })
class EmptyIterator implements Iterator<String> {
    boolean hasNext() { false }
}

다음 코드로 클래스를 사용(예상 예외가 던져지고 예상 형태의 메시지를 갖는지 확인)할 수 있어요.

def ex = shouldFail(UnsupportedOperationException) {
     new EmptyIterator().next()
}
assert ex.message.startsWith('Should never be called but was called on ')

next 메서드가 제공된 동등한 생성 코드를 검토하는 것도 가치 있어요.

class EmptyIterator implements java.util.Iterator<String> {

    boolean hasNext() {
        false
    }

    String next() {
        throw new UnsupportedOperationException('Should never be called but was called on ' + new Date())
    }

}
@groovy.transform.NullCheck

@NullCheck AST 변환은 생성자와 메서드에 null 검사 가드 문장을 추가해서, null 인자가 제공되면 그 메서드들이 조기에 실패하게 해요. 방어적 프로그래밍의 한 형태로 볼 수 있어요. 애노테이션은 개별 메서드나 생성자에 추가하거나, 클래스에 추가해 모든 메서드/생성자에 적용할 수 있어요.

@NullCheck
String longerOf(String first, String second) {
    first.size() >= second.size() ? first : second
}

assert longerOf('cat', 'canary') == 'canary'
def ex = shouldFail(IllegalArgumentException) {
    longerOf('cat', null)
}
assert ex.message == 'second cannot be null'
2.1.2. 클래스 설계 애노테이션 (Class design annotations)

이 범주의 애노테이션들은 잘 알려진 디자인 패턴(위임, 싱글턴 등)의 구현을 선언적 스타일로 단순화하는 것을 목표로 해요.

@groovy.transform.BaseScript

@BaseScript는 스크립트가 기본적으로 groovy.lang.Script를 확장하는 대신 커스텀 스크립트 베이스 클래스를 확장해야 함을 나타내기 위해 스크립트 안에서 사용돼요. 더 자세한 내용은 도메인 특화 언어 문서를 참고하세요.

@groovy.lang.Delegate

@Delegate AST 변환은 위임 디자인 패턴을 구현하는 것을 목표로 해요. 다음 클래스에서:

class Event {
    @Delegate Date when
    String title
}

when 프로퍼티가 @Delegate로 애노테이트되어 있어서, Event 클래스가 Date 메서드로의 호출을 when 프로퍼티에 위임한다는 뜻이에요. 이 경우 생성된 코드는 이렇게 생겼어요.

class Event {
    Date when
    String title
    boolean before(Date other) {
        when.before(other)
    }
    // ...
}

그러면 예를 들어 before 메서드를 Event 클래스에서 직접 호출할 수 있어요.

def ev = new Event(title:'Groovy keynote', when: Date.parse('yyyy/MM/dd', '2013/09/10'))
def now = new Date()
assert ev.before(now)

프로퍼티(또는 필드)를 애노테이트하는 대신 메서드를 애노테이트할 수도 있어요. 이 경우 메서드는 delegate의 getter나 팩토리 메서드로 생각할 수 있어요. 예시로, (좀 특이하게) round-robin 방식으로 접근되는 delegate 풀을 가진 클래스가 여기 있어요.

class Test {
    private int robinCount = 0
    private List<List> items = [[0], [1], [2]]

    @Delegate
    List getRoundRobinList() {
        items[robinCount++ % items.size()]
    }

    void checkItems(List<List> testValue) {
        assert items == testValue
    }
}
def t = new Test()
t << 'fee'
t << 'fi'
t << 'fo'
t << 'fum'
t.checkItems([[0, 'fee', 'fum'], [1, 'fi'], [2, 'fo']])

이런 round-robin 방식으로 표준 리스트를 쓰면 리스트의 많은 예상 프로퍼티를 위반하므로, 위 클래스가 이 사소한 예시를 넘어 유용한 일을 할 거라고 기대하지 마세요. @Delegate AST 변환의 동작은 다음 파라미터들로 바꿀 수 있어요.

interface Greeter { void sayHello() }
class MyGreeter implements Greeter { void sayHello() { println 'Hello!'} }

class DelegatingGreeter { // no explicit interface
    @Delegate MyGreeter greeter = new MyGreeter()
}
def greeter = new DelegatingGreeter()
assert greeter instanceof Greeter // interface was added transparently
class WithDeprecation {
    @Deprecated
    void foo() {}
}
class WithoutDeprecation {
    @Deprecated
    void bar() {}
}
class Delegating {
    @Delegate(deprecated=true) WithDeprecation with = new WithDeprecation()
    @Delegate WithoutDeprecation without = new WithoutDeprecation()
}
def d = new Delegating()
d.foo() // passes thanks to deprecated=true
d.bar() // fails because of @Deprecated
class WithAnnotations {
    @Transactional
    void method() {
    }
}
class DelegatingWithoutAnnotations {
    @Delegate WithAnnotations delegate
}
class DelegatingWithAnnotations {
    @Delegate(methodAnnotations = true) WithAnnotations delegate
}
def d1 = new DelegatingWithoutAnnotations()
def d2 = new DelegatingWithAnnotations()
assert d1.class.getDeclaredMethod('method').annotations.length==1
assert d2.class.getDeclaredMethod('method').annotations.length==2
class WithAnnotations {
    void method(@NotNull String str) {
    }
}
class DelegatingWithoutAnnotations {
    @Delegate WithAnnotations delegate
}
class DelegatingWithAnnotations {
    @Delegate(parameterAnnotations = true) WithAnnotations delegate
}
def d1 = new DelegatingWithoutAnnotations()
def d2 = new DelegatingWithAnnotations()
assert d1.class.getDeclaredMethod('method',String).parameterAnnotations[0].length==0
assert d2.class.getDeclaredMethod('method',String).parameterAnnotations[0].length==1
class Worker {
    void task1() {}
    void task2() {}
}
class Delegating {
    @Delegate(excludes=['task2']) Worker worker = new Worker()
}
def d = new Delegating()
d.task1() // passes
d.task2() // fails because method is excluded
class Worker {
    void task1() {}
    void task2() {}
}
class Delegating {
    @Delegate(includes=['task1']) Worker worker = new Worker()
}
def d = new Delegating()
d.task1() // passes
d.task2() // fails because method is not included
interface AppendStringSelector {
    StringBuilder append(String str)
}
class UpperStringBuilder {
    @Delegate(excludeTypes=AppendStringSelector)
    StringBuilder sb1 = new StringBuilder()

    @Delegate(includeTypes=AppendStringSelector)
    StringBuilder sb2 = new StringBuilder()

    String toString() { sb1.toString() + sb2.toString().toUpperCase() }
}
def usb = new UpperStringBuilder()
usb.append(3.5d)
usb.append('hello')
usb.append(true)
assert usb.toString() == '3.5trueHELLO'
interface AppendBooleanSelector {
    StringBuilder append(boolean b)
}
interface AppendFloatSelector {
    StringBuilder append(float b)
}
class NumberBooleanBuilder {
    @Delegate(includeTypes=AppendBooleanSelector, interfaces=false)
    StringBuilder nums = new StringBuilder()
    @Delegate(includeTypes=[AppendFloatSelector], interfaces=false)
    StringBuilder bools = new StringBuilder()
    String result() { "${nums.toString()} ~ ${bools.toString()}" }
}
def b = new NumberBooleanBuilder()
b.append(true)
b.append(3.14f)
b.append(false)
b.append(0.0f)
assert b.result() == "truefalse ~ 3.140.0"
b.append(3.5d) // would fail because we didn't include append(double)
class Worker {
    void task$() {}
}
class Delegating {
    @Delegate(allNames=true) Worker worker = new Worker()
}
def d = new Delegating()
d.task$() //passes
속성 기본값 설명 예시
interfaces True 필드가 구현하는 인터페이스를 클래스도 구현해야 할지
deprecated false true면 @Deprecated로 애노테이트된 메서드도 위임함
methodAnnotations False delegate의 메서드의 애노테이션을 위임 메서드로 가져올지
parameterAnnotations False delegate의 메서드 파라미터의 애노테이션을 위임 메서드로 가져올지
excludes 빈 배열 위임에서 제외할 메서드 목록. 더 세밀한 제어는 excludeTypes도 참고
includes 정의되지 않은 마커 배열(모든 메서드를 나타냄) 위임에 포함할 메서드 목록. 더 세밀한 제어는 includeTypes도 참고
excludeTypes 빈 배열 위임에서 제외할 메서드 시그니처를 담은 인터페이스 목록
includeTypes 정의되지 않은 마커 배열(기본적으로 목록 없음) 위임에 포함할 메서드 시그니처를 담은 인터페이스 목록
allNames False 내부 이름을 가진 메서드에도 위임 패턴을 적용할지
@groovy.transform.Immutable

@Immutable 메타 애노테이션은 다음 애노테이션들을 결합해요.

@Immutable 메타 애노테이션은 불변 클래스 생성을 단순화해요. 불변 클래스는 보통 추론하기 쉽고 본질적으로 스레드 안전하므로 유용해요. Java에서 불변 클래스를 달성하는 방법의 모든 세부 사항은 Effective Java, Minimize Mutability를 참고하세요. @Immutable 메타 애노테이션은 Effective Java에 설명된 일들을 대부분 자동으로 해 줘요. 메타 애노테이션을 사용하려면 다음 예시처럼 클래스를 애노테이트하기만 하면 돼요.

import groovy.transform.Immutable

@Immutable
class Point {
    int x
    int y
}

불변 클래스의 요구 사항 중 하나는 클래스 안의 상태 정보를 수정할 방법이 없다는 것이에요. 이를 달성하려면 각 프로퍼티에 불변 클래스를 사용하거나, 생성자와 프로퍼티 getter 안의 변경 가능한 프로퍼티들에 대해 방어적 복사(defensive copy in/out) 같은 특수 코딩을 수행하는 게 필요해요. @ImmutableBase, @MapConstructor, @TupleConstructor 사이에서 프로퍼티는 불변으로 식별되거나, 알려진 많은 경우에 대한 특수 코딩이 자동으로 처리돼요. 허용되는 처리된 프로퍼티 타입을 확장할 수 있는 다양한 메커니즘이 제공돼요. 자세한 내용은 @ImmutableOptions와 @KnownImmutable을 참고하세요. @Immutable을 클래스에 적용한 결과는 @Canonical 메타 애노테이션을 적용한 것과 상당히 비슷하지만, 생성된 클래스에는 불변성을 다루는 추가 로직이 있을 거예요. 예를 들어 프로퍼티의 뒷받침 필드가 자동으로 final로 만들어지므로 프로퍼티를 수정하려 하면 ReadOnlyPropertyException이 던져지는 걸 보면 알 수 있어요. @Immutable 메타 애노테이션은 그것이 모은 애노테이션들에서 볼 수 있는 구성 옵션들을 지원해요. 더 자세한 내용은 그 애노테이션들을 참고하세요.

@groovy.transform.ImmutableBase

@ImmutableBase로 생성된 불변 클래스는 자동으로 final이 돼요. 또한 각 프로퍼티의 타입이 검사되고 클래스에 대해 다양한 검사가 이뤄져요(예: public 인스턴스 필드는 현재 허용되지 않음). 원하면 copyWith 생성자도 생성해요. 다음 애노테이션 속성이 지원돼요.

import groovy.transform.Immutable

@Immutable( copyWith=true )
class User {
    String  name
    Integer age
}

def bob   = new User( 'bob', 43 )
def alice = bob.copyWith( name:'alice' )
assert alice.name == 'alice'
assert alice.age  == 43
속성 기본값 설명 예시
copyWith false copyWith( Map ) 메서드를 생성할지 여부
@groovy.transform.PropertyOptions

이 애노테이션은 클래스 구성 동안 변환에서 사용할 커스텀 프로퍼티 핸들러를 지정할 수 있게 해 줘요. 메인 Groovy 컴파일러는 무시하지만 @TupleConstructor, @MapConstructor, @ImmutableBase 같은 다른 변환들이 참조해요. @Immutable 메타 애노테이션이 뒤에서 자주 사용해요.

@groovy.transform.VisibilityOptions

이 애노테이션은 다른 변환이 생성한 구성의 커스텀 가시성을 지정할 수 있게 해 줘요. 메인 Groovy 컴파일러는 무시하지만 @TupleConstructor, @MapConstructor, @NamedVariant 같은 다른 변환들이 참조해요.

@groovy.transform.ImmutableOptions

Groovy의 불변성 지원은 알려진 불변 클래스(java.net.URI나 java.lang.String 같은)의 사전 정의된 목록에 의존하고, 그 목록에 없는 타입을 사용하면 실패해요. @ImmutableOptions 애노테이션의 다음 애노테이션 속성 덕분에 알려진 불변 타입의 목록에 추가할 수 있어요.

import groovy.transform.Immutable
import groovy.transform.TupleConstructor

@TupleConstructor
final class Point {
    final int x
    final int y
    public String toString() { "($x,$y)" }
}

@Immutable(knownImmutableClasses=[Point])
class Triangle {
    Point a,b,c
}
import groovy.transform.Immutable
import groovy.transform.TupleConstructor

@TupleConstructor
final class Point {
    final int x
    final int y
    public String toString() { "($x,$y)" }
}

@Immutable(knownImmutables=['a','b','c'])
class Triangle {
    Point a,b,c
}
속성 기본값 설명 예시
knownImmutableClasses 빈 목록 불변으로 간주되는 클래스 목록
knownImmutables 빈 목록 불변으로 간주되는 프로퍼티 이름 목록

타입을 불변으로 간주하면서 자동으로 처리되는 타입 중 하나가 아니라면, 불변성을 보장하도록 그 클래스를 올바르게 코딩하는 것은 여러분의 몫이에요.

@groovy.transform.KnownImmutable

@KnownImmutable 애노테이션은 실제로 어떤 AST 변환도 트리거하지 않아요. 단순한 마커 애노테이션이에요. 자신의 클래스(Java 클래스 포함)에 이 애노테이션을 붙이면 그 클래스가 불변 클래스 안의 멤버로 허용되는 타입으로 인식돼요. 이렇게 하면 @ImmutableOptions의 knownImmutables나 knownImmutableClasses 애노테이션 속성을 명시적으로 사용하지 않아도 돼요.

@groovy.transform.Memoized

@Memoized AST 변환은 @Memoized를 메서드에 붙이기만 하면 메서드 호출의 결과를 캐시해서 캐싱 구현을 단순화해요. 다음 메서드를 상상해 볼게요.

long longComputation(int seed) {
    // slow computation
    Thread.sleep(100*seed)
    System.nanoTime()
}

이것은 메서드의 실제 파라미터를 기반으로 한 긴 계산을 흉내 내요. @Memoized가 없으면 각 메서드 호출은 몇 초가 걸리고 무작위 결과를 반환해요.

def x = longComputation(1)
def y = longComputation(1)
assert x!=y

@Memoized를 추가하면 파라미터를 기반으로 캐싱을 추가해서 메서드의 의미론이 바뀌어요.

@Memoized
long longComputation(int seed) {
    // slow computation
    Thread.sleep(100*seed)
    System.nanoTime()
}

def x = longComputation(1) // returns after 100 milliseconds
def y = longComputation(1) // returns immediately
def z = longComputation(2) // returns after 200 milliseconds
assert x==y
assert x!=z

캐시의 크기는 두 개의 선택적 파라미터로 구성할 수 있어요.

  • protectedCacheSize: 가비지 컬렉션 후에도 지워지지 않음이 보장되는 결과의 수
  • maxCacheSize: 메모리에 보관할 수 있는 최대 결과 수

기본적으로 캐시 크기는 무제한이고 어떤 캐시 결과도 가비지 컬렉션으로부터 보호되지 않아요. protectedCacheSize>0을 설정하면 일부 결과가 보호되는 무제한 캐시가 만들어져요. maxCacheSize>0을 설정하면 가비지 보호 없이 제한된 캐시가 만들어져요. 둘 다 설정하면 제한되고 보호된 캐시가 만들어져요.

@groovy.transform.TailRecursive

@TailRecursive 애노테이션은 메서드의 끝에 있는 재귀 호출을 같은 코드의 동등한 반복 버전으로 자동 변환하는 데 사용할 수 있어요. 이렇게 하면 너무 많은 재귀 호출로 인한 스택 오버플로를 피할 수 있어요. 팩토리얼을 계산할 때의 사용 예시가 아래에 있어요.

import groovy.transform.CompileStatic
import groovy.transform.TailRecursive

@CompileStatic
class Factorial {

    @TailRecursive
    static BigInteger factorial( BigInteger i, BigInteger product = 1) {
        if( i == 1) {
            return product
        }
        return factorial(i-1, product*i)
    }
}

assert Factorial.factorial(1) == 1
assert Factorial.factorial(3) == 6
assert Factorial.factorial(5) == 120
assert Factorial.factorial(50000).toString().size() == 213237 // Big number and no Stack Overflow

현재 이 애노테이션은 자기 재귀 메서드 호출, 즉 정확히 같은 메서드에 대한 단일 재귀 호출에 대해서만 동작해요. 단순한 상호 재귀가 포함된 시나리오가 있다면 클로저와 trampoline() 사용을 고려해 보세요. 또한 현재는 void가 아닌 메서드만 처리된다는 점을 알아두세요(void 호출은 컴파일 오류가 납니다).

Caution: 현재 일부 형태의 메서드 오버로딩이 컴파일러를 속일 수 있고, 어떤 비꼬리 재귀 호출이 잘못 꼬리 재귀로 처리됐을 수 있습니다.

@groovy.lang.Singleton

@Singleton 애노테이션은 클래스에 싱글턴 디자인 패턴을 구현하는 데 사용할 수 있어요. 싱글턴 인스턴스는 기본적으로 클래스 초기화를 사용해 즉시(eagerly) 정의되거나, 지연(lazily)으로 정의되며 그 경우 더블 체크 로킹을 사용해 필드가 초기화돼요.

@Singleton
class GreetingService {
    String greeting(String name) { "Hello, $name!" }
}
assert GreetingService.instance.greeting('Bob') == 'Hello, Bob!'

기본적으로 싱글턴은 클래스가 초기화될 때 즉시 생성되고 instance 프로퍼티를 통해 사용 가능해요. property 파라미터로 싱글턴의 이름을 바꿀 수 있어요.

@Singleton(property='theOne')
class GreetingService {
    String greeting(String name) { "Hello, $name!" }
}

assert GreetingService.theOne.greeting('Bob') == 'Hello, Bob!'

lazy 파라미터로 초기화를 지연시키는 것도 가능해요.

class Collaborator {
    public static boolean init = false
}
@Singleton(lazy=true,strict=false)
class GreetingService {
    static void init() {}
    GreetingService() {
        Collaborator.init = true
    }
    String greeting(String name) { "Hello, $name!" }
}
GreetingService.init() // make sure class is initialized
assert Collaborator.init == false
GreetingService.instance
assert Collaborator.init == true
assert GreetingService.instance.greeting('Bob') == 'Hello, Bob!'

이 예시에서는 strict 파라미터를 false로 설정해서 우리 자신의 생성자를 정의할 수 있게 했어요.

@groovy.lang.Mixin

더 이상 사용되지 않아요(deprecated). 대신 트레이트(traits) 사용을 고려하세요.

2.1.3. 로깅 개선 (Logging improvements)

Groovy는 가장 널리 사용되는 로깅 프레임워크와의 통합을 돕는 AST 변환 계열을 제공해요. 각 일반적인 프레임워크마다 변환과 관련 애노테이션이 있어요. 이 변환들은 로깅 프레임워크를 사용하는 간소화된 선언적 접근을 제공해요. 각 경우 변환은 다음을 수행해요.

  • 로거에 해당하는 static final log 필드를 애노테이트된 클래스에 추가함
  • log.level()에 대한 모든 호출을 밑에 있는 프레임워크에 따라 적절한 log.isLevelEnabled 가드로 감쌈

이 변환들은 두 개의 파라미터를 지원해요.

  • value(기본 log) — 로거 필드의 이름에 해당
  • category(기본값은 클래스 이름) — 로거 카테고리의 이름

이 애노테이션들 중 하나로 클래스를 애노테이트해도 일반적인 장황한 방식으로 로깅 프레임워크를 사용하는 것을 막지 않는다는 점을 알아둘 만해요.

@groovy.util.logging.Log

사용 가능한 첫 번째 로깅 AST 변환은 JDK 로깅 프레임워크에 의존하는 @Log 애노테이션이에요. 다음을 쓰는 것:

@groovy.util.logging.Log
class Greeter {
    void greet() {
        log.info 'Called greeter'
        println 'Hello, world!'
    }
}

이것과 동등해요:

import java.util.logging.Level
import java.util.logging.Logger

class Greeter {
    private static final Logger log = Logger.getLogger(Greeter.name)
    void greet() {
        if (log.isLoggable(Level.INFO)) {
            log.info 'Called greeter'
        }
        println 'Hello, world!'
    }
}
@groovy.util.logging.Commons

Groovy는 @Commons 애노테이션으로 Apache Commons Logging 프레임워크를 지원해요. 다음을 쓰는 것:

@groovy.util.logging.Commons
class Greeter {
    void greet() {
        log.debug 'Called greeter'
        println 'Hello, world!'
    }
}

이것과 동등해요:

import org.apache.commons.logging.LogFactory
import org.apache.commons.logging.Log

class Greeter {
    private static final Log log = LogFactory.getLog(Greeter)
    void greet() {
        if (log.isDebugEnabled()) {
            log.debug 'Called greeter'
        }
        println 'Hello, world!'
    }
}

클래스패스에 적절한 commons-logging jar를 여전히 추가해야 해요.

@groovy.util.logging.Log4j

Groovy는 @Log4j 애노테이션으로 Apache Log4j 1.x 프레임워크를 지원해요. 다음을 쓰는 것:

@groovy.util.logging.Log4j
class Greeter {
    void greet() {
        log.debug 'Called greeter'
        println 'Hello, world!'
    }
}

이것과 동등해요:

import org.apache.log4j.Logger

class Greeter {
    private static final Logger log = Logger.getLogger(Greeter)
    void greet() {
        if (log.isDebugEnabled()) {
            log.debug 'Called greeter'
        }
        println 'Hello, world!'
    }
}

클래스패스에 적절한 log4j jar를 여전히 추가해야 해요. 이 애노테이션은 호환되는 reload4j log4j 드롭 인 교체물과도 사용할 수 있어요. log4j jar 대신 그 프로젝트의 jar를 사용하기만 하면 돼요.

@groovy.util.logging.Log4j2

Groovy는 @Log4j2 애노테이션으로 Apache Log4j 2.x 프레임워크를 지원해요. 다음을 쓰는 것:

@groovy.util.logging.Log4j2
class Greeter {
    void greet() {
        log.debug 'Called greeter'
        println 'Hello, world!'
    }
}

이것과 동등해요:

import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger

class Greeter {
    private static final Logger log = LogManager.getLogger(Greeter)
    void greet() {
        if (log.isDebugEnabled()) {
            log.debug 'Called greeter'
        }
        println 'Hello, world!'
    }
}

클래스패스에 적절한 log4j2 jar를 여전히 추가해야 해요.

@groovy.util.logging.Slf4j

Groovy는 @Slf4j 애노테이션으로 Simple Logging Facade for Java (SLF4J) 프레임워크를 지원해요. 다음을 쓰는 것:

@groovy.util.logging.Slf4j
class Greeter {
    void greet() {
        log.debug 'Called greeter'
        println 'Hello, world!'
    }
}

이것과 동등해요:

import org.slf4j.LoggerFactory
import org.slf4j.Logger

class Greeter {
    private static final Logger log = LoggerFactory.getLogger(Greeter)
    void greet() {
        if (log.isDebugEnabled()) {
            log.debug 'Called greeter'
        }
        println 'Hello, world!'
    }
}

클래스패스에 적절한 slf4j jar를 여전히 추가해야 해요.

@groovy.util.logging.PlatformLog

Groovy는 @PlatformLog 애노테이션으로 Java Platform Logging API and Service 프레임워크를 지원해요. 다음을 쓰는 것:

@groovy.util.logging.PlatformLog
class Greeter {
    void greet() {
        log.info 'Called greeter'
        println 'Hello, world!'
    }
}

이것과 동등해요:

import java.lang.System.Logger
import java.lang.System.LoggerFinder
import static java.lang.System.Logger.Level.INFO

class Greeter {
    private static final transient Logger log =
        LoggerFinder.loggerFinder.getLogger(Greeter.class.name, Greeter.class.module)
    void greet() {
        log.log INFO, 'Called greeter'
        println 'Hello, world!'
    }
}

이 능력을 사용하려면 JDK 9+를 사용해야 해요.

2.1.4. 선언적 동시성 (Declarative concurrency)

Groovy 언어는 선언적 접근으로 일반적인 동시성 패턴을 단순화하는 것을 목표로 하는 애노테이션 세트를 제공해요.

@groovy.transform.Synchronized

@Synchronized AST 변환은 synchronized 키워드와 비슷하게 동작하지만 더 안전한 동시성을 위해 다른 객체에 대해 잠금을 걸어요. 어떤 메서드나 static 메서드에 적용할 수 있어요.

import groovy.transform.Synchronized

import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

class Counter {
    int cpt
    @Synchronized
    int incrementAndGet() {
        cpt++
    }
    int get() {
        cpt
    }
}

이것을 쓰는 것은 잠금 객체를 만들고 전체 메서드를 synchronized 블록으로 감싸는 것과 동등해요.

class Counter {
    int cpt
    private final Object $lock = new Object()

    int incrementAndGet() {
        synchronized($lock) {
            cpt++
        }
    }
    int get() {
        cpt
    }

}

기본적으로 @Synchronized는 $lock(static 메서드의 경우 $LOCK)이라는 필드를 만들지만, 다음 예시처럼 value 속성을 지정해서 원하는 어떤 필드도 사용하게 할 수 있어요.

import groovy.transform.Synchronized

import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

class Counter {
    int cpt
    private final Object myLock = new Object()

    @Synchronized('myLock')
    int incrementAndGet() {
        cpt++
    }
}
    int get() {
        cpt
    }
}
@groovy.transform.WithReadLock과 @groovy.transform.WithWriteLock

@WithReadLock AST 변환은 @WithWriteLock 변환과 함께 동작해서 JDK가 제공하는 ReentrantReadWriteLock 기능으로 읽기/쓰기 동기화를 제공해요. 애노테이션은 메서드나 static 메서드에 추가할 수 있어요. 투명하게 $reentrantLock final 필드(static 메서드의 경우 $REENTRANTLOCK)를 만들고 적절한 동기화 코드가 추가돼요. 예를 들어 다음 코드는:

import groovy.transform.WithReadLock
import groovy.transform.WithWriteLock

class Counters {
    public final Map<String,Integer> map = [:].withDefault { 0 }

    @WithReadLock
    int get(String id) {
        map.get(id)
    }

    @WithWriteLock
    void add(String id, int num) {
        Thread.sleep(200) // emulate long computation
        map.put(id, map.get(id)+num)
    }
}

이것과 동등해요.

import groovy.transform.WithReadLock as WithReadLock
import groovy.transform.WithWriteLock as WithWriteLock

public class Counters {

    private final Map<String, Integer> map
    private final java.util.concurrent.locks.ReentrantReadWriteLock $reentrantlock

    public int get(java.lang.String id) {
        $reentrantlock.readLock().lock()
        try {
            map.get(id)
        }
        finally {
            $reentrantlock.readLock().unlock()
        }
    }

    public void add(java.lang.String id, int num) {
        $reentrantlock.writeLock().lock()
        try {
            java.lang.Thread.sleep(200)
            map.put(id, map.get(id) + num )
        }
        finally {
            $reentrantlock.writeLock().unlock()
        }
    }
}

@WithReadLock과 @WithWriteLock 모두 대체 잠금 객체 지정을 지원해요. 그 경우 참조된 필드는 다음 대안처럼 사용자가 선언해야 해요.

import groovy.transform.WithReadLock
import groovy.transform.WithWriteLock

import java.util.concurrent.locks.ReentrantReadWriteLock

class Counters {
    public final Map<String,Integer> map = [:].withDefault { 0 }
    private final ReentrantReadWriteLock customLock = new ReentrantReadWriteLock()

    @WithReadLock('customLock')
    int get(String id) {
        map.get(id)
    }

    @WithWriteLock('customLock')
    void add(String id, int num) {
        Thread.sleep(200) // emulate long computation
        map.put(id, map.get(id)+num)
    }
}

자세한 내용은:

2.1.5. 더 쉬운 클로닝과 외부화 (Easier cloning and externalizing)

Groovy는 Cloneable과 Externalizable 인터페이스의 구현을 용이하게 하는 두 애노테이션을 제공하는데, 각각 @AutoClone과 @AutoExternalize예요.

@groovy.transform.AutoClone

@AutoClone 애노테이션은 style 파라미터 덕분에 다양한 전략으로 @java.lang.Cloneable 인터페이스를 구현하는 것을 목표로 해요.

  • 기본 AutoCloneStyle.CLONE 전략은 먼저 super.clone()을 호출한 다음 각 cloneable 프로퍼티에 clone()을 호출해요.
  • AutoCloneStyle.SIMPLE 전략은 일반 생성자 호출을 사용하고 소스에서 클론으로 프로퍼티를 복사해요.
  • AutoCloneStyle.COPY_CONSTRUCTOR 전략은 복사 생성자를 만들고 사용해요.
  • AutoCloneStyle.SERIALIZATION 전략은 직렬화(또는 외부화)를 사용해 객체를 클로닝해요.

이 전략들 각각에는 groovy.transform.AutoClonegroovy.transform.AutoCloneStyle의 Javadoc에서 논의되는 장단점이 있어요. 예를 들어 다음 예시는:

import groovy.transform.AutoClone

@AutoClone
class Book {
    String isbn
    String title
    List<String> authors
    Date publicationDate
}

이것과 동등해요.

class Book implements Cloneable {
    String isbn
    String title
    List<String> authors
    Date publicationDate

    public Book clone() throws CloneNotSupportedException {
        Book result = super.clone()
        result.authors = authors instanceof Cloneable ? (List) authors.clone() : authors
        result.publicationDate = publicationDate.clone()
        result
    }
}

String 프로퍼티는 명시적으로 처리되지 않는다는 점을 알아두세요. String은 불변이고 Object의 clone() 메서드가 String 참조를 복사하기 때문이에요. 기본 필드와 java.lang.Number의 구체적 서브클래스 대부분에도 같은 것이 적용돼요. 클로닝 스타일 외에도 @AutoClone은 여러 옵션을 지원해요.

import groovy.transform.AutoClone
import groovy.transform.AutoCloneStyle

@AutoClone(style=AutoCloneStyle.SIMPLE,excludes='authors')
class Book {
    String isbn
    String title
    List authors
    Date publicationDate
}
import groovy.transform.AutoClone
import groovy.transform.AutoCloneStyle

@AutoClone(style=AutoCloneStyle.SIMPLE,includeFields=true)
class Book {
    String isbn
    String title
    List authors
    protected Date publicationDate
}
속성 기본값 설명 예시
excludes 빈 목록 클로닝에서 제외해야 하는 프로퍼티나 필드 이름 목록. 쉼표로 구분된 필드/프로퍼티 이름 문자열도 허용. groovy.transform.AutoClone#excludes 참고
includeFields false 기본적으로 프로퍼티만 클로닝됨. 이 플래그를 true로 설정하면 필드도 클로닝함
@groovy.transform.AutoExternalize

@AutoExternalize AST 변환은 java.io.Externalizable 클래스 생성에 도움을 줘요. 인터페이스를 클래스에 자동으로 추가하고 writeExternal과 readExternal 메서드를 생성해요. 예를 들어 이 코드는:

import groovy.transform.AutoExternalize

@AutoExternalize
class Book {
    String isbn
    String title
    float price
}

이것으로 변환돼요.

class Book implements java.io.Externalizable {
    String isbn
    String title
    float price

    void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(isbn)
        out.writeObject(title)
        out.writeFloat( price )
    }

    public void readExternal(ObjectInput oin) {
        isbn = (String) oin.readObject()
        title = (String) oin.readObject()
        price = oin.readFloat()
    }

}

@AutoExternalize 애노테이션은 동작을 약간 커스터마이즈하게 해 주는 두 파라미터를 지원해요.

import groovy.transform.AutoExternalize

@AutoExternalize(excludes='price')
class Book {
    String isbn
    String title
    float price
}
import groovy.transform.AutoExternalize

@AutoExternalize(includeFields=true)
class Book {
    String isbn
    String title
    protected float price
}
속성 기본값 설명 예시
excludes 빈 목록 외부화에서 제외해야 하는 프로퍼티나 필드 이름 목록. 쉼표로 구분된 필드/프로퍼티 이름 문자열도 허용. groovy.transform.AutoExternalize#excludes 참고
includeFields false 기본적으로 프로퍼티만 외부화됨. 이 플래그를 true로 설정하면 필드도 클로닝함
2.1.6. 더 안전한 스크립팅 (Safer scripting)

Groovy 언어는 런타임에 사용자 스크립트를 실행하기 쉽게 만들어 줘요(예: groovy.lang.GroovyShell 사용). 하지만 스크립트가 모든 CPU를 먹지 않도록(무한 루프) 하려면 어떻게 할까요? 아니면 동시 스크립트가 스레드 풀의 사용 가능한 모든 스레드를 천천히 소모하지 않도록은요? Groovy는 더 안전한 스크립팅을 목표로 여러 애노테이션을 제공하며, 예를 들어 실행을 자동으로 중단할 수 있게 해 주는 코드를 생성해요.

@groovy.transform.ThreadInterrupt

JVM 세계에서 복잡한 상황 중 하나는 스레드를 멈출 수 없을 때예요. Thread#stop 메서드는 존재하지만 사용되지 않도록 권장되고(그리고 신뢰할 수 없으며) 그래서 유일한 기회는 Thread#interrupt에 있어요. 후자를 호출하면 스레드에 interrupt 플래그가 설정되지만 스레드의 실행을 멈추지는 못해요. 스레드 안에서 실행되는 코드가 interrupt 플래그를 확인하고 제대로 종료하는 것이 책임이기 때문에 이것은 문제가 돼요. 개발자로서 실행 중인 코드가 독립 스레드에서 실행되도록 의도된 것임을 안다면 이 말이 이해가 되지만, 일반적으로는 알 수 없어요. 사용자 스크립트에서는 스레드가 무엇인지조차 모를 수 있으니(DSL을 생각해 보세요) 더 나빠요. @ThreadInterrupt은 코드의 중요한 지점에 스레드 중단 검사를 추가해서 이것을 단순화해요.

  • 루프 (for, while)
  • 메서드의 첫 명령
  • 클로저 본문의 첫 명령

다음 사용자 스크립트를 상상해 볼게요.

while (true) {
    i++
}

이것은 명백한 무한 루프예요. 이 코드가 자신의 스레드에서 실행되면 중단은 도움이 되지 않아요. 스레드에 join하면 호출 코드는 계속할 수 있지만, 스레드는 여전히 살아 있고 백그라운드에서 실행되며 중단할 능력이 없어서 천천히 스레드 고갈을 일으켜요. 이것을 우회하는 한 가지 방법은 셸을 이렇게 설정하는 것이에요.

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
        new ASTTransformationCustomizer(ThreadInterrupt)
)
def binding = new Binding(i:0)
def shell = new GroovyShell(binding,config)

그러면 셸은 모든 스크립트에 @ThreadInterrupt AST 변환을 자동으로 적용하도록 구성돼요. 이렇게 하면 사용자 스크립트를 이렇게 실행할 수 있어요.

def t = Thread.start {
    shell.evaluate(userCode)
}
t.join(1000) // give at most 1000ms for the script to complete
if (t.alive) {
    t.interrupt()
}

변환은 사용자 코드를 자동으로 이렇게 수정해요.

while (true) {
    if (Thread.currentThread().interrupted) {
        throw new InterruptedException('The current thread has been interrupted.')
    }
    i++
}

루프 안에 도입된 검사는 현재 스레드에 interrupt 플래그가 설정되어 있으면 예외가 던져지고 그로써 스레드의 실행이 중단됨을 보장해요. @ThreadInterrupt은 변환의 동작을 더 커스터마이즈하게 해 주는 여러 옵션을 지원해요.

class BadException extends Exception {
    BadException(String message) { super(message) }
}

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
        new ASTTransformationCustomizer(thrown:BadException, ThreadInterrupt)
)
def binding = new Binding(i:0)
def shell = new GroovyShell(this.class.classLoader,binding,config)

def userCode = """
try {
    while (true) {
        i++
    }
} catch (BadException e) {
    i = -1
}
"""

def t = Thread.start {
    shell.evaluate(userCode)
}
t.join(1000) // give at most 1s for the script to complete
assert binding.i > 0
if (t.alive) {
    t.interrupt()
}
Thread.sleep(500)
assert binding.i == -1
@ThreadInterrupt(checkOnMethodStart=false)
@ThreadInterrupt(applyToAllClasses=false)
class A { ... } // interrupt checks added
class B { ... } // no interrupt checks
class A {
    @ThreadInterrupt(applyToAllMembers=false)
    void method1() { ... } // interrupt checked added
    void method2() { ... } // no interrupt checks
}
속성 기본값 설명 예시
thrown java.lang.InterruptedException 스레드가 중단되면 던져지는 예외의 타입을 지정
checkOnMethodStart true 각 메서드 본문의 시작 부분에 중단 검사를 삽입할지. groovy.transform.ThreadInterrupt 참고
applyToAllClasses true 같은 소스 유닛(같은 소스 파일)의 모든 클래스에 변환을 적용할지. groovy.transform.ThreadInterrupt 참고
applyToAllMembers true 클래스의 모든 멤버에 변환을 적용할지. groovy.transform.ThreadInterrupt 참고
@groovy.transform.TimedInterrupt

@TimedInterrupt AST 변환은 @groovy.transform.ThreadInterrupt와는 약간 다른 문제를 풀려고 해요. 스레드의 interrupt 플래그를 확인하는 대신, 스레드가 너무 오래 실행 중이면 자동으로 예외를 던져요.

Note: 이 애노테이션은 감시 스레드를 생성하지 않습니다. 대신 @ThreadInterrupt와 비슷한 방식으로 코드의 적절한 곳에 검사를 배치해서 동작합니다. 즉 I/O에 의해 차단된 스레드가 있으면 중단되지 않는다는 뜻입니다.

다음 사용자 코드를 상상해 보세요.

def fib(int n) { n<2?n:fib(n-1)+fib(n-2) }

result = fib(600)

여기 유명한 피보나치 수 계산의 구현은 최적화와는 거리가 멀어요. 높은 n 값으로 호출하면 답하는 데 몇 분이 걸릴 수 있어요. @TimedInterrupt로 스크립트가 실행될 수 있는 시간을 선택할 수 있어요. 다음 설정 코드는 사용자 스크립트가 최대 1초 실행되게 해요.

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
        new ASTTransformationCustomizer(value:1, TimedInterrupt)
)
def binding = new Binding(result:0)
def shell = new GroovyShell(this.class.classLoader, binding,config)

이 코드는 클래스를 @TimedInterrupt로 애노테이트하는 것과 동등해요.

@TimedInterrupt(value=1, unit=TimeUnit.SECONDS)
class MyClass {
    def fib(int n) {
        n<2?n:fib(n-1)+fib(n-2)
    }
}

@TimedInterrupt은 변환의 동작을 더 커스터마이즈하게 해 주는 여러 옵션을 지원해요.

@TimedInterrupt(value=500L, unit= TimeUnit.MILLISECONDS, applyToAllClasses = false)
class Slow {
    def fib(n) { n<2?n:fib(n-1)+fib(n-2) }
}
def result
def t = Thread.start {
    result = new Slow().fib(500)
}
t.join(5000)
assert result == null
assert !t.alive
@TimedInterrupt(value=500L, unit= TimeUnit.MILLISECONDS, applyToAllClasses = false)
class Slow {
    def fib(n) { n<2?n:fib(n-1)+fib(n-2) }
}
def result
def t = Thread.start {
    result = new Slow().fib(500)
}
t.join(5000)
assert result == null
assert !t.alive
@TimedInterrupt(thrown=TooLongException, applyToAllClasses = false, value=1L)
class Slow {
    def fib(n) { Thread.sleep(100); n<2?n:fib(n-1)+fib(n-2) }
}
def result
def t = Thread.start {
    try {
        result = new Slow().fib(50)
    } catch (TooLongException e) {
        result = -1
    }
}
t.join(5000)
assert result == -1
@TimedInterrupt(checkOnMethodStart=false)
@TimedInterrupt(applyToAllClasses=false)
class A { ... } // interrupt checks added
class B { ... } // no interrupt checks
class A {
    @TimedInterrupt(applyToAllMembers=false)
    void method1() { ... } // interrupt checked added
    void method2() { ... } // no interrupt checks
}
속성 기본값 설명 예시
value Long.MAX_VALUE unit과 결합해 실행이 몇 후에 타임아웃되는지 지정
unit TimeUnit.SECONDS value와 결합해 실행이 몇 후에 타임아웃되는지 지정
thrown java.util.concurrent.TimeoutException 타임아웃에 도달하면 던져지는 예외의 타입을 지정
checkOnMethodStart true 각 메서드 본문의 시작 부분에 중단 검사를 삽입할지. groovy.transform.TimedInterrupt 참고
applyToAllClasses true 같은 소스 유닛(같은 소스 파일)의 모든 클래스에 변환을 적용할지. groovy.transform.TimedInterrupt 참고
applyToAllMembers true 클래스의 모든 멤버에 변환을 적용할지. groovy.transform.TimedInterrupt 참고

Warning: @TimedInterrupt은 현재 static 메서드와 호환되지 않습니다!

@groovy.transform.ConditionalInterrupt

더 안전한 스크립팅을 위한 마지막 애노테이션은 커스텀 전략으로 스크립트를 중단하고 싶을 때의 기본 애노테이션이에요. 특히 리소스 관리를 사용하고 싶을 때(API 호출 횟수 제한 등) 선택하는 애노테이션이에요. 다음 예시에서 사용자 코드는 무한 루프를 사용하지만 @ConditionalInterrupt는 쿼터 매니저를 확인하고 스크립트를 자동으로 중단하게 해 줘요.

@ConditionalInterrupt({Quotas.disallow('user')})
class UserCode {
    void doSomething() {
        int i=0
        while (true) {
            println "Consuming resources ${++i}"
        }
    }
}

쿼터 검사는 여기서 매우 기본적이지만, 어떤 코드든 될 수 있어요.

class Quotas {
    static def quotas = [:].withDefault { 10 }
    static boolean disallow(String userName) {
        println "Checking quota for $userName"
        (quotas[userName]--)<0
    }
}

이 테스트 코드로 @ConditionalInterrupt이 제대로 동작하는지 확인할 수 있어요.

assert Quotas.quotas['user'] == 10
def t = Thread.start {
    new UserCode().doSomething()
}
t.join(5000)
assert !t.alive
assert Quotas.quotas['user'] < 0

물론 실제로는 @ConditionalInterrupt을 사용자 코드에 손으로 직접 추가할 가능성은 낮아요. ThreadInterrupt 섹션의 예시와 비슷한 방식으로 org.codehaus.groovy.control.customizers.ASTTransformationCustomizer를 사용해 주입할 수 있어요.

def config = new CompilerConfiguration()
def checkExpression = new ClosureExpression(
        Parameter.EMPTY_ARRAY,
        new ExpressionStatement(
                new MethodCallExpression(new ClassExpression(ClassHelper.make(Quotas)), 'disallow', new ConstantExpression('user'))
        )
)
config.addCompilationCustomizers(
        new ASTTransformationCustomizer(value: checkExpression, ConditionalInterrupt)
)

def shell = new GroovyShell(this.class.classLoader,new Binding(),config)

def userCode = """
        int i=0
        while (true) {
            println "Consuming resources \\${++i}"
        }
"""

assert Quotas.quotas['user'] == 10
def t = Thread.start {
    shell.evaluate(userCode)
}
t.join(5000)
assert !t.alive
assert Quotas.quotas['user'] < 0

@ConditionalInterrupt은 변환의 동작을 더 커스터마이즈하게 해 주는 여러 옵션을 지원해요.

@ConditionalInterrupt({ ... })
config.addCompilationCustomizers(
        new ASTTransformationCustomizer(thrown: QuotaExceededException,value: checkExpression, ConditionalInterrupt)
)
assert Quotas.quotas['user'] == 10
def t = Thread.start {
    try {
        shell.evaluate(userCode)
    } catch (QuotaExceededException) {
        Quotas.quotas['user'] = 'Quota exceeded'
    }
}
t.join(5000)
assert !t.alive
assert Quotas.quotas['user'] == 'Quota exceeded'
@ConditionalInterrupt(checkOnMethodStart=false)
@ConditionalInterrupt(applyToAllClasses=false)
class A { ... } // interrupt checks added
class B { ... } // no interrupt checks
class A {
    @ConditionalInterrupt(applyToAllMembers=false)
    void method1() { ... } // interrupt checked added
    void method2() { ... } // no interrupt checks
}
속성 기본값 설명 예시
value 실행이 허용되는지 확인하기 위해 호출될 클로저. 클로저가 false를 반환하면 실행이 허용됨. true를 반환하면 예외가 던져짐
thrown java.lang.InterruptedException 실행을 중단해야 할 때 던져지는 예외의 타입을 지정
checkOnMethodStart true 각 메서드 본문의 시작 부분에 중단 검사를 삽입할지. groovy.transform.ConditionalInterrupt 참고
applyToAllClasses true 같은 소스 유닛(같은 소스 파일)의 모든 클래스에 변환을 적용할지. groovy.transform.ConditionalInterrupt 참고
applyToAllMembers true 클래스의 모든 멤버에 변환을 적용할지. groovy.transform.ConditionalInterrupt 참고
2.1.7. 컴파일러 지시어 (Compiler directives)

이 범주의 AST 변환은 코드 생성에 초점을 맞추기보다 코드의 의미론에 직접적인 영향을 미치는 애노테이션을 묶어요. 그런 점에서 컴파일 타임이나 런타임에 프로그램의 동작을 바꾸는 컴파일러 지시어로 볼 수 있어요.

@groovy.transform.Field

@Field 애노테이션은 스크립트 맥락에서만 의미가 있고, 스크립트의 일반적인 스코프 오류를 해결하는 것을 목표로 해요. 예를 들어 다음 예시는 런타임에 실패할 거예요.

def x

String line() {
    "="*x
}

x=3
assert "===" == line()
x=5
assert "=====" == line()

던져지는 오류는 해석하기 어려울 수 있어요: groovy.lang.MissingPropertyException: No such property: x. 이유는 스크립트가 클래스로 컴파일되고 스크립트 본문 자체가 단일 run() 메서드로 컴파일되기 때문이에요. 스크립트에 정의된 메서드는 독립적이어서 위 코드는 이와 동등해요.

class MyScript extends Script {

    String line() {
        "="*x
    }

    public def run() {
        def x
        x=3
        assert "===" == line()
        x=5
        assert "=====" == line()
    }
}

그래서 def x는 사실상 line 메서드의 스코프 밖인 지역 변수로 해석돼요. @Field AST 변환은 변수의 스코프를 둘러싼 스크립트의 필드로 바꿔서 이것을 고치는 것을 목표로 해요.

@Field def x

String line() {
    "="*x
}

x=3
assert "===" == line()
x=5
assert "=====" == line()

결과적으로 동등한 코드는 이제 이렇죠.

class MyScript extends Script {

    def x

    String line() {
        "="*x
    }

    public def run() {
        x=3
        assert "===" == line()
        x=5
        assert "=====" == line()
    }
}
@groovy.transform.PackageScope

기본적으로 Groovy 가시성 규칙은 수식어를 지정하지 않고 필드를 만들면 그 필드가 프로퍼티로 해석됨을 의미해요.

class Person {
    String name // this is a property
}
}

프로퍼티 대신 패키지 프라이빗 필드(프라이빗 필드 + getter/setter)를 만들고 싶다면 필드를 @PackageScope로 애노테이트하면 돼요.

class Person {
    @PackageScope String name // not a property anymore
}

@PackageScope 애노테이션은 클래스, 메서드, 생성자에도 사용할 수 있어요. 또한 클래스 레벨에서 애노테이션 속성으로 PackageScopeTarget 값 목록을 지정하면, 그 클래스 안에서 명시적 수식어가 없고 제공된 PackageScopeTarget과 일치하는 모든 멤버가 패키지 보호 상태로 남아요. 예를 들어 클래스 안의 필드에 적용하려면 다음 애노테이션을 사용해요.

import static groovy.transform.PackageScopeTarget.FIELDS
@PackageScope(FIELDS)
class Person {
  String name     // not a property, package protected
  Date dob        // not a property, package protected
  private int age // explicit modifier, so won't be touched
}

@PackageScope 애노테이션은 일반적인 Groovy 관례의 일부로는 거의 사용되지 않지만, 패키지 내부에서 보여야 하는 팩토리 메서드나 테스트 목적으로 제공된 메서드·생성자, 또는 그런 가시성 관례를 요구하는 서드파티 라이브러리와 통합할 때 종종 유용해요.

@groovy.transform.Final

@Final은 본질적으로 final 수식어의 별칭이에요. 의도는 @Final 애노테이션을 거의 직접 사용하지 말라는 것이에요(final을 그냥 쓰세요). 다만 애노테이트되는 노드에 final 수식어를 적용해야 하는 메타 애노테이션을 만들 때 @Final을 섞어 쓸 수 있어요. 예:

@AnnotationCollector([Singleton,Final]) @interface MySingleton {}

@MySingleton
class GreetingService {
    String greeting(String name) { "Hello, $name!" }
}
assert GreetingService.instance.greeting('Bob') == 'Hello, Bob!'
assert Modifier.isFinal(GreetingService.modifiers)
@groovy.transform.AutoFinal

@AutoFinal 애노테이션은 애노테이트된 노드 안의 여러 곳에 final 수식어를 자동으로 삽입하도록 컴파일러에 지시해요. 메서드(또는 생성자)에 적용하면 그 메서드(또는 생성자)의 파라미터가 final로 표시돼요. 클래스 정의에 적용하면 그 클래스 안의 모든 선언된 메서드와 생성자에 대해 같은 처리가 일어나요. 메서드나 생성자의 파라미터를 본문 안에서 재할당하는 것은 종종 나쁜 습관으로 간주돼요. 모든 파라미터 선언에 final 수식어를 추가하면 이 습관을 완전히 피할 수 있어요. 어떤 프로그래머들은 어디에나 final을 추가하는 것이 보일러플레이트 코드의 양을 늘리고 메서드 시그니처를 다소 시끄럽게 만든다고 느껴요. 대안으로 코드 리뷰 프로세스를 사용하거나 codenarc rule을 적용해서 그 습관이 관찰되면 경고를 주는 방법이 있을 수 있어요. 하지만 이런 대안들은 IDE 안이나 컴파일 동안이 아니라 품질 검사 중에 지연된 피드백으로 이어질 수 있어요. @AutoFinal 애노테이션은 보일러플레이트 노이즈를 최소화하면서 간결한 코드를 유지하고 컴파일러/IDE 피드백을 극대화하는 것을 목표로 해요. 다음 예시는 클래스 레벨에서 애노테이션을 적용하는 것을 보여 줘요.

import groovy.transform.AutoFinal

@AutoFinal
class Person {
    private String first, last

    Person(String first, String last) {
        this.first = first
        this.last = last
    }

    String fullName(String separator) {
        "$first$separator$last"
    }

    String greeting(String salutation) {
        "$salutation, $first"
    }
}

이 예시에서 생성자의 두 파라미터와 fullname과 greeting 메서드 둘 다의 단일 파라미터가 final이 돼요. 생성자나 메서드 본문 안에서 그 파라미터를 수정하려는 시도는 컴파일러가 표시해 줄 거예요. 다음 예시는 메서드 레벨에서 애노테이션을 적용하는 것을 보여 줘요.

class Calc {
    @AutoFinal
    int add(int a, int b) { a + b }

    int mult(int a, int b) { a * b }
}

여기서 add 메서드는 final 파라미터를 가지지만 mult 메서드는 그대로 남아요.

@groovy.transform.AnnotationCollector

@AnnotationCollector는 전용 섹션에서 설명하는 메타 애노테이션 생성을 허용해요.

@groovy.transform.TypeChecked

@TypeChecked는 Groovy 코드에서 컴파일 타임 타입 검사를 활성화해요. 자세한 내용은 타입 검사 섹션을 참고하세요.

@groovy.transform.CompileStatic

@CompileStatic은 Groovy 코드에서 static 컴파일을 활성화해요. 자세한 내용은 타입 검사 섹션을 참고하세요.

@groovy.transform.CompileDynamic

@CompileDynamic은 Groovy 코드의 일부에서 static 컴파일을 비활성화해요. 자세한 내용은 타입 검사 섹션을 참고하세요.

@groovy.lang.DelegatesTo

@DelegatesTo는 기술적으로 말하면 AST 변환이 아니에요. 코드를 문서화하고 타입 검사static 컴파일을 사용하는 경우 컴파일러를 돕는 것이 목적이에요. 이 애노테이션은 이 가이드의 DSL 섹션에서 철저히 설명돼요.

@groovy.transform.SelfType

@SelfType은 AST 변환이 아니라 트레이트와 함께 사용하는 마커 인터페이스예요. 자세한 내용은 트레이트 문서를 참고하세요.

2.1.8. Swing 패턴 (Swing patterns)
@groovy.beans.Bindable

@Bindable은 일반 프로퍼티를 바운드 프로퍼티(bound property, JavaBeans 명세에 따름)로 변환하는 AST 변환이에요. @Bindable 애노테이션은 프로퍼티나 클래스에 둘 수 있어요. 클래스의 모든 프로퍼티를 바운드 프로퍼티로 변환하려면 다음 예시처럼 클래스를 애노테이트하면 돼요.

import groovy.beans.Bindable

@Bindable
class Person {
    String name
    int age
}

이것은 이렇게 쓰는 것과 동등해요.

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport

class Person {
    final private PropertyChangeSupport this$propertyChangeSupport

    String name
    int age

    public void addPropertyChangeListener(PropertyChangeListener listener) {
        this$propertyChangeSupport.addPropertyChangeListener(listener)
    }

    public void addPropertyChangeListener(String name, PropertyChangeListener listener) {
        this$propertyChangeSupport.addPropertyChangeListener(name, listener)
    }

    public void removePropertyChangeListener(PropertyChangeListener listener) {
        this$propertyChangeSupport.removePropertyChangeListener(listener)
    }

    public void removePropertyChangeListener(String name, PropertyChangeListener listener) {
        this$propertyChangeSupport.removePropertyChangeListener(name, listener)
    }

    public void firePropertyChange(String name, Object oldValue, Object newValue) {
        this$propertyChangeSupport.firePropertyChange(name, oldValue, newValue)
    }

    public PropertyChangeListener[] getPropertyChangeListeners() {
        return this$propertyChangeSupport.getPropertyChangeListeners()
    }

    public PropertyChangeListener[] getPropertyChangeListeners(String name) {
        return this$propertyChangeSupport.getPropertyChangeListeners(name)
    }
}

따라서 @Bindable은 클래스에서 많은 보일러플레이트를 제거해서 가독성을 극적으로 높여 줘요. 애노테이션을 단일 프로퍼티에 붙이면 그 프로퍼티만 바운드돼요.

import groovy.beans.Bindable

class Person {
    String name
    @Bindable int age
}
@groovy.beans.ListenerList

@ListenerList AST 변환은 컬렉션 프로퍼티를 애노테이트하기만 하면 클래스에 리스너를 추가·제거하고 그 목록을 가져오는 코드를 생성해요.

import java.awt.event.ActionListener
import groovy.beans.ListenerList

class Component {
    @ListenerList
    List<ActionListener> listeners;
}

변환은 목록의 제네릭 타입에 기반해 적절한 add/remove 메서드를 생성해요. 또한 클래스에 선언된 public 메서드에 기반해 fireXXX 메서드도 만들어요.

import java.awt.event.ActionEvent
import java.awt.event.ActionListener as ActionListener
import groovy.beans.ListenerList as ListenerList

public class Component {

    @ListenerList
    private List<ActionListener> listeners

    public void addActionListener(ActionListener listener) {
        if ( listener == null) {
            return
        }
        if ( listeners == null) {
            listeners = []
        }
        listeners.add(listener)
    }

    public void removeActionListener(ActionListener listener) {
        if ( listener == null) {
            return
        }
        if ( listeners == null) {
            listeners = []
        }
        listeners.remove(listener)
    }

    public ActionListener[] getActionListeners() {
        Object __result = []
        if ( listeners != null) {
            __result.addAll(listeners)
        }
        return (( __result ) as ActionListener[])
    }

    public void fireActionPerformed(ActionEvent param0) {
        if ( listeners != null) {
            ArrayList<ActionListener> __list = new ArrayList<ActionListener>(listeners)
            for (def listener : __list ) {
                listener.actionPerformed(param0)
            }
        }
    }
}

@ListenerList은 변환의 동작을 더 커스터마이즈하게 해 주는 여러 옵션을 지원해요.

class Component {
    @ListenerList(name='item')
    List<ActionListener> listeners;
}
class Component {
    @ListenerList(synchronize = true)
    List<ActionListener> listeners;
}
속성 기본값 설명 예시
name 제네릭 타입 이름 기본적으로 add/remove 등 메서드에 붙는 접미사는 목록의 제네릭 타입의 단순 클래스 이름
synchronize false true로 설정하면 생성된 메서드가 동기화됨
@groovy.beans.Vetoable

@Vetoable 애노테이션은 @Bindable과 비슷한 방식으로 동작하지만 바운드 프로퍼티 대신 JavaBeans 명세에 따른 제약 프로퍼티(constrained property)를 생성해요. 애노테이션은 클래스에 둘 수 있는데 그 경우 모든 프로퍼티가 제약 프로퍼티로 변환되고, 또는 단일 프로퍼티에 둘 수 있어요. 예를 들어 이 클래스를 @Vetoable로 애노테이트하면:

import groovy.beans.Vetoable

import java.beans.PropertyVetoException
import java.beans.VetoableChangeListener

@Vetoable
class Person {
    String name
    int age
}

이렇게 쓰는 것과 동등해요.

public class Person {

    private String name
    private int age
    final private java.beans.VetoableChangeSupport this$vetoableChangeSupport

    public void addVetoableChangeListener(VetoableChangeListener listener) {
        this$vetoableChangeSupport.addVetoableChangeListener(listener)
    }

    public void addVetoableChangeListener(String name, VetoableChangeListener listener) {
        this$vetoableChangeSupport.addVetoableChangeListener(name, listener)
    }

    public void removeVetoableChangeListener(VetoableChangeListener listener) {
        this$vetoableChangeSupport.removeVetoableChangeListener(listener)
    }

    public void removeVetoableChangeListener(String name, VetoableChangeListener listener) {
        this$vetoableChangeSupport.removeVetoableChangeListener(name, listener)
    }

    public void fireVetoableChange(String name, Object oldValue, Object newValue) throws PropertyVetoException {
        this$vetoableChangeSupport.fireVetoableChange(name, oldValue, newValue)
    }

    public VetoableChangeListener[] getVetoableChangeListeners() {
        return this$vetoableChangeSupport.getVetoableChangeListeners()
    }

    public VetoableChangeListener[] getVetoableChangeListeners(String name) {
        return this$vetoableChangeSupport.getVetoableChangeListeners(name)
    }

    public void setName(String value) throws PropertyVetoException {
        this.fireVetoableChange('name', name, value)
        name = value
    }

    public void setAge(int value) throws PropertyVetoException {
        this.fireVetoableChange('age', age, value)
        age = value
    }
}

애노테이션을 단일 프로퍼티에 붙이면 그 프로퍼티만 vetoable이 돼요.

import groovy.beans.Vetoable

class Person {
    String name
    @Vetoable int age
}
2.1.9. 테스트 지원 (Test assistance)
@groovy.test.NotYetImplemented

@NotYetImplemented는 JUnit 3/4 테스트 케이스의 결과를 반전하는 데 사용돼요. 특히 기능이 아직 구현되지 않았지만 테스트는 작성된 경우에 유용해요. 그 경우 테스트가 실패할 것으로 예상돼요. @NotYetImplemented로 표시하면 테스트의 결과가 반전돼요. 다음 예시를 볼게요.

import groovy.test.GroovyTestCase
import groovy.test.NotYetImplemented

class Maths {
    static int fib(int n) {
        // todo: implement later
    }
}

class MathsTest extends GroovyTestCase {
    @NotYetImplemented
    void testFib() {
        def dataTable = [
                1:1,
                2:1,
                3:2,
                4:3,
                5:5,
                6:8,
                7:13
        ]
        dataTable.each { i, r ->
            assert Maths.fib(i) == r
        }
    }
}

이 기법을 사용하는 또 다른 장점은 버그를 고치는 방법을 알기 전에 버그에 대한 테스트 케이스를 쓸 수 있다는 것이에요. 나중에 언젠가 코드 수정이 부수 효과로 버그를 고치면, 실패할 것으로 예상됐던 테스트가 통과했기 때문에 알림을 받게 돼요.

@groovy.transform.ASTTest

@ASTTest는 다른 AST 변환이나 Groovy 컴파일러 자체를 디버깅하는 데 도움을 주기 위한 특별한 AST 변환이에요. 개발자가 컴파일 동안 AST를 "탐험"하고, 컴파일 결과가 아니라 AST에 대해 assertion을 수행하게 해 줘요. 즉 이 AST 변환은 바이트코드가 생성되기 전에 AST에 접근하게 해 준다는 뜻이에요. @ASTTest는 애노테이트 가능한 어떤 노드에도 놓을 수 있고 두 파라미터를 요구해요.

  • phase: @ASTTest가 트리거될 단계를 설정. 테스트 코드는 이 단계의 끝에서 AST 트리에 대해 동작함.
  • value: 단계에 도달하면 애노테이트된 노드에서 실행될 코드

Tip: 컴파일 단계는 org.codehaus.groovy.control.CompilePhase 중 하나에서 선택해야 합니다. 다만 같은 애노테이션으로 노드를 두 번 애노테이트하는 것은 불가능하므로, 두 개의 서로 다른 컴파일 단계에서 같은 노드에 @ASTTest를 사용할 수 없습니다.

value는 애노테이트된 노드에 해당하는 특별한 변수 node와, 여기에서 다룰 헬퍼 lookup 메서드에 접근할 수 있는 클로저 표현식이에요. 예를 들어 클래스 노드를 이렇게 애노테이트할 수 있어요.

import groovy.transform.ASTTest
import org.codehaus.groovy.ast.ClassNode

@ASTTest(phase=CONVERSION, value={   (1)
    assert node instanceof ClassNode (2)
    assert node.name == 'Person'     (3)
})
class Person {
}
  • (1) CONVERSION 단계 후의 추상 구문 트리 상태를 확인해요.
  • (2) node는 @ASTTest로 애노테이트된 AST 노드를 가리켜요.
  • (3) 컴파일 타임에 assertion을 수행하는 데 사용할 수 있어요.

@ASTTest의 흥미로운 기능 하나는 assertion이 실패하면 컴파일이 실패한다는 것이에요. 이제 컴파일 타임에 AST 변환의 동작을 확인하고 싶다고 상상해 볼게요. 여기서는 @PackageScope를 사용할 거고, @PackageScope로 애노테이트된 프로퍼티가 패키지 프라이빗 필드가 되는지 확인하고 싶어요. 이를 위해 변환이 실행되는 단계를 알아야 하는데, org.codehaus.groovy.transform.PackageScopeASTTransformation에서 찾을 수 있어요: 바로 semantic analysis 단계예요. 그러면 테스트는 이렇게 쓸 수 있어요.

import groovy.transform.ASTTest
import groovy.transform.PackageScope

@ASTTest(phase=SEMANTIC_ANALYSIS, value={
    def nameNode = node.properties.find { it.name == 'name' }
    def ageNode = node.properties.find { it.name == 'age' }
    assert nameNode
    assert ageNode == null // shouldn't be a property anymore
    def ageField = node.getDeclaredField 'age'
    assert ageField.modifiers == 0
})
class Person {
    String name
    @PackageScope int age
}

@ASTTest 애노테이션은 문법이 허용하는 곳에만 놓을 수 있어요. 때로는 애노테이트할 수 없는 AST 노드의 내용을 테스트하고 싶을 수 있어요. 그 경우 @ASTTest는 특별한 토큰으로 표시된 AST 노드를 검색해 주는 편리한 lookup 메서드를 제공해요.

def list = lookup('anchor') (1)
Statement stmt = list[0] (2)
  • (1) label이 'anchor'인 AST 노드 목록을 반환해요.
  • (2) lookup은 항상 목록을 반환하므로 어떤 요소를 처리할지 항상 선택해야 해요.

예를 들어 for 루프 변수의 선언된 타입을 테스트하고 싶다고 상상해 보세요. 그러면 이렇게 할 수 있어요.

import groovy.transform.ASTTest
import groovy.transform.PackageScope
import org.codehaus.groovy.ast.ClassHelper
import org.codehaus.groovy.ast.expr.DeclarationExpression
import org.codehaus.groovy.ast.stmt.ForStatement

class Something {
    @ASTTest(phase=SEMANTIC_ANALYSIS, value={
        def forLoop = lookup('anchor')[0]
        assert forLoop instanceof ForStatement
        def decl = forLoop.collectionExpression.expressions[0]
        assert decl instanceof DeclarationExpression
        assert decl.variableExpression.name == 'i'
        assert decl.variableExpression.originType == ClassHelper.int_TYPE
    })
    void someMethod() {
        int x = 1;
        int y = 10;
        anchor: for (int i=0; i<x+y; i++) {
            println "$i"
        }
    }
}

@ASTTest는 테스트 클로저 안에서 이런 변수들도 노출해요.

  • node — 평소처럼 애노테이트된 노드에 해당
  • compilationUnit — 현재 org.codehaus.groovy.control.CompilationUnit에 접근
  • compilePhase — 현재 컴파일 단계(org.codehaus.groovy.control.CompilePhase)를 반환

후자는 phase 속성을 지정하지 않을 때 흥미로워요. 그 경우 클로저는 SEMANTIC_ANALYSIS 이후(그것을 포함해) 각 컴파일 단계 후에 실행돼요. 변환의 컨텍스트는 각 단계 후에 유지되어, 두 단계 사이에 무엇이 바뀌었는지 확인할 기회를 줘요. 예시로, 클래스 노드에 등록된 AST 변환 목록을 덤프하는 방법이 여기 있어요.

import groovy.transform.ASTTest
import groovy.transform.CompileStatic
import groovy.transform.Immutable
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.control.CompilePhase

@ASTTest(value={
    System.err.println "Compile phase: $compilePhase"
    ClassNode cn = node
    System.err.println "Global AST xforms: ${compilationUnit?.ASTTransformationsContext?.globalTransformNames}"
    CompilePhase.values().each {
        def transforms = cn.getTransforms(it)
        if (transforms) {
            System.err.println "Ast xforms for phase $it:"
            transforms.each { map ->
                System.err.println(map)
            }
        }
    }
})
@CompileStatic
@Immutable
class Foo {
}

그리고 두 단계 사이에 테스트할 변수를 기억하는 방법이 여기 있어요.

import groovy.transform.ASTTest
import groovy.transform.ToString
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.control.CompilePhase

@ASTTest(value={
    if (compilePhase == CompilePhase.INSTRUCTION_SELECTION) {           (1)
        println "toString() was added at phase: ${added}"
        assert added == CompilePhase.CANONICALIZATION                   (2)
    } else {
        if (node.getDeclaredMethods('toString') && added == null) {     (3)
            added = compilePhase                                        (4)
        }
    }
})
@ToString
class Foo {
    String name
}
  • (1) 현재 컴파일 단계가 instruction selection이면
  • (2) toString이 CANONICALIZATION에서 추가됐는지 확인하고 싶어요.
  • (3) 그렇지 않으면 toString이 존재하고 컨텍스트의 변수인 added가 null이면
  • (4) 이 컴파일 단계가 toString이 추가된 단계라는 뜻이에요.
2.1.10. Grape 처리 (Grape handling)
@groovy.lang.Grab
@groovy.lang.GrabConfig
@groovy.lang.GrabExclude
@groovy.lang.GrabResolver
@groovy.lang.Grapes

Grape는 Groovy에 내장된 의존성 관리 엔진으로, 이 가이드의 섹션에서 철저히 설명되는 여러 애노테이션에 의존해요.

3. AST 변환 개발하기 (Developing AST transformations)

이 절에서는 Groovy로 AST 변환을 개발하는 방법을 차근차근 다뤄볼게요. AST 변환은 소스 코드의 구문 트리(AST, Abstract Syntax Tree)를 컴파일 중에 바꾸는 메커니즘이라서 "컴파일 타임 메타프로그래밍"의 핵심이에요. 다음 소스(문서의 핵심 예제)를 두고, 이 소스가 AST 변환을 거쳐 어떤 모습으로 변하는지 알아보면서 진행할게요.

@WithLogging
def greet() {
    println 'Hello'
}

greet()

여기서 @WithLogging이 바로 우리가 만들 AST 변환이에요. 이 변환은 메서드의 시작과 끝에서 로그를 출력하도록 메서드 본문을 감싸줘요. 그래서 변환 후의 코드는 대략 이렇게 보이게 돼요.

def greet() {
    println "LOG"
    println 'Hello'
    println "LOG"
}

greet()를 호출하면 콘솔에 이렇게 찍혀요.

LOG
Hello
LOG

이제 이 AST 변환을 직접 구현해볼게요. 이 변환을 세 가지 방식으로 구현하는 방법을 보여줄 거예요.

3.1 AST 변환을 만드는 여러 방법

로컬 AST 변환 (Local transformations)

먼저 가장 간단한 로컬 AST 변환(Local AST transformation)을 만들어볼게요. 그 이름처럼 로컬 변환은 "로컬"에 적용되는데, 즉 단일 클래스나 메서드 같은 특정 위치에만 적용되는 변환이에요. 이건 우리가 방금 본 @WithLogging처럼 소스의 한 곳에서 @... 애노테이션을 통해 적용돼요.

이 변환을 구현하려면 두 가지를 만들어야 해요.

  • 전역 AST 변환을 정의할 때는 컴파일러가 변환을 찾도록 @GroovyASTTransformation 애노테이션을 붙여야 해요. 반면 로컬 AST 변환은 해당 설정이 필요 없어요. 대신 로컬 변환은 애노테이션 클래스에 붙어서 적용돼요.

  • 전역 변환은 CompilePhase를 지정해야 해요. 로컬 변환은 그럴 필요 없이 항상 CompilePhase.SEMANTIC_ANALYSIS에서 실행돼요. (컴파일 단계에 대한 더 자세한 내용은 컴파일 파이프라인Global transformations를 참고해요.)

로컬 AST 변환의 동작을 직접 구현한 WithLogger 예제를 볼게요. ASTTransformation 인터페이스의 visit(ASTNode[], SourceUnit) 메서드를 구현하면 돼요.

@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.METHOD])
@interface WithLogging {
}

@GroovyASTTransformationClass(classes = 'LoggingASTTransformation')
class LoggingASTTransformation implements ASTTransformation {

    @Override
    void visit(ASTNode[] nodes, SourceUnit sourceUnit) {
        def method = nodes[1]
        def initCode = new ast.BlockStatement()
        def startMessage = createPrintlnAst("Start $method.name")
        def endMessage = createPrintlnAst("End $method.name")
        initCode.addStatement(startMessage)
        def existingStatements = method.code
        initCode.addStatement(existingStatements)
        initCode.addStatement(endMessage)
        method.code = initCode
    }
}

사용법은 다음과 같아요.

@WithLogging
def greet() {
    println 'Hello'
}

greet()

실행 결과는 이런 식이에요.

Start greet
Hello
End greet

@WithLogging이 로컬 AST 변환이라는 걸 어떻게 알 수 있을까요? 비밀은 @GroovyASTTransformationClass 애노테이션에 있어요. 이 애노테이션은 어떤 변환 클래스가 이 애노테이션을 처리하는지 지정해요. 이 애노테이션을 붙이면 Groovy 컴파일러가 @WithLogging을 발견했을 때 그에 해당하는 LoggingASTTransformation(@GroovyASTTransformationClass(classes = 'LoggingASTTransformation'))를 찾아서 적용해요.

visit에서 일어나는 일을 하나씩 볼게요.

  • nodes 매개변수는 AST 노드 배열이에요. 로컬 변환에서는 nodes[0]이 애노테이션(WithLogging)을 나타내고 nodes[1]이 애노테이션이 붙은 요소(여기서는 메서드 greet)를 나타내요.

  • sourceUnit 매개변수는 소스 파일 정보(SourceUnit)를 담고 있어요.

  • method에 메서드 노드(MethodNode)가 들어와요. method.name으로 메서드 이름을 얻을 수 있고, method.code로 메서드 본문(Statement)을 얻을 수 있어요.

  • createPrintlnAst(...) 헬퍼는 AST에 추가할 println 문을 만들어요. 메서드 시작/끝 로그를 만들어서 initCode라는 새 BlockStatement에 넣고, 기존 본문을 그 사이에 끼워 넣어요.

이제 createPrintlnAst 같은 헬퍼를 실제로 만들려면 AST 노드를 직접 구성해야 해요. org.codehaus.groovy.ast.builder.AstBuilder를 쓰면 비교적 짧게 만들 수 있어요.

import org.codehaus.groovy.ast.builder.AstBuilder

def createPrintlnAst(String message) {
    new AstBuilder().buildFromSpec {
        expression {
            methodCall {
                variable 'this'
                constant 'println'
                argumentList {
                    constant message
                }
            }
        }
    }[0]
}

buildFromSpec은 람다/클로저 안에서 선언적인 "사양(spec)" 스타일로 AST를 만들어요. 여기서는 println message를 호출하는 expression을 만들었어요. 이렇게 만들어진 코드는 @WithLogging을 붙인 메서드가 호출될 때마다 시작과 끝 로그를 출력하게 해 줘요.

AST 변환 클래스 작성법

AST 변환을 구현할 때 알아 두면 좋은 몇 가지 팁이 있어요.

소스 위치 정보가 소실되지 않게 하라

컴파일러는 AST를 ANS(Abstract Syntax Tree)로 다루다 보니, 변환 중에 소스 위치 정보가 유실되면 런타임 스택 트레이스나 디버깅이 불편해져요. 그래서 AST를 조작할 때는 원래 노드의 라인/컬럼 정보를 유지하는 습관이 좋아요. 예를 들어 StatementsetSourcePosition(originalStatement) 같은 메서드로 위치 정보를 복사해둘 수 있어요.

컬렉션을 직접 수정하지 마라

AST의 컬렉션(예: statement, methodCall)을 직접 늘리거나 줄이면 예기치 않은 문제가 생길 수 있어요. 되도록 addStatement, setCode 같은 Groovy가 제공하는 헬퍼 API를 통해서 수정하는 게 안전해요.

성능 고려

AST 변환은 매 빌드마다 실행되므로 성능도 신경 써야 해요. 복잡한 계산은 캐시하고, 불필요한 노드 탐색은 피하는 게 좋아요.

3.2 전역 AST 변환 (Global transformations)

전역 AST 변환(Global AST transformation)은 소스의 임의의 위치에 적용되는 변환이 아니라, 전역적으로 모든 컴파일 유닛에 적용되는 변환이에요. 예를 들어 특정 패키지의 모든 클래스를 자동으로 처리하고 싶을 때 전역 변환이 유용해요.

전역 변환은 META-INF/services 에 등록해야 해요. 구체적으로는 META-INF/services/org.codehaus.groovy.transform.ASTTransformation이라는 파일에 변환 클래스의 정규화된 이름(FQCN)을 한 줄씩 적어야 컴파일러가 찾을 수 있어요.

예를 들어 다음처럼 변환을 구현했다고 해볼게요.

@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class ClassLogger implements ASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit sourceUnit) {
        def classes = sourceUnit.AST?.classes
        classes?.each { it.addMethod(...) }
    }
}

이 클래스를 META-INF/services/org.codehaus.groovy.transform.ASTTransformation 파일에 아래처럼 등록하면, 이후 컴파일되는 모든 클래스가 이 변환을 거치게 돼요.

ClassLogger

전역 변환은 @GroovyASTTransformation(phase = ...)실행 컴파일 단계를 반드시 지정해야 해요. CompilePhase의 각 단계는 다음과 같아요.

단계 설명
INITIALIZATION 소스에서 AST 초기화
PARSING 구문 분석
CONVERSION AST 변환 초기 단계
SEMANTIC_ANALYSIS 의미 분석
CANONICALIZATION 정규화
INSTRUCTION_SELECTION 명령 선택
CLASS_GENERATION 클래스 생성
OUTPUT 산출물 생성
FINALIZATION 마무리

3.3 @GroovyASTTransformationClass와 로컬 변환의 실제 동작

앞서 로컬 변환 예제에서 @GroovyASTTransformationClass(classes = 'LoggingASTTransformation')를 봤죠. 이 애노테이션은 변환 클래스를 지연(lazy) 참조하는 방식이에요. 즉 컴파일러는 해당 클래스를 그 시점에 로딩하고, visit을 호출해요. .classes 대신 .value를 써도 되고, 클래스 패스에 있는 클래스 이름의 배열을 여러 개 줄 수도 있어요.

@GroovyASTTransformationClass(['LoggingASTTransformation'])
@interface WithLogging {}

이 밖에도 변환을 직접 클래스 리터럴로 참조하는 방식(classes = LoggingASTTransformation)도 가능해요.

3.4 AST 변환과 컴파일 커스터마이저 (Compilation customizers)

AST 변환은 스크립트나 클래스를 컴파일할 때 CompilationCustomizer를 통해서도 적용할 수 있어요. 예를 들어 CompileStaticCustomizer는 스크립트 전체를 정적 컴파일로 바꾸는 대표적인 커스터마이저예요.

import org.codehaus.groovy.control.customizers.CompileStaticCustomizer

def configuration = new CompilerConfiguration()
configuration.addCompilationCustomizers(new CompileStaticCustomizer())

CompilerConfiguration에 커스터마이저를 추가하면 그 설정으로 컴파일되는 모든 코드에 @CompileStatic을 붙인 것과 같은 효과를 낼 수 있어요. (@CompileStatic과 정적 컴파일은 [컴파일 타임 메타프로그래밍] 문서의 관련 절에서 자세히 다뤄요.)

3.5 AST 변환 작성 시 알아야 할 AST 노드 핵심 타입

AST를 직접 다룰 때 자주 만나는 핵심 타입들을 정리해볼게요. 이들은 org.codehaus.groovy.ast 패키지에 있어요.

타입 역할
ASTNode 모든 AST 노드의 공통 부모
ModuleNode 소스 파일(모듈) 전체를 나타냄
ClassNode 클래스/인터페이스/애노테이션을 나타냄
MethodNode 메서드/생성자를 나타냄
FieldNode 필드를 나타냄
PropertyNode 프로퍼티를 나타냄
ConstructorNode 생성자를 나타냄
BlockStatement 명령문 블록(여러 statement)
ExpressionStatement 표현식 하나를 문으로 감싼 것
MethodCallExpression 메서드 호출
PropertyExpression 프로퍼티 접근
ConstantExpression 리터럴 상수
VariableExpression 변수 참조
ReturnStatement return 문
IfStatement if 문
BinaryExpression 이항 연산 식
ClosureExpression 클로저 식
DeclarationExpression 변수 선언 식

3.6 AST 변환 예시: 로그를 붙이는 트리 변환

이제 실제로 트리를 직접 순회하며 변환하는 조금 더 실전적인 예시를 볼게요. @WithLogging이 클래스 안의 메서드 각각에 자동으로 출·입 로그를 걸어주는 변환을 구현해볼게요. 전역 변환으로 만들면 모든 클래스에 일괄 적용할 수 있어요.

@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class GlobalLogging implements ASTTransformation {

    void visit(ASTNode[] nodes, SourceUnit sourceUnit) {
        sourceUnit.AST.classes.each { ClassNode clazz ->
            clazz.methods.each { MethodNode method ->
                if (!method.isSynthetic() && method.name != 'main') {
                    def body = method.code
                    def newBody = new ast.BlockStatement(
                        [createLog('<< ' + method.name)],
                        new VariableScope())
                    newBody.addStatement(body)
                    newBody.addStatement(createLog('>> ' + method.name))
                    method.code = newBody
                }
            }
        }
    }

    private static Statement createLog(String message) {
        new ast.ExpressionStatement(
            new ast.MethodCallExpression(
                new ast.VariableExpression('this'),
                new ast.ConstantExpression('println'),
                new ast.ArgumentListExpression(
                    new ast.ConstantExpression(message))))
    }
}

이 예시에서 주목할 점은, method.code를 새 BlockStatement로 통째로 교체하면서 기존 본문을 그대로 안에 넣어 보존한다는 거예요. 이렇게 하면 원래 로직은 유지되고 앞뒤로 로그만 추가돼요. isSynthetic()은 컴파일러가 만든 자동 생성 메서드(예: getter/setter)를 제외하려는 의도예요.

3.7 AST 변환과 성능, 그리고 안정성

AST 변환은 강력하지만, 실제 프로젝트(특히 대규모 코드베이스)에서는 주의해서 써야 해요.

  • 빌드 시간이 늘어난다: 모든 클래스가 변환을 통과하므로 큰 프로젝트라면 컴파일 시간에 영향을 줄 수 있어요.
  • 디버깅이 어려워진다: 생성된 코드는 개발자가 작성하지 않은 코드라 스택 트레이스 해석이 까다로워질 수 있어요. 소스 위치 정보를 보존하는 게 중요해요.
  • 호환성: Groovy 버전이 올라가면서 AST 노드 API가 바뀌면 수정이 필요할 수 있어요. 가능하면 안정된 공개 API를 쓰는 게 좋아요.
  • 대안: 모든 작업이 AST 변환일 필요는 없어요. @Delegate, @Mixin, 메타클래스, invokeMethod 같은 런타임 기능으로 해결 가능한 일이면 그쪽이 훨씬 단순하고 안전할 수 있어요.

더 알아보기