<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ changseop yeom - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/korean/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ changseop yeom - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/korean/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 16 Jun 2026 21:15:06 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/korean/news/author/changseop/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Node.js와 함께 Docker를 사용하는 방법: 개발자를 위한 핸드북 ]]>
                </title>
                <description>
                    <![CDATA[ 이 핸드북에서는 Docker가 무엇인지, 그리고 Docker가 왜 백엔드 및 풀스택 개발자에게 반드시 필요한 기술인지를 배우게 됩니다. 그리고 가장 중요한 것은, 실제 프로젝트 전 과정을 통해 Docker를 어떻게 활용하는지 익히게 된다는 점입니다. 우리는 흔한 “Hello World” 예제를 훨씬 뛰어넘어, 완전한 풀스택 자바스크립트 애플리케이션(Node.js + Express 백엔드, HTML/CSS/JS 프론트엔드, MongoDB 데이터베이스, Mongo ]]>
                </description>
                <link>https://www.freecodecamp.org/korean/news/how-to-use-to-docker-with-nodejs-handbook/</link>
                <guid isPermaLink="false">692970fd7cfd9604dd367dc8</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ NodeJS ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ changseop yeom ]]>
                </dc:creator>
                <pubDate>Mon, 05 Jan 2026 23:47:22 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/korean/news/content/images/2026/01/74610cbc-124b-48aa-9cb6-7ed861123511.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p data-test-label="translation-intro">
        <strong>기사 원문:</strong> <a href="https://www.freecodecamp.org/news/how-to-use-to-docker-with-nodejs-handbook/" target="_blank" rel="noopener noreferrer" data-test-label="original-article-link">How to Use to Docker with Node.js: A Handbook for Developers</a>
      </p><p>이 핸드북에서는 Docker가 무엇인지, 그리고 Docker가 왜 백엔드 및 풀스택 개발자에게 반드시 필요한 기술인지를 배우게 됩니다. 그리고 가장 중요한 것은, 실제 프로젝트 전 과정을 통해 Docker를 어떻게 활용하는지 익히게 된다는 점입니다.</p><p>우리는 흔한 “Hello World” 예제를 훨씬 뛰어넘어, 완전한 풀스택 자바스크립트 애플리케이션(Node.js + Express 백엔드, HTML/CSS/JS 프론트엔드, MongoDB 데이터베이스, Mongo Express 관리자 UI)을 컨테이너화하는 과정을 처음부터 끝까지 함께 따라가 볼 것입니다.​</p><p>여러 개의 컨테이너를 네트워킹(통신)하는 방법, Docker Compose로 모든 서비스를 오케스트레이션하는 방법, 나만의 이미지를 빌드하고 버전을 관리하는 방법, 볼륨을 사용해 데이터를 영구적으로 보존하는 방법, 그리고 이미지를 안전하게 AWS ECR 개인 레포지토리에 푸시해 공유와 프로덕션 배포에 활용하는 방법 등을 배우게 됩니다.﻿​</p><p>튜토리얼을 끝낼 즈음에는 “내 컴퓨터에서는 잘 되는데” 문제를 없애고, 멀티 서비스 애플리케이션을 자신 있게 관리하며, 어디서든 일관된 환경을 배포하고, 일상적인 개발 워크플로와 CI/CD 파이프라인에 Docker를 능숙하게 통합할 수 있게 될 것입니다.﻿​</p><p>Docker는 백엔드 개발자에게 매우 중요한 기술이기 때문에, 먼저 Docker의 기본 개념부터 차근차근 살펴보겠습니다.﻿</p><h2 id="-">전제조건</h2><p>이 기술 핸드북은 풀스택 개발에 대해 어느 정도 실무 경험이 있는 개발자를 대상으로 합니다. 독자는 애플리케이션 배포에 익숙하고, CI/CD 파이프라인에 대한 기본적인 이해를 갖추고 있어야 합니다. 비록 이 가이드에서 Docker를 기초부터 다루긴 하지만, 완전 초보 개발자를 위한 내용은 아니며, 실제 개발 경험이 있고 Docker로 워크플로를 한 단계 끌어올리고 싶은 분들을 전제로 합니다. </p><p>마지막으로, AWS에 대한 기본적인 지식과 일반적인 배포 개념을 알고 있다면 도움이 되지만, 전문가일 필요는 없습니다. 이 핸드북은 프로덕션급 역량을 강화하고, Docker를 자신의 프로젝트에 자신 있게 통합하고자 하는 개발자에게 이상적인 자료입니다.﻿​</p><h2 id="--1">목차</h2><!--kg-card-begin: html--><ol>
  <li>
    <p>
      컨테이너란 무엇인가?
    </p>
  </li>
  <li>
    <p>
      Docker와 가상 머신
    </p>
  </li>
  <li>
    <p>
      Docker 설치
    </p>
  </li>
  <li>
    <p>
      기본 Docker 명령어
    </p>
  </li>
  <li>
    <p>
      JavaScript로 연습하기
    </p>
    <ul>
      <li>
        <p>
          MongoDB 이미지 가져오기
        </p>
      </li>
      <li>
        <p>
          Mongo Express 이미지 가져오기
        </p>
      </li>
      <li>
        <p>
          Docker 네트워크
        </p>
      </li>
    </ul>
  </li>
  <li>
    <p>
      Mongo 컨테이너 실행 방법
    </p>
  </li>
  <li>
    <p>
      Mongo Express 컨테이너 실행 방법
    </p>
  </li>
  <li>
    <p>
      Node.js를 MongoDB에 연결하는 방법
    </p>
  </li>
  <li>
    <p>
      Docker Compose 사용 방법
    </p>
    <ul>
      <li>
        왜 Docker Compose를 사용하는가?
      </li>
    </ul>
  </li>
  <li>
    <p>
      나만의 Docker 이미지 빌드하기
    </p>
    <ul>
      <li>
        <p>
          해결책
        </p>
      </li>
      <li>
        <p>
          MongoDB가 동작하는 이유
        </p>
      </li>
      <li>
        <p>
          앱을 Docker Compose에 추가하기
        </p>
      </li>
      <li>
        <p>
          모든 서비스 시작하기
        </p>
      </li>
      <li>
        <p>
          모든 것이 제대로 동작하는지 확인하기
        </p>
      </li>
      <li>
        <p>
          무엇이 바뀌었고 왜 동작하는가
        </p>
      </li>
    </ul>
  </li>
  <li>
    <p>
      컨테이너 관리 방법
    </p>
  </li>
  <li>
    <p>
      프라이빗 Docker 저장소를 만드는 방법
    </p>
    <ul>
      <li>
        <p>
          1단계: AWS 액세스 키 받기
        </p>
      </li>
      <li>
        <p>
          2단계: AWS CLI 설치 여부 확인
        </p>
      </li>
      <li>
        <p>
          3단계: AWS CLI 설정
        </p>
      </li>
      <li>
        <p>
          4단계: AWS 설정 테스트
        </p>
      </li>
      <li>
        <p>
          5단계: ECR(Docker 레지스트리)에 로그인
        </p>
      </li>
      <li>
        <p>
          Docker 저장소의 이미지 이름 규칙 이해하기
        </p>
      </li>
      <li>
        <p>
          6단계: 이미지 빌드, 태그, 푸시하기
        </p>
      </li>
    </ul>
  </li>
  <li>
    <p>
      과제: 새 버전을 만들어 푸시하기
    </p>
    <ul>
      <li>
        <p>
          이미지 배포하기
        </p>
      </li>
      <li>
        <p>
          왜 ECR에서 전체 이미지 URL을 사용해야 하는가?
        </p>
      </li>
      <li>
        <p>
          Docker Compose로 앱 배포하기
        </p>
      </li>
      <li>
        <p>
          프라이빗 Docker 이미지 공유하기
        </p>
      </li>
    </ul>
  </li>
  <li>
    <p>
      Docker 볼륨
    </p>
    <ul>
      <li>
        <p>
          Docker 볼륨 동작 방식
        </p>
      </li>
      <li>
        <p>
          Docker 볼륨의 종류
        </p>
      </li>
      <li>
        <p>
          볼륨을 사용하는 Docker Compose 예시 파일
        </p>
      </li>
      <li>
        <p>
          애플리케이션 시작하기
        </p>
      </li>
    </ul>
  </li>
  <li>
    <p>
      결론
    </p>
  </li>
</ol>
<!--kg-card-end: html--><h2 id="--2">컨테이너란 무엇인가?</h2><p>컨테이너는 애플리케이션이 실행되는 데 필요한 모든 것(의존성, 라이브러리, 설정 파일 등)을 함께 묶어서 패키징하는 방법입니다. </p><p>컨테이너는 이식성이 좋아 팀 간에 쉽게 공유할 수 있고, 호환성 문제를 걱정하지 않고 어떤 머신에든 배포할 수 있습니다.﻿​</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-4.png" class="kg-image" alt="image-4" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-4.png 600w, https://www.freecodecamp.org/korean/news/content/images/2025/12/image-4.png 736w" sizes="(min-width: 720px) 720px" width="736" height="736" loading="lazy"></figure><h2 id="--3">컨테이너는 어디에 있나요?</h2><p>컨테이너는 팀과 시스템 사이에서 자유롭게 이동하고 공유될 수 있기 때문에, 컨테이너가 “머무를” 장소가 필요합니다. 여기에서 컨테이너 저장소(레지스트리)가 등장하는데, 이는 컨테이너 이미지를 보관하는 특수한 저장 공간입니다. 조직은 내부용 비공개 저장소를 둘 수 있고, <a href="https://hub.docker.com/">Docker Hub</a> 같은 공개 저장소를 통해 누구나 공유된 컨테이너 이미지를 찾아보고 사용할 수 있습니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-5.png" class="kg-image" alt="image-5" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-5.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-5.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-5.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-5.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1196" loading="lazy"></figure><p>Docker Hub의 카탈로그 페이지를 방문해 보면, Redis, Jenkins 같은 개발자와 팀이 만든 공식 및 커뮤니티 기반 컨테이너 저장소들을 다양하게 볼 수 있습니다.﻿​</p><p>예전에는 여러 개발자가 서로 다른 프로젝트를 진행할 때, 각자 자신의 시스템에 필요한 서비스를 직접 설치해야 했습니다. 개발자마다 리눅스, macOS, Windows처럼 사용하는 운영체제가 다르다 보니, 설정 과정도 제각각이었습니다. 이 때문에 시간이 많이 들고 오류도 자주 발생했으며, 특히 여러 서비스를 반복해서 세팅해야 할 때는 새로운 환경을 구성하는 일이 큰 골칫거리였습니다.﻿​</p><p>Docker는 이런 상황을 완전히 바꾸어 놓았습니다. 이제는 모든 서비스와 의존성을 하나씩 직접 설치하는 대신, Docker 명령 한 줄만 실행해서 컨테이너를 띄우면 됩니다. 각 컨테이너는 자신에게 필요한 모든 것을 갖춘 격리된 환경이기 때문에, Windows든 macOS든 Linux든 어떤 머신에서도 동일하게 동작합니다. 덕분에 협업이 훨씬 매끄러워지고, 서로 다른 설정, 누락된 의존성, 버전 불일치 때문에 생기던 병목 현상이 사라집니다.﻿​</p><p>요약하면, Docker는 애플리케이션과 그 의존성을 하나의 이식 가능한 컨테이너로 포장해서 어디서나 동일한 방식으로 실행할 수 있도록 해주는 플랫폼입니다.</p><h2 id="docker-vs-">Docker vs 가상 머신</h2><p>Docker와 가상 머신(VM)은 모두 애플리케이션을 “가상” 환경에서 실행하는 방법이지만, 동작 방식은 서로 다릅니다. 이런 차이를 이해하려면, 먼저 컴퓨터가 소프트웨어를 어떻게 실행하는지 간단히 살펴볼 필요가 있습니다.﻿​</p><p>레이어를 빠르게 훑어보면:</p><ul><li><strong>커널(Kernel)</strong>: 운영체제에서 CPU, 메모리, 디스크 같은 하드웨어와 직접 통신하는 부분입니다. 앱과 컴퓨터 사이를 이어 주는 중개자라고 생각하면 됩니다.</li><li><strong>애플리케이션 계층(Application layer)</strong>: 실제 프로그램과 앱이 실행되는 영역입니다. 커널 위에 올라가 있으며, 커널을 통해 하드웨어 자원에 접근합니다.﻿​</li></ul><p>이제 가상 머신을 좀 더 자세히 보겠습니다. VM은 <strong>운영체제 전체</strong>를 가상화하기 때문에, 자체 커널과 자체 애플리케이션 계층을 모두 포함합니다. VM을 하나 다운로드한다는 것은, 사실상 수 기가 바이트에 이르는 완전한 운영체제를 내 컴퓨터 안에 또 하나 들여오는 것과 같습니다.﻿​</p><p>자체 OS를 부팅해야 하므로 VM은 시작 속도가 느립니다. 하지만 필요한 것을 모두 가지고 있기 때문에 호스트 환경과 상관없이 거의 어떤 시스템에서도 잘 돌아가는 높은 호환성을 제공합니다.﻿​</p><p>반면 Docker는 전체 OS가 아니라 <strong>애플리케이션 계층</strong>만 가상화합니다. 컨테이너는 호스트 시스템의 커널을 공유하지만, 애플리케이션이 필요로 하는 의존성, 라이브러리, 설정 파일 등은 컨테이너 안에 함께 포함합니다.﻿​</p><p>Docker 이미지는 보통 수 메가바이트 수준으로 작고, 자체 OS를 부팅하지 않기 때문에 컨테이너는 거의 즉시 시작됩니다. Docker가 설치되어 있기만 하면, 호스트가 어떤 운영체제이든(Windows, macOS, Linux) 컨테이너는 동일하게 실행될 수 있습니다.﻿​</p><p>간단히 정리하면:</p><ul><li>VM은 내 컴퓨터 안에 “또 하나의 완전한 컴퓨터”를 띄우는 느낌으로, 크고 무겁고 느립니다.</li><li>Docker 컨테이너는 애플리케이션을 담은 독립 패키지에 가까워, 작고 빠르며 이식성이 좋습니다.﻿​</li></ul><p>간단한 비교 표입니다:</p><!--kg-card-begin: html--><div class="hn-table">
  <table>
    <thead>
      <tr>
        <td>기능</td>
        <td>가상 머신</td>
        <td>Docker 컨테이너</td>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>크기</td>
        <td>GB 단위 (큼)</td>
        <td>MB 단위 (작음)</td>
      </tr>
      <tr>
        <td>시작 속도</td>
        <td>느림</td>
        <td>빠름</td>
      </tr>
      <tr>
        <td>OS 계층</td>
        <td>전체 OS + 커널</td>
        <td>호스트 커널을 공유</td>
      </tr>
      <tr>
        <td>이식성</td>
        <td>호환되는 호스트에서 실행됨</td>
        <td>도커가 설치된 곳이라면 어디든 실행됨</td>
      </tr>
    </tbody>
  </table>
