프로젝트 소개
공공기관 민원 분석 대시보드를 개발하는 프로젝트였다. 수백만 건의 민원을 AI로 클러스터링해 인사이트를 제공하는 시스템이며, 외부 인터넷이 완전히 차단된 폐쇄망 환경에서 운영됐다.
사수가 CI/CD 구축을 제안했을 때 바로 하겠다고 했다. 새로운 걸 직접 해보는 걸 좋아하기도 하고, GitHub Actions나 AWS 기반 배포는 토이 프로젝트로 해본 경험이 있어서 CI/CD 개념 자체는 낯설지 않았다. 하지만 Jenkins는 처음이었다. 게다가 인터넷도 안 되는 폐쇄망이라는 조건이 붙었다.
전체 구조는 아래 이미지를 참고하면 된다. (이 글에서 나타나는 모든 코드, 네이밍 등은 사내 보안상 핵심 개념을 바탕으로 임의로 재작성한 것이다.)

삽질 1 — 폐쇄망에서 Docker 이미지를 어떻게 가져오지?
Jenkins와 SVN을 Docker로 구축하기로 했다. 그런데 폐쇄망에서는 docker pull이 안 된다. 외부 저장소에 접근할 수 없으니 당연한 거였다.
1차 시도 — 그냥 pull 해보기
docker pull jenkins/jenkins:lts
# Error response from daemon: Get "https://registry-1.docker.io/..."
# dial tcp: i/o timeout
역시 바로 막혔다. 당연한 결과였지만 일단 해봤다.
원인 파악
Docker 이미지를 받으려면 외부 레지스트리(registry-1.docker.io)에 접근해야 하는데, 폐쇄망은 이 트래픽 자체를 차단한다.
해결 — docker save/load로 USB 이동
인터넷이 되는 외부 PC에서 이미지를 파일로 저장한 뒤, USB로 옮겨서 폐쇄망 PC에서 불러오는 방식이다.
# 외부 PC에서
docker pull jenkins/jenkins:lts
docker save -o jenkins-image.tar jenkins/jenkins:lts
# jenkins-image.tar 796MB 생성
# USB로 옮긴 뒤 폐쇄망 PC에서
docker load -i jenkins-image.tar
docker images
# REPOSITORY TAG IMAGE ID
# jenkins/jenkins lts f40cc28e014b ← 정상 로드
"이미지를 파일로 저장한다고?" 반신반의했는데 USB에서 docker load를 실행했을 때 정상 동작하는 걸 보고 신기했다. Docker의 이식성이 이런 극단적인 상황에서도 제대로 작동했다.
삽질 2 — Maven 의존성은 어떻게 해결하지?
Docker 이미지 문제는 해결했다. 그런데 더 큰 문제가 남아있었다. Spring Boot 프로젝트를 빌드하려면 수백 개의 Maven 의존성이 필요한데, 인터넷이 안 되니 라이브러리를 다운로드할 수 없는 것이다.
1차 시도 — 그냥 빌드해보기
mvn clean package
# Could not resolve dependencies for project...
# Cannot access central (https://repo.maven.apache.org/maven2)
예상대로였다. Maven은 빌드할 때마다 Maven Central에서 라이브러리를 받아오려고 하는데, 당연히 막혔다.
2차 시도 — 오프라인 모드 시도
Maven에 오프라인 모드(-o)가 있다는 걸 알았다. 하지만 로컬 저장소(.m2)가 비어있으니 오프라인으로 실행해도 의존성이 없어서 똑같이 실패했다.
mvn clean package -o
# Cannot access central in offline mode
원인 파악
결국 문제는 단순했다. 라이브러리 자체가 없다. 오프라인으로 빌드하려면 .m2 로컬 저장소에 라이브러리가 미리 있어야 한다.
해결 — .m2 로컬 저장소째로 옮기기
외부 PC에서 프로젝트를 한 번 빌드해서 .m2 저장소를 가득 채운 뒤, 그 폴더 전체를 폐쇄망으로 옮겼다.
# 외부 PC에서 빌드 (의존성 다운로드)
mvn clean install
# ~/.m2/repository/ → 753MB
# 압축해서 USB로 이동
tar -czf m2-repository.tar.gz ~/.m2/repository/
# 폐쇄망 PC 압축 해제 후 settings.xml 설정
핵심은 settings.xml이다. 모든 외부 저장소 요청을 로컬 파일로 리다이렉트하도록 설정했다.
<settings>
<localRepository>/var/jenkins_home/.m2/repository</localRepository>
<mirrors>
<mirror>
<id>local-repo</id>
<url>file:///var/jenkins_home/.m2/repository</url>
<mirrorOf>*</mirrorOf>
</mirror>
</mirrors>
</settings>
mirrorOf="*"는 "모든 저장소 요청을 여기로"라는 의미다. 이렇게 하면 Maven이 외부에 나가려 해도 전부 로컬로 돌아온다. 사실상 완전한 오프라인 빌드다.
삽질 3 — SVN Webhook이 간헐적으로 동작을 안 한다
Docker와 Maven 문제는 해결했다. 이제 SVN에 코드를 커밋하면 자동으로 빌드되도록 post-commit hook을 설정했다.
증상
처음엔 잘 됐다. 커밋하면 Jenkins 빌드가 바로 트리거됐다. 그런데 가끔 커밋을 해도 빌드가 실행되지 않는 경우가 생겼다.
1차 시도 — hook 스크립트 로그 확인
# hook 로그 확인
tail -f /svn/repos/project/hooks/post-commit.log
# 실패 시 로그
curl: (7) Failed to connect to jenkins-server port 8081: Connection refused
Jenkins 서버에 연결 자체가 안 되는 경우였다.
원인 파악
폐쇄망 내부 네트워크가 간헐적으로 불안정했다. SVN 컨테이너에서 Jenkins 컨테이너로 HTTP 요청이 가끔 실패하는 것이었다. Webhook 방식은 SVN이 Jenkins를 직접 찌르는 구조라, 네트워크가 순간적으로 끊기면 그냥 빌드가 스킵된다.
해결 — Webhook + Polling 하이브리드
Webhook만으로는 불안하니, Jenkins가 주기적으로 SVN을 직접 확인하는 Polling을 백업으로 추가했다.
방식 역할
| Webhook (post-commit hook) | 커밋 즉시 빌드 트리거 (평상시) |
| Polling (30분마다) | Webhook 실패 시 보완 |
triggers {
pollSCM('H/30 * * * *') // 30분마다 SVN 변경사항 확인
}
Webhook이 실패하더라도 최대 30분 내에는 빌드가 실행되도록 보장할 수 있었다.
삽질 4 — sshpass 쓰다가 보안 문제 발견
배포 자동화를 위해 사수가 sshpass를 알려줬다. SSH 비밀번호를 스크립트에 넣어서 자동 접속하는 방식이었다.
sshpass -p 'password' scp project.war user@prod-server:/data/was/
1차 시도 — 그냥 쓰기
편하게 잘 됐다. 그런데 Jenkins 콘솔 로그를 보다가 심각한 걸 발견했다.
# Jenkins 콘솔 출력
+ sshpass -p password scp project.war user@prod-server:/data/was/
^^^^^^^^
비밀번호가 평문으로 그대로 출력됨
시스템 프로세스 목록에도 그대로 노출된다.
ps aux | grep sshpass
# sshpass -p password ssh user@prod-server
해결 — SSH 키 인증으로 교체
비밀번호가 로그에 그대로 찍히는 걸 보고 바로 찾아봤다. SSH 키 인증 방식으로 바꾸면 이 문제가 해결된다는 걸 알게 됐고, 사수에게 공유했다. 사수도 몰랐던 부분이었다.
# Jenkins 컨테이너에서 키 생성
ssh-keygen -t rsa -b 4096 -N "" -f /var/jenkins_home/.ssh/id_rsa
# 운영 서버에 공개키 등록
cat id_rsa.pub >> ~/.ssh/authorized_keys
Jenkins Credentials에 개인키를 등록하고 sshagent 블록 안에서 사용하면 비밀번호가 어디에도 노출되지 않는다.
stage('Deploy') {
steps {
sshagent(['prod-server-ssh']) {
sh 'scp project.war user@prod-server:/data/was/'
}
}
}
도입 전후 비교
항목 도입 전 도입 후
| 배포 시간 | 수동 30분 | 자동 5분 |
| 배포 실수 | 월 2~3회 | 0회 |
| 빌드 빈도 | 하루 2~3회 | 하루 20~30회 |
| 버그 발견 시점 | 배포 후 2~3일 | 커밋 후 5분 |
가장 큰 변화는 개발 리듬이었다. 예전에는 "빌드 한 번 하려면 30분 걸리니까 모아서 하자"는 식이었는데, 이제는 작은 변경도 바로 반영하고 결과를 확인할 수 있게 됐다.
마무리
3주간의 삽질 끝에 완성한 파이프라인은 지금도 잘 돌아가고 있다. 돌이켜보면 각 문제 자체는 어렵지 않았다. docker save/load도, .m2 통째로 옮기기도, SSH 키 인증도 알고 나면 단순한 방법이다.
하지만 폐쇄망이라는 제약 덕분에 Docker와 Maven이 내부적으로 어떻게 동작하는지 훨씬 깊이 이해하게 됐다. 평소엔 그냥 docker pull, mvn install 한 줄로 끝나는 것들을 말이다.
제약이 오히려 이해를 깊게 만들었다.
'트러블 슈팅' 카테고리의 다른 글
| FastAPI + PostgreSQL DB Connection Pool로 안정성 개선하기 (0) | 2026.04.09 |
|---|---|
| 사내 AI 도구 배포 시스템 구축기 — 브라우저 캐시와 행망 보안이 설치를 망쳤다 (0) | 2026.04.09 |
| OpenCode 포크했더니 @opencode-ai/plugin이 안 된다고? 나도 그랬다 (0) | 2026.03.17 |
| Docker + Bash + Python으로 이기종 DB 간 데이터 이관 자동화하기 (0) | 2026.03.16 |
| Python + Inno Setup으로 사내 AI 도구 배포 자동화하기 (0) | 2026.03.11 |