Spark Connect on Kubernetes #1: 견고한 Spark Connect 만들기
안녕하세요. 토스증권 Data Infra팀 박지원입니다.
토스증권은 분석가와 엔지니어가 복잡한 설정 없이 Spark를 쓸 수 있도록, Spark Connect를 Kubernetes에서 서비스로 운영하고 있습니다. 사내 여러 팀의 분석과 데이터 파이프라인이 이 위에서 돌아가는 만큼, 안정성을 확보하는 것을 가장 중요한 목표로 두고 있습니다.
이 시리즈에서는 Spark Connect를 Production 수준의 서비스로 만들어온 과정을 풀어보려고 합니다. 첫 주제는 견고한 Spark Connect 만들기 — 여러 사용자가 함께 써도 각자의 작업을 서로 지키며, 안정적으로 운영하는 방법입니다. Spark Connect가 처음인 분도 부담 없이 따라올 수 있게, 개념부터 차근차근 짚어 보겠습니다.
Spark는 원래 어떻게 동작하나요
Spark Connect가 무엇인지 이해하려면, 먼저 Spark가 원래 어떻게 동작하는지부터 봐야 합니다.
Spark에는 두 종류의 프로세스가 있습니다. 작업을 계획하고 지휘하는 Driver, 그리고 실제 연산을 나눠 수행하는 Executor입니다. Driver가 코드를 받아 실행 계획을 세우고, Executor들에게 Task를 나눠주고, 결과를 모읍니다.
Classic Spark에는 이 Driver를 어디서 실행하느냐에 따라 두 가지 배포 모드가 있습니다.


① Client mode는 Driver가 클라이언트 프로세스 안에 있습니다. 클라이언트가 곧 Driver죠. ② Cluster mode는 Driver가 클러스터 노드에서 실행돼, 작업을 제출할 때마다 떴다가 끝나면 사라집니다. 대신 클라이언트가 죽어도 앱은 계속 돌아가고요.
중요한 건 두 모드의 공통점입니다. 둘 다 App마다 Driver가 새로 뜨고, 그 Driver는 App의 수명과 함께 살고 죽습니다.
그리고 작업을 제출하려면 클라이언트 쪽에도 Spark 라이브러리와 설정이 갖춰져 있어야 하죠.
Spark Connect가 바로 이 지점을 개선했습니다. 2022년 SPIP로 출발해 Spark 3.4에 처음 들어왔어요. 초기에는 클라이언트에서 쓸 수 없는 API가 적지 않았지만 버전을 거듭하며 공백이 메워졌고, 4.0에서는 기존 Dataset/DataFrame API와 동등한 수준에 올라섰습니다. 4.1에서도 사용성과 성능 개선이 이어지고 있고요. 저희는 4.1 버전을 운영하고, 글의 코드·설정도 4.1 기준이에요.
Spark Connect는 무엇이 다른가요
Spark Connect는 Driver를 애플리케이션마다 뜨는 것에서, 미리 떠있는 서버로 만들었습니다.

클라이언트는 더 이상 Driver를 갖고 있지 않습니다. DataFrame이나 SQL 연산을 Unresolved Logical Plan으로 바꿔 Protocol Buffer로 인코딩하고, 이걸 gRPC로 서버에 보냅니다. 이미 떠 있는 서버가 분석·최적화·스케줄링·실행을 맡고, 결과는 Arrow로 다시 스트리밍됩니다. 마치 JDBC Client로 데이터베이스에 질의하는 모델과 유사합니다.
그래서 좋은 점
Driver를 서버로 분리해 미리 띄워두면, 이런 이점이 생깁니다.
여기까지만 보면 이상적입니다. 그런데 미리 띄워둔 long-running 서버 하나에 여러 클라이언트가 붙는다는 모델은, 동시에 새로운 문제들을 만들었습니다.
Spark Connect 서버가 만드는 문제들
미리 말해두면, 이 문제들의 근본적인 원인은 하나입니다. Spark의 기본 설계 곳곳이 ‘앱 하나 = 워크로드 하나’를 전제하는데, 우리는 그 앱을 여러 사용자가 공유하는 long-running 서버로 쓰고 있다는 것. 아래 문제들은 전부 이 전제가 깨지는 지점에서 나옵니다.
문제 ①, Driver = 단일 장애점
Spark Connect 서버 하나에 여러 세션이 붙으면, 그 세션들은 하나의 SparkContext를 공유합니다. Driver JVM이 하나라는 뜻입니다.
세션이 아무리 늘어도 Driver는 하나입니다. 그래서 Driver 프로세스에 문제가 생겨 내려가는 순간, 영향은 한 세션에 그치지 않고 그 서버에 붙은 모든 세션으로 번집니다. 진행 중이던 job과 캐시도 함께 사라지죠.

