_

Always be tactful

MAIN/IMDEF

03. 이미지, 도커 파일, 도커 컴포즈란 무엇인가

택트 2025. 7. 24. 07:24

일반적으로 개발이나 DevOps 맥락에서 '이미지'라고 함은 거의 대부분 '도커 이미지'를 의미한다. (가상 머신 이미지, 디스크 이미지 같은 다른 종류의 이미지도 있긴 함). 아무튼 이미지라는 표현은 도커가 제공하는 환경의 일관성, 재현성, 경량성이라는 핵심 가치를 직관적으로 전달하기 위해 쓴다고 보면 되는데, 무슨 말인지 이해하기 어려우니 풀어서 이야기해 보자.

 

이미지라는 단어는 시각적인 스냅샷을 연상시킨다. 특정 시점의 파일 시스템과 설정 상태를 그대로 찍어낸 사진이라고 생각하자. 예를 들어 MySQL 도커 이미지라고 할 때, 특정 MySQL 버전과 그 실행에 필요한 모든 파일 및 설정이 고정된 상태로 패키징 된 스냅샷인 것이다. 특정 프로그램을 실행하는 데 필요한 모든 것을 담고 있는 읽기 전용 패키지라고 하면 될까 싶다.

 

조금 더 구체적인 예시로 'mysql:8.0'이라는 도커 이미지는 다음과 같은 내용을 담고 있을 것이다.

 

  1. MySQL 서버가 실행될 수 있는 최소한의 Linux 운영체제의 파일 시스템
  2. MySQL 8.0 버전의 모든 바이너리 파일, 라이브러리, 의존성 파일
  3. MySQL 8.0 시작 시 사용되는 기본적인 cnf 파일 (빌드 시점)
  4. 컨테이너가 실행될 때 MySQL 서버를 어떻게 시작하고 초기화할지에 대한 스크립트

 

참고로, 도커 파일은 하나의 도커 이미지를 어떻게 만들지를 정의하는 스크립트 파일이다. 도커 파일을 'docker build' 명령어를 통해 실행하면, 도커 파일에 정의된 단계에 따라 하나의 도커 이미지가 생성된다. 그것이 웹 애플리케이션이든, 백엔드 API든, 직업 개발한 프로그램의 이미지를 만들 때 도커 파일이 사용된다고 보면 된다.


 

도커 컴포즈는 여러 개의 도커 컨테이너들을 묶어서 하나의 애플리케이션으로 정의하고 관리하는 도구다. 복잡한 애플리케이션은 보통 여러 서비스(웹 서버, 백엔드 API, 데이터베이스, 캐시)로 구성되는데, 이 서비스들을 일일이 수동으로 실행하고 연결하는 것은 매우 번거롭다. 도커 컴포즈가 이 과정을 자동화해 준다고 생각하자.

 

한 번 점검하고 나니 저번에 실패했던 배포가 떠올랐다. 당시 백엔드 API를 jar 파일로 묶어서 직접 올리고 외부 DB를 사용하는 방식을 채택했는데, 이 방식 자체가 도커를 사용하지 않는 전통적인 방식이라고 한다. 이 경우 서버에 자바 런타임 환경(JRE)이 설치되어 있어야 하고, jar 파일을 복사한 후 내부적인 명령으로 직접 실행되는 느낌인 것 같다. 따라서 종속성 관리, 환경 설정, 버전 관리 등이 수동으로 이루어진다고 볼 수 있다.

 

도커 환경에서는 조금 다르다. 예를 들어 스프링 부트 애플리케이션을 개발했다면, 이 애플리케이션을 jar 파일로 빌드한 후, 이 jar 파일을 포함하는 도커 이미지를 만들게 된다. 그리고 이 과정은 보통 도커 파일을 통해 정의된다.

 

# 예시 Dockerfile (Java Spring Boot 앱)
FROM openjdk:17-jdk-slim
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

 