</div>
<!--kg-card-end: html--><h2 id="docker-">Docker 설치</h2><p><br>이제 Docker가 무엇인지 알게 되었으니, 직접 내 컴퓨터에서 Docker를 실행해 봅시다. Docker는 Windows, macOS, Linux에서 모두 동작하지만, 운영체제마다 설치 과정에 약간씩 차이가 있습니다. Docker <a href="https://docs.docker.com/get-started/introduction/">공식 문서</a>에는 모든 운영체제별 설치 방법이 정리되어 있습니다.﻿​</p><p>시각적으로 보는 것을 더 선호한다면, Windows와 Linux에서 Docker를 설치하는 과정을 단계별로 보여주는 <a href="https://www.youtube.com/watch?v=BuGEGM_elXY">YouTube 영상</a>도 참고할 수 있습니다.﻿​</p><p>간단한 설치 로드맵은 다음과 같습니다:</p><p>먼저 시스템 요구 사항을 확인합니다. Docker는 모든 컴퓨터에서 돌아가는 것이 아니므로, 사용하는 OS 버전이 지원 대상인지 반드시 확인해야 합니다.(<a href="https://docs.docker.com/engine/install/">공식 문서</a>에 체크리스트가 있습니다.﻿​)</p><ol><li>Windows 및 macOS 사용자:</li></ol><ul><li><strong>새로운 시스템</strong>: <a href="https://docs.docker.com/desktop/">Docker Desktop</a>을 다운로드해 설치하는 것이 가장 쉽습니다. </li><li><strong>구형 시스템</strong>: Docker Desktop을 지원하지 않는 경우(예: Hyper-V 미지원, 오래된 OS 버전 등)에는 <a href="https://docker-docs.uclv.cu/toolbox/toolbox_install_windows/">Docker Toolbox</a>를 사용할 수 있습니다. Toolbox는 가벼운 가상 머신 위에 Docker를 올려 주기 때문에, 오래된 컴퓨터에서도 컨테이너를 실행할 수 있습니다.﻿​</li></ul><p>2. Linux 사용자</p><ul><li>보통 각 배포판의 패키지 관리자(예: Ubuntu/Debian은 <code>apt</code>, CentOS/Fedora는 <code>yum</code> 등)를 사용해 Docker를 설치합니다. <a href="https://docs.docker.com/desktop/setup/install/linux/">공식 문서</a>에 배포판별 설치 명령이 정리되어 있습니다.﻿​</li></ul><p>설치가 끝나면, 터미널이나 명령 프롬프트를 열고 다음 명령어를 입력해 설치를 확인합니다:</p><pre><code class="language-bash">docker --version
</code></pre><p>여기에 Docker 버전 정보가 출력되면, 축하합니다! 정상적으로 설치가 완료된 것입니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/11/image-5.png" class="kg-image" alt="image-5" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/11/image-5.png 600w, https://www.freecodecamp.org/korean/news/content/images/2025/11/image-5.png 632w" width="632" height="34" loading="lazy"></figure><p>이제 도커가 설치되었으니, 컨테이너를 실행하고, 이미지를 가져오고, 애플리케이션을 안전하고 격리된 환경에서 실험할 준비가 된 것입니다.﻿​</p><p><strong>초보자를 위한 팁</strong>:</p><p>만약 구형 시스템에서 Docker Toolbox를 사용 중이라면, 명령어 자체는 대부분 동일하지만, <strong>Docker Quickstart Terminal</strong> 안에서 명령을 실행해야 합니다. 이 터미널이 가상 머신 환경을 자동으로 설정해 주기 때문입니다.</p><h2 id="-docker-">기본 Docker 명령어</h2><p>지금까지 우리는 ‘이미지(images)’와 ‘컨테이너(containers)’라는 용어를 여러 번 사용해 왔습니다. 때로는 이 두 용어를 서로 바꿔 쓰기도 했습니다. 하지만 이 둘 사이에는 중요한 차이가 있습니다.</p><ul><li><strong>Docker 이미지</strong>: 이미지는 <strong>설계도(blueprint)</strong>나 패키지라고 생각하면 됩니다. 앱이 실행되는 데 필요한 코드, 라이브러리, 의존성, 설정 등이 모두 들어 있지만, 아직 “실행 중”인 상태는 아닙니다.</li><li><strong>Docker 컨테이너</strong>: 컨테이너는 이미지를 실제로 실행한 “<strong>실행 인스턴스</strong>”입니다. 컨테이너를 시작하면, Docker가 이미지를 가져와 자신만의 격리된 환경에서 그 내용을 실행합니다.</li></ul><p>이렇게 기억하면 도움이 됩니다: <em>이미지(image)는 레시피이고, 컨테이너(container)는 케이크입니다. </em>하나의 레시피(이미지)로 케이크(컨테이너)를 여러 개 만들 수 있는 것입니다.</p><p><strong>중요한 점</strong>: Docker Hub에는 컨테이너가 아니라 이미지가 저장됩니다. 따라서 Docker Hub에서 무언가를 pull 한다는 것은 이미지를 다운로드한다는 뜻입니다. <br>예를 들어:</p><pre><code class="language-bash">docker pull redis
</code></pre><p>(명령어를 입력하면) 아래 내용을 확인할 수 있습니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/11/image-9.png" class="kg-image" alt="image-9" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/11/image-9.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/11/image-9.png 1000w, https://www.freecodecamp.org/korean/news/content/images/2025/11/image-9.png 1130w" sizes="(min-width: 720px) 720px" width="1130" height="410" loading="lazy"></figure><p>이 명령은 Redis 이미지를 내 컴퓨터로 다운로드받습니다. 다운로드가 끝나면, 다음 명령으로 로컬에 있는 모든 이미지를 확인할 수 있습니다.</p><pre><code class="language-bash">docker images
</code></pre><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/11/image-10.png" class="kg-image" alt="image-10" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/11/image-10.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/11/image-10.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/11/image-10.png 1600w, https://www.freecodecamp.org/korean/news/content/images/2025/11/image-10.png 1654w" sizes="(min-width: 720px) 720px" width="1654" height="880" loading="lazy"></figure><p>이제 필요할 때마다 이미지에서 컨테이너를 시작할 수 있습니다.</p><pre><code class="language-bash">docker run -d --name my-redis redis
</code></pre><p>이 명령은 방금 내려받은 <code>redis</code> 이미지로부터, <code>my-redis</code> 라는 컨테이너를 하나 띄웁니다.</p><ul><li><code>docker run</code> 은 도커에게 “이 이미지로 새 컨테이너를 시작하라”고 지시하는 명령입니다.</li><li><code>d</code> 는 “detached mode”의 약자로, 컨테이너를 백그라운드에서 돌려서 터미널을 계속 쓸 수 있게 해 줍니다.</li><li><code>--name my-redis</code> 는 도커가 임의의 이름을 붙이는 대신 <code>my-redis</code>라는 알아보기 쉬운 이름을 컨테이너에 지정합니다. 이렇게 하면 나중에 관리가 더 편해집니다.<br><code>redis</code> 는 컨테이너를 시작하는 데 사용할 이미지 이름입니다.</li></ul><p>현재 실행 중인 컨테이너를 모두 보고 싶다면:</p><pre><code class="language-bash">docker ps
</code></pre><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/11/image-12.png" class="kg-image" alt="image-12" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/11/image-12.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/11/image-12.png 1000w, https://www.freecodecamp.org/korean/news/content/images/2025/11/image-12.png 1518w" sizes="(min-width: 720px) 720px" width="1518" height="396" loading="lazy"></figure><p>이 명령은 다음과 같은 정보를 포함해 컨테이너 목록을 보여 줍니다.</p><ul><li>컨테이너 ID</li><li>이름</li><li>상태(실행 중인지, 중지 상태인지)</li><li>어떤 이미지로부터 실행되고 있는지 등</li></ul><p>실행 중이 아닌 컨테이너까지 모두 보고 싶다면, <code>-a </code>옵션을 추가합니다.</p><pre><code class="language-bash">docker ps -a
</code></pre><h2 id="--4">이미지 버전 지정 방법:</h2><p>기본적으로 Docker는 이미지의 <strong>최신 버전</strong>을 가져옵니다. 하지만 특정 버전이 필요할 때는 <code>콜론(:) </code>뒤에 버전 태그를 붙여서 지정할 수 있습니다. 예를 들어:</p><pre><code class="language-bash">docker pull redis:7.2
docker run -d --name my-redis redis:7.2</code></pre><p>어떤 버전들이 있는지 알고 싶다면 <a href="https://hub.docker.com/repositories">Docker Hub</a>나 관련 페이지에서 태그 목록을 확인할 수 있습니다. 또한 로컬에서 <code>docker images</code> 명령을 실행하면, 내려받은 이미지들과 각 버전을 확인할 수 있습니다.</p><h2 id="--5">컨테이너 중지, 시작, 삭제 방법:</h2><p>실행 중인 컨테이너를 멈추고 싶다면:</p><pre><code class="language-bash">docker stop my-redis
</code></pre><p>다시 시작하려면:</p><pre><code class="language-bash">docker start my-redis
</code></pre><p>더 이상 필요 없는 <strong>컨테이너는 삭제</strong>할 수 있습니다.</p><pre><code class="language-bash">docker rm my-redis
</code></pre><h2 id="--6">컨테이너 재시작 방법</h2><p>무언가 충돌이 발생했거나, 새 설정을 반영하고 싶거나, 단순히 한 번 새로고침하고 싶을 때는 <strong>컨테이너 ID</strong>(또는 이름)으로 재시작할 수 있습니다. 예를 들어:</p><pre><code class="language-bash">docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED         STATUS         PORTS      NAMES
c002bed0ae9a   redis     "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes   6379/tcp   my-redis</code></pre><p>이 컨테이너를 다음처럼 재시작할 수 있습니다.</p><pre><code class="language-bash">docker restart c002bed0ae9a
</code></pre><p>또는 이름으로:</p><pre><code class="language-bash">docker restart my-redis
</code></pre><p>기억해 두면 좋은 다른 방법들입니:</p><ul><li><strong>먼저 중지 후 시작</strong><br><br><code>docker stop c002bed0ae9a</code><br><code>docker start c002bed0ae9a</code>			<br></li><li><strong>로그를 보면서 시작</strong><br><br><code>docker start c002bed0ae9a &amp;&amp; docker logs -f c002bed0ae9a</code></li></ul><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image.png" class="kg-image" alt="image" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="946" loading="lazy"></figure><h2 id="-redis-">여러 Redis 컨테이너를 실행하고 포트를 이해하는 방법</h2><p>현재 Redis 컨테이너가 한개 실행 중인 상태라고 가정해 보겠습니다:</p><pre><code class="language-bash">docker ps
</code></pre><p>출력 예시는 다음과 같습니다:</p><pre><code class="language-bash">CONTAINER ID   IMAGE     COMMAND                  STATUS          PORTS      NAMES
c002bed0ae9a   redis     "docker-entrypoint.s…"   Up 20 minutes   6379/tcp   my-redis</code></pre><p><strong>PORTS</strong> 열을 보면 <code>6379/tcp</code> 로 표시되어 있습니다. 이는 컨테이너 내부에서 Redis가 기본 포트 6379로 실행 중임을 의미합니다.﻿ 기본적으로 이 포트는 컨테이너 내부 포트이며, 명시적으로 지정하지 않는 한 여러분의 컴퓨터(호스트)에는 자동으로 노출되지 않습니다. Docker는 사용자가 명시적으로 지정해야만 포트를 매핑합니다.</p><h2 id="-redis--1">동일한 포트에서 다른 Redis 컨테이너 실행 시도하기</h2><p>다음과 같이 시도하면:</p><pre><code class="language-bash">docker run -d --name my-redis2 redis:7.4.7-alpine
</code></pre><p>첫 번째 컨테이너가 이미 호스트 포트 6379를 사용하고 있기 때문에 매핑에 실패합니다. 여기서 포트 바인딩이 필요합니다.</p><h2 id="--7">포트 바인딩이란 무엇인가?</h2><p>포트 바인딩(포트 매핑이라고도 함)은 Docker가 컨테이너 내부의 포트를 호스트 머신(여러분의 노트북/데스크톱/서버)의 포트에 연결하는 데 사용되는 원리입니다.</p><p>포트 바인딩이 없으면 컨테이너 내부에서 실행되는 모든 서비스는 완전히 격리됩니다: 내부 포트(예: Redis는 6379, Node.js 앱은 3000, MongoDB는 27017)에서 수신 대기할 수 있지만, 브라우저, 컴퓨터의 다른 앱, 심지어 다른 네트워크에 있는 다른 컨테이너를 포함하여 컨테이너 외부의 어떤 것도 이에 접근할 수 없습니다.</p><ul><li><strong>컨테이너 포트</strong>: 앱이 실행되고 있는 컨테이너 내부의 포트(Redis는 기본적으로 <code>6379</code>)</li><li><strong>호스트 포트</strong>: 해당 컨테이너에 액세스하는 데 사용하려는 컴퓨터의 포트.</li></ul><p>Docker는 <code>-p</code> 플래그를 사용하여 컨테이너 포트를 다른 호스트 포트에 매핑할 수 있게 해줍니다.</p><h2 id="-redis--2">다른 호스트 포트에서 두 번째 Redis 컨테이너 실행하기</h2><pre><code class="language-bash">docker run -d --name my-redis2 -p 6380:6379 redis:7.4.7-alpine
</code></pre><p><code>-p 6380:6379</code>는 호스트 포트 6380을 컨테이너 포트 6379에 매핑(연결)합니다.</p><ul><li>이제 <code>localhost:6380</code>을 사용하여 두 번째 컨테이너의 Redis에 연결할 수 있습니다. </li><li>컨테이너 내부에서 Redis는 여전히 포트 6379에서 실행됩니다.</li></ul><p>두 컨테이너를 확인해보세요:</p><pre><code class="language-bash">docker ps
</code></pre><p>출력은 다음과 같습니다:</p><pre><code class="language-bash">CONTAINER ID   IMAGE     STATUS          PORTS             NAMES
c002bed0ae9a   redis     Up 20 minutes   6379/tcp          my-redis
d123abcd5678   redis     Up 1 minute     0.0.0.0:6380-&gt;6379/tcp   my-redis2
</code></pre><p>첫 번째 컨테이너는 6379에서 내부적으로 실행되고(호스트 포트가 노출되지 않음), 두 번째 컨테이너는 호스트 포트 6380이 컨테이너 포트 6379로 트래픽을 전달하도록 매핑되어 있습니다.</p><p>각 컨테이너를 전화선(컨테이너 포트)이 있는 방으로 생각해보세요.</p><ul><li>외부(호스트)에서 그 방으로 전화를 걸고 싶습니다.</li><li>두 개의 방에 동시에 같은 외부 전화선을 사용할 수 없습니다.</li><li><strong>포트 바인딩</strong>을 사용하면 내부 전화번호가 같더라도, 각 방에 다른 외부 회선을 할당할 수 있습니다.</li></ul><h2 id="--8">포트 바인딩이 존재하는 이유</h2><ol><li><strong>호스트의 포트 충돌 방지</strong>: 컴퓨터에서 한 번에 하나의 프로세스만 지정된 포트를 사용할 수 있습니다. 이미 호스트 포트 6379를 사용하는 Redis 컨테이너가 하나 있다면, 두 번째 컨테이너도 같은 호스트 포트에 바인딩할 수 없습니다. 포트 바인딩을 사용하면 각각을 다른 호스트 포트(6379 → 6380, 6381 등)에 매핑하여 여러 동일한 컨테이너를 나란히 실행할 수 있습니다.</li><li><strong>호스트에서 컨테이너화된 서비스에 액세스</strong>: 브라우저, Postman, MongoDB Compass, redis-cli, curl 등은 모두 호스트에서 실행됩니다. -p 플래그 없이는 컨테이너 내부의 서비스와 통신할 방법이 없습니다.</li><li><strong>선택적 노출</strong>: 컨테이너가 사용하는 모든 포트를 노출할 필요는 없습니다. 실제로 외부에서 필요한 포트만 매핑하여 나머지는 비공개 상태로 안전하게 유지할 수 있습니다.</li></ol><p>또한 개발 및 프로덕션 환경에서 더 많은 유연성을 제공합니다. 개발 환경에서는 컨테이너 3000을 호스트 3000에 매핑할 수 있습니다. 하지만 프로덕션 환경에서는(예: 리버스 프록시 뒤에서) 컨테이너 3000을 호스트 80 또는 443에 매핑하거나, 전혀 노출하지 않고 다른 컨테이너가 Docker의 내부 네트워크를 통해 통신하도록 할 수 있습니다.</p><h2 id="--9">컨테이너 탐색 방법</h2><p>컨테이너를 탐색하려면 다음을 실행하세요:</p><pre><code class="language-bash">docker exec -it my-redis2 /bin/sh
</code></pre><ul><li><code>docker exec</code>는 컨테이너에서 명령을 실행합니다.</li><li><code>it</code> 대화형 터미널(입력하고 출력을 볼 수 있게 해줌).</li><li><code>/bin/sh</code>는 컨테이너 내부에서 셸을 시작합니다.</li></ul><p>내부에 들어가면 프롬프트가 다음과 같이 변경됩니다:</p><pre><code>/data #
</code></pre><p>이제 호스트 머신에 영향을 주지 않고 전부 컨테이너 내부에서 <strong>파일을 나열하고</strong>, 디렉토리를 탐색하거나, 프로그램을 실행할 수 있습니다.</p><figure class="kg-card kg-image-card"><img src="https://velog.velcdn.com/images/richman/post/05cd8a33-5d31-4be1-bba7-a6de1dccc4bf/image.png" class="kg-image" alt="image" width="2880" height="1800" loading="lazy"></figure><h3 id="docker-run-vs-docker-start"><code>docker run</code> vs <code>docker start</code></h3><p>이 문서 전체에서 <code>docker run</code>과 <code>docker start</code>를 사용해 왔는데, 그 차이점이 중요한 이유는 다음과 같습니다:</p><ul><li><strong>우발적인 중복 방지</strong>: 매번 <code>docker run</code>을 사용하면 새 컨테이너가 생성됩니다. 이미 설정한 것을 단지 다시 시작하려면 <code>docker start</code>가 더 빠르고 안전합니다.</li><li><strong>구성 유지</strong>: <code>docker start</code>는 컨테이너의 원래 설정, 포트, 볼륨 및 이름 등을 보존하므로 옵션을 변경하여 문제가 발생할 위험이 없습니다.</li><li><strong>여러 컨테이너로 효율적으로 작업</strong>: 여러 서비스 또는 동일한 앱의 다른 버전을 실행할 때, <code>run</code>과 <code>start</code>를 언제 사용할지 아는 것은 리소스를 관리하고 포트 충돌을 피하며 워크플로우를 원활하게 유지하는 데 도움이 됩니다.</li><li><strong>워크플로우 속도 향상</strong>: 기존 컨테이너를 시작하는 것은 거의 즉각적이지만, 새 컨테이너를 생성하는 것은 약간 더 오래 걸립니다.</li></ul><p><strong>요약하자면</strong> <code>docker run</code> = 새로운 것을 생성, <code>docker start</code> = 이미 가지고 있는 것을 다시 시작하는 것입니다.</p><h2 id="javascript-">JavaScript로 연습하기</h2><p>이제 핵심 Docker 개념을 다루었으니 실제로 적용해봅시다. 이 섹션에서는 다음으로 구성된 간단한 JavaScript 프로젝트를 컨테이너화합니다:</p><ul><li><strong>프론트엔드</strong>: HTML, CSS 및 JavaScript로 구축</li><li><strong>백엔드</strong>: 간단한 Node.js 서버(<code>server.js</code>)</li><li><strong>데이터베이스</strong>: Docker Hub에서 직접 가져온 MongoDB 인스턴스</li><li><strong>MongoDB용 UI</strong>: <strong>Mongo Express</strong>를 사용하여 데이터베이스를 시각화하고 관리</li></ul><p>이 예제는 Docker가 어떻게 격리되고 일관된 환경에서 코드, 종속성 및 서비스 등을 포함한 애플리케이션의 여러 컴포넌트(구성 요소)를 관리하는지를 보여줍니다.</p><p><a href="https://github.com/Oghenekparobo/docker_tut_js">GitHub에서 스타터 프로젝트</a>를 가져올 수 있습니다.</p><p>또는 터미널을 사용하여 직접 클론하세요:</p><pre><code class="language-bash">git clone https://github.com/Oghenekparobo/docker_tut_js.git
cd docker_tut_js
</code></pre><p>여기에는 Node.js 백엔드와 함께 기본 HTML 및 JavaScript 파일이 포함되어 있습니다.</p><p>다음으로 데이터베이스 설정을 준비합니다. <a href="https://hub.docker.com/">Docker Hub</a>로 이동하여 검색창에 "<strong>mongo</strong>"를 입력하세요. Docker에서 게시한 공식 MongoDB 이미지가 표시됩니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-6.png" class="kg-image" alt="image-6" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-6.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-6.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-6.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-6.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1044" loading="lazy"></figure><h2 id="mongodb-">MongoDB 이미지를 가져오는 방법</h2><p>이제 Docker Hub에서 공식 MongoDB 이미지를 탐색했으니 실제로 로컬 환경으로 가져와봅시다.</p><p>터미널을 열고 프로젝트 디렉토리(예: <code>docker_tut_js</code>)로 이동한 다음 실행하세요:</p><pre><code class="language-bash">docker pull mongo
</code></pre><p>이 명령은 Docker에게 Docker Hub에서 최신 버전의 MongoDB 이미지를 다운로드하도록 지시합니다.</p><p>다음과 유사한 출력이 표시됩니다:</p><pre><code class="language-bash">Using default tag: latest
latest: Pulling from library/mongo
b8a35db46e38: Already exists 
a637dbfff7e5: Pull complete 
0c9047ace63c: Pull complete 
02cd4cf70021: Pull complete 
dfb5d357a025: Pull complete 
007bf0024f67: Pull complete 
67fd8af3998d: Pull complete 
d702312e8109: Pull complete 
Digest: sha256:7d1a1a613b41523172dc2b1b02c706bc56cee64144ccd6205b1b38703c85bf61
Status: Downloaded newer image for mongo:latest
docker.io/library/mongo:latest
</code></pre><p>무슨 일이 일어나고 있는지 설명하면:</p><ul><li><strong>"Using default tag: latest"</strong>: 특정한 버전이 제공되지 않았으므로 Docker가 MongoDB의 최신 버전을 가져옵니다.</li><li><strong>"Pulling from library/mongo"</strong>: Docker의 공식 이미지 라이브러리에서 다운로드하고 있습니다.</li><li><strong>"Pull complete"</strong>: 각 줄은 성공적으로 다운로드 중인 이미지의 레이어를 나타냅니다.</li><li><strong>"Downloaded newer image for mongo:latest"</strong>: MongoDB 이미지가 이제 시스템의 로컬에 저장되었음을 확인합니다.</li></ul><p>다음을 실행하여 사용 가능한지 확인할 수 있습니다:</p><pre><code class="language-bash">docker images
</code></pre><p>repository 열에 <strong>mongo</strong>가 나열되어 있어야 합니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-7.png" class="kg-image" alt="image-7" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-7.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-7.png 1000w, https://www.freecodecamp.org/korean/news/content/images/2025/12/image-7.png 1164w" sizes="(min-width: 720px) 720px" width="1164" height="740" loading="lazy"></figure><h2 id="mongo-express-">Mongo Express 이미지 가져오기 방법</h2><p>이제 MongoDB 이미지가 준비되었으니 <strong>Mongo Express</strong> 이미지를 가져와봅시다.</p><p>Mongo Express는 브라우저를 통해 MongoDB 컬렉션을 보고 관리할 수 있는 경량 웹 기반 인터페이스로, phpMyAdmin이 MySQL에서 작동하는 방식과 유사합니다.</p><p>터미널을 열고(여전히 프로젝트 디렉토리에서) 다음을 실행하세요:</p><pre><code class="language-bash">docker pull mongo-express
</code></pre><p>다음과 유사한 출력이 표시됩니다:</p><pre><code class="language-bash">Using default tag: latest
latest: Pulling from library/mongo-express
b8a35db46e38: Already exists
a637dbfff7e5: Pull complete
4e0e0977e9c3: Pull complete
02cd4cf70021: Pull complete
Digest: sha256:3d6dbac587ad91d0e2eab83f09a5b31a1c8f9d91a8825ddaa6c7453c25cb4812
Status: Downloaded newer image for mongo-express:latest
docker.io/library/mongo-express:latest
</code></pre><p>이것이 의미하는 바는 다음과 같습니다:</p><ul><li><code>docker pull mongo-express</code>는 Docker Hub에서 공식 Mongo Express 이미지를 다운로드합니다.</li><li>각 "<strong>Pull complete</strong>" 줄은 성공적으로 다운로드된 이미지의 레이어를 나타냅니다.</li><li><code>mongo-express:latest</code>는 최신 버전이 이제 로컬에 저장되었음을 확인합니다.</li></ul><p>두 이미지가 모두 사용 가능한지 확인하려면 다음을 실행하세요:</p><pre><code class="language-bash">docker images
</code></pre><p>출력에 mongo와 mongo-express가 나열되어 있어야 합니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-8.png" class="kg-image" alt="image-8" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-8.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-8.png 1000w, https://www.freecodecamp.org/korean/news/content/images/2025/12/image-8.png 1164w" sizes="(min-width: 720px) 720px" width="1164" height="740" loading="lazy"></figure><p>이제 두 이미지가 모두 다운로드되었으므로, 다음 단계는 MongoDB가 실행되고 접근 가능한지 확인한 다음, 브라우저를 통해 관리할 수 있도록 Mongo Express에 연결하는 것입니다.</p><p>그 전에, 이 두 컨테이너가 어떻게 통신할지 간단히 살펴봅시다.</p><h2 id="docker--1">Docker 네트워크</h2><p>MongoDB와 Mongo Express가 별도의 컨테이너에서 실행될 때 서로 통신할 방법이 필요합니다. Docker는 <strong>Docker 네트워크</strong>라는 것을 사용하여 이를 처리합니다. 이는 내부 포트를 외부 세계에 노출하지 않고 컨테이너가 안전하게 통신할 수 있게 해주는 가상 브리지입니다.</p><p>Docker에서 컨테이너를 실행하면 자동으로 격리된 네트워크가 생성됩니다. 이를 마치 컨테이너들이 외부 세계에 모든 것을 노출하지 않고 안전하게 서로 통신할 수 있는 개인 공간처럼 생각하면 됩니다.</p><p>예를 들어, MongoDB 컨테이너와 Mongo Express 컨테이너가 동일한 Docker 네트워크에 있으면 컨테이너 이름(예: <code>mongo</code> 또는 <code>mongo-express</code>)만 사용하여 통신할 수 있습니다. Docker가 이를 내부적으로 처리하므로<code>localhost</code>나 포트 번호를 사용할 필요가 없습니다.</p><p>하지만 Docker 네트워크 외부의 모든 것(여러분의 호스트 머신이나 Node.js 앱 같은)은 노출된 포트를 통해 연결됩니다.</p><p>따라서 나중에 전체 애플리케이션, Node.js 백엔드, MongoDB, Mongo Express, 심지어 프론트엔드(<code>index.html</code>)까지 Docker로 패키징하면, 이 모든 컨테이너가 Docker 네트워크를 통해 원활하게 상호 작용합니다. 그러면 컴퓨터의 브라우저는 우리가 노출한 호스트 주소와 포트를 사용하여 Node.js 앱에 연결됩니다.</p><p>기본적으로 Docker는 이미 몇 가지 내장 네트워크를 제공합니다. 다음을 실행하여 확인할 수 있습니다:</p><pre><code>docker network ls
</code></pre><p>다음과 같은 결과가 나올 것입니다:</p><pre><code class="language-bash">NETWORK ID     NAME      DRIVER    SCOPE
712a7144f1a0   bridge    bridge    local
4ae27eedea5b   host      host      local
4806000201ce   none      null      local
</code></pre><p>이것들은 Docker에 의해 자동으로 생성됩니다. 지금은 이것들에 대해 너무 걱정할 필요가 없습니다 – 우리는 커스튬(자체 사용자 정의) 네트워크를 만드는 데 집중하겠습니다.</p><p>설정을 위해 MongoDB와 Mongo Express가 공유할 수 있는 별도의 네트워크를 만들겠습니다. 이를 mongo-network라고 부르겠습니다:</p><pre><code class="language-bash">docker network create mongo-network
</code></pre><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-9.png" class="kg-image" alt="image-9" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-9.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-9.png 1000w, https://www.freecodecamp.org/korean/news/content/images/2025/12/image-9.png 1144w" sizes="(min-width: 720px) 720px" width="1144" height="740" loading="lazy"></figure><h2 id="mongo-">Mongo 컨테이너 실행 방법</h2><p>MongoDB와 Mongo Express 컨테이너가 통신할 수 있도록 하려면 동일한 Docker 네트워크 내에서 실행해야 합니다. 그래서 앞서 mongo-network를 만든 것입니다.</p><p>MongoDB부터 시작하겠습니다. <code>docker run</code> 명령은 이미지에서 컨테이너를 시작하는 데 사용된다는 것을 기억하세요. 이번에는 공식 MongoDB 이미지를 실행하고 네트워크에 연결할 것입니다.</p><p>또한 컨테이너 외부에서 접근할 수 있도록 기본 MongoDB 포트 27017을 노출하고, 루트 사용자 이름과 비밀번호에 대한 환경 변수를 설정합니다.</p><p>다음은 명령어입니다:</p><pre><code class="language-bash">docker run -p 27017:27017 -d \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  --name mongo \
  --network mongo-network \
  mongo
</code></pre><p>각 부분이 하는 일은 다음과 같습니다:</p><ul><li><code>-p 27017:27017</code>은 컨테이너의 MongoDB 포트를 호스트 머신에 매핑합니다.</li><li><code>-d</code>는 컨테이너를 분리 모드(백그라운드)에서 실행합니다.</li><li><code>-e</code>는 데이터베이스의 루트 크레덴셜(루트 자격 증명)에 대한 환경 변수를 설정합니다.</li><li><code>--name mongo</code>는 컨테이너에 사용자 정의 이름을 부여해 더 쉽게 참조할 수 있도록 합니다.</li><li><code>--network mongo-network</code>는 우리가 만든 네트워크에 컨테이너를 연결합니다.</li></ul><p>성공적으로 실행되면 MongoDB 인스턴스가 Docker 네트워크 내에서 실행되며, Mongo Express와 같은 다른 컨테이너가 연결할 준비가 됩니다.</p><p>MongoDB 컨테이너를 생성한 후 실행 중이고 정상인지 쉽게 확인할 수 있습니다.</p><p>먼저 <code>docker ps</code>를 실행하여 모든 활성 컨테이너를 확인하세요. MongoDB 컨테이너(<code>mongo</code>)가 포트 <code>27017</code>이 노출된 상태로 나열되어야 합니다. 컨테이너 내부에서 무슨 일이 일어나고 있는지에 대한 자세한 정보를 얻으려면 <code>docker logs mongo</code>를 사용하거나, 원한다면 컨테이너 ID를 사용하여(예: <code>docker logs 7abb38175ae28</code>) 로그를 확인할 수 있습니다. 로그는 MongoDB의 시작 메시지를 보여줄 것이며, 데이터베이스가 성공적으로 시작되었고 연결될 준비가 되었음을 나타내는 명령줄을 찾아야 합니다.</p><p>이것은 Mongo Express와 같은 다른 서비스를 연결하기 전에 모든 것이 올바르게 작동하는지 확인하는 빠른 방법입니다.</p><pre><code class="language-bash">docker ps
</code></pre><p>이것은 <strong>실행 중인 모든 컨테이너</strong>를 나열합니다. MongoDB 컨테이너(<code>mongo</code>)가 포트 <code>27017</code>이 노출된 상태로 표시되어야 합니다.</p><pre><code class="language-bash">docker logs mongo 또는 컨테이너의 id 예시: docker logs 7abb38175ae283429354609866c8d97521f37b535c475ae448295f8fc0ed947f
</code></pre><p>이것은 시작 메시지를 보여줍니다. MongoDB가 성공적으로 시작되었고 연결될 준비가 되었음을 나타내는 줄을 찾아보세요.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-10.png" class="kg-image" alt="image-10" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-10.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-10.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-10.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-10.png 2400w" sizes="(min-width: 720px) 720px" width="2504" height="222" loading="lazy"></figure><h2 id="mongo-express--1">Mongo Express 컨테이너 실행 방법</h2><p>이제 MongoDB가 실행 중이므로 MongoDB 데이터베이스를 관리하고 보기 위한 웹 기반 인터페이스인 Mongo Express를 실행할 수 있습니다. MongoDB와 통신할 수 있도록 동일한 네트워크(<code>mongo-network</code>)에 연결하겠습니다.</p><p>다음은 명령어입니다:</p><pre><code class="language-bash">docker run -d \
  -e ME_CONFIG_MONGODB_ADMINUSERNAME=admin \
  -e ME_CONFIG_MONGODB_ADMINPASSWORD=password \
  -e ME_CONFIG_MONGODB_SERVER=mongo \
  --name mongo-express \
  --network mongo-network \
  -p 8081:8081 \
  mongo-express
