Spring Batch2025년 5월 22일4분 읽기

LMS와 실서비스가 같은 DB를 쓰고 있었다 — DB 분리 + ETL로 조회 20s → 1s

10년 된 LMS의 히스토리 DB가 실서비스 메모리를 잡아먹기 시작했다. 캐싱이 안 되는 이유, DB를 분리한 이유, ETL 파이프라인을 어떻게 설계했는지.

#Spring Batch#ETL#DB분리#MySQL#성능개선#Backend

LMS와 실서비스가 같은 DB를 쓰고 있었다 — DB 분리 + ETL로 조회 20s → 1s


상황

교육 플랫폼의 차세대 LMS 백엔드를 개발하면서 접한 레거시 시스템이 있었습니다. 10년 이상 운영된 LMS였는데, 시간이 지날수록 특이한 장애가 반복됐습니다.

학생별 수업 이력 조회가 20초 이상 걸리는 건 기본이고, 오전 피크타임에는 이것과 무관해 보이는 실서비스 — 로그인, 컨텐츠 재생, 수강신청 — 에서도 프리징이 발생했습니다. 콜센터로 하루에 3건씩 VOC가 들어왔고, 원인이 뭔지 파악이 안 되고 있었습니다.


원인 분석

첫 번째로 의심한 건 LMS 히스토리 조회 쿼리였습니다. 학생 한 명의 수업 전체 이력을 조회하면 수년치 누적 데이터가 나왔고, 인덱스 설계가 엉망이라 Full Scan이 발생했습니다.

그런데 이상한 점이 있었습니다. LMS 조회가 느린 건 이해가 가는데, 왜 실서비스까지 느려지냐는 것이었습니다. 수강신청이나 로그인이 LMS 이력 조회와 무슨 상관이 있을까요?

DB 서버를 확인해 보니 원인이 보였습니다.

text
[실서비스 DB 서버]
  ├─ 실서비스 테이블 (로그인, 컨텐츠, 수강)
  └─ LMS 히스토리 테이블 (10년치 학습 이력)  ← 같은 서버에 있음!

LMS와 실서비스가 같은 DB 서버를 공유하고 있었습니다.

LMS 히스토리 조회가 Full Scan을 돌리면서 DB 버퍼 풀(Buffer Pool)을 독점했습니다. MySQL의 버퍼 풀은 자주 쓰는 데이터를 메모리에 올려두는데, LMS Full Scan이 수십만 건의 이력 데이터를 메모리로 로드하면서 실서비스가 쓰던 캐시 데이터를 밀어냈습니다. 실서비스 쿼리들이 갑자기 캐시 미스가 나고 디스크 I/O가 급증해서 느려진 것이었습니다.


왜 캐싱이 안 됐나

처음 제안받은 해결책 중 하나가 "조회 결과를 Redis에 캐싱하면 되지 않냐"였습니다.

그런데 이 경우엔 캐싱이 효과가 없었습니다.

학생이 수업을 들으면 즉시 이력에 반영이 돼야 했습니다. 5분 전에 들은 수업이 이력 화면에 안 보이면 학생이나 부모가 이상하게 생각합니다. 이 요건 때문에 캐시 TTL을 짧게 가져가야 했고, 그러면 캐시 hit율이 극히 낮아서 효과가 없었습니다.

캐싱으로 해결할 수 있는 문제가 아니었습니다.


해결 방향: DB 분리 + ETL

근본 해결책은 LMS 분석 DB를 아예 분리하는 것이었습니다.

text
[변경 전]
실서비스 DB ─── 실서비스 테이블
           └─── LMS 히스토리 테이블

[변경 후]
실서비스 DB ─── 실서비스 테이블만
LMS 분석 DB ─── LMS 히스토리 테이블 (복제·가공)
                └─ 레플리카 1, 2, 3 (읽기 분산)

실서비스 DB에서 LMS 관련 부하를 완전히 제거하고, LMS 전용 DB에서 읽기 트래픽을 레플리카 3대로 분산했습니다.

Spring Batch로 ETL 파이프라인 구성

실서비스 DB에서 LMS 분석 DB로 데이터를 적재하는 ETL을 Spring Batch로 만들었습니다.

java
@Configuration
public class LmsEtlJobConfig {
 
    @Bean
    public Job lmsEtlJob(Step lmsHistoryStep) {
        return jobBuilderFactory.get("lmsEtlJob")
            .start(lmsHistoryStep)
            .build();
    }
 
