Ian's Archive 🏃🏻

thumbnail
데이터 플랫폼, Apache Spark 도입을 고민하고 보류한 이유
JAVA
2025.07.25.

최근 우리 팀은 정책 상 등록할 수 있는 데이터의 최대 크기가 수십GB 수준으로 확정되면서, 검토 중이던 Apache Spark를 최종적으로 도입하지 않기로 결정했다.

하지만 추후 크기가 큰 데이터 융복합이나, 대량의 데이터가 생길 경우에는 다시 고민을 해보자는 결론이 났으므로, 리마인드 겸 블로그를 통해 정리한다.


현재 개발 중인 스케줄러가 있다. 이 스케줄러가 처리하는 작업은 크게 4가지로 나뉜다.

  1. 모듈: 이미 존재하는 데이터를 동기화하는 내부 로직
  2. DB: 사용자가 설정한 값으로 DB에서 데이터를 가져오는 작업
  3. API: 외부 API를 호출하여 데이터를 가져오는 작업
  4. File: 사용자가 업로드한 파일을 읽어 처리하는 작업

‘모듈’을 제외한 나머지 3가지는 사용자가 입력한 설정값을 기반으로 동작한다. 여기서 고민이 됐던 부분은 바로 ‘File’ 처리였다.

1. 고민의 시작

가장 큰 걱정거리는 3가지였다.

  • 대용량 파일을 업로드하면 문제없이 끝까지 처리할 수 있을까?
  • 파일 처리 부하가 급증할 때 다른 API까지 먹통이 되면서 서버가 뻗지는 않을까?
  • 현재 스트림 방식으로 파일이 DB에 잘 적재만 되게 구현했는데, 융복합 기능 개발 시 운영서버나 DB서버에 쌓일 부하

로컬 PC에서 40GB짜리 txt 파일을 Java 코드로 변환 후 DB에 삽입하는 테스트를 했을 때 돌아는 갔지만, 실제 여러 사용자가 접속하는 서비스 환경을 생각하니 문제가 없을 지 확신이 없었다.

작성한 코드는 결국 Java 애플리케이션 위에서 돌고, 대용량 파일을 처리하려면 스트림 처리를 하더라도 그만큼의 메모리(JVM Heap)를 점유할 것이기 때문이다.

한 사용자가 올린 파일 하나가 너무 큰 파일이거나 전체 짧은 시간에 여러 요청이 와서 서비스가 OOM(OutOfMemoryError)으로 죽어버리는 좋지 않은 시나리오가 계속해서 걱정이 되었다.
(괜한 고민이긴 했다. 수십 GB정도는 서버 성능이 좋아서 기존대로 처리해도 크게 문제가 되지는 않을 것 같다는 피드백이 있었다.)

이러한 성능상의 문제를 해결해줄 수 있는 솔루션이 있는지 찾아보았다.

2. 그래서 어떤 방법이 있는데?

  1. 기존 방안 - 스트림 활용

장점

  • 현재 구현방식으로 순수한 Java코드로 해결이 가능

단점

  • 추후에 복잡한 데이터 변환이나 여러 데이터 소스를 조인하는 로직을 구현하기엔 코드가 지저분해짐
  • 결국 서버 한 대의 성능이 한계
  1. 작업 분리

구현 방법

  • 메시지 큐 도입
  • 큐를 감시하고 있는 별도의 워커 애플리케이션은 만듬, 워커는 큐에 메시지가 들어오면 그걸 가져가서 실제 파일 처리를 수행하고, 결과를 DB에 저장

장점

  • 격리, 워커가 파일을 처리하다가 죽더라도 메인 웹 어플리케이션은 영향을 받지 않음
  • 확장성, 일이 많이 쌓이면 워커 어플리케이션의 인스턴스 개수 증가, 메인 서버와 독립적으로 확장

단점

  • 새로운 시스템을 도입해야하고, 아키텍처가 복잡해짐
  • 워커 어플리케이션을 별도로 개발하고 배포, 관리해야 함
  1. 대용량 데이터 처리 전문 프레임워크 도입