실제로 한 사용자의 쿼리 하나가 서버 전체를 내릴 수 있습니다. Spark는 executor 실패가 임계값(spark.executor.maxNumFailures, 기본값 max(3, 2 × executor 수))을 넘으면 앱을 종료하는데, 이 로직이 바로 ‘앱 하나 = 워크로드 하나’ 전제로 설계돼 있거든요. Kubernetes 환경의 실제 코드를 보면:
// ExecutorPodsLifecycleManager.scala
if (failureTracker.numFailedExecutors > maxNumExecutorFailures) {
logError(log"Max number of executor failures " +
log"(${MDC(LogKeys.MAX_EXECUTOR_FAILURES, maxNumExecutorFailures)}) reached")
stopApplication(EXCEED_MAX_EXECUTOR_FAILURES)
}stopApplication이 하는 일이 곧 sys.exit(11)입니다. gRPC 서버도, SparkContext도, 모든 세션도 이 한 줄에 함께 내려갑니다.
이 카운터의 두 가지 성질이 멀티세션 서버에서 위험을 키웁니다. 첫째, Executor 단위 글로벌 카운터라 개별 쿼리의 Task 수준 fault tolerance(spark.task.maxFailures)와 무관하게 쌓이고, 시간이 지나도 사라지지 않습니다.
둘째, 모든 세션의 실패를 합산합니다. 그래서 OOM을 일으키는 쿼리가 반복되거나 여러 사용자의 executor 실패가 누적되면, 그 합이 임계값을 넘는 순간 서버 전체가 종료되고 원인을 만든 세션과 함께 아무 잘못 없는 다른 세션까지 함께 사라집니다.
문제 ②, 리소스 경합과 Task Scheduling 한계
서버가 세션마다 newSession()을 만들긴 하지만, 이건 세션 로컬 상태(SQL 네임스페이스)만 격리할 뿐입니다. CPU·메모리·Executor 같은 실행 리소스는 모두가 나눠 씁니다. 그래서 한 사용자가 Job을 대량으로 제출하면 다른 사용자의 응답이 느려집니다.
이건 Driver의 Task Scheduling 구조 때문입니다. 한 Driver의 스케줄러는 모든 세션의 Task를 함께 놓고, Executor의 Task 슬롯이 빌 때마다 하나씩 나눠줍니다. 기본 정책이 FIFO라 경합이 생기면 먼저 제출된 작업이 우선이고, 선점(Preemption)이 없어서 이미 슬롯을 차지한 Task를 중단시키지도 못합니다. 무거운 작업이 슬롯을 전부 채우면 뒤에 온 가벼운 쿼리는 그 Task들이 끝나 슬롯이 반환되기만 기다려야 합니다.