    @Bean
    public Step lmsHistoryStep(
        ItemReader<LearningHistory> reader,
        ItemProcessor<LearningHistory, LmsAnalyticsRecord> processor,
        ItemWriter<LmsAnalyticsRecord> writer
    ) {
        return stepBuilderFactory.get("lmsHistoryStep")
            .<LearningHistory, LmsAnalyticsRecord>chunk(1000) // 1000건 단위
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .build();
    }
}
java
// 실서비스 DB에서 읽기
@Bean
public JdbcCursorItemReader<LearningHistory> lmsHistoryReader() {
    return new JdbcCursorItemReaderBuilder<LearningHistory>()
        .dataSource(productionDataSource) // 실서비스 DB
        .sql("SELECT * FROM learning_history WHERE updated_at > ?")
        .preparedStatementSetter(ps -> ps.setTimestamp(1, getLastSyncTime()))
        .rowMapper(new LearningHistoryRowMapper())
        .build();
}
 
// LMS 분석 DB에 쓰기
@Bean
public JdbcBatchItemWriter<LmsAnalyticsRecord> lmsAnalyticsWriter() {
    return new JdbcBatchItemWriterBuilder<LmsAnalyticsRecord>()
        .dataSource(analyticsDataSource) // LMS 분석 DB
        .sql("INSERT INTO lms_analytics (...) VALUES (...) ON DUPLICATE KEY UPDATE ...")
        .build();
}

실시간 반영 요건과 배치 주기 타협

"즉시 반영이 필요하다"고 했는데, 배치로 적재하면 딜레이가 생깁니다. 이 부분을 요건 담당자와 협의했습니다.

실제로 이력 화면의 사용 패턴을 보니, 학생이 수업을 끝낸 직후 바로 이력 화면을 확인하는 경우는 거의 없었습니다. 대부분 당일 수업이 다 끝난 뒤나 다음 날 확인했습니다.

5분 배치 주기로 타협했습니다. "수업 5분 후에 이력에 반영된다"는 기준을 공지하고, 실서비스 DB 부하 문제를 해소하는 게 우선이었습니다.


레플리카 읽기 분산

LMS 분석 DB에도 레플리카 3대를 붙여서 읽기 트래픽을 분산했습니다.

java
// 읽기는 레플리카로, 쓰기는 마스터로
@Transactional(readOnly = true)
public List<LmsAnalyticsRecord> getStudentHistory(Long studentId) {
    // readOnly = true 설정 시 레플리카로 라우팅
    return lmsAnalyticsRepository.findByStudentId(studentId);
}
 
@Transactional
public void updateLmsRecord(LmsAnalyticsRecord record) {
    // 쓰기는 마스터
    lmsAnalyticsRepository.save(record);
}

Spring의 @Transactional(readOnly = true)로 읽기 전용 트랜잭션을 표시하고, LazyConnectionDataSourceProxy와 함께 Read/Write 분리를 구성했습니다.


결과

항목변경 전변경 후
학습 이력 조회 응답20초 이상1초 이하
실서비스 DB 메모리LMS가 과점유LMS 부하 완전 제거
오전 피크타임 VOC하루 3건0건
레플리카없음3대 (읽기 분산)

실서비스 안정화 효과가 가장 컸습니다. LMS 이력 조회가 빨라진 것도 좋았지만, 전혀 관계없어 보이던 로그인과 수강신청이 갑자기 안정화된 게 눈에 띄었습니다. DB 공유 구조가 원인이었다는 걸 증명한 결과였습니다.


마치며

이 경험에서 가장 인상적이었던 건 문제의 원인이 예상치 못한 곳에 있었다는 점입니다.

로그인이 느리다는 VOC가 들어왔을 때 로그인 코드를 보는 게 자연스러운 반응입니다. 그런데 진짜 원인은 완전히 다른 기능이 같은 DB 서버 자원을 공유하면서 생긴 간섭이었습니다.

DB 서버 메트릭(버퍼 풀 사용량, 디스크 I/O, 슬로우 쿼리)을 함께 보지 않았다면 로그인 코드만 계속 뒤지고 있었을 겁니다. 장애 원인을 코드에서 찾기 전에 인프라 메트릭을 먼저 보는 습관이 생긴 계기였습니다.

#Spring Batch#ETL#DB분리#MySQL#성능개선#Backend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js