구현 방법

  • 분산처리 프레임워크 사용 (Apache Spark)
  • Spark 엔진이 알아서 파일을 분할하고, 여러코어로 병렬 처리, 메모리가 부족하면 디스크 사용 등 복잡한 과정 책임져 준다.

장점

  • Spark가 알아서 메모리, 디스크 관리
  • 단일 머신에선 멀티코어 100% 활용, 나중에 데이터가 커지면 코드 수정 없이 클러스터 환경으로 전환해 무제한으로 확장 가능
  • 데이터 조인, 집계 등 복잡한 로직을 매우 간결한 코드로 작성할 수 있게 해주는 강력한 API 제공

단점

  • 간단한 작업을 처리하기엔 배보다 배꼽이 더 클 수 있다.
  • Spark라는 새로운 기술에 대한 학습 곡선
  • Spark라는 무거운 의존성 추가된다.

오버 엔지니어링을 가장 걱정했지만, 실제 서비스는 데이터 처리가 가장 핵심이고 “여러 사용자가”, “동시에”, “어떤 크기의 파일을 올릴지 모르는 상황에서”, “앞으로 더 복잡한 분석 기능이 추가될 수 있고”, “장애 상황에서도 안정적으로” 동작해야 했기 때문에 Spark 도입하는 것이 좋은 방법이라 생각했다.

3. Spark 정리

Spark는 단순히 파일 처리 도구가 아니라, **통합 분석 엔진(Unified Analytics Engine)**이다. 분산 처리를 위한 RDD(Resilient Distributed Dataset)라는 핵심 개념 위에, 다양한 모듈을 얹어서 사용한다.

  • Spark SQL: 정형 데이터를 다루는 모듈. 우리가 흔히 아는 DataFrame이나 Dataset API를 제공해서 SQL 쓰듯이 데이터를 다룰 수 있게 해준다. 이게 이번 고민의 핵심이었다.
  • Structured Streaming: 실시간 데이터 처리용. 배치 처리 코드랑 거의 비슷하게 쓸 수 있어서 좋다.
  • MLlib / GraphX: 각각 머신러닝, 그래프 처리용 라이브러리다.

이 모든 걸 하나의 엔진과 일관된 API로 쓸 수 있다는 게 Spark의 가장 큰 매력이다.

4. Spark, 그런데 컴퓨터가 한 대 인데?

Spark 하면 보통 수십, 수백 대의 컴퓨터를 엮어서 쓰는 ‘분산 처리’ 기술의 끝판왕으로 알려져 있다. 그런데 우리 서버는 그냥 단일 클러스터, 쉽게 말해 컴퓨터 한 대다. 이런 환경에서 Spark를 쓰는 게 과연 의미가 있을까? “닭 잡는 데 소 잡는 칼 쓰는 격 아닌가?” 하는 의문이 들기도 했다.

단일 머신에서 Spark를 실행하는 걸 **‘로컬 모드(Local Mode)‘**라고 한다. 이 모드의 장점을 파고들기 전에, 먼저 기존 방식과 Spark 방식의 근본적인 차이를 짚어볼 필요가 있다.

전통적인 방식 vs. Spark 방식

시나리오: DB 안에 있는 40GB짜리 로그 테이블(Table A)과 100GB짜리 사용자 행동 테이블(Table B)을 조인해서, 두 정보를 합친 약 140GB 크기의 새로운 분석용 테이블(Table C)을 만든다고 가정

(시나리오는 추후 데이터 융복합과정이 부하가 더 클것으로 생각되어 이러한 시나리오로 가정)

방식 1: 전통적인 Java + SQL

  1. Java 애플리케이션이 거대하고 복잡한 JOIN SQL을 생성한다.
  2. 이 SQL을 DB로 전송한다.
  3. 모든 부하는 DB 서버에 집중된다. DB는 1억 건과 100만 건 테이블을 전부 읽고, 조인하고, 그룹핑하고, 계산까지 혼자 다 한다. 이 작업 동안 DB는 다른 요청에 제대로 응답하기 어렵다.