Scheduler가 다루는 건 어디까지나 Task 슬롯의 순서뿐이라, 사용자별로 CPU·메모리를 보장하거나 우선순위를 세밀하게 나누는 데는 구조적 한계가 있습니다. Spark에는 작업을 풀(Pool)로 나눠 가중치를 주는 Fair Scheduler도 있지만, 이 역시 빈 슬롯을 누구에게 먼저 줄지 순서를 바꿀 뿐 CPU·메모리를 격리해주지는 못합니다.
사실 Spark Connect에서는 이 Fair Scheduler의 Pool 분리조차 동작하지 않습니다. spark.scheduler.pool은 Job을 제출하는 실행 스레드에 thread-local로 얹혀 있어야 스케줄러가 인식하는데요. Classic Spark에서는 클라이언트가 곧 Driver라 setLocalProperty() 한 줄이면 그 스레드에 바로 적용되었지만, Spark Connect는 클라이언트와 Driver가 떨어져 있어 서버가 요청을 실행하는 스레드에 이 값을 대신 넣어줘야 합니다. 그런데 서버는 다른 값은 전파하면서 정작 Scheduler Pool만 빠뜨려요. 그래서 모든 쿼리가 Default Pool 하나에 들어갑니다. 커뮤니티에도 알려진 공백인데, 저희는 요청을 실행하는 서버 스레드에 사용자별 Pool을 직접 넣어주는 방식으로 메웠어요. 다만 Pool을 '사용자별'로 가르려면 먼저 그 요청이 누구 것인지 알아야 하죠. 이 사용자 식별이 인증·인가의 몫이라 자세한 건 3편에서 다룰게요.
어쨌거나 Pool이 다루는 건 어디까지나 Task 슬롯의 순서입니다. CPU·메모리 격리는 스케줄러로는 불가능하고, 결국 Spark 바깥에서 풀어야 했습니다.
문제 ③, 고정된 스케일
마지막은 이번 편에서는 다루지 않는 문제입니다. 서버는 미리 정해진 스펙(이미지, Driver·Executor 리소스, Spark 설정)으로 뜹니다. Dynamic Resource Allocation으로 Executor 수는 부하에 따라 늘었다 줄었다 할 수 있지만, 서버 자체의 스펙은 실행 시점에 고정됩니다. 서버를 그때그때 만들고 교체할 수 있어야 풀리니, 이건 다음 편 몫이에요.
그래서, 어떻게 풀었나
이 문제들을 한 번에 없애는 단일 해법은 없었습니다. 단일 장애점(①)과 리소스 경합(②)은 이번 편에서, 고정된 스케일(③)과 팀 단위 격리는 다음 편에서 다룰게요. 단일 장애점부터 보겠습니다.
공유 Driver의 SPOF를 어떻게 극복했나
단일 장애점은 두 단계로 풀었습니다. 먼저 서버가 죽는 일 자체를 줄이고, 그걸로도 못 막는 장애는 여러 Replica로 다중화해 받아냈어요.
먼저, 죽는 일 자체부터 줄이기
Replica를 늘리기 전에, 앞에서 본 ‘실패가 누적돼 서버를 죽이는’ 경로부터 끊었습니다. 글로벌 Executor 실패 카운터를 사실상 비활성화하고, 문제 쿼리의 정리는 Task·Stage·Job 단위의 Fault Tolerance에 맡긴 거예요.
spark.executor.maxNumFailures # 사실상 무한대로 → 글로벌 카운터 비활성화
spark.executor.failuresValidityInterval # 누적된 실패 기록을 주기적으로 비움
spark.task.maxFailures # 같은 Task가 거듭 실패하면 그 쿼리를 끊음
spark.stage.maxConsecutiveAttempts # shuffle fetch 실패로 stage가 반복되면 job을 끊음핵심은 첫 줄, 글로벌 카운터를 끄는 것입니다. failuresValidityInterval은 흔히 오래된 실패를 잊는 설정으로 소개되지만 카운터를 꺼둔 저희에게는 long-running 서버에 실패 기록이 끝없이 쌓이지 않게 주기적으로 비워주는 쪽에 가깝습니다. 나머지 두 값은 Bad query가 Executor를 붙잡는 시간을 줄이기 위한 저희 환경의 선택이라, 워크로드에 따라 달라질 수 있어요. 다만 task.maxFailures를 기본값보다 공격적으로 낮추면, 문제 쿼리를 빨리 끊는 대신 일시적인 장애에도 멀쩡한 쿼리까지 실패할 수 있어서 조금 여유를 두는 편이 안전해요.
task.maxFailures와 stage.maxConsecutiveAttempts는 같은 일을 단계만 달리해 막는 게 아니라, 서로 다른 실패를 맡습니다. 앞은 같은 Task가 거듭 실패하는 경우(OOM·예외)를, 뒤는 Shuffle Fetch 실패로 Stage가 통째로 다시 도는 경우를 끊어요.
이제 Executor가 반복해서 죽어도 서버는 살아남고, 문제를 일으킨 쿼리만 실패합니다. 에러도 달라졌어요. 전에는 서버가 죽어 모두가 ‘SparkContext에 접근할 수 없음’을 받았다면, 지금은 해당 쿼리를 보낸 사용자만 어떤 Executor가 어떤 Exit code로 죽었는지 담긴 Stage failure 에러를 받습니다. 원인 추적이 가능한 실패가 된 거죠.

