Big Project · AI's EYE 37
Docker Python 앱 충돌: 런타임 격리와 임포트 네임스페이스의 진실
Docker container가 서로 분리돼 있다면 각 서비스가 같은 Python package 이름을 사용해도 항상 안전해 보인다. 왜 CI와 합쳐진 테스트에서 문제가 되는지 궁금하다.
근거 · 프로젝트 문서 · 코드 · 테스트
1장: CI 통합 테스트에서 드러난 의문의 ‘No module named…’
솔라의 손가락이 노트북 트랙패드 위에서 멈췄다. 화면 가득한 CI/CD 파이프라인 로그의 맨 끝, 선명한 붉은색으로 빛나는 FAILED라는 단어 때문이었다. 몇 번을 다시 실행해도 결과는 같았다.
“이럴 리가 없는데…”
나지막한 혼잣말이 정적을 깼다. 문제는 새로 추가된 마이크로서비스 AICC와 기존 API 서비스의 통합 테스트 단계에서 발생했다. 각 서비스는 완벽하게 독립된 도커(Docker) 컨테이너 안에서 실행되도록 설계되었다. 솔라는 당연히 한 컨테이너의 내부 사정이 다른 컨테이너에 영향을 줄 리 없다고 믿었다. 컨테이너 기술의 핵심은 바로 그 ‘격리’에 있으니까.
하지만 로그는 솔라의 믿음을 비웃고 있었다.
ERROR: aicc.tests.test_integration - No module named 'app.main'
“모듈이 없다고? app.main이 왜 없어. AICC 서비스의 메인 애플리케이션인데.”
솔라는 고개를 저었다. 더 이상한 점은 오류가 날 때도, 나지 않을 때도 있다는 점이었다. 마치 동전 던지기 같았다. 무언가 잘못된 건 분명했지만, 그 원인이 안갯속처럼 희미했다. 이 문제를 팀에 공유하기 전에, 스스로 납득할 만한 가설이라도 세우고 싶었다.
그때, 조용히 다가온 루나가 솔라의 화면을 함께 들여다보았다. 루나는 복잡한 상황을 단순한 질문으로 정리하는 데 능숙했다.
“계속 같은 곳에서 실패하네.”
“언니, 이거 좀 봐. 말이 안 돼. API 서비스랑 AICC 서비스는 각자 다른 도커 컨테이너에서 돌잖아. 개발할 때도, 로컬에서 실행할 때도 아무 문제 없었어. 근데 통합 테스트만 돌리면 AICC 서비스가 자기 자신의 app.main 모듈을 못 찾겠대. 웃긴 건 API 서비스도 패키지 이름이 app이거든. 하지만 컨테이너가 다른데 무슨 상관이야? 서로의 존재도 모를 텐데.”
솔라의 목소리에는 확신과 혼란이 뒤섞여 있었다. 도커의 격리라는 강력한 방화벽을 굳게 믿고 있었기에, 눈앞의 현상은 마치 유령을 본 것처럼 비현실적이었다.
루나는 잠시 생각에 잠기더니, 화면의 특정 부분을 가리키는 대신 다른 질문을 던졌다.
“그 테스트가 실패한 곳이 정확히 어디지? 솔라 네가 실행한 컨테이너 안이야?”
“아니, CI 서버의 파이프라인에서. 통합 테스트 단계.”
“그럼 그 통합 테스트는 어떻게 실행되는데? 그 CI 잡(job)의 로그를 처음부터 한번 같이 볼까? 에러 메시지 말고, 그 일이 벌어지기까지의 과정을.”
루나의 말에 솔라는 스크롤을 맨 위로 올렸다. 익숙한 명령어들이 차례로 눈에 들어왔다. 두 개의 서비스 소스 코드를 내려받는 git clone, 파이썬 의존성을 설치하는 pip install… 그리고 테스트를 실행하는 pytest 명령어까지.
로그를 한 줄 한 줄 따라 내려가던 솔라의 눈이 한 지점에서 커졌다.
+ pytest tests/
“잠깐만… 이거…”
솔라는 마침내 무언가 이상한 점을 발견했다. CI 서버는 통합 테스트를 위해, API 서비스의 소스 코드와 AICC 서비스의 소스 코드를 하나의 작업 공간에 모두 내려받고 있었다. 그리고 테스트 실행기인 pytest는 그 두 프로젝트의 소스 코드가 모두 존재하는 ‘하나의 파이썬 환경’ 위에서 실행되고 있었다.
컨테이너라는 개별적인 방에서 각자 살던 서비스들이, ‘통합 테스트’라는 이름의 운동장에 모두 불려 나온 셈이었다. 운동장에서는 app이라는 이름을 가진 팀이 둘이 될 수 없었다. 파이썬 인터프리터는 경로에서 먼저 발견된 app 패키지만을 기억할 뿐, 그게 API 서비스의 것인지 AICC 서비스의 것인지 구분하지 못했다. 만약 API의 app 패키지가 먼저 로드되었다면, AICC 테스트 코드가 app.main을 찾으려 할 때 당연히 실패할 수밖에 없었다. AICC의 main 모듈은 API의 app 패키지 안에는 존재하지 않으니까.
“아…”
짧은 탄식이 솔라의 입에서 흘러나왔다. 머릿속에 흩어져 있던 퍼즐 조각들이 제자리를 찾는 순간이었다.
“알겠다. 도커 컨테이너 안에서 각자 실행될 땐 격리되니까 문제가 없었어. 하지만 CI 서버의 통합 테스트 환경은… 두 서비스의 소스 코드를 한곳에 모아놓고 테스트를 실행하는구나. 이건 더 이상 격리된 환경이 아니야. 말 그대로 ‘같은 파이썬 환경’이었던 거야.”
솔라는 다시 붉은색 에러 메시지를 보았다. 처음에는 이해할 수 없는 외계어 같았던 문장이, 이제는 너무나 명확한 사실을 설명해주고 있었다.
API와 AICC가 모두 app 패키지를 쓰자 같은 Python 환경에서 먼저 잡힌 쪽 때문에 No module named 'app.main'이 발생했다.
이 문장은 더 이상 버그 리포트가 아니라, 자신이 놓치고 있던 사실에 대한 명료한 진단서였다. 도커의 ‘런타임 격리’라는 개념에만 매몰되어, CI/CD 파이프라인이 만드는 ‘통합 테스트 환경’이라는 또 다른 맥락을 보지 못했던 것이다.
문제의 직접적인 원인은 파악했다. 하지만 근본적인 질문이 뒤따랐다. 그렇다면 ‘격리’는 대체 어디까지 믿어야 하는 걸까? 컨테이너가 제공하는 런타임의 경계와, 파이썬이 모듈을 읽어 들이는 임포트의 경계는 왜 이렇게 다르게 작동하는 걸까? ‘같은 파이썬 환경’이라는 유령의 실체는 무엇이며, 어떻게 이 보이지 않는 벽을 다뤄야 할까?
2장: 런타임 격리와 임포트 네임스페이스: 다른 종류의 경계
솔라의 책상 위에는 더 이상 CI 파이프라인 로그가 열려 있지 않았다. 그 대신, 솔라는 자신의 노트북에 새로운 디렉터리 두 개를 만들었다. 하나는 project_api, 다른 하나는 project_aicc. 그리고 각 디렉터리 안에, 똑같이 app이라는 이름의 하위 폴더를 생성했다. 마치 어젯밤의 문제를 축소 재현하는 작은 무대 세트 같았다. project_api/app 안에는 __init__.py 파일을 만들어 SERVICE_NAME = "API" 라는 코드를 넣었고, project_aicc/app 안의 파일에는 SERVICE_NAME = "AICC" 라고 적었다.
물리적으로 분리된 두 개의 폴더. 도커 컨테이너가 그랬던 것처럼, 이 둘은 서로 다른 공간에 존재했다. 하지만 어제 CI 서버에서 벌어진 일은 이 명백한 분리가 어떤 조건에서는 무너진다는 것을 보여주었다. 솔라는 터미널 창을 열고, 자신이 만든 두 프로젝트 폴더를 나란히 화면에 띄웠다. 이 ‘같은 파이썬 환경’이라는 유령의 실체를 직접 붙잡아보고 싶었다.
“언니, 내가 어제 문제를 이렇게 재현해봤어.”
솔라가 화면을 가리키자, 커피를 들고 다가온 루나가 조용히 옆에 섰다.
“API 프로젝트에도 app 패키지가 있고, AICC 프로젝트에도 app 패키지가 있어. 각자 자기 폴더 안에 있으니 완벽히 분리된 것처럼 보여. 도커 컨테이너처럼 말이야. 그런데 CI 서버는 이 두 프로젝트를 한 공간에 놓고 테스트를 돌렸고, 파이썬은 둘 중 하나만 ‘진짜’ app으로 인식했지. 여전히 궁금한 건, 파이썬은 대체 어떻게 이 둘을 헷갈리는 걸까? 눈앞에 경로가 다 보이는데 말이야.”
솔라의 질문은 더 이상 ‘왜 실패했는가’가 아니었다. ‘어떤 원리로 그렇게 작동하는가’로 바뀌어 있었다.
루나는 고개를 끄덕이며 솔라의 노트북 키보드를 부드럽게 가리켰다.
“그럼 파이썬에게 직접 물어보자. 어떻게 세상을 보는지.”
루나는 설명 대신 행동을 제안했다. 솔라는 그 의도를 알아채고 파이썬 인터프리터를 실행했다. 하얀 커서가 깜박이는 새로운 프롬프트가 나타났다.
“먼저 파이썬이 모듈을 찾을 때 뒤져보는 경로 목록을 확인해볼까?”
솔라는 루나의 말에 따라 코드를 입력했다.
>>> import sys
>>> for path in sys.path:
... print(path)
...
/usr/local/lib/python3.9
/usr/local/lib/python3.9/lib-dynload
...
익숙하지만 깊게 생각해본 적 없는 경로들이 화면을 채웠다. 파이썬이 기본적으로 참조하는 라이브러리 폴더들이었다.
“여기에 우리가 만든 프로젝트 폴더를 추가하면, 파이썬의 ‘세상’에 우리 프로젝트가 포함되는 거야. CI 환경이 하는 일이 바로 이거지.”
루나의 말에 따라, 솔라는 먼저 project_api의 경로를 파이썬의 검색 경로 목록 맨 앞에 추가했다.
>>> sys.path.insert(0, '/path/to/your/projects/project_api')
이제 이 파이썬 인터프리터는 project_api 폴더 안을 들여다볼 수 있게 되었다. 솔라는 약간의 긴장감과 함께 app 모듈을 임포트했다.
>>> import app
>>> print(app.SERVICE_NAME)
API
예상대로였다. 파이썬은 project_api/app을 정확히 찾아냈다. 진짜 실험은 이제부터였다. 솔라는 이어서 project_aicc의 경로도 목록에 추가했다. 어제의 CI 환경처럼, 두 개의 app 패키지가 모두 접근 가능한 상태를 만드는 것이다.
>>> sys.path.insert(0, '/path/to/your/projects/project_aicc')
>>> import app
>>> print(app.SERVICE_NAME)
API
“어?”
솔라의 입에서 의아한 소리가 나왔다. 분명 project_aicc 폴더를 검색 경로 맨 앞에 추가해서, 이번에는 “AICC”라는 결과가 나올 거라고 기대했다. 하지만 결과는 여전히 “API”였다.
루나가 조용히 한마디 거들었다.
“파이썬은 게으른 독서가 같아. 한번 읽은 책은 다시 도서관에 가서 찾지 않고, 자기 책상 위에 올려두고 계속 보거든. 그 책상이 sys.modules야.”
솔라는 곧바로 sys.modules에 app이 있는지 확인했다.
>>> 'app' in sys.modules
True
>>> loaded_app = sys.modules['app']
>>> loaded_app.__file__
'/path/to/your/projects/project_api/app/__init__.py'
명백했다. 파이썬은 처음 import app을 실행했을 때 project_api에서 찾은 app 모듈을 sys.modules라는 캐시에 저장했다. 그 후로는 sys.path가 어떻게 바뀌든, 캐시에 있는 모듈을 먼저 꺼내 쓸 뿐이었다. 검색 경로 목록에서 project_aicc를 먼저 찾도록 순서를 바꿔준 것은 아무 소용이 없었다.
솔라는 모든 걸 지우고 처음부터 다시 시작했다. 새로운 인터프리터를 열고, 이번에는 처음부터 project_aicc 경로를 먼저 추가했다.
# (새로운 파이썬 인터프리터 실행)
>>> import sys
>>> sys.path.insert(0, '/path/to/your/projects/project_aicc')
>>> sys.path.insert(1, '/path/to/your/projects/project_api')
>>> import app
>>> print(app.SERVICE_NAME)
AICC
“아…”
이제야 모든 것이 명확해졌다. 솔라는 고개를 끄덕였다. 도커가 제공하는 ‘런타임 격리’는 파일 시스템, 네트워크, 프로세스 같은 운영체제 수준의 자원을 분리하는 벽이었다. 하지만 파이썬의 ‘임포트 네임스페이스’는 전혀 다른 규칙으로 작동했다. 그 규칙은 sys.path라는 평평한 목록의 순서와, sys.modules라는 한번 본 것을 기억하는 캐시, 이 두 가지가 전부였다. 두 개의 다른 벽, 다른 종류의 경계였던 것이다.
솔라는 중얼거렸다. “컨테이너라는 방은 달라도, ‘파이썬’이라는 언어 자체의 규칙이 우선이었던 거네. CI 서버는 그저 두 프로젝트를 파이썬의 규칙이 적용되는 하나의 운동장에 풀어놓은 것뿐이고.”
runtime 격리와 source·test import namespace는 다른 경계이며
머릿속에서 맴돌던 문장이 비로소 온전히 제 자리를 찾았다. 이제 sys.path와 sys.modules를 들여다보면, 어떤 상황에서든 파이썬이 어떤 모듈을 참조할지 정확히 예측할 수 있다는 자신감이 생겼다. 이것이 바로 ‘Python 임포트 경로 분석’이라는 보이지 않는 도구를 손에 넣은 순간이었다.
그렇다면 이 문제의 해결책은 명백해 보였다. 파이썬이 헷갈리지 않게 처음부터 다른 이름을 주면 되는 거 아닐까? 하지만 그게 최선일까? 단순히 이름만 바꾼다고 이 복잡한 통합 환경의 문제가 모두 해결될까? 근본적인 이유는 무엇일까? 새로운 질문이 꼬리를 물고 이어졌다.
3장: 모듈 이름 충돌 해결: 고유한 서비스 패키지 명명
솔라의 책상 위는 어제의 실험 흔적이 그대로 남아 있었다. project_api와 project_aicc 폴더가 나란히 놓여 있었다. 어제 그녀는 이 두 폴더와 파이썬 인터프리터를 이용해, 도커의 런타임 격리와 파이썬의 임포트 네임스페이스가 서로 다른 경계임을 확인했다. 파이썬은 sys.path라는 지도와 sys.modules라는 기억에 의존할 뿐, 폴더 구조나 컨테이너 같은 물리적 경계에는 관심이 없다는 사실을 깨달았다.
그렇다면 해결책은 하나뿐인 것 같았다. 파이썬이 헷갈리지 않도록, 처음부터 다른 이름을 주는 것.
솔라는 키보드에 손을 올렸다. 더 이상 가설을 세우며 고민할 필요는 없었다. 직접 부딪혀서 확인하면 될 일이었다. 그녀는 터미널을 열어 망설임 없이 명령어를 입력했다.
mv project_aicc/app project_aicc/aicc
project_aicc 폴더 안에 있던 app이라는 이름의 디렉터리가 aicc로 바뀌는 것은 순식간이었다. 이제 project_api 폴더에는 app이, project_aicc 폴더에는 aicc가 존재했다. 이름이 충돌할 여지가 사라진 것이다.
솔라는 어제와 똑같은 실험을 다시 시작했다. 이것은 더 이상 원인을 파악하기 위한 실험이 아니라, 자신의 해결책을 검증하기 위한 실험이었다. 그녀는 새로운 파이썬 인터프리터를 열고, 두 프로젝트의 경로를 모두 sys.path에 추가했다. 이제 CI 서버의 통합 테스트와 동일하게, 파이썬이 두 프로젝트 소스를 모두 볼 수 있는 환경이 만들어졌다.
>>> import sys
>>> # 경로를 정확히 지정해야 합니다.
>>> sys.path.insert(0, '/path/to/your/projects/project_api')
>>> sys.path.insert(1, '/path/to/your/projects/project_aicc')
심호흡을 한번 하고, 솔라는 먼저 API 서비스의 모듈을 임포트했다.
>>> import app
>>> print(app)
<module 'app' from '/path/to/your/projects/project_api/app/__init__.py'>
성공. 어제와 같이 project_api의 app 모듈을 정확히 가져왔다. 이제 관건은 aicc였다. 어제라면 import app을 다시 시도하고 캐시된 모듈 때문에 좌절했겠지만, 오늘은 달랐다.
>>> import aicc
>>> print(aicc)
<module 'aicc' from '/path/to/your/projects/project_aicc/aicc/__init__.py'>
명백한 성공이었다. 파이썬 인터프리터는 두 개의 모듈을 아무런 혼동 없이 각각 인식하고 있었다. sys.modules 캐시를 들여다보아도 app과 aicc는 사이좋게 각자의 자리를 차지하고 있었다.
“봤지, 언니?”
솔라가 의기양양하게 옆에서 지켜보던 루나를 향해 말했다.
“그냥 이름만 바꿔주니까 해결되네. app이랑 aicc 둘 다 문제없이 임포트 돼. 문제가 너무 간단해서 허무할 정도야. 그냥 이름만 다르면 되는 거였어.”
솔라의 말에는 문제를 해결했다는 뿌듯함과 동시에, 해결책이 너무 단순했다는 약간의 김빠짐이 섞여 있었다.
루나는 솔라의 화면을 잠시 들여다보더니, 고개를 끄덕였다. 하지만 “정답이야”라고 말하는 대신 다른 질문을 던졌다.
“간단한 해결책이 아니라, 가장 근본적인 해결책일 수도 있지. 솔라, 넌 지금 AICC 서비스에 뭘 준 거지? 이전엔 없었던 걸 말이야.”
”…이름?”
솔라는 잠시 생각하다 대답했다. 루나는 대답 없이 솔라를 바라볼 뿐이었다. 솔라는 자신의 대답이 불충분했음을 깨닫고 다시 생각에 잠겼다. ‘이름’이라는 단어 주변을 맴돌던 생각들이 하나의 핵심으로 모여들었다.
“아니, 그냥 이름이 아니야. ‘고유한 이름’. 파이썬의 세상, 그러니까 저 sys.path와 sys.modules로 이루어진 평평한 운동장 안에서, 다른 누구와도 겹치지 않는 주소 같은 거. 이 서비스만의 식별자를 준 거야.”
바로 그거였다. 도커 컨테이너가 런타임 환경에 ‘격리’라는 경계를 만들어주듯, aicc라는 고유한 패키지 이름은 파이썬의 ‘임포트 네임스페이스’에 명확한 경계를 그어준 것이다. app이라는 흔한 이름은 마치 서울의 ‘김 서방’과 같았다. 하지만 aicc는 이 세상에 단 하나뿐인, 서비스와 일치하는 고유한 이름표였다. 이제 통합 테스트 환경이라는 거대한 운동장에 모든 서비스의 소스 코드를 풀어놓아도, 파이썬은 각자의 이름표를 보고 정확히 주인을 찾아갈 수 있게 되었다.
솔라는 자신이 얻은 깨달음이 단순한 버그 수정 기술이 아님을 직감했다. 이것은 여러 서비스가 협력하는 환경을 설계하는 하나의 원칙, ‘서비스별 패키지 명명 규칙’이었다.
API와 AICC가 모두 app 패키지를 쓰자 같은 Python 환경에서 먼저 잡힌 쪽 때문에...
솔라는 처음 자신을 혼란에 빠뜨렸던 바로 그 문장을 떠올렸다. 이제 그 문장은 실패의 원인 분석이 아니라, 이 원칙을 어겼을 때 어떤 일이 벌어지는지를 알려주는 명확한 증거처럼 보였다.
“좋아. 이제 패키지 이름 충돌은 완벽하게 막았어. 이걸 팀에 제안해서 우리 프로젝트의 모든 서비스에 적용해야겠어. API는 api, 결제는 payment처럼 말이야.”
솔라는 자신감에 차서 키보드를 두드렸다. 검증용으로 만들었던 폴더들을 정리하고, 실제 프로젝트의 docker-compose.yml 파일을 열었다. 이제 이 명료해진 경계를 가지고 전체 시스템을 다시 바라볼 수 있을 것 같았다.
파일을 들여다보던 솔라의 손가락이 문득 멈췄다.
“그런데 언니, 임포트 충돌은 해결됐는데… 그럼 이제 AICC 서비스가 API 서비스를 호출해야 할 때는 주소를 어떻게 적어야 하지? 내 테스트 코드에는 그냥 http://localhost:8000 라고 되어 있는데, 이건 내 컴퓨터에서나 통하는 얘기잖아. 컨테이너들끼리는 서로를 어떻게 찾아야 하는 거지? 이 이름표가 거기까지 알려주진 않는 것 같은데.”
임포트 네임스페이스의 경계는 명확해졌지만, 네트워크라는 또 다른 경계의 문제가 수면 위로 떠오르고 있었다.
4장: 호스트 IP 대신 Compose DNS: Docker 내 서비스 통신
솔라의 손가락이 docker-compose.yml 파일 위에서 멈췄다. 어제까지만 해도 혼돈의 원인이었던 이 파일은 이제 명료한 질서의 상징처럼 보였다. 각 서비스의 패키지 이름을 고유하게 만드는 ‘서비스별 패키지 명명 규칙’을 적용하자 파이썬 임포트 충돌이라는 유령은 자취를 감췄다. api는 api답게, aicc는 aicc답게 각자의 코드 공간에서 고유한 정체성을 가졌다.
하지만 솔라의 시선은 파일의 다른 부분, 서비스 간의 관계를 암시하는 구조에 머물러 있었다. api 서비스와 aicc 서비스가 나란히 정의된 모습을 보니, 또 다른 질문이 고개를 들었다. 이들은 이제 각자의 방에 고유한 이름표를 붙였지만, 서로 대화가 필요할 땐 어떻게 상대방의 방문을 두드려야 할까? 임포트의 세계가 아닌, 네트워크의 세계에서는 어떤 주소를 사용해야 하는 걸까.
솔라는 aicc 서비스의 통합 테스트 코드 한 구석에 주석으로 남겨둔 한 줄을 떠올렸다.
# response = requests.get("http://localhost:8000/some-endpoint") # <-- 이게 맞나?
localhost. 자신의 노트북에서 개발할 때는 마법처럼 통했던 주소다. 하지만 이제 aicc 컨테이너 안에서 이 코드가 실행된다면, localhost는 api 컨테이너가 아닌 aicc 컨테이너 자신을 가리킬 것이다. 문을 두드렸더니 자기 자신이 나오는 셈이다.
“결국 IP 주소를 직접 알아내야 하는 건가?”
솔라는 중얼거리며 터미널에 docker ps를 입력해 실행 중인 컨테이너 목록을 보았다. api 컨테이너의 ID를 복사해서 docker inspect 명령어로 내부 IP 주소를 알아낼 수는 있었다. 172.19.0.2. 하지만 이 숫자를 코드에 박아 넣는 것은 끔찍하게 느껴졌다. 컨테이너를 다시 시작할 때마다 IP 주소는 바뀔 수 있었다. 바뀔 때마다 코드를 수정하고 다시 빌드해야 한다면, 그건 자동화가 아니었다.
그때, 솔라의 고민을 들여다보던 루나가 조용히 화면을 가리켰다. 그녀가 가리킨 것은 터미널의 IP 주소가 아니라, 솔라가 방금 전까지 보고 있던 docker-compose.yml 파일이었다.
“솔라, 거기서 aicc가 api를 부를 때 뭐라고 부르고 싶어?”
“응? api 서비스라고 부르고 싶지.”
“파일에 그렇게 써 있잖아.”
루나의 말은 지극히 단순했다. 솔라는 다시 파일의 내용을 들여다보았다.
services:
api:
build: ./project_api
ports:
- "8000:8000"
aicc:
build: ./project_aicc
depends_on:
- api
services: 아래에 api와 aicc라는 이름이 선명하게 보였다. 솔라는 이전 장에서 파이썬의 임포트 문제를 해결하기 위해 패키지에 aicc라는 고유한 이름표를 붙여주었던 것을 기억했다. 혹시 도커의 세계에도 비슷한 규칙이 있는 걸까?
“설마… 서비스 이름이 그냥 주소라고?”
솔라의 목소리에 반신반의하는 기색이 역력했다. 너무 간단해서 오히려 믿기 어려운 가설이었다. IP 주소, 포트 포워딩, 네트워크 설정 같은 복잡한 개념들 대신, 그냥 api라고 부르면 된다니.
루나는 대답 대신 솔라의 키보드를 향해 눈짓했다. 직접 확인해보라는 뜻이었다.
솔라는 새로운 실험을 설계했다. docker-compose exec 명령어를 사용하면 실행 중인 컨테이너 안으로 들어가 명령을 내릴 수 있었다. aicc 컨테이너 안으로 들어가서 api 컨테이너에 네트워크 요청을 보내보는 것이다.
docker-compose exec aicc /bin/sh
프롬프트가 바뀌며, 솔라는 이제 aicc 컨테이너의 세상 안으로 들어왔다. 여기서 localhost는 aicc 자신이다. IP 주소는 언제 바뀔지 모른다. 그렇다면 남은 건 단 하나의 가설.
솔라는 침을 삼키고 키보드를 두드렸다.
# aicc 컨테이너 내부에서 실행
/app # curl http://api:8000/health
엔터 키를 누르자, 잠시의 정적 후 화면에 응답이 나타났다.
{"status": "ok"}
api 서비스가 보낸 응답이었다. 성공이었다.
”…된다.”
솔라는 허탈한 웃음을 터뜨렸다. “이름이 그냥 주소네. docker-compose.yml에 적은 서비스 이름이 그대로 호스트 이름이 되는 거였어.”
이제야 모든 조각이 맞춰졌다. docker-compose는 단순히 여러 컨테이너를 띄우는 도구가 아니었다. 이 서비스들을 위한 보이지 않는 사설 네트워크를 함께 만들고, 그 안에서 통용되는 이름표, 즉 내부 DNS 서버를 운영하고 있었던 것이다. 개발자는 복잡한 IP 주소를 신경 쓸 필요 없이, docker-compose.yml에 선언한 서비스 이름으로 서로를 호출하기만 하면 되었다. container 사이 주소도 host IP가 아니라 http://api:8000 같은 Compose DNS를 사용했다는 문장이 바로 이 원리를 설명하고 있었다.
솔라는 깨달았다. 어제 그녀가 손에 넣은 ‘서비스별 패키지 명명 규칙’이 파이썬의 임포트 네임스페이스라는 경계를 다루는 도구였다면, 지금 발견한 ‘Compose DNS 네트워크 활용’은 컨테이너 간의 네트워크 네임스페이스라는 또 다른 경계를 다루는 도구였다.
runtime 격리와 source·test import namespace는 다른 경계이며
이 문장은 이제 네트워크라는 차원까지 확장되었다. 런타임 격리, 임포트 네임스페이스, 그리고 네트워크 네임스페이스. 각각의 경계는 자신만의 주소 체계와 이름 규칙을 가지고 있었다.
두 가지 큰 문제를 해결했다는 안도감이 밀려왔다. 파이썬이 import aicc로 AICC 코드를 찾을 수 있게 됐고, aicc 컨테이너는 http://api:8000으로 API 컨테이너를 찾을 수 있게 됐다. 그런데 문득 새로운 의문이 고개를 들었다.
“좋아, 이제 두 개의 열쇠를 모두 찾았어. 파이썬 임포트를 위한 이름표, 컨테이너 통신을 위한 이름표. 그런데 CI 통합 테스트 환경은 이 두 가지가 모두 필요한 하나의 공간이잖아. 이 두 개의 서로 다른 이름 시스템이 어떻게 함께 작동해서 전체 테스트를 성공시키는 거지? 아직 이 둘을 동시에 사용해보진 않았는데…”
분리된 문제들을 해결했지만, 이제는 그것들을 통합할 시간이 다가오고 있었다.
5장: CI/CD를 위한 견고한 서비스 경계: 통합적 접근
솔라의 책상 위에는 이제 실험용 폴더가 아닌 실제 프로젝트 파일들이 열려 있었다. 왼쪽 화면에는 패키지 이름이 aicc로 바뀐 AICC 서비스의 소스 코드가, 오른쪽 화면에는 docker-compose.yml 파일이 보였다. 지난 며칠간의 깨달음이 모두 이 파일들에 녹아 있었다.
파이썬이 모듈을 헷갈리지 않도록 패키지 이름을 aicc로 고유하게 만들었다. 그리고 aicc 서비스의 테스트 코드에서 API 서비스를 호출하는 주소는 http://localhost:8000 대신 http://api:8000으로 수정했다. 마치 두 개의 서로 다른 자물쇠에 맞는 열쇠 두 개를 찾아낸 기분이었다. 파이썬 임포트를 위한 ‘고유한 패키지 이름’이라는 열쇠, 그리고 컨테이너 간 통신을 위한 ‘Compose DNS’라는 열쇠.
모든 준비는 끝났다. 이제 이 변경 사항들을 CI 서버에 올려 최종 시험을 치를 시간이었다. 솔라는 커밋 메시지를 작성하고 git push 명령어를 입력했다. 하지만 엔터 키를 누르기 직전, 손가락이 잠시 멈칫했다. 모든 조각이 제자리에 있는 것 같은데도, 마음 한구석에 작은 불안감이 남았다. 두 개의 서로 다른 문제를 각기 다른 방식으로 해결했지만, 이 두 해결책이 ‘통합 테스트’라는 하나의 무대 위에서 과연 아름다운 협주를 이룰 수 있을까? 혹시 내가 놓친 또 다른 종류의 경계가 존재하진 않을까?
“이제 확인해 볼 시간인가 보네.”
솔라의 망설임을 읽기라도 한 듯, 루나가 조용히 다가와 말했다. 루나의 시선은 솔라의 화면에 나란히 떠 있는 두 개의 ‘열쇠’에 머물러 있었다.
솔라는 심호흡 한번 하고 엔터 키를 눌렀다. 변경 사항이 원격 저장소로 전송되고, 잠시 후 CI/CD 파이프라인이 자동으로 실행되었다. 둘은 나란히 서서 새로운 파이프라인 로그가 화면에 실시간으로 찍히는 것을 지켜보았다.
git clone, pip install… 익숙한 단계들이 차례로 녹색 체크 표시와 함께 지나갔다. 그리고 마침내, 며칠 동안 솔라를 괴롭혔던 통합 테스트 단계에 이르렀다. 화면이 잠시 멈춘 듯했고, 긴장감이 흘렀다.
+ pytest tests/
============================= test session starts ==============================
...
collected 25 items
tests/test_integration.py::test_aicc_api_interaction PASSED [ 4%]
...
============================== 25 passed in 34.52s ===============================
붉은색 FAILED 대신, 선명한 초록색의 PASSED가 화면을 채웠다. 파이프라인은 성공적으로 끝났다.
“됐다…!”
솔라는 안도의 한숨과 함께 나지막이 외쳤다. 하지만 기쁨보다 더 큰 것은 명확한 이해에서 오는 평온함이었다. 이제 그녀는 이 성공이 우연이 아님을, 그 작동 원리를 처음부터 끝까지 설명할 수 있었다.
“언니, 이제 알겠어. CI 서버는 두 서비스의 코드를 한 공간에 내려받았지만, 내 테스트 코드가 import aicc를 실행했을 때 파이썬은 더 이상 헷갈리지 않았어. aicc라는 이름은 이 넓은 테스트 환경에서 유일했으니까. 이게 첫 번째 경계였어. 임포트 네임스페이스의 경계.”
솔라는 마우스를 움직여 로그의 다른 부분을 가리켰다.
“그리고 테스트 코드 안에서 http://api:8000을 호출했을 때, docker-compose가 만든 내부 네트워크가 api라는 이름을 가진 컨테이너를 정확히 찾아줬어. IP 주소가 뭐든 상관없었지. 이게 두 번째 경계, 네트워크 네임스페이스의 경계였던 거야.”
그녀는 고개를 저었다. “처음엔 도커의 ‘격리’만 믿었어. 컨테이너라는 벽만 있으면 모든 게 안전할 거라고 생각했지. 하지만 틀렸어. 통합 환경은 단순히 서비스들을 한데 모아놓는 게 아니었어. 각기 다른 규칙을 가진 여러 겹의 경계를 이해하고, 그 경계마다 명확한 이름과 주소를 부여해주는 일이었던 거야.”
runtime 격리와 source·test import namespace는 다른 경계이며, 서비스별 package 이름과 Compose DNS를 명시해야 한다.
머릿속에 맴돌던 문장이 비로소 완전한 실체를 갖추었다. 런타임 격리, 임포트 네임스페이스, 네트워크 네임스페이스. 이 세 가지는 서로 다른 차원의 경계이며, 각각에 맞는 질서가 필요했다. 솔라는 이제 그 질서를 부여하는 방법, 즉 ‘통합 환경 경계 관리’라는 도구를 손에 넣은 것이다.
루나는 조용히 고개를 끄덕이며 새로운 질문을 던졌다.
“그럼, 만약 내일 우리가 ‘payment’라는 새로운 결제 서비스를 추가해야 한다면, 솔라 네가 가장 먼저 확인할 두 가지는 뭐야?”
솔라는 망설임 없이 대답했다. 마치 오랫동안 준비해온 답처럼 명료했다.
“첫째, 새 서비스의 파이썬 패키지 이름은 반드시 payment여야 해요. 절대 app 같은 흔한 이름을 쓰면 안 돼요. 둘째, 다른 서비스에서 payment를 호출할 땐, 반드시 http://payment:포트번호 같은 Compose DNS 이름을 써야 하고요. localhost나 IP 주소는 절대 안 되죠.”
그 대답은 더 이상 가설이 아닌, 경험으로 체득한 원칙이었다. 솔라는 자신의 노트북에 새로운 메모 파일을 열었다. 그리고 방금 자신이 말한 내용을 바탕으로 ‘신규 마이크로서비스 추가 시 체크리스트’ 초안을 작성하기 시작했다.
- 고유한 패키지 이름: 서비스 이름과 동일한 파이썬 패키지 이름을 사용한다. (예:
payment서비스 ->payment패키지) - 서비스 간 통신:
docker-compose.yml의 서비스 이름을 DNS로 사용한다. (예:http://payment:8080)
이 간단한 두 줄의 원칙은, 지난 며칠간의 혼돈을 겪으며 그녀가 얻어낸 값진 결과물이었다. 버그를 해결하는 것을 넘어, 미래의 버그를 예방하는 견고한 시스템의 기초를 다지는 첫걸음이었다.