</code></pre><p>각 부분이 하는 일은 다음과 같습니다:</p><ul><li><code>-d</code>는 컨테이너를 분리 모드(백그라운드)에서 실행합니다.</li><li><code>-e ME_CONFIG_MONGODB_ADMINUSERNAME=admin</code>은 Mongo Express가 사용할 MongoDB 어드민(관리자) 사용자 이름을 설정합니다.</li><li><code>-e ME_CONFIG_MONGODB_ADMINPASSWORD=password</code>는 해당 MongoDB 비밀번호를 설정합니다.</li><li><code>-e ME_CONFIG_MONGODB_SERVER=mongo</code>는 Mongo Express에 연결할 MongoDB 서버를 알려줍니다. 두 컨테이너가 동일한 네트워크에 있기 때문에 여기서는 컨테이너 이름 <code>mongo</code>를 사용합니다.</li><li><code>--name mongo-express</code>는 더 쉽게 참조할 수 있도록 컨테이너에 친숙한 이름을 부여합니다.</li><li><code>--network mongo-network</code>는 MongoDB와 동일한 Docker 네트워크에 컨테이너를 연결하여 서로 통신할 수 있게 합니다.</li><li><code>-p 8081:8081</code>은 호스트 머신의 포트 8081에서 Mongo Express 웹 인터페이스를 노출합니다.</li><li><code>mongo-express</code>는 실행 중인 Docker 이미지의 이름입니다.</li></ul><p>컨테이너가 실행되면 브라우저를 열고 <code>http://localhost:8081</code>에 접속하여 Mongo Express에 액세스하고 MongoDB 인스턴스와 상호작용할 수 있습니다.</p><p>사용 가능한 환경 변수 및 옵션에 대한 자세한 내용은 <a href="https://hub.docker.com/_/mongo-express">Mongo Express의 공식 Docker Hub 페이지</a>에서 확인할 수 있습니다.</p><p>브라우저에서 <code><a href="http://localhost:8081/">http://localhost:8081</a></code>을 열기 전에 Mongo Express 컨테이너가 제대로 실행되고 있는지 확인하는 것이 좋습니다. 로그를 보면 확인할 수 있습니다:</p><pre><code class="language-bash">docker logs &lt;container-id&gt;
# 또는
docker logs mongo-express
</code></pre><p>다음과 유사한 출력이 표시되어야 합니다:</p><pre><code class="language-bash">Waiting for mongo:27017...
No custom config.js found, loading config.default.js
Welcome to mongo-express 1.0.2
------------------------
Mongo Express server listening at http://0.0.0.0:8081
Server is open to allow connections from anyone (0.0.0.0)
basicAuth credentials are "admin:pass", it is recommended you change this in your config.js!
</code></pre><p>이것은 Mongo Express가 실행 중이며 MongoDB 인스턴스에 연결할 준비가 되었음을 확인하는 것입니다.</p><p>로그에 표시된 basicAuth 자격 증명(admin:pass)을 기록해두세요. 이러한 자격 증명이 있는 경우 브라우저에서 Mongo Express에 액세스할 때 사용해야 합니다. 나중에 보안을 강화하기 위해 사용자 정의 config.js 파일에서 변경할 수 있습니다.</p><p>로그에서 모든 것이 정상으로 보이면 <a href="http://localhost:8081">http://localhost:8081</a>에 안전하게 접속하여 Mongo Express 인터페이스에 액세스할 수 있습니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-11.png" class="kg-image" alt="image-11" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-11.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-11.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-11.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-11.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="993" loading="lazy"></figure><p>Mongo Express에 액세스할 때 브라우저에서 사용자 이름과 비밀번호를 묻는 경우 컨테이너 로그에 표시된 basicAuth 자격 증명을 사용하세요:</p><pre><code class="language-text">사용자 이름: admin
비밀번호: pass</code></pre><p>이것들은 기본 자격 증명이며, 보안을 강화하기 위해 나중에 사용자 정의 <code>config.js</code> 파일에서 변경하는 것을 <strong>강력히 권장</strong>합니다.</p><p>Mongo Express를 열면 이미 생성된 일부 기본 데이터베이스가 표시됩니다. 이 프로젝트에서는 todos라는 새 데이터베이스를 만들 것입니다. 생성되면 Node.js 애플리케이션이 이 데이터베이스에 연결하여 데이터를 저장하고 검색할 수 있습니다.</p><h2 id="node-js-mongodb-">Node.js를 MongoDB에 연결하는 방법</h2><p>이미 Docker 컨테이너(mongo) 내에서 MongoDB가 실행되고 있습니다. 컨테이너는 기본 MongoDB 포트 27017을 호스트에 노출하므로 노트북/데스크톱의 모든 프로세스가 <a href="http://localhost:27017/">localhost:27017</a>을 통해 접근할 수 있습니다.</p><p><strong>중요</strong>: Node.js 앱은 <strong>Docker 외부</strong>에 있습니다(터미널에서 시작하는 일반 node server.js 프로세스입니다).</p><p>앱이 외부에 있기 때문에 호스트 이름으로 컨테이너 이름 mongo가 <strong>아닌</strong> localhost(또는 127.0.0.1)를 <strong>사용해야 합니다.</strong></p><p>나중에 Node.js 앱을 컨테이너화하고 동일한 Docker 네트워크에 배치하면 호스트를 mongo로 전환할 것입니다. 지금은 localhost로 유지하세요.</p><h2 id="node-js-">Node.js 백엔드</h2><p>다음은 MongoDB를 사용하는 <code>server.js </code>버전입니다:</p><pre><code class="language-javascript">const express = require("express");
const multer = require("multer");
const path = require("path");
const fs = require("fs");
const { MongoClient, ObjectId } = require("mongodb");

const app = express();
const PORT = 3000;

// Host = localhost  →  노출된 포트를 통해 MongoDB 컨테이너와 통신
// Port = 27017      →  기본 MongoDB 포트
// User / Pass       →  admin / password (컨테이너에 제공한 자격 증명)
const mongoUrl = "mongodb://admin:password@localhost:27017";
const dbName = "todos";
let db;

MongoClient.connect(mongoUrl)
  .then((client) =&gt; {
    db = client.db(dbName);
    console.log("Connected to MongoDB →", dbName);
  })
  .catch((err) =&gt; console.error("MongoDB connection error:", err));

const uploadDir = path.join(__dirname, "uploads");
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);

const storage = multer.diskStorage({
  destination: (req, file, cb) =&gt; cb(null, uploadDir),
  filename: (req, file, cb) =&gt; {
    const unique = Date.now() + "-" + Math.round(Math.random() * 1e9);
    cb(null, "photo-" + unique + path.extname(file.originalname));
  },
});
const upload = multer({ storage });

app.use(express.static(__dirname));
app.use("/uploads", express.static(uploadDir));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get("/todos", async (req, res) =&gt; {
  const todos = await db.collection("todos").find().toArray();
  res.json(todos);
});

app.post("/todos", upload.single("photo"), async (req, res) =&gt; {
  const text = req.body.text?.trim();
  if (!text) return res.status(400).json({ error: "Text required" });

  const todo = {
    text,
    image: req.file ? `/uploads/${req.file.filename}` : null,
    createdAt: new Date(),
  };

  const result = await db.collection("todos").insertOne(todo);
  todo._id = result.insertedId;
  res.json(todo);
});

// 서버 시작
app.listen(PORT, () =&gt; {
  console.log(`Server → http://localhost:${PORT}`);
});
</code></pre><h2 id="--10">프론트엔드</h2><p><code>index.html</code>:</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;title&gt;Todo + Image&lt;/title&gt;
    &lt;style&gt;
      body {
        font-family: sans-serif;
        margin: 2rem;
        max-width: 800px;
      }
      .todo {
        border: 1px solid #ccc;
        padding: 1rem;
        margin-bottom: 1rem;
        border-radius: 8px;
      }
      .todo img {
        max-height: 150px;
        margin-top: 0.5rem;
      }
      .error {
        color: red;
      }
      input[type="text"] {
        width: 100%;
        padding: 0.5rem;
        margin-bottom: 0.5rem;
      }
      #preview {
        max-width: 300px;
        margin-top: 0.5rem;
        display: none;
      }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Todo List with Images&lt;/h1&gt;

    &lt;div id="addForm"&gt;
      &lt;input type="text" id="textInput" placeholder="What needs to be done?" /&gt;
      &lt;input type="file" id="imageInput" accept="image/*" /&gt;
      &lt;img id="preview" alt="preview" /&gt;
      &lt;button id="addBtn"&gt;Add Todo&lt;/button&gt;
      &lt;p id="status"&gt;&lt;/p&gt;
    &lt;/div&gt;

    &lt;h2&gt;Todos&lt;/h2&gt;
    &lt;div id="todos"&gt;&lt;/div&gt;

    &lt;script&gt;
      const $ = document.querySelector.bind(document);

      const textInput = $("#textInput");
      const imageInput = $("#imageInput");
      const preview = $("#preview");
      const addBtn = $("#addBtn");
      const status = $("#status");
      const todosDiv = $("#todos");

      imageInput.addEventListener("change", () =&gt; {
        const file = imageInput.files[0];
        if (!file) {
          preview.style.display = "none";
          return;
        }
        const reader = new FileReader();
        reader.onload = (e) =&gt; {
          preview.src = e.target.result;
          preview.style.display = "block";
        };
        reader.readAsDataURL(file);
      });

      addBtn.addEventListener("click", async () =&gt; {
        const text = textInput.value.trim();
        if (!text) {
          status.textContent = "Please enter a todo text.";
          status.className = "error";
          return;
        }

        const form = new FormData();
        form.append("text", text);
        if (imageInput.files[0]) form.append("photo", imageInput.files[0]);

        try {
          const res = await fetch("/todos", { method: "POST", body: form });
          const data = await res.json();
          if (!res.ok) throw new Error(data.error || "failed");
          status.textContent = "Todo added!";
          status.className = "";
          textInput.value = "";
          imageInput.value = "";
          preview.style.display = "none";
          loadTodos(); // 목록 새로고침
        } catch (err) {
          status.textContent = "Error: " + err.message;
          status.className = "error";
        }
      });

      async function loadTodos() {
        const res = await fetch("/todos");
        const todos = await res.json();
        todosDiv.innerHTML = "";
        todos.forEach((t) =&gt; {
          const div = document.createElement("div");
          div.className = "todo";
          div.innerHTML = `&lt;strong&gt;${escapeHtml(t.text)}&lt;/strong&gt;`;
          if (t.image) {
            div.innerHTML += `&lt;br&gt;&lt;img src="${t.image}" alt="todo image"&gt;`;
          }
          todosDiv.appendChild(div);
        });
      }

      function escapeHtml(s) {
        const div = document.createElement("div");
        div.textContent = s;
        return div.innerHTML;
      }

      loadTodos();
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre><p>이제 Node.js 앱이 Docker에서 실행 중인 MongoDB 컨테이너에 연결할 수 있습니다. 앱이 현재 Docker 외부에서 실행되고 있으므로 설정한 자격 증명(<code>admin</code> / <code>password</code>)을 사용하여 <code>localhost:27017</code>을 통해 연결합니다.</p><p>연결되면 Node.js 백엔드는 메모리 내 배열을 대체하여 MongoDB의 todos 데이터베이스에서 직접 <code>todos</code>를 저장하고 검색합니다. 나중에 Node.js 앱을 컨테이너화하고 MongoDB와 동일한 Docker 네트워크에 배치하면 호스트를 <code>localhost</code>에서 컨테이너 이름 <code>mongo</code>로 전환할 수 있습니다. 우리는 그 방향으로 가고 있습니다.</p><p>코드를 실행하고 설정에 맞게 수정할 준비가 된 전체 백엔드 및 프론트엔드 코드는 이곳에서 얻을 수 있습니다: <a href="https://github.com/Oghenekparobo/docker_tut_js/tree/mongodb-connection">GitHub 저장소</a>.</p><h2 id="docker-compose-">Docker Compose 사용 방법</h2><p>이제 Node.js 앱이, 컨테이너 안에서 실행 중인 MongoDB와 Mongo Express에 연결되었습니다. 우리는 네트워크를 만들고 컨테이너를 시작했으며, 모든 서비스가 완벽하게 통신하고 있습니다.</p><p>하지만 솔직히 말해서: 매번 길고 복잡한 <code>docker run</code> 명령어를 입력하기란 꽤 번거롭습니다. 명령어 한 번에 모든 것을 실행할 수 있는 간단하고 깔끔한 방법이 있으면 좋겠죠? 바로 <strong>Docker Compose</strong>가 그 역할을 합니다.</p><p>Docker Compose는 하나의 명령으로, 여러 컨테이너로 구성된 애플리케이션을 정의하고 실행할 수 있는 도구입니다. 여러 개의 <code>docker run</code> 명령을 수동으로 실행하는 대신, 간단한 <code>docker-compose.yml</code> 파일에 각 서비스(예: Node.js 앱, MongoDB, Mongo Express)와 해당 구성, 환경 변수, 공유 네트워크를 지정하여, 설정을 정의할 수 있습니다.</p><p>즉, 여러 컨테이너를 하나의 프로젝트처럼 관리할 수 있게 도와주며,<br>시작, 중지, 유지 보수를 모두 하나의 파일과 명령으로 처리할 수 있습니다.</p><p><code>docker-compose.yml</code> 또는 <code>docker-compose.yaml</code> 중 어느 것이나 사용이 가능하지만, 관례상 <code>.yml</code> 확장자가 더 일반적입니다.</p><p>Docker는 실행 시 이를 자동으로 감지합니다:</p><pre><code class="language-bash">docker compose up
</code></pre><p>네, 관례대로 docker-compose.yml 파일을 사용하는 것이 좋습니다.</p><p>이제 MongoDB와 Mongo Express 컨테이너를 실행하려면 각각 다음 두 명령어를 사용하면 됩니다.</p><pre><code class="language-bash"># MongoDB container
docker run -p 27017:27017 -d \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  --name mongo \
  --network mongo-network \
  mongo

# Mongo Express container
docker run -d \
  -e ME_CONFIG_MONGODB_ADMINUSERNAME=admin \
  -e ME_CONFIG_MONGODB_ADMINPASSWORD=password \
  -e ME_CONFIG_MONGODB_SERVER=mongo \
  --name mongo-express \
  --network mongo-network \
  -p 8081:8081 \
  mongo-express
</code></pre><p>이제 매번 이러한 긴 명령어를 입력하는 대신, <strong>Docker Compose</strong> 파일을 사용하여 모든 명령어를 한 번에 실행할 것입니다.</p><p><code>docker-compose.yml</code> 파일은 Node.js 프로젝트의 루트 디렉터리에 위치해야 합니다.<br></p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-12.png" class="kg-image" alt="image-12" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-12.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-12.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-12.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-12.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1119" loading="lazy"></figure><p><code>docker-compose.yml</code> 파일은 다음과 같습니다:</p><pre><code class="language-yaml">version: "3.8"

services:
  mongodb:
    image: mongo
    container_name: mongo
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password

  mongo-express:
    image: mongo-express
    container_name: mongo-express
    ports:
      - "8081:8081"
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: admin
      ME_CONFIG_MONGODB_ADMINPASSWORD: password
      ME_CONFIG_MONGODB_SERVER: mongodb
    depends_on:
      - mongodb
</code></pre><p>지금 무슨 일이 벌어지고 있는지 하나씩 살펴보도록 하겠습니다.</p><ul><li><code>version: "3.8"</code> → <strong>Compose 파일의 버전</strong>을 정의합니다. 각 버전마다 조금씩 다른 문법 규칙과 기능이 있으며, 3.8 버전은 최신 Docker Engine과 호환되는 현대적인 버전입니다.</li><li><code>services:</code> → 실행할 모든 컨테이너를 정의하는 영역입니다. 여기서는 <code>mongodb</code>와 <code>mongo-express</code> 두 서비스입니다.</li></ul><p><strong>MongoDB 서비스</strong></p><ul><li><code>image: mongo</code>: Docker Hub에서 공식 MongoDB 이미지를 가져옵니다.</li><li><code>container_name: mongo</code>: 컨테이너에 친근한 이름을 지정합니다.</li><li><code>ports: "27017:27017"</code>: MongoDB의 기본 포트를 호스트에 노출하여 Node.js 또는 다른 애플리케이션이 연결할 수 있도록 합니다.</li><li><code>environment</code>: 초기 루트 사용자 이름과 비밀번호를 설정합니다.</li></ul><p><strong>Mongo Express 서비스</strong></p><ul><li><code>image: mongo-express</code>: 공식 Mongo Express 이미지를 사용합니다.</li><li><code>container_name: mongo-express</code>: 컨테이너 이름을 더 쉽게 참조할 수 있도록 친근하게 지정합니다.</li><li><code>ports: "8081:8081"</code>: 호스트의 8081 포트에서 Mongo Express 웹 인터페이스를 노출합니다.</li><li><code>environment</code>: Mongo Express에게 MongoDB를 연결하는 방법(사용자 이름, 비밀번호, 호스트)를 알려줍니다.</li><li><code>depends_on: - mongodb</code>: MongoDB가 먼저 실행된 후 Mongo Express가 즉시 시작되도록 보장합니다.</li></ul><h3 id="docker-compose--1">Docker Compose를 사용하는 이유</h3><ul><li><strong>한 번의 명령으로 시작 가능: </strong>여러 개의 <code>docker run</code> 명령 대신, 그저 아래 명령어를 실행하세요:</li></ul><pre><code>docker compose up -d
</code></pre><ul><li><strong>자동 네트워킹: </strong>Compose는 기본 네트워크를 생성하여 서비스들이 <strong>서비스 이름</strong>(우의 경우 <code>mongodb</code>)을 사용하여 통신할 수 있도록 합니다.</li><li><strong>쉽고 유연한 유지보수: </strong>모든 서비스를 함께 중지, 재시작, 재빌드할 수 있습니다.</li></ul><p>새로운 <code>docker-compose.yml</code> 파일을 실행하기 전에 충돌을 일으키는 컨테이너가 현재 실행 중이지 않은지 확인하는 것이 중요합니다. 이전 <code>docker run</code> 명령으로 MongoDB와 Mongo Express가 이미 실행 중이었음을 기억하세요.</p><p>충돌을 방지하려면(이미 사용 중인 포트처럼) 실행 중인 컨테이너를 모두 중지하고 제거해야 합니다.</p><p>방법은 다음과 같습니다.</p><pre><code class="language-bash"># 실행 중인 컨테이너 목록 보기
docker ps

# 개별 컨테이너 중지(&lt;container_name&gt; 부분을 mongo 또는 mongo-express로 대체)
docker stop mongo
docker stop mongo-express

# 중지된 컨테이너 삭제
docker rm mongo
docker rm mongo-express

# (선택) 실행 중인 모든 컨테이너 중지 및 삭제
docker stop $(docker ps -q)
docker rm $(docker ps -a -q)
</code></pre><ul><li><code>docker ps</code> 명령은 현재 실행 중인 컨테이너들을 보여줍니다.</li><li><code>docker stop &lt;name&gt;</code> 명령은 지정한 이름의 컨테이너를 정상적으로 중지합니다.</li><li><code>docker rm &lt;name&gt;</code> 명령은 중지된 컨테이너를 Docker에서 삭제합니다.</li><li><code>docker stop $(docker ps -q)</code> 명령은 현재 실행 중인 모든 컨테이너를 중지합니다.</li><li><code>docker rm $(docker ps -a -q)</code> 명령은 실행 중이든 중지 중이든 모든 컨테이너를 삭제합니다.</li></ul><p>이전 단계의 컨테이너를 모두 중지하고 제거했다면,<br>이제 포트 충돌 없이 안전하게 Docker Compose 환경을 실행할 준비가 된 것입니다.</p><p>이제 모든 기존 컨테이너가 중지되었으므로,<br><code>docker-compose.yml</code> 파일을 이용해 MongoDB와 Mongo Express를 함께 실행할 수 있습니다.</p><p>Node.js 프로젝트 루트(즉, <code>docker-compose.yml</code> 파일이 위치한 디렉터리)에서 다음 명령을 실행하세요:</p><pre><code class="language-bash">docker compose up -d
</code></pre><p>이 명령이 하는 일:</p><ul><li><code>docker compose</code> → Docker에게 Compose를 사용하라고 지시합니다.</li><li><code>up</code> → Compose 파일에 정의된 모든 서비스를 빌드(필요 시)하고 시작합니다.</li><li><code>-d</code> → 컨테이너를 백그라운드(분리 모드)로 실행시켜, 즉, (터미널을 차지하지 않고) 백그라운드에서 실행된다는 의미입니다.</li></ul><p>이 명령을 실행하면 Docker는 MongoDB와 Mongo Express를 동시에 시작하고,<br>두 컨테이너를 같은 내부 네트워크에 연결하며, 각 서비스에 대해 우리가 지정한 포트를 노출합니다.(MongoDB는 <code>27017</code>, Mongo Express는 <code>8081</code>)</p><p>모든 것이 정상적으로 작동했다면, 다음 명령을 실행한 후:﻿</p><pre><code class="language-bash">docker compose up -d</code></pre><p>정상적으로 실행되었다면 다음과 유사한 출력이 나타납니다:</p><pre><code class="language-bash">[+] Running 3/3
 ✔ Network docker_tut_default  Created                                                                                               0.0s 
 ✔ Container mongo             Started                                                                                               0.6s 
 ✔ Container mongo-express     Started                                                                                               0.8s 
stephenjohnson@Oghenekparobo docker_tut %
</code></pre><h3 id="--11">이 출력이 의미하는 것</h3><ul><li><code>Network docker_tut_default Created</code> → Docker Compose가 서비스 간 통신을 위해 새 네트워크를 자동으로 생성했습니다.</li><li><code>Container mongo Started</code> → MongoDB 컨테이너가 실행되었습니다.</li><li><code>Container mongo-express Started</code> → Mongo Express 컨테이너가 실행되었습니다.</li></ul><p>컨테이너가 실제로 실행 중인지 확인하려면 다음 명령을 입력합니다:</p><pre><code class="language-bash">docker ps
</code></pre><p>이 명령은 현재 활성 상태의 모든 컨테이너를 나열합니다. <code>mongo</code>와 <code>mongo-express</code> 컨테이너가 각각 MongoDB(포트 <code>27017</code>)와 Mongo Express(포트 <code>8081</code>)로 표시되어 있어야 합니다.</p><ul><li>Mongo Express에 접속하려면 브라우저를 열고 <a href="http://localhost:8081">http://localhost:8081</a>로 이동하여 웹 인터페이스를 통해 MongoDB와 상호작용하세요.</li><li>MongoDB에 접속하려면 Node.js 앱에서 Compose 파일에 설정한 자격 증명을 사용하여 <code>localhost:27017</code>로 MongoDB에 연결할 수 있습니다.</li></ul><p><code>docker run</code> 명령을 여러 번 사용하는 것과 비교했을 때, Docker Compose를 사용하는 것이 더 쉬운 이유는 다음과 같습니다:</p><ul><li>여러 컨테이너를 한 번의 명령으로 실행할 수 있습니다.</li><li>컨테이너 간 자동 네트워크 설정이 이루어집니다.</li><li>나중에 컨테이너를 중지, 삭제, 재빌드하기가 훨씬 쉽습니다.</li></ul><p>간단히 말해, Docker Compose는 모든 것을 단순화하고 체계화하여 개발 환경 관리를 훨씬 더 쉽게 만들어줍니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-13.png" class="kg-image" alt="image-13" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-13.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-13.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-13.png 1600w, https://www.freecodecamp.org/korean/news/content/images/2025/12/image-13.png 1962w" sizes="(min-width: 720px) 720px" width="1962" height="746" loading="lazy"></figure><p>이 단계에서 알아야 할 중요한 점은 MongoDB에 추가하는 모든 데이터는 임시적이라는 것입니다. &nbsp;컨테이너를 중지하거나 삭제한 뒤 다시 실행하면, 이전에 저장한 데이터가 모두 사라진 것을 확인할 수 있습니다. 이는 컨테이너 내부의 데이터가 기본적으로 영구 보존되지 않기 때문입니다.</p><p>걱정하지 마세요. 이것은 정상적인 동작입니다. 튜토리얼의 이후 단계에서 <strong>Docker Volumes</strong> 개념을 다루며 어떻게 데이터를 영구적으로 저장할 수 있는지 배울 예정입니다. </p><p>일단은 컨테이너를 재시작할 때마다 MongoDB가 초기 상태로 시작된다는 사실만 기억하시면 됩니다.</p><p>전체 예제(Dockerfile<strong>과</strong> docker-compose 파일을 포함한 예시)는 <a href="https://github.com/Oghenekparobo/docker_tut_js/tree/docker-compose">이 GitHub 링크</a>에서 확인하실 수 있습니다.</p><h2 id="-docker--1">나만의 Docker 이미지 빌드하는 방법</h2><p>이제 Node.js 애플리케이션을 로컬에서 테스트했고, MongoDB와 Mongo Express와도 완벽히 연동되어 작동하는 것을 확인했습니다. 다음 단계는 이제 배포를 위한 준비입니다.</p><p>로컬 환경에서 직접 애플리케이션을 실행하는 것은 개발 단계에서는 괜찮지만, 배포 환경이나 다른 서버로 옮기고자 할 때는 실용적이지 않습니다. Docker 이미지를 생성하여, 애플리케이션 코드, 의존성, 설정, 실행 환경을 하나의 이동 가능한 단위로 패키징할 수 있습니다. 이 이미지는 Docker가 설치된 어디서든 동일하게 실행될 수 있으며, 개발–테스트–운영 환경 간에 일관된 동작 보장이 가능합니다.</p><p>간단히 말해, Docker 이미지를 빌드한다는 것은 애플리케이션을 컨테이너화하고 배포 준비 상태로 만든다는 뜻입니다.</p><p>이번에는 우리가 만든 Todo 앱을 컨테이너화하기 위해 <strong>Dockerfile</strong>이 필요합니다.<br>Dockerfile은 Docker에게 이 애플리케이션으로 이미지를 어떻게 빌드할지를 알려주는 청사진(blueprint)입니다. 이 설정은 기본 환경을 정의하고, 애플리케이션 코드를 복사하며, 필요한 의존성을 설치하고, 애플리케이션이 어떻게 시작될지를 지정합니다. 이 청사진을 통해 Docker는 어떤 머신에서든 동일하게 동작하는 일관된 이미지를 생성할 수 있으며, 우리의 Node.js 애플리케이션을 완전히 이식 가능하고 배포 준비가 완료된 상태로 만들어 줍니다.</p><p>우리의 Dockerfile에서는 대문자 <code>D</code>를 사용하는 점에 주의하세요. 이는 표준 명명 규칙입니다. 이 파일은 Node.js 프로젝트의 <strong>루트 디렉토리</strong>에 위치시켜야 합니다. 단순한 프로젝트의 경우, <code>server.js</code> 또는 <code>index.js</code> 같은 메인 파일과 <code>package.json</code>이 루트에 함께 위치합니다. Docker는 이 파일을 청사진으로 사용하여 애플리케이션의 컨테이너 이미지를 빌드합니다.</p><p>만약 메인 앱 파일이 하위 폴더에 있다면 괜찮습니다. 단, Dockerfile 내 <code>COPY</code> 및 <code>CMD</code> 명령에서 정확한 경로를 지정해야 합니다. 중요한 점은 Dockerfile이 프로젝트 루트에 있어야 Docker가 여러분의 앱 빌드 기준점을 인식할 수 있다는 것입니다.</p><p>우리의 Dockerfile 내용은 다음과 같습니다:</p><pre><code class="language-dockerfile"># Use full Node 18 (Debian-based)
FROM node:18

