EROKE ORIGINAL WEBBOOK

WAR 배포, 밤에 깨지 않게

WAR 배포, 밤에 깨지 않게 웹북 표지
전체 목차32개 장
  1. WAR 배포, 밤에 깨지 않게: 1장 WAR는 ZIP 파일보다 큰 계약이다
  2. WAR 배포, 밤에 깨지 않게: 2장 Tomcat과 Jakarta 호환성을 먼저 잠근다
  3. WAR 배포, 밤에 깨지 않게: 3장 Maven으로 테스트를 통과한 WAR를 만든다
  4. WAR 배포, 밤에 깨지 않게: 4장 Gradle과 Spring Boot WAR의 차이를 이해한다
  5. WAR 배포, 밤에 깨지 않게: 5장 환경 설정과 비밀을 WAR 밖으로 꺼낸다
  6. WAR 배포, 밤에 깨지 않게: 6장 아티팩트 승격 계약을 만든다
  7. WAR 배포, 밤에 깨지 않게: 7장 CATALINA_HOME과 CATALINA_BASE를 분리한다
  8. WAR 배포, 밤에 깨지 않게: 8장 systemd가 프로세스의 정본이 되게 한다
  9. WAR 배포, 밤에 깨지 않게: 9장 webapps 복사는 가장 단순하지만 원자적으로 한다
  10. WAR 배포, 밤에 깨지 않게: 10장 Tomcat Manager 자동 배포를 좁게 연다
  11. WAR 배포, 밤에 깨지 않게: 11장 병렬 버전과 reverse proxy로 중단 시간을 줄인다
  12. WAR 배포, 밤에 깨지 않게: 12장 health·smoke·지표가 모두 통과해야 승격한다
  13. WAR 배포, 밤에 깨지 않게: 13장 TLS 종료 지점을 먼저 찾는다
  14. WAR 배포, 밤에 깨지 않게: 14장 인증서·개인키·체인을 구분한다
  15. WAR 배포, 밤에 깨지 않게: 15장 교체 전에 다섯 가지를 검증한다
  16. WAR 배포, 밤에 깨지 않게: 16장 Nginx에서 인증서를 교체한다
  17. WAR 배포, 밤에 깨지 않게: 17장 Tomcat이 TLS를 직접 종료할 때 교체한다
  18. WAR 배포, 밤에 깨지 않게: 18장 Certbot 갱신을 교체 절차와 연결한다
  19. WAR 배포, 밤에 깨지 않게: 19장 WAR 배포 롤백은 파일 하나의 문제가 아니다
  20. WAR 배포, 밤에 깨지 않게: 20장 인증서 rollback은 만료 전 인증서로만 한다
  21. WAR 배포, 밤에 깨지 않게: 21장 배포 장애 10가지를 증상에서 진단한다
  22. WAR 배포, 밤에 깨지 않게: 22장 로그와 타임라인으로 원인을 좁힌다
  23. WAR 배포, 밤에 깨지 않게: 23장 CI/CD pipeline에 중단 조건을 넣는다
  24. WAR 배포, 밤에 깨지 않게: 24장 최종 실전: 금요일 WAR와 인증서를 함께 교체한다
  25. WAR 배포, 밤에 깨지 않게: 25장 서버 한 대를 애플리케이션 지도로 바꾼다
  26. WAR 배포, 밤에 깨지 않게: 26장 Tomcat·JDK·javax·jakarta 경계를 업그레이드 행렬로 잠근다
  27. WAR 배포, 밤에 깨지 않게: 27장 JNDI·JDBC·classloader를 WAR 밖 계약으로 만든다
  28. WAR 배포, 밤에 깨지 않게: 28장 세션과 상태가 롤링 배포의 진짜 경계다
  29. WAR 배포, 밤에 깨지 않게: 29장 공유 파일·SFTP·배치·cron의 숨은 쓰기를 찾는다
  30. WAR 배포, 밤에 깨지 않게: 30장 reverse proxy·context path·forwarded header를 한 흐름으로 검증한다
  31. WAR 배포, 밤에 깨지 않게: 31장 다중 인스턴스·재해복구를 배포 절차와 연결한다
  32. WAR 배포, 밤에 깨지 않게: 32장 WAR를 유지할 것과 현대화할 것을 증거로 나눈다

빌드부터 Tomcat·JNDI·인증서·다중 인스턴스 운영과 롤백까지

Maven·Gradle WAR 빌드부터 Tomcat, JNDI, 세션, 배치, Nginx·TLS와 재해복구까지 ReleasePortal로 익힌다.

상태: 출간 후보 웹교정쇄 · 운영자 검수 대기 · 32개 장

웹북 초판 기준일 2026년 8월 2일 · 공통 실습 ReleasePortal · fixture FIXTURE-2026-08

Nginx에서 TLS를 종료하고 Tomcat의 ReleasePortal WAR로 전달하는 운영 구조
Nginx에서 TLS를 종료하고 Tomcat의 ReleasePortal WAR로 전달하는 운영 구조

개발 PC에서 BUILD SUCCESS가 보이는 순간과 사용자가 새 기능을 안전하게 쓰는 순간 사이에는 긴 운영 경로가 있다. 같은 소스를 빌드했는지, WAR 안에 필요한 파일이 있는지, 서버 JVM과 호환되는지, 환경 설정과 비밀이 분리됐는지, health가 실제 기능을 증명하는지, 실패하면 어느 버전으로 돌아갈지가 모두 맞아야 한다.

인증서 교체도 파일 두 개를 덮는 일이 아니다. 새 인증서의 도메인, 유효기간, 개인키, 중간 인증서 체인을 검증하고 설정 문법을 확인한 다음 reload해야 한다. 마지막에는 서버 안 파일이 아니라 외부 클라이언트가 받은 serial과 만료일을 확인해야 한다.

이 책은 가상의 사내 릴리스 조회 서비스 ReleasePortal을 처음부터 운영한다. 로컬에서는 정적이지만 표준 구조를 가진 실제 WAR를 만들고 SHA-256을 기록한다. 운영에서는 일반적인 Linux, Tomcat, Nginx 구조를 사용한다. Spring Boot와 Maven·Gradle 변형도 별도 장에서 다룬다.

운영 명령은 조직의 변경 승인, 백업, 접근통제, 개인정보 정책을 대신하지 않는다. <HOST>, <VERSION> 같은 꺾쇠 값은 그대로 입력하지 말고 자신의 값으로 바꾼다. 실제 서버가 아니라 책의 build/server-sandbox에서 먼저 연습한다.

공통 실습에는 Node.js 20 이상과 JDK의 jar 명령이 필요하다. 인증서 장은 OpenSSL을 사용한다. 운영 예시는 Linux와 systemd를 기준으로 하며, Windows 독자는 WSL 또는 별도 Linux 실습 VM에서 서버 명령을 연습한다. 체크섬 명령만 운영체제별로 다르다.

환경 SHA-256 명령
Linux sha256sum build/releaseportal.war
macOS shasum -a 256 build/releaseportal.war
Windows PowerShell Get-FileHash build/releaseportal.war -Algorithm SHA256

명령이 설치됐는지 node --version, jar --version, openssl version으로 먼저 확인한다. 책의 테스트·빌드는 macOS와 Linux에서 같은 Node 스크립트를 사용하지만, 실제 Tomcat·Nginx·systemd 운영 절차는 Linux 서버에서 검증한다.

1장 WAR는 ZIP 파일보다 큰 계약이다

WAR(Web Application Archive)는 Servlet 컨테이너가 읽는 웹 애플리케이션 묶음이다. 압축 형식만 보면 ZIP과 비슷하지만 경로에 의미가 있다.


releaseportal.war
├── index.html
├── health.json
├── META-INF/
│   └── build-info.json
└── WEB-INF/
    ├── web.xml
    ├── classes/        # 컴파일된 애플리케이션 클래스
    └── lib/            # 애플리케이션이 함께 배포하는 JAR

WEB-INF 아래 파일은 브라우저가 직접 읽는 공개 자원이 아니다. classeslib는 웹 애플리케이션 classloader가 사용한다. 컨테이너가 제공하는 Servlet API를 WAR에 중복 포함하면 classloader 충돌이 생길 수 있다.

책 루트에서 첫 WAR를 만든다.


cd war-deployment-operations
npm run build:war
jar --list --file build/releaseportal.war
# Linux
sha256sum build/releaseportal.war
# macOS에서는: shasum -a 256 build/releaseportal.war

