[TIL] Class 핫리로드와 톰캣의 오해 - 톰캣이 올라오고 class 파일을 교체해도 적용될까?

2026. 6. 16. 00:12·[TIL]

.class 핫 리로드 오해 트러블슈팅 — 톰캣이 아니라 별도 JVM이었습니다

1. 개요 (Overview)

이슈명

운영 서버에서 .class 파일만 교체했는데 재시작 없이 반영되는 세팅 원인 추적

문제 정의

운영 중 배치 서버에서 .class 파일을 교체한 뒤, 톰캣을 재시작하지 않았는데도 배치 작업에서 변경 사항이 반영되는 현상이 관찰되었습니다. 처음에는 "톰캣이 핫 리로드를 지원하는 것인가?" 라고 추정했지만, 실제로 내부 프로젝트 설정에는 핫 리로드 관련 옵션이 활성화되어 있지 않았습니다. 이 문서는 왜 반영된 것처럼 보였는지를 추적하고, JVM 클래스 로딩 메커니즘에 대한 오해를 바로잡은 기록입니다.


2. 문제 재현 및 로그 (Reproduction & Logs)

재현 방법

  1. Tomcat이 구동 중인 서버에서 WEB-INF/classes/ 아래의 특정 .class 파일을 새 버전으로 교체합니다.
  2. 톰캣을 재시작하지 않습니다.
  3. cron으로 등록된 배치 작업이 실행될 때까지 기다립니다.
  4. 배치 실행 결과를 확인하면, 변경된 코드가 반영되어 있습니다.

수정 전 코드 (문제가 된 코드)

문제 코드라기보다는, 오해를 유발한 배치 실행 스크립트 구조입니다.

