본문으로 건너뛰기

boj-boot 라이브러리, Gradle 플러그인 개발기

· 약 22분
VSFe
블로그 주인장

최근에, boj-boot 라는 이름의 라이브러리와 Gradle 플러그인을 개발했다. 라이브러리와 Gradle 플러그인은 개발 자체가 처음이기도 했고, 생각보다 자료도 많지 않아서 다양한 삽질을 했다...

사실 이 글의 작성 목적은 개발에 대한 회고 목적 보단, 개발 과정에서 학습하고 고민한 JUnit5 과 Gradle 에 대한 정보를 공유하기 위함 으로, 해당 플랫폼에 대해 관심이 있다면, 간단하게 읽어보면 좋을 것 같다.

Overview

이번 boj-boot 의 목적은, 자바로 알고리즘 문제를 푸는 사람들이, 코드 수정할 때 마다 일일히 테스트 케이스를 넣는 것이 불편하지 않을까? 였다. 당장 프로그래머스가 좋은 환경을 제공하고 있는 만큼, Java 개발 환경에서도 비슷하게 구현할 수 있을 것이라는 생각이 들었다.

  • 시간 제한, 테스트 케이스 같은 정보는 백준 크롤링만 하면 쉽게 땡겨올 수 있고,
  • 입력에 대한 출력 결과를 String 으로 뺄 수만 있다면 일반적인 테스트 코드로도 정답 여부를 검증할 수 있다.

따라서 크게 문제가 될 게 없었다.

Library vs Plugin

이 프로젝트를 적용한 build.gradle.kts 를 보자.

plugins {
id("java")
}

group = "org.vsfe"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("io.github.vsfe:boj-boot-plugin:1.0.3")
}
}

apply(plugin = "io.github.vsfe.boj-boot-plugin")

dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
implementation("io.github.vsfe:boj-commons:1.0.2")
}

tasks.test {
useJUnitPlatform()
}
  • 위쪽의 plugins 에 등록하지 못하고 하단의 apply 를 사용하여 plugin 으로 등록하게 된 이유는, gradle plugin portal 에 등록이 되지 않았기 때문이다. (해당 내용은 후술함.)

도대체 pluginsdependencies 는 무슨 차이일까?

  • 간단하게 설명하자면, Gradle plugin 은 일종의 스크립트 와 유사한 동작을 한다.
  • 반대로 dependencies (라이브러리) 는 일반적인 외부 의존성을 떠올리면 된다.

결과적으로 원하는 작업을 하기 위해선 둘 모두가 필요했는데,

  • 백준 데이터를 크롤링 하여 코드를 자동 생성해줘야 했고, (Gradle Plugin)
  • 이 과정에서 테스트 코드에 추가적인 작업이 필요했기에 사용할 수 있는 코드가 필요했다. (라이브러리)

자, 그렇다면 개발 단계를 조금만 더 살펴보자.

라이브러리 개발 - JUnit5 분해하기

모든 코드를 분석할 건 아니지만, (사실 별거 없기도 하고) 중요한 핵심 로직에 대해서 언급하고 설명하고자 한다.

테스트 구조 잡기

백준 문제 풀이는 다음과 같이 진행된다.

  • 입력을 받는다.
  • 로직을 처리한다.
  • 결과를 출력한다.

결국 입력값이 어떻게 되냐에 따라 출력값이 달라질텐데, 이를 테스트 코드에서 어떻게 검증할 수 있을까? 일반적인 유닛 테스트와 달리 어떤 파라미터 값을 넣어서 함수를 호출하는 것이 아니기 때문에, 다소 어려울 수 있을 것이다.

하지만, 의외로 어렵지 않다. 잠깐 JUnit의 몇몇 어노테이션을 살펴보자.

  • @BeforeAll: 해당 테스트 클래스의 모든 테스트가 시작되기 전, 실행되는 메서드
  • @BeforeEach: 각각의 테스트가 실행되기 전, 실행되는 메서드
  • @AfterAll: 해당 테스트 클래스의 모든 테스트가 수행된 후, 실행되는 메서드
  • @AfterEach: 각각의 테스트가 실행된 후, 실행되는 메서드

따라서, 테스트가 수행되는 LifeCycle은 다음과 같다.