목록에 WEB-INF/web.xml, health.json, META-INF/build-info.json이 있어야 한다. 체크섬은 빌드마다 다를 수도 있다. JAR 도구가 파일 시각을 기록하기 때문이다. 조직에서 완전한 재현 빌드를 요구한다면 빌드 도구의 reproducible archive 설정과 SOURCE_DATE_EPOCH 지원을 별도로 고정한다. 이 실습의 핵심은 “보낸 파일”과 “배포한 파일”의 체크섬이 같음을 증명하는 것이다.

↑ 목차로 돌아가기

2장 Tomcat과 Jakarta 호환성을 먼저 잠근다

배포 장애의 상당수는 소스가 아니라 세대 차이다. Tomcat 10 이상은 Jakarta EE 네임스페이스인 jakarta.*를 사용한다. 오래된 애플리케이션이 javax.servlet.*로 컴파일됐다면 단순히 WAR를 Tomcat 11에 복사해 해결되지 않는다. 소스를 마이그레이션하거나 호환되는 컨테이너 계열을 유지해야 한다.

2026년 8월 기준 Tomcat 11.0 문서는 Servlet 6.1 계열과 Java 17 이상을 전제로 한다. 운영에서는 “최신”이라는 말 대신 다음 호환성 표를 저장소에 둔다.

경계 프로젝트가 고정할 값 확인 명령
빌드 JDK 예: 21 ./mvnw -version
bytecode target 예: 17 또는 21 빌드 설정과 javap -verbose
Servlet namespace jakarta 또는 javax import와 의존성 트리
Tomcat 계열 예: 11.0.x $CATALINA_HOME/bin/version.sh
운영 JVM 검증된 공급자·패치 java -version

개발 PC의 java -version만 보면 부족하다. Maven이나 IDE가 다른 JDK를 사용할 수 있다. 빌드 로그에 Maven JVM, compiler release, Git commit을 남긴다. UnsupportedClassVersionError가 나면 서버 JVM을 무작정 올리기 전에 조직 지원 정책과 애플리케이션의 target을 비교한다.

↑ 목차로 돌아가기

3장 Maven으로 테스트를 통과한 WAR를 만든다

Maven 프로젝트는 <packaging>war</packaging>으로 패키징을 선언하고 package 단계에서 WAR Plugin을 실행한다. 2026년 8월 공식 Maven 문서의 안정 버전 예시는 3.5.1이다. 버전은 부모 POM 또는 plugin management에서 고정한다.


<packaging>war</packaging>
<build>
  <finalName>releaseportal</finalName>
  <pluginManagement>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
    </plugins>
  </pluginManagement>
</build>

정상 빌드 명령은 war:war만 직접 부르는 것이 아니라 전체 lifecycle을 거친다.


./mvnw --batch-mode clean verify
./mvnw --batch-mode package
jar --list --file target/releaseportal.war

보통 package도 앞 단계 테스트를 실행하므로 두 명령을 항상 연달아 실행할 필요는 없다. 팀이 integration-test와 verify 검사를 별도 profile에 묶었다면 CI 명령을 한 줄로 문서화한다. -DskipTests는 운영 배포 pipeline의 기본값이 될 수 없다.

빌드 결과에는 WAR, 체크섬, Git commit, 빌드 JDK, 의존성 목록 또는 SBOM, 테스트 결과를 함께 보관한다. 같은 commit을 서버에서 다시 빌드하지 않는다. CI가 한 번 만든 불변 아티팩트를 개발·검증·운영으로 승격한다.

↑ 목차로 돌아가기

4장 Gradle과 Spring Boot WAR의 차이를 이해한다

Gradle은 war plugin을 적용하고 war 또는 전체 build task로 묶는다.


plugins {
    id 'java'
    id 'war'
}

war {
    archiveFileName = 'releaseportal.war'
}

Spring Boot 애플리케이션을 외부 Servlet 컨테이너에 배포하려면 공식 전통 배포 방식의 세 조건을 확인한다.

  1. 애플리케이션 진입점이 SpringBootServletInitializer를 확장하고 configure를 구현한다.
  2. 패키징을 WAR로 바꾼다.
  3. 내장 Tomcat 의존성을 Maven provided 또는 Gradle providedRuntime으로 둔다.

@SpringBootApplication
public class ReleasePortalApplication extends SpringBootServletInitializer {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(ReleasePortalApplication.class);
    }

    public static void main(String[] args) {
        SpringApplication.run(ReleasePortalApplication.class, args);
    }
}

WebFlux는 Servlet API에 의존하지 않고 기본적으로 Reactor Netty를 사용하므로 전통 WAR 배포 대상이 아니다. “Spring이면 모두 WAR”라고 가정하지 않는다. Spring Boot의 실행 가능한 WAR는 java -jar로도 실행될 수 있지만, 외부 Tomcat과 독립 실행 중 어느 운영 모델을 택했는지 한 서비스에서 혼용하지 않는다.

↑ 목차로 돌아가기

5장 환경 설정과 비밀을 WAR 밖으로 꺼낸다

운영 DB 비밀번호와 인증서 개인키를 WAR에 넣으면 아티팩트 저장소, 개발 PC, 백업에 복제된다. WAR는 환경에 독립적이어야 한다. 바뀌는 값은 환경 변수, JVM system property, JNDI, 외부 설정 디렉터리, 조직 비밀 저장소 중 하나로 주입한다.


불변: 코드, 정적 자원, DB migration 스크립트, build-info
환경별: DB URL, 외부 API 주소, 로그 레벨, feature flag
비밀: DB 비밀번호, API token, TLS private key

Tomcat의 $CATALINA_BASE/bin/setenv.sh를 형상관리 밖에서 관리하거나 systemd EnvironmentFile을 사용할 수 있다. 환경 변수는 같은 계정의 프로세스나 진단 도구에 노출될 수 있으므로 조직의 비밀 주입 수단을 우선한다. 명령행 -Dpassword=...는 process list와 배포 로그에 남을 수 있다.

설정이 들어갔는지 확인하려고 값을 로그에 출력하지 않는다. 연결 성공, secret version, 설정 source 같은 비밀이 아닌 증거만 기록한다. health endpoint도 DB URL과 token을 반환하지 않는다.

↑ 목차로 돌아가기

6장 아티팩트 승격 계약을 만든다

배포 요청에는 “최신 WAR”가 아니라 식별 가능한 봉투가 필요하다.


{
  "service": "releaseportal",
  "version": "1.0.0",
  "gitCommit": "abc1234",
  "artifact": "releaseportal.war",
  "sha256": "배포 파일의 64자리 해시",
  "builtWith": "JDK 21",
  "target": "Tomcat 11 / Java 21",
  "migration": "expand only",
  "health": "/releaseportal/health",
  "rollback": "0.9.0"
}

SNAPSHOT이나 같은 이름의 WAR를 덮어써도 아티팩트 저장소에서는 내용이 바뀌지 않게 한다. 운영에 반입할 때 서버에서 체크섬을 다시 계산한다. 전송 전과 전송 후 해시가 다르면 unzip이나 재복사로 고치지 않고 반입을 중단한다.

2부 Tomcat 서버에 안전하게 배포한다

↑ 목차로 돌아가기

7장 CATALINA_HOME과 CATALINA_BASE를 분리한다

CATALINA_HOME은 Tomcat 프로그램, CATALINA_BASE는 한 인스턴스의 설정·로그·webapps·temp다. 둘을 분리하면 같은 바이너리 계열 아래에서 인스턴스 설정과 애플리케이션을 독립적으로 관리할 수 있다.


/opt/tomcat/apache-tomcat-11.0.x/     CATALINA_HOME, 읽기 전용
/srv/tomcat/releaseportal/            CATALINA_BASE
  conf/
  logs/
  temp/
  webapps/
  work/

Tomcat을 root로 실행하지 않는다. 전용 계정은 필요한 디렉터리만 읽고 쓰며, 8080은 loopback 또는 내부망에만 bind한다. Nginx가 443을 받고 Tomcat으로 proxy한다면 외부에서 8080에 직접 접근하지 못하게 방화벽과 Connector address를 함께 설정한다.

기본 examples, docs, manager 애플리케이션은 운영 필요성에 따라 제거하거나 접근을 제한한다. Manager를 사용한다면 브라우저 역할과 script 역할을 분리하고 localhost, 관리망, VPN 같은 좁은 source만 허용한다.

↑ 목차로 돌아가기

8장 systemd가 프로세스의 정본이 되게 한다