# /home/app/scripts/run-daily-report.sh
/usr/local/java/bin/java \
    -cp /home/app/public_html/WEB-INF/classes:/home/app/public_html/WEB-INF/lib/*:/usr/local/tomcat/lib/* \
    com.example.batch.DailyReportJob \
    > /home/app/logs/cron/DailyReportJob.log 2>&1

이 스크립트는 cron에 등록되어 있으며, java -cp ... MainClass 형태로 톰캣과는 독립된 JVM 프로세스를 매번 새로 기동합니다.

에러 로그

에러가 발생한 것은 아닙니다. 오히려 정상 동작처럼 보였기 때문에 문제였습니다. 톰캣 로그에서 reload 관련 흔적을 검색하면 다음과 같이 아무것도 나오지 않습니다.

$ grep -iE "reloading|reload.*context" /usr/local/tomcat/logs/catalina.out
# (결과 없음)

기대 결과

.class 파일을 교체한 뒤 톰캣을 재시작하지 않았다면, 톰캣 내부에서 실행되는 코드는 변경 전 버전 그대로 동작해야 합니다. 그런데 "반영이 잘 되었다"는 보고가 들어오면서 혼란이 시작되었습니다.


3. 원인 분석 (Root Cause Analysis)

3-1. 배경 지식 — JVM의 클래스 로딩 원리

JVM은 .class 파일을 프로세스 시작 시점 또는 해당 클래스가 처음 사용되는 시점에 메모리로 로드합니다. 한 번 로드된 클래스는 JVM의 Metaspace(Java 8 기준)에 Class 객체로 적재되며, 이후 모든 코드는 이 메모리 상의 객체를 참조합니다.

프로세스 시작
    ↓
[ClassLoader가 .class 파일을 디스크에서 읽음]
    ↓
[JVM 메모리 (Metaspace) 에 Class 객체로 적재]
    ↓
이후 모든 코드는 "메모리에 적재된 Class"를 참조
    ↓
디스크의 .class 파일이 바뀌어도 → JVM은 모름

핵심은 디스크 파일 ≠ JVM 메모리라는 점입니다. 한 번 로드되면 JVM은 디스크를 다시 확인하지 않습니다. 따라서 .class 파일만 교체한다고 실행 중인 JVM의 동작이 바뀌지 않는 것이 정상입니다.

3-2. 톰캣의 클래스 로딩 구조

톰캣은 WEB-INF/classes/ 아래의 .class 파일들을 WebappClassLoader 라는 전용 클래스로더로 로드합니다.

톰캣 JVM 프로세스
├── 톰캣 본체 (Catalina, Coyote …)
└── WebappClassLoader  ← webapp 전용
        ├── WEB-INF/classes/*.class
        └── WEB-INF/lib/*.jar

 

이 WebappClassLoader도 결국 JVM의 일반 ClassLoader이므로, 한 번 로드한 클래스는 메모리에 머무릅니다. 톰캣이 실행 중인 상태에서 디스크의 .class를 교체해도 webapp의 동작은 바뀌지 않는 것이 기본입니다.

3-3. 톰캣의 핫 리로드 옵션 두 가지

톰캣은 위 제약을 우회할 수 있는 두 가지 메커니즘을 제공합니다.

1. reloadable="true" (자동 감시)

<Context reloadable="true">

톰캣이 백그라운드 스레드로 WEB-INF/classes와 WEB-INF/lib 전체를 주기적으로 스캔합니다. 변경이 감지되면 webapp 컨텍스트 전체를 reload합니다. 즉, 기존 WebappClassLoader를 폐기하고 새로 생성한 뒤 클래스를 다시 로드하는 방식입니다. 운영 환경에서는 성능 저하와 메모리 누수(PermGen/Metaspace leak) 위험 때문에 거의 사용하지 않습니다.

 

2. <WatchedResource> (특정 파일 감시)

<WatchedResource>WEB-INF/web.xml</WatchedResource>

지정된 특정 파일의 수정 시각(mtime)만 감시하다가, 변경되면 컨텍스트를 reload합니다. .class 자체를 감시하는 것은 아니지만, reload가 발생하는 시점에 디스크의 최신 .class가 함께 로딩됩니다.

3-4. 실제 운영 환경의 설정 확인

해당 서버의 context.xml을 확인하면 다음과 같았습니다.

<Context>                                <!-- reloadable 속성 없음 -->
    <ResourceLink name="appdb" ... />
    ...
    <WatchedResource>WEB-INF/web.xml</WatchedResource>
    ...
</Context>
항목 값
reloadable 속성 없음 (기본값 false)
WatchedResource WEB-INF/web.xml 하나만

 

톰캣은 .class 변경을 자동으로 감지하지 않습니다. web.xml이 변경될 때만 컨텍스트를 reload합니다. 배포 스크립트에도 touch web.xml 같은 명령은 존재하지 않았습니다.

3-5. 핵심 원인(오해) — 코드는 톰캣 안에서 돌고 있지 않았다 (✅핵심)

reloadable=true도 없고, WatchedResource에 .class가 등록된 것도 아닌데 어떻게 반영이 되었을까요?

답은 "해당 코드가 톰캣 안에서 실행되고 있지 않았다" 는 것이었습니다.

cron에 등록된 배치 스크립트를 다시 확인합니다.

/usr/local/java/bin/java \
    -cp /home/app/public_html/WEB-INF/classes:... \
    com.example.batch.DailyReportJob

이 명령은 java -cp ... 메인클래스 형태입니다. 톰캣을 통해 호출하는 것이 아니라, 완전히 새로운 독립 JVM 프로세스를 기동하는 것입니다.

크론 시각 (예: 01:50)
    ↓
신규 JVM 프로세스 fork (PID 새로 할당)
    ↓
classpath(WEB-INF/classes)에서 .class 파일을 디스크로부터 새로 읽음  ★
    ↓
DailyReportJob.main() 실행
    ↓
JVM 종료 (프로세스 소멸)

★ 표시가 핵심입니다. 매 실행마다 완전히 새로운 JVM이므로, 디스크의 최신 .class를 자연스럽게 로드합니다. 핫 리로드가 아니라 그냥 새 프로세스 시작인 것입니다.

3-6. 두 개의 JVM이 같은 디렉터리를 공유하는 구조

가장 혼동을 유발하는 부분입니다. 같은 WEB-INF/classes를 두 개의 서로 다른 JVM이 공유합니다.

디스크
└── /home/app/public_html/WEB-INF/classes/
        ├── com/example/servlet/*.class
        ├── com/example/batch/DailyReportJob.class
        └── ...
              ↑                          ↑
              │                          │
    ┌─────────┘                          └────────────┐
    │                                                  │
[톰캣 JVM 프로세스]                       [크론 배치 JVM 프로세스]
- 항상 떠 있음                            - 크론 시각마다 새로 떴다가 꺼짐
- 시작 시 .class 로드 → 메모리 보관       - 매 실행 시 .class 새로 로드
- 디스크 변경 모름                        - 항상 디스크의 최신 버전 사용
- /api/* 등 HTTP 요청 처리               - main()만 실행하고 종료

같은 파일을 참조하지만, 읽는 시점이 완전히 다릅니다.

3-7. .class 교체 후 실제 동작 정리

.class 파일을 WEB-INF/classes/ 아래에 덮어쓴 직후의 상황을 정리하면 다음과 같습니다.

시점/주체 동작
디스크 새 .class로 교체 완료
톰캣 JVM 기존 클래스를 메모리에 보관 중 → 반영 ❌
다음 크론 발화 (예: 01:50) 새 JVM 시작 → 디스크에서 새 .class 로딩 → 반영 ✅
그다음 크론 발화 (예: 02:30) 또 다른 새 JVM → 역시 새 .class 로딩 → 반영 ✅

"톰캣이 핫 리로드를 한 것이 아니라, 톰캣과 무관한 배치들이 매번 신규 JVM으로 실행되어 자연스럽게 새 코드로 동작한 것" 입니다.

만약 톰캣 내부 서블릿이나 API 코드를 변경한 경우라면, 톰캣을 재기동하지 않는 한 절대 반영되지 않습니다.


4. 해결 과정 (Troubleshooting Steps)

4-1. 시도해 볼 수 있는 접근들과 한계

".class만 교체하면 반영된다"는 전제가 참이라고 믿었을 때 흔히 떠올리는 추론과 그 한계입니다.

추론 검증 결과
reloadable="true"가 설정되어 있을 것이다 context.xml 확인 해당 속성 없음 (기본값 false)
배포 스크립트에서 touch web.xml을 실행할 것이다 스크립트 전문 grep touch 명령 없음
톰캣이 자체적으로 .class 변경을 감지할 것이다 catalina.out에서 reload 로그 검색 reload 흔적 없음
어딘가에 커스텀 ClassLoader가 있을 것이다 소스 코드 전수 조사 커스텀 ClassLoader 없음

 

이 모든 추론이 실패한 이유는 전제 자체가 틀렸기 때문입니다. "톰캣 안에서 실행되는 코드가 반영된 것"이 아니라, "톰캣 밖에서 독립 JVM으로 실행되는 배치가 반영된 것"이었습니다.

4-2. 최종 해결책 — 실행 구조 파악

이 사례에서 "해결"이란 코드 수정이 아니라 실행 구조에 대한 정확한 이해입니다.

검증 방법 1: 톰캣 PID 변화 확인

$ ps -ef | grep tomcat | grep -v grep
root  1234  ...  /usr/local/java/bin/java ... org.apache.catalina.startup.Bootstrap start

.class 교체 전후로 톰캣 PID가 동일하다면, 톰캣은 재시작되지 않은 것입니다.

검증 방법 2: 배치 프로세스 확인

# 크론 발화 직후 실행
$ ps -ef | grep DailyReportJob | grep -v grep
app  5678  ...  /usr/local/java/bin/java -cp ... com.example.batch.DailyReportJob

실행 중일 때만 잡히고, 완료 후에는 사라집니다. 매번 새로운 PID가 할당됩니다.

검증 방법 3: JVM 클래스 로딩 시점 직접 확인

$ /usr/local/java/bin/java -verbose:class -cp ... com.example.batch.DailyReportJob 2>&1 | head -30

일반적으로 다음과 같은 출력을 확인할 수 있습니다.

[Loaded com.example.batch.DailyReportJob from file:/home/app/public_html/WEB-INF/classes/...]

디스크에서 직접 읽는 것이 명시적으로 보입니다.

검증 방법 4: 톰캣 reload 로그 부재 확인

$ grep -iE "reloading|reload.*context" /usr/local/tomcat/logs/catalina.out
# (결과 없음)

.class 교체 시점에 reload 로그가 없다면, 톰캣은 아무런 반응도 하지 않은 것입니다.

4-3. 수정 후 기대되는 효과

코드 수정 사례는 아니지만, 이 분석을 통해 확립된 배포 기준은 다음과 같습니다.

변경 대상 반영 방법
크론 배치용 클래스 .class 교체만 하면 됩니다 (다음 크론 발화 시 신규 JVM이 자동으로 반영)
톰캣 서블릿/API 코드 톰캣 재기동 또는 web.xml touch (WatchedResource 트리거) 필요
properties 값 변경 전용 reload API 호출 또는 톰캣 재기동
standalone JAR 앱 shutdown.sh → 파일 교체 → startup.sh (프로세스 재시작)

5. 학습 및 회고 (Lesson Learned & Next Steps)

원인 한 줄 요약

톰캣이 .class 변경을 자동 반영한 것이 아니라, 같은 WEB-INF/classes를 classpath로 빌려쓰는 독립 JVM 프로세스(크론 배치)가 매번 새로 기동되면서 자연스럽게 최신 .class를 로드한 것입니다.

개선 사항

  1. *실행 구조를 문서화합니다. *
    • 어떤 코드가 톰캣 내부에서 실행되고, 어떤 코드가 독립 JVM으로 실행되는지를 운영 문서에 명확히 기록합니다. 동일 디렉터리를 공유하는 구조는 특히 혼동을 유발하기 쉽습니다.
  2. 배치 전용 classpath 분리를 검토합니다.
    • 톰캣의 WEB-INF/classes를 배치 JVM이 직접 참조하는 구조는, 톰캣 배포와 배치 배포의 결합도를 높입니다. 배치 전용 디렉터리를 별도로 두거나, 빌드 시 별도의 fat JAR를 생성하는 방식을 고려할 수 있습니다.
  3. "핫 리로드" 관련 개념을 정확히 구분합니다.
    • 클래스 리로드: WebappClassLoader를 교체하여 클래스 정의 자체를 다시 로드하는 것 (톰캣의 reloadable="true")
    • 설정 리로드: 이미 로드된 클래스가 들고 있는 정적 데이터(properties 등)를 다시 읽는 것 (reload API)
    • 신규 JVM 기동: 아예 새 프로세스를 띄워 디스크에서 처음부터 로드하는 것 (크론 배치)
    • 이 세 가지는 본질적으로 다른 메커니즘이며, 혼용하면 잘못된 배포 판단으로 이어질 수 있습니다.
  4. 배포 후 검증 절차를 수립합니다.
    • .class 교체 후 "반영 되었는가"를 확인할 때, 어떤 JVM에서 확인하는지를 반드시 구분합니다. 배치 로그에서 확인했다면 톰캣 반영과는 무관합니다.

마치며

이번 사례의 본질은 "톰캣의 동작을 잘못 이해한 것"이 아니라, "어떤 코드가 어떤 프로세스에서 실행되는지를 정확히 파악하지 못한 것" 입니다.

 

같은 디렉터리를 공유하는 두 개의 JVM이 존재할 때, .class 교체의 영향 범위는 JVM별로 완전히 다릅니다.

 

JVM의 클래스 로딩은 "한 번 로드하면 끝"이라는 단순한 원리입니다. 이 원리만 정확히 알고 있어도 "왜 반영이 되는가/안 되는가"를 실행 구조에서 바로 추론할 수 있습니다. 인프라와 코드의 관계를 정확히 이해하는 것이, 운영 환경에서의 가장 확실한 트러블슈팅 도구라고 생각합니다.

'[TIL]' 카테고리의 다른 글

[TIL] EHcache 캐시 오염 트러블슈팅 - 캐시 객체  (0) 2026.06.27
[TIL] HTTP 커넥션 풀 고갈 트러블슈팅 - EntityUtils.consume()  (0) 2026.05.23
트러블슈팅 - OpenFeign과 @Configuration: 빈 등록의 함정과 FeignContext의 이해  (0) 2025.10.17
TIL - CDC로 향하는 가는 첫 번째 과정(1) : OracleDB 트랜잭션 로그(Redo log)를 읽어서 Kafka에 적재하기  (0) 2025.01.10
[ TIL ] RECOVER_YOUR_DATA : RDS 해킹 일지  (1) 2024.11.13
'[TIL]' 카테고리의 다른 글
  • [TIL] EHcache 캐시 오염 트러블슈팅 - 캐시 객체
  • [TIL] HTTP 커넥션 풀 고갈 트러블슈팅 - EntityUtils.consume()
  • 트러블슈팅 - OpenFeign과 @Configuration: 빈 등록의 함정과 FeignContext의 이해
  • TIL - CDC로 향하는 가는 첫 번째 과정(1) : OracleDB 트랜잭션 로그(Redo log)를 읽어서 Kafka에 적재하기
7.06com
7.06com
우당탕탕 코딩하기
  • 7.06com
    우당탕탕 개발자의 이야기
    7.06com
  • 전체
    오늘
    어제
    • 분류 전체보기 (67)
      • [Spring] (7)
      • [JAVA] (3)
      • [디자인패턴] (1)
      • [TIL] (11)
      • [CI,CD] (5)
      • [협업] (1)
      • [Database] (5)
      • [CS] (3)
      • [코딩테스트] (15)
      • [알고리즘] (0)
      • [후기-회고] (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
7.06com
[TIL] Class 핫리로드와 톰캣의 오해 - 톰캣이 올라오고 class 파일을 교체해도 적용될까?
상단으로

티스토리툴바