(BeforeAll 실행) ->
(테스트 #1에 대한 BeforeEach 실행) -> (테스트 #1 실행) -> (테스트 #1에 대한 AfterEach 실행) ->
(테스트 #2에 대한 BeforeEach 실행) -> (테스트 #2 실행) -> (테스트 #2에 대한 AfterEach 실행) ->
(AfterAll 실행)

자세한 이야기를 하기 전, 결론부터 말하자면 BeforeEach와 AfterAll 에서 stdin/stdout 을 바꿔치기 함으로써 테스트를 구현할 수 있었다.

테스트 메서드 그리기

JDK의 System.java 를 열어보면, 다음과 같은 메서드가 있다.

private static native void setIn0(InputStream in);
private static native void setOut0(PrintStream out);
private static native void setErr0(PrintStream err);

native 메서드 이기 때문에 실 구현체는 JDK와 아키텍쳐에 따라 다를 것이다. 다만 해당 메서드를 호출하고 있는 다른 메서드의 로직/주석을 통해 확인해보면, 다음과 같은 내용을 정리할 수 있다.

  • System 안에는 public static 인스턴스인 in, out, err 가 존재한다.
  • 처음 System 객체가 생성되는 시점엔, null 값으로 주어진다.
  • thread initialize 작업이 완료된 후 내부적으로 initPhase1() 이라는 메서드가 호출된다.
    • 해당 메서드 내부에서 in, out, err 이 FileDesciptor 의 stdin, stdout, stderr 로 덮어 씌여진다.
    • 참고로 해당 FileDescriptor 는 하단과 같이 정의되어 있다.
    /**
* A handle to the standard input stream. Usually, this file
* descriptor is not used directly, but rather via the input stream
* known as {@code System.in}.
*
* @see java.lang.System#in
*/
public static final FileDescriptor in = new FileDescriptor(0);

/**
* A handle to the standard output stream. Usually, this file
* descriptor is not used directly, but rather via the output stream
* known as {@code System.out}.
* @see java.lang.System#out
*/
public static final FileDescriptor out = new FileDescriptor(1);

/**
* A handle to the standard error stream. Usually, this file
* descriptor is not used directly, but rather via the output stream
* known as {@code System.err}.
*
* @see java.lang.System#err
*/
public static final FileDescriptor err = new FileDescriptor(2);
  • 이후 필요할 시 in, out, err는 setter 메서드로 덮어 씌워버릴 수 있다.

그렇다면 Global 하게 입/출력 결과를 홀딩할 수 있는 어떤 객체가 있다면,

  • 테스트 전에 System.in, System.out을 바꿔치기 한다.
    • System.in 을 주어진 파일에 대한 데이터를 읽도록 바꿔치기 하고, System.out은 결과를 String 형태로 꺼낼 수 있도록 바꿔치기 한다.
  • 테스트를 수행한다.
  • 테스트 이후에 System.in, System.out을 원상복구 한다.

잠깐, 그렇다면 언제 결과를 검증해야 할까? 이 질문에 대한 답을 하기 전, 역으로 질문 하나를 해보겠다.

상술한 테스트의 Life-Cycle 에서, Junit5 가 인식하는 테스트 하나의 범위는 어디까지일까?

정답은 BeforeEach ~ AfterEach 까지의 모든 구간이다. 즉, AfterEach 에서 테스트 결과를 비교해도, 정상적으로 해당 테스트로 인식한다 는 것이다. 따라서 다시 한 번 테스트 구조를 작성하면,

  • @BeforeEach 에서 System.in, System.out을 바꿔치기 한다.
  • 테스트를 수행한다.
  • @AfterEach 에서 테스트 이후에 System.in, System.out을 원상복구 하고, 바꿔치기 했던 OutputStream 에 기록된 문자열을 꺼내서 원하는 결과와 비교한다.

@ExtendWith 에 들어갈 친구 직접 만들기

위에서 @BeforeEach@AfterEach를 사용하면 테스트 구조를 잡을 수 있다고 했으나, 사실 여전히 해결되지 않은 문제가 있다.

  • 테스트케이스의 수가 변동될 수 있는데, 그렇다면 테스트케이스 하나에 대응하는 테스트를 일일히 만들어야 하는가?
  • 맞는 테스트케이스 폴더를 가져오기 위해선 문제 번호를 알아내야 하는데, 이 정보는 어떻게 가져올 수 있는가?

사실 하나의 테스트 코드로 일부 파라미터만 변경하여 테스트를 돌리는 방법은 이미 존재한다. (@ParameterizedTest 어노테이션을 사용하면 되는데, 자세한 것은 https://www.baeldung.com/parameterized-tests-junit-5 을 참고하자.)

다만... 다소 어려운 이유로 @ParametrizedTest를 사용할 수 없었기에, (간단하게 설명하면, @BeforeEach 에서 파라미터 정보를 가져올 수 없기에, 몇 번 테스트케이스 파일을 가져올지 판단할 수 없다는 문제가 있었다.) 다른 방법을 사용할 필요가 있었다. 또한, 가능하면 @BeforeEach@AfterEach 를 감출 수 있다면, 유저 입장에서의 테스트 코드가 훨씬 깔끔해질 것 같기도 했다.

Mockito를 사용한 적이 있다면, 이 코드가 매우 익숙할 것이다.

@ExtendWith(MockitoExtension.class)
public class TestClass {

}

@ExtendWith에는 Extension 을 상속하는 여러 인터페이스를 구현한 클래스를 넣을 수 있는데, 어떤 인터페이스가 있는지 확인해보자.

  • BeforeAllCallback, BeforeEachCallback, AfterAllCallback, AfterEachCallback: @BeforeAll, @BeforeEach, @AfterAll, @AfterEach 메서드를 클래스에 정의하는 것이 아닌, 내부적으로 사용할 수 있도록 하기 위한 인터페이스 (구현한 클래스를 @ExtendWith 에 넣을 경우, 해당 콜백을 통해 등록한 메서드가 정해진 Life-Cycle 에 실행된다.)
  • BeforeTestExecutionCallback, AfterTestExecutionCallback: 상단의 네 메서드와 다르게, 유저가 정의한 @BeforeEach@AfterEach 와 별도로 정의하고 싶은 것이 있을 때 사용하는 인터페이스
    • 정확히 말하자면, BeforeTestExecutionCallback@BeforeEach 이후에, AfterTestExecutionCallback@AfterEach 이전에 호출된다.
  • ExecutionCondition: 특정 테스트의 실행 여부를 결정할 때 사용하는 인터페이스로, 리턴값에 따라 @Disabled 어노테이션과 비슷한 역할을 할 수 있다.
  • InvocationInterceptor: 테스트 Life-Cycle 과정 중간 과정을 Intercept 하여, 다양한 추가 작업을 하도록 하는 인터페이스.
  • LifecycleMethodExecutionExceptionHandler, TestExecutionExceptionHandler: 테스트 도중 발생한 Exception 을 처리하는 방법을 정의하도록 하는 인터페이스
    • 전자는 @BeforeAll, @BeforeEach, @AfterAll, @AfterEach 에 대하여, 후자는 테스트 실행 과정에 대해 정의한다.
  • TestInstanceFactory, TestInstancePostProcessor, TestInstancePreConstrctCallback, TestInstancePreDestroyCallback: 테스트의 생명주기를 정의하도록 하는 TestInstance 를 동적으로 생성/관리 하도록 하는 인터페이스
  • ParameterResolver: 테스트에 파라미터가 주어지는 경우, 해당 값을 처리하도록 하는 인터페이스
  • TestWatcher: 테스트 결과를 처리하도록 하는 인터페이스
  • TestTemplateInvocationContextProvider: @TestTemplate 으로 정의된 테스트 템플릿에 대해, 실행 환경을 정의하도록 하는 인터페이스

이외에도 다양한 구현체가 있으므로, 더 자세한 것은 공식문서를 살펴보자.

동적인 테스트를 만들기 위해선, @ParametrizedTest를 쓸 수도 있지만, 상술했듯이 @BeforeEach 에서 테스트의 Context를 읽을 수 없다는 문제가 있었다. 따라서, @TestTemplate 으로 기본 틀을 만들고, 내부적으로 TestTemplateInvocationContextProvider를 구현하여 테스트를 동적으로 생성하도록 변경했다.

(참고로, @DynamicTest 등의 방법도 존재하지만, 이 경우에는 우리가 통제할 수 없는 요소가 너무 많아 채택하지 않았다.)

이런식으로 최대한 내부를 감추고, 추상화를 최대한 하게 되면, 다음과 같은 테스트 코드가 만들어진다.

@BojTest(problemNumber = 1000)
@ExtendWith(BojTestExtension.class)
public class Boj1000Test {
@TestTemplate
@DisplayName("BOJ 1000")
@Timeout(value = 5000L, unit = TimeUnit.MILLISECONDS)
void test() throws Exception {
Main.main(new String[0]);
}
}

내부 코드를 일일히 설명하는 것은 비효율적이므로, 간단하게 동작 과정을 설명하면 다음과 같다.

  • @BojTest 의 problemNumber 파라미터를 읽어 문제 번호를 가져온다.
  • resources/testcase/p{problemNumber} 폴더에 존재하는 파일의 목록을 가져오고, 이를 통해 테스트의 수를 결정한다.
  • 각각의 테스트 마다 상술한 방식으로 @BeforeEach@AfterEach를 구현한다. 이 때, 이 둘은 callback 형태로 구성되어 있으므로 내부적으로 변수를 가질 수 있고, 이에 객체를 생성할 때 context를 멤버 변수로 주입하는 방식으로 호출하는 테스트케이스의 정보를 가져올 수 있다.

결국 이로 인해 사용자는 테스트 코드를 수정하지 않아도 테스트케이스 파일에 따라 자연스럽게 테스트의 수를 조절할 수 있고, 테스트 파일을 생성하는 과정에서도 특별한 로직 없이 문제 번호와 시간 제한만 있으면 쉽게 테스트 파일을 생성할 수 있게 되었다.

Gradle Plugin 개발 - Gradle 조금 더 살펴보기

다음은 Gradle Plugin 으로 넘어가자. 사실 Gradle Plugin는 깊게 설명할 부분이 없어서, 가볍게 설명하고 넘어가려고 한다.

상술했듯이 Gradle Plugin 은 어떤 일련의 행동을 수행하는 도구라고 말할 수 있는데, 어쨌거나 Gradle에 종속적인 만큼 해당 프로젝트에 대한 메타데이터 정보를 모두 갖고 있다는 특징이 있다. 그렇기에 Plugin 을 통해 문제 파일을 생성하기 위한 메타데이터를 어렵지 않게 추출할 수 있었다.

Plugin 의 구조는 다음과 같다. (Kotlin 코드라 Java 유저는 당황할 수 있지만, Java로도 작성할 수 있으니 너무 걱정하지 말자.)

class BojBootPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.tasks.register("boj", BojTask::class.java) { task ->
task.group = "boj"
task.description = "test"
}
}
}

abstract class BojTask : DefaultTask() {
@TaskAction
fun generateFiles() {
// Do something...
}
}
  • Plugin 을 통해 해당 Plugin 의 기본 정보를 정의한다.
  • Task 를 통해 수행하는 일련의 작업을 정의한다.
    • DefaultTaskTask를 상속한 추상 클래스로, 특별한 설정이 필요하지 않다면 위 처럼 사용해도 무방하다.
    • 내부적으로 project 라는 변수를 갖고 있는데, 해당 변수는 프로젝트의 거의 모든 메타데이터를 가지고 있다.
      • 특히 maintest 의 실제 디렉토리 정보를 가져올 수 있기 때문에, 어렵지 않게 파일 추가가 가능했다.

플러그인 Publish 하기

MavenCentral 을 통해 어렵지 않게 Publish 할 수 있는 라이브러리와 달리, Gradle Plugin 은 약간 다른 방법을 통해 배포해야 한다.

  • Gradle Plugin Portal 을 통해 배포한다.
  • 다만, 배포할 때 심사 과정이 포함되며, 심사 기준을 만족하지 못하면 Reject 될 수 있다.
  • 특히, 첫 심사는 상당한 시간이 소요된다. (직접 확인하는 것으로 보인다.)

이번에 개발한 플러그인은 너무 특수 목적이기도 하고, 국내가 아니면 사용하는 경우도 극히 드물 것이므로 심사를 기다리는 건 썩 좋은 선택이 아니라고 봤다.

따라서, MavenCentral 을 통해 일반 라이브러리 처럼 배포를 하되, 받아서 사용하는 사용자가 이걸 플러그인으로 인식할 수 있도록 Gradle 설정을 바꿔줘야 했다.

플러그인 우회해서 사용하기

다시 맨 위에 있던 build.gradle.kts를 살펴보자.

plugins {
id("java")
}

group = "org.vsfe"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("io.github.vsfe:boj-boot-plugin:1.0.3")
}
}

