Skip to main content

2 posts tagged with "kotlin"

View All Tags

Java 에서의 스케쥴링, 어떻게 가능한 것인가?

· 20 min read
VSFe
블로그 주인장

우리는 Spring 에서 스케쥴링 잡을 수행하기 위해, @Scheduled 라는 어노테이션을 사용하곤 한다.

@Scheduled(cron = "0/5 * * * * ?")
public void scheduledCron() {
// do something...
}

@Scheduled(fixedDelay = 1000)
public void scheduledfixed() {
// do something...
}

그런데 상식적으로 생각을 해보면, 정말로 Java가 1ms 단위까지 세밀하게 스케쥴링을 할 수 있을까? 라는 의문이 들 수 있다.

Java 이야기는 아니지만 Redis/MongoDB 같은 데이터베이스의 TTL 또한 ms 단위로 넣을 수 있지만 실제로는 ms 단위로 데이터 체킹 및 삭제가 이뤄지지 않기도 한다. Redis는 기본적으로 사용자가 데이터 조회 시도 전 까지 삭제를 수행하지 않고, (주기적으로 샘플링하여 체킹하고 지우는 작업이 존재하지만, 이는 보조적인 역할이다.) MongoDB는 TTL Monitor 스레드가 주기적으로 데이터를 확인하여 제거한다. (ttlMonitorSleepSecs 파라미터에 따라 다르지만, 디폴트는 1분이다. 즉, 최대 59초까지는 데이터가 안 지워질 수 있다.)

결국, 수많은 상용 기술들은 스케쥴링 처리가 우리의 의도 처럼 정확한 시간에 수행된다 라고 말하기 어려운데, Java는 어떨까?

Java가 스케쥴링을 지원하는 방법

가장 간단한 생각 - Thread.sleep()

Java의 Thread 는 대부분의 실제 동작을 OS에 위임하고 있다. 그렇기에, Thread.sleep(millis, nanos) 와 같은 메서드는 OS 스케쥴러에 이를 위임하게 되고, 자연스럽게 주기적인 작업을 수행하도록 유도할 수 있을 것이다.

try {
while (true) {
Thread.sleep(5000);
// do something...
}
} catch (InterruptedException e) {
e.printStackTrace();
}

다만, 이 방법이 스케쥴링 관점에서 효율적일까?

  • 운영체제 관점에서, 인터럽트 된 스레드는 인터럽트 처리가 완료되어도 바로 실행되는게 아니라, 스케쥴링의 대상이 될 뿐이다. 즉, 5000ms 이후에 바로 실행됨이 보장되지 않는다.
  • Real-Time 운영체제가 아닌 일반적인 운영체제의 경우, 정확한 Real Time 스케쥴링이 어렵다. (사실 이 부분 때문에라도, ms 단위 스케쥴링의 정확성을 보장하는 것은 거의 불가능하다.)
  • 만약 우선순위를 설정했다면, (Thread.setPriority() 등으로) sleep 으로 시간 지연한 스레드가 바로 수행됨이 보장되지 않는다.
  • 스케쥴링 해야 하는 작업이 2개 이상이라면 각각의 작업에 대해 별도의 스레드가 필요한데, 각각의 스레드가 Sleep 처리가 되면 효율성이 매우 떨어진다.

사실 다른 사항들은 약간의 오차를 감안하고 넘어갈 수 있다고 쳐도, 마지막이 가장 치명적일 것이다. 스레드는 결국 자원을 차지하고 있기 때문에, 블로킹된 스레드의 수가 많으면 많아질 수록 프로그램의 전반적인 효율은 떨어질 것이다.

그렇기에, Java에서는 일반적으로 여러 작업을 병렬적으로 수행하기 위해서 스레드풀을 사용하고 있고, Java에서는 가장 기본적인 ThreadPoolExecutor를 상속한 ScheduledThreadPoolExecutor 를 통해 스레드풀을 사용하면서도 스케쥴링 잡을 수행할 수 있도록 구현하고 있다.

해당 도구의 구현을 좀 더 자세히 살펴보기 전, 위 글만 보면 생각할 수 있을 만한 “블로킹만 최소화 하면 좀 괜찮지 않을까?” 에 대한 해답을 알아보자.

방향 살짝 틀어보기 - Spin Lock

다음 코드를 보자.

var now = System.nanoTime();
while (true) {
var newTime = System.nanoTime();
if (newTime - now > 1_000) {
// do something
now = newTime;
}
}

이건 더 효율적인 솔루션일까?

