Groovy 프로그램 구조

Groovy 프로그램 구조

이 장에서는 Groovy 프로그래밍 언어의 프로그램 구조에 대해 다뤄요.

⚠️ 원문 페이지(groovy-lang.org/program-structure.html)는 404로 바뀌어, 현재 실재하는 Program structure 페이지를 기준으로 번역했어요.

출처: Groovy 공식 문서

본문

1. 패키지 이름

패키지 이름은 Java에서와 똑같은 역할을 해요. 코드 베이스를 충돌 없이 나눌 수 있게 해 주죠. Groovy 클래스는 클래스 정의 전에 패키지를 명시해야 하며, 명시하지 않으면 기본 패키지로 간주됩니다.

패키지를 정의하는 건 Java와 아주 유사해요.

// com.yoursite라는 패키지 정의
package com.yoursite

com.yoursite.com 패키지에 있는 어떤 Foo 클래스를 참조하려면 완전한 이름 com.yoursite.com.Foo를 써야 해요. 아니면 아래에서 볼 import 문을 사용할 수도 있습니다.

2. 임포트(Imports)

어떤 클래스든 참조하려면 그 패키지에 대한 자격 있는 참조가 필요해요. Groovy는 Java의 개념을 따라 import 문으로 클래스 참조를 해석할 수 있게 합니다.

예를 들어 Groovy는 MarkupBuilder 같은 여러 빌더 클래스를 제공해요. MarkupBuildergroovy.xml 패키지 안에 있으므로, 이 클래스를 사용하려면 다음과 같이 import 해야 합니다.

// 클래스 MarkupBuilder 임포트
import groovy.xml.MarkupBuilder

// 임포트한 클래스를 사용해 객체 생성
def xml = new MarkupBuilder()

assert xml != null

2.1. 기본 임포트(Default imports)

기본 임포트는 Groovy 언어가 기본으로 제공하는 임포트예요. 다음 코드를 볼게요.

long time = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)
Date date = new Date(time)

같은 코드를 Java로 쓰면 Date 클래스에 import java.util.Date; 같은 임포트 문이 필요해요. 하지만 Groovy는 이런 클래스들을 기본으로 임포트해 줍니다.

Groovy가 여러분 대신 추가해 주는 임포트는 다음과 같습니다.

import java.lang.*
import java.util.*
import java.io.*
import java.net.*
import java.time.*
import groovy.lang.*
import groovy.util.*
import java.math.BigInteger
import java.math.BigDecimal

이 패키지들의 클래스가 가장 흔히 쓰이기 때문이에요. 이렇게 기본 임포트가 되면 상용구 코드가 줄어듭니다.

2.2. 단순 임포트(Simple import)

단순 임포트는 클래스 이름을 패키지와 함께 완전히 명시하는 임포트 문이에요. 아래 코드의 import groovy.xml.MarkupBuilder 문이 바로 패키지 안의 클래스를 직접 가리키는 단순 임포트예요.

// 클래스 MarkupBuilder 임포트
import groovy.xml.MarkupBuilder

// 임포트한 클래스를 사용해 객체 생성
def xml = new MarkupBuilder()

assert xml != null

2.3. 별표 임포트(Star import)

Groovy는 Java처럼 *를 사용해 패키지의 모든 클래스를 임포트하는 특별한 방법을 제공해요. 이를 주문형(on-demand) 또는 별표(star) 임포트라고 해요. MarkupBuildergroovy.xml 패키지에 있는데, 그 옆에 StreamingMarkupBuilder라는 클래스도 있죠. 두 클래스를 모두 써야 한다면 이렇게 할 수 있어요.

import groovy.xml.MarkupBuilder
import groovy.xml.StreamingMarkupBuilder

def markupBuilder = new MarkupBuilder()

assert markupBuilder != null

assert new StreamingMarkupBuilder() != null