apply(plugin = "io.github.vsfe.boj-boot-plugin")

dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
implementation("io.github.vsfe:boj-commons:1.0.2")
}

tasks.test {
useJUnitPlatform()
}

Gradle Plugin Portal 에 등록된 플러그인은 그냥 바로 최상단 plugins 에 사용할 수 있다. 하지만 우리의 boj-boot-plugin 은 다소 복잡한 방법으로 등록되는데, 이를 잠깐만 살펴보자.

  • buildscript는 Gradle이 어떤 방식으로 Build를 수행할지에 대해 정의하는 block 이다.
    • 코드나 스크립트가 들어갈 수 있는데, 우리는 외부에 존재하는 build script 파일을 가져오는 전략을 취한다.
    • 이는 다시 말해서, 플러그인 내부에 존재하는 build.gradle.kts를 로드하는 의미이다.
    • 따라서, 해당 파일을 어디서 다운 받을 수 있는지, (repositories) 어떤 파일인지 (dependencies) 에 대해서만 넘겨주는 것이다.
  • apply 는 script나 plugin 을 등록하기 위해 존재하는 gradle의 함수이다.
    • 정의한 plugin의 id를 넘김으로써, 해당 plugin 을 gradle 프로젝트에 적용되도록 한다.
    • 자세한 정보는 해당 페이지를 참고하자.

결론

사실 처음 개발할 땐 가볍게 생각하고 시작한 것이지만, JUnit 에 대해 저렇게나 깊게 공부할 것이라는 생각은 못했다 (...) Gradle Plugin 또한 블로그에서는 가볍게 적었지만, 개발하는 과정에선 상당한 삽질을 했기에 다소 고생을 했다.

뭐, 그래도 덕분에 재밌었다.