결론만 말하자면 특정 상황에 따라 더 효율적일 수 있으나, Java 에선 대부분 비효율적이다.

  • 이론적으로는 아주 정확한 타이밍을 모니터링 할 수 있지만, Java 언어 자체가 Hard Real Time 에 적합한 언어는 아니라, 완벽한 정확성을 보장할 수 없다.
  • 그와 반비례하여 자원 소모량이 엄청나게 올라간다. 하나의 작업마다 저렇게 Spin Lock을 걸어버리면, OS 및 JVM 스케쥴러에 악영향을 주기 쉽다.

ScheduledThreadPoolExecutor - Java가 내놓은 해결책

일반적으로, Java에서 병렬처리를 구현하기 위해선 ThreadPoolExecutor 를 사용하는 편이다. 그렇다면 ScheduledThreadPoolExecutor는 어떤 차이를 갖고 있을까?

사실, ScheduledThreadPoolExecutorThreadPoolExecutor 를 상속한 클래스이다.

public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService 

그런데, 이 클래스가 ThreadPoolExecutor 와 비교하여 어떤 차이를 갖고 있는가?

  • ThreadPoolExecutorBlockingQueue<Runnable> 구조 (즉, 일반적인 큐)를 사용하고 있으나, ScheduledThreadPoolExecutor 는 자체적으로 큐를 새로 정의해서 사용한다. (DelayedWorkQueue) 해당 큐가 스케쥴링이 가능하게 된 핵심적인 구조이므로, 해당 큐에 대해서는 뒤에서 더 자세히 설명한다.
  • 또한, Runnable을 구현한 SchduledFutureTask 를 큐에 넣어 사용하고 있다.

image-20250330022818322

해당 다이어그램은 ScheduledFutureTask 의 구조를 표현한 것이다. 모든 구조에 대해서 이해할 필요는 없고, 전체적인 스케쥴러에 대한 이해를 위해 알아야 할 두 가지 특성만 알고 가도록 하자.

  • Delayed 인터페이스에는 getDelay(TimeUnit unit) 메서드가 있다. 이는 얼마나 더 기다려야 하는지를 반환하는 메서드이다.
  • RunnableScheduledFuture 인터페이스에는 isPeriodic() 메서드가 있다. 이는 이 작업이 한 번 수행되는 것이 아니라 주기적으로 실행되는지 여부를 반환하는 메서드이다.

저 두 메서드만 봐도, 이게 왜 스케쥴링이 가능한지 이해가 갈 것이다.

이제, 핵심이라고 할 수 있는 DelayedWorkQueue 에 대해 알아보자.