Executor 실패만이 아니라, 쿼리 결과 그 자체도 서버를 위협합니다. Spark Connect에서는 모든 세션의 쿼리 결과가 Driver를 거쳐 클라이언트로 스트리밍되니까, 누군가 거대한 테이블을 Collect하면 그 데이터가 전부 Driver 메모리로 향하거든요. 여기는 spark.driver.maxResultSize가 막아줍니다. 한 액션의 Task 결과 누적 크기가 한도를 넘으면 job을 abort하는데, 눈여겨볼 건 차단 시점이에요. executor에 쌓인 큰 결과는 Driver가 가져오기 전에 크기부터 검사하고, 한도를 넘으면 Fetch 자체를 포기합니다. 위험한 데이터가 Driver 메모리에 발을 들이기 전에 끊는 거죠.
다만 기본값 1g은 이것도 ‘앱 하나 = 워크로드 하나’ 시절의 숫자입니다. 한도가 쿼리 단위라서, 여러 세션이 동시에 큰 결과를 받아 가면 각자 한도까지 통과합니다. 멀티세션 서버에서는 동시 쿼리 수를 감안해 이 값을 보수적으로 잡아야 합니다.
그래도 서버는 죽을 수 있습니다. Driver 자체가 OOM으로 내려가거나 노드가 통째로 사라지는 것까지 설정으로 막을 수는 없으니까요. 그래서 다음 단계가 필요했습니다.
하나의 Spark Connect 서버를 여러 replica로
가장 확실한 격리는 SparkContext 자체를 나누는 것입니다. 그래서 Spark Connect를 서버 한 대가 아니라, 같은 스펙의 서버 여러 대(Replica)로 구성할 수 있게 했어요. 각 Replica는 자기만의 SparkContext·Driver·Executor를 가지므로, 한 대가 장애로 내려가도 같은 Spark Connect의 다른 replica가 세션을 받습니다. 단일 장애점의 영향 범위가 Spark Connect 전체에서 replica 한 대로 줄어든 거죠.