처리 부하: 모든 계산(1억 건 x 100만 건 조인)이 DB 서버에 집중된다. 이 작업이 실행되는 동안 DB는 다른 서비스 요청에 응답하기 어려울 수 있다.

SQL의 역할: 데이터 **처리(Process)와 적재(Load)**를 모두 담당

방식 2: Spark를 이용한 방식

  1. Spark가 DB에 “판매 기록이랑 고객 정보 그냥 통째로 줘” 라고 요청한다. (단순 SELECT *)
  2. DB에서 읽어온 데이터의 처리 주체는 Spark가 된다. Spark는 데이터를 여러 조각으로 나눠 병렬로 조인, 그룹핑, 계산을 수행한다. 이 모든 무거운 작업은 DB가 아닌 Spark 엔진(여기서는 우리 애플리케이션 서버)에서 일어난다.
  3. Spark가 모든 계산을 끝내고 나온 최종 결과(예: 1,000건)만 DB에 “이것만 저장해줘” 라고 요청한다. (단순 INSERT)

마지막은 SQL이 지만 Spark는 많은 데이터를 처리하는 무거운 계산을 DB 외부의 분산 환경에서 모두 끝내고, 그 결과인 몇천 건의 “정답”만 들고 가서 “이것만 DB에 적어주세요” 라고 요청하는 방식이다.

핵심은 **계산 부하를 DB에서 Spark로 완전히 분리(Offloading)**하는 것이다. DB는 데이터 창고 역할에만 충실하게 되고, 시스템 전체의 안정성이 올라간다.

이 개념을 파일 처리에 그대로 적용할 수 있다. 기존 방식은 Java 애플리케이션의 메모리가 모든 부하를 감당했지만, Spark를 쓰면 Spark 엔진이 그 역할을 대신하게 되는 거다.

추후에 시스템을 확장하더라도 DB 서버 스케일업 (비용 높고 한계 명확)보단, Spark 클러스터 스케일아웃 (비용 효율적, 거의 무한 확장)이 효율적이다.

5. 단일 클러스터일 경우 진짜 이점

그럼 이제 “컴퓨터 한 대인데 그게 무슨 소용이야?” 라는 질문에 답을 할 차례다. 크게 3가지 장점이 있다.

  1. 멀티코어 활용 극대화 (병렬 처리)

평범한 Java 코드는 특별한 병렬 프로그래밍을 하지 않는 이상, 기본적으로 단일 코어로만 동작한다. 8코어 CPU가 있어도 1개 코어만 죽어라 일하고 나머지 7개는 노는 셈이다.

하지만 Spark 로컬 모드는 다르다. local[*] 옵션을 주면 Spark는 알아서 기기의 모든 CPU 코어를 최대한 활용해 작업을 병렬로 처리한다. 40GB짜리 파일을 8개 코어가 5GB씩 나눠서 동시에 처리하는 식이다. 덕분에 단일 머신 내에서도 훨씬 빠른 성능을 기대할 수 있다.

  1. 메모리 관리의 효율성 (OOM으로부터의 해방)

이게 Spark를 선택한 가장 결정적인 이유다.

내 서버의 가용 메모리가 16GB인데 40GB짜리 파일 처리를 요청받았다고 가정해보자.

  • Spark 로컬 모드: Spark는 데이터를 한 번에 메모리에 올리지 않는다. 파일을 여러 개의 작은 조각(Partition)으로 나누어 처리한다. 만약 처리 도중 메모리가 부족해지면, 처리 중인 데이터를 디스크에 잠시 내려놓고(Spilling) 나중에 다시 읽어오는 아주 영리한 방식으로 동작한다. 속도는 조금 느려질 수 있어도, 애플리케이션이 죽는 최악의 상황은 절대 발생하지 않는다.
  1. 코드의 일관성과 확장성 (미래를 위한 투자)

이건 전략적으로 가장 중요한 이점이다.

  • 지금은 데이터가 적으니 단일 머신(로컬 모드)에서 Spark 코드를 개발한다.
  • 1년 뒤, 데이터가 100배로 늘어 도저히 서버 한 대로 감당이 안 되는 시점이 왔다.
  • 이때, 우리는 기존에 작성한 Spark 코드를 단 한 줄도 수정할 필요 없이, 실행 환경만 로컬 모드에서 실제 분산 클러스터(YARN, Kubernetes 등)로 바꿔주기만 하면 된다.

