Image by pixabay
보통 프레임워크의 특정 기능을 처음 학습하며 샘플 코드를 작성해 볼 때 “Hello World”를 찍어봤다고 표현하곤 합니다. 요즘 프레임워크들이 워낙 추상화가 잘 되어 있다보니 “Hello World” 찍어 본 코드를 그냥 서비스 개발에 사용해도 되는 경우도 있습니다. 그러나 대게는 “Hello World” 코드를 서비스에 적용하기에는 무리가 있습니다. 왜냐하면 서비스를 구동하는데는 조금 더 복잡한 요구사항들과 여러 예외 상황들이 존재하고 “Hello World” 코드는 그것들을 감당하지 못하는 경우가 많기 때문입니다.
만약 “Hello World” 이상의 코드를 작성하기 원한다면 프레임워크에 대해 조금 더 깊이 있는 이해가 필요합니다. 프레임워크 내부 코드를 알아야 한다는 뜻이 아니라 프레임워크가 이런 저런 상황에서는 어떻게 동작하는지 이해하고 또한 나는 그때 어떻게 대응하는 코드를 작성할 수 있는지 알아야 한다는 뜻입니다.
한 가지 상황을 생각해 보겠습니다. 우리가 순수 자바 라이브러리를 활용해 어떤 작업을 1초 주기로 반복해야 한다면 java.util.concurrent 패키지의 ScheduledThreadPoolExecutor 클래스를 활용할 수 있습니다. 이런 요구를 만족하는 “Hello World” 코드는 다음과 같이 간단히 작성 할 수 있습니다.
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate( () -> {
log.info("Hello World");
}, 0, 1000, TimeUnit.MILLISECONDS);
군더더기 없이 깔끔합니다. 단순하게 람다식으로 정의된 작업을 1초 단위로 항상 실행해 줄 것만 같습니다. 테스트 코드를 작성해서 한 번 확인해 보겠습니다. 테스트 코드에서는 반복되는 각 작업의 시작 시간을 측정해서 정말 1초 간격으로 실행되고 있는지 검증합니다. 결과는 의도했던 대로 작업이 1초 간격으로 잘 실행되고 있습니다.
@Test
void scheduleAtFixedRate() {
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
List<LocalTime> taskStartTimes = new CopyOnWriteArrayList<>();
// When: 반복 작업 실행 후 일정 횟수 실행 대기
executor.scheduleAtFixedRate( () -> {
taskStartTimes.add(LocalTime.now());
}, 0, 1000, TimeUnit.MILLISECONDS);
await().until(() -> taskStartTimes.size() > 5);
// Then: 작업 간 간격 검증
IntStream.range(1, taskStartTimes.size()).forEach( i -> { // 약 1초 간격으로 작업 반복
long duration = Duration.between(taskStartTimes.get(i - 1), taskStartTimes.get(i)).toMillis();
assertTrue(duration > 900 && duration < 1100);
});
}
다음은 시스템의 요구사항을 이해하고 “Hello World” 코드가 우리 요구사항을 만족할 수 있는지 검토해 볼 차례입니다. 우리 애플리케이션에서는 이 반복 작업을 통해 특정 메시지를 타겟 서버로 주기적으로 전송해야 합니다. 그런데 타겟 서버 스펙에 반드시 메시지 요청을 1초 간격으로 띄워서 보내야한다는 제약사항이 있습니다. 그렇지 않으면 동작을 예측할 수 없다는 무서운 문구도 보입니다.
과거에 경험 하기로 해당 채널로 보낼 메시지를 처리하는데 1초를 넘기는 상황이 간헐적으로 있었던 기억이 납니다. 이런 경우 “Hello World” 코드를 통해 실행되는 반복 작업은 1초 이상의 간격을 반드시 유지해 주는 건지 의심이 생깁니다. 과연 “Hello World” 코드에서 사용한 scheduleAtFixedRate() 메서드는 메시지 간 간격을 항상 1초 이상으로 유지해 주는 걸까요?
먼저 Javadoc 문서를 읽어봅니다. 우려했던 상황에 대한 코멘트가 달려있어 반가운 마음에 읽어보니 충분하게 설명되어 있지가 않습니다. 간단히 “이어지는 작업 실행은 늦게 시작될 수 있다. 그러나 이전 작업과 동시에 시작되지는 않는다.” 정도로 설명되어 있습니다. 애매하게 ‘may’ 라는 표현을 썻기 때문에 동작을 확신할 수 없습니다.
이럴 때는 예외적인 조건을 테스트 코드로 만들어 API 동작을 확인해 보는 것이 좋습니다. 그럼 테스트 코드를 작성해 결과를 확인해 보겠습니다. 테스트 코드는 1초 주기의 반복 작업을 시작하고, 첫 번째 작업에서 1.3초를 지연함으로 작업 실행이 설정 주기보다 오랜 시간이 걸리는 상황을 시뮬레이션 합니다. 그리고 이어지는 작업이 얼마의 시간 간격으로 실행되는지 확인해 봅니다. 실행 결과는 처음 작업 실행에 1.3초가 걸리면 다음 작업은 약 0.7초 뒤에 실행되는 것을 확인할 수 있습니다.
전체 코드는 GitHub 에서 확인할 수 있습니다. 지면 관계 상 private help 메서드는 생략합니다.
@Test
void scheduleWithLongSparkTask() throws InterruptedException {
final AtomicBoolean longTaskSparked = new AtomicBoolean(false);
final long taskExecTime = 100;
final long longTaskExecTime = 1300;
final long period = 1000;
// When: 반복 작업 실행 후 일정 횟수 실행 대기
executor.scheduleAtFixedRate(() -> {
taskStartTimes.add(LocalTime.now());
// 첫 작업만 오래 걸리는 작업으로 모의
executeSparkTask(task, taskExecTime, longTaskExecTime, longTaskSparked);
}, 0, period, TimeUnit.MILLISECONDS);
shutdownAfterSomeCycle(executor, taskStartTimes::size, 3);
// Then: 작업 간 간격 검증
final long followTaskPeriod = 700; // 뒤 이은 작업 간격
assertDurationWithMargin(taskStartTimes.get(0), taskStartTimes.get(1), longTaskExecTime/*1300*/, 100);
assertDurationWithMargin(taskStartTimes.get(1), taskStartTimes.get(2), followTaskPeriod/*700*/, 100);
assertDurationWithMargin(taskStartTimes.get(2), taskStartTimes.get(3), period/*1000*/, 100);
}
테스트를 통해 scheduleAtFixedRate() 메서드로 시작된 반복 작업 중 하나의 작업이 설정된 주기 보다 긴 시간 실행되면 이어지는 작업의 실행 주기가 설정한 주기 보다 작아질 수 있음을 알게 되었습니다.
이제 우리 애플리케이션의 요구사항을 만족하기 위해 어떤 경우에도 작업과 작업 사이에 1초 이상의 간격을 주는 방법을 찾아야 합니다. 조금 더 ScheduledThreadPoolExecutor 클래스 서비스를 검색해 보니 scheduleAtFixedRate() 외에 scheduleWithFixedDelay() 라는 메서드가 보입니다. Javadoc을 읽어보면 scheduleWithFixedDelay() 메서드는 작업과 작업의 시작 시간 간격(period)을 설정하는 것이 아니라, 앞선 작업이 끝난 이후 다음 작업이 시작되기 까지의 지연 시간(delay)을 설정해 준다는 것을 알 수 있습니다. 따라서 앞선 작업이 아무리 늦게 끝나도 항상 설정된 지연 값이 적용되는 것을 알 수 있습니다. 정확히 우리가 원하는 스펙입니다. 역시 테스트 코드를 작성해 확인해 보면 우리의 예외적인 상황까지 완벽하게 처리해 줌을 확인할 수 있습니다.
@Test
void scheduleWithLongSparkTask() throws InterruptedException {
final AtomicBoolean longTaskSparked = new AtomicBoolean(false);
final long delay = 1000;
final long taskExecTime = 100;
final long longTaskExecTime = 1300;
// When: 반복 작업 실행 후 일정 횟수 실행 대기
executor.scheduleWithFixedDelay(() -> {
taskStartTimes.add(LocalTime.now());
// 첫 작업만 오래 걸리는 작업으로 모의
executeSparkTask(task, taskExecTime, longTaskExecTime, longTaskSparked);
}, 0, delay, TimeUnit.MILLISECONDS);
shutdownAfterSomeCycle(executor, taskStartTimes::size, 3);
// Then: 작업 간 간격 검증
assertDurationWithMargin(taskStartTimes.get(0), taskStartTimes.get(1), delay + longTaskExecTime, 100);
assertDurationWithMargin(taskStartTimes.get(1), taskStartTimes.get(2), delay + taskExecTime, 100);
assertDurationWithMargin(taskStartTimes.get(2), taskStartTimes.get(3), delay + taskExecTime, 100);
}
가상의 상황을 통해 살펴본 것처럼, 우리는 여러 프레임워크를 활용해 빠르게 애플리케이션을 개발할 수 있지만 "Hello World"를 작성할 수 있는 수준의 이해로는 부족할 때가 많습니다. 그래서 직접 프레임워크를 개발하는 것만큼의 노력은 아니지만 일정 수준의 프레임워크를 이해하기 위한 노력이 필요합니다. 그런데 프레임워크는 너무나 방대하고 크기 때문에 어디서부터 읽고 이해해야 할지 막막할 때가 많습니다. 그래서 우리는 애플리케이션을 만들며 어떤 요구와 제약이 있는지 먼저 잘 이해하고 그리고 나서 그 문제를 해결하기 위해 프레임워크가 안전하게 동작하는 방법을 찾고 적용해 보며 학습해 나가는 것이 좋습니다. 물론 기초적인 개념들에 대해서는 미리 선행 학습이 되어야 겠지만 너무 깊게 무작정 파고 들다보면 실제로 사용하지 않는 기능, 필요하지 않은 수준에서 학습을 하게 될 수도 있기 때문에 주의해야 합니다.
처음에는 단순하게 프레임워크를 제대로 이해하고 코드를 작성하자는 주제의 글을 쓰기 시작했는데 글의 마지막에 도달하니 제품을 개발하면서 제품의 요구사항이나 여러 제약 사항을 이해하고 그것의 가이드를 받아 프레임워크를 학습하는 것이 좋다는 결론에 다다랐습니다. 최근에 읽었던 ‘개발자의 원칙’이라는 책에서 이동욱님(네피림)의 프로덕트 중심주의란 개념이 있었는데 같은 맥락이 아닐까 생각합니다. 글을 쓰고 예제 코드를 만들며 많은 학습이 되었습니다. 감사합니다.