터미널에서 startup.sh를 실행하고 창을 닫는 방식은 누가 어떤 환경으로 시작했는지 남기기 어렵다. systemd unit에 계정, 경로, JVM 옵션, 재시작 정책과 종료 시간을 명시한다.


[Unit]
Description=ReleasePortal Tomcat
After=network-online.target

[Service]
Type=simple
User=releaseportal
Group=releaseportal
Environment=CATALINA_HOME=/opt/tomcat/current
Environment=CATALINA_BASE=/srv/tomcat/releaseportal
EnvironmentFile=-/etc/releaseportal/runtime.env
ExecStart=/opt/tomcat/current/bin/catalina.sh run
ExecStop=/opt/tomcat/current/bin/shutdown.sh
TimeoutStopSec=45
Restart=on-failure
UMask=0027

[Install]
WantedBy=multi-user.target

catalina.sh run은 Tomcat을 foreground에 두므로 systemd가 실제 JVM 프로세스를 직접 추적한다. startup.shType=forking을 쓰려면 CATALINA_PIDPIDFile까지 정확히 맞춰야 하며, PID가 오래 남는 경우를 별도로 처리해야 한다. 새 구성은 systemd-analyze verify releaseportal.service로 문법을 검사하고 journalctl -u releaseportal에서 종료와 재시작이 예상대로 기록되는지 staging에서 확인한다.

이것은 출발점이지 보안 완성본이 아니다. 배포판과 Tomcat 실행 방식에 따라 Type=simplecatalina.sh run을 선호할 수 있다. unit을 바꾼 뒤 systemctl daemon-reload, systemctl cat, systemctl show로 실제 적용값을 본다. JVM heap과 GC 옵션은 setenv.sh와 unit에 중복 선언하지 않는다.

↑ 목차로 돌아가기

9장 webapps 복사는 가장 단순하지만 원자적으로 한다

Tomcat Host의 appBase가 기본 webapps이고 autoDeploy가 켜져 있으면 WAR가 들어왔을 때 배포될 수 있다. 큰 WAR를 네트워크에서 webapps/releaseportal.war로 직접 복사하면 Tomcat이 전송 중 파일을 관찰할 위험이 있다.


# 배포 서버의 staging 영역
install -m 0640 releaseportal.war /srv/tomcat/releaseportal/staging/releaseportal.war.tmp
sha256sum /srv/tomcat/releaseportal/staging/releaseportal.war.tmp
jar tf /srv/tomcat/releaseportal/staging/releaseportal.war.tmp >/dev/null

# 같은 파일 시스템 안에서 최종 이름으로 이동
mv /srv/tomcat/releaseportal/staging/releaseportal.war.tmp \
   /srv/tomcat/releaseportal/webapps/releaseportal.war

실제 소유자와 경로는 서버 정책에 맞춘다. mv의 원자성은 같은 파일 시스템에서 이름을 바꿀 때 기대할 수 있다. 다른 mount 사이 이동은 복사와 삭제가 될 수 있다. exploded directory가 이전 배포에서 남아 있으면 새 WAR와 어느 쪽이 정본인지 혼란이 생긴다. unpackWARs, autoDeploy, deployOnStartup 정책을 명시한다.

WAR 이름은 context path가 된다. releaseportal.war/releaseportal, ROOT.war/다. root 배포를 위해 애플리케이션 코드를 root path에 고정하지 말고 reverse proxy routing과 context 설정을 검토한다.

↑ 목차로 돌아가기

10장 Tomcat Manager 자동 배포를 좁게 연다

Tomcat Manager text API는 서버 전체를 재시작하지 않고 WAR를 배포·중지·제거할 수 있다. 편리한 만큼 권한이 크다. manager-script 역할만 가진 전용 계정을 사용하고 인터넷에 공개하지 않는다.


GET  /manager/text/list
PUT  /manager/text/deploy?path=/releaseportal&update=true
GET  /manager/text/stop?path=/releaseportal
GET  /manager/text/undeploy?path=/releaseportal

자동화에서는 자격 증명을 스크립트, Git, shell history, CI 로그에 넣지 않는다. secret file이나 CI credential helper에서 짧게 주입하고 응답이 OK로 시작하는지 확인한다. HTTP 200만으로 성공을 판정하면 FAIL - ... 본문을 놓칠 수 있다.

Manager의 update=true는 기존 앱을 내리고 다시 배포할 수 있으므로 무중단을 자동으로 보장하지 않는다. 세션, warm-up, DB migration과 트래픽 구조를 별도로 설계한다.

↑ 목차로 돌아가기

11장 병렬 버전과 reverse proxy로 중단 시간을 줄인다

Tomcat은 WAR 파일명의 ## 뒤를 버전으로 해석하는 병렬 배포 방식을 지원한다. 예를 들어 releaseportal##001.warreleaseportal##002.war는 같은 context path의 서로 다른 버전이 된다. 새 세션은 최신 버전으로, 기존 세션은 이전 버전에 남을 수 있다.

병렬 배포는 마법의 무중단이 아니다. 세션 직렬화, 외부 session store, WebSocket, 백그라운드 작업, DB schema 호환성을 검토해야 한다. 이전 버전이 새 schema에서 계속 동작해야 한다.

더 명확한 방식은 두 Tomcat 인스턴스 또는 두 upstream을 blue/green으로 운영하는 것이다.


upstream releaseportal_active {
    server 127.0.0.1:8081;
    # 검증 후 8082로 변경하거나 upstream 관리 기능 사용
}

