본문으로 건너뛰기

"jvm" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

스토리지 선택 기준 - (3) 그래서 OpenSearch 를 S3에 올릴 수 있나요?

· 약 17분
VSFe
블로그 주인장

이 글은 시리즈의 3편입니다. 1편 (티어링)2편 (Kubernetes PV) 을 먼저 읽고 오면 좋습니다. (안 읽어도 이해는 됩니다만...)

1편과 2편에서, 거의 염불 외우듯이

"OpenSearch data path는 S3 위에 올리면 안 된다."

라고 말했던 걸 기억하는가?

2편에서 S3 CSI driver 이야기를 하면서도, EFS 이야기를 하면서도 계속 같은 결론으로 돌아왔다. 그런데 정작 왜 안 되는지는 "network filesystem이라 latency/locking이 병목"이라는 식으로 대~충 넘어갔다.

사실 이걸 제대로 설명하려면 결국 Lucene이 디스크를 어떻게 읽는지, 더 내려가서 OS가 파일을 어떻게 메모리에 붙이는지까지 파고들어야 하기 때문이다.

혹시 시리즈 초반에 스토리지 선택의 가장 깊은 제약은 비용표가 아니라 엔진이 디스크를 읽는 방식에 있다 라고 말했던 것을 기억하는가? 드디어 내부적인 이야기를 할 수 있을 것 같다.

"Lucene의 mmap"은 그 시스템콜의 mmap인가?

OpenSearch/Lucene을 좀 다뤄봤다면 mmap 이라는 단어를 수도 없이 들었을 것이다. index.store.typemmapfs, hybridfs 같은 게 있고, 운영하다 보면 vm.max_map_count 를 올리라는 소리도 듣는다.

그런데 여기서 한 가지 짚고 넘어가야 할 게 있다. Lucene이 말하는 mmap은 "mmap 비슷한 자체 캐시 추상화" 인가, 아니면 진짜 OS의 mmap(2) 시스템콜 인가?

결론부터 말하면 진짜 그 mmap 이 맞다. 다만 Lucene이 직접 C로 mmap(2) 를 호출하는 건 아니고, Java NIO와 JVM native 구현을 거쳐서 결국 OS의 mmap 계열 기능을 사용하는 구조다. 경로를 따라가 보자.

  • Lucene의 MMapDirectory 는 segment 파일을 열 때 FileChannel.map() 을 호출한다.
  • FileChannel.map() 의 자바 문서를 보면 "파일의 특정 영역을 메모리에 직접 매핑한다" 고 정의되어 있고, 그 결과로 MappedByteBuffer 를 돌려준다. MappedByteBuffer 문서도 이 버퍼의 내용이 memory-mapped file region이라고 설명한다.
  • OpenJDK의 Unix 구현을 보면 더 직접적이다. FileChannelImpl.c<sys/mman.h> 를 include하고, 내부 map0 에서 mmap64(...) 를 호출하며, unmap 시점엔 munmap(...) 을 호출하고 있다.

물론 우리가 내부를 알 필요는 없지만, 결국 Memory Mapped I/O 를 쓰고 있다는것에 일단 주목하도록 하자.

참고로, Java 21 이후의 Lucene은 MemorySegment API (소위 Panama, Foreign Function & Memory API) 를 사용한다.. (예전엔 sun.misc.Cleaner 를 강제로 끄집어내는 더러운 unmap hack을 써야 했는데... 이제는 그럴 필요가 없어졌다.) 어쨌거나 "mmap 느낌의 무언가"가 아니라 OS 가상메모리 매핑을 그대로 쓰는 구조 라는 게 포인트다.

내부적으로 어떤일이 벌어지는가?

Lucene segment 하나는 대략 이런 파일들의 묶음이다.

_0.tim  // term dictionary
_0.tip // term index
_0.doc // postings (freq)
_0.pos // positions
_0.dvd // doc values data
_0.dvm // doc values metadata

Lucene은 이 파일들을 read() 로 매번 복사해서 읽는 대신, 파일의 영역을 프로세스 주소 공간에 매핑 한다.

Process Virtual Address Space

0x7000_0000 ─────────────────────
_0.tim mapped region
0x7100_0000 ─────────────────────
_0.doc mapped region
0x7200_0000 ─────────────────────
_0.dvd mapped region
0x7300_0000 ─────────────────────

그 다음, Lucene 입장에서 특정 offset의 byte를 읽는 건 대략 이런 느낌이 된다.

byte b = mappedBuffer.get(offset);

코드만 보면 그냥 메모리에서 한 바이트 꺼내는 것 같지만, 이게 Java heap에서 읽는 게 아니라는 점 이 중요하다. 실제 흐름은 이렇다.

  1. Lucene이 mapped address의 특정 offset에 접근한다.
  2. 해당 page가 아직 RAM에 없으면 page fault 가 발생한다.
  3. kernel이 backing file에서 해당 page를 page cache 로 읽어 온다.
  4. page table을 갱신한다.
  5. 이후 같은 page에 대한 접근은 그냥 메모리 접근처럼 처리된다.

