대용량 job 스케줄러 생성기
📘SyncChecker
큐 포화와 실행 충돌을 해결하기까지
이 글은 데이터 동기화 검증 시스템을 운영하면서
Job 실행 엔진에 생긴 세 가지 문제를 어떻게 풀었는지 정리한 기록입니다.
시스템은 Oracle, MySQL, PostgreSQL 사이의 데이터 정합성을 주기적으로 검증합니다.
각 Job은 스케줄러에 의해 생성되고, 대기 큐에 들어간 뒤, 실행 루프가 하나씩 꺼내 실행합니다.
처음에는 이 구조가 단순하고 충분해 보였습니다.
하지만 Job 수가 많아지고, 실행 시간이 길어지고, 여러 데이터소스를 동시에 물기 시작하면서 문제는 한 번에 터졌습니다.
겉으로는 모두 “스케줄러가 불안정하다”는 증상으로 보였지만, 실제로는 서로 다른 세 가지 병목이 겹쳐 있었습니다.
- 동일 Job이 중복 실행되는 문제
- 메모리 대기 큐가 포화되는 문제
- 긴 Job 때문에 짧은 Job이 계속 밀리는 문제
이번 개선의 핵심은 큐 자료구조를 바꾼 것이 아니라,
실행 가능하지 않은 Job을 어떻게 다룰 것인지에 대한 정책을 분리한 것
이었습니다.
문제 1. 동일 Job이 중복 실행됐다
가장 먼저 터진 문제는 중복 실행이었습니다.
스케줄러는 주기적으로 같은 Job을 다시 발생시킵니다.
문제는 Job 실행 시간이 스케줄 주기보다 길어질 때입니다.
예를 들어 1분마다 실행되는 Job이 있는데, 실제 검증은 3분이 걸린다고 해보겠습니다.
그러면 1분 뒤에 같은 Job이 다시 들어오고, 2분 뒤에도 또 들어옵니다.
이걸 그대로 실행하면 같은 대상에 대해 동일 Job이 동시에 여러 번 돌게 됩니다.
정합성 검증 시스템에서는 이게 단순한 중복 작업이 아닙니다.
- 같은 테이블을 동시에 읽고
- 같은 구간을 겹쳐 검증하고
- 서로 다른 실행 결과가 뒤섞일 수 있습니다
즉, 성능 문제 이전에 결과 자체가 오염될 수 있는 구조였습니다.
해결: 실행 불가 Job을 바로 버리지 말고, 뒤로 미루자
이 문제를 해결하기 위해 실행 루프에서 “바로 실행 가능한 Job”과 “지금은 실행하면 안 되는 Job”을 나눴습니다.
이미 실행 중인 Job이 다시 들어오면, 그 Job을 즉시 실행하지 않고 별도 지연 큐로 옮깁니다.
이후 실행 루프는 매번 새 Job만 보는 것이 아니라, 지연 큐를 먼저 순회하면서 이제 실행 가능한 상태가 되었는지 다시 확인합니다.
이 방식의 장점은 명확합니다.
- 중복 실행은 막고
- 이미 들어온 다음 스케줄은 잃어버리지 않고
- 이전 실행이 끝나는 즉시 이어서 실행할 수 있습니다
즉, 이 구조는 단순 차단이 아니라 중복 실행을 방지하면서 실행 순서를 보존하는 방식입니다.
핵심은 “같은 Job이면 무시한다”가 아니라,
지금은 못 돌리는 Job을 안전하게 보류했다가, 가능한 순간 다시 실행한다
는 데 있습니다.
문제 2. 메모리 큐가 꽉 차면 이후 Job을 감당할 수 없었다
두 번째 문제는 대기 큐 포화였습니다.
대기 큐는 메모리 기반 bounded queue였습니다.
평소에는 충분했습니다. 하지만 특정 시점에는 짧은 시간에 Job이 몰립니다.
예를 들면 이런 경우입니다.
- 스케줄이 여러 개 겹쳐서 동시에 발생할 때
- 장시간 밀린 Job이 한꺼번에 들어올 때
- 특정 구간 재실행이나 배치성 작업이 한 번에 붙을 때
이런 상황에서는 실행 속도보다 유입 속도가 더 빨라집니다.
그러면 결국 메모리 큐가 먼저 한계에 닿습니다.
이 문제의 본질은 큐 크기를 키우는 것으로 끝나지 않습니다.
용량을 두 배로 늘려도, 피크 유입이 더 크면 결국 같은 문제가 다시 생깁니다.
즉, 이건 queue size 문제가 아니라
메모리 안에 다 못 담는 순간을 어떻게 처리할 것인가
의 문제였습니다.
해결: 메모리 큐를 넘치면 DB로 흘려보냈다
해결 방식은 단순합니다.
메모리 큐에 여유가 있을 때는 그대로 적재합니다. 하지만 큐 여유가 부족하면 Job을 메모리에 계속 우겨 넣지 않습니다.
대신 Job payload를 DB 테이블에 직렬화해서 저장합니다.
그리고 별도의 백그라운드 루프가 계속 큐 여유를 감시하다가, 공간이 생기면 DB에 쌓아둔 Job을 다시 메모리 큐로 올립니다.
이 구조를 도입하면서 얻은 건 두 가지입니다.
- 피크 유입 시점에도 Job을 잃지 않는다
- 메모리 한계 때문에 시스템 전체가 흔들리지 않는다
이건 외부 메시지 큐를 도입한 건 아니지만, 사고방식은 전형적인 백프레셔 처리와 같습니다.
즉,
- 소비자가 느리면
- 생산자를 무한히 받지 않고
- 다른 저장소로 압력을 분산한다
는 패턴입니다.
중요한 점: 이건 영속 큐가 아니라 런타임 overflow buffer였다
여기서 한 가지는 분명히 해두는 게 좋습니다.
이 구조는 Kafka나 RabbitMQ 같은 완전한 영속 메시지 큐는 아닙니다.
역할은 더 실용적입니다.
런타임 중 메모리 큐가 감당하지 못하는 순간을 받아주는 overflow buffer에 가깝습니다.
즉, 목적은 메시징 플랫폼 대체가 아니라
메모리 한계 때문에 Job이 유실되는 상황을 막는 것
입니다.
실무에서는 이런 수준의 분리만으로도 시스템 안정성이 크게 올라갑니다.
문제 3. 직렬 FIFO 구조에서는 긴 Job이 짧은 Job을 계속 막았다
세 번째 문제는 기아 현상이었습니다.
FIFO만 보면 공정해 보입니다.
먼저 들어온 Job부터 처리하니 직관적이기 때문입니다.
하지만 실행 시간이 크게 다른 Job이 섞이면 FIFO는 오히려 비효율적입니다.
예를 들어 아주 긴 Job이 앞에 여러 개 쌓이면, 뒤에 있는 짧은 Job은 실행 가능하더라도 계속 기다려야 합니다.
문제는 그 짧은 Job이
- 리소스도 적게 쓰고
- 빨리 끝날 수 있고
- 지금 당장 실행해도 안전한 상태
일 수 있다는 점입니다.
그런데도 단순히 앞에 긴 Job이 있다는 이유로 계속 밀리게 됩니다.
이건 스케줄링이라기보다 대기열에 묶여 있는 상태에 가깝습니다.
해결: 지연 큐를 먼저 보고, 실행 가능한 Job을 고른다
중복 실행 방지를 위해 만든 지연 큐가 이 문제도 같이 풀어줬습니다.
실행 루프는 매번 waiting queue만 보는 것이 아니라, 먼저 지연 큐를 확인합니다.
그리고 그 안에서
- 더 이상 동일 Job 충돌이 없고
- 필요한 리소스도 사용할 수 있는 Job
을 먼저 꺼냅니다.
즉 구조가 FIFO에서 완전히 우선순위 큐로 바뀐 건 아니지만, 적어도 “지금 당장 실행 불가능한 Job이 앞을 막고 있는 상황”은 해소됩니다.
이 차이가 큽니다.
기존에는 “먼저 왔지만 못 도는 Job”이 전체 흐름을 막았습니다.
지금은 “먼저 왔지만 아직 못 도는 Job”은 옆으로 빼두고, 실행 가능한 Job부터 계속 흘려보낼 수 있습니다.
이건 운영 체감에서 꽤 큰 차이를 만듭니다.
데이터소스 커넥션 풀도 스케줄러가 같이 보호하게 만들었다
여기서 한 단계 더 나간 부분이 있습니다.
실행 가능 여부를 판단할 때 단순히 “동일 Job이 이미 실행 중인가”만 보지 않았습니다.
각 Job은 source/target validation, source/target repair 같은 여러 데이터소스를 함께 사용합니다.
문제는 특정 데이터소스에 연결이 몰릴 수 있다는 점입니다.
이 상태에서 스케줄러가 무작정 Job을 계속 실행시키면, 실제 DB 커넥션 풀이 먼저 포화됩니다.
그러면 Job은 큐에서는 실행 중인데, 실제로는 DB 자원을 기다리며 지연되는 비효율적인 상태가 됩니다.
그래서 실행 루프에서 각 데이터소스별 동시 사용 개수를 따로 추적하고, 해당 수가 그 데이터소스의 풀 크기에 닿으면 그 Job은 바로 실행하지 않고 다시 지연시킵니다.
이건 중요한 차이입니다.
스케줄러가 단순히 Job 개수만 제한하는 게 아니라, DB 자원 사용량을 기준으로 admission control을 하고 있는 셈입니다.
즉,
- Job 슬롯이 남아 있어도
- 필요한 데이터소스 풀이 빡빡하면
- 실행을 미룬다
는 정책입니다.
이 덕분에 얻는 효과는 분명합니다.
- 커넥션 풀 포화로 인한 연쇄 지연을 줄이고
- 특정 DB에 작업이 몰리는 것을 막고
- 전체 시스템이 한쪽 DB 때문에 같이 느려지는 상황을 줄일 수 있습니다
즉, 이 구조는 단순 스케줄링이 아니라 리소스 인지형 실행 제어에 가깝습니다.
JSON 직렬화만으로는 끝나지 않았다
큐 overflow를 DB에 저장하는 구조는 생각보다 단순하지 않습니다.
문제는 Job payload 안에 들어 있는 필터 값들입니다.
메모리 안에 있을 때는 타입이 살아 있습니다.
- 숫자는 숫자
- 날짜는 날짜
- SCN은 long
하지만 DB에 JSON으로 직렬화했다가 다시 읽어오면 그 타입 정보가 깨집니다.
즉, 복원 시점에는 겉보기엔 같은 값이지만 실행 로직이 기대하는 타입과 다를 수 있습니다.
그래서 DB에서 다시 꺼낼 때는 해당 컬럼의 실제 타입을 조회해 문자열 값을 다시 적절한 자바 타입으로 복원하는 과정이 필요했습니다.
이 부분이 중요합니다.
overflow buffer는 단순 저장소가 아니라 실행 가능한 Job payload를 다시 만들어내는 복원 계층이기 때문입니다.
실무에서는 이런 부분이 빠지면 큐는 살아 있어도 실행 단계에서 다시 깨집니다.
이번 설계에서 좋았던 점
이번 개선이 좋았던 이유는 단순히 큐를 하나 더 만든 데 있지 않습니다.
핵심은 문제를 각각 다른 종류의 “실행 불가”로 본 데 있습니다.
정리하면 이렇습니다.
1. 같은 이유로 막힌 Job이 아니었다
실행 불가에는 여러 종류가 있었습니다.
- 이미 같은 Job이 실행 중이라서 못 도는 경우
- 메모리 큐가 꽉 차서 못 받는 경우
- 데이터소스 풀이 부족해서 지금 돌리면 안 되는 경우
이걸 모두 같은 방식으로 처리하면 구조가 금방 꼬입니다.
이번에는 이유에 따라 경로를 분리했습니다.
2. “못 도는 Job”을 버리지 않고 상태만 바꿨다
실행할 수 없는 Job을 실패로 보지 않고, 보류 상태로 분류한 점이 좋았습니다.
실행 가능 여부는 시점의 문제일 뿐, 작업 자체가 잘못된 건 아니기 때문입니다.
이 관점이 들어가면 스케줄러가 훨씬 탄탄해집니다.
3. 리소스 제약을 실행기 바깥으로 넘기지 않았다
커넥션 풀이 알아서 막아주길 기대하는 대신, 실행기 자체가 DB 자원 사용량을 보고 admission control을 하도록 만들었습니다.
이건 운영에서 꽤 큰 차이를 만듭니다.
뒤에서 막히는 것보다 앞에서 덜 넣는 편이 훨씬 낫기 때문입니다.
마무리
대용량 Job 스케줄러 문제는 종종 “큐 크기 늘리면 되지 않나”, “스레드 수 조정하면 되지 않나” 수준에서 생각되기 쉽습니다.
하지만 운영에서는 그 정도로 끝나지 않습니다.
이번 사례에서 진짜 중요했던 건 큐 자료구조보다도,
- 지금 실행하면 안 되는 Job을 어떻게 보류할지
- 메모리 한계를 넘는 순간을 어디로 흘릴지
- DB 자원이 부족할 때 실행을 누가 막을지
를 분리해서 설계한 것이었습니다.
결국 이 작업의 핵심은 스케줄러를 더 똑똑하게 만든 게 아니라,
실행 불가 상태를 정상 상태로 인정하고,그 상태를 안전하게 다루는 구조로 바꾼 것
이었습니다.
운영 시스템의 안정성은 보통 “잘 도는 순간”보다 못 도는 순간을 어떻게 처리하느냐에서 갈립니다.
이번 Job 실행 엔진 개선이 딱 그런 사례였습니다.