완전히 유효한 코드예요. 하지만 * 임포트를 쓰면 한 줄로 같은 효과를 낼 수 있어요. 별표는 groovy.xml 패키지 아래의 모든 클래스를 임포트해 줍니다.

import groovy.xml.*

def markupBuilder = new MarkupBuilder()

assert markupBuilder != null

assert new StreamingMarkupBuilder() != null

* 임포트의 한 가지 문제는 로컬 네임스페이스를 어지럽힐 수 있다는 거예요. 하지만 Groovy가 제공하는 별칭(aliasing)을 쓰면 쉽게 해결할 수 있습니다.

2.4. 정적 임포트(Static import)

Groovy의 정적 임포트 기능은 임포트한 클래스를 마치 여러분 자신의 클래스에 있는 정적 메서드처럼 참조할 수 있게 해 줍니다.

import static java.lang.Boolean.FALSE

assert !FALSE //Boolean 접두사 없이 직접 사용!

이건 Java의 정적 임포트 기능과 비슷하지만, Java보다 더 동적이에요. 임포트한 메서드와 같은 이름이지만 다른 타입을 가진 메서드를 정의할 수 있게 해 주기 때문이죠.

import static java.lang.String.format (1)

class SomeClass {

    String format(Integer i) { (2)
        i.toString()
    }

    static void main(String[] args) {
        assert format('String') == 'String' (3)
        assert new SomeClass().format(Integer.valueOf(1)) == '1'
    }
}

(1) — 메서드의 정적 임포트

(2) — 위에 정적으로 임포트한 메서드와 같은 이름이지만 다른 매개변수 타입을 가진 메서드의 선언

(3) — Java에서는 컴파일 오류지만, 유효한 Groovy 코드예요

만약 시그니처가 같으면, 임포트된 메서드가 우선합니다.

2.5. 정적 임포트 별칭(Static import aliasing)

as 키워드를 쓰는 정적 임포트는 네임스페이스 문제에 대한 우아한 해결책을 제공해요. Calendar 인스턴스를 그 getInstance() 메서드로 얻고 싶다고 해볼게요. 이건 정적 메서드이므로 정적 임포트를 쓸 수 있어요. 하지만 클래스 이름과 분리되면 오해를 부를 수 있는 getInstance()를 매번 호출하는 대신, 별칭으로 임포트해서 코드 가독성을 높일 수 있어요.

import static Calendar.getInstance as now

assert now().class == Calendar.getInstance().class

이제 깔끔하죠!

2.6. 정적 별표 임포트(Static star import)

정적 별표 임포트는 일반 별표 임포트와 아주 비슷해요. 주어진 클래스의 모든 정적 멤버를 임포트해 줍니다.

예를 들어 애플리케이션에서 사인과 코사인을 계산해야 한다고 해볼게요. java.lang.Math 클래스에는 우리의 필요에 맞는 sincos라는 정적 메서드가 있어요. 정적 별표 임포트를 쓰면 이렇게 할 수 있습니다.

import static java.lang.Math.*

assert sin(0) == 0.0
assert cos(0) == 1.0

보시다시피 Math. 접두사 없이 sincos 메서드에 직접 접근할 수 있었어요.

2.7. 임포트 별칭(Import aliasing)

타입 별칭을 쓰면 완전한 클래스 이름을 우리가 고른 이름으로 참조할 수 있어요. 앞에서처럼 as 키워드로 할 수 있죠.

예를 들어 java.sql.DateSQLDate로 임포트하면, java.util.Date와 같은 파일에서 두 클래스의 완전한 이름을 쓰지 않고 사용할 수 있어요.

import java.util.Date
import java.sql.Date as SQLDate

Date utilDate = new Date(1000L)
SQLDate sqlDate = new SQLDate(1000L)

assert utilDate instanceof java.util.Date
assert sqlDate instanceof java.sql.Date

2.8. 네임스페이스 충돌

