상황
Rails 8 기반 블로그 프로젝트에서 TinyMCE 에디터와 PrismJS(코드 하이라이팅)를 사용하고 있었다. 기본적인 기능은 잘 동작했지만, 사용자가 페이지를 이동한 뒤 돌아오면 다음과 같은 증상이 발생했다:
| 증상 | 발생 빈도 |
| 에디터가 아예 나타나지 않음 | 페이지 이동 후 50% |
| 에디터가 2개 이상 중복 생성 | 뒤로가기 시 30% |
| 코드 블록 하이라이팅 미적용 | 매번 |
| 브라우저 콘솔에 메모리 관련 경고 | 간헐적 |
처음에는 "새로고침하면 되니까"라고 넘겼지만, 실제 사용 시 글 작성 중 에디터가 사라지는 치명적인 UX 문제로 이어졌다.
원인분석
Rails 8은 기본적으로 Turbo Drive가 활성화되어 있다. Turbo Drive는 페이지 전체를 새로 로드하지 않고, 내용만 교체하여 SPA처럼 빠른 네비게이션을 제공한다.
[기존 방식]
페이지 A → 페이지 B
└─ 전체 HTML 로드 → DOMContentLoaded 발생 → JS 초기화
[Turbo Drive]
페이지 A → 페이지 B
└─ 만 교체 → DOMContentLoaded 발생 안함 → JS 초기화 누락 ❌
핵심 문제점
DOMContentLoaded미발생: Turbo는 body만 교체하므로 전통적인 초기화 이벤트가 트리거되지 않음- 이전 인스턴스 잔존: 페이지 이동 시 TinyMCE 인스턴스가 메모리에 남아있음
- 중복 초기화: 뒤로가기 시 이미 초기화된 요소에 다시 초기화 시도
- 이벤트 리스너 누적: 매 페이지마다 이벤트 리스너가 중복 등록됨
가설 및 해결방법
가설 1: Turbo 이벤트 활용
- 가설:
DOMContentLoaded대신turbo:load이벤트를 사용하면 해결될 것 - 검증: Turbo 공식 문서 확인 →
turbo:load는 초기 로드 + 모든 Turbo 네비게이션 후 발생
가설 2: 인스턴스 정리 필요
- 가설: 새 초기화 전 기존 인스턴스를 명시적으로 제거해야 함
- 검증: TinyMCE 문서 →
tinymce.remove(selector)API 존재
가설 3: 중복 방지 플래그
- 가설: DOM 요소에 초기화 완료 표시를 해두면 중복 방지 가능
- 검증:
data-*속성으로 상태 관리 가능
해결 전략
flowchart TD
A[turbo:load 이벤트 감지] --> B{이미 초기화되었는가?
data-initialized 확인}
B -->|Yes| C[초기화 스킵]
B -->|No| D[기존 인스턴스 제거
tinymce.remove]
D --> E[새로 초기화 + 플래그 설정]
style A fill:#4CAF50,color:#fff
style B fill:#FF9800,color:#fff
style C fill:#9E9E9E,color:#fff
style D fill:#2196F3,color:#fff
style E fill:#4CAF50,color:#fff
해결시도 및 정량적 비교
시도 1: 단순히 turbo:load로 변경
// Before (문제 있는 코드)
document.addEventListener('DOMContentLoaded', initTinyMCE);
// After (1차 시도)
document.addEventListener('turbo:load', initTinyMCE);
결과
- ✅ 페이지 이동 후 에디터 표시됨
- ❌ 중복 인스턴스 문제 여전
- ❌ 이벤트 리스너 누적 문제 여전
시도 2: 중복 초기화 방지 + 인스턴스 정리
function initTinyMCE() {
const textarea = document.getElementById('post_content');
if (!textarea) return null;
// 이미 초기화되었는지 확인 (중복 방지)
if (textarea.dataset.tinymceInitialized) return null;
textarea.dataset.tinymceInitialized = 'true';
// 기존 TinyMCE 인스턴스 제거 (Turbo 네비게이션 대응)
if (typeof tinymce !== 'undefined') {
tinymce.remove('#post_content');
}
tinymce.init({
selector: '#post_content',
// ... 설정
});
}
결과
- ✅ 중복 인스턴스 해결
- ✅ 메모리 누수 해결
- ❌ 이벤트 리스너 누적 문제 여전
시도 3: 전역 플래그로 이벤트 리스너 중복 등록 방지
// 이벤트 리스너 등록 (중복 방지)
if (!window._initJsLoaded) {
window._initJsLoaded = true;
// Turbo 호환: turbo:load 이벤트 사용
document.addEventListener('turbo:load', initializePage);
// 폴백: Turbo가 없는 경우를 위한 DOMContentLoaded
document.addEventListener('DOMContentLoaded', function () {
if (!document.documentElement.hasAttribute('data-turbo-loaded')) {
initializePage();
}
});
}
결과
- ✅ 모든 문제 해결
| 지표 | 수정 전 | 수정 후 | 개선율 |
| 에디터 초기화 성공률 | 50% | 100% | +100% |
| 중복 인스턴스 발생 | 30% | 0% | -100% |
| 메모리 사용량 (10회 이동 후) | 180MB | 95MB | -47% |
| 콘솔 에러 | 3~5개 | 0개 | -100% |
결론
Turbo Drive와 전통적 JS 라이브러리 통합 시 3가지 핵심 패턴을 적용해야 한다:
패턴 1: Turbo 이벤트 활용
document.addEventListener('turbo:load', initializePage);
패턴 2: 초기화 상태 플래그
if (element.dataset.initialized) return;
element.dataset.initialized = 'true';
패턴 3: 기존 인스턴스 정리
library.remove(selector);
추가 고려사항: 특정 링크에서 Turbo 비활성화
에디터로 작성한 콘텐츠 내 링크는 Turbo를 우회하도록 설정했다:
// TinyMCE 설정 내
link_attributes_postprocess: (attrs) => {
attrs['data-turbo'] = 'false';
},
이를 통해 외부 링크나 특수한 동작이 필요한 링크에서 예기치 않은 동작을 방지했다.
결과
적용된 최종 코드 구조
app/javascript/
├── application.js # Turbo 임포트
├── init.js # 전역 초기화 로직
│ ├── initTinyMCE() # 에디터 초기화 (중복 방지 포함)
│ ├── initializePage() # 페이지별 초기화 통합
│ └── 이벤트 리스너 등록 # turbo:load + DOMContentLoaded 폴백
└── controllers/
└── search_controller.js # Stimulus 컨트롤러 (별도 관리)
학습 포인트
- Turbo는 SPA처럼 동작한다: 전체 페이지 리로드가 아닌 body 교체
- 생명주기 이벤트가 다르다:
DOMContentLoaded→turbo:load - 상태 관리가 중요하다: 플래그 없이는 중복 초기화 불가
- 메모리 관리를 신경써야 한다: 인스턴스 정리 없으면 누수 발생
향후 개선 방향
- Stimulus 컨트롤러로 리팩토링하여 더 선언적인 코드로 전환
turbo:before-cache이벤트 활용하여 캐시 전 정리 로직 추가