← all posts
DEV 2026.05.02 · 12 min read Intermediate

Spring Boot DevTools는 어떻게 개발 루프를 단축하는가

LiveReload WebSocket 통신부터 두 ClassLoader 분리 전략, Fat JAR 구조까지, DevTools가 개발 사이클을 최적화하는 설계 결정을 추적한다.


Spring Boot DevTools는 spring-boot-devtools 의존성 하나로 활성화된다. 그런데 이 작은 라이브러리 안에는 WebSocket 서버, 이중 ClassLoader, 환경 속성 주입이라는 세 가지 독립적인 메커니즘이 동시에 작동한다. 왜 이렇게 복잡한 구조가 필요한가?

개발 루프의 병목

전통적인 개발 사이클은 세 단계로 반복된다. 코드 수정 → 서버 재시작(5–30초) → 브라우저 새로고침(수동). DevTools는 이 중 마지막 두 단계를 각각 다른 방식으로 공격한다.

브라우저 새로고침은 LiveReload가 담당한다. DevTools는 애플리케이션과 함께 포트 35729에서 TCP 서버를 열고, 브라우저 확장 프로그램이 이 포트로 WebSocket을 연결한다. 파일이 변경되면 서버가 {"command":"reload","path":"*","liveCSS":true} 메시지를 연결된 모든 브라우저로 전송한다. 브라우저는 즉시 페이지를 새로고침한다. HTML이나 CSS만 바뀌었다면 서버 재시작 없이 이 단계만 실행된다.

서버 재시작은 Restart 메커니즘이 담당한다. 그런데 JVM 전체를 재시작하는 것이 아니다.

두 ClassLoader가 재시작을 빠르게 만드는 방법

DevTools Restart의 핵심 아이디어는 단순하다. 라이브러리는 거의 변경되지 않는다. 그렇다면 Spring, Hibernate, Jackson 같은 라이브러리 클래스는 재로딩하지 않아도 된다.

┌─────────────────────────────────────────────────┐
│                      JVM                        │
│                                                 │
│  Base ClassLoader  (라이브러리 — 재시작 시 유지)     │
│    spring-*.jar, hibernate-*.jar, jackson-*.jar  │
│                 ↑ 부모                           │
│  Restart ClassLoader (앱 코드 — 변경 시 교체)      │
│    com/example/...class, application.yml         │
└─────────────────────────────────────────────────┘

파일 변경이 감지되면 Restarter는 세 단계를 실행한다. ApplicationContext.close()로 현재 컨텍스트를 종료하고, 변경된 .class 파일을 포함한 새 RestartClassLoader를 생성한 후, SpringApplication.run()을 재실행한다. Base ClassLoader는 교체되지 않으므로 라이브러리 재로딩이 없다. 결과는 JVM 재시작 5–30초 대비 1–3초.

RestartClassLoader는 부모 위임 모델을 역전시킨다. 일반 ClassLoader가 부모에게 먼저 위임하는 것과 달리, RestartClassLoader는 자신이 먼저 클래스를 찾는다(child-first). 변경된 클래스가 Base ClassLoader의 캐시를 우회하게 하기 위해서다.

파일 감지는 폴링 방식이다. FileSystemWatcher가 1초 간격으로 감시 디렉토리의 스냅샷을 비교한다. 마지막 변경 후 400ms(quiet period) 동안 추가 변경이 없으면 이벤트를 발생시킨다. 최대 1.4초 지연이 있지만, IDE가 여러 .class 파일을 연속으로 생성할 때 불완전한 상태에서 재시작하는 것을 막는 장치다.

숨겨진 속성 주입

DevTools는 세 번째로, 아무 설정 없이도 개발에 최적화된 속성을 자동으로 주입한다. DevToolsPropertyDefaultsPostProcessorprepareEnvironment() 단계에서 MapPropertySource를 가장 낮은 우선순위(addLast)로 추가한다.