Java와 비슷하게, Groovy에서도 같은 이름이지만 다른 타입을 가진 여러 임포트를 지정하는 것은 오류예요.

import java.awt.List
import java.util.List // 오류: 이름이 이미 선언됨

그리고 같은 이름의 임포트와 최상위 타입을 선언하는 것도 오류입니다.

import java.util.List
class List { } // 오류: 이름이 이미 선언됨

하지만 내부 타입은 유닛 스코프의 이름을 가릴 수 있어요.

import java.util.List
class Main {
    class List { } // 허용됨; "List"는 `Main`의 스코프 안에서는 이 타입을, 그 밖에서는 java.util.List를 가리킴
}

3. 스크립트와 클래스

Groovy는 스크립트와 클래스 둘 다 지원해요. Groovy 5부터는 JEP 445 호환 스크립트도 지원합니다.

3.1. 스크립트의 동기

다음 코드를 예로 들어볼게요.

class Main {                                    (1)
    static void main(String... args) {          (2)
        println 'Groovy world!'                 (3)
    }
}

(1) — Main 클래스를 정의해요. 이름은 아무거나 괜찮아요

(2) — public static void main(String[]) 메서드는 클래스의 main 메서드로 사용 가능해요

(3) — 메서드의 본문

이건 Java에서 볼 수 있는 전형적인 코드예요. Java에서는 코드를 실행하려면 반드시 클래스에 넣어야 하죠. Groovy는 그걸 더 쉽게 만들어요. 다음 코드는 동일합니다.

println 'Groovy world!'

스크립트는 클래스를 명시적으로 선언하지 않아도 되는 클래스로 생각할 수 있어요. 몇 가지 차이점이 있는데, 곧 다룰게요. 먼저 Groovy의 주요 Script 클래스를 살펴보고, 그다음 JEP 445 호환 클래스를 다룰게요.

3.2. Script 클래스

groovy.lang.Script는 항상 클래스로 컴파일됩니다. Groovy 컴파일러가 클래스를 컴파일해 주는데, 스크립트 본문은 run 메서드에 복사돼요. 따라서 앞선 예시는 마치 다음과 같이 컴파일되는 셈이에요.

import org.codehaus.groovy.runtime.InvokerHelper
class Main extends Script {                     (1)
    def run() {                                 (2)
        println 'Groovy world!'                 (3)
    }
    static void main(String[] args) {           (4)
        InvokerHelper.runScript(Main, args)     (5)
    }
}

(1) — Main 클래스는 groovy.lang.Script 클래스를 상속해요

(2) — groovy.lang.Script는 값을 반환하는 run 메서드를 요구해요

(3) — 스크립트 본문이 run 메서드로 들어가요

(4) — main 메서드는 자동으로 생성돼요

(5) — 그리고 스크립트 실행을 run 메서드에 위임해요

스크립트가 파일에 있으면, 파일의 기본 이름이 생성된 스크립트 클래스의 이름을 결정해요. 이 예시에서 파일 이름이 Main.groovy라면 스크립트 클래스는 Main이 됩니다.

3.3. 메서드

이렇게 스크립트 안에 메서드를 정의할 수 있어요.

int fib(int n) {
    n < 2 ? 1 : fib(n-1) + fib(n-2)
}
assert fib(10)==89

메서드와 코드를 섞을 수도 있어요. 생성된 스크립트 클래스는 모든 메서드를 스크립트 클래스로 가져오고, 모든 스크립트 본문을 run 메서드에 모아요.

println 'Hello'                                 (1)

int power(int n) { 2**n }                       (2)

println "2^6==${power(6)}"                      (3)

(1) — 스크립트 시작

(2) — 스크립트 본문 안에 메서드 정의

(3) — 스크립트 계속

문장 1과 3은 때때로 "느슨한(loose)" 문장이라고 불러요. 명시적인 둘러싸는 메서드나 클래스 안에 들어 있지 않기 때문이죠. 느슨한 문장들은 순서대로 run 메서드에 모입니다.