location /releaseportal/ {
    proxy_pass http://releaseportal_active;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

새 인스턴스에 직접 health와 smoke를 실행하고, 설정 테스트 후 reload로 upstream을 전환한다. 실패하면 이전 upstream으로 되돌린다. reload 전에 현재 설정과 활성 버전을 기록한다.

↑ 목차로 돌아가기

12장 health·smoke·지표가 모두 통과해야 승격한다

프로세스가 떠 있다는 health만으로는 로그인, DB 쓰기, 정적 자원, proxy path가 정상인지 알 수 없다. 검사를 층으로 나눈다.

  1. 아티팩트: 체크섬과 WAR 필수 항목
  2. 기동: Tomcat context가 STARTED인지
  3. readiness: DB와 필수 의존성 준비
  4. smoke: 대표 사용자 경로의 읽기·쓰기
  5. 외부 경로: Nginx와 실제 hostname을 거친 HTTPS
  6. 지표: 오류율, p95, JVM, DB pool, thread

curl --fail --silent http://127.0.0.1:8080/releaseportal/health
curl --fail --silent https://portal.example.com/releaseportal/health

두 응답이 다르면 애플리케이션보다 proxy route, Host header, context path, 인증서를 본다. curl -k는 인증서 검증을 끄므로 정상 판정에 사용하지 않는다. 개발용 self-signed fixture에서만 그 한계를 이해하고 쓴다.

ReleasePortal에서 WAR와 TLS 교체 후보를 같은 기준으로 판정하는 화면
ReleasePortal에서 WAR와 TLS 교체 후보를 같은 기준으로 판정하는 화면

3부 인증서를 안전하게 교체한다

↑ 목차로 돌아가기

13장 TLS 종료 지점을 먼저 찾는다

브라우저가 받은 인증서를 교체하려면 실제 TLS handshake를 처리하는 곳을 찾아야 한다. 흔한 구조는 네 가지다.

구조 인증서가 있는 곳 reload 대상
Nginx → Tomcat HTTP Nginx Nginx
Load Balancer → Nginx/Tomcat HTTP LB 또는 CDN 해당 관리 plane
Nginx → Tomcat HTTPS 양쪽 모두 각각의 인증서 목적 확인
Tomcat 단독 HTTPS Tomcat keystore/PEM Tomcat TLS 설정

서버에서 파일 검색부터 하지 않는다. DNS, load balancer listener, Nginx listen 443 ssl, Tomcat Connector를 따라 요청 경로를 그린다. 외부에서 받은 인증서 issuer와 serial을 확인한다.


openssl s_client -connect portal.example.com:443 \
  -servername portal.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -serial -dates

-servername은 SNI를 보낸다. 한 IP에서 여러 hostname을 서비스할 때 이를 빼면 기본 virtual host의 다른 인증서를 볼 수 있다. 로드밸런서에서 TLS를 종료한다면 Tomcat의 keystore를 바꿔도 사용자 인증서는 달라지지 않는다.

↑ 목차로 돌아가기

14장 인증서·개인키·체인을 구분한다

서버 인증서는 공개키와 이름, 유효기간, 발급자 서명을 담는다. 개인키는 서버가 해당 인증서의 주체임을 증명하는 비밀이다. 체인은 서버 인증서에서 브라우저가 신뢰하는 root까지 연결하는 중간 인증서 묶음이다.

일반적인 PEM 파일은 다음과 같다.


cert.pem       서버 인증서
chain.pem      중간 인증서 체인
fullchain.pem  cert.pem + chain.pem
privkey.pem    개인키, 절대 공개 금지

개인키를 이메일·메신저·티켓에 첨부하지 않는다. 새 키를 생성할지 기존 키를 재사용할지는 CA, 조직 보안 정책, pinning과 HSM 정책에 따른다. 키가 유출됐다고 의심되면 단순 갱신이 아니라 즉시 폐기·재발급·노출 범위 조사를 한다.

파일 권한은 web server master process가 읽을 최소 범위로 제한한다. 인증서 공개 정보는 민감하지 않지만 개인키와 PKCS12 비밀번호는 비밀이다. 백업에도 같은 접근통제와 보존 기간을 적용한다.

↑ 목차로 돌아가기

15장 교체 전에 다섯 가지를 검증한다

인증서를 받은 즉시 운영 경로로 복사하지 않는다. staging 디렉터리에서 다음을 확인한다.

1. 유효기간과 식별 정보


openssl x509 -in next-cert.pem -noout \
  -subject -issuer -serial -dates -ext subjectAltName
openssl x509 -in next-cert.pem -checkend 1209600 -noout

두 번째 명령의 1,209,600초는 14일이다. 이 값은 운영 여유 시간 예시이며 조직 정책으로 정한다. CN만 보지 말고 SAN에 서비스 hostname이 정확히 있는지 확인한다. wildcard는 한 label의 범위를 자동으로 넘지 않는다.

2. 개인키 일치


openssl x509 -in next-cert.pem -pubkey -noout \
 | openssl pkey -pubin -outform DER \
 | openssl sha256

openssl pkey -in next-key.pem -pubout -outform DER \
 | openssl sha256

두 digest가 같아야 한다. 개인키 본문이나 passphrase를 화면 공유에 노출하지 않는다.

3. 체인

공개 CA 체인은 서버가 보내야 할 intermediate와 클라이언트 trust store의 root 관계를 확인한다. 사설 CA 실습은 다음처럼 검증한다.


openssl verify -CAfile build/cert-fixture/ca.crt \
  build/cert-fixture/next.crt

운영에서는 CA가 제공한 fullchain과 web server 요구 형식을 따른다. root를 무조건 fullchain에 붙이는 것이 정답은 아니다.

4. 키 사용과 알고리즘

openssl x509 -text에서 public key, signature algorithm, key usage, extended key usage를 확인한다. 조직과 클라이언트 호환 정책을 적용한다. 단순히 RSA 비트 수 하나만 보고 안전을 판정하지 않는다.

5. 대상 일치

발급된 hostname 목록, Nginx server_name, Tomcat SSLHostConfig hostName, DNS와 모니터링 대상이 같은 서비스인지 대조한다.

책의 실제 fixture를 만든다.


npm run cert:fixture
cat build/cert-fixture/verification.json

생성된 CA와 키는 교육용이며 빌드 디렉터리 밖으로 복사하지 않는다.

↑ 목차로 돌아가기

16장 Nginx에서 인증서를 교체한다

Tomcat 앞에 Nginx가 TLS를 종료하는 구조가 흔하다. Nginx 공식 문서는 ssl_certificate에 certificate chain 파일, ssl_certificate_key에 제한된 개인키 파일을 지정한다.


server {
    listen 443 ssl;
    server_name portal.example.com;

    ssl_certificate     /etc/letsencrypt/live/portal.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/portal.example.com/privkey.pem;

    location /releaseportal/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

교체 순서는 다음과 같다.

  1. 현재 인증서 serial, 만료일, 파일 경로와 Nginx 설정 백업을 기록한다.
  2. 새 인증서의 SAN·기간·키·체인을 staging에서 검증한다.
  3. 설정이 새 파일 또는 안정적인 live symlink를 가리키게 한다.
  4. nginx -t로 전체 설정을 검사한다.
  5. systemctl reload nginx로 새 worker가 설정을 읽게 한다.
  6. 외부 hostname에서 serial, 체인, health, 주요 페이지를 확인한다.
  7. 실패하면 이전 파일 참조로 되돌리고 다시 nginx -t와 reload를 한다.

reload는 기존 연결을 즉시 모두 끊는 restart와 목적이 다르지만, 장시간 연결과 플랫폼 구현을 모니터링한다. 설정 테스트가 실패했는데 reload를 강행하지 않는다. Nginx master가 개인키를 읽지 못하면 새 worker가 뜨지 않을 수 있다.

↑ 목차로 돌아가기

17장 Tomcat이 TLS를 직접 종료할 때 교체한다

Tomcat 11은 JSSE 방식의 JKS·PKCS12 keystore와 PEM/OpenSSL 스타일 구성을 지원한다. 한 Connector에서 두 스타일의 속성을 섞지 않는다.

PKCS12를 만드는 예시는 다음과 같다. passphrase는 명령행 문자열 대신 권한이 제한된 파일이나 조직 비밀 도구로 전달한다.


openssl pkcs12 -export \
  -in next-cert.pem \
  -inkey next-key.pem \
  -certfile chain.pem \
  -name tomcat \
  -out next-tomcat.p12

keytool -list -v -keystore next-tomcat.p12 -storetype PKCS12

JSSE Connector의 핵심 형태는 다음과 같다. 비밀번호를 평문 server.xml에 고정하지 말고 Tomcat과 조직이 지원하는 비밀 주입 방식을 사용한다.


<Connector port="8443"
           protocol="org.apache.coyote.http11.Http11NioProtocol"
           SSLEnabled="true" scheme="https" secure="true">
  <SSLHostConfig protocols="TLSv1.3,TLSv1.2">
    <Certificate
      certificateKeystoreFile="/etc/releaseportal/tls/current.p12"
      certificateKeystoreType="PKCS12"
      certificateKeyAlias="tomcat" />
  </SSLHostConfig>
</Connector>

Tomcat Manager text endpoint의 sslReload는 certificate와 key 파일을 다시 읽지만 server.xml 자체를 다시 parse하지 않는다. 파일 내용만 교체했다면 reload 후보가 될 수 있다. Connector 구조나 경로를 바꿨다면 재시작과 별도 변경 절차가 필요하다.


/manager/text/sslReload
/manager/text/sslReload?tlsHostName=portal.example.com

Manager 권한을 인터넷에 열지 않는다. 실행 뒤 sslConnectorCiphers, sslConnectorCerts 계열 진단과 외부 handshake를 확인한다. 지원 중인 정확한 endpoint와 역할은 사용 중인 Tomcat 버전 문서를 다시 본다.

↑ 목차로 돌아가기

18장 Certbot 갱신을 교체 절차와 연결한다

Certbot의 renew가 exit 0이라고 해서 반드시 인증서가 교체된 것은 아니다. 갱신할 대상이 없어도 성공으로 끝날 수 있다. 성공적으로 새 인증서가 발급됐을 때만 실행할 작업은 deploy hook에 둔다.


certbot renew --dry-run
certbot renew --deploy-hook /usr/local/sbin/reload-releaseportal-tls

deploy hook은 단순 reload 한 줄보다 방어적으로 만든다.


#!/usr/bin/env bash
set -euo pipefail
nginx -t
systemctl reload nginx

운영 hook에는 갱신된 lineage와 domain 환경 변수를 검증하고, 구조화 로그와 모니터링 이벤트를 남길 수 있다. hook 파일은 root 소유와 제한된 쓰기 권한을 사용한다. 일반 사용자가 수정할 수 있는 hook은 인증서 갱신 시 root 명령 실행 통로가 된다.

dry-run은 ACME 발급 경로를 시험한다. Certbot 문서상 dry-run에서 deploy hook 실행은 별도 옵션과 동작 차이가 있으므로 버전 문서를 확인한다. timer가 실제로 활성화됐는지, 마지막 실행과 다음 실행, 실패 알림이 있는지도 점검한다. 자동 갱신은 무관심이 아니라 정기적인 복구 훈련을 요구한다.

4부 실패를 복구하고 자동화한다

↑ 목차로 돌아가기

19장 WAR 배포 롤백은 파일 하나의 문제가 아니다

애플리케이션 rollback은 이전 WAR를 되놓는 것으로 끝나지 않을 수 있다. DB schema, 메시지 형식, cache, background job, session이 새 버전과 함께 바뀐다.

안전한 schema 변경은 expand-contract 순서를 따른다.

  1. 새·구 버전이 함께 쓸 수 있는 column/table을 추가한다.
  2. 새 애플리케이션을 배포하고 데이터를 채운다.
  3. 이전 버전이 없어졌음을 확인한다.
  4. 별도 배포에서 오래된 구조를 제거한다.

배포 직전에 destructive migration을 실행하면 WAR만 rollback해도 이전 앱이 뜨지 않는다. migration은 별도 artifact와 승인 단계로 다루고, rollback 가능 시점을 명시한다.

책의 sandbox 배포를 실행한다.


npm run build:war
npm run lab:deploy
readlink build/server-sandbox/current
cat build/server-sandbox/current/release.json

current는 1.0.0, previous는 0.9.0을 가리킨다. 실제 Tomcat webapps는 symlink 갱신을 항상 같은 방식으로 감지하지 않을 수 있다. 이 패턴은 release 보관과 원자적 승격 개념을 훈련하기 위한 것이다. 운영에서는 appBase 정책, Context docBase, blue/green 또는 배포 도구의 공식 원자적 전환 기능을 사용한다.

↑ 목차로 돌아가기

20장 인증서 rollback은 만료 전 인증서로만 한다

새 인증서가 문제를 일으켰을 때 이전 인증서로 돌아갈 수 있으려면 이전 인증서가 아직 유효하고 폐기되지 않았으며 개인키가 안전하게 보관돼 있어야 한다. “백업 파일이 있다”와 “사용 가능한 rollback”은 다르다.

인증서 rollback 조건을 기록한다.

  • 이전 인증서의 notAfter가 rollback window보다 뒤다.
  • 이전 key가 compromise되지 않았다.
  • 이전 체인이 현재 클라이언트에서 신뢰된다.
  • 파일 권한과 secret 참조를 복구할 수 있다.
  • 설정 테스트와 외부 handshake 검증 명령이 준비돼 있다.

새 인증서 자체가 정상인데 일부 오래된 클라이언트만 실패할 수 있다. 알고리즘, 중간 CA, SNI, trust store를 분리해 조사한다. 안전한 클라이언트를 약화시키는 전역 TLS 설정 변경보다 영향받는 클라이언트와 신뢰 체인을 먼저 확인한다.

↑ 목차로 돌아가기

21장 배포 장애 10가지를 증상에서 진단한다

1. /releaseportal이 404다

WAR가 STARTED인지, 실제 context path가 무엇인지, Nginx location과 proxy_pass의 trailing slash가 URI를 어떻게 바꾸는지 확인한다. 애플리케이션 로그보다 먼저 외부 URL과 내부 Tomcat URL을 비교한다.

2. Tomcat은 떴지만 앱만 FAIL이다

배포 시각의 catalina와 localhost log에서 첫 root cause를 찾는다. 같은 예외가 반복된 마지막 줄만 복사하지 않는다. web.xml, listener 초기화, JNDI, 환경 설정, DB 연결을 본다.

3. UnsupportedClassVersionError

빌드 JDK와 server JVM bytecode 세대가 다르다. 서버를 즉석 upgrade하거나 오래된 JDK로 재빌드하기 전에 지원 matrix를 확인한다.

4. ClassNotFoundException 또는 NoClassDefFoundError

jar tf WARWEB-INF/lib를 확인한다. Maven provided로 둔 라이브러리를 컨테이너가 실제 제공하는지, 반대로 Servlet API를 중복 포함했는지 본다. 공유 $CATALINA_BASE/lib에 애플리케이션 JAR를 임시 복사해 문제를 숨기지 않는다.

5. 배포 뒤 CPU와 thread가 치솟는다

트래픽을 확대하지 않는다. thread dump, GC, DB pool, 외부 API 지연을 배포 전 baseline과 비교한다. health가 UP이어도 error rate와 p95 기준으로 rollback한다.

6. 브라우저가 이전 인증서를 보여 준다

DNS가 여러 IP를 가리키는지, CDN/LB/Nginx/Tomcat 중 어디서 TLS가 끝나는지 확인한다. 각 IP에 SNI를 넣어 조회한다. reload한 서버가 실제 traffic 대상인지 본다.

7. no suitable certificate 또는 alias 오류

PKCS12/JKS의 entry type이 PrivateKeyEntry인지, alias의 대소문자, certificate와 key 일치, password와 파일 권한을 확인한다.

8. 일부 기기만 체인 오류가 난다

서버가 보낸 full chain을 openssl s_client -showcerts로 확인한다. 서버 내부 파일 목록만 보지 않는다. 오래된 trust store와 누락 intermediate를 구분한다.

9. HTTPS인데 앱이 redirect loop를 만든다

proxy가 X-Forwarded-Proto를 전달하고 애플리케이션 또는 Tomcat이 신뢰할 proxy 범위를 올바르게 처리하는지 본다. 외부가 임의로 보낸 forwarded header를 그대로 신뢰하지 않는다.

10. reload는 성공했는데 새 키를 못 읽는다

새 worker나 Tomcat 프로세스 계정이 실제 파일과 symlink 경로의 모든 상위 디렉터리를 읽을 수 있는지 확인한다. SELinux/AppArmor audit도 본다. 권한 문제를 777로 해결하지 않는다.

↑ 목차로 돌아가기

22장 로그와 타임라인으로 원인을 좁힌다

장애 기록은 세 개의 시계를 맞춘다.


09:00:12 artifact SHA 검증
09:01:04 WAR promote
09:01:18 Tomcat context STARTED
09:01:31 internal health UP
09:02:02 external smoke FAIL / login redirect
09:02:20 rollback decision
09:03:10 previous traffic restored

서버, JVM, Nginx, DB의 timezone과 NTP 상태가 다르면 사건 순서가 뒤집힌다. UTC 기반 구조화 로그와 request/correlation ID를 사용한다. 비밀번호, cookie, Authorization header, private key는 로그에 남기지 않는다.

배포 기록에는 사람과 자동화의 결정을 나눈다. 누가 승인했는지보다 무엇을 보고 승인했는지가 중요하다. artifact digest, 검사 결과, 지표 query, 인증서 serial, rollback 버전을 연결한다.

↑ 목차로 돌아가기

23장 CI/CD pipeline에 중단 조건을 넣는다

좋은 pipeline은 단계가 많아서가 아니라 실패를 안전하게 멈춘다.


source checkout
  → unit/integration test
  → WAR package
  → archive/SBOM/secret scan
  → checksum/sign
  → artifact repository
  → staging deploy
  → internal smoke
  → approval
  → production canary
  → metrics gate
  → promote or rollback

CI runner가 운영 Tomcat의 모든 권한을 상시 가지지 않게 한다. 짧은 수명의 배포 자격 증명, 환경별 승인, protected branch와 immutable artifact를 사용한다. Manager 자격 증명과 TLS private key를 같은 secret에 넣지 않는다.

배포 중복도 막는다. 같은 서비스에 두 pipeline이 동시에 webapps를 바꾸면 마지막 writer가 누구인지 알 수 없다. 환경별 lock이나 deployment queue를 둔다. 외부 응답이 끊겼다고 deploy를 바로 재시도하지 말고 서버의 현재 version과 작업 상태를 조회한다.

↑ 목차로 돌아가기

24장 최종 실전: 금요일 WAR와 인증서를 함께 교체한다

운영 변경 하나에 애플리케이션 1.0.0과 만료 18일 남은 인증서 교체가 함께 들어왔다. 가능하면 두 변경을 분리하는 것이 원인 격리에 유리하다. 불가피하게 같은 window에서 한다면 단계 사이 관찰점을 둔다.

변경 전

  1. 현재 WAR digest, Git commit, context, JVM과 Tomcat 버전을 기록한다.
  2. 현재 외부 인증서 serial, SAN, notAfter와 TLS 종료 지점을 기록한다.
  3. 0.9.0 rollback과 이전 인증서의 사용 가능성을 검증한다.
  4. DB migration이 양방향 호환인지 확인한다.
  5. 담당자, 승인자, rollback 결정 시각과 소통 채널을 정한다.

애플리케이션 배포

WAR checksum과 구조를 검증하고 green slot에 배포한다. 내부 health와 smoke 뒤 소량 traffic을 보내 오류율과 p95를 본다. 기준을 넘으면 인증서 작업을 시작하지 않고 앱부터 rollback한다.

인증서 교체

새 certificate의 SAN·기간·key·chain을 검증한다. Nginx라면 nginx -t, Tomcat이라면 keystore alias와 TLS 설정을 확인한다. reload 후 외부 SNI handshake에서 새 serial을 확인한다. 모든 DNS 대상 IP와 주요 클라이언트 경로를 확인한다.

완료

변경 ticket에 WAR digest, 새 certificate serial/notAfter, smoke 결과, 지표 dashboard, rollback 만료시각을 남긴다. 구버전 WAR와 인증서는 보존 정책이 끝날 때 안전하게 폐기한다. private key 백업을 일반 artifact 보관함에 두지 않는다.

졸업 기준

  • 빌드한 WAR와 서버의 WAR가 같음을 digest로 증명할 수 있다.
  • javax/jakarta, 빌드 JDK/서버 JVM 경계를 설명할 수 있다.
  • health와 smoke, 외부 경로 검사의 차이를 말할 수 있다.
  • TLS 종료 지점과 실제 교체 대상을 찾을 수 있다.
  • 인증서의 SAN·기간·키·체인을 교체 전에 검증할 수 있다.
  • reload 뒤 외부에서 serial을 확인하고 이전 상태로 rollback할 수 있다.

5부 레거시 Java 웹 인프라를 해부하고 살려 낸다

↑ 목차로 돌아가기

25장 서버 한 대를 애플리케이션 지도로 바꾼다

레거시 시스템을 넘겨받으면 webapps에 있는 WAR부터 복사하고 싶어진다. 그러나 실제 애플리케이션은 WAR 밖에 더 많이 숨어 있다. JVM 옵션, CATALINA_BASE/conf, JNDI 자원, 프록시 경로, 공유 디렉터리, cron, 배치 계정, 인증서, DNS, 방화벽, DB 스키마가 합쳐져 서비스가 된다. 먼저 변경 없는 읽기 전용 인벤토리를 만든다.


서비스: ReleasePortal
외부 URL / context: https://portal.example.invalid/releaseportal
TLS 종료: Nginx 2대
애플리케이션: releaseportal.war, SHA-256, build commit
런타임: JDK / Tomcat / Servlet namespace
설정: CATALINA_BASE, context XML, systemd EnvironmentFile
상태: HTTP session, 로컬 cache, 업로드 임시 파일
연계: JNDI DB, SMTP, SFTP, 배치, 공유 파일
소유자: 앱 / WAS / DB / 네트워크 / 인증서
복구: 이전 WAR, DB 호환 범위, RTO/RPO, 담당자

운영 서버에서는 명령이 읽기 전용인지 먼저 확인한다. 출력에 비밀이 포함될 수 있으므로 원문 전체를 출판 예제나 외부 AI에 붙이지 않는다.


java -version
"$CATALINA_HOME/bin/version.sh"
systemctl cat releaseportal
ps -ef | grep '[o]rg.apache.catalina.startup.Bootstrap'
ss -lntp
jar --list --file /srv/releases/releaseportal.war | sed -n '1,80p'

pssystemctl cat에는 비밀번호나 토큰이 노출될 수 있다. 공유 전 키 이름만 남기고 값은 마스킹한다. lsof, ss, 로그 열람 권한도 조직 절차를 따른다.

WAR 밖의 프록시·세션·JNDI·공유 파일·배치 의존성을 한 장에 표시한 레거시 인프라 지도
WAR 밖의 프록시·세션·JNDI·공유 파일·배치 의존성을 한 장에 표시한 레거시 인프라 지도
ReleasePortal 실습 앱이 namespace·세션·JNDI pool·배치·공유 파일·프록시 계약을 한 화면에서 HOLD로 판정한 동일 실습 화면
ReleasePortal 실습 앱이 namespace·세션·JNDI pool·배치·공유 파일·프록시 계약을 한 화면에서 HOLD로 판정한 동일 실습 화면

실습 25 읽기 전용 인수 카드

실제 서버 대신 제공 fixture를 기준으로 legacy-inventory.md를 만든다. 모르는 항목은 추측하지 않고 UNKNOWN으로 둔다. 각 항목에 확인 명령, 확인 시각, 소유자, 변경 금지 여부를 붙인다. 빈칸이 있다는 사실도 배포 위험의 증거다.

↑ 목차로 돌아가기

26장 Tomcat·JDK·javax·jakarta 경계를 업그레이드 행렬로 잠근다

Tomcat major 버전만 올려 해결할 수 없는 경계가 있다. Tomcat 9 계열의 Java EE API는 주로 javax.*, Tomcat 10 이후 Jakarta EE API는 jakarta.*를 사용한다. Apache Tomcat 공식 migration 안내는 이 패키지 변경을 큰 비호환으로 설명하며 애플리케이션 재컴파일이 필요할 수 있다고 밝힌다. 변환 도구는 탐색과 임시 전환에 유용하지만 사용자 코드, 서드파티 라이브러리, JSP 태그, 직렬화 데이터의 호환을 대신 증명하지 않는다.

2026년 8월 9일 Tomcat 공식 migration 페이지는 현재 지원 계열로 9.0.x, 10.1.x, 11.0.x를 안내한다. 실제 선택은 현재 패치 릴리스, 보안 공지, 필요한 Servlet/JSP 사양, JDK 지원 범위를 출간·배포 시점에 다시 대조한다. 구버전 설정 파일 전체를 새 major의 conf에 덮어쓰지 않고 새 기본 설정에서 필요한 차이만 이식한다.

현재 후보 확인할 증거
JDK 서버 실제 버전 목표 LTS class file, JVM 옵션, TLS provider
Tomcat major·patch 지원 patch migration guide, 기본 설정 diff
Servlet API javax 또는 jakarta 목표 namespace import, web.xml, taglib
Framework Spring 등 실제 버전 호환 버전 공식 compatibility, 통합 테스트
JDBC driver 공용/앱 번들 검증 버전 DB·JDK·pool 호환
session 직렬화 객체 새 classpath 역직렬화·failover 테스트

WAR의 class file 수준은 javap -verbose 또는 빌드 설정으로 확인하고 서버 JVM과 대조한다. UnsupportedClassVersionError가 나면 JDK를 무작정 바꾸지 말고 빌드 target과 운영 표준을 함께 정한다.

실습 26 세 갈래 업그레이드 리허설

현재 유지, 동일 major patch upgrade, Jakarta 전환 세 경로를 표로 만든다. 각 경로에 변경 파일, 테스트, rollback 가능성, 세션·DB 호환, 중단 시간을 적는다. 한 번에 JDK·Tomcat major·framework·DB schema를 모두 바꾸는 경로는 원인 격리가 어려우므로 분리안을 함께 제시한다.

↑ 목차로 돌아가기

27장 JNDI·JDBC·classloader를 WAR 밖 계약으로 만든다

레거시 WAR는 java:comp/env/jdbc/... 이름만 알고 실제 DB 주소와 자격은 컨테이너가 제공하는 경우가 많다. 이 구조는 환경 분리에 유용하지만 설정 위치와 classloader를 모르면 NameNotFoundException, driver 누락, 중복 pool, 메모리 누수가 생긴다.


<!-- $CATALINA_BASE/conf/Catalina/localhost/releaseportal.xml 예시 -->
<Context>
  <Resource name="jdbc/ReleaseDB"
            auth="Container"
            type="javax.sql.DataSource"
            driverClassName="org.postgresql.Driver"
            url="jdbc:postgresql://db.example.invalid:5432/release"
            username="${RELEASE_DB_USER}"
            password="${RELEASE_DB_PASSWORD}"
            maxTotal="20" maxIdle="5" maxWaitMillis="5000" />
</Context>

예시는 구조 설명용이다. 모든 Tomcat 설정이 ${...} 비밀 치환을 자동 제공한다고 가정하지 않는다. 실제 배포 방식의 secret injection과 파일 권한을 검증한다. JDBC driver를 $CATALINA_BASE/lib에 둘지 WEB-INF/lib에 둘지는 pool의 소유자와 classloader 경계에 따라 결정한다. 두 곳에 서로 다른 버전을 중복 배치하지 않는다.

pool 크기는 인스턴스 하나가 아닌 전체 합으로 계산한다.


인스턴스 4대 × maxTotal 30 = 최대 120 connection
DB 허용 100, 운영 예약 20이라면 이미 한도를 소비한다.

연결 검증 쿼리, 대기 제한, 유휴 연결 정리, 누수 탐지는 DB와 driver 특성에 맞춰 작은 부하에서 측정한다. maxWaitMillis=-1 같은 무한 대기는 장애를 thread 고갈로 바꿀 수 있다.

실습 27 JNDI 실패 카드

이름 미발견, driver class 없음, 비밀번호 거부, pool 고갈, DB timeout 다섯 증상에 대해 첫 확인 지점과 금지 행동을 적는다. 운영 비밀번호를 명령행이나 Git에 넣는 해결은 금지한다. fixture에서는 자격 대신 ${RELEASE_DB_PASSWORD} 표지만 사용한다.

↑ 목차로 돌아가기

28장 세션과 상태가 롤링 배포의 진짜 경계다

두 Tomcat에 WAR를 올렸다고 무중단이 되는 것은 아니다. 로그인 session이 인스턴스 메모리에만 있으면 트래픽이 다른 노드로 이동할 때 로그아웃되거나 장바구니가 사라질 수 있다. 먼저 상태를 분류한다.

상태 위치 배포 영향 선택지
HTTP session Tomcat 메모리 재시작·노드 전환 sticky, replication, 외부 session
로컬 cache JVM heap 버전 간 불일치 만료·버전 key·외부 cache
업로드 임시 파일 로컬 disk 다른 노드가 못 봄 object/shared storage, 업로드 고정
예약 작업 상태 메모리/DB 중복 실행 durable state, leader/lock

Tomcat cluster의 session replication은 설정 한 줄로 끝나지 않는다. session attribute가 직렬화 가능해야 하고, 모든 노드의 class와 설정이 맞아야 하며, replication 네트워크를 신뢰된 구간으로 제한해야 한다. 전체 복제는 노드와 session이 늘수록 비용이 커질 수 있다. sticky session은 복제를 줄이지만 노드 장애 때 상태 손실 가능성을 없애지 않는다.

새 WAR가 session 객체 class를 바꾸면 구버전이 만든 session을 새 버전이 읽지 못할 수 있다. 롤링 window에서는 세션 스키마를 양방향으로 읽거나, session을 비우는 사용자 영향과 공지를 승인해야 한다.

실습 28 세션 failover

가상 사용자 S-104가 node-a에 로그인했다고 가정한다. node-a drain, node-b 이동, 구·신 WAR 혼재, rollback 네 시점에서 session 기대 결과를 쓴다. “sticky니까 안전” 대신 노드 장애와 쿠키 route가 바뀔 때의 결과를 시험한다.

↑ 목차로 돌아가기

29장 공유 파일·SFTP·배치·cron의 숨은 쓰기를 찾는다

레거시 웹은 HTTP 요청만 처리하지 않는다. 특정 폴더의 CSV를 읽고, SFTP로 정산 파일을 보내고, 새벽 cron이 같은 DB를 수정한다. WAR만 복제하면 배치가 인스턴스 수만큼 중복 실행될 수 있다.

파일 연계는 incoming, processing, done, failed 상태를 디렉터리와 메타데이터로 구분한다. 같은 파일 시스템에서 원자적 rename으로 소유권을 넘기고, 파일 크기가 더 이상 변하지 않는지 확인한다. 네트워크 파일 시스템에서는 원자성·잠금 의미가 다를 수 있어 실제 저장소에서 검증한다.


partner_20260809_001.csv.part  # 전송 중
partner_20260809_001.csv       # 완료 후 rename
처리 key: partner + business-date + sequence + content-hash

cron, Spring scheduler, DB job, WAS timer가 같은 일을 중복 수행하지 않는지 전체 목록을 만든다. 다중 인스턴스에서는 leader election, DB lock, 전용 batch worker 중 하나를 명시하고 lock 만료와 재시도 정책을 시험한다. PID 파일만으로 분산 중복을 막을 수 있다고 가정하지 않는다.

실습 29 중복 배치 사고

node-a와 node-b가 같은 정산 파일을 동시에 발견한 fixture를 만든다. 한 노드만 processing 상태를 획득하고, 다른 노드는 처리 결과를 조회해야 한다. 첫 노드가 중간에 죽으면 lock 만료 뒤 재개하되 이미 반영된 DB 쓰기를 멱등 key로 중복 생성하지 않는다.

↑ 목차로 돌아가기

30장 reverse proxy·context path·forwarded header를 한 흐름으로 검증한다

외부 사용자는 Nginx의 /releaseportal을 보지만 Tomcat은 내부 HTTP와 다른 host·port를 볼 수 있다. 이 차이를 잘못 처리하면 HTTPS redirect loop, 잘못된 절대 URL, secure cookie 누락, SSO callback 불일치가 생긴다.


location /releaseportal/ {
  proxy_pass http://releaseportal_pool/releaseportal/;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

이 예시를 그대로 복사하기 전에 현재 Nginx 버전, trailing slash, context, 신뢰할 proxy 범위를 확인한다. 애플리케이션이나 Tomcat의 RemoteIpValve가 forwarded header를 신뢰한다면 임의 클라이언트가 직접 주입하지 못하도록 신뢰 프록시를 제한한다. 외부 URL, 내부 URL, health URL, SSO callback, cookie path를 표로 고정한다.

실습 30 외부 경로 smoke

내부 Tomcat health가 UP인 상태에서 외부 경로 404, redirect loop, 잘못된 cookie path 세 실패를 주입한다. 승격 판단은 내부 health가 아니라 실제 SNI·host·context를 통과한 외부 smoke까지 기다려야 한다.

↑ 목차로 돌아가기

31장 다중 인스턴스·재해복구를 배포 절차와 연결한다

HA는 서버가 두 대라는 뜻이 아니다. 같은 전원·스토리지·DB·load balancer를 공유하면 공통 장애점이 남는다. 인스턴스, zone, DB, 파일, DNS, 인증서, secret, 관측 시스템의 장애 도메인을 그린다. RTO는 복구까지 허용 시간, RPO는 허용 가능한 데이터 손실 범위다.

배포 때 한 노드를 drain하고 active request와 session, batch가 빠졌는지 확인한다. 새 WAR를 올린 뒤 readiness와 외부 smoke가 통과해야 pool에 복귀한다. 모든 노드를 동시에 재시작하는 스크립트에는 승인과 동시성 제한을 둔다.

DR 환경의 WAR와 설정이 운영과 같다는 가정은 훈련으로 증명한다. DB가 오래된 시점이면 새 WAR가 기대하는 schema와 맞지 않을 수 있고, 인증서·DNS·secret이 만료됐을 수 있다. 복구 훈련은 로그인, 조회, 쓰기, 배치, 파일 연계까지 확인한다.

실습 31 한 노드와 한 zone을 잃는다

node-a 중단과 zone 전체 중단을 각각 표로 만든다. 남은 capacity, session, batch owner, DB connection, DNS/TLS, 경보, 사용자 공지를 기록한다. 복구 성공은 프로세스가 뜬 순간이 아니라 핵심 사용자 여정과 데이터 일관성이 확인된 순간이다.

↑ 목차로 돌아가기

32장 WAR를 유지할 것과 현대화할 것을 증거로 나눈다

컨테이너나 Kubernetes로 옮긴다고 레거시가 자동으로 현대화되지는 않는다. WAR 안팎의 상태와 연계를 모른 채 포장만 바꾸면 장애 위치만 이동한다. 먼저 현재 운영을 재현 가능하게 만든 뒤 변경 가치가 큰 경계부터 분리한다.


유지: 안정된 WAR 패키징, 검증된 Servlet 기능
외부화: 비밀, 환경 설정, session, 업로드 파일
분리: 중복 위험 배치, 독립 확장 가능한 읽기 API
교체: 지원 종료 JDK/Tomcat, 취약·무소유 라이브러리
폐기: 사용되지 않는 context, 계정, cron, 인증서

현대화 후보는 장애 빈도, 변경 빈도, 확장 병목, 규제 위험, 담당자 부재로 순위를 매긴다. strangler 방식으로 일부 경로를 새 서비스로 보내더라도 데이터 정본, 인증, transaction, rollback 경계를 먼저 정한다. dual write는 조용한 불일치를 만들 수 있으므로 reconciliation과 중단 기준이 필요하다.

실습 32 레거시 금요일 확장 캡스톤

다음 조건을 한 번에 받는다.

  • Tomcat 9의 javax WAR와 JDK 11
  • 지원 patch upgrade 요청
  • node 두 대와 sticky session
  • JNDI pool 합계가 DB 한도에 근접
  • 새벽 정산 cron이 두 노드에 존재
  • Nginx에서 TLS 종료

변경을 patch upgrade, pool 조정, batch 단일화, session 시험 네 묶음으로 분리한다. 각 묶음에 증거, 승인, smoke, 보호 지표, rollback을 적는다. Jakarta 전환은 별도 프로젝트로 격리한다. 마지막으로 한 노드를 drain해 배포하고 외부 경로·로그인·DB·정산 fixture를 확인한 뒤 다음 노드로 진행한다.

레거시 확장 졸업 기준

  • WAR 밖 의존성을 소유자와 함께 한 장에 그릴 수 있다.
  • JDK·Tomcat·Servlet namespace·framework 호환을 행렬로 설명할 수 있다.
  • JNDI pool의 전체 connection 예산과 classloader 위치를 계산할 수 있다.
  • session·cache·파일·batch 상태가 롤링 배포에서 어떻게 움직이는지 말할 수 있다.
  • 외부 proxy 경로와 내부 health의 차이를 실제 smoke로 검증할 수 있다.
  • 노드·zone 장애에서 RTO/RPO와 데이터 일관성을 확인할 수 있다.
  • WAR 유지와 현대화 대상을 유행이 아니라 운영 증거로 나눌 수 있다.

레거시 인프라 20개 미션 워크북

각 미션은 관찰, 위험, 변경, 검증, rollback, 소유자 여섯 칸으로 기록한다. 운영 서버에 쓰기 명령을 실행하지 않고 fixture와 스테이징에서 먼저 수행한다.

  1. 외부 URL에서 DNS·TLS·proxy·context·WAR까지 요청 경로를 그린다.
  2. JDK·Tomcat·Servlet/JSP·framework·JDBC driver 버전을 고정한다.
  3. WAR SHA-256과 WEB-INF/lib, web.xml, build info를 기록한다.
  4. CATALINA_HOME과 인스턴스별 CATALINA_BASE를 구분한다.
  5. systemd unit·EnvironmentFile·실행 계정·파일 권한을 검수한다.
  6. JNDI 이름·DB·pool·driver classloader와 전체 connection 예산을 계산한다.
  7. HTTP session attribute와 직렬화·sticky·failover 경로를 시험한다.
  8. 로컬 cache·임시 파일·업로드·공유 디렉터리를 찾는다.
  9. cron·WAS timer·DB job·외부 scheduler의 중복을 제거한다.
  10. SFTP/파일 연계에 .part, 원자 이동, 멱등 key를 적용한다.
  11. Nginx host·proto·forwarded header와 신뢰 프록시를 확인한다.
  12. 내부 health, 기능 smoke, 외부 SNI 경로를 서로 다른 검사로 만든다.
  13. 한 노드를 drain하고 active request·session·batch가 빠지는지 본다.
  14. 새 WAR와 구 WAR가 같은 DB schema를 함께 사용할 수 있는지 시험한다.
  15. Manager·JMX·배포 계정의 역할과 접근망을 최소화한다.
  16. 인증서 SAN·키·체인·reload·외부 serial 확인을 반복한다.
  17. 노드 장애와 zone 장애의 capacity·RTO·RPO를 계산한다.
  18. 깨끗한 DR 환경에 아티팩트와 설정 정본으로 복구한다.
  19. 지원 종료·무소유·고장 빈도가 높은 의존성을 현대화 후보로 정렬한다.
  20. 금요일 캡스톤을 실행하고 타임라인·판정·남은 위험을 인계한다.

워크북 출간 판정표

항목 0점 1점 2점
인벤토리 WAR 파일만 일부 설정 proxy·JNDI·상태·배치·소유자까지 연결
호환성 버전 추정 버전 목록 JDK·namespace·framework 통합 시험
상태 sticky만 사용 session 확인 failover·직렬화·파일·batch 검증
배포 파일 복사 health 확인 immutable artifact·외부 smoke·rollback
보안 관리자 공유 계정 분리 최소 권한·신뢰망·비밀·감사 증거
복구 재시작 이전 WAR DB·session·DR·RTO/RPO 훈련

총점 10점 이상이고 인벤토리·상태·복구가 각각 2점이어야 현장 배포 후보로 본다. 이 표는 조직의 변경 승인과 보안 검토를 대신하지 않는다.

부록 A 30분 로컬 실습


cd war-deployment-operations
npm run qa

정상 결과는 테스트 5개, WAR 생성, 로컬 CA와 인증서 두 벌 생성, 1.0.0 sandbox 승격, 24장 웹북 생성이다. build/cert-fixture의 private key는 운영용이 아니며 npm run cert:fixture 때마다 다시 만들어진다.

실습 화면은 책에 포함된 Node.js HTTP server로 연다. 별도 Python 설치가 필요 없다.


npm start
# 브라우저: http://127.0.0.1:4182/dist/lab/index.html

Python 3가 이미 설치된 환경이라면 python3 -m http.server 4182 --directory .도 같은 용도로 쓸 수 있다. 두 서버 모두 로컬 실습용이며 외부 인터페이스에 공개하지 않는다.

파일을 file://로 열면 ES module import가 막힐 수 있다. 종료는 터미널에서 Ctrl+C다.

부록 B 운영 체크리스트

WAR

  • [ ] source commit과 승인 PR이 고정됐다.
  • [ ] build JDK, target bytecode, Tomcat/Servlet 호환성을 확인했다.
  • [ ] 테스트·취약점·secret 검사가 통과했다.
  • [ ] WAR 구조, checksum, SBOM을 보관했다.
  • [ ] 환경 설정과 비밀이 WAR 밖에 있다.
  • [ ] migration이 rollback window와 호환된다.
  • [ ] health·smoke·지표 기준과 이전 버전이 준비됐다.

인증서

  • [ ] TLS 종료 지점을 확인했다.
  • [ ] SAN, notBefore/notAfter, key, chain을 검증했다.
  • [ ] private key의 소유자와 권한이 최소화됐다.
  • [ ] 설정 테스트 뒤 reload한다.
  • [ ] SNI를 포함한 외부 handshake에서 serial을 확인한다.
  • [ ] 갱신 실패와 만료 임박 경보가 있다.
  • [ ] rollback 인증서가 유효하고 안전하다.

부록 C 공식 자료

위 링크는 2026년 8월 9일에 다시 확인했다. 당시 Apache Tomcat의 migration 페이지는 9.0.x, 10.1.x, 11.0.x를 지원 branch로 안내했고 세 branch 모두 Java 17에서 실행할 수 있다고 설명했다. 다만 애플리케이션이 실제로 요구하는 JDK·Servlet API·라이브러리 조합은 별도 검증 대상이다. major version을 올릴 때는 이전 conf를 통째로 복사하지 않고 새 버전의 기본 설정에서 필요한 차이만 이식한다.

Spring Boot의 traditional deployment 문서는 WAR 배포에서 SpringBootServletInitializer, war packaging과 embedded servlet container의 provided 범위를 요구한다. reactive WebFlux 애플리케이션은 WAR 배포 대상이 아니라는 경계도 확인한다. Tomcat 9에서 10으로 옮길 때 javax.*에서 jakarta.*로 바뀌는 것은 바이너리 호환 변경이 아니므로 소스·의존성·descriptor와 session persistence까지 staging에서 함께 검증한다.

공식 문서는 제품 버전과 운영체제에 따라 달라진다. 실제 서버의 정확한 version 문서를 변경 승인 시점에 다시 확인한다. 이 책은 공식 문장을 복제하지 않고 운영 판단과 실습을 독자적으로 구성했다.

부록 D 저작권·상표·보안

본문, 코드, fixture, SVG와 실습 화면은 이 책을 위해 새로 작성했다. Apache Tomcat, Maven, Spring, Gradle, Nginx, Certbot은 각 권리자의 명칭 또는 상표이며 제품 식별을 위해 사용했다. 제휴나 보증을 뜻하지 않는다. 외부 제품 화면과 로고, 타 도서의 표와 문장을 배포물에 포함하지 않는다.

실습 도메인은 예약된 .invalid 영역을 사용한다. 생성된 인증서와 private key는 로컬 교육용이다. 실제 조직의 인증서, 키, host, 계정, 로그를 출판 예제나 외부 AI 서비스에 입력하지 않는다.

↑ 목차로 돌아가기