자동 적용되는 주요 기본값은 다음과 같다.

  • spring.thymeleaf.cache=false — 템플릿을 매 요청마다 디스크에서 읽음
  • spring.web.resources.cache.period=0 — 정적 리소스 HTTP 캐시 비활성화
  • spring.h2.console.enabled=true — H2 콘솔 자동 활성화
  • logging.level.web=DEBUG — HTTP 요청 상세 로깅

application.yml의 우선순위가 더 높으므로 필요한 값만 명시적으로 덮어쓸 수 있다. 그리고 DevTools가 developmentOnly(Gradle) 또는 <optional>true</optional>(Maven)로 선언되어 있어 bootJar 패키징 시 포함되지 않는다. 운영 배포 JAR에는 이 자동 설정이 존재하지 않는다.

Fat JAR와 bootRun의 ClassLoader 차이

./gradlew bootRun으로 실행하면 위의 Base/Restart ClassLoader 분리가 활성화된다. 반면 IDE에서 일반 run으로 실행하면 단일 AppClassLoader가 모든 클래스를 로딩한다. DevTools Restart의 동작이 달라질 수 있으므로, DevTools를 최대로 활용하려면 bootRun을 기본 실행 방식으로 지정하는 것이 좋다.

bootJar로 생성되는 Fat JAR의 구조도 이 ClassLoader 설계와 연결된다.

app.jar
├── BOOT-INF/
│   ├── classes/        ← 앱 코드 (Restart ClassLoader 대상)
│   └── lib/*.jar       ← 의존성 JAR (중첩 JAR)
└── org/springframework/boot/loader/
    └── JarLauncher.class

java -jar app.jar를 실행하면 MANIFEST.MFMain-ClassJarLauncher가 먼저 실행된다. Java 표준 ClassLoader는 JAR 안의 JAR를 직접 읽지 못하기 때문이다. JarLauncherLaunchedURLClassLoader를 만들어 BOOT-INF/lib/ 안의 중첩 JAR들을 처리한 후, Start-Class(실제 앱 main)를 호출한다.

트레이드오프

DevTools Restart — ClassLoader 교체로 1–3초 재시작을 달성하지만, ApplicationContext 전체를 재생성한다. DataSource 커넥션 풀, 스레드 풀, 인메모리 세션이 모두 초기화된다. JRebel은 Bean을 유지하며 더 빠른 반영을 제공하지만 유료다. DCEVM + HotSwapAgent는 무료이나 설정이 복잡하다.

폴링 방식 파일 감지 — OS 이벤트(inotify/FSEvents)가 아닌 주기적 스냅샷 비교를 사용해 OS/파일시스템에 독립적이다. 대신 최대 1.4초 지연과 파일 수가 많을 때 CPU 사용량 증가라는 비용이 있다.

Remote DevTools — HTTP 터널로 원격 서버에 클래스 파일을 푸시할 수 있다. 편리하지만 임의 클래스 업로드는 사실상 RCE 취약점과 동등하다. 스테이징 환경에서 일시적으로만 사용하고, 운영 환경에서는 절대 활성화해선 안 된다.

정리

  • DevTools는 세 독립 메커니즘의 조합이다. LiveReload(WebSocket 자동 새로고침), Restart(ClassLoader 교체로 빠른 재시작), PropertyDefaults(개발용 기본값 주입).
  • Restart가 빠른 이유는 JVM을 재시작하지 않아서가 아니라, Base ClassLoader를 재사용해 라이브러리 재로딩을 생략하기 때문이다.
  • DevTools의 모든 기능은 developmentOnly 의존성 선언으로 운영 JAR에서 자동 제외된다.
  • bootRun과 IDE run은 ClassLoader 구조가 다르다. DevTools Restart를 온전히 사용하려면 bootRun을 써야 한다.

다음 글에서는 ApplicationContext 계층 구조와 Bean 라이프사이클 훅이 재시작 과정에서 어떤 순서로 호출되는지 추적한다.