즉, 애플리케이션이 read() 시스템콜을 매번 날려서 I/O를 요청하는 게 아니라, page fault를 통해 kernel의 VM subsystem이 필요한 page를 끌어오는 방식이다. 한 번 올라온 page는 page cache에 남아 있으니, 두 번째 접근부턴 시스템콜도 디스크 I/O도 없다.

"파일을 메모리에 다 올린다"는게 아니다.

여기서 흔히 오해하는 게 있다. mmap은 파일 전체를 즉시 RAM에 복사하는 게 아니다. 기본적으로는 파일의 주소 범위를 프로세스 virtual address space에 연결만 하는 것이다.

예를 들어 100GB짜리 segment 파일을 mmap하면,

Virtual address space : 100GB 사용
Physical RAM : 실제 접근한 page 중심으로만 사용
Disk I/O : page fault 발생 시 필요한 page 단위로만 수행

그래서 Lucene 문서도 MMapDirectory 가 파일 크기만큼 virtual address space를 잡아먹으므로 64-bit JRE가 중요하다 고 언급하고 있다. (32bit JRE면 주소 공간이 부족해서 큰 인덱스를 mmap할 수가 없다.) OpenSearch에서 vm.max_map_count 나 address space, page cache 이야기가 같이 나오는 이유도 결국 여기에 있다. 수많은 segment 파일을 매핑하다 보면 매핑 개수 자체가 OS 한계에 부딪히기 때문이다.

read() 방식과 mmap 방식의 차이

시스템 콜을 학부에서 배워서 다 까먹었을 수 있으니, 방식의 차이를 잠깐 보도록 하자.

read() 방식은 대략 이렇다.

반면 mmap 방식은 이렇다.

결국 read() 는 "파일에서 사용자 버퍼로 읽어와라" 이고, mmap 은 "파일을 내 주소 공간에 매핑해두고, 접근할 때 OS가 page 단위로 가져오게 하자" 이다.

read() 는 kernel page cache에서 user buffer로의 복사(copy_to_user)가 한 번 더 일어나지만, mmap은 매핑된 가상 주소가 곧 page cache의 그 page를 가리키므로 그 복사가 없다. (물론 모든 상황에서 mmap이 빠르다는 뜻은 절대 아니다. 이건 뒤에서 다시 이야기한다.)

왜 Lucene/OpenSearch에서 이게 중요하지?

Lucene 검색은 segment 내부의 여러 자료구조를 offset 기반으로 마구 찔러대는 작업이다.

  • term dictionary
  • posting list
  • doc values
  • stored fields
  • norms
  • points index

이런 구조는 "큰 파일을 처음부터 끝까지 순차 scan" 하기보다는, 여러 segment 파일의 여러 offset을 랜덤하게 접근하는 일이 압도적으로 많다.

mmap을 쓰면 Lucene은 이 랜덤 접근을 직접적인 offset read API 호출로 처리하지 않고, OS의 page cache와 가상메모리 시스템에 통째로 맡길 수 있다.

그래서 OpenSearch를 운영할 때 흔히 듣는 그 조언,

"heap을 너무 크게 잡지 마라. 남는 RAM은 page cache로 써야 한다."

가 바로 여기랑 연결된다. Lucene segment 파일은 Java heap 안에 올라가는 게 아니라 mmap + page cache를 통해 접근된다. 따라서 JVM heap을 과하게 크게 잡으면, 그만큼 OS page cache로 쓸 RAM이 줄어들고, 그 결과 segment access가 page cache hit이 아니라 disk I/O로 더 자주 떨어지게 된다. (heap을 늘렸는데 검색이 느려지는 역설이 여기서 나온다.)

단, write path 전체가 mmap이라는 뜻은 아니다

오해를 막기 위해 짚고 넘어가자. OpenSearch/Lucene이 모든 I/O를 mmap으로만 하는 건 아니다.

mmap이 특히 중요한 건 index read path 다. 반면 indexing 도중 새 segment를 쓰거나 translog를 쓰는 경로는 일반적인 file write / fsync 계열이 관여한다. 새로 만들어진 segment가 검색 대상이 되고 나서야 mmap 기반으로 읽히는 식으로 이해하면 된다.

그래서 OpenSearch의 스토리지 요구사항은 사실 두 가지가 섞여 있다.

Write path
- indexing buffer flush
- segment creation
- translog write / fsync
- merge write
→ write throughput, fsync latency 가 중요

Read path
- segment search
- doc values access
- terms / postings random access
→ mmap, page cache, random read latency 가 중요

1편에서 "Hot tier는 IOPS 중심 block storage여야 한다" 고 했던 게 이 두 path를 동시에 만족시켜야 하기 때문이다. write는 throughput/fsync, read는 random access이니까... 둘 다 로컬 블록 디바이스가 잘하는 일이고, 네트워크 너머의 무언가가 잘하는 일이 아니다.