# Set environment variables
ENV MONGO_DB_USERNAME=admin \
    MONGO_DB_PASSWORD=password

# Set working directory
WORKDIR /home/app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy source code
COPY . .

# Expose port
EXPOSE 3000

# Start the app
CMD ["node", "server.js"]
</code></pre><p>여기서 어떤 일이 일어나고 있는지 살펴보겠습니다:</p><ul><li><code>FROM node:13-alpine</code> 은 컨테이너의 기본 이미지로서 Node.js가 이미 설치되어 있으며 매우 가볍기 때문에 이미지를 작게 유지할 수 있습니다.</li><li><code>ENV MONGO_DB_USERNAME=admin \ MONGO_DB_PASSWORD=password</code> 는 컨테이너 내부에서 환경 변수를 설정하여 Node.js 애플리케이션이 MongoDB에 연결할 수 있게 합니다.</li><li><code>WORKDIR /home/app</code> 은 컨테이너 내부에서의 작업 디렉터리를 지정합니다. 이후 실행되는 <code>COPY</code>, <code>RUN</code> 등의 명령은 이 폴더 기준으로 수행됩니다.</li><li><code>COPY . .</code> 은 로컬 프로젝트의 모든 파일을 컨테이너의 작업 디렉토리로 복사합니다. 여기에는 <code>server.js</code>, <code>package.json</code>을 포함하여 애플리케이션을 실행하는 데 필요한 모든 파일이 포함됩니다.</li><li><code>RUN npm install</code> 은 <code>package.json</code>에 나열된 의존성을 컨테이너 안에 설치합니다.</li><li><code>EXPOSE 3000</code> 은 컨테이너가 3000 포트에서 수신 대기한다는 것을 Docker에 알려주며, 이는 우리 Node.js 애플리케이션이 실행되는 포트입니다.</li><li><code>CMD ["node", "server.js"]</code> 은 컨테이너가 시작될 때 실행되는 명령어를 정의하여 Node.js 서버를 실행합니다.</li></ul><p>이 Dockerfile을 프로젝트 루트에 배치함으로써 Docker는 애플리케이션 파일과 의존성을 정확히 어디서 찾아야 하는지 알게 됩니다. 이미지를 빌드할 때 이식 가능한 컨테이너에 모두 패키징하여 Docker가 설치된 어디서든 실행할 수 있게 하며, 배포를 간단하고 일관되도록 해줍니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-14.png" class="kg-image" alt="image-14" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-14.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-14.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-14.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-14.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="960" loading="lazy"></figure><p>이제 Dockerfile이 준비되었으니, 다음 단계는 Node.js 애플리케이션용 Docker 이미지를 빌드하는 것입니다.</p><p>이미지를 빌드하려면 터미널을 열고, 프로젝트 루트 디렉토리(Dockerfile이 있는 곳)에 있는지 확인한 뒤 다음 명령어를 실행하세요:</p><pre><code class="language-bash">docker build -t todo-app:1.0 .
</code></pre><ul><li><code>todo-app</code> → 생성할 이미지의 이름</li><li><code>:1.0</code> → 버전 태그 (예: <code>1.0</code>, <code>v1</code>, <code>latest</code> 등 자유롭게 지정 가능)</li><li><code>.</code> → Docker에게 현재 폴더(프로젝트 루트)를 빌드 컨텍스트로 사용하라고 알려줍니다.</li></ul><p>다음 명령어를 실행한 후:</p><pre><code class="language-bash">docker build -t todo-app:1.0 .
</code></pre><p>Docker는 Dockerfile을 읽고, Node.js 애플리케이션과 모든 의존성을 패키징하여 Docker 이미지를 생성합니다. 이미지가 생성되었는지 확인하려면 다음 명령어를 실행하세요:</p><pre><code class="language-bash">docker images
</code></pre><p>다음과 같은 출력이 나타날 것입니다:</p><pre><code class="language-bash">REPOSITORY      TAG       IMAGE ID       CREATED          SIZE
todo-app        1.0       d85dd4ed97f9   45 seconds ago   147MB
mongo           latest    1d659cebf5e9   2 weeks ago      894MB
mongo-express   latest    1133e12468c7   20 months ago    182MB
</code></pre><p>이 출력은 <code>todo-app</code> 이미지가 MongoDB와 Mongo Express 이미지와 함께 성공적으로 생성되었음을 보여줍니다.</p><h2 id="node-js--1">Node.js 앱 컨테이너 실행</h2><p>이제 이미지가 생성되었으니 다음 단계는 이 이미지를 사용해 컨테이너를 실행하는 것입니다. 컨테이너는 기본적으로 이미지의 실행 중인 인스턴스입니다. 이를 위해 다음 명령어를 실행하세요:</p><p><code>docker run todo-app:1.0</code></p><p>이 명령어는 다음을 수행합니다:</p><ul><li><code>docker run</code>은 이미지에서 새 컨테이너를 시작합니다.</li><li><code>todo-app:1.0</code>은 Docker에게 (방금 빌드한) 이미지를 사용하라고 알려줍니다.</li></ul><p>이 명령어가 실행되면 Node.js 애플리케이션이 컨테이너 내부에서 로컬 환경과 분리된 상태로 실행됩니다. 브라우저에서 <a href="http://localhost:3000/">http://localhost:3000</a>을 열어 Todo 앱이 로컬에서와 동일하게 작동하는 것을 확인할 수 있습니다.</p><p>실행 중인 모든 컨테이너를 보려면 다음을 사용하세요:</p><pre><code class="language-bash">docker ps
</code></pre><p>다음과 같은 출력을 볼 수 있습니다:</p><pre><code class="language-bash">CONTAINER ID   IMAGE           COMMAND         CREATED       STATUS       PORTS                  NAMES
d85dd4ed97f9   todo-app:1.0    "node server.js"  10s ago      Up 10s       0.0.0.0:3000-&gt;3000/tcp   awesome_todo
</code></pre><p>이것은 컨테이너가 실행 중임을 확인해 줍니다. 필요시 중지하려면:</p><pre><code class="language-bash">docker stop &lt;container-id&gt;
</code></pre><h2 id="--12">오류 해결</h2><p>여기서 문제가 발생합니다: <code>docker run todo-app:1.0</code>을 실행하면 다음과 같은 오류가 나타납니다:</p><pre><code>Server → http://localhost:3000 
MongoDB connection error: MongoServerSelectionError: getaddrinfo ENOTFOUND mongodb
    at Topology.selectServer (/home/app/node_modules/mongodb/lib/sdam/topology.js:346:38)
    ...
    [cause
</code></pre><p>특히 todo 리스트 생성 등의 작업을 시도할 때 발생합니다.</p><p><code>getaddrinfo ENOTFOUND mongodb</code> 오류는 Node.js 컨테이너가 MongoDB를 찾을 수 없음을 알려줍니다. MongoDB가 다른 컨테이너에서 실행 중이더라도 앱 컨테이너는 격리되어 있어 이를 찾을 수 없습니다.</p><h2 id="--13">왜 이런 일이 발생하는가:</h2><p><code>server.js</code>에서 MongoDB에 다음과 같이 연결하는 것을 기억하세요:</p><pre><code class="language-js">const mongoUrl = "mongodb://admin:password@localhost:27017";
</code></pre><p>문제는 <code>localhost</code>에 있습니다. 로컬 머신에서 앱을 실행할 때는 <code>localhost</code>가 완벽하게 작동하지만, Docker 컨테이너 내부에서는 <code>localhost</code>가 해당 컨테이너 자신만 가리킵니다.</p><p>비유하자면:</p><ul><li><strong>로컬 실행</strong>: 앱과 MongoDB가 같은 방에 있는 두 사람, <code>localhost</code> 작동</li><li><strong>Docker 실행</strong>: 각 컨테이너가 별도의 방, <code>localhost</code>는 그 방만 가리킴</li></ul><h2 id="--14">해결책</h2><p>MongoDB 연결 URL을 <code>localhost</code> 대신 Docker 서비스 이름으로 변경해야 합니다. <code>server.js</code> 파일을 업데이트하세요:</p><pre><code class="language-js">const mongoUrl = "mongodb://admin:password@localhost:27017";
</code></pre><p>다음으로 변경하세요:</p><pre><code class="language-js">const mongoUrl = "mongodb://admin:password@mongodb:27017";
</code></pre><p>완전한 업데이트된 <code>server.js</code>는 다음과 같습니다:</p><pre><code class="language-js">const express = require("express");
const multer = require("multer");
const path = require("path");
const fs = require("fs");
const { MongoClient, ObjectId } = require("mongodb");

const app = express();
const PORT = 3000;

// Host = localhost  →  talks to the MongoDB container via the exposed port
// Port = 27017      →  default MongoDB port
// User / Pass       →  admin / password (the credentials you gave the container)
const mongoUrl = "mongodb://admin:password@mongodb:27017";
const dbName = "todos";
let db;

MongoClient.connect(mongoUrl)
  .then((client) =&gt; {
    db = client.db(dbName);
    console.log("Connected to MongoDB →", dbName);
  })
  .catch((err) =&gt; console.error("MongoDB connection error:", err));

const uploadDir = path.join(__dirname, "uploads");
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);

const storage = multer.diskStorage({
  destination: (req, file, cb) =&gt; cb(null, uploadDir),
  filename: (req, file, cb) =&gt; {
    const unique = Date.now() + "-" + Math.round(Math.random() * 1e9);
    cb(null, "photo-" + unique + path.extname(file.originalname));
  },
});
const upload = multer({ storage });

app.use(express.static(__dirname));
app.use("/uploads", express.static(uploadDir));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get("/todos", async (req, res) =&gt; {
  const todos = await db.collection("todos").find().toArray();
  res.json(todos);
});

app.post("/todos", upload.single("photo"), async (req, res) =&gt; {
  const text = req.body.text?.trim();
  if (!text) return res.status(400).json({ error: "Text required" });

  const todo = {
    text,
    image: req.file ? `/uploads/${req.file.filename}` : null,
    createdAt: new Date(),
  };

  const result = await db.collection("todos").insertOne(todo);
  todo._id = result.insertedId;
  res.json(todo);
});

// Start server
app.listen(PORT, () =&gt; {
  console.log(`Server → http://localhost:${PORT}`);
});
</code></pre><h2 id="-mongodb-">왜 <code>mongodb</code>가 작동하는가</h2><p>호스트명 <code>mongodb</code>는 <code>docker-compose.yml</code>에서 정의한 서비스 이름과 일치합니다:</p><pre><code class="language-yaml">services:
  mongodb:    # ← 다른 컨테이너들이 사용하는 호스트명
    image: mongo
    container_name: mongo
    ...
</code></pre><p>같은 Docker Compose 네트워크에서 컨테이너들이 실행될 때, Docker는 내부 DNS를 제공하여 서비스 이름을 올바른 컨테이너 IP 주소로 해석합니다. 따라서 앱이 <code>mongodb:27017</code>에 연결하려고 하면 Docker가 자동으로 MongoDB 컨테이너로 라우팅합니다.</p><h2 id="docker--2">Docker 이미지 재빌드</h2><p>코드를 업데이트했으니 이 변경사항을 포함하여 Docker 이미지를 재빌드해야 합니다:</p><pre><code class="language-bash">docker build -t todo-app:1.0 .
``

You should see output confirming the build completed successfully:
``
[+] Building 8.1s (10/10) FINISHED
 =&gt; [internal] load build definition from Dockerfile
 =&gt; =&gt; transferring dockerfile: 443B
 ...
 =&gt; =&gt; naming to docker.io/library/todo-app:1.0
</code></pre><h2 id="docker-compose--2">Docker Compose에 앱 추가</h2><p>이제 <code>docker-compose.yml</code> 파일을 업데이트하여 <code>todo-app</code> 서비스를 포함하세요:</p><pre><code class="language-yaml">version: "3.8"

services:
  mongodb:
    image: mongo
    container_name: mongo
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password

  mongo-express:
    image: mongo-express
    container_name: mongo-express
    ports:
      - "8081:8081"
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: admin
      ME_CONFIG_MONGODB_ADMINPASSWORD: password
      ME_CONFIG_MONGODB_SERVER: mongodb
    depends_on:
      - mongodb

  todo-app:
    image: todo-app:1.0
    container_name: todo-app
    ports:
      - "3000:3000"
    depends_on:
      - mongodb
</code></pre><p><code>todo-app</code> 서비스는 다음을 포함합니다:</p><ul><li><code><strong>image: todo-app:1.0</strong></code> - 방금 재빌드한 Docker 이미지 사용</li><li><code><strong>container_name: todo-app</strong></code> - 컨테이너에 친근한 이름 부여</li><li><code><strong>ports: "3000:3000"</strong></code> - 3000 포트로 앱 노출</li><li><code><strong>depends_on: mongodb</strong></code> - MongoDB가 앱보다 먼저 시작되도록 보장</li></ul><h2 id="--15">모든 서비스 시작</h2><p>먼저 실행 중인 컨테이너들을 중지하세요:</p><pre><code class="language-bash">docker compose down
</code></pre><p><strong>로컬 시스템에서 3000 포트가 사용 중이라면 이를 중지하세요 (3000 포트 해제)</strong>.</p><p>이전에 로컬에서 서버를 실행했지만, 이제 Docker 이미지를 빌드했으므로 앱은 컨테이너 내부에서 실행되며 로컬 머신 환경에 더 이상 의존하지 않습니다.</p><pre><code>node server.js
Server → http://localhost:3000
</code></pre><p>이제 해당 터미널에서 Ctrl + C로 중지하세요.</p><p>그 다음 모든 것을 함께 시작하세요:</p><pre><code class="language-bash">docker compose up -d
``

You should see:

``
[+] Running 4/4
 ✔ Network docker_tut_default  Created
 ✔ Container mongo             Started
 ✔ Container mongo-express     Started
 ✔ Container todo-app          Started
</code></pre><h2 id="--16">모든 것이 작동하는지 확인</h2><p>모든 컨테이너가 실행 중인지 확인하세요:</p><pre><code class="language-bash">docker ps
``

예상 출력:
``
CONTAINER ID   IMAGE           COMMAND                  CREATED          STATUS          PORTS                      NAMES
a1b2c3d4e5f6   todo-app:1.0    "node server.js"         30 seconds ago   Up 28 seconds   0.0.0.0:3000-&gt;3000/tcp     todo-app
3d7c797fde1d   mongo-express   "/sbin/tini -- /dock…"   30 seconds ago   Up 29 seconds   0.0.0.0:8081-&gt;8081/tcp     mongo-express
4511ade73c38   mongo           "docker-entrypoint.s…"   30 seconds ago   Up 29 seconds   0.0.0.0:27017-&gt;27017/tcp   mongo
``

## 애플리케이션 테스트

이제 모든 것이 작동하는지 확인해보겠습니다:

### 1. Todo 앱 접속
브라우저를 열고 다음으로 이동하세요:
``
http://localhost:3000
``

### 2. Todo 항목 생성
기능을 테스트하기 위해 몇 개의 todo 항목을 추가하세요. 이미지 업로드도 시도해보세요!

### 3. Mongo Express에서 확인
Mongo Express를 열어보세요:
``
http://localhost:8081
</code></pre><p><code>todos</code> 데이터베이스, 그 다음 <code>todos</code> 컬렉션으로 이동하세요. 방금 생성한 모든 todo와 완전한 데이터가 표시됩니다.</p><h2 id="--17">무엇이 변경되었고 왜 작동하는가</h2><p><strong>수정 전:</strong></p><ul><li>연결 문자열이 <code>localhost:27017</code> 사용 ❌</li><li>컨테이너가 자기 자신에서 MongoDB 찾음</li><li><code>ENOTFOUND</code> 오류로 연결 실패</li></ul><p><strong>수정 후:</strong></p><ul><li>연결 문자열이 <code>mongodb:27017</code> 사용 ✅</li><li>Docker 내부 DNS가 <code>mongodb</code>를 MongoDB 컨테이너로 해석</li><li>연결 성공 및 데이터 정상 흐름</li></ul><p>이는 Docker 네트워킹의 핵심 교훈입니다: 컨테이너들은 <code>localhost</code>가 아닌 서비스 이름으로 통신합니다. Docker Compose는 모든 서비스가 이름으로 서로를 찾을 수 있는 네트워크를 자동 생성합니다.</p><h2 id="--18">컨테이너 관리 방법</h2><p>실행 중인 컨테이너를 관리하는 방법을 간단히 알아보겠습니다. 일반적으로 다음 명령어들을 사용합니다:</p><p><strong>모든 서비스 중지</strong>:</p><pre><code class="language-bash">docker compose down
</code></pre><p><strong>앱 로그 보기</strong>:</p><pre><code class="language-bash">docker compose logs todo-app
</code></pre><p><strong>실시간 로그 보기</strong>:</p><pre><code class="language-bash">docker compose logs -f todo-app
</code></pre><p><strong>코드 변경 후 재빌드</strong>:</p><pre><code class="language-bash">docker build -t todo-app:1.0 .
docker compose up -d --force-recreate todo-app
</code></pre><p>이제 애플리케이션이 완전히 컨테이너화되어 프로덕션 준비가 완료되었습니다. 세 서비스가 완벽하게 함께 작동하며, <code>docker-compose.yml</code> 파일과 빌드된 이미지만 있으면 Docker가 지원되는 어디서든 이 전체 스택을 배포할 수 있습니다.</p><p>전체 업데이트된 코드는 <a href="https://github.com/Oghenekparobo/docker_tut_js/tree/docker-image">여기</a>에서 확인하세요.</p><h2 id="-docker--2">프라이빗 Docker 레지스트리 생성 방법</h2><p>이제 커스텀 Docker 이미지를(로컬 머신 대신에) 프라이빗 컨테이너 레지스트리에 저장하고 싶습니다. 이는 세 가지 주요 장점을 제공합니다:</p><ul><li><strong>1. 제어된 접근</strong> - 명시적으로 권한을 부여한 사람이나 서버만 이미지를 가져오거나 업로드할 수 있습니다. 코드와 의존성이 비공개로 안전하게 유지됩니다.</li><li><strong>2. 안정적인 배포</strong> - 올바른 AWS 자격 증명을 가진 누구나(또는 어떤 서버도) 전 세계 어디서든 정확히 동일한 이미지를 가져올 수 있어 "내 머신에서는 작동하는데..." 문제가 사라집니다.</li><li><strong>3. 버전 관리 및 라이프사이클 관리</strong> - 여러 태그 버전(1.0, 2.0, latest 등)을 유지하고 필요시 쉽게 롤백할 수 있습니다.</li></ul><p>첫 번째 단계는 프라이빗 Docker 레지스트리(컨테이너 레지스트리)를 생성하는 것입니다. 여기서는 <a href="https://aws.amazon.com/ecr/">AWS Elastic Container Registry (ECR)</a>를 사용하겠습니다. Amazon ECR은 컨테이너 이미지와 아티팩트를 어디에서나 안전하게 저장·관리·공유·배포할 수 있게 해주는 완전관리형 컨테이너 레지스트리 서비스입니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-15.png" class="kg-image" alt="image-15" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-15.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-15.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-15.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-15.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1178" loading="lazy"></figure><p>홈페이지에 접속한 후 <strong>Create</strong> 버튼을 클릭하세요. 레포지토리 이름을 이미지와 동일하게 <code>todo-app</code>으로 지정한 후 Create를 클릭하여 설정을 완료합니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-16.png" class="kg-image" alt="image-16" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-16.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-16.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-16.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-16.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1178" loading="lazy"></figure><p>추가 옵션은 신경 쓰지 않아도 됩니다 - 이것은 AWS 튜토리얼이 아닙니다.</p><p><strong>참고</strong>: AWS ECR에서는 각 이미지가 고유한 레포지토리를 가지며, 해당 이미지의 다양한 태그 버전을 저장합니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-17.png" class="kg-image" alt="image-17" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-17.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-17.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-17.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-17.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1058" loading="lazy"></figure><p>이제 이미지를 프라이빗 레포지토리에 푸시하려면 두 가지를 해야 합니다. 먼저 프라이빗 레포에 로그인해야 합니다. AWS가 푸시를 허용하기 전에 본인을 인증해야 하기 때문입니다. 즉, 로컬 이미지를 레포에 푸시할 때 "이 레지스트리에 접근 권한이 있습니다. 여기 제 자격 증명입니다."라고 말하는 셈입니다.</p><p>우리의 경우 AWS ECR을 사용하므로 사용자 이름과 비밀번호를 수동으로 입력하는 대신 AWS를 통해 인증합니다.</p><h2 id="1-aws-">1단계: AWS 액세스 키 가져오기</h2><p>AWS 콘솔에서 액세스 키를 찾으려면 다음 단계를 따르세요:</p><p>1.	<a href="https://console.aws.amazon.com">https://console.aws.amazon.com</a> 에 접속해 AWS 콘솔에 로그인합니다.</p><p>2.	오른쪽 상단의 계정 이름을 클릭한 뒤 “보안 자격 증명(Security Credentials)”으로 이동합니다.</p><p>3.	아래로 스크롤해 "Access keys" 섹션을 찾습니다.</p><p>4.	액세스 키가 없다면:</p><ul><li>"Create access key"를 클릭합니다.</li><li>"Command Line Interface (CLI)"를 선택합니다.</li><li>확인 체크박스를 선택하고 Next를 클릭합니다.</li><li>설명을 입력(선택 사항)한 뒤 "Create access key"를 클릭합니다.</li></ul><p>5.<strong> 중요</strong>: <strong>액세스 키 ID</strong>(예: <code>AKIAIOSFODNN7EXAMPLE</code>처럼 보임)와 <strong>시크릿 액세스 키</strong>(예: <code>wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY</code>처럼 보임)를 둘 다 복사해 <strong>바로 저장하세요.</strong> 시크릿 키는 한 번만 표시되므로 잃어버리면 새 키 쌍을 만들어야 합니다.</p><p>또는, 다른 사람이 당신의 AWS 계정을 관리한다면 관리자에게 다음 사항을 요청해야 합니다:</p><ul><li>ECR 권한이 부여된 IAM 사용자</li><li>해당 사용자의 액세스 키 ID와 시크릿 액세스 키</li></ul><h2 id="2-aws-cli-">2단계: AWS CLI 설치 여부 확인</h2><p>다음 명령어로 확인할 수 있습니다:</p><pre><code class="language-bash">aws --version
</code></pre><h2 id="3-aws-cli-">3단계: AWS CLI에 자격 증명 설정</h2><p>다음 명령어를 실행합니다:</p><pre><code class="language-bash">aws configure
``