static class DelayedWorkQueue extends AbstractQueue<Runnable> implements BlockingQueue<Runnable> {

/*
* A DelayedWorkQueue is based on a heap-based data structure
* like those in DelayQueue and PriorityQueue, except that
* every ScheduledFutureTask also records its index into the
* heap array.
// 중략

주석에 적힌 heap-based data structure 라는 말에 집중하자. 즉, 자바에서 스케쥴링은 우선순위 큐 기반의 스케쥴러를 활용하여 수행한다라고 말할 수 있는 것이다.

동작 과정을 조금만 더 살펴보자.

  • ScheduledThreadPoolExecutor 의 Worker Thread 는 기본적인 ThreadPoolExecutor의 동작 방식을 따라간다.
    • 즉, Task를 큐에서 꺼내오고, 있으면 작업을 수행한다.
  • DelayedWorkQueue가장 먼저 수행되어야 하는 작업에 대해 우선순위 큐 형식으로 데이터를 저장하고 있다.
    • 다만 interface 구조 상 일반 Runnable 도 큐에 들어갈 수 있는데, 이렇게 될 경우 delay = 0이고 반복실행이 되지 않는 ScheduledFutureTask 로 간주한다. (즉, 즉시 실행된다.)
    • 만약, 같은 타이밍에 수행되어야 하는 작업이 있다면, 이는 FIFO 로 처리된다. (내부적으로 순서를 기록하기 위한 AtomicLong sequencer 를 포함하고 있다.)
    • Task가 있음에도 큐의 맨 앞에 있는 Task의 수행되어야 하는 시점이 현재 시점보다 과거라면, (다시 말해서 우선순위 큐에 의해 가장 빨리 시작되어야 하는 것으로 판단된 Task가 현재 시점 뒤에 수행되야 한다면) null 을 반환한다. 즉, 큐에 아무것도 없는 것으로 처리한다. (Queue.poll() 수행 기준)
  • Worker Thread 는 기본적으로 큐에서 데이터를 소비하려 하며, 만약 데이터가 없다면 Queue의 poll(keepAliveTime, TimeUnit.NANOSECONDS) 이나 take() 를 수행한다.
    • DelayedWorkQueue는 해당 메서드를 수행할 경우 스레드를 Condition.await() 를 통해 sleep 시켜버리고, 새로운 데이터가 들어오는 순간 Condition.signal() 을 통해 깨운다.
    • 이를 통해, Spin Lock 같은 방식을 사용하지 않고도 스레드를 대기시킬 수 있으며, 자연스럽게 Task를 수행할 수 있다.

물론 위 내용만 읽어본다면, 해결되지 않는 의문이 여전히 존재한다.

  • DelayedWorkQueue에 현재 시점보다 10초 뒤에 수행해야 할 Task를 넣었다고 가정하자.
  • Worker Thread는 Task를 가져오려 하나, 큐에 데이터가 없는 것으로 판단되어 await가 될 것이다.
  • 그렇다면 누가 깨우지...??

사실 이는, Queue를 상속한 BlockingQueue에는 take() 라는 메서드가 추가로 존재한다 라는 사실을 알아야 이해할 수 있다.

  • Queue.poll() - 데이터를 가져온다. 없으면 null을 반환한다.
  • BlockingQueue.take() - 데이터를 가져오려 시도한다. 없으면 데이터를 가져올 수 있을 때 까지 블로킹된다.

그렇기 때문에, DelayedWorkQueue 는 각각의 메서드를 아래와 같이 구현했다.

  • DelayedWorkQueue.poll() - Task를 가져오되, 만약 맨 앞 Task가 현재 시점 뒤에 수행되어야 하면 null을 반환한다.
  • DelayedWorkQueue.take() - Task를 가져오되, 만약 맨 앞 Task가 현재 시점 뒤에 수행되어야 하면 그 시간 까지 await 되었다가 (awaitNano(nanoSecond) 를 통해) task를 가져온다.

물론 실제로는 조금 더 복잡하다. (이미 작업을 수행중인 다른 Worker Thread가 있으면 현재 Thread는 그냥 await 된다거나...) 하지만 이정도만 이해해도, 우리는 충분히 동작 방식을 이해했다고 말할 수 있을 것이다.

이 방법을 통해 효율성을 최대로 높일 수 있으나, 역시 처음 제시한 문제였던 완벽히 정확한 시간에 수행되는 것이 불가하다는 여전히 해결하지 못했다. 다만 이는 일반적인 언어 환경에서는 엄밀한 수행이 불가능하기 때문에, 이해하고 넘어가도록 하자.

Spring 의 @Scheduled 는?

사실 대부분의 사람들은 위에서 언급한 ScheduledThreadPoolExecutor를 써볼 일이 거의 없겠지만, 반대로 Spring 이 제공하는 @Scheduled는 많이 써봤을 것이다.

뭔가 윗 부분을 꼼꼼하게 읽었다면 Spring 이 제공하는 것도 저걸 사용하지 않았을까? 하는 생각이 들텐데, 과연 진짜인지 확인해보도록 하자.

@EnableScheduling

  • 스케쥴링 기능을 활성화 하기 위해 붙이는 메타 어노테이션이다.
  • 해당 어노테이션을 붙이게 되면, 자동으로 SchedulingConfiguration.java 를 import 하여 사용하게 된다.

SchedulingConfiguration

  • ScheduledAnnotationBeanPostProcessor 를 등록한다.

SchduledAnnotationBeanPostProcessor

  • 빈 후처리기 (BeanPostProcessor) 는 Spring Container 에 빈을 등록하기 전, 특정 빈들을 대상으로 추가 작업을 해주는 Processor 이다.
  • @Scheduled, @Schedules 어노테이션이 붙은 메서드들이 캡쳐 대상이며, 해당 설정을 파싱하여 RunnableTrigger 형태로 변경한다.
  • Spring 이 자체적으로 스케쥴링을 추상화 한 인터페이스인 TaskScheduler 에 정보를 전달하며, 주로 기본 구현체인 ThreadPoolTaskScheduler를 사용한다.
  • 그런데, ThreadPoolTaskSchedulerScheduledExecutorService를 갖고 있으며, 해당 인터페이스의 구현체가 위에서 설명한 ScheduledThreadPoolExecutor 이다.
  • 추가적으로, Cron Job의 경우 내부적으로 CronTrigger를 갖고 있는데, 이는 다음 실행 시점을 계산하여 주기적으로 큐에 들어갈 수 있도록 도와준다.

결국 돌고 돌아, 기본적인 원리는 위에서 설명한 것과 동일한 것이다. 다만 사용성을 높이기 위해 많은 사항이 추상화 되어 있고, 그렇기에 이를 사용하는 우리 입장에선 아무 내용도 몰라도 문제 없이 사용할 수 있는 것이다.

하지만 내부 구조를 알고 있으니, 우리는 @Scheduled를 사용함에 있어 추가적인 가이드라인을 얻을 수 있다.

@Scheduled 사용 시 알 수 있는 것들

  • corePoolSize = 1 이다. 즉, 스케쥴링 잡은 병렬로 수행되지 않는다.

    • 내부적으로 스레드풀을 사용하고 있지만, 결국은 스레드가 1개라서 작업은 모두 순차적으로 수행된다.
    • 앞서 동일한 타이밍에 수행되어야 하는 Task가 여러 개라면, FIFO 형식으로 수행된다 했으나, 빈 후처리기에 들어가는 순서를 완벽하게 조정하는 것은 쉽지 않기 때문에, (여러 변수가 존재하므로) 결국 같은 시간에 수행되어야 하는 Task는 순서를 보장하기 어렵다.
    • 병렬 처리를 원한다면, Configuration 으로 새로운 ThreadPoolTaskScheduler를 재정의해야 한다.
  • queueSize 는 제한이 없다. 즉, 아무리 많은 스케쥴링 잡이라도 일단 넣을 수는 있다.

    • 당연히 너무 많으면 문제가 될 수 있겠지만, 구성상 그렇게 많은 작업을 수행하기 어려우므로, 특별하게 조정하지 않아도 문제는 없다.
  • Scheduled Task의 다음 실행 시점은 작업이 끝나고 결정된다.

    • 앞에서 설명을 하지 않았던 부분이지만, DelayedWorkQueue 가 스케쥴링 대상으로 쓰는 ScheduledFutureTaskrun() 메서드를 살펴보자.

    • public void run() {
      if (!canRunInCurrentRunState(this))
      cancel(false);
      else if (!isPeriodic())
      super.run();
      else if (super.runAndReset()) {
      setNextRunTime();
      reExecutePeriodic(outerTask);
      }
      }

      private void setNextRunTime() {
      long p = period;
      if (p > 0)
      time += p;
      else
      time = triggerTime(-p);
      }
    • super.runAndReset() -> setNextRunTime() 순으로 수행됨을 볼 수 있다. 즉, 작업이 수행된 이후, 다음 작업이 실행될 타이밍을 계산한다 라고 말할 수 있다.

    • Spring @Scheduled 환경에서, 특정 작업을 fixedRate로 등록하게 되면 (시작 시점 + 주기) 로 다음 시간이 계산되는데, 만약 10초가 걸리는 작업을 fixedRate = 1000 으로 등록했다고 가정 해보자.

      • 이 경우, 잘 모른다면 10초 동안 해당 작업이 10번 큐에 들어간다고 생각할 수 있지만, 작업이 종료된 후 단 한 번만 계산되기 때문에, 큐에는 1개만 들어가게 된다.
      • 다만 이렇게 되면 작업이 종료되고 텀 없이 바로 이어서 작업이 수행되므로, 텀을 확보하고 싶다면 fixedDelay = 1000 으로 설정하는게 맞다.
    • 참고로, 이와 관련된 ScheduledThreadPoolExecutor의 메서드는 다음과 같다.

      • scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
        • (이전 실행 시작 시점) + period 만큼 대기
      • scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)
        • (이전 실행 종료 시점) + period 만큼 대기

Spring 을 사용하지 않고 스케쥴링을 구현해야 하는 상황이 발생하여 처음으로 ScheduledThreadPoolExecutor 를 사용해 보고, 동작 원리가 궁금해져 글을 작성하다 보니 다소 글이 길어졌다.

별개로 올해 Spring/JDK 분석 스터디를 개설하려고 고민하고 있는 중이다. 원래는 해당 스터디를 의식하고 작성한 글은 아니었지만, 작성하다보니 스터디의 방식을 이런 식으로 하면 좋겠다는 생각이 들었다.

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

· 22 min read
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 또한 블로그에서는 가볍게 적었지만, 개발하는 과정에선 상당한 삽질을 했기에 다소 고생을 했다.

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