그래서 S3와 근본적으로 안 맞는다

이제 1·2편 내내 미뤄왔던 결론을 낼 수 있다.

mmap은 기본적으로 파일 디스크립터, OS page cache, page fault, page table 을 전제로 한다. 이건 "로컬 파일시스템 위의 블록 디바이스" 라는 모델이 있어야 성립하는 이야기다.

그런데 S3는 그런 파일시스템 블록 디바이스가 아니다. S3는

GET object
GET object (range)
PUT object

이런 식의 HTTP 기반 object storage 다. 물론 S3 FUSE나 2편에서 본 Mountpoint for S3 CSI 같은 걸로 "파일처럼 보이게" 만들 수는 있지만, 그건 어디까지나 POSIX 파일시스템을 흉내 내는 계층일 뿐이다. Lucene이 원하는 mmap / page fault / random page access와는 의미가 완전히 다르다.

Lucene mmap
→ OS가 파일 page를 virtual memory에 매핑
→ page fault 단위로 kernel이 읽음
→ low-latency random access 전제

S3
→ HTTP object / range request
→ ms 단위 네트워크 왕복
→ object 단위 / 큰 range 단위 접근에 적합

page fault 하나가 ms 단위 네트워크 왕복이 되어버리는 순간, Lucene의 랜덤 offset 접근 패턴은 그냥 죽는다. (term dictionary 한 번 타고 posting list 찔러보는 데 매번 HTTP GET이 날아간다고 생각해보자. 신나는 과금폭탄!!) 그래서 "OpenSearch data path를 S3 위에 올리면 안 된다" 는 설명의 기술적 핵심 중 하나가 바로 이 mmap / page cache 모델 인 것이다.

반대로, 그래서 S3는 snapshot repository나 searchable snapshot, 데이터 레이크처럼 object / 큰 range 단위로 접근하는 용도엔 더없이 잘 맞는다. 같은 데이터를 다루더라도 접근 패턴이 다르면 스토리지도 달라져야 한다는, 이 시리즈 1편의 이야기로 다시 돌아오는 셈이다.

정 비용을 아껴서 쿼리하고 싶다면?

물론 이런 경우는 있다.

로그 데이터라서 쿼리 횟수는 극히 적지만, 어쨌든 데이터를 보존하고 싶어요.

대표적으로 감사 로그 (보통 컴플라이언스 정책에 따라 1년 넘게 보관하기도 하고...), 시스템 로그등이 이에 해당할 것이다. 보존 기간은 길고, 그렇다고 쿼리를 많이 하지도 않지만 어쨌거나 저장해야 하고, 가끔 감사가 오거나 장애가 발생했을 때 원인 추적용으로 조회해야 하는 경우.

그럴 땐, S3 Compatiable Storage 에 Parquet 형식으로 파일을 저장하고, Parquet 파일에 대한 쿼리를 지원하는 쿼리엔진을 사용해보자.

  • Trino, Spark/Flink SQL, DuckDB, ClickHouse, Dremio, StarRocks, Apache Doris, ... 등등, 지원하는 것은 많다.
  • 다만, 파일로 저장하는 경우 "어떤 데이터가 어디에 있지?" 를 파악하기 어렵다보니, 보통 메타데이터용 테이블을 따로 둔다
    • Apache Hive를 초기에 썼었지만, 최근에는 Iceberg 같은 카탈로그를 사용하는 편이다.
image-20260726185416137

관련된 유즈케이스가 궁금하면, 아래 링크를 참고하도록 하자.

결론

세 편에 걸쳐 스토리지 선택을 이야기했지만, 결국 또 이 블로그 식대로 결론을 냈다.

처음엔 그냥 "어떤 스토리지가 싸고 빠른가" 정도의 질문이었지만, 답을 찾다 보니 결국 비용표가 아니라 page fault까지 내려와 버렸다. 늘 그렇듯, 좋은 선택을 하려면 한 겹 더 내려가서 "이게 왜 이렇게 동작하는가" 를 들여다봐야 하는 것 같다. (그리고 그 과정이 제일 재밌기도 하고.)

정리하면, Lucene의 mmap은 진짜 OS memory-mapped file이다. 다만 Lucene이 직접 C로 mmap(2) 를 호출하는 게 아니라, Java NIO와 JVM native 구현을 통해 OS의 mmap 계열 기능을 사용한다고 보면 된다. 그리고 이 한 가지 사실이, 스토리지 계층을 어떻게 설계해야 하는지에 대한 의외로 많은 결정을 좌우한다.

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

· 약 20분
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 분석 스터디를 개설하려고 고민하고 있는 중이다. 원래는 해당 스터디를 의식하고 작성한 글은 아니었지만, 작성하다보니 스터디의 방식을 이런 식으로 하면 좋겠다는 생각이 들었다.