DataSet API의 간결함 -> 가장 마음에 들었던 부분이다.

그리고 Spark에서는 데이터 변환이나 합성을 위해 DataSet을 사용하는 방법을 지원하는데, 이게 물건이다. 기존 Java 코드였다면 CSV 한 줄씩 읽어서 객체로 파싱하고, for문 돌리면서 조건 체크하고, 다른 데이터랑 합치기 위해 또 복잡한 로직을 짜야 했다. 하지만 DataSet API를 쓰니 dataset.filter(…).join(…).withColumn(…) 같은 함수형 스타일로 코드가 훨씬 깔끔해졌다. 가독성도 좋고 유지보수도 쉬워 보였다.

6. 그래서 얼마나 빨라졌나? (속도보단 안정성성)

실제 파일 처리 스케줄러에 Spark를 적용한 후 시간을 측정해봤다.

구분 시작 시간 종료 시간 소요 시간
Spark 적용 전 2025-07-23 16:49:21 2025-07-23 17:19:59 약 30분 38초
Spark local[*] 옵션 2025-07-24 11:21:05 2025-07-24 11:46:26 약 25분
Spark 적용 후 2025-07-24 14:54:51 2025-07-24 15:29:15 약 34분 24초

“어? 오히려 더 느려졌잖아?” 라고 생각할 수 있다. 맞다. local[2] 처럼 적은 코어를 할당하고 디스크 I/O가 발생하니 작업에서의 속도가 조금 느려졌다.

local[*]일 경우는 CPU를 많이 잡아먹긴 하지만 5분 정도 시간이 단축되었다.

하지만

  • 기존 구조는 메모리보다 큰 파일이 들어오는 순간 OOM으로 그냥 죽는다. (언제 터질지 모르는 시한폭탄과 같다.)
  • Spark 구조는 파일 크기가 아무리 커져도 느려질지언정 죽지 않고 작업을 완료한다. (시스템 전체의 생존을 보장하는 안전장치인 셈이다.)

나는 약간의 속도를 포기하고, 그 대가로 안정성과 미래 확장성을 얻었다. 이보다 더 좋은 거래는 없다고 생각했다.

잠깐! Spark는 메모리 기반 아니었어?

  • 많은 사람들이 “Spark는 인메모리(In-Memory) 처리, 하둡은 디스크 기반 처리”라고 알고 있을 거다. 이건 반은 맞고 반은 틀린 이야기다. Spark는 하둡과 달리 가급적 메모리를 사용해서 작업 간 데이터 전달을 하기에 빠른 게 맞다.

  • 하지만 핵심은 메모리가 부족할 때다. 이때 Spark는 앞서 말했듯 지능적으로 디스크를 활용하여 OOM 없이 안정적으로 작업을 끝낸다. 즉, “Spark는 메모리의 속도와 디스크의 안정성을 모두 활용하는 하이브리드 시스템” 이라고 이해하는 게 가장 정확하다.

7. 적용 방법

  1. build.gradle 추가
copyButtonText
	// Spark
	implementation 'org.apache.spark:spark-core_2.13:3.5.6'
	implementation 'org.apache.spark:spark-sql_2.13:3.5.6'
	implementation 'com.databricks:spark-xml_2.13:0.18.0'

	// Spark는 window 파일 시스템 작업을 위해 내부적으로 Hadoop API를 사용
	runtimeOnly("org.apache.hadoop:hadoop-client-runtime:3.3.6")
	runtimeOnly("org.apache.hadoop:hadoop-client-api:3.3.6")

	// Spark UI가 필요로 하는 javax.servlet-api를 직접 추가
	// Spring Boot 3.x의 jakarta.servlet-api와의 충돌을 해결
	implementation("javax.servlet:javax.servlet-api:4.0.1")

	// 2. Spark UI의 REST API가 사용하는 Jersey 관련 라이브러리 버전을 명시적으로 지정
	implementation "org.glassfish.jersey.core:jersey-client:${jerseyVersion}"
	implementation "org.glassfish.jersey.core:jersey-common:${jerseyVersion}"
	implementation "org.glassfish.jersey.core:jersey-server:${jerseyVersion}"
	implementation "org.glassfish.jersey.containers:jersey-container-servlet:${jerseyVersion}"
	implementation "org.glassfish.jersey.containers:jersey-container-servlet-core:${jerseyVersion}"
	implementation "org.glassfish.jersey.inject:jersey-hk2:${jerseyVersion}"
  1. yml 설정 변경