이렇게 만들어진 도커 이미지를 컨테이너 오케스트레이션 도구(Kubernetes, Docker Swarm)나 클라우드 서비스(AWS ECS/EKS. 구글 클라우드 Run/GKE, Azure AKS)에 배포한다. 이렇게 되면 개발 환경에서 테스트한 것과 동일한 런타임 환경이 프로덕션 서버에 배포되며, 이에 따라 '로컬에서는 되는데 서버에서는 안 되는 문제'가 사라질 것으로 예상된다.

 

새로운 버전의 이미지를 배포하거나, 문제가 발생했을 때 이전 버전의 이미지로 롤백하는 것이 훨씬 간편하다는데 이 부분에 대해서는 아직 차이를 잘 모르겠다. jar 파일로 직접 올리는 방식도 깃에서 버전관리가 되고 있기 때문에 큰 상관없지 않나?

 

더보기

제미나이에게 물어보니, Git을 통한 버전 관리와는 다른 차원에서 설명된다고 한다. jar 파일을 직접 배포하는 방식에서 롤백하려거든 Git에서 이전 버전의 코드를 체크아웃하고, 다시 빌드해서 이전 버전의 jar 파일을 생성해야 한다. 이후 서버에 접속하고, 현재 실행 중인 애플리케이션 프로세스를 수동으로 찾아 종료한 뒤, 기존 파일을 덮어쓰는 등 교체를 진행할 것이다.

 

반면, 도커를 사용하면 롤백 과정이 훨씬 간소화된다. 도커 이미지는 기본적으로 버전별 '태그'가 붙는데, 바로 이 태그가 핵심이다. 컨테이너 오케스트레이션 도구는 기본적으로 선언적 배포 방식을 사용하는데, 이것이 태그와 연계되어 기존 컨테이너를 종료하고 새로운 버전의 컨테이너를 띄우는 것이다.

 

결국 핵심 차이는 자동화와 선언성이다. jar 파일 방식은 개발자가 어떻게 롤백할지를 일일이 명령해야 하는 반면, 도커 이미지 방식은 무엇을 배포할지만 지시하면 시스템이 알아서 처리한다. Git이 소스 코드의 버전을 관리하는 것처럼, 도커 이미지는 실행 가능한 애플리케이션 패키지의 버전을 관리한다고 보면 된다. 즉, 소스 코드를 이전 버전으로 돌리는 것과, 이미 빌드되어 바로 실행 가능한 패키지를 이전 버전으로 돌리는 것에는 큰 차이가 있다.

 

아무튼 감히 과거 삽질에 대해 추측해 보자면, 당시 내가 로컬 환경에서 정의한 것과 Azure에서 자체적으로 정의한 환경이 달라서 발생한 이슈이지 않을까 싶다. 어쩌면 도커 파일을 통해 이미지를 생성해 클라우드 서비스에 배포했더라면 문제가 해결될지도 모르겠다.

 

Azure에서는 데이터베이스를 위한 별도의 관리형 서비스를 제공한다. 모든 관리 부담을 공급자인 마이크로소프트가 대신 처리해 주기 때문에 개발자는 온전히 애플리케이션 개발에 몰두할 수 있게 된다. 이것이 클라우드 환경에서 DB를 운영하는 가장 일반적이고 효율적인 방법이다. 따라서 도커 컨테이너 내부 DB를 로컬 단계에서 빠르게 구축하고 테스트해 보되, 실제 서비스가 동작하는 프로덕션 환경은 'Azure Database for MySQL'로 위임하자.


 

배포는 당분간 건들지 않겠다던 며칠 전 나와의 약속을 벌써 깨트렸다. 나름 변명을 해 보자면, 과거에 실패했던 원인을 알 것 같아서, 금방 해결할 수 있을 것 같아서였다. 결국 이번에도 해결하지 못했지만 말이다. 아래는 미래의 내가 참고할 수 있도록 오늘 있었던 일을 문서화시킨 내용이다.


 

Azure App Service와 MySQL Flexible Server 연결 문제 해결 기록

 