Replica 수와 Executor 정책은 워크로드에 따라 조합합니다. 부하 변동이 크고 가용성이 중요한 워크로드라면 여러 replica(HA)에 Dynamic Resource Allocation을 얹어 한가한 replica는 Driver만 두고 Task가 쌓일 때만 Executor를 띄워요. 반대로 부하가 일정하거나 한 대로 충분하면 단일 replica에 Executor를 고정해 둡니다. DRA를 쓰는 쪽에선 부하가 replica들로 나뉘니, Executor 총량은 replica 수가 아니라 실제 작업량에 비례하고요.
요청을 어느 서버로 보낼까
하나의 Spark Connect가 여러 replica로 늘었으니, 이제 들어온 요청을 그중 어느 서버로 보낼지 정해야 합니다. 여기가 까다로웠어요. 새 세션은 한가한 서버에 놓여야 하고, 기존 세션은 늘 같은 서버로 가야 하거든요.
처음엔 Istio로 풀어봤어요. Spark Connect의 replica들을 하나의 Aggregate Service로 묶고, consistent hash로 세션을 고정했습니다.
- session affinity — Spark Connect 세션은 특정 Driver의 JVM 메모리에만 존재하니, 같은 세션의 요청은 늘 같은 replica로 가야 합니다.
- consistent hash — DestinationRule이 Client에서 따로 주입해준
x-spark-session-id헤더를 해시해서 같은 세션을 같은 pod에 고정합니다.
# DestinationRule
trafficPolicy:
loadBalancer:
consistentHash:
httpHeaderName: x-spark-session-id # 같은 세션 → 같은 podsession affinity는 이걸로 잡혔습니다. 여기에도 명확한 한계가 있어요.
Istio는 Spark를 모른다
consistent hash는 우리가 원한 session affinity를 정확히 만들어줬습니다. 같은 x-spark-session-id는 늘 같은 pod로 갔으니까요. 문제는 affinity가 아니라 새 세션을 어디에 둘지였습니다.
consistent hash는 오직 해시값으로만 pod를 정합니다. 그 서버가 지금 메모리를 얼마나 쓰는지, 무거운 쿼리가 몇 개 돌고 있는지는 전혀 보지 않습니다. 그래서 이미 바쁜 Spark Connect 서버에도 해시가 그쪽을 가리키면 새 세션을 계속 얹습니다. 한 서버는 터질 듯한데 옆 서버는 놀고 있어도, Istio 입장에선 그냥 균일한 백엔드 주소일 뿐 이 Spark 서버가 얼마나 바쁜지 알 수 없습니다.

우리가 원한 건 두 가지였어요. 기존 세션은 원래 서버에 묶어두는 것, 새 세션은 서버 부하를 보고 할당하는 것. 범용 프록시의 로드밸런싱 정책은 이 둘을 동시에 만족하지 못합니다. consistent hash는 앞의 것만, Least request 같은 부하 기반 정책은 뒤의 것만 해주거든요.
부하를 분산하려면, 단순한 주소가 아니라 Spark 서버의 상태를 아는 router가 필요했습니다.
그래서 Gateway를 개발했어요
만들기 전에 기존 프로젝트부터 살펴봤어요. Kyuubi는 멀티테넌트 게이트웨이라는 방향이 같고 성숙한 프로젝트지만, 제공하는 인터페이스가 Thrift/JDBC(HiveServer2 프로토콜) — SQL Only입니다. 우리는 분석가와 파이프라인이 쓰는 DataFrame API를 그대로 서비스해야 한다는 요구사항이 확실했고, 그걸 표준 프로토콜로 제공하는 건 Spark Connect뿐이었어요. Kyuubi의 Spark Connect 프로토콜 지원은 아직 커뮤니티에서 논의가 진행 중이고요. 그래서 우리 요구에 맞는 대안이 없었습니다.
결국 Spark Connect 서버의 부하를 이해하는 Gateway를 직접 만들었습니다. 이 Gateway, 그리고 다음 편에서 다룰 테넌트의 생성·교체·삭제를 맡는 Controller — 이 둘을 묶어 우리는 Spark Connect Hub라고 부릅니다. 이번 편은 그중 트래픽을 받아 나누는 Data Plane, 즉 Gateway 이야기예요. 핵심은 새 세션을 가장 한가한 서버에 배치하는 것입니다. 그러려면 먼저 이 서버가 지금 얼마나 바쁜지 관측할 수 있어야 합니다.
서버 부하를 어떻게 점수로 만드나
Spark는 Driver마다 모니터링용 REST API를 열어둡니다. Gateway는 이걸 폴링해 세 가지 숫자를 읽어요. 지금 executor에서 돌고 있는 Task 수(active), 슬롯을 못 잡고 줄 서 있는 Task 수(pending), 그리고 이 서버가 동시에 굴릴 수 있는 Task 슬롯의 총량(maxTasks)입니다. maxTasks는 executor마다의 슬롯(≈ 코어 수)을 전부 더한 값, 곧 그 서버의 동시 처리 용량이에요.
보는 건 메모리 사용량이 아니라 이 슬롯이 얼마나 꽉 찼고 얼마나 밀려 있나입니다. 그래서 부하를 (active + pending) / maxTasks로 잡아요 — 처리 중이거나 대기 중인 Task를 전체 슬롯으로 나눈 거죠. 슬롯이 다 차고 대기까지 쌓인 서버는 1을 넘고, 노는 서버는 0에 가까우니, 무거운 쿼리가 도는 서버를 집어내는 신호가 됩니다. 다만 폴링 스냅샷이라 측정할 때마다 출렁여서, 지수가중이동평균(EWMA)으로 다듬어 순간 스파이크가 아니라 그 서버의 평소 바쁨이 점수에 남게 했습니다.
여기에 보조 신호로 활성 세션 수를 더합니다. REST 폴링은 갱신에 지연이 있어, 짧은 시간에 새 세션이 몰리면 측정값이 갱신되기 전까지 전부 같은 한가한 서버로 쏠리거든요. 세션 수는 저장소에서 즉시 읽히니 배치하는 순간 바로 반영돼, 그 폴링 공백을 메워 줍니다.
부하 점수 = 0.9 × utilization + 0.1 × min(active_session_count / cap, 1)utilization을 지배적으로(0.9) 두고 세션 수는 보조로(0.1) 얹습니다. 점수가 같은 서버가 여럿이면 한쪽으로 쏠리지 않게 무작위로 고르고요.
세션은 한 번 정한 서버를 끝까지
새 세션은 위 점수로 배치하지만, 이미 자리를 잡은 세션은 부하와 무관하게 원래 서버로 보냅니다. 세션 상태가 그 Driver의 메모리에만 있으니 다른 데로 보내면 안 되거든요.