application-dev.yml

copyButtonText
spark:
  app:
    name: Dev-DataPlatform-Job
  master:
    uri: local[6]
  sql:
    warehouse:
      dir: file:///${java.io.tmpdir}/spark-warehouse-dev
    shuffle:
      partitions: 3
  driver:
    memory: 4g
  memory:
    offHeap:
      size: 1g

application-prod.yml

copyButtonText
spark:
  app:
    name: Prod-DataPlatform-Job
  master:
    uri: local[16]
  sql:
    warehouse:
      dir: file:///tmp/spark-warehouse-prod
    shuffle:
      partitions: 16
  driver:
    memory: 20g
  memory:
    offHeap:
      size: 4g
  1. SparkConfig 생성
copyButtonText
@Configuration
public class SparkConfig {

    @Value("${spark.app.name}")
    private String appName;

    @Value("${spark.master.uri}")
    private String masterUri;

    @Value("${spark.sql.warehouse.dir}")
    private String warehouseDir;

    @Value("${spark.driver.memory}")
    private String driverMemory;

    @Value("${spark.sql.shuffle.partitions}")
    private String shufflePartitions;

    @Value("${spark.memory.offHeap.size}")
    private String offHeapMemorySize;

    @Bean
    public SparkConf sparkConf() {
        SparkConf sparkConfiguration = new SparkConf()
                .setAppName(appName)
                .setMaster(masterUri)
                .set("spark.sql.warehouse.dir", warehouseDir)
                .set("spark.driver.memory", driverMemory)
                .set("spark.sql.shuffle.partitions", shufflePartitions)
                .set("spark.memory.offHeap.enabled", "true")
                .set("spark.memory.offHeap.size", offHeapMemorySize)
                .set("spark.scheduler.mode", "FAIR"); // FAIR 스케줄러 모드 설정

        try {
            ClassPathResource resource = new ClassPathResource("fairscheduler.xml");

            try (InputStream inputStream = resource.getInputStream()) {
                // 임시 파일을 생성합니다.
                File tempFile = File.createTempFile("fairscheduler-", ".xml");
                tempFile.deleteOnExit();

                try (OutputStream outputStream = new FileOutputStream(tempFile)) {
                    StreamUtils.copy(inputStream, outputStream);
                }

                sparkConfiguration.set("spark.scheduler.allocation.file", tempFile.getAbsolutePath());
            }

        } catch (IOException e) {
            throw new RuntimeException("Could not find or process fairscheduler.xml in classpath", e);
        }

        return sparkConfiguration;
    }

    @Bean(destroyMethod = "stop")
    public SparkSession sparkSession() {
        return SparkSession.builder()
                .config(sparkConf())
                .getOrCreate();
    }
}
  1. Service 코드 변경