이 문서는 Azure App Service에 배포된 Spring Boot 애플리케이션이 Azure Database for MySQL Flexible Server에 연결하는 과정에서 발생했던 문제들과 해결 시도들을 시간순으로 정리합니다.

 

1. 초기 배포 후 데이터베이스 연결 불가

Spring Boot 백엔드 애플리케이션을 Azure App Service에 배포한 후, 데이터베이스 연결 오류가 발생했습니다.

 

[초기 오류 메시지] 앱 로그에는 다음 메시지가 반복되었습니다:

java.net.SocketTimeoutException: Connect timed out
java.sql.SQLException: Access denied for user 'funczun@utact-mysql-db'@'52.xxx.xxx.xxx'

 

[문제 진단 1]

네트워크 방화벽 문제: App Service가 MySQL 서버에 접근하는 것이 차단되었을 가능성

인증 정보 문제: 데이터베이스 사용자 이름 또는 비밀번호가 잘못되었을 가능성

 

[해결 시도 1]

- MySQL Flexible Server 방화벽 설정:

Azure Portal에서 utact-mysql-db MySQL 서버로 이동하여 '네트워킹' 섹션에서 'Azure 내의 모든 Azure 서비스 허용'을 활성화했습니다.

문제 해결을 위해 임시로 0.0.0.0/0 (모든 IP 허용) 방화벽 규칙도 추가했습니다.

결과: 오류가 지속되어 방화벽 문제가 아님을 확인했습니다.

 

- App Service 환경 변수 및 MySQL 비밀번호 확인:

App Service '구성' > '애플리케이션 설정'에서 MYSQL_HOST, MYSQL_DATABASE, MYSQL_USERNAME, MYSQL_PASSWORD 환경 변수 값을 재확인하고, 오타나 공백이 없는지 꼼꼼히 검토했습니다. MySQL 서버의 비밀번호를 단순한 것으로 재설정한 후, App Service 환경 변수에도 다시 입력했습니다.

결과: 오류가 지속되어 인증 정보 문제가 아님을 확인했습니다.

 

2. Hibernate Dialect 문제 해결

초기 연결 오류 지속 중, 로그에서 새로운 유형의 오류가 나타났습니다.

 

[관련 오류 메시지]

org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.connections.spi.ConnectionProvider]
Caused by: org.hibernate.tool.schema.extract.spi.SchemaExtractionException: Unable to determine Dialect without a JDBC connection

 

[문제 진단 2]

이 오류는 Hibernate가 데이터베이스 연결 없이 올바른 SQL 방언(Dialect)을 결정하지 못해 발생했습니다. Spring Boot 3.x와 MySQL 8.x 조합에서는 MySQL8Dialect를 명시적으로 지정해야 합니다.

 

[해결 시도 2]

- application.yml에 MySQL8Dialect 명시:

spring:
  # ... (기존 설정)
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect

 

- JAR 파일 재빌드 및 재배포:

Eclipse Gradle bootJar 태스크로 새 JAR 파일을 빌드했습니다. az webapp deploy 명령어로 새 JAR 파일을 Azure App Service에 배포했습니다. 배포 후 App Service를 수동으로 재시작했습니다.

결과: determineDialect 오류는 더 이상 나타나지 않았습니다.

 

3. TLS/SSL 연결 불일치 문제 해결

determineDialect 오류 해결 후에도 Connect timed out과 Access denied 오류가 지속되었습니다.

 

[문제 진단 3]

MySQL Flexible Server의 '네트워킹' 설정에서 "TLS/SSL 연결 적용"이 "필수(REQUIRED)"임을 확인했습니다. 이는 MySQL 서버가 SSL 연결을 강제함을 의미합니다. 하지만 application.yml의 JDBC URL에는 useSSL=false가 설정되어 있었습니다. 이 불일치가 문제의 원인이었습니다.

 

[해결 시도 3]

- application.yml SSL 설정 수정:

