boj-boot 라이브러리, Gradle 플러그인 개발기
최근에, 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 에 등록이 되지 않았기 때문이다. (해당 내용은 후술함.)
도대체 plugins
와 dependencies
는 무슨 차이일까?
- 간단하게 설명하자면, Gradle plugin 은 일종의 스크립트 와 유사한 동작을 한다.
- 어떤 동작을 도와주는 거지, Spring-MVC나 JPA 처럼 어떤 코드가 새로 생기고, 그걸 활용하는 건 아니다.
- 자세한 사항은 해당 문서를 읽어보자.
- 반대로 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 에 기록된 문자열을 꺼내서 원하는 결과와 비교한다.