copyButtonText
    /**
     * 스케줄에 등록된 파일 처리를 위한 메인 진입점.
     * 파일 유형을 감지하고 적절한 처리기로 라우팅합니다.
     */
    public void processScheduledFile(String schedulId, String schedulHistId) { // History ID를 받도록 수정
        SchedulDetailDto detail = wrkClctDao.getSchedulDetailById(schedulId);
        String fileType = determineFileType(detail.getUploadedFiles());

        if (fileType == null) {
            String errorMsg = "처리할 파일이 없거나 지원하지 않는 파일 유형입니다.";
            log.error("스케줄 ID [{}]: {}", schedulId, errorMsg);
            updateHistory(schedulHistId, "FAIL", errorMsg);
            return;
        }

        log.info("스케줄 ID [{}]: 감지된 파일 유형 '{}'에 대한 처리를 시작합니다.", schedulId, fileType);

        try {
            // 공간정보 파일은 기존 ogr2ogr 방식 유지
            if ("shp".equalsIgnoreCase(fileType) || "gpkg".equalsIgnoreCase(fileType)) {
                handleGeospatialFile(detail, fileType);
            } else {
                // 그 외 모든 파일(CSV, JSON, XML, TXT 등)은 Spark으로 통합 처리
                handleFileWithSpark(detail, fileType);
            }
        } catch (Exception e) {
            String errorMsg = "파일 처리 중 최상위 오류 발생: " + e.getMessage();
            log.error("파일 처리 중 오류 발생 (스케줄 ID: {})", schedulId, e);
            updateHistory(schedulHistId, "FAIL", errorMsg);
            throw new RuntimeException("스케줄 " + schedulId + "의 파일 처리 실패", e);
        }
    }

    /**
     * Spark를 사용하여 일반 파일(CSV, JSON, XML, TXT 등)을 처리하는 통합 메소드.
     *
     * @param detail   스케줄 상세 정보
     * @param fileType 파일 유형
     */
    private void handleFileWithSpark(SchedulDetailDto detail, String fileType) {
        UploadedFileDto uploadedFile = detail.getUploadedFiles().get(0);
        Path filePath = Paths.get(permanentStoragePath, uploadedFile.getUniqueName());

        if (!Files.exists(filePath)) {
            throw new RuntimeException("파일을 찾을 수 없습니다: " + filePath);
        }

        log.info("Spark(v{}) 기반 파일 처리 시작. 파일: {}", sparkSession.version(), filePath);

        // 1. Spark를 사용하여 파일 읽기
        Dataset<Row> sourceDf = readDataWithSpark(filePath.toAbsolutePath().toString(), fileType);
        if (sourceDf == null) {
            throw new IllegalArgumentException("지원하지 않는 파일 유형이거나 읽기에 실패했습니다: " + fileType);
        }
        log.info("파일로부터 {} 건의 데이터를 DataFrame으로 읽었습니다.", sourceDf.count());
        sourceDf.printSchema();

        // 2. 데이터 변환 (Transformation)
        Dataset<Row> transformedDf = transformDataFrame(sourceDf, detail.getDataId());
        log.info("DataFrame 변환 완료. 최종 스키마:");
        transformedDf.printSchema();

        // 3. Spark를 사용하여 대상 DB에 데이터 쓰기
        String targetTableName = "clct_storage_test." + sanitizeIdentifier(detail.getClctDataEngNm());
        Properties targetProps = new Properties();
        targetProps.put("user", targetDbUsername);
        targetProps.put("password", targetDbPassword);
        targetProps.put("driver", "org.postgresql.Driver");

        SaveMode saveMode = "overwrite".equalsIgnoreCase(detail.getUpdateMode()) ? SaveMode.Overwrite : SaveMode.Append;

        // 테이블 DDL 관리를 위해, Overwrite 모드일 때 테이블을 미리 준비
        if (saveMode == SaveMode.Overwrite) {
            List<ClctDataColumnResponseDto> columnSchema = clctDataColumnService.findByDataId(detail.getDataId());
            prepareTargetTable(sanitizeIdentifier(detail.getClctDataEngNm()), "overwrite", columnSchema);
        }

        transformedDf.write().mode(saveMode).jdbc(targetDbUrl, targetTableName, targetProps);
        log.info("Spark 파일 처리 작업 성공. 데이터가 테이블 '{}'에 저장되었습니다.", targetTableName);
    }
  1. fairscheduler.xml 설정