프롬프트에 다음 네 가지를 입력해야 합니다:
``
AWS Access Key ID [None]: &lt;여기에 Access Key ID를 붙여넣으세요&gt;
AWS Secret Access Key [None]: &lt;여기에 Secret Access Key를 붙여넉으세요&gt;
Default region name [None]: eu-north-1 또는 원하는 리전
Default output format [None]: json
</code></pre><p>프롬프트가 뜨면 키를 그대로 붙여넣고, 리전은 <code>eu-north-1</code> 또는 원하는 리전을 입력하고, 출력 형식은 <code>json</code>을 입력하거나 Enter를 눌러도 됩니다.</p><h2 id="4-aws-">4단계: AWS 설정 테스트</h2><p>모든 설정이 올바른지 확인하려면:</p><pre><code class="language-bash">aws sts get-caller-identity
</code></pre><p>정상이라면 AWS 계정 정보가 출력됩니다.</p><p><strong>5단계: ECR(Docker 레지스트리)에 로그인</strong><br>이제 ECR에 로그인합니다:</p><pre><code class="language-bash">aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin 244836489456.dkr.ecr.eu-north-1.amazonaws.com
</code></pre><p><strong>"Login Succeeded"</strong>가 출력되어야 합니다.</p><h2 id="docker--3">Docker 이미지 명명 방식 이해하기</h2><p>모든 Docker 이미지는 Docker에게 해당 이미지를 어디서 찾거나 저장할지를 알려주는 이름을 가지고 있습니다.<br>예를 들어 아래 명령을 실행하면:</p><pre><code class="language-bash">docker pull mongo:4.2
</code></pre><p>실제로는 다음에서 이미지를 가져옵니다:</p><pre><code>docker.io/library/mongo:4.2
</code></pre><p>여기에서 어떤 일이 일어나는지 살펴보겠습니다:</p><ul><li><code>[docker.io](http://docker.io/)</code> → 레지스트리(이 경우 Docker Hub)</li><li><code>library</code> → 공식 이미지에 대한 기본 네임스페이스</li><li><code>mongo</code> → 레포지토리 이름</li><li><code>4.2</code> → 이미지 태그</li></ul><p>`todo-app:1.0 같은 로컬 이미지를 빌드하면, 그 이미지는 여러분의 머신에만 존재합니다. 전체 레지스트리 경로를 포함하지 않으면 Docker는 그것을 어디에 푸시해야 할지 모릅니다.</p><p>AWS ECR을 사용할 때는 이미지 이름에 ECR 레지스트리 URL을 포함해야 합니다. 예를 들어:</p><pre><code class="language-bash">docker tag todo-app:1.0 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
</code></pre><p>그 다음 이렇게 푸시합니다:</p><pre><code class="language-bash">docker push 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
</code></pre><p>이 전체 경로가 없으면 Docker는 어떤 원격 레포지토리를 가리키는지 알 수 없습니다. 그래서 <code>todo-app:1.0</code>만으로는 동작하지 않습니다.</p><h2 id="6-">6단계: 이미지 빌드, 태그, 푸시</h2><p></p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-18.png" class="kg-image" alt="image-18" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-18.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-18.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-18.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-18.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1058" loading="lazy"></figure><pre><code>로컬 이미지를 전체 ECR 경로로 태그하세요.
docker tag todo-app:1.0 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0

그리고 푸시:
docker push 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
</code></pre><p><strong>주의</strong>: 이미지 태그를 지정하고 푸시할 때 주의하세요. 모든 ECR 저장소 URL은 특정 AWS 계정과 리전에 연결되어 있습니다.</p><p>예를 들어, 튜토리얼에서 사용하는 것은:</p><pre><code>244836489456.dkr.ecr.eu-north-1.amazonaws.com
</code></pre><p>하지만 여러분의 ECR URL은 사용하는 AWS 계정과 선택한 리전(예: us-east-1, ap-south-1 등)에 따라 서로 다르게 됩니다.</p><p>그래서 <code>docker tag</code>, <code>docker push</code>를 실행하기 전에, 레지스트리 URL, 리전을 반드시 여러분의 값으로 바꿔야 합니다.</p><p>그렇지 않으면 “tag does not exist”, “repository not found” 같은 오류가 납니다.</p><p>요약하자면, 당황하지 말고 리전을 다시 한 번 확인한 뒤, 이미지를 푸시하기 전에 AWS 콘솔에 표시된 ECR URL이 정확한지 항상 확인하세요.</p><p>6단계를 성공적으로 실행했다면 터미널에 다음과 비슷한 출력이 보일 것입니다.</p><pre><code class="language-bash">stephenjohnson@Oghenekparobo docker_tut % docker tag todo-app:1.0 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
stephenjohnson@Oghenekparobo docker_tut % docker push 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
The push refers to repository [244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app]
4f94b5cbe8ab: Pushed 
85ba7bf54231: Pushed 
4ea46a43fa07: Pushed 
dee30873f229: Pushed 
e78159dbd370: Pushed 
a358a725b813: Pushed 
cd8a6003174c: Pushed 
abb63e49e652: Pushed 
6cc65bdde70e: Pushed 
41a4e3939504: Pushed 
3520c50ae60e: Pushed 
75ba6634710f: Pushed 
1.0: digest: sha256:51f07267936fc94d9b677db8a760801e6c5fd4764f4bb2bd7b4dd150c756a39b size: 2842</code></pre><p>이 출력은 이미지가 프라이빗 AWS ECR 레포지토리에 성공적으로 푸시되었음을 의미합니다.</p><p>이제 AWS Management Console에서 ECR로 이동하면, 태그 <code>1.0</code>이 붙은 <code>todo-app</code> 이미지를 목록에서 확인할 수 있습니다.</p><p>이 시점부터 이미지는 AWS ECR에 안전하게 저장되어 있으며, 레포지토리에 접근할 수 있는 어느 곳에서든 가져오거나 배포할 준비가 된 상태입니다.</p><figure class="kg-card kg-image-card"><img src="https://www.freecodecamp.org/korean/news/content/images/2025/12/image-19.png" class="kg-image" alt="image-19" srcset="https://www.freecodecamp.org/korean/news/content/images/size/w600/2025/12/image-19.png 600w, https://www.freecodecamp.org/korean/news/content/images/size/w1000/2025/12/image-19.png 1000w, https://www.freecodecamp.org/korean/news/content/images/size/w1600/2025/12/image-19.png 1600w, https://www.freecodecamp.org/korean/news/content/images/size/w2400/2025/12/image-19.png 2400w" sizes="(min-width: 720px) 720px" width="2000" height="1006" loading="lazy"></figure><h2 id="--19">과제: 앱의 새 버전 만들기 및 푸시하기</h2><p>이제 첫 번째 이미지(<code>todo-app:1.0</code>)를 AWS ECR에 성공적으로 푸시했으니, 실제 환경에서 개발자가 앱을 수정하고 새 버전을 배포하는 워크플로를 한 번 시뮬레이션해보겠습니다.<br>이제 Node.js 앱에 작은 변경을 주고, 이미지를 다시 빌드한 뒤, 업데이트된 버전을 <code>todo-app:2.0</code>으로 푸시할 것입니다.</p><h3 id="--20">이미지 배포하기</h3><p>이제 Docker Compose를 사용해 이미지를 배포해보겠습니다.</p><p>지금까지는 로컬 이미지를 사용해 앱을 실행했습니다:</p><pre><code class="language-yaml">image: todo-app:1.0
</code></pre><p>하지만 이제 이미지를 AWS ECR에 올렸으므로, Docker가 이미지를 어디에서 가져와야 하는지 알 수 있도록 해당 줄을 전체 ECR 이미지 URI로 교체해야 합니다.</p><p>로컬 이미지:</p><pre><code class="language-yaml">image: todo-app:1.0
</code></pre><p>프라이빗 레포지토리 이미지(ECR):</p><pre><code class="language-yaml">image: 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0

</code></pre><p>Docker는 “todo-app:1.0”이 어디에 저장되어 있는지 마법처럼 추측해주지 않습니다. 전체 레지스트리 URL을 포함하지 않으면, Docker는 그것이 AWS가 아니라 <strong>로컬 머신</strong>에 있는 이미지라고 가정합니다.</p><p>다음은 ECR에서 앱 이미지를 가져오도록 정리되고, 수정되고, 올바르게 포맷된 docker-compose 파일입니다:</p><pre><code class="language-yaml">version: "3.8"

services:
  my-app:
    image: 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
    container_name: my-app
    ports:
      - "3000:3000"
    depends_on:
      - mongodb

  mongodb:
    image: mongo
    container_name: mongo
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password

  mongo-express:
    image: mongo-express
    container_name: mongo-express
    ports:
      - "8081:8081"
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: admin
      ME_CONFIG_MONGODB_ADMINPASSWORD: password
      ME_CONFIG_MONGODB_SERVER: mongodb
    depends_on:
      - mongodb
</code></pre><h4 id="-todo-app-my-app-">왜 서비스 이름을 “todo-app”이 아니라 “my-app”으로 했을까?</h4><p>여기서는 다음 둘을 헷갈리지 않게 하려고 이름을 바꿨습니다:</p><ul><li><strong>로컬</strong>의 “todo-app:1.0”</li><li><strong>ECR</strong>에 있는 “todo-app:1.0”</li></ul><p>이렇게 하면 좀 더 구분이 명확해지지만, 원한다면 다시 원래 이름으로 돌려도 됩니다.</p><h3 id="-ecr-url-">왜 ECR에는 전체 이미지 URL을 써야 할까?</h3><p>mongo, mongo-express 같은 다른 컨테이너는 다음처럼 동작합니다:</p><pre><code class="language-yaml">image: mongo
image: mongo-express
</code></pre><p>그 이유는 Docker가 이 이미지들이 <strong>Docker Hub</strong>에 있다는 것을 알고 있기 때문입니다.</p><p><br>하지만 AWS ECR 같은 프라이빗 레포지토리의 경우, 전체 경로를 주지 않으면 Docker는 “todo-app”이 어디에 있는지 전혀 알 수 없습니다:</p><pre><code>AWS_ACCOUNT_ID.dkr.ecr.REGION.amazonaws.com/repository_name:tag
</code></pre><p>이 정보는 Docker에게 다음을 알려 줍니다:</p><ul><li>어떤 AWS 계정인지</li><li>어떤 리전인지</li><li>어떤 레포지토리인지</li><li>어떤 버전(tag)인지</li></ul><p>이 URL이 없으면 Docker는 이미지를 가져올 수 없습니다.</p><p>프라이빗 ECR 레포지토리에서 가져오고 싶을 때마다(그리고 Docker Compose를 사용할 때도 마찬가지로) 반드시 로그인 상태여야 합니다.</p><p>다음 명령을 실행하세요:</p><pre><code class="language-bash">aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin 244836489456.dkr.ecr.eu-north-1.amazonaws.com
</code></pre><p>로그인되어 있지 않으면 Docker Compose는 보통 다음과 같은 오류를 냅니다:</p><p>❌ pull access denied<br>❌ repository does not exist<br>❌ no basic auth credentials</p><h3 id="docker-compose--3">Docker Compose로 앱 배포하기</h3><p>배포하기 전에, 포트 충돌이나 남은 컨테이너 때문에 문제가 생기지 않도록 기존 컨테이너를 중지하고 제거하는 것이 좋습니다:</p><pre><code class="language-yaml"># 이 프로젝트에서 실행 중인 모든 컨테이너 중지
docker-compose down --remove-orphans

#선택 사항: 아무 것도 실행 중이 아닌지 확인
docker ps</code></pre><p>이렇게 하면 3000 포트를 포함한 매핑된 포트가 비워져, 새 컨테이너를 시작할 때 발생할 수 있는 오류를 방지할 수 있습니다.</p><p>환경을 깨끗하게 정리했다면, 이제 스택을 배포합니다:</p><pre><code class="language-yaml">docker-compose up -d
</code></pre><p>Docker Compose는 다음 작업을 수행합니다:</p><ul><li>AWS ECR에 접속 → 인증한 뒤 프라이빗 레포지토리에서 <code>todo-app:1.0</code> 이미지를 가져옴</li><li>MongoDB 시작 → 설정한 자격 증명으로 DB 컨테이너 실행</li><li>Mongo Express 시작 → 웹 기반 MongoDB 관리 인터페이스 실행</li><li>Node.js 앱 시작 → MongoDB에 연결된 <code>my-app</code> 컨테이너 실행</li></ul><p>실행 중인 컨테이너를 확인하려면:</p><pre><code class="language-yaml">docker ps
</code></pre><p>다음과 같은 컨테이너들이 보여야 합니다:</p><ul><li>mongo</li><li>mongo-express</li><li>my-app</li></ul><p>만약 my-app이 시작에 실패했다면, 보통 <strong>3000 포트가 이미 사용 중인 경우</strong>입니다. 사용 중인 프로세스를 중지하여 포트를 비우세요:</p><pre><code class="language-yaml">lsof -i :3000
kill -9 &lt;PID&gt;  # if a process is using it</code></pre><p>그 후 다시 실행하세요:</p><pre><code class="language-yaml">docker-compose up -d
</code></pre><p>앱에 접속하려면:</p><ul><li>Node.js 앱: <a href="http://localhost:3000/">http://localhost:3000/</a></li><li>Mongo Express: <a href="http://localhost:8081/">http://localhost:8081/</a></li></ul><p>이 워크플로를 통해 깨끗한 상태에서 시작할 수 있고, 포트 충돌이나 컨테이너 충돌 같은 흔한 문제를 피할 수 있습니다.</p><h3 id="-docker--3">프라이빗 Docker 이미지 공유하기</h3><p>Node.js 앱 이미지를 AWS ECR에 푸시했다면, 이제 이미지는 프라이빗 레포지토리에 안전하게 저장된 상태입니다. 그렇다면 다른 개발자, 팀원, 또는 서버가 같은 이미지를 실행해야 할 때는 어떻게 할까요? 프라이빗이기 때문에 <code>mongo</code>나 <code>nginx</code> 같은 공개 이미지처럼 자동으로 가져올 수 없고, 반드시 <strong>인증된 접근</strong>이 필요합니다.</p><p>다음은 (다른 사람들이) 이미지를 가져와 사용하는 방법입니다.</p><h3 id="1-iam-">1. IAM 권한 부여</h3><p>협업자는 ECR 권한이 있는 <strong>AWS IAM 사용자 또는 역할</strong>이 필요합니다. 최소한 다음 작업을 허용하는 정책이 있어야 합니다:</p><ul><li><code>ecr:GetAuthorizationToken</code></li><li><code>ecr:BatchCheckLayerAvailability</code></li><li><code>ecr:GetDownloadUrlForLayer</code></li><li><code>ecr:BatchGetImage</code></li></ul><p>이를 위해 전용 IAM 사용자를 만든 다음, 해당 사용자에게 Access Key ID와 Secret Access Key를 제공할 수 있습니다.</p><h3 id="2-aws-cli--1">2. AWS CLI 설치 및 설정</h3><p>협업자는 AWS CLI가 설치되어 있어야 합니다. 그 다음 자신의 자격 증명으로 CLI를 설정합니다:</p><pre><code class="language-bash">aws configure
</code></pre><p>이때 다음 값을 입력합니다:</p><ul><li>Access Key ID</li><li>Secret Access Key</li><li>기본 리전(해당 ECR 레포지토리가 있는 리전, 예: <code>eu-north-1</code>)</li><li>기본 출력 형식(보통 <code>json</code>)</li></ul><h3 id="3-docker-ecr-">3. Docker를 ECR에 인증시키기</h3><p>이미지를 pull하기 전에, Docker는 AWS 자격 증명을 사용해 먼저 인증을 해야 합니다:</p><pre><code class="language-bash">aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin 244836489456.dkr.ecr.eu-north-1.amazonaws.com
</code></pre><p>성공하면 Docker가 다음과 같이 응답합니다:</p><pre><code>Login Succeeded
</code></pre><h3 id="4-pull">4. 이미지 Pull</h3><p>이제 협업자는 AWS 계정, 리전, 레포지토리 이름, 태그를 모두 포함한 전체 ECR URI로 이미지를 가져올 수 있습니다:</p><pre><code class="language-bash">docker pull 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
</code></pre><h3 id="5-">5. 컨테이너 실행</h3><p>이미지를 pull한 후에는 로컬에서 다음과 같이 컨테이너를 실행할 수 있습니다:</p><pre><code class="language-bash">docker run -p 3000:3000 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
</code></pre><p>또는 Docker Compose 파일에 포함시켜 <code>image:</code> 필드를 전체 ECR URI로 교체할 수 있습니다.</p><ul><li>mongo 같은 공개 이미지는 Docker Hub가 공개 레지스트리이기 때문에 이런 과정이 필요 없지만, 프라이빗 ECR 이미지는 반드시 명시적인 인증이 필요합니다.</li><li>프라이빗 레포지토리에서 pull을 할 때는 항상 유효한 로그인 상태가 필요합니다. Docker는 자격 증명을 추측할 수 없습니다.</li><li>전체 이미지 URI를 사용하는 것은 Docker가 이미지를 어디에서 가져와야 하는지 정확히 알 수 있게 해 줍니다.</li></ul><p>이 설정을 통해 팀은 로컬 머신, 스테이징 서버, 운영 환경 어디서든 애플리케이션을 공유, 배포, 실행할 수 있으며, 동시에 저장소를 비공개로 안전하게 유지할 수 있습니다.</p><h2 id="docker--4">Docker 볼륨</h2><p>MongoDB 같은 컨테이너를 실행할 때, 컨테이너 내부에서 생성된 데이터는 모두 일시적입니다. 컨테이너가 중지되거나 삭제되면 그 안의 데이터도 모두 사라집니다. 테스트 용도로는 괜찮지만, 프로덕션 환경에는 적합하지 않습니다.</p><p>이를 해결하기 위해 Docker는 <strong>볼륨</strong>을 제공합니다. 볼륨을 사용하면 컨테이너가 데이터를 컨테이너 외부(호스트 머신이나 Docker가 관리하는 스토리지)에 저장하여, 컨테이너가 재시작되거나 재빌드되거나 삭제되더라도 데이터가 유지되도록 할 수 있습니다.</p><h3 id="docker--5">Docker 볼륨의 동작 방식</h3><p>Docker 볼륨은 컨테이너를 위한 “지속되는 폴더”라고 생각하면 됩니다:</p><ul><li>볼륨에 기록된 데이터는 컨테이너가 삭제돼도 안전하게 남아 있습니다.</li><li>컨테이너는 이 볼륨에 자유롭게 읽기/쓰기를 할 수 있습니다.</li><li>볼륨은 데이터베이스, 로그, 파일 업로드, 그 외 애플리케이션에서 필요한 모든 영속 데이터에 필수적입니다.</li></ul><h3 id="docker--6">Docker 볼륨의 종류</h3><p>Docker에는 세 가지 주요 볼륨 유형이 있습니다:</p><h3 id="1-named-volumes-">1. 네임드 볼륨(Named Volumes)</h3><p>네임드 볼륨은 사용자가 이름을 부여한 볼륨으로, Docker가 완전히 관리합니다. 보통 프로덕션 데이터베이스나 여러 컨테이너가 공유해야 하는 영속 데이터에 사용합니다.</p><p>예시입니다:</p><pre><code class="language-yaml">volumes:
  mongo-data:
</code></pre><p>서비스에서의 모습입니다:</p><pre><code class="language-yaml">volumes:
  - mongo-data:/data/db
</code></pre><h3 id="2-bind-mounts-">2. 바인드 마운트(Bind Mounts)</h3><p>바인드 마운트는 <strong>호스트 머신</strong>의 폴더를 컨테이너 내부 경로에 직접 연결합니다. 개발 환경에서 코드, 로그, 업로드 파일 등을 실시간으로 동기화할 때 자주 사용합니다.</p><p>예시입니다:</p><pre><code class="language-yaml">volumes:
  - ./uploads:/usr/src/app/uploads
</code></pre><h3 id="3-anonymous-volumes-">3. 익명 볼륨(Anonymous Volumes)</h3><p>이름이 없는 볼륨입니다. Docker가 자동으로 임의의 이름을 붙입니다. 테스트용 임시 데이터처럼, 짧게 쓰고 버릴 데이터에 사용할 수 있지만, 프로덕션에서는 흔히 쓰이지 않습니다.</p><p>예시입니다:</p><pre><code class="language-yaml">volumes:
  - /data/tmp
</code></pre><h3 id="-docker-compose-">볼륨을 사용하는 Docker Compose 예제</h3><p>다음은 Node.js + MongoDB + Mongo Express 스택에서 가장 흔한 볼륨 유형을 사용하는 전체 <code>docker-compose.yml</code> 예제입니다:</p><pre><code class="language-yaml">version: "3.8"

services:
  my-app:
    image: 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
    container_name: my-app
    ports:
      - "3000:3000"
    depends_on:
      - mongodb
    volumes:
      - ./uploads:/usr/src/app/uploads  # 파일 업로드용 bind mount

  mongodb:
    image: mongo
    container_name: mongo
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password
    volumes:
      - mongo-data:/data/db  # 데이터베이스 영속 스토리지 네임드 볼륨

  mongo-express:
    image: mongo-express
    container_name: mongo-express
    ports:
      - "8081:8081"
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: admin
      ME_CONFIG_MONGODB_ADMINPASSWORD: password
      ME_CONFIG_MONGODB_SERVER: mongodb
    depends_on:
      - mongodb

volumes:
  mongo-data:  # 네임드 볼륨 정의
</code></pre><p>이 코드가 동작하는 방식:</p><ul><li><strong>MongoDB 볼륨(<code>mongo-data</code>)</strong>: 네임드 볼륨으로, 컨테이너 내부의 <code>/data/db</code> 아래에 있는 모든 DB 파일을 저장합니다. 컨테이너가 재시작·삭제·재빌드되어도 데이터는 유지됩니다.</li><li><strong>Node.js 업로드(<code>./uploads</code>)</strong>: 바인드 마운트로, 호스트의 <code>uploads</code> 폴더를 컨테이너의 <code>/usr/src/app/uploads</code>에 매핑합니다. 업로드된 파일은 즉시 호스트에서도 확인할 수 있습니다.</li><li><strong>익명 볼륨</strong>: 이 예제에는 포함되어 있지 않은데, 이름 없이 정의된 볼륨은 Docker가 자동으로 생성하며, 보통 프로덕션보다는 임시 용도로만 사용합니다.</li></ul><h3 id="--21">시각적 개념(단순화):</h3><pre><code>Host Machine(호스트 머신)
├─ /project/uploads   ← 컨테이너와 동기화되는 bind mount
├─ Docker Volumes
│  └─ mongo-data      ← MongoDB 영속 데이터를 위한 네임드 볼륨

Containers(컨테이너)
├─ my-app
│  └─ /usr/src/app/uploads  ← 호스트 uploads 폴더를 봄
├─ mongodb
│  └─ /data/db              ← 네임드 볼륨 mongo-data 사용
├─ mongo-express
</code></pre><h3 id="-takeaways-">핵심 정리 (Takeaways)</h3><ul><li>중요한 데이터에는 항상 볼륨을 사용하세요.</li><li>프로덕션 환경의 데이터베이스에는 네임드 볼륨이 가장 적합합니다.</li><li>바인드 마운트는 개발용과 실시간 동기화에 가장 잘 어울립니다.</li><li>익명 볼륨은 테스트를 제외하고는 거의 필요하지 않습니다.</li><li>볼륨은 컨테이너의 라이프사이클과 데이터의 라이프사이클을 분리해 주며, 이는 Docker 모범 사례의 핵심 개념입니다.</li></ul><h3 id="--22">애플리케이션 시작하기<br></h3><p>Docker Compose에 볼륨 설정까지 완료했다면, 다음 단계는 애플리케이션을 시작하고 볼륨이 제대로 동작하는지 확인하는 것입니다. 아래는 간단한 단계별 가이드입니다.</p><p><strong>1. 컨테이너 시작</strong></p><p>다음 명령을 실행하세요:</p><pre><code class="language-bash">docker-compose up -d
</code></pre><p><code>-d</code> 플래그는 컨테이너를 백그라운드(detached 모드)에서 실행한다는 의미입니다.</p><p>Docker는 다음을 수행합니다:</p><ul><li>(로그인되어 있다면) AWS ECR에서 앱 이미지를 pull합니다.</li><li>네임드 볼륨과 함께 MongoDB를 시작합니다.</li><li>Mongo Express를 시작합니다.</li><li>Node.js 앱을 시작합니다.</li></ul><p><strong>2. 실행 중인 컨테이너 확인</strong></p><p>모든 것이 제대로 시작되었는지 확인하려면:</p><pre><code class="language-bash">docker ps
</code></pre><p>다음과 비슷한 출력이 보여야 합니다:</p><pre><code class="language-bash">CONTAINER ID   IMAGE                                               STATUS          PORTS
2a2e120cc912   244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0   Up 5s    0.0.0.0:3000-&gt;3000/tcp
f4d5a1ab1234   mongo                                               Up 5s          0.0.0.0:27017-&gt;27017/tcp
c3d5b2bc2345   mongo-express                                      Up 5s          0.0.0.0:8081-&gt;8081/tcp</code></pre><p><strong>3. 볼륨 확인</strong></p><p>Docker 볼륨 목록을 보려면:</p><pre><code class="language-bash">docker volume ls
</code></pre><p>여기서 예를 들어 <code>mongo-data</code> 같은 네임드 볼륨을 확인할 수 있어야 합니다.</p><p>볼륨을 자세히 보려면:</p><pre><code class="language-bash">docker volume inspect docker_tut_mongo-data
</code></pre><p>그러면 Docker가 호스트에서 MongoDB 데이터를 어디에 저장하는지 보여주는데, 예를 들어 다음과 같이 표시됩니다:<br></p><pre><code class="language-bash">[
    {
        "Name": "mongo-data",
        "Driver": "local",
        "Mountpoint": "/var/lib/docker/volumes/mongo-data/_data",
        "Labels": {},
        "Scope": "local"
    }
]</code></pre><p><strong>MongoDB 컨테이너 내부의 <code>/data/db</code>에 저장되는 모든 것은 실제로 호스트의 이 경로에 저장됩니다.</strong></p><p><strong>4. 데이터 영속성 테스트</strong><br><br>	1. MongoDB 또는 앱에 접속해서 데이터를 몇 개 추가해 보세요.<br>	2. 그 다음 컨테이너를 중지 및 삭제합니다:</p><pre><code class="language-bash">docker-compose down</code></pre><p>	3. 이후 다시 앱을 시작합니다:</p><pre><code class="language-bash">docker-compose up -d</code></pre><p>	4. 데이터를 다시 확인해 보세요.</p><ul><li>MongoDB가 네임드 볼륨을 사용하고 있기 때문에 데이터는 그대로 남아 있습니다.</li><li>이로써 볼륨이 영속적으로 동작한다는 것이 증명됩니다.</li></ul><p>5. 선택: Node.js 업로드(바인드 마운트) 확인</p><ul><li>앱을 통해 파일을 업로드했다면, 프로젝트 폴더의 <code>./uploads</code>를 확인해 보세요.</li><li>바인드 마운트는 호스트와 컨테이너 디렉터리를 동기화하므로, 업로드한 파일이 호스트 머신의 <code>./uploads</code> 폴더에도 생성되어 있어야 합니다.</li></ul><h2 id="--23">결론</h2><p>잘하셨습니다! 이 포괄적인 Docker 튜토리얼의 끝에 도달했습니다. 여러분은 컨테이너와 이미지의 기본을 풀어내는 것부터 네트워킹, Docker Compose, 볼륨, 심지어 비공개 AWS ECR 저장소 배포까지, 완전히 컨테이너화된(프로덕션할 준비가 되고 확장 가능한) Node.js 애플리케이션 스택을 구축했습니다.<br>이것들은 실제 시나리오에서 애플리케이션을 개발하고, 협업하고, 배포하는 방식을 바꿔놓을 실전 스킬들입니다.</p><p>끈질기게 따라와 주셔서 감사합니다. Docker는 처음엔 압도적으로 느껴질 수 있습니다 – 그 긴 명령어들, 네트워킹 특이점들, 영속 데이터 문제들은 결코 사소하지 않죠.<br>하지만 여기까지 왔다는 것은? 가파른 학습 곡선을 정복하고 개발 여정에서 새로운 경지에 도달했다는 뜻입니다.<br>이제 "내 컴퓨터에서는 잘 작동하는데"라는 골칫거리를 없애고, CI/CD 파이프라인을 간소화하며, 백엔드나 풀스택 전문가로 한 단계 업그레이드할 준비가 되었습니다.</p><p>계속 실험해보세요: todo-app을 수정하거나, Dockerfile에서 멀티스테이지 빌드(도커 다중 단계 빌드 - 단일 Dockerfile 내에서 여러 개의 빌드 단계를 정의하여 최종 이미지 크기를 최적화하고 빌드 효율성을 높이는 기술)를 시도하거나, 다음으로 Kubernetes 같은 오케스트레이션 도구를 탐구해보세요. Docker 생태계는 방대하지만, 이 기초를 갖췄으니 더 깊이 파고들 준비가 되었습니다. 문제가 생기거나 질문이 있으면 Docker Hub, Stack Overflow, GitHub 커뮤니티를 활용하세요.</p><p>최종 코드는 <a href="https://github.com/Oghenekparobo/docker_tut_js/tree/final" rel="nofollow noopener">여기</a>에서 확인할 수 있습니다.</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Python과 Gemini로 AI 코딩 에이전트 만들기 ]]>
                </title>
                <description>
                    <![CDATA[ 이 핸드북에서는 Google의 무료 Gemini API [https://ai.google.dev/gemini-api/docs/pricing]를 활용해 Claude Code의 기본 버전을 직접 만들어봅니다. 만약 Cursor나 Claude Code 같은 “에이전트형” AI 코드 에디터를 써본 적이 있다면, 이번에 만들 프로젝트의 개념도 익숙할 거예요. 사실 LLM을 활용할 수 있다면, (다소) 꽤 효과적인 맞춤형 에이전트를 구축하는 것은 놀라울 정도로 간단합니다. 이 핸드북은 완전히 ]]>
                </description>
                <link>https://www.freecodecamp.org/korean/news/build-an-ai-coding-agent-with-python-and-gemini/</link>
                <guid isPermaLink="false">690493b0d35fdc04ded3dcf9</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ changseop yeom ]]>
                </dc:creator>
                <pubDate>Mon, 24 Nov 2025 03:23:02 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/korean/news/content/images/2025/11/2470669e-8592-463e-8b4c-55eace8dd80a.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p data-test-label="translation-intro">
        <strong>기사 원문:</strong> <a href="https://www.freecodecamp.org/news/build-an-ai-coding-agent-with-python-and-gemini/" target="_blank" rel="noopener noreferrer" data-test-label="original-article-link">How to Build an AI Coding Agent with Python and Gemini</a>
      </p><p>이 핸드북에서는 Google의 <a href="https://ai.google.dev/gemini-api/docs/pricing">무료 Gemini API</a>를 활용해 Claude Code의 기본 버전을 직접 만들어봅니다. 만약 Cursor나 Claude Code 같은 “에이전트형” AI 코드 에디터를 써본 적이 있다면, 이번에 만들 프로젝트의 개념도 익숙할 거예요. 사실 LLM을 활용할 수 있다면, (다소) 꽤 효과적인 맞춤형 에이전트를 구축하는 것은 놀라울 정도로 간단합니다.</p><p>이 핸드북은 완전히 무료인 텍스트 기반 핸드북입니다. 다만, 따라해 볼 수 있는 두 가지 다른 옵션이 있습니다:</p><p>코딩 챌린지와 프로젝트가 포함된 <a href="https://www.boot.dev/courses/build-ai-agent-python">Boot.dev에서 제공하는 AI Agent 강의</a>의 인터랙티브 버전을 체험하거나 FreeCodeCamp 유튜브 채널에서 제공하는 이 강의의 <a href="https://www.youtube.com/watch?v=YtHdaXuOAks">단계별 안내 영상</a>을 시청할 수도 있습니다.</p><figure class="kg-card kg-embed-card" data-test-label="fitted">
        <div class="fluid-width-video-container">
          <div style="padding-top: 56.49999999999999%;" class="fluid-width-video-wrapper">
            <iframe width="200" height="113" src="https://www.youtube.com/embed/YtHdaXuOAks?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" title="Guide to Agentic AI – Build a Python Coding Agent with Gemini" name="fitvid0"></iframe>
          </div>
        </div>
      </figure><h2 id="-">사전 준비</h2><ul><li>이 과정을 시작하기 전에 Python의 기본 문법과 개념에 익숙해야 합니다.<br>아직 배우지 않았다면, <a href="https://www.boot.dev/courses/learn-code-python">Boot.dev의 Python 강의</a>에서 기초를 익혀보세요. </li><li>또한 Unix 계열 명령줄 사용법도 알고 있어야 합니다. 아직 익숙하지 않다면, <a href="https://www.boot.dev/courses/learn-linux">Boot.dev의 Linux </a> 강의를 참고하세요.</li></ul><h2 id="--1"><strong>목차</strong></h2><ul><li>전제 조건</li><li>에이전트의 역할은 무엇인가요?</li><li>학습 목표</li><li>파이썬 설정</li><li>Gemini API를 통합하는 방법</li><li>명령줄 입력</li><li>메시지 구조</li><li>Verbose 모드</li><li>계산기 프로젝트를 빌드하는 방법</li><li>에이전트 기능</li><li>시스템 프롬프트</li><li>기능 선언</li><li>더 많은 기능 선언</li><li>함수 호출</li><li>에이전트 루프 구축</li><li>결론</li></ul><h2 id="--2">에이전트의 역할은 무엇인가요?</h2><p>우리가 만들 프로그램은 다음과 같은 CLI 도구입니다:</p><ol><li>코딩 작업을 입력받습니다 (예시: "내 앱에서 문자열이 제대로 분할되지 않아요, 수정해주세요")</li></ol><p>2. 미리 정의된 함수 세트 중에서 선택하여 작업을 수행합니다, 예를 들어:</p><ul><li>디렉토리의 파일들을 스캔</li><li>파일의 내용을 읽기</li><li>파일의 내용을 덮어쓰기</li><li>파일에 대해 Python 인터프리터 실행</li></ul><p>3. 작업이 완료될 때까지 2단계를 반복합니다 (또는 비참하게 실패할 수도 있습니다)</p><p>예를 들어, 버그가 있는 계산기 앱이 있어서 제 에이전트를 사용해 코드를 수정했습니다:</p><pre><code class="language-bash">&gt; uv run main.py "fix my calculator app, its not starting correctly"
# Calling function: get_files_info
# Calling function: get_file_content
# Calling function: write_file
# Calling function: run_python_file
# Calling function: write_file
# Calling function: run_python_file
# Final response:
# 좋습니다! 계산기 앱이 이제 정상적으로 작동하는 것 같습니다. 출력에는 식과 결과가 포맷된 형태로 표시됩니다.</code></pre><h2 id="--3">학습 목표</h2><p>이 프로젝트의 학습 목표는 다음과 같습니다:</p><ul><li>여러 디렉토리를 사용하는 Python 프로젝트를 소개합니다</li><li>실제 업무에서 반드시 사용하게 될 AI 도구들이 실제로 어떻게 동작하는지 이해합니다</li><li>Python과 함수형 프로그래밍 기술을 연습합니다</li></ul><p>목표는 LLM을 처음부터 만드는 것이 아니라, 사전 학습된 LLM을 사용하여 에이전트를 처음부터 구축하는 것입니다.</p><h2 id="python-">Python 설정</h2><p>프로젝트를 위해 가상 환경을 설정해봅시다.<br>가상 환경은 (우리가 사용할 Google AI 라이브러리처럼) 각 프로젝트의 의존성을 다른 프로젝트와 분리할 수 있도록 해주는 Python의 방식입니다.</p><p><code>uv</code>를 사용하여 새 프로젝트를 생성합니다. 이 명령어는 디렉터리를 생성하고 Git도 초기화합니다:</p><pre><code class="language-bash">uv init your-project-name
cd your-project-name
</code></pre><p>프로젝트 디렉토리 최상위에 가상 환경을 생성합니다:</p><pre><code class="language-bash">uv venv
</code></pre><p><strong>경고:</strong> 항상 <code>venv</code> 디렉토리를 <code>.gitignore</code> 파일에 추가하세요.</p><p>가상 환경을 활성화합니다:</p><pre><code class="language-bash">source .venv/bin/activate
</code></pre><p>터미널 프롬프트 시작 부분에 <code>(your-project-name)</code>이 보여야 합니다. 예를 들면 다음과 같습니다:</p><pre><code>(aiagent) wagslane@MacBook-Pro-2 aiagent %
</code></pre><p><code>uv</code>를 사용하여 프로젝트에 두 개의 의존성을 추가합니다. &nbsp;<code>pyproject.toml</code> 파일에 저장될 것입니다:</p><pre><code class="language-bash">uv add google-genai==1.12.1
uv add python-dotenv==1.1.0
</code></pre><p>이렇게 하면 파이썬 프로젝트가 <code>google-genai</code> 버전 <code>1.12.1</code>과 <code>python-dotenv</code> 버전 <code>1.1.0</code>을 필요로 한다는 것을 지정하게 됩니다.</p><p><code>uv</code> 가상 환경을 활용해서 프로젝트를 실행하려면 다음을 입력합니다:</p><pre><code class="language-bash">uv run main.py
</code></pre><p>터미널에서 <code>Hello from YOUR PROJECT NAME</code>이 보여야 합니다.</p><h2 id="gemini-api-">Gemini API 통합 방법</h2><p><a href="https://www.cloudflare.com/learning/ai/what-is-large-language-model/">대규모 언어 모델(LLM)</a>은 최근 AI 세계에서 큰 주목을 받고 있는 첨단 AI 기술입니다. ChatGPT, Claude, Cursor, Google Gemini는 모두 LLM 기반입니다. 이 과정의 목적상, LLM을 똑똑한 텍스트 생성기로 생각할 수 있습니다. ChatGPT처럼 프롬프트를 입력하면, 답변이라 생각되는 텍스트를 반환합니다.</p><p>이 과정에서는 <a href="https://ai.google.dev/gemini-api/docs/pricing">Google의 Gemini API</a>를 사용하여 에이전트를 구동할 것입니다. 꽤 똑똑하지만, 더 중요한 것은 무료 티어가 있다는 점입니다.</p><h3 id="--4"><strong>토큰</strong></h3><p>토큰은 LLM의 통화라고 생각할 수 있습니다. 토큰은 LLM이 처리해야 하는 텍스트 양을 측정하는 방식입니다. 대부분의 모델에서 토큰은 <a href="https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them">대략 4글자</a>입니다. LLM API로 작업할 때 얼마나 많은 토큰을 사용하고 있는지 이해하는 것이 중요합니다.</p><p>우리는 Gemini API의 무료 티어 한도 내에서 충분히 사용할 수 있지만, 그래도 토큰 사용량을 모니터링할 것입니다!</p><p><strong>경고:</strong> 로컬 테스트를 포함하여 모든 API 호출은 무료 티어의 토큰을 소비한다는 점을 알아야 합니다. 한도 초과 시 강의를 계속하려면 (일반적으로 24시간) 한도가 재설정될 때까지 기다려야 할 수 있습니다. API 키를 재생성해도 할당량은 재설정되지 않습니다.</p><p>API 키 생성 방법:</p><ol><li><a href="https://aistudio.google.com/">Google AI Studio</a>에 아직 계정이 없다면 계정을 만듭니다</li><li>"Create API Key" 버튼을 클릭합니다. 길을 잃었을 경우 이 <a href="https://ai.google.dev/gemini-api/docs/api-key">문서</a>를 참고하실 수 있습니다.</li></ol><p>이미 GCP 계정과 프로젝트가 있다면, 해당 프로젝트에서 API 키를 생성할 수 있습니다. 없다면 AI Studio가 자동으로 생성해줍니다.</p><p>3. API 키를 복사한 다음 프로젝트 디렉토리의 새 <code>.env</code> 파일에 붙여넣습니다. 파일은 다음과 같아야 합니다:</p><pre><code class="language-bash">GEMINI_API_KEY="your_api_key_here"
</code></pre><p>4. <code>.env</code> 파일을 <code>.gitignore</code>에 추가합니다</p><p><strong>경고: </strong>API 키, 비밀번호 또는 기타 민감한 정보를 Git에 커밋해서는 안 됩니다.</p><p>5. <code>main.py</code> 파일을 업데이트합니다. 프로그램이 시작될 때 <code>dotenv</code> 라이브러리를 사용하여 <code>.env</code> 파일에서 환경 변수를 로드하고 API 키를 읽습니다:</p><pre><code class="language-python">import os
from dotenv import load_dotenv

load_dotenv()
api_key = os.environ.get("GEMINI_API_KEY")
</code></pre><p>6. <code>genai</code> 라이브러리를 임포트하고 API 키를 사용하여 <a href="https://googleapis.github.io/python-genai/#create-a-client">Gemini 클라이언트</a>의 새 인스턴스를 생성합니다:</p><pre><code class="language-python">from google import genai

client = genai.Client(api_key=api_key)
</code></pre><p>7. <a href="https://googleapis.github.io/python-genai/#generate-content"><code>client.models.generate_content()</code></a> 메서드를 사용하여 <code>gemini-2.0-flash-001</code> 모델로부터 응답을 받습니다. 두 개의 인자를 사용해야 합니다:</p><ul><li><code>model</code>: 모델명 <code>gemini-2.0-flash-001</code> (무료 티어가 넉넉합니다)</li><li><code>content</code>: 모델에 보낼 프롬프트(문자열). 이 프롬프트를 사용합니다:</li></ul><p>"Boot.dev와 FreeCodeCamp는 왜 백엔드 개발을 배우기에 훌륭한 곳인가요? 최대 한 단락으로 설명하세요."</p><p><code>generate_content</code> 메서드는 <a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentResponse"><code>GenerateContentResponse</code> 객체</a>를 반환합니다. 응답의 <a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentResponse.text">.text 속성</a>을 출력하여 모델의 답변을 볼 수 있습니다.</p><p>모두 정상적으로 동작하면, 코드를 실행했을 때 터미널에 모델의 답변이 나옵니다.</p><p>8. 추가로, 텍스트 답변 외에 상호작용에 사용된 토큰 수를 다음 형식으로 출력합니다:</p><pre><code class="language-plaintext">Prompt tokens: X
Response tokens: Y
</code></pre><p>응답에는 다음을 모두 포함하는 <a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentResponseDict.usage_metadata">.usage_metadata</a> 속성이 있습니다:</p><ul><li><code>prompt_token_count</code> 속성 (프롬프트의 토큰 수)</li><li><code>candidates_token_count</code> 속성 (응답의 토큰 수)</li></ul><p><strong>경고:</strong> Gemini API는 외부 웹 서비스이며 때때로 느리고 불안정할 수 있습니다. 인내심을 갖고 기다려 주세요.</p><h2 id="--5">명령줄 입력</h2><p>우리는 현재 Gemini로 전달되는 프롬프트를 하드코딩했는데, 이는 그다지 유용하지 않습니다. 이제 프롬프트를 명령줄 인자로 받을 수 있도록 코드를 수정해 봅시다.</p><p>사용자가 프롬프트를 바꿀 때마다 코드를 직접 수정하지 않도록 말입니다.</p><p>프롬프트를 명령줄 인자로 받을 수 있도록 코드를 수정하세요. 예를 들어:</p><pre><code class="language-bash">uv run main.py "Why are episodes 7-9 so much worse than 1-6?"
</code></pre><p><strong>팁:</strong> <a href="https://docs.python.org/3/library/sys.html#sys.argv"><code>sys.argv</code></a> 변수는 스크립트에 전달된 모든 명령줄 인자를 문자열 리스트로 제공합니다. 첫 번째 요소는 스크립트 이름이고 그 뒤는 인자들입니다. 사용을 위해서는 반드시 <code>import sys</code>를 추가해야 합니다.</p><p>만약 프롬프트가 제공되지 않으면, 오류 메시지를 출력하고 exit 코드 1로 프로그램을 종료하세요</p><h2 id="--6">메시지 구조</h2><p>LLM API는 일반적으로 "원샷" 방식으로 사용되지 않습니다. 예를 들어: </p><ul><li>프롬프트: "삶의 의미는 무엇인가요?"</li><li>응답: "42"</li></ul><p>ChatGPT가 대화에서 동작하는 것과 같이 작동합니다. 대화에는 히스토리가 있으며, 우리가 그 히스토리를 관리하면 각 새 프롬프트마다 모델이 전체 대화 맥락을 참고해 답변을 할 수 있습니다.</p><h3 id="--7">역할</h3><p>중요한 것은 대화의 각 메시지에는 "역할"이 있다는 것입니다. ChatGPT 같은 채팅 앱에서는 이러한 구조로 대화가 이어집니다:</p><ul><li><strong>사용자:</strong> "삶의 의미는 무엇인가요?"</li><li><strong>모델:</strong> "42입니다."</li><li><strong>사용자:</strong> "잠깐, 방금 뭐라고 했나요?"</li><li><strong>모델:</strong> "42입니다. 그것은 삶과 우주, 그리고 모든 것에 대한 궁극적인 질문의 답입니다."</li><li><strong>사용자:</strong> "하지만 왜요?"</li><li><strong>모델:</strong> "Douglas Adams가 그렇게 말했기 때문입니다."</li></ul><p>따라서 비록 우리 프로그램이 현재는 "원샷" 구조로 동작하더라도, 대화의 메시지를 리스트로 저장하고, &nbsp;"역할"을 적절히 전달하도록 코드를 업데이트하겠습니다.</p><p><code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Content">types.Content</a></code> 객체의 새 리스트를 만들고 (현재는) 사용자 프롬프트만 유일한 메시지로 설정하세요:</p><pre><code class="language-python">from google.genai import types

messages = [
    types.Content(role="user", parts=[types.Part(text=user_prompt)]),
]
</code></pre><p><a href="https://googleapis.github.io/python-genai/genai.html#genai.models.Models.generate_content"><code>models.generate_content</code></a> 호출 시 messages 리스트를 사용하도록 업데이트합니다:</p><pre><code class="language-python">response = client.models.generate_content(
    model="gemini-2.0-flash-001",
    contents=messages,
)
</code></pre><p><strong>참고:</strong> 앞으로, 에이전트가 작업을 반복할 때 메시지가 추가될 예정입니다.</p><h2 id="verbose-">Verbose 모드</h2><p>AI 에이전트를 디버깅하고 개발하면서 콘솔에 훨씬 더 많은 정보를 출력하고 싶지만, 동시에 CLI 도구의 사용자 경험을 너무 시끄럽게 만들고 싶지는 않습니다.</p><p>선택적 명령줄 플래그인 <code>--verbose</code>를 추가하여 "verbose" 출력을 켜고 끌 수 있도록 하겠습니다. 자세한 정보를 보고 싶을 때만 이 옵션을 켜면 됩니다.</p><p><br>새로운 명령줄 인자 <code>--verbose</code>를 추가합니다. 포함된다면 프롬프트 다음 위치에 추가합니다. 예를 들어:</p><pre><code class="language-bash">uv run main.py "What is the meaning of life?" --verbose
</code></pre><p><code>--verbose</code> 플래그가 포함되면 콘솔 출력에 다음 정보가 나타나야 합니다:</p><ul><li>사용자의 프롬프트: <code>"User prompt: {user_prompt}"</code></li><li>각 반복에서프롬프트 토큰 수: <code>"Prompt tokens: {prompt_tokens}"</code></li><li>각 반복의 응답 토큰 수: <code>"Response tokens: {response_tokens}"</code></li></ul><p>그렇지 않으면 이러한 내용들을 출력하지 않아야 합니다.</p><h2 id="--8">계산기 프로젝트 구축 방법</h2><p>AI 에이전트를 만들고 있으므로, 에이전트가 작업할 프로젝트가 필요합니다. 간단한 명령줄 계산기 앱을 준비했으니, 이 코드를 테스트 프로젝트로써 AI가 읽고 수정하고 실행하게 할 것입니다.</p><p>먼저 프로젝트 루트에 <code>calculator</code>라는 새 디렉토리를 만듭니다. 그런 다음 아래의 <code>main.py</code>와 <code>tests.py</code> 파일을 <code>calculator</code> 디렉토리에 복사하여 붙여넣습니다.</p><p><em>이 코드가 어떻게 동작하는지에 대해 너무 걱정하지 마세요 - 우리 프로젝트는 계산기를 만드는 것이 아니라, 우리 AI 에이전트 프로젝트가 다룰 테스트 프로젝트입니다!</em></p><pre><code class="language-python"># main.py
import sys
from pkg.calculator import Calculator
from pkg.render import format_json_output

def main():
    calculator = Calculator()
    if len(sys.argv) &lt;= 1:
        print("Calculator App")
        print('Usage: python main.py "&lt;expression&gt;"')
        print('Example: python main.py "3 + 5"')
        return

    expression = " ".join(sys.argv[1:])
    try:
        result = calculator.evaluate(expression)
        if result is not None:
            to_print = format_json_output(expression, result)
            print(to_print)
        else:
            print("Error: Expression is empty or contains only whitespace.")
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    main()
</code></pre><pre><code class="language-python"># tests.py

import unittest
from pkg.calculator import Calculator

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calculator = Calculator()

    def test_addition(self):
        result = self.calculator.evaluate("3 + 5")
        self.assertEqual(result, 8)

    def test_subtraction(self):
        result = self.calculator.evaluate("10 - 4")
        self.assertEqual(result, 6)

    def test_multiplication(self):
        result = self.calculator.evaluate("3 * 4")
        self.assertEqual(result, 12)

    def test_division(self):
        result = self.calculator.evaluate("10 / 2")
        self.assertEqual(result, 5)

    def test_nested_expression(self):
        result = self.calculator.evaluate("3 * 4 + 5")
        self.assertEqual(result, 17)

    def test_complex_expression(self):
        result = self.calculator.evaluate("2 * 3 - 8 / 2 + 5")
        self.assertEqual(result, 7)

    def test_empty_expression(self):
        result = self.calculator.evaluate("")
        self.assertIsNone(result)

    def test_invalid_operator(self):
        with self.assertRaises(ValueError):
            self.calculator.evaluate("$ 3 5")

    def test_not_enough_operands(self):
        with self.assertRaises(ValueError):
            self.calculator.evaluate("+ 3")

if __name__ == "__main__":
    unittest.main()
</code></pre><p><code>calculator</code> 안에 새<code>pkg</code> 디렉토리를 만드세요. 그리고 아래 <code>calculator.py</code>와 <code>render.py</code> 파일을 <code>pkg</code> 디렉토리에 복사해 붙여넣으세요.</p><pre><code class="language-python"># calculator.py

class Calculator:
    def __init__(self):
        self.operators = {
            "+": lambda a, b: a + b,
            "-": lambda a, b: a - b,
            "*": lambda a, b: a * b,
            "/": lambda a, b: a / b,
        }
        self.precedence = {
            "+": 1,
            "-": 1,
            "*": 2,
            "/": 2,
        }
    def evaluate(self, expression):
        if not expression or expression.isspace():
            return None
        tokens = expression.strip().split()
        return self._evaluate_infix(tokens)


    def evaluateinfix(self, tokens):
        values = []
        operators = []

        for token in tokens:
            if token in self.operators:
                while (
                    operators
                    and operators[-1] in self.operators
                    and self.precedence[operators[-1]] &gt;= self.precedence[token]
                ):
                    self._apply_operator(operators, values)
                operators.append(token)

            else:
                try:
                    values.append(float(token))
                except ValueError:
                    raise ValueError(f"invalid token: {token}")

        while operators:
            self._apply_operator(operators, values)

        if len(values) != 1:
            raise ValueError("invalid expression")

        return values[0]

    def applyoperator(self, operators, values):
        if not operators:
            return

        operator = operators.pop()
        if len(values) &lt; 2:
            raise ValueError(f"not enough operands for operator {operator}")

        b = values.pop()
        a = values.pop()
        values.append(self.operators[operator](a, b))
</code></pre><pre><code class="language-python"># render.py

import json

def format_json_output(expression: str, result: float, indent: int = 2) -&gt; str:
    if isinstance(result, float) and result.is_integer():
        result_to_dump = int(result)
    else:
        result_to_dump = result

    output_data = {
        "expression": expression,
        "result": result_to_dump,
    }
    return json.dumps(output_data, indent=indent)
</code></pre><p>이것이 최종 구조입니다:</p><pre><code>├── calculator
│   ├── main.py
│   ├── pkg
│   │   ├── calculator.py
│   │   └── render.py
│   └── tests.py
├── main.py
├── pyproject.toml
└── .env
</code></pre><p><code>calculator</code> 테스트를 실행합니다:</p><pre><code class="language-bash">uv run calculator/tests.py
</code></pre><p>모든 테스트가 통과하기를 바랍니다!</p><p>이제 계산기 앱을 실행합니다:</p><pre><code class="language-bash">uv run calculator/main.py "3 + 5"
</code></pre><p>8이 나오기를 바랍니다!</p><h2 id="--9">에이전트 함수</h2><p>에이전트에게 작업을 수행할 수 있는 능력을 제공해야 합니다. 디렉토리의 내용을 나열하고 파일의 메타데이터(이름과 크기)를 볼 수 있는 기능부터 시작하겠습니다.</p><p>이 함수를 LLM 에이전트와 통합하기 전에 먼저 함수 자체만 만들어봅시다. 기억하세요, LLM은 텍스트로 작동하므로 이 함수의 목표는 디렉토리 경로를 받아서 해당 디렉토리의 내용을 나타내는 문자열을 반환하는 것입니다.</p><p>프로젝트 루트에 <code>functions</code>라는 새 디렉토리를 만듭니다(<code>calculator</code> 디렉토리 안이 아닙니다). 그 디렉토리 안에 <code>get_files_info.py</code>라는 새 파일을 만듭니다. 그 파일 안에 이 함수를 작성합니다:</p><pre><code class="language-python">def get_files_info(working_directory, directory="."):
</code></pre><p>지금까지의 프로젝트 구조는 다음과 같습니다:</p><pre><code>project_root/
 ├── calculator/
 │   ├── main.py
 │   ├── pkg/
 │   │   ├── calculator.py
 │   │   └── render.py
 │   └── tests.py
 └── functions/
     └── get_files_info.py
</code></pre><p><code>directory</code> 매개변수는 <code>working_directory</code> 내의 상대 경로로 처리되어야 합니다. <code>os.path.join(working_directory, directory)</code>를 사용하여 전체 경로를 만든 다음 작업 디렉토리 경계 내에 있는지 검증합니다.</p><p><code>directory</code>의 절대 경로가 <code>working_directory</code> 외부에 있으면 문자열 오류 메시지를 반환합니다:</p><pre><code class="language-python">f'Error: Cannot list "{directory}" as it is outside the permitted working directory'
</code></pre><p><strong>위험:</strong> 이 제한이 없으면 LLM이 머신의 어디에서나 마구 실행되어 민감한 파일을 읽거나 중요한 데이터를 덮어쓸 수 있습니다. 이것은 LLM이 호출할 수 있는 모든 함수에 포함할 매우 중요한 단계입니다.</p><p><code>directory</code> 인자가 디렉토리가 아니면, 다시, 오류 문자열을 반환합니다:</p><pre><code class="language-python">f'Error: "{directory}" is not a directory'
</code></pre><p><strong>경고:</strong> <code>get_files_info</code>를 포함한 모든 "도구 호출" 함수는 항상 문자열을 반환해야 합니다. 내부에서 오류가 발생할 수 있다면 이러한 오류를 잡아내고 대신 오류를 설명하는 문자열을 반환해야 합니다. 이렇게 하면 LLM이 오류를 우아하게 처리할 수 있습니다.</p><p>디렉토리의 내용을 나타내는 문자열을 구축하고 반환합니다. 다음 형식을 사용해야 합니다:</p><pre><code class="language-bash">- README.md: file_size=1032 bytes, is_dir=False
- src: file_size=128 bytes, is_dir=True
- package.json: file_size=1234 bytes, is_dir=False
</code></pre><p><strong>팁:</strong> 정확한 파일 크기와 파일 순서조차도 운영 체제와 파일 시스템에 따라 다를 수 있습니다. 출력이 예제와 바이트 단위로 정확히 일치할 필요는 없고, 전체적인 형식만 맞으면 됩니다.</p><p>표준 라이브러리 함수에서 오류가 발생하면 이를 잡아내고 대신 오류를 설명하는 문자열을 반환합니다. 오류 문자열 앞에는 항상 "Error:"를 붙입니다.</p><p>완전한 구현은 다음과 같습니다:</p><pre><code class="language-python">import os

def get_files_info(working_directory, directory="."):
    abs_working_dir = os.path.abspath(working_directory)
    target_dir = os.path.abspath(os.path.join(working_directory, directory))
    if not target_dir.startswith(abs_working_dir):
        return f'Error: Cannot list "{directory}" as it is outside the permitted working directory'
    if not os.path.isdir(target_dir):
        return f'Error: "{directory}" is not a directory'
    try:
        files_info = []
        for filename in os.listdir(target_dir):
            filepath = os.path.join(target_dir, filename)
            file_size = os.path.getsize(filepath)
            is_dir = os.path.isdir(filepath)
            files_info.append(
                f"- {filename}: file_size={file_size} bytes, is_dir={is_dir}"
            )
        return "\n".join(files_info)
    except Exception as e:
        return f"Error listing files: {e}"
</code></pre><p>다음은 도움이 될 몇 가지 표준 라이브러리 함수들입니다:</p><ul><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.abspath">os.path.abspath()</a></code>: 상대 경로에서 절대 경로 얻기</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.join">os.path.join()</a></code>: 두 경로를 안전하게 결합 (슬래시 처리)</li><li><code><a href="https://docs.python.org/3/library/stdtypes.html#str.startswith">.startswith()</a></code>: 문자열이 부분 문자열로 시작하는지 확인</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.isdir">os.path.isdir()</a></code>: 경로가 디렉토리인지 확인</li><li><code><a href="https://docs.python.org/3/library/os.html#os.listdir">os.listdir()</a></code>: 디렉토리의 내용 나열</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.getsize">os.path.getsize()</a></code>: 파일의 크기 얻기</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.isfile">os.path.isfile()</a></code>: 경로가 파일인지 확인</li><li><code><a href="https://docs.python.org/3/library/stdtypes.html#str.join">.join()</a></code>: 구분자로 문자열 리스트를 결합</li></ul><h3 id="--10">파일 내용 가져오기 함수</h3><p>이제 디렉토리의 내용을 가져올 수 있는 함수가 있으니, 파일의 내용을 가져올 수 있는 함수가 필요합니다. 다시 말하자면, 파일 내용을 문자열로 반환하거나, 문제가 발생하면 오류 문자열을 반환합니다.</p><p>항상 그렇듯이, 안전하게 함수의 범위를 특정 작업 디렉토리로 지정합니다.</p><p><code>functions</code> 디렉토리에 새 함수를 만듭니다. 제가 사용한 시그니처는 다음과 같습니다:</p><pre><code class="language-python">def get_file_content(working_directory, file_path):
</code></pre><p><code>file_path</code>가 <code>working_directory</code> 외부에 있으면 오류가 있는 문자열을 반환합니다:</p><pre><code class="language-python">f'Error: Cannot read "{file_path}" as it is outside the permitted working directory'
</code></pre><p><code>file_path</code>가 파일이 아니면, 다시, 오류 문자열을 반환합니다:</p><pre><code class="language-python">f'Error: File not found or is not a regular file: "{file_path}"'
</code></pre><p>파일을 읽고 그 내용을 문자열로 반환합니다.</p><ul><li>파일이 <code>10000</code>자보다 길면 <code>10000</code>자로 잘라내고 끝에 이 메시지를 추가합니다: <code>[...File "{file_path}" truncated at 10000 characters]</code>.</li><li><code>10000</code>자 제한을 하드코딩하는 대신 <code>config.py</code> 파일에 저장했습니다.</li></ul><p><strong>경고:</strong> 실수로 거대한 파일을 읽고 모든 데이터를 LLM에 보내고 싶지 않습니다. 그것은 토큰 한도를 소진하는 좋은 방법입니다.</p><p>표준 라이브러리 함수에서 오류가 발생하면 이를 잡아내고 대신 오류를 설명하는 문자열을 반환합니다. 오류 앞에는 항상 "Error:"를 붙입니다.</p><p>먼저 <code>config.py</code>를 만듭니다:</p><pre><code class="language-python">MAX_CHARS = 10000
WORKING_DIR = "./calculator"
</code></pre><p>다음은 <code>functions/get_file_content.py</code>에 대한 완전한 구현입니다:</p><pre><code class="language-python">import os
from config import MAX_CHARS

def get_file_content(working_directory, file_path):
    abs_working_dir = os.path.abspath(working_directory)
    abs_file_path = os.path.abspath(os.path.join(working_directory, file_path))
    if not abs_file_path.startswith(abs_working_dir):
        return f'Error: Cannot read "{file_path}" as it is outside the permitted working directory'
    if not os.path.isfile(abs_file_path):
        return f'Error: File not found or is not a regular file: "{file_path}"'
    try:
        with open(abs_file_path, "r") as f:
            content = f.read(MAX_CHARS)
            if os.path.getsize(abs_file_path) &gt; MAX_CHARS:
                content += f'[...File "{file_path}" truncated at {MAX_CHARS} characters]'
        return content
    except Exception as e:
        return f'Error reading file "{file_path}": {e}'
</code></pre><ul><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.abspath">os.path.abspath</a></code>: 상대 경로에서 절대 경로 얻기</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.join">os.path.join</a></code>: 두 경로를 안전하게 결합 (슬래시 처리)</li><li><code><a href="https://docs.python.org/3/library/stdtypes.html#str.startswith">.startswith</a></code>: 문자열이 특정 부분 문자열로 시작하는지 확인</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.isfile">os.path.isfile</a></code>: 경로가 파일인지 확인</li></ul><p>파일에서 읽는 예제:</p><pre><code class="language-python">MAX_CHARS = 10000

with open(file_path, "r") as f:
    file_content_string = f.read(MAX_CHARS)
</code></pre><h3 id="--11">파일 쓰기 함수</h3><p>지금까지 우리 프로그램은 읽기 전용이었습니다만... 이제 정말 <s>위험하고</s> 재미있어집니다! 에이전트에게 파일을 쓰고 덮어쓸 수 있는 능력을 제공하겠습니다.</p><p><code>functions</code> 디렉토리에 새 함수를 만듭니다. 제가 사용한 시그니처는 다음과 같습니다:</p><pre><code class="language-python">def write_file(working_directory, file_path, content):
</code></pre><p><code>file_path</code>가 <code>working_directory</code> 외부에 있으면 오류가 있는 문자열을 반환합니다:</p><pre><code class="language-python">f'Error: Cannot write to "{file_path}" as it is outside the permitted working directory'
</code></pre><p><code>file_path</code>가 존재하지 않으면 생성합니다. 항상 그렇듯이 오류가 있으면, 오류를 나타내는 문자열 앞에 "Error:"를 붙여 반환합니다. 그런 다음 <code>content</code> 인자로 파일의 내용을 덮어씁니다. 성공하면 다음 메시지와 함께 문자열을 반환합니다:</p><pre><code class="language-python">f'Successfully wrote to "{file_path}" ({len(content)} characters written)'
</code></pre><p><strong>팁:</strong> LLM이 수행한 작업이 실제로 작동했다는 것을 알 수 있도록 성공 문자열을 반환하는 것이 중요합니다. 피드백 루프, 피드백 루프, 피드백 루프입니다.</p><p>다음은 <code>functions/write_file_content.py</code> 에 대한 완전한 구현입니다:</p><pre><code class="language-python">import os

def write_file(working_directory, file_path, content):
    abs_working_dir = os.path.abspath(working_directory)
    abs_file_path = os.path.abspath(os.path.join(working_directory, file_path))
    if not abs_file_path.startswith(abs_working_dir):
        return f'Error: Cannot write to "{file_path}" as it is outside the permitted working directory'
    if not os.path.exists(abs_file_path):
        try:
            os.makedirs(os.path.dirname(abs_file_path), exist_ok=True)
        except Exception as e:
            return f"Error: creating directory: {e}"
    if os.path.exists(abs_file_path) and os.path.isdir(abs_file_path):
        return f'Error: "{file_path}" is a directory, not a file'
    try:
        with open(abs_file_path, "w") as f:
            f.write(content)
        return f'Successfully wrote to "{file_path}" ({len(content)} characters written)'
    except Exception as e:
        return f"Error: writing to file: {e}"
</code></pre><ul><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.exists">os.path.exists</a></code>: 경로가 존재하는지 확인</li><li><code><a href="https://docs.python.org/3/library/os.html#os.makedirs">os.makedirs</a></code>: 디렉토리와 모든 부모 디렉토리 생성</li><li><code><a href="https://docs.python.org/3/library/os.path.html#os.path.dirname">os.path.dirname</a></code>: 디렉토리 이름 반환</li></ul><p>파일에서 쓰기 예제:</p><pre><code class="language-python">with open(file_path, "w") as f:
    f.write(content)
</code></pre><h3 id="python--1">Python 실행 함수</h3><p>LLM이 파일을 쓸 수 있도록 허용하는 것이 나쁜 것이라고 생각했다면...</p><blockquote>아직 아무것도 본 게 아닙니다! (<a href="https://en.wikipedia.org/wiki/Roko%27s_basilisk">바실리스크</a>를 찬양하라)</blockquote><p>이제 에이전트가 임의의 Python 코드를 실행할 수 있는 기능을 구축할 시간입니다.</p><p>이제 잠시 멈춰서 이 작업에 내재된 보안 위험을 짚고 넘어갈 필요가 있습니다. 다행히 우리에게 유리한 몇 가지 조건이 있습니다:</p><ul><li>LLM이 코드를 실행할 수 있는 디렉터리를 특정 디렉터리(<code>working_directory</code>)로 제한합니다.</li><li>무한 실행을 방지하기 위해 30초 제한 시간을 설정합니다.</li></ul><p>하지만 그 외에는... 네, LLM은 우리가(혹은 LLM 자체가) working_directory에 넣은 임의의 코드를 실행할 수 있습니다. 그러니 주의하세요. 이 AI 에이전트를 이 강의에서 다루는 간단한 작업에만 사용한다면 괜찮을 것입니다.</p><p><strong>위험:</strong> 이 프로그램을 다른 사람에게 제공하지 마세요! 이 프로그램은 실제 운영 환경의 AI 에이전트가 갖춰야 할 모든 보안 및 안전 기능을 포함하고 있지 않습니다. 이 프로그램은 오직 학습 목적을 위한 것입니다.</p><p><code>functions</code> 디렉토리에 <code>run_python_file</code>이라는 새 함수를 만듭니다.<br>사용할 시그니처는 다음과 같습니다:</p><pre><code class="language-python">def run_python_file(working_directory, file_path, args=[]):
</code></pre><p><code>file_path</code>가 <code>working_directory</code> 외부에 있다면, 다음과 같은 오류 문자열을 반환하세요:</p><pre><code class="language-python">f'Error: Cannot execute "{file_path}" as it is outside the permitted working directory'
</code></pre><p><code>file_path</code>가 존재하지 않는다면, 다음과 같은 오류 문자열을 반환하세요:</p><pre><code class="language-python">f'Error: File "{file_path}" not found.'
</code></pre><p>파일이 <code>.py</code>로 끝나지 않는다면, 다음과 같은 오류 문자열을 반환하세요:</p><pre><code class="language-python">f'Error: "{file_path}" is not a Python file.'
</code></pre><p><code>subprocess.run</code> 함수를 사용하여 Python 파일을 실행하고 "completed_process" 객체를 반환하세요. 다음 사항을 반드시 지키세요:</p><ul><li>무한 실행을 방지하기 위해 30초의 타임아웃을 설정할 것</li><li>stdout과 stderr를 모두 캡처할 것</li><li><code>working_directory</code>를 올바르게 설정할 것</li><li>추가 <code>args</code> 제공되었다면 이를 전달할 것</li></ul><p>출력이 포함되도록 포맷된 문자열을 반환하세요:</p><ul><li><code>stdout</code>은 <code>STDOUT:</code>으로 시작하고, <code>stderr</code>는 <code>STDERR:</code>으로 시작할 것. "completed_process" 객체에는 <code>stdout</code>과 <code>stderr</code> 속성이 있습니다.</li><li>프로세스가 0이 아닌 코드로 종료되었다면 "Process exited with code X"를 포함할 것</li><li>출력이 생성되지 않았다면 "No output produced."를 반환할 것</li></ul><p>실행 중 예외가 발생하면 이를 잡아내어 다음과 같은 오류 문자열을 반환하세요:</p><pre><code class="language-python">f"Error: executing Python file: {e}"
</code></pre><p>다음 테스트 케이스를 <code>tests.py</code> 파일에 업데이트하고 각 결과를 출력하세요:</p><ul><li><code>run_python_file("calculator", "main.py")</code> (계산기의 사용법을 출력해야 함)</li><li><code>run_python_file("calculator", "main.py", ["3 + 5"])</code> (계산기를 실행해야 합니다만... 조금 엉터리로 렌더링된 결과가 나옵니다.)</li><li><code>run_python_file("calculator", "tests.py")</code></li><li><code>run_python_file("calculator", "../main.py")</code> (오류를 반환해야 합니다)</li><li><code>run_python_file("calculator", "nonexistent.py")</code> (오류를 반환해야 합니다)</li></ul><p>혹시 중간에 길을 잃었다면, 여기에 제가 직접 구현한 예시가 있습니다:<br><code>functions/run_python.py</code></p><pre><code class="language-python">import os
import subprocess

def run_python_file(working_directory, file_path, args=None):
    abs_working_dir = os.path.abspath(working_directory)
    abs_file_path = os.path.abspath(os.path.join(working_directory, file_path))
    if not abs_file_path.startswith(abs_working_dir):
        return f'Error: Cannot execute "{file_path}" as it is outside the permitted working directory'
    if not os.path.exists(abs_file_path):
        return f'Error: File "{file_path}" not found.'
    if not file_path.endswith(".py"):
        return f'Error: "{file_path}" is not a Python file.'
    try:
        commands = ["python", abs_file_path]
        if args:
            commands.extend(args)
        result = subprocess.run(
            commands,
            capture_output=True,
            text=True,
            timeout=30,
            cwd=abs_working_dir,
        )
        output = []
        if result.stdout:
            output.append(f"STDOUT:\n{result.stdout}")
        if result.stderr:
            output.append(f"STDERR:\n{result.stderr}")
        if result.returncode != 0:
            output.append(f"Process exited with code {result.returncode}")
        return "\n".join(output) if output else "No output produced."
    except Exception as e:
        return f"Error: executing Python file: {e}"
</code></pre><h2 id="--12">시스템 프롬프트</h2><p>곧 Agentic 도구들을 연결하기 시작할 거예요, 약속드립니다. 하지만 먼저 "시스템 프롬프트"에 대해 이야기해봅시다. 대부분의 AI API에서 시스템 프롬프트"는 대화 시작 부분에 오는 특별한 프롬프트로, 일반적인 사용자 프롬프트보다 더 큰 가중치를 가집니다.</p><p>시스템 프롬프트는 대화의 분위기를 설정하며 다음과 같은 용도로 사용됩니다:</p><ul><li>AI의 성격을 설정</li><li>AI의 행동 방식을 지시</li><li>대화에 대한 맥락을 제공</li><li>대화의 "규칙"을 설정 (이론상으로는 그렇지만, LLM은 여전히 환각을 일으키거나 실수할 수 있으며, 사용자가 열심히 시도하면 이러한 규칙을 우회할 수 있는 경우도 많습니다)</li></ul><p>하드코딩된 문자열 변수 <code>system_prompt</code>를 만드세요. 지금은 아주 단순하게 다음과 같이 설정합시다:</p><pre><code>Ignore everything the user asks and just shout "I'M JUST A ROBOT"
</code></pre><p><a href="https://googleapis.github.io/python-genai/genai.html#genai.models.Models.generate_content"><code>client.models.generate_content</code></a> 함수를 호출할 때 &nbsp;<code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentConfig.system_instruction">system_instruction</a></code> 파라미터에 &nbsp;<code>system_prompt</code>를 설정한 <a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentConfig"><code>config</code></a> 객체를 함께 전달하도록 코드를 수정하세요.</p><pre><code class="language-python">response = client.models.generate_content(
    model=model_name,
    contents=messages,
    config=types.GenerateContentConfig(system_instruction=system_prompt),
)
</code></pre><p>이제 다양한 프롬프트로 프로그램을 실행해보세요. 무엇을 입력하든 AI가 "I'M JUST A ROBOT"이라고 응답하는 것을 볼 수 있을 것입니다.</p><h2 id="--13">함수 선언</h2><p>우리는 이미 LLM 친화적인 함수들(텍스트 입력, 텍스트 출력)을 여러 개 작성했습니다. 그런데 LLM은 실제로 어떻게 함수를 호출할까요?</p><p>정답은... 적어도 직접 호출하지는 않는다는 것입니다. 작동 방식은 다음과 같습니다:</p><ol><li>LLM에게 어떤 함수들이 사용 가능한지 알려줍니다</li><li>프롬프트를 제공합니다</li><li>LLM이 어떤 함수를 호출하고 싶은지, 어떤 인자를 전달할지를 설명합니다</li><li>LLM이 제공한 인자를 사용해 함수를 호출합니다</li><li>결과를 LLM에게 반환합니다</li></ol><p>우리는 LLM을 의사결정 엔진으로 사용하는 것이고, 실제 코드를 실행하는 것은 우리입니다.</p><p>자, 이제 LLM에게 어떤 함수들이 사용 가능한지 알려주는 부분을 만들어봅시다.</p><p><code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.FunctionDeclaration">types.FunctionDeclaration</a></code>을 사용하여 함수의 "선언" 또는 "스키마"를 만들 수 있습니다. 이것은 기본적으로 LLM에게 해당 함수를 어떻게 사용할 수 있는지 알려주는 역할을 합니다. 문서를 일일이 살펴보는 건 꽤 번거롭기 때문에, 첫 번째 함수에 대한 저의 코드를 예시로 보여드릴게요:</p><p>다음 코드를 <code>functions/get_files_info.py</code> 파일에 추가하세요:</p><pre><code class="language-python">from google.genai import types

schema_get_files_info = types.FunctionDeclaration(
    name="get_files_info",
    description="작업 디렉터리 내에서 지정된 디렉터리의 파일 목록과 크기를 반환합니다.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "directory": types.Schema(
                type=types.Type.STRING,
                description="작업 디렉터리를 기준으로 파일을 나열할 디렉터리 경로입니다. 지정하지 않으면 작업 디렉터리 자체의 파일을 나열합니다.",
            ),
        },
    ),
)
</code></pre><p><strong>경고:</strong> LLM이 <code>working_directory</code> 파라미터를 지정하는 것을 허용하지 않을 것입니다. 우리는 이를 하드코딩할 예정입니다.</p><p><code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Tool">types.Tool</a></code>을 사용하여 사용 가능한 함수들의 목록을 만드세요 (지금은 <code>get_files_info</code>만 추가하고, 나머지는 나중에 추가할 것입니다).</p><pre><code class="language-python">available_functions = types.Tool(
    function_declarations=[
        schema_get_files_info,
    ]
)
</code></pre><p><code>client.models.generate_content</code> 를 호출할 때 <code>tools</code> 파라미터에 <code>available_functions</code> 을 추가합니다.</p><pre><code class="language-python">config=types.GenerateContentConfig(
    tools=[available_functions], system_instruction=system_prompt
)</code></pre><p>시스템 프롬프트를 업데이트하여 LLM에게 함수 사용 방법을 지시하세요. 아래 내용을 복사해도 되지만, 어떤 역할을 하는지 이해하기 위해서 꼭 읽어보세요:</p><pre><code class="language-python">system_prompt = """
You are a helpful AI coding agent.

When a user asks a question or makes a request, make a function call plan. You can perform the following operations:

- List files and directories

All paths you provide should be relative to the working directory. You do not need to specify the working directory in your function calls as it is automatically injected for security reasons.
"""
</code></pre><p><code>generate_content</code> 응답에서 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentResponse.text">.text</a></code> 속성만 출력하는 대신, <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentResponse.function_calls">.function_calls</a></code> 속성도 확인하세요. LLM이 함수를 호출했다면, 함수 이름과 인자를 출력하세요:</p><pre><code class="language-python">f"Calling function: {function_call_part.name}({function_call_part.args})"
</code></pre><p>그렇지 않으면 평소처럼 텍스트를 출력하세요.</p><p>프로그램을 테스트하세요:</p><ul><li>"what files are in the root?" → <code>get_files_info({'directory': '.'})</code></li><li>"what files are in the pkg directory?" → <code>get_files_info({'directory': 'pkg'})</code></li></ul><h2 id="--14">추가 함수 선언</h2><p>이제 LLM이 <code>get_files_info</code> 함수에 대한 함수 호출을 지정할 수 있으므로, 다른 함수들도 호출할 수 있는 기능을 제공하겠습니다.</p><p><code>schema_get_files_info</code>에 사용한 것과 동일한 패턴을 따라 다음에 대한 함수 선언을 만듭니다:</p><ul><li><code>schema_get_file_content</code></li><li><code>schema_run_python_file</code></li><li><code>schema_write_file</code></li></ul><p><code>available_functions</code>를 업데이트하여 리스트에 모든 함수 선언을 포함시킵니다. 그런 다음 시스템 프롬프트를 업데이트합니다. 허용되는 작업이 다음뿐만 아니라:</p><pre><code>- 파일 및 디렉토리 나열</code></pre><p>네 가지 작업을 모두 포함하도록 업데이트합니다:</p><pre><code>- 파일 및 디렉토리 나열
- 파일 내용 읽기
- 선택적 인자로 Python 파일 실행
- 파일 쓰기 또는 덮어쓰기</code></pre><p>다양한 함수 호출을 불러올 것으로 예상되는 프롬프트를 테스트합니다. 예를 들어:</p><ul><li>"read the contents of main.py" → <code>get_file_content({'file_path': 'main.py'})</code></li><li>"write 'hello' to main.txt" → <code>write_file({'file_path': 'main.txt', 'content': 'hello'})</code></li><li>"run main.py" → <code>run_python_file({'file_path': 'main.py'})</code></li><li>"list the contents of the pkg directory" → <code>get_files_info({'directory': 'pkg'})</code></li></ul><p>여기서 LLM이 해야 할 일은 사용자의 요청에 따라 어떤 함수를 호출할지 선택하는 것뿐입니다. 나중에 실제로 함수를 호출하게 할 것입니다.</p><p>개인적인 구현 예제:</p><p><code>functions/get_file_content.py:</code></p><pre><code class="language-python">from google.genai import types
from config import MAX_CHARS