따라서 위 코드는 내부적으로 다음과 같이 변환됩니다.

import org.codehaus.groovy.runtime.InvokerHelper
class Main extends Script {
    int power(int n) { 2** n}                   (1)
    def run() {
        println 'Hello'                         (2)
        println "2^6==${power(6)}"              (3)
    }
    static void main(String[] args) {
        InvokerHelper.runScript(Main, args)
    }
}

(1) — power 메서드는 생성된 스크립트 클래스에 그대로 복사돼요

(2) — 첫 번째 문장이 run 메서드에 복사돼요

(3) — 두 번째 문장이 run 메서드에 복사돼요

비록 Groovy가 스크립트에서 클래스를 만든다고 해도, 그건 사용자에게 완전히 투명해요. 특히 스크립트는 바이트코드로 컴파일되고 줄 번호가 보존됩니다. 즉 스크립트에서 예외가 발생하면 스택 트레이스는 우리가 보여준 생성된 코드가 아니라 원래 스크립트에 해당하는 줄 번호를 보여줘요.

3.4. 변수

스크립트의 변수는 타입 정의가 필요 없어요. 즉 이 스크립트는

int x = 1
int y = 2
assert x+y == 3

다음과 동일하게 동작합니다.

x = 1
y = 2
assert x+y == 3

하지만 둘 사이에는 의미적 차이가 있어요.

  • 첫 번째 예시처럼 변수를 선언하면 그건 지역 변수예요. 컴파일러가 생성할 run 메서드에 선언되고, 스크립트의 메인 본문 밖에서는 보이지 않죠. 특히 그런 변수는 스크립트의 다른 메서드에서는 보이지 않아요
  • 변수를 선언하지 않으면 groovy.lang.Script#getBinding()으로 들어가요. binding은 메서드에서 보이고, 특히 스크립트를 애플리케이션과 상호작용하는 데 쓰면서 스크립트와 애플리케이션 사이에 데이터를 공유해야 할 때 중요해요. 더 자세한 내용은 통합 가이드를 참고하세요

모든 메서드에서 변수를 보이게 하는 또 다른 방법은 @Field 애노테이션을 쓰는 거예요. 이렇게 애노테이션된 변수는 생성된 스크립트 클래스의 필드가 되고, 지역 변수처럼 접근에 Script Binding이 관여하지 않아요. binding 변수와 같은 이름의 지역 변수나 스크립트 필드가 있다면, 잠재적 혼동을 피하려고 둘 중 하나의 이름을 바꾸는 걸 권장해요. 그게 불가능하면 binding.varName을 사용해 binding 변수에 접근할 수 있어요.

3.5. 편의 변형(Convenience variations)

앞서 말했듯이, 보통 public static void mainrun 메서드가 스크립트에 자동으로 추가돼요. 그래서 둘 중 하나를 직접 추가하는 건 보통 불법이고, 시도하면 중복 메서드 컴파일 오류가 나요.

하지만 위 규칙이 적용되지 않는 몇 가지 예외가 있어요. 스크립트가 오직 호환되는 main 메서드만 있고 다른 느슨한 문장이 없거나(Groovy 5부터는) 오직 인자 없는 run 인스턴스 메서드만 있으면 허용됩니다. 이 경우 느슨한 문장이 없으므로(애초에 없으니까) run 메서드에 모을 게 없어요. 여러분이 제공한 메서드가 Groovy가 해당 메서드를 추가하는 대신 사용됩니다.

이건 암시적으로 추가되는 main이나 run 메서드에 애노테이션을 붙여야 할 때 유용할 수 있어요. 다음 예시처럼요.

@CompileStatic
static main(args) {
    println 'Groovy world!'
}

