Groovy 객체 지향(Object Orientation)
Groovy 객체 지향(Object Orientation)
이 장에서는 Groovy 프로그래밍 언어의 객체 지향 개념에 대해 다뤄요.
출처: Groovy 공식 문서
본문
1. 타입
1.1. 기본형(primitive) 타입
Groovy는 Java 언어 명세에서 정의한 것과 같은 기본형 타입을 지원해요.
- 정수형 타입: byte (8비트), short (16비트), int (32비트), long (64비트)
- 부동소수점 타입: float (32비트), double (64비트)
- boolean 타입 (true 또는 false 중 하나)
- char 타입 (16비트, 숫자 타입으로 사용 가능하며 UTF-16 코드를 나타내요)
Java와 마찬가지로, Groovy는 어떤 기본형 타입에 대응하는 객체가 필요할 때 각각의 래퍼 클래스를 사용해요.
| 기본형 타입 | 래퍼 클래스 |
|---|---|
| boolean | Boolean |
| char | Character |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
예를 들어 래퍼 클래스를 요구하는 메서드를 호출하면서 매개변수로 기본형 변수를 넘기거나, 그 반대의 경우에 자동 박싱(automatic boxing)과 언박싱(unboxing)이 일어나요. Java와 비슷하지만 Groovy는 그 아이디어를 더 발전시켜요.
대부분의 시나리오에서 기본형을 완전한 객체 래퍼처럼 취급할 수 있어요. 예를 들어 기본형에 .toString()이나 .equals(other)을 호출할 수 있죠. Groovy는 참조와 기본형 사이를 필요에 따라 자동으로 감싸고 풀어요.
클래스의 정적 필드로 선언된 int를 사용하는 예시를 볼게요(정적 필드는 곧 다룹니다).
class Foo {
static int i
}
assert Foo.class.getDeclaredField('i').type == int.class (1)
assert Foo.i.class != int.class && Foo.i.class == Integer.class (2)
(1) — 기본형 타입이 바이트코드에서 존중돼요
(2) — 런타임에 필드를 보면 자동으로 래핑되었음을 알 수 있어요
이제 혹시 기본형에 대한 참조로 수학 연산자를 쓸 때마다 언박싱·리박싱 비용을 치르게 되나 걱정할 수도 있어요. 하지만 그렇지 않아요. Groovy가 여러분의 연산자를 그 대응 메서드로 컴파일해서 그것들을 사용하기 때문이죠. 게다가 Groovy는 기본형 매개변수를 받는 Java 메서드를 호출할 때 자동으로 기본형으로 언박싱하고, Java의 기본형 메서드 반환 값은 자동으로 박싱해요. 다만 Java의 메서드 해석과는 약간 다른 점이 있다는 것만 알아두세요.
1.2. 참조 타입
기본형을 제외한 모든 것은 객체이며, 그 타입을 정의하는 연관 클래스가 있어요. 클래스와, 인터페이스·트레이트·레코드 같은 클래스 관련 또는 클래스 유사한 것들은 곧 다룰게요.
String과 List 타입의 두 변수를 다음과 같이 선언할 수 있어요.
String movie = 'The Matrix'
List actors = ['Keanu Reeves', 'Hugo Weaving']
1.3. 제네릭스
Groovy는 제네릭스와 관련해 Java와 같은 개념을 이어받아요. 클래스와 메서드를 정의할 때 타입 매개변수를 사용해 제네릭 클래스, 인터페이스, 메서드 또는 생성자를 만들 수 있어요.
Java에서든 Groovy에서든 정의됐든 간에, 제네릭 클래스와 메서드를 사용할 때는 타입 인자를 제공해야 할 수 있어요.
"문자열의 리스트" 타입의 변수를 다음과 같이 선언할 수 있어요.
List<String> roles = ['Trinity', 'Morpheus']
Java는 이전 버전 Java와의 역호환성을 위해 타입 이레이저(type erasure)를 사용해요. 동적 Groovy는 타입 이레이저를 더 공격적으로 적용하는 것으로 생각할 수 있어요. 일반적으로 컴파일 타임에 검사되는 제네릭스 타입 정보는 더 적어요. Groovy의 정적 특성은 제네릭스 정보에 대해 Java와 비슷한 검사를 적용합니다.
2. 클래스
Groovy 클래스는 Java 클래스와 매우 비슷하며, JVM 수준에서 Java 클래스와 호환돼요. 클래스는 메서드와 필드, 프로퍼티(JavaBeans 프로퍼티라고 생각하면 되는데 상용구가 훨씬 적어요)를 가질 수 있어요. 클래스와 클래스 멤버는 Java와 같은 수식어(public, protected, private, static 등)를 가질 수 있는데, 소스 수준에서 곧 설명할 약간의 차이가 있어요.
Groovy 클래스와 Java 클래스의 핵심 차이는 다음과 같아요.
- 가시성 수식어가 없는 클래스나 메서드는 자동으로 public이 돼요(패키지 비공개 가시성을 위해 특별한 애노테이션을 사용할 수 있어요)
- 가시성 수식어가 없는 필드는 자동으로 프로퍼티가 돼요. 그래서 명시적 getter·setter 메서드가 필요 없어 코드가 덜 장황해져요. 이 부분은 필드와 프로퍼티 섹션에서 더 다룰게요
- 클래스는 소스 파일 정의와 같은 기본 이름을 가질 필요는 없지만, 대부분의 시나리오에서 그렇게 하길 권장해요(다음 스크립트에 대한 점도 참고하세요)
- 하나의 소스 파일에는 하나 이상의 클래스가 들어갈 수 있어요(하지만 파일에 클래스가 아닌 코드가 있으면 그건 스크립트로 간주됩니다). 스크립트는 단지 몇 가지 특별한 관례를 가진 클래스이며 소스 파일과 같은 이름을 가져요(그래서 스크립트 안에 스크립트 소스 파일과 같은 이름의 클래스 정의를 넣지 마세요)
다음 코드는 예시 클래스를 보여줘요.
class Person { (1)
String name (2)
Integer age
def increaseAge(Integer years) { (3)
this.age += years
}
}
(1) — Person이라는 이름의 클래스 시작
(2) — name이라는 문자열 필드이자 프로퍼티
(3) — 메서드 정의
2.1. 일반 클래스
일반 클래스는 최상위이면서 구상(concrete)인 클래스를 말해요. 즉 다른 어떤 클래스나 스크립트에서도 제약 없이 인스턴스화할 수 있다는 뜻이에요. 이런 식으로 얘네는 public일 수밖에 없어요(public 키워드를 생략할 수는 있지만). 클래스는 다음 스니펫처럼 new 키워드로 생성자를 호출해 인스턴스화됩니다.
def p = new Person()
2.2. 내부 클래스
내부 클래스는 다른 클래스 안에 정의돼요. 둘러싼 클래스는 평소처럼 내부 클래스를 사용할 수 있어요. 반대로 내부 클래스는 둘러싼 클래스의 멤버에, 그것이 비공개여도 접근할 수 있어요. 둘러싼 클래스가 아닌 다른 클래스는 내부 클래스에 접근할 수 없어요. 예시를 볼게요.
class Outer {
private String privateStr
def callInnerMethod() {
new Inner().methodA() (1)
}
class Inner { (2)
def methodA() {
println "${privateStr}." (3)
}
}
}
(1) — 내부 클래스가 인스턴스화되고 그 메서드가 호출돼요
(2) — 둘러싼 클래스 안의 내부 클래스 정의
(3) — 비공개여도 둘러싼 클래스의 필드가 내부 클래스에 의해 접근돼요
내부 클래스를 쓰는 이유는 몇 가지가 있어요.
- 내부 클래스를 알 필요가 없는 다른 클래스로부터 숨겨서 캡슐화를 높여요. 이는 더 깨끗한 패키지와 워크스페이스로 이어져요
- 한 클래스만 사용하는 클래스들을 그룹화해 좋은 구성을 제공해요
- 내부 클래스가 그것을 사용하는 클래스 근처에 있으므로 더 유지보수하기 쉬운 코드로 이어져요
내부 클래스는 흔히 바깥 클래스가 필요한 메서드를 가진 어떤 인터페이스의 구현이에요. 아래 코드는 이런 전형적인 사용 패턴을 보여주는데, 여기서는 스레드와 함께 쓰고 있어요.
class Outer2 {
private String privateStr = 'some string'
def startThread() {
new Thread(new Inner2()).start()
}
class Inner2 implements Runnable {
void run() {
println "${privateStr}."
}
}
}
Inner2 클래스는 Outer2 클래스에 run 메서드의 구현을 제공하기 위해서만 정의됐다는 점을 유의하세요. 익명 내부 클래스는 이 경우 장황함을 없애 주는데, 곧 다룰게요.
Groovy 3+는 비정적 내부 클래스 인스턴스화를 위한 Java 문법도 지원해요. 예를 들어:
class Computer {
class Cpu {
int coreNumber
Cpu(int coreNumber) {
this.coreNumber = coreNumber
}
}
}
assert 4 == new Computer().new Cpu(4).coreNumber
2.2.1. 익명 내부 클래스
앞선 내부 클래스 예시(Inner2)는 익명 내부 클래스로 단순화할 수 있어요. 같은 기능을 다음 코드로 얻을 수 있어요.
class Outer3 {
private String privateStr = 'some string'
def startThread() {
new Thread(new Runnable() { (1)
void run() {
println "${privateStr}."
}
}).start() (2)
}
}
(1) — 이전 섹션의 마지막 예시와 비교하면, new Inner2()가 구현 전체와 함께 new Runnable()로 바뀌었어요
(2) — start 메서드는 정상적으로 호출돼요
따라서 한 번만 쓰는 새 클래스를 정의할 필요가 없어졌죠.
2.2.2. 추상 클래스
추상 클래스는 일반적인 개념을 나타내므로 인스턴스화할 수 없고, 서브클래싱되기 위해 만들어져요. 그 멤버에는 필드/프로퍼티와 추상 또는 구상 메서드가 포함됩니다. 추상 메서드는 구현이 없으며 구상 서브클래스가 구현해야 해요.
abstract class Abstract { (1)
String name
abstract def abstractMethod() (2)
def concreteMethod() {
println 'concrete'
}
}
(1) — 추상 클래스는 abstract 키워드로 선언해야 해요
(2) — 추상 메서드도 abstract 키워드로 선언해야 해요
추상 클래스는 흔히 인터페이스와 비교됩니다. 둘 중 하나를 고를 때 적어도 두 가지 중요한 차이가 있어요. 첫째, 추상 클래스는 필드/프로퍼티와 구상 메서드를 포함할 수 있지만 인터페이스는 추상 메서드(메서드 시그니처)만 포함할 수 있어요. 게다가 하나의 클래스는 여러 인터페이스를 구현할 수 있지만, 추상이든 아니든 클래스는 하나만 확장할 수 있어요.
2.3. 상속
Groovy의 상속은 Java의 상속과 비슷해요. 자식 클래스(또는 서브클래스)가 부모(또는 수퍼클래스)에게서 코드나 프로퍼티를 재사용할 수 있는 메커니즘을 제공해요. 상속으로 연결된 클래스들은 상속 계층 구조를 형성해요. 공통 동작과 멤버는 중복을 줄이기 위해 계층 위로 올려 보내지고, 특수화는 자식 클래스에서 일어나요.
다양한 형태의 상속이 지원됩니다.
- 구현 상속(implementation inheritance): 수퍼클래스나 하나 이상의 트레이트에서 코드(메서드, 필드, 프로퍼티)를 자식 클래스가 재사용하는 것
- 계약 상속(contract inheritance): 클래스가 수퍼클래스, 또는 하나 이상의 트레이트나 인터페이스에 정의된 특정 추상 메서드를 제공하겠다고 약속하는 것
2.4. 수퍼클래스
부모 클래스는 보이는 필드, 프로퍼티, 메서드를 자식 클래스와 공유해요. 자식 클래스는 최대 하나의 부모 클래스를 가질 수 있어요. extends 키워드는 수퍼클래스 타입을 주기 직전에 사용합니다.
2.5. 인터페이스
인터페이스는 클래스가 지켜야 할 계약을 정의해요. 보통 인터페이스는 0개 이상의 추상 메서드 정의를 포함하지만, 메서드의 구현은 정의하지 않아요.
다음은 greet 메서드 하나를 정의하는 Greeter 인터페이스예요.
interface Greeter { (1)
void greet(String name) (2)
}
(1) — 인터페이스는 interface 키워드로 선언해야 해요
(2) — greet 메서드의 추상 메서드 시그니처
이런 메서드 시그니처는 기본적으로 public이에요. 인터페이스에서 protected나 패키지 비공개 메서드를 쓰는 건 오류입니다.
interface Greeter {
protected void greet(String name) (1)
}
(1) — protected를 사용하는 건 컴파일 타임 오류예요
클래스가 그 implements 목록에 인터페이스를 정의하거나, 그 수퍼클래스 중 하나가 그렇게 하면 그 클래스는 인터페이스를 구현합니다.
class SystemGreeter implements Greeter { (1)
void greet(String name) { (2)
println "Hello $name"
}
}
def greeter = new SystemGreeter()
assert greeter instanceof Greeter (3)
(1) — SystemGreeter는 implements 키워드로 Greeter 인터페이스를 선언해요
(2) — 그런 다음 요구되는 greet 메서드를 구현해요
(3) — SystemGreeter의 어떤 인스턴스든 Greeter 인터페이스의 인스턴스이기도 해요
인터페이스는 다른 인터페이스를 확장할 수 있어요.
interface ExtendedGreeter extends Greeter { (1)
void sayBye(String name)
}
(1) — ExtendedGreeter 인터페이스는 extends 키워드로 Greeter 인터페이스를 확장해요
클래스가 인터페이스의 인스턴스가 되려면 명시적이어야 한다는 점을 알아두는 게 좋아요. 예를 들어 다음 클래스는 Greeter 인터페이스에 선언된 대로 greet 메서드를 정의하지만, 인터페이스 목록에 Greeter를 선언하지는 않아요.
class DefaultGreeter {
void greet(String name) { println "Hello" }
}
greeter = new DefaultGreeter()
assert !(greeter instanceof Greeter)
즉 Groovy는 구조적 타이핑을 정의하지 않아요. 하지만 as 강제 변환 연산자를 사용해 객체의 인스턴스가 런타임에 인터페이스를 구현하게 할 수 있습니다.
greeter = new DefaultGreeter() (1)
coerced = greeter as Greeter (2)
assert coerced instanceof Greeter (3)
(1) — 인터페이스를 구현하지 않는 DefaultGreeter의 인스턴스를 만들어요
(2) — 인스턴스를 런타임에 Greeter로 강제 변환해요
(3) — 강제 변환된 인스턴스는 Greeter 인터페이스를 구현해요
여기서 두 개의 서로 다른 객체가 있다는 걸 볼 수 있어요. 하나는 인터페이스를 구현하지 않는 소스 객체인 DefaultGreeter 인스턴스이고, 다른 하나는 강제 변환된 객체에 위임하는 Greeter의 인스턴스예요.
Groovy 트레이트는 인터페이스에 가깝지만, 매뉴얼의 다른 곳에서 설명하는 몇 가지 중요한 기능을 더 지원해요. 인터페이스가 주는 것보다 더 많은 힘이 필요하다면 트레이트 사용을 고려해 보세요.
인터페이스는 보통 추상 메서드 정의를 포함하지만, 비추상 메서드도 가능합니다. 허용되는 변형은 default, static, private이에요.
- 기본(default) 메서드는 API 기능을 진화시키는 메커니즘을 제공해요. 기존 인터페이스에 새 기능을 추가하면서도 그 인터페이스의 이전 버전용으로 작성된 코드와의 바이너리 호환성을 유지할 수 있어요. 기본 메서드의 공유 기능은 비공개 메서드에 둘 수 있어요
- 정적(static) 메서드는 OO 계약에 영향을 주거나 오버라이드되지 않고 인터페이스 클래스에 직접 메서드를 연결하는 메커니즘을 제공해요. 별도의 유틸리티 클래스를 만들지 않고 팩토리나 유틸리티 메서드를 쓰는 데 사용할 수 있어요
3. 클래스 멤버
3.1. 생성자
생성자는 객체를 특정 상태로 초기화하는 데 사용되는 특별한 메서드예요. 일반 메서드처럼, 각 생성자가 고유한 타입 시그니처를 갖는다면 클래스가 둘 이상의 생성자를 선언할 수 있어요. 객체가 생성 중 어떤 매개변수도 요구하지 않으면 no-arg 생성자를 사용할 수 있어요. 생성자가 제공되지 않으면 빈 no-arg 생성자를 Groovy 컴파일러가 제공합니다.
Groovy는 두 가지 호출 스타일을 지원해요.
- 위치 매개변수(positional parameters)는 Java 생성자를 사용하는 방식과 비슷하게 사용돼요
- 명명된 매개변수(named parameters)는 생성자를 호출할 때 매개변수 이름을 지정할 수 있게 해 줘요
3.1.1. 위치 매개변수
위치 매개변수로 객체를 만들려면, 해당 클래스가 하나 이상의 생성자를 선언해야 해요. 생성자가 여러 개면 각각 고유한 타입 시그니처를 가져야 합니다. 생성자는 groovy.transform.TupleConstructor 애노테이션으로 추가할 수도 있어요.
보통 생성자를 하나 이상 선언하면, 그 생성자 중 하나가 호출되어야 클래스를 인스턴스화할 수 있어요. 이 경우 명명된 매개변수로는 클래스를 만들 수 없는 게 보통이라는 점을 알아두세요. Groovy는 클래스에 no-arg 생성자가 있거나 첫 번째(그리고 아마 유일한) 인자로 Map 인자를 받는 생성자가 제공되는 한 명명된 매개변수를 지원해요 — 자세한 내용은 다음 섹션을 참고하세요.
선언된 생성자를 사용하는 형태는 세 가지예요. 첫 번째는 new 키워드가 있는 일반적인 Java 방식이에요. 나머지는 리스트를 원하는 타입으로 강제 변환하는 것에 의존합니다. 이 경우 as 키워드로 강제 변환하거나, 변수를 정적으로 타이핑함으로써 강제 변환할 수 있어요.
class PersonConstructor {
String name
Integer age
PersonConstructor(name, age) { (1)
this.name = name
this.age = age
}
}
def person1 = new PersonConstructor('Marie', 1) (2)
def person2 = ['Marie', 2] as PersonConstructor (3)
PersonConstructor person3 = ['Marie', 3] (4)
(1) — 생성자 선언
(2) — 생성자 호출, 고전적인 Java 방식
(3) — as 키워드를 사용한 강제 변환으로 생성자 사용
(4) — 할당에서 강제 변환을 사용한 생성자 사용
3.1.2. 명명된 매개변수
생성자가 없거나(또는 no-arg 생성자라면) 맵(프로퍼티/값 쌍) 형태로 매개변수를 넘겨 객체를 만들 수 있어요. 이는 여러 매개변수 조합을 허용하고 싶을 때 유용해요. 그렇지 않으면 전통적인 위치 매개변수를 쓰려면 가능한 모든 생성자를 선언해야 할 거예요. 첫 번째(그리고 아마 유일한) 인자가 Map인 생성자를 갖는 것도 지원되는데, 그런 생성자는 groovy.transform.MapConstructor 애노테이션으로 추가할 수도 있어요.
class PersonWOConstructor { (1)
String name
Integer age
}
def person4 = new PersonWOConstructor() (2)
def person5 = new PersonWOConstructor(name: 'Marie') (3)
def person6 = new PersonWOConstructor(age: 1) (4)
def person7 = new PersonWOConstructor(name: 'Marie', age: 2) (5)
(1) — 선언된 생성자 없음
(2) — 인스턴스화에 매개변수 없음
(3) — 인스턴스화에 name 매개변수 제공
(4) — 인스턴스화에 age 매개변수 제공
(5) — 인스턴스화에 name과 age 매개변수 제공
다만 이 접근법은 생성자 호출자에게 더 많은 힘을 주는 대신, 이름과 값 타입을 정확히 맞추는 더 큰 책임을 호출자에게 부과한다는 점을 강조하고 싶어요. 따라서 더 큰 통제를 원한다면 위치 매개변수로 생성자를 선언하는 게 선호될 수 있어요.
참고.
- 위 예시는 생성자를 제공하지 않았지만, no-arg 생성자나 첫 인자가 Map인 생성자도 제공할 수 있어요(대개 그것이 유일한 인자예요)
- 생성자가 없거나(또는 no-arg 생성자라면) Groovy는 명명된 생성자 호출을 no-arg 생성자 호출 + 공급된 각 명명된 프로퍼티에 대한 setter 호출로 대체해요
- 첫 인자가 Map이면 Groovy는 모든 명명된 매개변수를(순서와 무관하게) 하나의 Map으로 합쳐 그 맵을 첫 매개변수로 공급해요. 프로퍼티가 final로 선언된 경우 좋은 접근법이 될 수 있어요(그 사후에 setter로 설정하는 대신 생성자에서 설정되므로)
- 위치 생성자뿐 아니라 no-arg 또는 Map 생성자도 함께 제공해 명명·위치 생성을 모두 지원할 수 있어요
- 첫 인자가 Map이지만 추가 위치 매개변수도 있는 생성자를 가져 혼합 생성을 지원할 수 있어요. 이 스타일은 주의해서 쓰세요
3.2. 메서드
Groovy 메서드는 다른 언어와 꽤 비슷해요. 몇 가지 특이점을 다음 하위 절에서 보여줄게요.
3.2.1. 메서드 정의
메서드는 반환 타입과 함께 정의하거나 def 키워드로 정의해 반환 타입을 비타입으로 만들 수 있어요. 메서드는 인자를 몇 개든 받을 수 있고, 그 타입이 명시적으로 선언되지 않아도 돼요. Java 수식어는 정상적으로 사용할 수 있고, 가시성 수식어를 제공하지 않으면 메서드는 public이에요.
Groovy 메서드는 항상 어떤 값을 반환해요. return 문이 제공되지 않으면, 마지막에 실행된 줄에서 평가된 값을 반환합니다. 예를 들어 다음 메서드들은 어느 것도 return 키워드를 사용하지 않는다는 점을 유의하세요.
def someMethod() { 'method called' } (1)
String anotherMethod() { 'another method called' } (2)
def thirdMethod(param1) { "$param1 passed" } (3)
static String fourthMethod(String param1) { "$param1 passed" } (4)
(1) — 반환 타입도 매개변수도 선언되지 않은 메서드
(2) — 명시적 반환 타입과 매개변수 없는 메서드
(3) — 타입이 정의되지 않은 매개변수를 가진 메서드
(4) — String 매개변수를 가진 정적 메서드
3.2.2. 명명된 매개변수
생성자처럼 일반 메서드도 명명된 매개변수로 호출할 수 있어요. 이를 지원하기 위해 메서드의 첫 인자가 Map이라는 관례가 사용됩니다. 메서드 본문에서 매개변수 값은 일반 맵처럼(map.key) 접근할 수 있어요. 메서드가 단일 Map 인자만 가지면, 공급되는 모든 매개변수는 명명되어야 해요.
def foo(Map args) { "${args.name}: ${args.age}" }
foo(name: 'Marie', age: 1)
명명된 매개변수와 위치 매개변수 섞기
명명된 매개변수는 위치 매개변수와 섞을 수 있어요. 동일한 관례가 적용되며, 이 경우 첫 인자로서의 Map 인자에 더해 해당 메서드는 필요에 따라 추가 위치 인자를 갖게 돼요. 메서드를 호출할 때 공급되는 위치 매개변수는 순서대로여야 합니다. 명명된 매개변수는 어느 위치에나 있을 수 있어요. 그것들은 맵으로 모여 자동으로 첫 매개변수로 공급돼요.
def foo(Map args, Integer number) { "${args.name}: ${args.age}, and the number is ${number}" }
foo(name: 'Marie', age: 1, 23) (1)
foo(23, name: 'Marie', age: 1) (2)
(1) — Integer 타입의 추가 number 인자를 가진 메서드 호출
(2) — 인자의 순서가 바뀐 메서드 호출
첫 인자로 Map이 없으면, 명명된 매개변수 대신 그 인자에 Map을 공급해야 해요. 그렇게 하지 않으면 groovy.lang.MissingMethodException이 나요.
def foo(Integer number, Map args) { "${args.name}: ${args.age}, and the number is ${number}" }
foo(name: 'Marie', age: 1, 23) (1)
(1) — 이 메서드 호출은 groovy.lang.MissingMethodException: No signature of method: foo() is applicable for argument types: (LinkedHashMap, Integer) values: [[name:Marie, age:1], 23]를 던져요. 명명된 인자 Map 매개변수가 첫 인자로 정의되지 않았기 때문이죠
위 예외는 명명된 인자를 명시적 Map 인자로 바꾸면 피할 수 있어요.
def foo(Integer number, Map args) { "${args.name}: ${args.age}, and the number is ${number}" }
foo(23, [name: 'Marie', age: 1]) (1)
(1) — 명명된 인자 대신 명시적 Map 인자를 사용하면 호출이 유효해져요
Groovy가 명명된 매개변수와 위치 매개변수를 섞는 것을 허용하긴 하지만, 불필요한 혼란을 부를 수 있어요. 명명된 인자와 위치 인자는 주의해서 섞으세요.
3.2.3. 기본 인자
기본 인자는 매개변수를 선택적으로 만들어요. 인자가 공급되지 않으면 메서드는 기본 값을 가정합니다.
def foo(String par1, Integer par2 = 1) { [name: par1, age: par2] }
assert foo('Marie').age == 1
매개변수는 오른쪽부터 빠지는데, 필수 매개변수는 절대 빠지지 않아요.
def baz(a = 'a', int b, c = 'c', boolean d, e = 'e') { "$a $b $c $d $e" }
assert baz(42, true) == 'a 42 c true e'
assert baz('A', 42, true) == 'A 42 c true e'
assert baz('A', 42, 'C', true) == 'A 42 C true e'
assert baz('A', 42, 'C', true, 'E') == 'A 42 C true E'
같은 규칙이 메서드뿐 아니라 생성자에도 적용돼요. @TupleConstructor를 사용하면 추가 설정 옵션이 적용됩니다.
3.2.4. 가변 인자(Varargs)
Groovy는 가변 개수의 인자를 가진 메서드를 지원해요. def foo(p1, …, pn, T… args)처럼 정의합니다. 여기서 foo는 기본적으로 n개의 인자를 지원하지만, n을 초과하는 추가 인자도 불특정 개수만큼 받아요.
def foo(Object... args) { args.length }
assert foo() == 0
assert foo(1) == 1
assert foo(1, 2) == 2
이 예시는 인자가 아예 없어도 어떤 개수의 인자든 받을 수 있는 foo 메서드를 정의해요. args.length는 주어진 인자의 수를 반환해요. Groovy는 T…의 대체 표기로 T[]를 허용합니다. 즉 마지막 매개변수가 배열인 어떤 메서드든 Groovy는 가변 개수의 인자를 받는 메서드로 봐요.
def foo(Object[] args) { args.length }
assert foo() == 0
assert foo(1) == 1
assert foo(1, 2) == 2
가변 인자 메서드가 null을 가변 인자 매개변수로 호출되면, 인자는 null이지 null을 유일한 요소로 하는 길이 1의 배열이 아니에요.
def foo(Object... args) { args }
assert foo(null) == null
가변 인자 메서드가 배열을 인자로 호출되면, 인자는 주어진 배열을 유일한 요소로 하는 길이 1의 배열이 아니라 그 배열 자체예요.
def foo(Object... args) { args }
Integer[] ints = [1, 2]
assert foo(ints) == [1, 2]
또 하나 중요한 점은 메서드 오버로딩과 결합된 가변 인자예요. 메서드 오버로딩의 경우 Groovy는 가장 구체적인 메서드를 선택합니다. 예를 들어 foo 메서드가 타입 T의 가변 인자를 받고, 다른 foo 메서드도 타입 T의 인자를 하나 받는다면, 두 번째 메서드가 선호돼요.
def foo(Object... args) { 1 }
def foo(Object x) { 2 }
assert foo() == 1
assert foo(1) == 2
assert foo(1, 2) == 1
3.2.5. 메서드 선택 알고리즘
동적 Groovy는 다중 디스패치(multiple dispatch, 일명 multimethods)를 지원해요. 메서드를 호출하면 실제로 호출되는 메서드가 메서드 인자의 런타임 타입을 기반으로 동적으로 결정돼요. 먼저 메서드 이름과 인자 수(가변 인자 허용 포함)를 고려하고, 그다음 각 인자의 타입을 고려해요. 다음 메서드 정의를 보세요.
def method(Object o1, Object o2) { 'o/o' }
def method(Integer i, String s) { 'i/s' }
def method(String s, Integer i) { 's/i' }
아마 예상대로, String과 Integer 매개변수로 method를 호출하면 세 번째 메서드 정의가 호출됩니다.
assert method('foo', 42) == 's/i'
여기서 더 흥미로운 건 타입이 컴파일 타임에 알려지지 않을 때예요. 아마 인자가 Object 타입으로 선언되어 있을 거예요(이 경우 그런 객체들의 리스트). Java는 모든 경우에 method(Object, Object) 변형이 선택되도록 결정하겠지만(캐스트를 쓰지 않으면), 다음 예시에서 볼 수 있듯 Groovy는 런타임 타입을 사용해 우리의 각 메서드를 한 번씩 호출해요(보통 캐스팅이 필요 없죠).
List<List<Object>> pairs = [['foo', 1], [2, 'bar'], [3, 4]]
assert pairs.collect { a, b -> method(a, b) } == ['s/i', 'i/s', 'o/o']
세 번의 메서드 호출 중 처음 두 번은 인자 타입의 정확한 일치가 발견됐어요. 세 번째 호출에서는 method(Integer, Integer)의 정확한 일치가 없었지만 method(Object, Object)가 여전히 유효하고 선택됩니다.
메서드 선택이란 결국 호환되는 매개변수 타입을 가진 유효한 메서드 후보들 중에서 *가장 가까운 맞음(closest fit)*을 찾는 거예요. 그래서 method(Object, Object)는 처음 두 호출에도 유효하지만, 타입이 정확히 일치하는 변형만큼 가까운 맞음이 아니에요. 가장 가까운 맞음을 결정하기 위해 런타임은 실제 인자 타입이 선언된 매개변수 타입에서 얼마나 거리가 떨어져 있는지에 대한 개념을 갖고, 모든 매개변수에 걸쳐 총 거리를 최소화하려고 합니다.
다음 표는 거리 계산에 영향을 주는 몇 가지 요소를 보여줘요.
| 측면 | 예시 |
|---|---|
| 직접 구현한 인터페이스가 상속 계층 위쪽의 것보다 더 가까이 매치돼요 | 다음 인터페이스와 메서드 정의가 주어졌을 때: interface I1 {} interface I2 extends I1 {} interface I3 {} class Clazz implements I3, I2 {} def method(I1 i1) { 'I1' } def method(I3 i3) { 'I3' } 직접 구현한 인터페이스가 매치됩니다: assert method(new Clazz()) == 'I3' |
| Object 배열이 Object보다 선호돼요 | def method(Object[] arg) { 'array' } def method(Object arg) { 'object' } assert method([] as Object[]) == 'array' |
| 비가변 인자 변형이 가변 인자 변형보다 선호돼요 | def method(String s, Object... vargs) { 'vararg' } def method(String s) { 'non-vararg' } assert method('foo') == 'non-vararg' |
| 가변 인자 변형 두 개가 적용 가능하면, 최소 개수의 가변 인자를 사용하는 쪽이 선호돼요 | def method(String s, Object... vargs) { 'two vargs' } def method(String s, Integer i, Object... vargs) { 'one varg' } assert method('foo', 35, new Date()) == 'one varg' |
| 인터페이스가 수퍼클래스보다 선호돼요 | interface I {} class Base {} class Child extends Base implements I {} def method(Base b) { 'superclass' } def method(I i) { 'interface' } assert method(new Child()) == 'interface' |
| 기본형 인자 타입에 대해, 같거나 약간 더 큰 선언 매개변수 타입이 선호돼요 | def method(Long l) { 'Long' } def method(Short s) { 'Short' } def method(BigInteger bi) { 'BigInteger' } assert method(35) == 'Long' |
두 변형이 정확히 같은 거리인 경우 이는 모호한 것으로 간주되어 런타임 예외를 일으켜요.
def method(Date d, Object o) { 'd/o' }
def method(Object o, String s) { 'o/s' }
def ex = shouldFail {
println method(new Date(), 'baz')
}
assert ex.message.contains('Ambiguous method overloading')
캐스팅을 사용해 원하는 메서드를 선택할 수 있어요.
assert method(new Date(), (Object)'baz') == 'd/o'
assert method((Object)new Date(), 'baz') == 'o/s'
3.2.6. 예외 선언
Groovy는 체크 예외(checked exceptions)를 언체크 예외처럼 취급할 수 있게 자동으로 허용해요. 즉 메서드가 던질 수 있는 체크 예외를 선언할 필요가 없다는 뜻이에요. 다음 예시는 파일을 찾지 못하면 FileNotFoundException을 던질 수 있어요.
def badRead() {
new File('doesNotExist.txt').text
}
shouldFail(FileNotFoundException) {
badRead()
}
또한 이전 예시에서 badRead 메서드 호출을 try/catch 블록으로 감쌀 필요도 없어요. 물론 원한다면 감쌀 수 있어요.
여러분의 코드가 던질 수 있는 예외(체크든 아니든)를 선언하고 싶다면 자유롭게 선언할 수 있어요. 예외를 추가해도 코드가 다른 Groovy 코드에서 사용되는 방식은 바뀌지 않지만, 여러분 코드를 읽는 사람을 위한 문서로 볼 수 있어요. 예외는 바이트코드의 메서드 선언의 일부가 되므로, 코드가 Java에서 호출될 수 있다면 포함하는 게 유용할 수 있어요. 명시적 체크 예외 선언을 사용한 것은 다음 예시에 있어요.
def badRead() throws FileNotFoundException {
new File('doesNotExist.txt').text
}
shouldFail(FileNotFoundException) {
badRead()
}
3.3. 필드와 프로퍼티
3.3.1. 필드
필드는 데이터를 저장하는 클래스, 인터페이스, 트레이트의 멤버예요. Groovy 소스 파일에 정의된 필드는 다음을 가져요.
- 필수 접근 수식어 (public, protected, private 중 하나)
- 하나 이상의 선택 수식어 (static, final, synchronized)
- 선택적 타입
- 필수 이름
class Data {
private int id (1)
protected String description (2)
public static final boolean DEBUG = false (3)
}
(1) — int 타입의 id라는 private 필드
(2) — String 타입의 description이라는 protected 필드
(3) — boolean 타입의 DEBUG라는 public static final 필드
필드는 선언 시 직접 초기화할 수 있어요.
class Data {
private String id = IDGenerator.next() (1)
// ...
}
(1) — 비공개 필드 id는 IDGenerator.next()로 초기화돼요
필드의 타입 선언을 생략할 수 있어요. 하지만 이는 나쁜 관행으로 여겨지며, 일반적으로 필드에는 강한 타입을 쓰는 게 좋아요.
class BadPractice {
private mapping (1)
}
class GoodPractice {
private Map<String,String> mapping (2)
}
(1) — mapping 필드가 타입을 선언하지 않아요
(2) — mapping 필드가 강한 타입을 가져요
나중에 선택적 타입 검사를 사용하고 싶다면 둘 사이의 차이가 중요해요. 클래스 설계를 문서화하는 방법으로서도 중요합니다. 하지만 스크립팅 같은 경우나 덕 타이핑에 의존하고 싶다면 타입을 생략하는 게 유용할 수 있어요.
3.3.2. 프로퍼티
프로퍼티는 클래스의 외부에서 보이는 특성(feature)이에요. 그런 특성을 나타내기 위해 공개 필드만 쓰는 것보다(이것은 더 제한된 추상화를 제공하고 리팩터링 가능성을 제한해요), Java의 전형적인 접근은 JavaBeans 명세에 설명된 관례를 따르는 거예요. 즉 비공개 backing 필드와 getter/setter의 조합으로 프로퍼티를 표현하는 거죠. Groovy도 같은 관례를 따르지만 프로퍼티를 정의하는 더 간단한 방법을 제공해요. 다음으로 프로퍼티를 정의할 수 있어요.
- 접근 수식어 없음 (public, protected, private 없음)
- 하나 이상의 선택 수식어 (static, final, synchronized)
- 선택적 타입
- 필수 이름
Groovy는 그러면 getter/setter를 적절히 생성해요. 예를 들어:
class Person {
String name (1)
int age (2)
}
(1) — backing private String name 필드, getName과 setName 메서드를 만들어요
(2) — backing private int age 필드, getAge와 setAge 메서드를 만들어요
프로퍼티가 final로 선언되면 setter는 생성되지 않아요.
class Person {
final String name (1)
final int age (2)
Person(String name, int age) {
this.name = name (3)
this.age = age (4)
}
}
(1) — String 타입의 읽기 전용 프로퍼티를 정의해요
(2) — int 타입의 읽기 전용 프로퍼티를 정의해요
(3) — name 매개변수를 name 필드에 할당해요
(4) — age 매개변수를 age 필드에 할당해요
프로퍼티는 이름으로 접근되며, 코드가 그 프로퍼티를 정의하는 클래스 안에 있지 않으면 getter나 setter를 투명하게 호출합니다.
class Person {
String name
void name(String name) {
this.name = "Wonder $name" (1)
}
String title() {
this.name (2)
}
}
def p = new Person()
p.name = 'Diana' (3)
assert p.name == 'Diana' (4)
p.name('Woman') (5)
assert p.title() == 'Wonder Woman' (6)
(1) — 프로퍼티가 그것을 정의하는 클래스 안에서 접근되므로 this.name이 필드에 직접 접근해요
(2) — 마찬가지로 읽기 접근도 name 필드에 직접 수행돼요
(3) — 프로퍼티에 대한 쓰기 접근이 Person 클래스 밖에서 이루어지므로 암시적으로 setName을 호출해요
(4) — 프로퍼티에 대한 읽기 접근이 Person 클래스 밖에서 이루어지므로 암시적으로 getName을 호출해요
(5) — 이것은 Person의 name 메서드를 호출하는데, 그 메서드는 필드에 직접 접근을 수행해요
(6) — 이것은 Person의 title 메서드를 호출하는데, 그 메서드는 필드에 직접 읽기 접근을 수행해요
이렇게 backing 필드에 직접 접근하는 동작은, 프로퍼티를 정의하는 클래스 안에서 프로퍼티 접근 문법을 사용할 때 스택 오버플로를 막기 위한 것이라는 점을 알아두는 게 좋아요.
인스턴스의 메타 properties 필드 덕분에 클래스의 프로퍼티를 나열할 수 있어요.
class Person {
String name
int age
}
def p = new Person()
assert p.properties.keySet().containsAll(['name','age'])
관례에 따라 Groovy는 Java Beans 명세를 따르는 getter나 setter가 있다면 backing 필드가 없어도 프로퍼티를 인식해요. 예를 들어:
class PseudoProperties {
// a pseudo property "name"
void setName(String name) {}
String getName() {}
// a pseudo read-only property "age"
int getAge() { 42 }
// a pseudo write-only property "groovy"
void setGroovy(boolean groovy) { }
}
def p = new PseudoProperties()
p.name = 'Foo' (1)
assert p.age == 42 (2)
p.groovy = true (3)
(1) — name pseudo-프로퍼티가 있으므로 p.name 쓰기가 허용돼요
(2) — age pseudo-읽기전용 프로퍼티가 있으므로 p.age 읽기가 허용돼요
(3) — groovy pseudo-쓰기전용 프로퍼티가 있으므로 p.groovy 쓰기가 허용돼요
이 문법 설탕은 Groovy로 작성된 많은 DSL의 핵심이에요.
프로퍼티 명명 관례
일반적으로 프로퍼티 이름의 처음 두 글자는 소문자이고, 여러 단어로 된 프로퍼티는 카멜 케이스를 쓰는 것을 권장해요. 그런 경우 생성된 getter와 setter는 프로퍼티 이름을 대문자화하고 get 또는 set 접두사(또는 boolean getter의 경우 선택적으로 "is")를 붙여 형성된 이름을 가져요. 그래서 getLength는 length 프로퍼티의 getter이고 setFirstName은 firstName 프로퍼티의 setter예요. isEmpty는 empty라는 프로퍼티의 getter 메서드 이름일 수 있어요.
대문자로 시작하는 프로퍼티 이름은 접두사만 추가된 getter/setter를 가지게 돼요. 그래서 권장 명명 관례를 따르지 않지만
Foo프로퍼티는 허용됩니다. 이 프로퍼티의 접근자 메서드는setFoo와getFoo가 돼요. 그 결과 같은 이름의 접근자 메서드를 가지게 되므로foo프로퍼티와Foo프로퍼티를 둘 다 가질 수 없어요.
JavaBeans 명세는 보통 약어일 수 있는 프로퍼티를 특별히 처리해요. 프로퍼티 이름의 처음 두 글자가 대문자이면 대문자화가 수행되지 않아요(더 중요하게는, 접근자 메서드 이름에서 프로퍼티 이름을 생성할 때 소문자화가 수행되지 않아요). 그래서 getURL은 URL 프로퍼티의 getter가 돼요.
JavaBeans 명세의 특별한 "약어 처리" 프로퍼티 명명 로직 때문에 프로퍼티 이름으로의 변환과 그 반대 변환은 비대칭적이에요. 이는 이상한 가장자리 케이스들로 이어져요. Groovy는 약간 이상해 보일 수 있지만 Groovy 설계 당시 인기 있었고 (현재까지) 역사적인 이유로 유지된 한 가지 모호성을 피하는 명명 관례를 채택합니다. Groovy는 프로퍼티 이름의 두 번째 글자를 봐요. 그것이 대문자이면 그 프로퍼티는 약어 스타일 프로퍼티로 간주되어 대문자화가 수행되지 않고, 그 외에는 정상적인 대문자화가 수행돼요. 권장하진 않지만, "중복 이름"처럼 보이는 프로퍼티를 가질 수 있게 해 줍니다. 예:
aProp과AProp, 또는pNAME과PNAME. getter는 각각getaProp과getAProp,getpNAME과getPNAME이 돼요.
프로퍼티의 수식어
가시성 수식어를 생략해 프로퍼티가 정의된다는 것을 이미 봤어요. 일반적으로 transient 같은 다른 수식어는 필드에 그대로 복사돼요. 두 가지 특별한 경우를 알아두는 게 좋아요.
final은 읽기 전용 프로퍼티를 위한 것인데, backing 필드에 복사되면서도 setter가 정의되지 않게 해요static은 backing 필드에 복사되면서도 접근자 메서드를 static으로 만들어요
final 같은 수식어를 접근자 메서드에도 전달하고 싶다면 프로퍼티를 길게(long hand) 쓰거나 분할 프로퍼티 정의를 고려해 볼 수 있어요.
프로퍼티의 애노테이션
AST 변환과 연관된 것들을 포함한 애노테이션은 프로퍼티의 backing 필드에 복사돼요. 이를 통해 필드에 적용 가능한 AST 변환을 프로퍼티에 적용할 수 있어요. 예를 들어:
class Animal {
int lowerCount = 0
@Lazy String name = { lower().toUpperCase() }()
String lower() { lowerCount++; 'sloth' }
}
def a = new Animal()
assert a.lowerCount == 0 (1)
assert a.name == 'SLOTH' (2)
assert a.lowerCount == 1 (3)
(1) — 즉시 초기화가 없음을 확인해요
(2) — 정상적인 프로퍼티 접근
(3) — 프로퍼티 접근 시 초기화를 확인해요
명시적 backing 필드가 있는 분할 프로퍼티 정의
Groovy의 프로퍼티 문법은 클래스 설계가 일반적인 JavaBean 관행과 일치하는 특정 관례를 따를 때 편리한 축약형이에요. 클래스가 그 관례에 정확히 맞지 않으면, 표준 Java에서처럼 getter, setter, backing 필드를 길게 쓸 수 있어요. 하지만 Groovy는 분할 정의 기능도 제공하는데, 여전히 축약 문법을 주면서도 관례를 약간 조정할 수 있게 해 줘요. 분할 정의에서는 같은 이름과 타입으로 필드와 프로퍼티를 씁니다. 필드 또는 프로퍼티 중 하나만 초기 값을 가질 수 있어요.
분할 프로퍼티에서 필드의 애노테이션은 프로퍼티의 backing 필드에 남아요. 정의의 프로퍼티 부분의 애노테이션은 getter와 setter 메서드에 복사돼요.
이 메커니즘은 표준 프로퍼티 정의가 필요에 정확히 맞지 않을 때 프로퍼티 사용자가 쓰고 싶을 수 있는 여러 일반적인 변형을 허용합니다. 예를 들어 backing 필드가 private가 아니라 protected여야 한다면:
class HasPropertyWithProtectedField {
protected String name (1)
String name (2)
}
(1) — name 프로퍼티를 위한 보통의 private 대신 protected backing 필드
(2) — name 프로퍼티 선언
또는 같은 예시지만 패키지 비공개 backing 필드를 사용한다면:
class HasPropertyWithPackagePrivateField {
String name (1)
@PackageScope String name (2)
}
(1) — name 프로퍼티 선언
(2) — name 프로퍼티를 위한 보통의 private 대신 패키지 비공개 backing 필드
마지막 예시로, 메서드 관련 AST 변환을 적용하거나 일반적으로 setter/getter에 어떤 애노테이션을 적용하고 싶을 수 있어요. 예를 들어 접근자를 synchronized로 하려면:
class HasPropertyWithSynchronizedAccessorMethods {
private String name (1)
@Synchronized String name (2)
}
(1) — name 프로퍼티의 backing 필드
(2) — setter/getter에 대한 애노테이션과 함께 name 프로퍼티 선언
명시적 접근자 메서드
클래스에 getter나 setter의 명시적 정의가 있으면 접근자 메서드의 자동 생성은 일어나지 않아요. 이를 통해 필요하다면 그런 getter나 setter의 정상 동작을 수정할 수 있어요. 상속된 접근자 메서드는 보통 고려되지 않지만, 상속된 접근자 메서드가 final로 표시되면 그런 메서드의 서브클래싱을 금지하는 final 요구 사항을 존중하기 위해 추가 접근자 메서드도 생성되지 않게 해요.
4. 애노테이션
4.1. 애노테이션 정의
애노테이션은 코드의 요소에 주석을 다는 데 전념하는 일종의 특별한 인터페이스예요. 애노테이션은 superinterface가 java.lang.annotation.Annotation 인터페이스인 타입입니다. 애노테이션은 @interface 키워드를 사용해 인터페이스와 매우 유사하게 선언돼요.
@interface SomeAnnotation {}
애노테이션은 본문이 없는 메서드와 선택적 기본 값의 형태로 멤버를 정의할 수 있어요. 가능한 멤버 타입은 제한돼요.
- 기본형 타입
- java.lang.String
- java.lang.Class
- java.lang.Enum
- 또 다른 java.lang.annotation.Annotation
- 또는 위의 배열
예를 들어:
@interface SomeAnnotation {
String value() (1)
}
@interface SomeAnnotation {
String value() default 'something' (2)
}
@interface SomeAnnotation {
int step() (3)
}
@interface SomeAnnotation {
Class appliesTo() (4)
}
@interface SomeAnnotation {}
@interface SomeAnnotations {
SomeAnnotation[] value() (5)
}
enum DayOfWeek { mon, tue, wed, thu, fri, sat, sun }
@interface Scheduled {
DayOfWeek dayOfWeek() (6)
}
(1) — String 타입의 value 멤버를 정의하는 애노테이션
(2) — 기본 값이 something인 String 타입의 value 멤버를 정의하는 애노테이션
(3) — 기본형 int 타입의 step 멤버를 정의하는 애노테이션
(4) — Class 타입의 appliesTo 멤버를 정의하는 애노테이션
(5) — 타입이 또 다른 애노테이션 타입의 배열인 value 멤버를 정의하는 애노테이션
(6) — 열거 타입 DayOfWeek인 dayOfWeek 멤버를 정의하는 애노테이션
Java 언어와 달리 Groovy에서는 애노테이션이 언어의 의미를 바꾸는 데 사용될 수 있어요. 특히 애노테이션을 기반으로 코드를 생성하는 AST 변환에 대해 그렇습니다.
4.1.1. 애노테이션 배치
애노테이션은 코드의 다양한 요소에 적용될 수 있어요.
@SomeAnnotation (1)
void someMethod() {
// ...
}
@SomeAnnotation (2)
class SomeClass {}
@SomeAnnotation String someVar (3)
(1) — @SomeAnnotation은 someMethod 메서드에 적용돼요
(2) — @SomeAnnotation은 SomeClass 클래스에 적용돼요
(3) — @SomeAnnotation은 someVar 변수에 적용돼요
애노테이션이 적용될 수 있는 범위를 제한하려면, java.lang.annotation.Target 애노테이션을 사용해 애노테이션 정의에 선언해야 해요. 예를 들어 애노테이션이 클래스나 메서드에 적용될 수 있다고 선언하는 방법은 다음과 같아요.
import java.lang.annotation.ElementType
import java.lang.annotation.Target
@Target([ElementType.METHOD, ElementType.TYPE]) (1)
@interface SomeAnnotation {} (2)
(1) — @Target 애노테이션은 애노테이션을 범위와 함께 주석 처리하는 것을 의미해요
(2) — 따라서 @SomeAnnotation은 TYPE 또는 METHOD에만 허용될 거예요
가능한 대상의 목록은 java.lang.annotation.ElementType에서 볼 수 있어요.
Groovy는 Java 8에서 도입된
java.lang.annotation.ElementType#TYPE_PARAMETER와java.lang.annotation.ElementType#TYPE_PARAMETER요소 타입을 지원하지 않아요.
4.1.2. 애노테이션 멤버 값
애노테이션을 사용할 때는 기본 값이 없는 모든 멤버를 최소한 설정해야 해요. 예를 들어:
@interface Page {
int statusCode()
}
@Page(statusCode=404)
void notFound() {
// ...
}
하지만 멤버 value가 설정되는 유일한 것이라면 애노테이션 값의 선언에서 value=를 생략할 수 있어요.
@interface Page {
String value()
int statusCode() default 200
}
@Page(value='/home') (1)
void home() {
// ...
}
@Page('/users') (2)
void userList() {
// ...
}
@Page(value='error',statusCode=404) (3)
void notFound() {
// ...
}
(1) — statusCode는 기본 값이 있으므로 생략할 수 있지만 value는 설정해야 해요
(2) — value가 기본 값이 없는 유일한 필수 멤버이므로 value=를 생략할 수 있어요
(3) — value와 statusCode 둘 다 설정해야 하면 기본 value 멤버에 value=를 사용해야 해요
4.1.3. 유지 정책(Retention policy)
애노테이션의 가시성은 유지 정책에 따라 달라져요. 애노테이션의 유지 정책은 java.lang.annotation.Retention 애노테이션으로 설정합니다.
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
@Retention(RetentionPolicy.SOURCE) (1)
@interface SomeAnnotation {} (2)
(1) — @Retention 애노테이션이 @SomeAnnotation 애노테이션에 주석을 답니다
(2) — 그래서 @SomeAnnotation은 SOURCE 유지 정책을 가질 거예요
가능한 유지 대상과 설명 목록은 java.lang.annotation.RetentionPolicy 열거형에서 볼 수 있어요. 선택은 보통 애노테이션이 컴파일 타임 또는 런타임에 보이길 원하는지에 따라 달라집니다.
4.1.4. 클로저 애노테이션 매개변수
Groovy 애노테이션의 흥미로운 기능은 클로저를 애노테이션 값으로 사용할 수 있다는 거예요. 그래서 애노테이션은 다양한 표현식과 함께 사용될 수 있고 IDE 지원도 유지됩니다. 예를 들어 JDK 버전이나 OS 같은 환경 제약에 따라 어떤 메서드를 실행하고 싶은 프레임워크를 상상해 보세요. 다음 코드를 작성할 수 있을 거예요.
class Tasks {
Set result = []
void alwaysExecuted() {
result << 1
}
@OnlyIf({ jdk>=6 })
void supportedOnlyInJDK6() {
result << 'JDK 6'
}
@OnlyIf({ jdk>=7 && windows })
void requiresJDK7AndWindows() {
result << 'JDK 7 Windows'
}
}
@OnlyIf 애노테이션이 인자로 Closure를 받게 하려면, value를 Class로 선언하기만 하면 돼요.
@Retention(RetentionPolicy.RUNTIME)
@interface OnlyIf {
Class value() (1)
}
예시를 완성하기 위해, 그 정보를 사용할 샘플 러너를 작성해 볼게요.
class Runner {
static <T> T run(Class<T> taskClass) {
def tasks = taskClass.newInstance() (1)
def params = [jdk: 6, windows: false] (2)
tasks.class.declaredMethods.each { m -> (3)
if (Modifier.isPublic(m.modifiers) && m.parameterTypes.length == 0) { (4)
def onlyIf = m.getAnnotation(OnlyIf) (5)
if (onlyIf) {
Closure cl = onlyIf.value().newInstance(tasks,tasks) (6)
cl.delegate = params (7)
if (cl()) { (8)
m.invoke(tasks) (9)
}
} else {
m.invoke(tasks) (10)
}
}
}
tasks (11)
}
}
(1) — 인자로 전달된 클래스(태스크 클래스)의 새 인스턴스를 만들어요
(2) — JDK 6이고 Windows가 아닌 환경을 흉내 내요
(3) — 태스크 클래스의 모든 선언된 메서드를 순회해요
(4) — 메서드가 public이고 인자를 받지 않으면
(5) — @OnlyIf 애노테이션을 찾으려고 해요
(6) — 찾으면 value를 얻고 그것으로 새 Closure를 만들어요
(7) — 클로저의 delegate를 우리의 환경 변수로 설정해요
(8) — 클로저(애노테이션 클로저)를 호출해요. boolean을 반환할 거예요
(9) — true이면 메서드를 호출해요
(10) — 메서드가 @OnlyIf로 애노테이션되지 않았으면 어쨌든 메서드를 실행해요
(11) — 그 후 태스크 객체를 반환해요
그러면 러너는 이렇게 사용할 수 있어요.
def tasks = Runner.run(Tasks)
assert tasks.result == [1, 'JDK 6'] as Set
4.2. 메타 애노테이션
4.2.1. 메타 애노테이션 선언
메타 애노테이션은(애노테이션 별칭이라고도 함) 컴파일 타임에 다른 애노테이션으로 대체되는 애노테이션이에요(하나의 메타 애노테이션은 하나 이상의 애노테이션의 별칭입니다). 메타 애노테이션은 여러 애노테이션을 포함하는 코드의 크기를 줄이는 데 쓸 수 있어요.
간단한 예시부터 시작해 볼게요. @Service와 @Transactional 애노테이션이 있고, 클래스에 둘 다 주석을 달고 싶다고 상상해 보세요.
@Service
@Transactional
class MyTransactionalService {}
같은 클래스에 추가할 수 있는 애노테이션의 곱셈을 고려하면, 메타 애노테이션이 정확히 같은 의미를 가진 단일 애노테이션으로 두 애노테이션을 줄이는 데 도움을 줄 수 있어요. 예를 들어 대신 이렇게 쓰고 싶을 수 있어요.
@TransactionalService (1)
class MyTransactionalService {}
(1) — @TransactionalService는 메타 애노테이션이에요
메타 애노테이션은 일반 애노테이션으로 선언되지만 @AnnotationCollector와 수집하는 애노테이션 목록으로 주석이 달려요. 우리의 경우 @TransactionalService 애노테이션은 이렇게 작성할 수 있어요.
import groovy.transform.AnnotationCollector
@Service (1)
@Transactional (2)
@AnnotationCollector (3)
@interface TransactionalService {
}
(1) — 메타 애노테이션에 @Service로 주석을 달아요
(2) — 메타 애노테이션에 @Transactional로 주석을 달아요
(3) — 메타 애노테이션에 @AnnotationCollector로 주석을 달아요
4.2.2. 메타 애노테이션의 동작
Groovy는 미리 컴파일된(precompiled) 메타 애노테이션과 소스 형태(source form) 메타 애노테이션을 모두 지원해요. 즉 메타 애노테이션이 미리 컴파일될 수도 있고, 현재 컴파일 중인 것과 같은 소스 트리에 있을 수도 있다는 뜻이에요.
메타 애노테이션은 Groovy 전용 기능이에요. Java 클래스를 메타 애노테이션으로 주석 달아 Groovy에서처럼 동작하길 기대할 방법은 없어요. 마찬가지로 Java로 메타 애노테이션을 쓸 수도 없어요. 메타 애노테이션 정의 와 사용이 모두 Groovy 코드여야 해요. 하지만 메타 애노테이션 안에서 Java 애노테이션과 Groovy 애노테이션을 함께 수집할 수는 있어요.
Groovy 컴파일러가 메타 애노테이션으로 주석 달린 클래스를 만나면, 그 메타 애노테이션을 수집된 애노테이션으로 대체해요. 그래서 이전 예시에서 @TransactionalService를 @Transactional과 @Service로 대체할 거예요.
def annotations = MyTransactionalService.annotations*.annotationType()
assert (Service in annotations)
assert (Transactional in annotations)
메타 애노테이션에서 수집된 애노테이션으로의 변환은 의미 분석(semantic analysis) 컴파일 단계에서 수행돼요.
별칭을 수집된 애노테이션으로 대체하는 것에 더해, 메타 애노테이션은 인자를 포함해 그것들을 처리할 수 있어요.
4.2.3. 메타 애노테이션 매개변수
메타 애노테이션은 매개변수를 가진 애노테이션을 수집할 수 있어요. 이를 설명하기 위해, 각각 인자를 하나 받는 두 애노테이션을 상상해 볼게요.
@Timeout(after=3600)
@Dangerous(type='explosive')
그리고 @Explosive라는 이름의 메타 애노테이션을 만들고 싶다고 가정해 보세요.
@Timeout(after=3600)
@Dangerous(type='explosive')
@AnnotationCollector
public @interface Explosive {}
기본적으로 애노테이션이 대체될 때, 애노테이션 매개변수 값은 별칭에 정의된 대로 얻게 돼요. 더 흥미롭게도, 메타 애노테이션은 특정 값을 오버라이딩하는 것을 지원해요.
@Explosive(after=0) (1)
class Bomb {}
(1) — @Explosive에 매개변수로 제공된 after 값은 @Timeout 애노테이션에 정의된 값을 오버라이드해요
두 애노테이션이 같은 매개변수 이름을 정의하면, 기본 프로세서는 애노테이션 값을 그 매개변수를 받는 모든 애노테이션에 복사해요.
@Retention(RetentionPolicy.RUNTIME)
public @interface Foo {
String value() (1)
}
@Retention(RetentionPolicy.RUNTIME)
public @interface Bar {
String value() (2)
}
@Foo
@Bar
@AnnotationCollector
public @interface FooBar {} (3)
@Foo('a')
@Bar('b')
class Bob {} (4)
assert Bob.getAnnotation(Foo).value() == 'a' (5)
println Bob.getAnnotation(Bar).value() == 'b' (6)
@FooBar('a')
class Joe {} (7)
assert Joe.getAnnotation(Foo).value() == 'a' (8)
println Joe.getAnnotation(Bar).value() == 'a' (9)
(1) — @Foo 애노테이션은 String 타입의 value 멤버를 정의해요
(2) — @Bar 애노테이션도 String 타입의 value 멤버를 정의해요
(3) — @FooBar 메타 애노테이션은 @Foo와 @Bar를 집계해요
(4) — Bob 클래스는 @Foo와 @Bar로 주석이 달려요
(5) — Bob의 @Foo 애노테이션 값은 a예요
(6) — 반면 Bob의 @Bar 애노테이션 값은 b예요
(7) — Joe 클래스는 @FooBar로 주석이 달려요
(8) — 그러면 Joe의 @Foo 애노테이션 값은 a예요
(9) — 그리고 Joe의 @Bar 애노테이션 값도 a예요
두 번째 경우, 메타 애노테이션 값이 @Foo와 @Bar 둘 다에 복사됐어요.
수집된 애노테이션이 호환되지 않는 타입으로 같은 멤버를 정의하면 컴파일 타임 오류예요. 예를 들어 이전 예시에서
@Foo가String타입의 value를 정의했는데@Bar가int타입의 value를 정의했다면요.
하지만 메타 애노테이션의 동작을 커스터마이즈하고 수집된 애노테이션이 어떻게 확장되는지 설명하는 것도 가능해요. 그 방법을 곧 살펴볼 건데, 먼저 다뤄야 할 고급 처리 옵션이 있어요.
4.2.4. 메타 애노테이션의 중복 애노테이션 처리
@AnnotationCollector 애노테이션은 mode 매개변수를 지원하는데, 중복 애노테이션이 있을 때 기본 프로세서가 애노테이션 대체를 처리하는 방식을 바꾸는 데 쓸 수 있어요.
커스텀 프로세서(다음에서 다룸)는 이 매개변수를 지원하거나 지원하지 않을 수 있어요.
예를 들어 @ToString 애노테이션을 포함하는 메타 애노테이션을 만들고, 메타 애노테이션을 이미 명시적 @ToString 애노테이션이 있는 클래스에 놓았다고 가정해 보세요. 이것이 오류여야 할까요? 두 애노테이션이 모두 적용되어야 할까요? 하나가 다른 것보다 우선하나요? 정답은 없어요. 어떤 시나리오에서는 이 답들 중 어느 것이든 맞을 수 있어요. 그래서 중복 애노테이션 문제를 다루는 하나의 올바른 방법을 미리 정하기보다, Groovy는 여러분이 자신만의 커스텀 메타 애노테이션 프로세서(다음에서 다룸)를 쓰게 하고, AST 변환 안에서 원하는 어떤 검사 로직이든 쓰게 합니다(집계의 빈번한 대상이죠). 그런데도 단순히 mode를 설정함으로써 추가 코딩 없이 여러 흔히 기대되는 시나리오가 자동으로 처리됩니다. mode 매개변수의 동작은 선택된 AnnotationCollectorMode enum 값에 의해 결정되며 다음 표에 요약되어 있어요.
| 모드 | 설명 |
|---|---|
| DUPLICATE | 애노테이션 컬렉션의 애노테이션은 항상 삽입돼요. 모든 변환이 실행된 후, (SOURCE 유지 정책을 제외한) 여러 애노테이션이 존재하면 오류예요 |
| PREFER_COLLECTOR | 컬렉터의 애노테이션이 추가되고, 같은 이름의 기존 애노테이션은 제거돼요 |
| PREFER_COLLECTOR_MERGED | 컬렉터의 애노테이션이 추가되고, 같은 이름의 기존 애노테이션은 제거되지만 기존 애노테이션에서 발견된 새 매개변수는 추가된 애노테이션에 병합돼요 |
| PREFER_EXPLICIT | 같은 이름의 기존 애노테이션이 발견되면 컬렉터의 애노테이션은 무시돼요 |
| PREFER_EXPLICIT_MERGED | 같은 이름의 기존 애노테이션이 발견되면 컬렉터의 애노테이션은 무시되지만 컬렉터 애노테이션의 새 매개변수는 기존 애노테이션에 추가돼요 |
4.2.5. 커스텀 메타 애노테이션 프로세서
커스텀 애노테이션 프로세서는 메타 애노테이션을 수집된 애노테이션으로 어떻게 확장할지 선택하게 해 줘요. 이 경우 메타 애노테이션의 동작은 전적으로 여러분에게 달려 있어요. 이렇게 하려면:
org.codehaus.groovy.transform.AnnotationCollectorTransform을 확장하는 메타 애노테이션 프로세서를 만들어요- 메타 애노테이션 선언에서 사용할 프로세서를 선언해요
이를 설명하기 위해 @CompileDynamic 메타 애노테이션이 어떻게 구현되는지 살펴볼게요.
@CompileDynamic은 @CompileStatic(TypeCheckingMode.SKIP)로 확장되는 메타 애노테이션이에요. 문제는 기본 메타 애노테이션 프로세서가 enum을 지원하지 않는데, 애노테이션 값 TypeCheckingMode.SKIP이 enum이라는 거예요.
순진한 구현은 동작하지 않아요.
@CompileStatic(TypeCheckingMode.SKIP)
@AnnotationCollector
public @interface CompileDynamic {}
대신 이렇게 정의할 거예요.
@AnnotationCollector(processor = "org.codehaus.groovy.transform.CompileDynamicProcessor")
public @interface CompileDynamic {
}
첫 번째로 눈에 띄는 것은 우리의 인터페이스가 더 이상 @CompileStatic으로 주석이 달려 있지 않다는 거예요. 그 이유는 우리가 그 대신 processor 매개변수에 의존하는데, 그것이 애노테이션을 생성할 클래스를 참조하기 때문이에요.
커스텀 프로세서는 이렇게 구현됩니다.
@CompileStatic (1)
class CompileDynamicProcessor extends AnnotationCollectorTransform { (2)
private static final ClassNode CS_NODE = ClassHelper.make(CompileStatic) (3)
private static final ClassNode TC_NODE = ClassHelper.make(TypeCheckingMode) (4)
List<AnnotationNode> visit(AnnotationNode collector, (5)
AnnotationNode aliasAnnotationUsage, (6)
AnnotatedNode aliasAnnotated, (7)
SourceUnit source) { (8)
def node = new AnnotationNode(CS_NODE) (9)
def enumRef = new PropertyExpression(
new ClassExpression(TC_NODE), "SKIP") (10)
node.addMember("value", enumRef) (11)
Collections.singletonList(node) (12)
}
}
(1) — 커스텀 프로세서는 Groovy로 작성되고, 더 나은 컴파일 성능을 위해 정적 컴파일을 사용해요
(2) — 커스텀 프로세서는 org.codehaus.groovy.transform.AnnotationCollectorTransform을 확장해야 해요
(3) — @CompileStatic 애노테이션 타입을 나타내는 클래스 노드를 만들어요
(4) — TypeCheckingMode enum 타입을 나타내는 클래스 노드를 만들어요
(5) — collector는 메타 애노테이션에서 발견된 @AnnotationCollector 노드예요. 보통 사용되지 않아요
(6) — aliasAnnotationUsage는 확장되는 메타 애노테이션이며, 여기서는 @CompileDynamic이에요
(7) — aliasAnnotated는 메타 애노테이션으로 주석 달린 노드예요
(8) — sourceUnit은 컴파일 중인 SourceUnit이에요
(9) — @CompileStatic에 대한 새 애노테이션 노드를 만들어요
(10) — TypeCheckingMode.SKIP과 동등한 표현식을 만들어요
(11) — 그 표현식을 애노테이션 노드에 추가해요. 이제 @CompileStatic(TypeCheckingMode.SKIP)이 됐어요
(12) — 생성된 애노테이션을 반환해요
예시에서 visit 메서드는 오버라이드해야 하는 유일한 메서드예요. 메타 애노테이션으로 주석 달린 노드에 추가될 애노테이션 노드 목록을 반환하기 위한 것이에요. 이 예시에서는 @CompileStatic(TypeCheckingMode.SKIP)에 해당하는 단일 노드를 반환해요.
5. 트레이트(Traits)
트레이트는 별도 페이지에서 다뤄요. Groovy 트레이트(Traits) 문서를 참고하세요.
6. 레코드 클래스(Record classes, incubating)
레코드 클래스(줄여서 records)는 단순한 데이터 집계를 모델링하는 데 유용한 특별한 종류의 클래스예요. 일반 클래스보다 의례(ceremony)가 덜한 간결한 문법을 제공합니다. Groovy에는 이미 @Immutable과 @Canonical 같은 AST 변환이 있어 의례를 크게 줄여 주지만, 레코드는 Java에서 도입됐고 Groovy의 레코드 클래스는 Java 레코드 클래스와 정렬되도록 설계되었어요.
예를 들어 이메일 메시지를 나타내는 Message 레코드를 만들고 싶다고 가정해 보세요. 이 예시를 위해 그런 메시지가 from 이메일 주소, to 이메일 주소, 메시지 본문만 담고 있다고 단순화할게요. 그런 레코드를 다음과 같이 정의할 수 있어요.
record Message(String from, String to, String body) { }
레코드 클래스를 아래처럼 일반 클래스와 같은 방식으로 사용할 거예요.
def msg = new Message('[email protected]', '[email protected]', 'Hello!')
assert msg.toString() == 'Message[[email protected], [email protected], body=Hello!]'
의례가 줄어들어 명시적 필드, getter, toString, equals, hashCode 메서드를 정의하지 않아도 돼요. 사실, 이것은 다음 대략적인 등가물의 축약형이에요.
final class Message extends Record {
private final String from
private final String to
private final String body
private static final long serialVersionUID = 0
/* constructor(s) */
final String toString() { /*...*/ }
final boolean equals(Object other) { /*...*/ }
final int hashCode() { /*...*/ }
String from() { from }
// other getters ...
}
레코드 getter의 특별한 명명 관례를 유의하세요. 그것들은 필드와 같은 이름이에요(흔한 JavaBean 관례인 "get" 접두사로 대문자화하는 대신). 레코드의 필드나 프로퍼티를 말할 때는 컴포넌트라는 용어가 보통 쓰여요. 그래서 우리의 Message 레코드는 from, to, body 컴포넌트를 가져요.
Java에서처럼, 보통 암시적으로 공급되는 메서드를 자신의 것으로 오버라이드할 수 있어요.
record Point3D(int x, int y, int z) {
String toString() {
"Point3D[coords=$x,$y,$z]"
}
}
assert new Point3D(10, 20, 30).toString() == 'Point3D[coords=10,20,30]'
레코드와 함께 제네릭스도 일반적인 방식으로 사용할 수 있어요. 예를 들어 다음 Coord 레코드 정의를 고려해 보세요.
record Coord<T extends Number>(T v1, T v2){
double distFromOrigin() { Math.sqrt(v1()**2 + v2()**2 as double) }
}
다음과 같이 사용할 수 있어요.
def r1 = new Coord<Integer>(3, 4)
assert r1.distFromOrigin() == 5
def r2 = new Coord<Double>(6d, 2.5d)
assert r2.distFromOrigin() == 6.5d
6.1. 특별한 레코드 기능
6.1.1. 컴팩트 생성자(Compact constructor)
레코드는 암시적 생성자를 가져요. 이는 일반적인 방식대로 자신의 생성자를 제공해 오버라이드할 수 있어요. 그렇게 하려면 모든 필드를 설정해야 해요. 하지만 간결함을 위해, 일반 생성자의 매개변수 선언 부분을 생략한 컴팩트 생성자 문법을 사용할 수 있어요. 이 특별한 경우에도 일반 암시적 생성자는 여전히 제공되지만 컴팩트 생성자 정의의 공급된 문장들로 보강됩니다.
public record Warning(String message) {
public Warning {
Objects.requireNonNull(message)
message = message.toUpperCase()
}
}
def w = new Warning('Help')
assert w.message() == 'HELP'
6.1.2. 직렬화 가능성(Serializability)
Groovy 네이티브 레코드는 Java 레코드에 적용되는 직렬화 가능성의 특별한 관례를 따라가요. Groovy 레코드-유사(record-like) 클래스(아래에서 다룸)는 정상적인 Java 클래스 직렬화 가능성 관례를 따릅니다.
6.2. Groovy 개선 사항
6.2.1. 인자 기본 값
Groovy는 생성자 인자에 대한 기본 값을 지원해요. 이 기능은 레코드에서도 사용할 수 있는데, 다음 레코드 정의는 y와 color에 기본 값이 있어요.
record ColoredPoint(int x, int y = 0, String color = 'white') {}
(오른쪽에서 하나 이상의 인자를 빠뜨려) 인자를 생략하면 다음 예시처럼 기본 값으로 대체돼요.
assert new ColoredPoint(5, 5, 'black').toString() == 'ColoredPoint[x=5, y=5, color=black]'
assert new ColoredPoint(5, 5).toString() == 'ColoredPoint[x=5, y=5, color=white]'
assert new ColoredPoint(5).toString() == 'ColoredPoint[x=5, y=0, color=white]'
이 처리는 생성자의 기본 인자에 대한 일반 Groovy 관례를 따르며, 기본적으로 다음 시그니처를 가진 생성자를 자동으로 제공해요.
ColoredPoint(int, int, String)
ColoredPoint(int, int)
ColoredPoint(int)
명명된 인자도 사용할 수 있어요(여기서도 기본 값이 적용됩니다).
assert new ColoredPoint(x: 5).toString() == 'ColoredPoint[x=5, y=0, color=white]'
assert new ColoredPoint(x: 0, y: 5).toString() == 'ColoredPoint[x=0, y=5, color=white]'
다음처럼 기본 인자 처리를 비활성화할 수 있어요.
@TupleConstructor(defaultsMode=DefaultsMode.OFF)
record ColoredPoint2(int x, int y, String color) {}
assert new ColoredPoint2(4, 5, 'red').toString() == 'ColoredPoint2[x=4, y=5, color=red]'
이렇게 하면 Java에서처럼 기본에 따라 단일 생성자가 생성돼요. 이 시나리오에서 인자를 빠뜨리면 오류가 돼요.
다음처럼 모든 프로퍼티가 기본 값을 갖도록 강제할 수 있어요.
@TupleConstructor(defaultsMode=DefaultsMode.ON)
record ColoredPoint3(int x, int y = 0, String color = 'white') {}
assert new ColoredPoint3(y: 5).toString() == 'ColoredPoint3[x=0, y=5, color=white]'
명시적 초기 값이 없는 프로퍼티/필드에는 인자 타입의 기본 값(null, 또는 기본형의 0/false)이 주어져요.
더 깊이
우리는 앞서 Message 레코드를 설명하고 그 대략적인 등가물을 보여줬어요. Groovy는 사실 중간 단계를 거치는데, record 키워드가 class 키워드와 수반되는 @RecordType 애노테이션으로 대체돼요.
@RecordType
class Message {
String from
String to
String body
}
그런 다음 @RecordType 자체가 메타 애노테이션(애노테이션 컬렉터)으로 처리되어 @TupleConstructor, @POJO, @RecordBase 같은 구성 요소 하위 애노테이션들로 확장돼요. 이는 어떤 의미에서 보통 무시할 수 있는 구현 세부사항이에요. 하지만 레코드 구현을 커스터마이즈하거나 구성하고 싶다면 @RecordType 스타일로 돌아가거나 필요한 구성 요소 하위 애노테이션 중 하나로 레코드 클래스를 보강할 수 있어요.
6.2.2. 선언적 toString 커스터마이즈
Java에 따라, 레코드의 toString 메서드를 직접 작성해 커스터마이즈할 수 있어요. 더 선언적인 스타일을 선호한다면, Groovy의 @ToString 변환으로 기본 레코드 toString을 오버라이드할 수도 있어요. 예를 들어 3차원 점 레코드를 이렇게 만들 수 있어요.
package threed
import groovy.transform.ToString
@ToString(ignoreNulls=true, cache=true, includeNames=true,
leftDelimiter='[', rightDelimiter=']', nameValueSeparator='=')
record Point(Integer x, Integer y, Integer z=null) { }
assert new Point(10, 20).toString() == 'threed.Point[x=10, y=20]'
패키지 이름을 포함하고(레코드에선 기본적으로 제외됨), 이 불변 레코드에 대해 바뀌지 않을 toString 값을 캐시함으로써 toString을 커스터마이즈했어요. 또한 null 값도 무시하고 있어요(우리 정의에서 z의 기본 값).
2차원 점에 대해서도 비슷한 정의를 가질 수 있어요.
package twod
import groovy.transform.ToString
@ToString(ignoreNulls=true, cache=true, includeNames=true,
leftDelimiter='[', rightDelimiter=']', nameValueSeparator='=')
record Point(Integer x, Integer y) { }
assert new Point(10, 20).toString() == 'twod.Point[x=10, y=20]'
패키지 이름이 없으면 이전 예시와 같은 toString을 가질 것임을 여기서 볼 수 있어요.
6.2.3. 레코드 컴포넌트 값의 리스트 얻기
레코드의 컴포넌트 값을 이렇게 리스트로 얻을 수 있어요.
record Point(int x, int y, String color) { }
def p = new Point(100, 200, 'green')
def (x, y, c) = p.toList()
assert x == 100
assert y == 200
assert c == 'green'
@RecordOptions(toList=false)로 이 기능을 비활성화할 수 있어요.
6.2.4. 레코드 컴포넌트 값의 맵 얻기
레코드의 컴포넌트 값을 이렇게 맵으로 얻을 수 있어요.
record Point(int x, int y, String color) { }
def p = new Point(100, 200, 'green')
assert p.toMap() == [x: 100, y: 200, color: 'green']
@RecordOptions(toMap=false)로 이 기능을 비활성화할 수 있어요.
6.2.5. 레코드의 컴포넌트 개수 얻기
레코드의 컴포넌트 수를 이렇게 얻을 수 있어요.
record Point(int x, int y, String color) { }
def p = new Point(100, 200, 'green')
assert p.size() == 3
@RecordOptions(size=false)로 이 기능을 비활성화할 수 있어요.
6.2.6. 레코드에서 n번째 컴포넌트 얻기
Groovy의 일반적인 위치 인덱싱으로 레코드의 특정 컴포넌트를 얻을 수 있어요.
record Point(int x, int y, String color) { }
def p = new Point(100, 200, 'green')
assert p[1] == 200
@RecordOptions(getAt=false)로 이 기능을 비활성화할 수 있어요.
6.3. 선택적인 Groovy 기능
6.3.1. 복사(Copying)
일부 컴포넌트를 바꿔 레코드의 복사본을 만드는 것이 유용할 수 있어요. 명명된 인자를 받는 선택적 copyWith 메서드로 할 수 있어요. 레코드 컴포넌트는 공급된 인자에서 설정됩니다. 언급되지 않은 컴포넌트에는 원래 레코드 컴포넌트의 (얕은) 복사가 사용돼요. Fruit 레코드에 copyWith를 사용하는 방법은 다음과 같아요.
@RecordOptions(copyWith=true)
record Fruit(String name, double price) {}
def apple = new Fruit('Apple', 11.6)
assert 'Apple' == apple.name()
assert 11.6 == apple.price()
def orange = apple.copyWith(name: 'Orange')
assert orange.toString() == 'Fruit[name=Orange, price=11.6]'
copyWith 기능은 RecordOptions#copyWith 애노테이션 속성을 false로 설정해 비활성화할 수 있어요.
6.3.2. 깊은 불변성(Deep immutability)
Java에서처럼 레코드는 기본적으로 얕은 불변성을 제공해요. Groovy의 @Immutable 변환은 여러 가변 데이터 타입에 대해 방어적 복사를 수행합니다. 레코드는 이 방어적 복사를 사용해 깊은 불변성을 얻을 수 있어요.
@ImmutableProperties
record Shopping(List items) {}
def items = ['bread', 'milk']
def shop = new Shopping(items)
items << 'chocolate'
assert shop.items() == ['bread', 'milk']
이 예시들은 Groovy의 레코드 기능이 세 가지 편의 수준을 제공한다는 원리를 보여줘요.
- 최대 간결함을 위해 record 키워드 사용
- 선언적 애노테이션으로 낮은 의례 커스터마이즈 지원
- 완전한 통제가 필요할 때 정상적인 메서드 구현 허용
6.3.3. 레코드의 컴포넌트를 타입이 있는 튜플로 얻기
레코드의 컴포넌트를 타입이 있는 튜플로 얻을 수 있어요.
import groovy.transform.*
@RecordOptions(components=true)
record Point(int x, int y, String color) { }
@CompileStatic
def method() {
def p1 = new Point(100, 200, 'green')
def (int x1, int y1, String c1) = p1.components()
assert x1 == 100
assert y1 == 200
assert c1 == 'green'
def p2 = new Point(10, 20, 'blue')
def (x2, y2, c2) = p2.components()
assert x2 * 10 == 100
assert y2 ** 2 == 400
assert c2.toUpperCase() == 'BLUE'
def p3 = new Point(1, 2, 'red')
assert p3.components() instanceof Tuple3
}
method()
Groovy에는 제한된 수의 TupleN 클래스가 있어요. 레코드에 컴포넌트가 많으면 이 기능을 사용하지 못할 수도 있어요.
6.4. Java와의 기타 차이
Groovy는 네이티브 레코드뿐 아니라 레코드-유사(record-like) 클래스 생성도 지원해요. 레코드-유사 클래스는 Java의 Record 클래스를 확장하지 않고, 그런 클래스는 Java에서 레코드로 보이지 않지만 그 외에는 비슷한 프로퍼티를 가집니다.
@RecordOptions 애노테이션(@RecordType의 일부)은 세 값 중 하나를 가질 수 있는 mode 애노테이션 속성을 지원해요(AUTO가 기본).
NATIVE — Java가 할 것과 비슷한 클래스를 생성해요. JDK16보다 이른 JDK에서 컴파일하면 오류를 생성해요.
EMULATE — 모든 JDK 버전에 대해 레코드-유사 클래스를 생성해요.
AUTO — JDK16+에서는 네이티브 레코드를, 그 외에는 에뮬레이션을 생성해요.
record 키워드를 쓰든 @RecordType 애노테이션을 쓰든 mode와는 독립적이에요.
7. 봉인 계층(Sealed hierarchies, incubating)
봉인(sealed) 클래스, 인터페이스, 트레이트는 어떤 서브클래스가 그것들을 확장/구현할 수 있는지를 제한해요. 봉인 클래스 이전에 클래스 계층 설계자에게는 두 가지 주요 선택지가 있었어요.
- 클래스를 final로 만들어 확장을 허용하지 않는다
- 클래스를 public이고 non-final로 만들어 누구나 확장할 수 있게 한다
봉인 클래스는 이러한 전부 또는 전무 선택에 비해 중간 지점을 제공해요.
봉인 클래스는 이전에 중간 지점을 얻으려고 사용했던 다른 트릭보다 더 유연해요. 예를 들어 클래스 계층에서 protected와 패키지-비공개 같은 접근 수식어는 상속 계층을 제한하는 데 약간의 능력을 주지만, 종종 그 계층의 유연한 사용을 희생해요.
봉인 계층은 알려진 클래스·인터페이스·트레이트의 계층 내에서 완전한 상속을 제공하지만, 계층 밖에서는 상속을 비활성화하거나 오직 통제된 상속만 제공해요.
예를 들어 원과 정사각형만 포함하는 도형 계층을 만들고 싶다고 가정해 보세요. 또한 계층의 인스턴스를 참조할 수 있는 도형 인터페이스도 원해요. 계층을 이렇게 만들 수 있어요.
sealed interface ShapeI permits Circle,Square { }
final class Circle implements ShapeI { }
final class Square implements ShapeI { }
Groovy는 대체 애노테이션 문법도 지원해요. 우리는 키워드 스타일이 더 낫다고 생각하지만, 편집기가 아직 Groovy 4를 지원하지 않는다면 애노테이션 스타일을 택할 수도 있어요.
@Sealed(permittedSubclasses=[Circle,Square]) interface ShapeI { }
final class Circle implements ShapeI { }
final class Square implements ShapeI { }
permits 절 덕분에 ShapeI 타입의 참조는 Circle 또는 Square를 가리킬 수 있고, 우리의 클래스들은 final이므로 앞으로 계층에 추가 클래스가 없다는 걸 알아요. 적어도 permits 절을 바꾸고 다시 컴파일하지 않는 한요.
일반적으로 우리는 여기처럼 계층의 일부를 즉시 고정하고 싶을 수 있어요. 서브클래스를 final로 표시한 곳요. 하지만 다른 때는 추가 통제된 상속을 허용하고 싶을 수 있어요.
sealed class Shape permits Circle,Polygon,Rectangle { }
final class Circle extends Shape { }
class Polygon extends Shape { }
non-sealed class RegularPolygon extends Polygon { }
final class Hexagon extends Polygon { }
sealed class Rectangle extends Shape permits Square{ }
final class Square extends Rectangle { }
(대체 애노테이션 문법을 보려면 클릭)
@Sealed(permittedSubclasses=[Circle,Polygon,Rectangle]) class Shape { }
final class Circle extends Shape { }
class Polygon extends Shape { }
@NonSealed class RegularPolygon extends Polygon { }
final class Hexagon extends Polygon { }
@Sealed(permittedSubclasses=Square) class Rectangle extends Shape { }
final class Square extends Rectangle { }
이 예시에서 Shape의 허용된 서브클래스는 Circle, Polygon, Rectangle이에요. Circle은 final이므로 계층의 그 부분은 확장될 수 없어요. Polygon은 암시적으로 non-sealed이고, RegularPolygon은 명시적으로 non-sealed로 표시돼요. 이는 우리 계층이 서브클래싱으로 추가 확장에 열려 있음을 의미해요. Polygon → RegularPolygon과 RegularPolygon → Hexagon에서 볼 수 있듯이요. Rectangle은 그 자체가 sealed이며, 이는 계층의 그 부분은 확장될 수 있지만 통제된 방식으로만(오직 Square만 허용) 확장될 수 있음을 의미해요.
봉인 클래스는 인스턴스별 데이터를 포함해야 하는 enum-유사 관련 클래스를 만들 때 유용해요. 예를 들어 다음 enum이 있을 수 있어요.
enum Weather { Rainy, Cloudy, Sunny }
def forecast = [Weather.Rainy, Weather.Sunny, Weather.Cloudy]
assert forecast.toString() == '[Rainy, Sunny, Cloudy]'
하지만 이제 날씨 예보에 날씨별 인스턴스 데이터도 추가하고 싶다고 해보세요. 우리의 추상화를 이렇게 바꿀 수 있어요.
sealed abstract class Weather { }
@Immutable(includeNames=true) class Rainy extends Weather { Integer expectedRainfall }
@Immutable(includeNames=true) class Sunny extends Weather { Integer expectedTemp }
@Immutable(includeNames=true) class Cloudy extends Weather { Integer expectedUV }
def forecast = [new Rainy(12), new Sunny(35), new Cloudy(6)]
assert forecast.toString() == '[Rainy(expectedRainfall:12), Sunny(expectedTemp:35), Cloudy(expectedUV:6)]'
봉인 계층은 다음 예시처럼 대수 데이터 타입 또는 추상 데이터 타입(ADT)을 지정할 때도 유용해요.
import groovy.transform.*
sealed interface Tree<T> {}
@Singleton final class Empty implements Tree {
String toString() { 'Empty' }
}
@Canonical final class Node<T> implements Tree<T> {
T value
Tree<T> left, right
}
Tree<Integer> tree = new Node<>(42, new Node<>(0, Empty.instance, Empty.instance), Empty.instance)
assert tree.toString() == 'Node(42, Node(0, Empty, Empty), Empty)'
봉인 계층은 다음 예시처럼 레코드와 잘 작동해요.
sealed interface Expr {}
record ConstExpr(int i) implements Expr {}
record PlusExpr(Expr e1, Expr e2) implements Expr {}
record MinusExpr(Expr e1, Expr e2) implements Expr {}
record NegExpr(Expr e) implements Expr {}
def threePlusNegOne = new PlusExpr(new ConstExpr(3), new NegExpr(new ConstExpr(1)))
assert threePlusNegOne.toString() == 'PlusExpr[e1=ConstExpr[i=3], e2=NegExpr[e=ConstExpr[i=1]]]'
7.1. Java와의 차이
- Java는 봉인 클래스의 서브클래스에 대한 기본 수식어를 제공하지 않고 final, sealed 또는 non-sealed 중 하나를 명시하도록 요구해요. Groovy는 non-sealed를 기본으로 하지만, 원한다면 non-sealed/@NonSealed를 여전히 쓸 수 있어요. 우리는 스타일 검사 도구 CodeNarc가 결국 non-sealed의 존재를 찾는 규칙을 가질 것으로 예상하므로, 더 엄격한 스타일을 원하는 개발자는 원한다면 CodeNarc와 그 규칙을 사용할 수 있을 거예요
- 현재 Groovy는 permittedSubclasses에 언급된 모든 클래스가 컴파일 타임에 사용 가능하고 기본 봉인 클래스와 함께 컴파일되는지 확인하지 않아요. 이는 미래 Groovy 버전에서 바뀔 수 있어요
Groovy는 "네이티브" 봉인 클래스뿐 아니라 클래스를 sealed로 애노테이션하는 것도 지원해요.
@SealedOptions 애노테이션은 세 값 중 하나를 가질 수 있는 mode 애노테이션 속성을 지원해요(AUTO가 기본).
NATIVE — Java가 할 것과 비슷한 클래스를 생성해요. JDK17보다 이른 JDK에서 컴파일하면 오류를 생성해요.
EMULATE — @Sealed 애노테이션을 사용해 클래스가 sealed임을 나타내요. 이 메커니즘은 JDK8+에서 Groovy 컴파일러와 동작하지만 Java 컴파일러는 인식하지 못해요.
AUTO — JDK17+에서는 네이티브 레코드를, 그 외에는 에뮬레이션을 생성해요.
sealed 키워드를 쓰든 @Sealed 애노테이션을 쓰든 mode와는 독립적이에요.