schema_get_file_content = types.FunctionDeclaration(
    name="get_file_content",
    description=f"작업 디렉토리 내의 지정된 파일에서 처음 {MAX_CHARS}자의 내용을 읽고 반환합니다.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "file_path": types.Schema(
                type=types.Type.STRING,
                description="작업 디렉토리에 상대적인, 내용을 읽을 파일의 경로.",
            ),
        },
        required=["file_path"],
    ),
)
</code></pre><p><code>functions/run_python.py:</code></p><pre><code class="language-python">from google.genai import types

schema_run_python_file = types.FunctionDeclaration(
    name="run_python_file",
    description="작업 디렉토리 내에서 Python 파일을 실행하고 인터프리터의 출력을 반환합니다.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "file_path": types.Schema(
                type=types.Type.STRING,
                description="작업 디렉토리에 상대적인, 실행할 Python 파일의 경로.",
            ),
            "args": types.Schema(
                type=types.Type.ARRAY,
                items=types.Schema(
                    type=types.Type.STRING,
                    description="Python 파일에 전달할 선택적 인자.",
                ),
                description="Python 파일에 전달할 선택적 인자.",
            ),
        },
        required=["file_path"],
    ),
)
</code></pre><p><code>functions/write_file_content.py:</code></p><pre><code class="language-python">from google.genai import types