편의 변형으로 인정받으려면 느슨한 문장이 없어야 하며, main 메서드의 매개변수는:

  • 위처럼 타입이 없거나(Object 타입),
  • String[] 타입이거나,
  • 인자가 없어야 해요(Groovy 5부터).

Groovy 5부터는 인자 없는 인스턴스 run 변형도 지원돼요. 이것도 애노테이션 추가를 허용해요. run 변형은 필드 선언에 JEP 445 규칙을 따르는데(그래서 @Field 애노테이션을 쓸 필요가 없음), 다음처럼 Jackson JSON 직렬화를 다루는 예시를 보여드릴게요.

@JsonIgnoreProperties(["binding"])
def run() {
    var mapper = new ObjectMapper()
    assert mapper.writeValueAsString(this) == '{"pets":["cat","dog"]}'
}

public pets = ['cat', 'dog']

run 변형은 스크립트가 Script 클래스를 상속하고 스크립트 컨텍스트와 binding에 접근해야 할 때 권장돼요. 그런 요구 사항이 없다면 main 변형 중 하나를 제공하면 JEP 445 호환 클래스가 만들어지는데, 이는 Script를 상속하지 않아요. JEP 445 호환 스크립트는 다음에서 좀 더 자세히 다룰게요.

4. JEP 445 호환 스크립트

Groovy 5부터 main 메서드를 포함한 JEP 445 호환 스크립트에 대한 지원이 추가됐어요. 이런 스크립트는 일반 Groovy Script 클래스와 몇 가지 차이가 있습니다.

  • public static void main 메서드가 추가되지 않아요
  • Script 클래스를 상속하지 않으므로 스크립트 컨텍스트나 binding 변수에 접근할 수 없어요
  • main 외에도 클래스 수준의 필드와 메서드를 추가로 정의할 수 있어요
  • main 메서드 밖에 "느슨한" 문장을 둘 수 없어요(필드 정의는 제외)

간단한 예시는 이렇게 생겼어요.

void main(args) {
    println new Date()
}

추가 필드와 메서드가 있는 예시는 이렇게 생길 수 있어요.

def main() {
    assert upper(foo) + lower(bar) == 'FOObar'
}

def upper(s) { s.toUpperCase() }

def lower = String::toLowerCase
def (foo, bar) = ['Foo', 'Bar']      (1)

(1) — 다중 할당(multi-assignment) 문법이 지원되며, 각 컴포넌트마다 별도의 필드 정의로 이어진다는 점을 유의하세요.

4.1. Java JEP 445 동작과의 차이

Groovy의 JEP 445 지원과 Java가 제공하는 것 사이에는 몇 가지 차이가 있어요.

  • Java는 인자 없는 main 메서드 또는 단일 String[] 매개변수를 가진 main 메서드를 지원해요. Groovy는 단일 비타입(Object) 매개변수도 추가로 지원해요(예: def main(args) { …​ }). 이 추가 사항은 Groovy 러너가 알지만, JEP 445를 지원하는 JDK의 Java 런치 프로토콜은 모릅니다
  • Java는 void main 메서드를 지원해요. Groovy는 비타입 def(Object) 메서드(예: def main(…​))와 void main(…​)도 추가로 지원해요. 이 추가 사항도 Groovy 러너는 알지만, JEP 445를 지원하는 JDK의 Java 런치 프로토콜은 모릅니다
  • 정적 main 변형의 경우, Groovy는 인자 없음 또는 비타입 변형을 표준 public static void main(String[] args) 시그니처로 승격시켜요. 이는 JEP 445 지원이 추가되기 이전 버전의 Groovy와의 호환성을 위한 거예요. 그 결과 이런 클래스들은 JEP 445 지원 이전의 Java 런치 프로토콜과도 호환됩니다
  • Groovy 러너는 JEP 445 호환 클래스를 알 수 있게 되었고, JDK11 이상에서 미리보기 모드를 켜지 않고도 모든 변형을 실행할 수 있어요

더 알아보기