useSSL=false를 useSSL=true&requireSSL=true&enabledTLSProtocols=TLSv1.2,TLSv1.3로 변경하여 앱이 SSL을 사용하도록 설정했습니다. (Azure MySQL Flexible Server는 공용 CA 인증서를 사용하므로, 별도로 인증서를 다운로드하여 앱에 포함시킬 필요는 없습니다.)

spring:
  datasource:
    url: jdbc:mysql://${MYSQL_HOST}:3306/${MYSQL_DATABASE:mydatabase}?useSSL=true&requireSSL=true&enabledTLSProtocols=TLSv1.2,TLSv1.3&serverTimezone=UTC&allowPublicKeyRetrieval=true

 

- JAR 파일 재빌드 및 재배포:

수정된 application.yml을 포함하도록 bootJar 태스크로 다시 빌드했습니다. az webapp deploy 명령어로 새 JAR 파일을 배포했습니다. 배포 후 App Service를 수동으로 재시작했습니다.

결과: Connect timed out과 Access denied 오류가 더 이상 직접적으로 나타나지 않았습니다. 이는 큰 진전이었습니다.

 

4. HikariCP 초기화 및 URL 파라미터 문제 (그리고 확인된 사실)

TLS/SSL 설정을 맞춘 후, 앱 컨테이너는 여전히 시작되지 못하고 새로운 유형의 오류를 출력했습니다.

 

[관련 오류 메시지]

at com.zaxxer.hikari.pool.PoolBase.newPoolEntry(PoolBase.java:205)
at com.zaxxer.hikari.pool.HikariPool.createPoolEntry(HikariPool.java:483)
at com.zaxxer.hikari.pool.HikariPool.checkFailFast(HikariPool.java:571)
...
Site container: utact-message-api terminated during site startup.

 

[문제 진단 4]

이 오류는 HikariCP (데이터베이스 커넥션 풀)가 초기 연결을 맺는 과정에서 실패했음을 나타냅니다. 데이터베이스와의 통신 채널(네트워크, SSL/TLS)은 열렸지만, JDBC URL의 특정 파라미터가 HikariCP 또는 MySQL Connector/J 드라이버의 특정 버전과 충돌하거나 불필요한 옵션일 수 있다고 판단했습니다. 특히 serverTimezone=UTC와 allowPublicKeyRetrieval=true는 MySQL Connector/J 8.x에서 불필요하거나 문제를 일으킬 수 있는 옵션으로 판단했습니다.

 

[해결 시도 4]

- application.yml URL 간소화 및 안정성 옵션 추가 시도:

문제 가능성이 있는 옵션들을 제거하기 위해 application.yml의 spring.datasource.url에서 serverTimezone=UTC와 allowPublicKeyRetrieval=true를 제거하고, autoReconnect=true&failOverReadOnly=false&maxReconnects=10와 같이 연결 안정성을 높이는 옵션을 추가하여 시도했습니다. useSSL=true&requireSSL=true만 남기고 다른 모든 옵션을 제거한 최대한 간소화된 URL로도 시도했습니다.

 

- JAR 파일 재빌드 및 재배포:

수정된 application.yml을 포함하도록 bootJar 태스크로 다시 빌드했습니다. az webapp deploy 명령어로 새 JAR 파일을 배포했습니다. 배포 후 App Service를 수동으로 재시작했습니다.

 

[확인된 사실 및 현재 상태]

위와 같은 HikariCP 오류 관련 해결 시도(URL 파라미터 조정)도 문제를 해결하지 못했습니다. 이는 초기화 관련 문제나 URL 파라미터 문제가 아니라는 것을 확인했습니다. 모든 가능한 코드 및 설정 변경을 시도했음에도 불구하고, 여전히 앱 컨테이너가 데이터베이스 연결 단계에서 실패하고 종료되고 있습니다.


 

이미지, 도커 파일, 도커 컴포즈에 대해 간략히 알아보았다. 내일은 스프링 이니셜라이저를 사용해 프로젝트를 생성하고 본격적인 개발에 돌입하겠다.