요청이 올 때마다 idle 타이머를 리셋하는 방식이라, 활발히 쓰는 세션은 계속 유지되고 한참 안 쓴 세션만 만료됩니다. 부하 인지 배치(새 세션)와 고정 affinity(기존 세션), 이 두 규칙으로 consistent hash가 못 하던 문제를 풀면서도 세션은 안전하게 지킵니다. 하나였던 Driver를 여러 대로 나눠 세션들이 한 SparkContext를 공유하지 않게 되었고, 새 세션은 그중 가장 한가한 서버로 갑니다. 이렇게 리소스 경합을 풀었어요.
라우팅 상태는 어디에 있을까 — ServerPool·SessionStore
새 세션은 한가한 서버로, 기존 세션은 원래 서버로 — 이런 결정을 내리려면 Gateway는 두 가지 상태를 들고 있어야 합니다. 어떤 서버가 있나(ServerPool), 이 세션은 어디 붙었나(SessionStore). 둘 다 ‘풀’이지만, 누가 Redis에 쓰고, Gateway가 그걸 어떻게 들고 있는지가 서로 다릅니다.
정리하면, 세션 매핑은 Gateway가 Redis에 직접 써서 모든 Gateway가 공유하고, 서버 목록은 Controller가 Redis로 흘려준 걸 각 Gateway가 메모리에 캐싱해 들고 있습니다. 그런데 ServerPool은 채우는 주체가 Gateway가 아니라고 했죠. 그 이야기를 할 차례입니다.
Gateway는 Kubernetes를 몰라도 돼요
살아있는 서버 목록(ServerPool)을 채우려면, 보통은 각 Gateway가 Kubernetes API를 직접 watch하게 만듭니다. 하지만 그러면 Gateway를 늘릴 때마다 watch가 replica 수만큼 API 서버에 붙고, Data Plane인 Gateway가 K8S 접근 권한과 informer까지 떠안아야 합니다. 무엇보다 트래픽을 받는 Data Plane이 Control Plane에 묶이는 게 안정성 면에서 좋지 않습니다 — Controller나 K8S API가 흔들리면 라우팅까지 같이 흔들리니까요.