copyButtonText
<?xml version="1.0"?>
<allocations>
    <!--
      'default' 풀은 모든 작업이 기본적으로 할당되는 곳입니다.
      FAIR 스케줄링 모드를 사용하도록 설정합니다.
    -->
    <pool name="default">
        <schedulingMode>FAIR</schedulingMode>
        <weight>1</weight>
        <minShare>0</minShare>
    </pool>

    <!--
      추후에 특정 종류의 작업에 우선순위를 주고 싶다면
      아래와 같이 새로운 풀을 추가할 수 있습니다. (지금은 필요 없음)

      <pool name="high-priority-jobs">
        <schedulingMode>FAIR</schedulingMode>
        <weight>2</weight>
        <minShare>10</minShare>
      </pool>
    -->
</allocations>

8. 간단한 설정만 했는데, 그럼 현재 구조에선 동작을 어떻게 하는거지?

처음엔 Spring 애플리케이션이 어떻게 Spark를 실행시키는지 궁금했다. 별도의 Spark 데몬 같은 걸 띄우는 건가? 하고 찾아보니 그게 아니었다.

  • 동작 방식: Spring Boot 애플리케이션에서 SparkSession을 생성하는 순간, Spring Boot 애플리케이션의 JVM 자체가 Spark 드라이버(Driver) 프로그램이 된다. 그리고 master 설정을 local[N]으로 하면, 이 드라이버는 자기 자신(같은 JVM) 내부에 N개의 실행자(Executor) 쓰레드를 생성해서 작업을 병렬로 처리한다. 즉, 외부 시스템 없이 Spring Boot 애플리케이션 하나가 북 치고 장구 치고 다 하는, 아주 깔끔한 구조다.

  • 미래: 나중에 클러스터 확장이 필요하면 Docker나 Kubernetes로 Spark 클러스터를 구축하고, 코드 수정 없이 application.yml의 spark.master.uri 값만 클러스터 주소(예: k8s://…)로 바꿔주면 끝이다. 로직은 그대로, 실행 환경만 바뀌는 것이다.

9. 결론

스파크의 장점을 요약하자면

  • Java 애플리케이션은 Spark에게 “이 작업 좀 해줘”라고 **요청(Orchestration)**만 한다.
  • 모든 무거운 데이터 처리는 독립된 Spark 엔진이 알아서 한다.
  • 덕분에 Java 애플리케이션의 메모리는 안전하게 유지되고, 다른 API 요청도 안정적으로 처리할 수 있다.

스케줄러 작업이 순차적으로 실행되는 환경에서, 앞선 작업 하나가 대용량 파일을 처리하다가 서버 전체를 죽여버린다면 뒤따라올 모든 작업이 그대로 멈추게 된다. Spark를 도입함으로써, 스케줄러는 이제 어떤 크기의 파일이 들어와도 메모리 걱정 없이 안정적으로 작업을 수행할 수 있게 된다는 점을 확인했다.

하지만, 앞서 말했듯 최종적으로 우리는 Spark를 도입하지 않았다.

PoC를 통해 Spark의 강력함은 충분히 확인했지만, 플랫폼 정책 상 처리할 데이터 최대 크기가 수십 GB로 확정된 것이 결정적이었다. 이 규모는 굳이 Spark라는 ‘소 잡는 칼’을 쓸 필요가 없는 크기라고 판단했다.

  • 복잡성: Spark 도입은 단순히 라이브러리 추가가 아니라, 무거운 의존성과 Spark만의 생명주기, 설정을 관리해야 하는 복잡성을 안고 가는 것이다.
  • 우선순위: 현재로서는 비즈니스 로직을 빠르게 개발하는 것이 새로운 기술 스택을 도입하고 학습하는 것보다 더 중요했다.

결국 약간의 속도를 포기하고 안정성과 확장성을 얻는 거래는 매우 매력적이었지만, 그 거래를 하기엔 우리가 다룰 ‘판돈(데이터 크기)‘이 아직 크지 않았다.

그래서 우리는 Java 애플리케이션이 Spark에게 “이 작업 좀 해줘”라고 요청하는 대신, 지금은 우리가 할 수 있는 가장 단순한 방법으로 문제를 해결하기로 했다. 물론, 데이터가 우리의 예상을 뛰어넘는 날이 온다면, 이 글에 정리해 둔 고민과 코드가 미래의 우리에게 아주 좋은 출발점이 되길…

Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.