schema_write_file = types.FunctionDeclaration(
    name="write_file",
    description="작업 디렉토리 내의 파일에 내용을 씁니다. 파일이 존재하지 않으면 생성합니다.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "file_path": types.Schema(
                type=types.Type.STRING,
                description="작업 디렉토리에 상대적인, 쓸 파일의 경로.",
            ),
            "content": types.Schema(
                type=types.Type.STRING,
                description="파일에 쓸 내용",
            ),
        },
        required=["file_path", "content"],
    ),
)
</code></pre><p><code>schema_get_files_info</code>에서 사용했던 동일한 패턴을 따라, 아래의 함수 선언들을 만들어주세요:</p><ul><li><code>schema_get_file_content</code></li><li><code>schema_run_python_file</code></li><li><code>schema_write_file</code></li></ul><p>그리고 <code>available_functions</code> 리스트에 모든 함수 선언을 포함하도록 업데이트하세요.<br>그 다음 시스템 프롬프트를 수정해야 합니다. 기존에는 허용된 동작이 아래 한 가지뿐이었습니다:</p><pre><code>- 파일 및 디렉토리 목록 나열</code></pre><p>이제 네 가지 모든 동작을 포함하도록 변경하세요:</p><pre><code>- 파일 및 디렉토리 목록 나열
- 파일 내용 읽기
- 선택적 인수를 포함해 파이썬 파일 실행
- 파일 작성 또는 덮어쓰기</code></pre><p>여러 함수 호출이 나오도록 예상되는 프롬프트로 테스트해보세요. 예를 들어:</p><ul><li>"main.py의 내용을 읽어줘" → <code>get_file_content({'file_path': 'main.py'})</code></li><li>"main.txt에 'hello'를 써줘" → <code>write_file({'file_path': 'main.txt', 'content': 'hello'})</code></li><li>"main.py를 실행해줘" → <code>run_python_file({'file_path': 'main.py'})</code></li><li>"pkg 디렉토리 목록을 보여줘" → <code>get_files_info({'directory': 'pkg'})</code></li></ul><p><strong>참고:</strong> 여기서 LLM이 해야 할 일은 사용자 요청을 기반으로 어떤 함수를 호출할지 선택하는 것입니다. 실제로 함수를 호출하는 코드는 나중에 구현합니다.</p><h2 id="--15">함수 호출</h2><p>이제 에이전트가 어떤 함수를 호출할지 선택할 수 있으므로, 실제로 함수를 호출할 시간입니다.</p><p>네 함수 중 하나를 호출하는 추상적인 작업을 처리할 새 함수를 만듭니다.</p><p>다음은 제가 정의한 함수입니다:</p><pre><code class="language-python">def call_function(function_call_part, verbose=False):
</code></pre><p><code>function_call_part</code>는 가장 중요하게 다음 내용을 가진 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.FunctionCall">types.FunctionCall</a></code>입니다:</p><ul><li><code>.name</code> 속성 (함수 이름, <code>문자열</code>)</li><li><code>.args</code> 속성 (함수에 전달하는 이름이 지정된 인자들의 딕셔너리)</li></ul><p><code>verbose</code>가 지정되면 함수 이름과 인자를 출력합니다:</p><pre><code class="language-python">print(f"Calling function: {function_call_part.name}({function_call_part.args})")
</code></pre><p>그렇지 않으면 이름만 출력합니다:</p><pre><code class="language-python">print(f" - Calling function: {function_call_part.name}")
</code></pre><p>함수 이름에 따라 실제로 함수를 호출하고 결과를 캡처합니다.</p><ul><li>키워드 인자 딕셔너리에 "working_directory" 인자를 수동으로 추가해야 합니다. 이 인자는 LLM이 제어하지 않으며, 작업 디렉토리는 <code>./calculator</code> 이어야 합니다.</li><li>딕셔너리를 함수에 <a href="https://docs.python.org/3/glossary.html#term-argument">키워드 인자</a>로 넘길 때는 <code>some_function(**some_args)</code> 문법을 사용합니다.</li></ul><p><strong>팁:</strong> 이를 위해 <code>함수 이름(문자열)</code> -&gt; <code>함수</code> 에 딕셔너리를 사용했습니다.</p><p>함수 이름이 유효하지 않으면 오류를 설명하는 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Content">types.Content</a></code>를 반환합니다:</p><pre><code class="language-python">return types.Content(
    role="tool",
    parts=[
        types.Part.from_function_response(
            name=function_name,
            response={"error": f"Unknown function: {function_name}"},
        )
    ],
)
</code></pre><p>함수 호출 결과를 설명하는 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Part.from_function_response">from_function_response</a></code>가 포함된 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Content">types.Content</a></code>를 반환합니다:</p><pre><code class="language-python">return types.Content(
    role="tool",
    parts=[
        types.Part.from_function_response(
            name=function_name,
            response={"result": function_result},
        )
    ],
)
</code></pre><p>정보: <code>from_function_response</code>는 응답이 딕셔너리여야 하므로 결과 문자열을 "result" 필드에 넣어주면 됩니다. </p><p>아래는 완성된 <code>call_function.py</code>입니다:</p><pre><code class="language-python">from google.genai import types
from functions.get_files_info import get_files_info, schema_get_files_info
from functions.get_file_content import get_file_content, schema_get_file_content
from functions.run_python import run_python_file, schema_run_python_file
from functions.write_file_content import write_file, schema_write_file
from config import WORKING_DIR