그래서 K8S를 보는 일은 Controller 하나에 몰아줬습니다. Controller는 Kubernetes Operator로 구현했어요. 단일 Spark Connect 서버의 라이프사이클은 이미 Spark Operator가 다루는 영역이라, 우리는 그 위에 한 계층을 더 올렸습니다. Controller는 서버를 직접 만들지 않고, 이 operator들이 관리하는 서버 리소스를 여러 대 묶음으로 선언·조율하죠 — 즉 우리가 푼 문제는 ‘서버 한 대를 어떻게 띄우나’가 아니라 ‘여러 대를 한 묶음으로, 무중단으로 어떻게 굴리나’입니다. 복제본 수 유지, 스펙 변경 시 Blue-Green 교체, 스케일 인/아웃 시 drain이 여기 들어갑니다(Controller의 자세한 동작은 다음 편에서 다룹니다). 이렇게 Controller가 파악한 서버 상태를 Redis instance store에 Publish하면, Gateway는 K8S API를 전혀 보지 않고 이 Redis만 짧은 주기로 폴링해 ServerPool을 맞춥니다.
폴링할 때마다 Instance store와 ServerPool을 비교해, 새 서버는 더하고 사라진 서버는 빼면서 그 서버에 묶인 세션 매핑까지 정리합니다. 살아있는 서버는 Controller가 주기적으로 갱신하고, 사라진 서버는 TTL이 지나 저절로 빠집니다. 그래서 서버가 생기든 없어지든 Gateway는 다음 폴링이면 따라잡습니다.
덕분에 Gateway를 아무리 늘려도 K8S API 부하는 그대로고, Controller가 잠시 죽어도 Gateway는 마지막 ServerPool로 계속 라우팅합니다. Data Plane은 Redis만 보면 되니 책임이 깔끔하게 나뉩니다.
마치며
정리하면, Spark Connect는 Driver를 미리 띄운 서버로 옮겨 가벼운 클라이언트와 즉시 세션을 얻는 대신, 서버 하나에 세션이 몰려 단일 장애점(①)과 리소스 경합(②)을 안게 됩니다.
먼저 서버가 죽는 일을 줄이려고, 멀티세션에서 깨지는 Spark 기본값을 다시 잡았습니다. 앞에서 짚은 값들에, Driver heap을 함께 나눠 쓰며 위험을 키우는 몇 가지를 더해 아래에 모았어요. 기존 Spark 운영에선 건드릴 일 없던 값들이라, Spark Connect를 운영한다면 여기부터 점검해 보면 좋습니다.
spark.driver.maxResultSize1gspark.driver.memory1gspark.driver.memoryOverheadmax(384m, 0.1×driver)spark.executor.failuresValidityIntervalspark.executor.maxNumFailuresmax(3, 2×executor)spark.sql.autoBroadcastJoinThreshold10mspark.stage.maxConsecutiveAttempts4task.maxFailures와 짝으로 워크로드에 맞춰 조정spark.task.maxFailures4spark.ui.store.path그래도 막지 못하는 장애는 여러 Replica로 받고, 그 앞에 Spark Connect Hub의 Gateway를 둔 뒤 새 세션은 한가한 서버로·기존 세션은 원래 서버로 보냈습니다. 덕분에 장애 범위는 replica 한 대로 줄고(①), 경합도 풀었죠(②).
다음 편에서는 고정된 스케일(③)과 테넌트 격리를 맡는 Control Plane·무중단 Blue-Green 교체를, 3편에서는 인증·인가로 사용자를 식별해 각자 허용된 범위 안에서만 데이터에 접근하도록 만드는 이야기를 다룹니다.