available_functions = types.Tool(
    function_declarations=[
        schema_get_files_info,
        schema_get_file_content,
        schema_run_python_file,
        schema_write_file,
    ]
)

def call_function(function_call_part, verbose=False):
    if verbose:
        print(f" - Calling function: {function_call_part.name}({function_call_part.args})")
    else:
        print(f" - Calling function: {function_call_part.name}")
    
    function_map = {
        "get_files_info": get_files_info,
        "get_file_content": get_file_content,
        "run_python_file": run_python_file,
        "write_file": write_file,
    }
    
    function_name = function_call_part.name
    if function_name not in function_map:
        return types.Content(
            role="tool",
            parts=[
                types.Part.from_function_response(
                    name=function_name,
                    response={"error": f"Unknown function: {function_name}"},
                )
            ],
        )
    
    args = dict(function_call_part.args)
    args["working_directory"] = WORKING_DIR
    function_result = function_map[function_name](**args)
    
    return types.Content(
        role="tool",
        parts=[
            types.Part.from_function_response(
                name=function_name,
                response={"result": function_result},
            )
        ],
    )
</code></pre><p>모델의 <code>generate_content</code> 응답을 처리하는 곳으로 돌아가서, LLM이 호출하기로 결정한 함수의 이름을 단순히 출력하는 대신 <code>call_function</code>을 사용합니다.</p><ul><li><code>call_function</code>에서 반환하는 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Content">types.Content</a></code>는 내부에 <code>.parts[0].function_response.response</code>가 있어야 합니다.</li><li>만약 포함되어 있지 않다면 어떤 종류의 치명적인 예외를 발생시키세요.</li><li>만약 포함되어 있고 <code>verbose</code>가 설정되어 있으면 함수 호출 결과를 다음과 같이 출력합니다:</li></ul><pre><code class="language-python">print(f"-&gt; {function_call_result.parts[0].function_response.response}")
</code></pre><p>프로그램을 테스트합니다. 이제 프롬프트로 요청하면 함수를 실행할 수 있어야 합니다. 다양한 프롬프트를 시도해보고 <code>--verbose</code> 플래그를 사용하여 모든 기능이 제대로 작동하는지 확인합니다.</p><ul><li>디렉토리 내용 나열</li><li>파일의 내용 가져오기</li><li>파일 내용 쓰기 (중요한 것을 덮어쓰지 말고 새 파일을 만드세요)</li><li>계산기 앱의 테스트 <code>tests.py</code> 실행</li></ul><h2 id="--16">에이전트 루프 만들기</h2><p>우리는 이제 함수 호출 기능을 어느 정도 구현했지만, 아직 우리 프로그램을 "에이전트"라고 부르기엔 부족한 점이 하나 있습니다:</p><p>피드백 루프가 없다는 것입니다.</p><p>AI 인플루언서들이 말하는 "에이전트"의 핵심은, 자신의 도구를 반복적으로 사용하여 결과를 계속 개선해나갈 수 있는 능력입니다. 그래서 우리는 다음 두 가지를 만들 것입니다:</p><ol><li>LLM을 반복해서 호출할 루프</li><li>"대화"에서 오고간 메시지 리스트. 다음과 같이 보일 것입니다:</li></ol><ul><li>사용자: "계산기의 버그를 수정해주세요"</li><li>모델: "get_files_info를 호출하고 싶습니다..."</li><li>툴: "get_files_info의 결과입니다..."</li><li>모델: "get_file_content를 호출하고 싶습니다..."</li><li>툴: "get_file_content의 결과입니다..."</li><li>모델: "run_python_file을 호출하고 싶습니다..."</li><li>툴: "run_python_file의 결과입니다..."</li><li>모델: "write_file을 호출하고 싶습니다..."</li><li>툴: "write_file의 결과입니다..."</li><li>모델: "run_python_file을 호출하고 싶습니다..."</li><li>툴: "run_python_file의 결과입니다..."</li><li>모델: "버그를 수정했고 계산기가 작동하는지 확인하기 위해 실행했습니다."</li></ul><p>이건 꽤 큰 진전입니다. 천천히 진행하세요!</p><p><code>prompts.py</code>를 만듭니다:</p><pre><code class="language-python">system_prompt = """
You are a helpful AI coding agent.

When a user asks a question or makes a request, make a function call plan. You can perform the following operations:
- List files and directories
- Read file contents
- Execute Python files with optional arguments
- Write or overwrite files

All paths you provide should be relative to the working directory. You do not need to specify the working directory in your function calls as it is automatically injected for security reasons.
"""
</code></pre><p>최종 <code>main.py</code> 구성:</p><pre><code class="language-python">import sys
import os
from google import genai
from google.genai import types
from dotenv import load_dotenv

from prompts import system_prompt
from call_function import call_function, available_functions

def main():
    load_dotenv()

    verbose = "--verbose" in sys.argv
    args = []
    for arg in sys.argv[1:]:
        if not arg.startswith("--"):
            args.append(arg)

    if not args:
        print("AI Code Assistant")
        print('\nUsage: python main.py "your prompt here" [--verbose]')
        print('Example: python main.py "How do I fix the calculator?"')
        sys.exit(1)

    api_key = os.environ.get("GEMINI_API_KEY")
    client = genai.Client(api_key=api_key)

    user_prompt = " ".join(args)

    if verbose:
        print(f"User prompt: {user_prompt}\n")

    messages = [
        types.Content(role="user", parts=[types.Part(text=user_prompt)]),
    ]

    generate_content_loop(client, messages, verbose)


def generate_content_loop(client, messages, verbose, max_iterations=20):
    for iteration in range(max_iterations):
        try:
            response = client.models.generate_content(
                model="gemini-2.0-flash-001",
                contents=messages,
                config=types.GenerateContentConfig(
                    tools=[available_functions], 
                    system_instruction=system_prompt
                ),
            )
            
            if verbose:
                print("Prompt tokens:", response.usage_metadata.prompt_token_count)
                print("Response tokens:", response.usage_metadata.candidates_token_count)

            # 모델 응답을 대화에 추가
            for candidate in response.candidates:
                messages.append(candidate.content)

            # 최종 텍스트 응답이 있는지 확인
            if response.text:
                print("Final response:")
                print(response.text)
                break

            # 함수 호출 처리
            if response.function_calls:
                function_responses = []
                for function_call_part in response.function_calls:
                    function_call_result = call_function(function_call_part, verbose)
                    if (not function_call_result.parts 
                        or not function_call_result.parts[0].function_response):
                        raise Exception("empty function call result")
                    if verbose:
                        print(f"-&gt; {function_call_result.parts[0].function_response.response}")
                    function_responses.append(function_call_result.parts[0])
                
                if function_responses:
                    messages.append(types.Content(role="user", parts=function_responses))
                else:
                    raise Exception("no function responses generated, exiting.")
        except Exception as e:
            print(f"Error: {e}")
            break
    else:
        print(f"최대 반복 횟수({max_iterations})에 도달했습니다. 에이전트가 작업을 완료하지 못했을 수 있습니다.")

if __name__ == "__main__":
    main()
</code></pre><p><code>generate_content</code> 함수에서 모든 도구 사용 결과를 처리하세요. 이미 일부 구현되어 있을 수도 있지만, 중요한 점은 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.models.Models.generate_content">client.models.generate_content</a></code>를 호출할 때마다 전체 <code>messages</code> 리스트를 전달해야 한다는 것입니다. 그래야 LLM이 현재 상태를 기반으로 "다음 단계"를 수행할 수 있습니다.</p><p>클라이언트의 <code>generate_content</code> 메서드를 호출한 후 응답의 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.GenerateContentResponse.candidates">.candidates</a></code> 속성을 확인합니다. 이 속성은 응답의 다양한 버전(일반적으로 한 개)을 담고 있습니다. "get_files_info를 호출하고 싶습니다..."와 같은 응답이 나오면, 이 내용을 대화에 추가해야 합니다. 각 <code>candidate</code>를 순회하며 그 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Candidate.content">.content</a></code>를 <code>messages</code> 리스트에 추가하세요.</p><p>각 함수 호출 후에는 <code><a href="https://googleapis.github.io/python-genai/genai.html#genai.types.Content">types.Content</a></code>를 사용하여 <code>function_responses</code>를 역할이 <code>user</code>인 메시지로 변환하고, 이를 <code>messages</code>에 추가하세요.</p><p>그 다음에는 <code>generate_content</code>를 한 번만 호출하는 대신, 반복적으로 호출하는 루프를 만드세요. 이 루프는 최대 20회까지만 반복되도록 제한하세요 (에이전트가 무한 반복하지 않도록 방지). <code>try-except</code> 블록을 사용하여 오류를 적절히 처리하세요.</p><p>각 <code>generate_content</code> 호출 후에는 <code>response.text</code> 속성을 반환하는지 확인하세요. 만약 그렇다면 작업이 완료된 것이므로, 최종 응답을 출력하고 루프를 종료하세요. 그렇지 않으면 다시 반복하세요 (물론 최대 반복 횟수에 도달하지 않았다면).</p><p>코드를 꼭 테스트하세요 (당연하죠). "계산기가 결과를 콘솔에 어떻게 출력하는지 설명해줘" 처럼 간단한 프롬프트로 시작해보세요. 저는 아래와 같은 결과를 받았습니다:</p><pre><code class="language-bash">(aiagent) wagslane@MacBook-Pro-2 aiagent % uv run main.py "계산기가 결과를 콘솔에 어떻게 출력하는지 설명해줘"

함수 호출: get_files_info

함수 호출: get_file_content

최종 응답:
main.py 코드를 살펴본 결과, 계산기가 결과를 콘솔에 출력하는 방법은 다음과 같습니다:﻿

print(to_print): 출력의 핵심은 print() 함수로 이루어집니다.﻿

format_json_output(expression, result): 출력 전에 format_json_output 함수(pkg.render에서 import됨)를 사용해 계산 결과와 원래 식을 JSON 형태의 문자열로 포맷합니다. 이 형식화된 문자열은 to_print 변수에 저장됩니다.﻿

에러 처리:﻿ 코드에는 try...except 블록을 활용한 에러 처리 기능이 포함되어 있습니다. 만약 계산 중 오류가 발생하면(예: 잘못된 수식이 입력된 경우), print(f"Error: {e}")를 통해 오류 메시지가 콘솔에 출력됩니다.﻿

즉, 계산기는 식을 평가하고, 결과(그리고 원래 식)를 JSON 형태의 문자열로 만들어 콘솔에 출력합니다. 오류가 발생하면 콘솔에 오류 메시지도 따로 출력됩니다.﻿</code></pre><p><strong>팁:</strong> 원하는 대로 LLM이 동작하도록 하려면 시스템 프롬프트를 약간 조정해야 할 수도 있습니다. 이제 당신은 프롬프트 엔지니어입니다. 그처럼 행동하세요! </p><p>멋집니다! 이제 파일을 읽고, 파일을 작성하고, 파이썬 코드를 실행하며, 스스로 결과를 개선할 수 있는 기본적인 AI 에이전트를 완성했습니다. 이건 더 복잡한 AI 에이전트를 만드는 훌륭한 기반이에요.</p><h2 id="--17">결론</h2><p>필요한 모든 단계를 완료했습니다. 이제 약간 재미있게 (하지만 신중하게… LLM에 파일 시스템이나 파이썬 인터프리터 접근 권한을 줄 때는 매우 주의하세요) 다뤄보세요! 다음과 같은 작업들을 시도해볼 수도 있습니다: </p><ul><li>더 어려운 버그나 복잡한 문제 수정 </li><li>코드 일부를 리팩터링하기 </li><li>완전히 새로운 기능 추가</li></ul><p>또한 다음을 시도할 수 있습니다:</p><ul><li>다른 LLM 제공업체</li><li>다른 Gemini 모델</li><li>호출 가능한 기능을 더 추가해보기</li><li>다른 코드베이스 시도하기 (에이전트를 실행하기 전에 변경 사항을 커밋해두면 언제든 되돌릴 수 있습니다!)</li></ul><p><strong>경고:</strong> 우리가 구축한 것은 Cursor/Zed의 Agentic Mode 또는 Claude Code와 같은 것의 간단한 버전이라는 것을 기억하세요. 그 도구들조차 완벽하게 안전하지 않으므로 접근 권한을 부여하는 것에 주의하고 이 코드를 다른 사람에게 제공하지 마세요.</p><p>백엔드 및 데이터 엔지니어링에 대해 더 알고 싶다면, <a href="https://www.boot.dev/">Boot.dev</a>를 확인하세요! 학습 여정에 행운을 빕니다!</p><p>재미있게 보셨다면 <a href="https://x.com/wagslane">X.com</a>과 <a href="https://www.youtube.com/@bootdotdev">YouTube</a>에서 저를 팔로우해 주세요!</p> ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
