infra
Platform

모듈 맵

[Infra Ops] 컨테이너 배포 vs WAS 배포 비교와 실무 전환

0 / 52 완료

펼치기
0 / 52 완료0%

인프라 운영 & SRE · 38 / 52

[Infra Ops] 컨테이너 배포 vs WAS 배포 비교와 실무 전환

Docker 컨테이너 배포 흐름, 기존 WAS 배포와 차이점, docker-compose 운영, 마이그레이션 고려사항까지 — 온프레미스 WAS 환경을 컨테이너로 전환하는 실무

🚨INCIDENT ALERT
HIGH

기존에 Tomcat에 WAR 파일을 배포하던 방식으로 운영하던 서비스를, 이번 신규 프로젝트는 Docker 이미지로 납품받기로 했습니다. 인프라 엔지니어가 Docker로 서비스를 올리려고 하는데, WAR 배포와 뭐가 다른지, 어떻게 관리해야 하는지 감이 안 옵니다. "컨테이너가 죽으면 어떻게 되나요?", "로그는 어디서 보나요?", "배포는 어떻게 하나요?" — 기존 WAS 운영 경험이 있다면 Docker도 금방 익힐 수 있습니다.

이번 챕터에서 배울 것
  • 1WAR 배포와 Docker 배포의 구조적 차이를 설명할 수 있다
  • 2docker-compose.yml로 서비스를 정의하고 배포할 수 있다
  • 3컨테이너 상태 확인과 로그를 실시간으로 모니터링할 수 있다
  • 4컨테이너 무중단 배포(pull → up -d) 절차를 수행할 수 있다
  • 5볼륨 마운트와 환경변수로 설정을 외부화할 수 있다
실습 환경 준비
Docker 설치 확인
docker --version && docker-compose --version
Docker 서비스 상태 확인
systemctl status docker
현재 실행 중인 컨테이너 확인
docker ps

WAR 배포 vs Docker 배포 비교

💡개념

두 배포 방식의 구조적 차이

Tomcat에 WAR를 올리는 방식과 Docker 컨테이너로 실행하는 방식은 근본적으로 다릅니다. WAR 배포는 서버에 미리 설치된 JDK와 Tomcat 위에서 실행되지만, Docker 이미지는 JDK와 Tomcat까지 포함해서 패키징됩니다.

WAR 배포 vs Docker 배포의 구조적 차이 — WAR 배포는 서버에 미리 설치된 JDK·Tomcat 위에서 실행돼 환경 차이에 취약. Docker는 JDK·Tomcat까지 이미지에 패키징해 빌드→이미지 push→어디서나 동일 실행 — "내 서버에선 됐는데" 문제를 없애고 롤백도 이미지 태그 교체로 단순화확대

SI 프로젝트에서 납품된 Docker 이미지를 서버에 올려야 하는데, 기존 Tomcat 배포 방식으로 접근하다 보니 막히는 지점이 생깁니다. "WAR는 어디에 복사하나요?" "catalina.out은 어디서 보나요?" — 질문 자체가 틀렸습니다. Docker 배포는 파일 복사가 아니라 이미지 태그 교체입니다. 로그도 docker logs로 봅니다. 기존 WAS 운영 경험이 있다면 대응 관계를 잡는 것이 빠릅니다. setenv.sh 대신 환경변수, systemctl 대신 docker-compose, webapps/ 대신 이미지 레지스트리 — 개념만 연결되면 Docker 운영은 금방 익숙해집니다.

항목WAR 배포 (Tomcat)Docker 배포
실행 단위WAR 파일컨테이너 이미지
환경 의존성서버의 JDK, Tomcat 버전에 의존이미지 내부에 모두 포함
서버 설정server.xml, setenv.shdocker-compose.yml
로그 위치/opt/tomcat/logs/docker logs 또는 볼륨 마운트
배포 방법WAR 복사 → Tomcat restartdocker pull → docker-compose up -d
롤백이전 WAR 파일 복원이전 이미지 태그로 전환
프로세스 관리systemctldocker-compose 또는 docker run

Docker 배포가 유리한 상황:

  • 개발/스테이징/운영 환경을 완전히 동일하게 유지해야 할 때
  • 마이크로서비스로 여러 서비스를 한 서버에서 실행할 때
  • CI/CD 파이프라인으로 빌드된 이미지를 그대로 배포할 때

WAR 배포가 여전히 유리한 상황:

  • 레거시 시스템에서 Docker 인프라가 없을 때
  • JEUS, WebLogic 같은 상용 WAS를 사용해야 할 때
  • 조직의 보안 정책상 컨테이너 사용이 제한될 때

Docker 기본 명령어

💡개념

컨테이너 실행과 상태 확인

Docker를 처음 접하는 인프라 엔지니어는 기존 WAS 관리 명령어와 대응해서 익히면 빠릅니다.

로컬 터미널
# 이미지 다운로드
docker pull myregistry.example.com/myapp:1.2.0

# 컨테이너 실행 (기본)
docker run -d \
  --name myapp \
  -p 8080:8080 \
  -v /opt/logs:/app/logs \
  --restart unless-stopped \
  myregistry.example.com/myapp:1.2.0

# 실행 중인 컨테이너 목록
docker ps
# CONTAINER ID   IMAGE          COMMAND       STATUS         PORTS                    NAMES
# a1b2c3d4e5f6   myapp:1.2.0    "java -jar"   Up 3 hours     0.0.0.0:8080->8080/tcp   myapp

# 모든 컨테이너 (중지된 것 포함)
docker ps -a

# 실시간 로그 (tail -f와 동일)
docker logs -f myapp

# 최근 100줄 + 실시간
docker logs --tail 100 -f myapp

# 컨테이너 리소스 사용량
docker stats myapp

# 컨테이너 내부 접속 (디버깅용)
docker exec -it myapp /bin/sh

# 컨테이너 중지/시작/재시작
docker stop myapp
docker start myapp
docker restart myapp

주요 옵션 설명:

옵션의미Tomcat 비교
-d백그라운드 실행nohup / systemctl start
-p 8080:8080호스트:컨테이너 포트 바인딩server.xml Connector port
-v /opt/logs:/app/logs볼륨 마운트log 파일 경로 설정
--name myapp컨테이너 이름서비스 이름
--restart unless-stopped자동 재시작systemctl enable

docker-compose로 서비스 관리

💡개념

docker-compose.yml 구성과 운영

실제 운영에서는 docker run 명령보다 docker-compose.yml 파일로 서비스를 정의하는 방식을 사용합니다. 설정이 파일로 관리되어 형상관리가 가능하고, 여러 서비스를 한 번에 관리할 수 있습니다.

YAML
# /opt/app/docker-compose.yml
version: '3.8'

services:
  app:
    image: myregistry.example.com/myapp:1.2.0
    container_name: myapp
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - DB_HOST=db-server
      - DB_PORT=3306
      - DB_NAME=mydb
    volumes:
      - /opt/app/logs:/app/logs
      - /opt/app/config:/app/config:ro
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"

주요 설정 항목:

  • environment: 환경변수 주입 (Tomcat의 setenv.sh 역할)
  • volumes: 로그, 설정 파일을 호스트와 공유
  • healthcheck: 서비스 정상 여부 자동 확인
  • logging: 로그 파일 크기/개수 제한 (logrotate 역할)

docker-compose 운영 명령어:

로컬 터미널
# 서비스 시작 (백그라운드)
docker-compose up -d

# 서비스 중지 (컨테이너 삭제하지 않음)
docker-compose stop

# 서비스 재시작
docker-compose restart

# 이미지 pull 후 재시작 (배포)
docker-compose pull && docker-compose up -d

# 서비스 로그
docker-compose logs -f app

# 서비스 상태 확인
docker-compose ps

Docker 이미지 레이어 — 운영 배포 최적화확대

컨테이너 배포 절차

💡개념

이미지 기반 무중단 배포 흐름

Docker 배포는 이미지 태그 교체 방식으로 롤백이 간단합니다. 배포 전 이전 이미지 태그를 기록해두는 것이 핵심입니다.

로컬 터미널
# 현재 실행 중인 이미지 버전 확인
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"

# 1. 새 이미지 pull (서비스 중단 없이)
docker-compose pull

# 2. 컨테이너 재생성 (1~2초 중단)
docker-compose up -d

# 3. 배포 확인
docker ps
docker logs --tail 20 myapp
curl -s http://localhost:8080/actuator/health

# 롤백 (이전 태그로 변경)
# docker-compose.yml에서 image: myapp:1.1.0 으로 변경 후
docker-compose up -d

Private Registry에서 이미지 받기:

로컬 터미널
# Registry 로그인
docker login myregistry.example.com -u user -p password

# 이미지 확인
docker images | grep myapp

# 불필요한 이전 이미지 정리
docker image prune -f
💡개념

컨테이너 하나가 새 버전으로 바뀌기까지 — 대상 서버 관점 6단계

컨테이너 배포는 파일을 덮어쓰는 게 아니라 "이미지를 받아 기존 컨테이너를 새 컨테이너로 갈아 끼우는" 일입니다. 빌드·push 된 이미지를 대상 서버가 pull 하고 → 기존 컨테이너를 내리고 → 포트·볼륨·환경변수를 붙여 새 컨테이너를 띄우고 → 헬스로 확인한 뒤 트래픽을 받게 합니다. 이 서버 쪽 흐름을 알면 "배포했는데 구버전이 그대로다", "새 컨테이너가 안 뜬다", "되돌릴 수 없다"를 각각 어느 칸에서 끊겼는지로 좁힐 수 있습니다.

TEXT
[빌드·CI]  docker build -t myapp:1.4.2 → docker push (레지스트리)
   │
   ① 빌드·push       버전 태그로 레지스트리에 업로드 (latest 지양)
   │
   ② 대상 서버 pull    docker-compose.yml 의 image 태그로 이미지를 당김
   │
   ③ 기존 컨테이너 정리  stop → rm (compose up -d 가 재생성으로 대체)
   │
   ④ 새 컨테이너 시작   -p 포트 바인딩 · -v 볼륨 마운트 · env 주입 후 start
   │
   ⑤ 헬스 확인       healthcheck·curl 로 준비 완료 확인
   │
   ⑥ 트래픽·롤백 대비   이전 태그 보존 → 문제 시 image 태그만 되돌려 up -d
   ▼
[대상 서버]  새 태그 컨테이너가 트래픽을 받고, 이전 태그는 롤백 카드로 대기

각 단계에서 하는 일과, 막히면 나타나는 증상:

단계하는 일막히면 증상
① 빌드·push버전 태그가 붙은 이미지를 레지스트리에 올린다latest만 쓰면 대상 서버가 무엇을 받을지·되돌릴 태그가 무엇인지 불명확
② 대상 서버 pulldocker-compose.ymlimage 태그로 이미지를 당긴다compose의 태그와 레지스트리에 실제로 올라간 태그가 어긋나면 pull 이 구버전을 그대로 받아 "배포했는데 안 바뀜"
③ 기존 컨테이너 정리옛 컨테이너를 stop·rm 한다(up -d가 재생성으로 대체)옛 컨테이너를 안 내리면 이름 충돌·포트 점유로 새 컨테이너가 address already in use
④ 새 컨테이너 시작-p 포트 바인딩·-v 볼륨 마운트·env 를 주입해 띄운다호스트 포트가 이미 사용 중이면 기동 실패, 볼륨 소유자 불일치면 Permission denied, 필수 env 누락이면 Exited(1)
⑤ 헬스 확인healthcheck·curl로 컨테이너가 준비됐는지 확인한다확인 없이 트래픽을 주면 준비 안 된 컨테이너로 502·503; standalone 은 unhealthy를 스스로 조치하지 않음(아래 심화)
⑥ 트래픽·롤백 대비이전 태그를 남겨 문제 시 되돌릴 카드로 둔다이전 태그를 안 남기면 되돌릴 대상이 없음 — 남겨두면 image 태그만 이전으로 바꿔 up -d 로 초 단위 롤백

정리하면 "배포했는데 안 바뀜"은 ②(태그 불일치)나 ③(옛 컨테이너 잔존), 기동 실패는 ④(포트 충돌·볼륨 권한·env 누락 — 각각 docker logs·ss -tlnp·docker exec myapp env로 확인), 되돌릴 수 없음은 ①⑥(가변 태그)에서 끊긴 것입니다. docker ps의 STATUS와 docker logs로 어느 칸이 비었는지부터 좁히면 됩니다.

실습: docker-compose 배포 실습

1docker-compose 상태 확인 및 배포

현재 서비스 상태를 확인하고 새 이미지를 pull합니다.

로컬 터미널
# 서비스 상태
docker-compose ps

# 새 이미지 pull
docker-compose pull

# 컨테이너 재시작
docker-compose up -d

# 배포 후 상태 확인
docker-compose ps
docker-compose logs --tail 30 app
docker-compose ps && docker-compose pull
🔍실행 후 확인할 것
  • docker-compose ps 로 State 먼저 확인, Up이면 docker-compose logs 에서 "Server startup" 메시지 확인, 그 다음 curl 헬스체크 — State가 Exited이면 logs로 종료 원인부터 추적
  • 컨테이너 상태 기준: Up (N seconds)=정상 실행 중, Up (Less than a second)=계속 재시작 중(crash loop 의심), Exited(1)=애플리케이션 오류, Restarting=restart_policy에 의해 계속 재시작
  • State가 Up인데 curl 헬스체크 실패하면 → 포트 매핑 또는 애플리케이션 초기화 미완료 — docker-compose logs app | tail -20 으로 "Started" 메시지 시점 확인 후 포트 0.0.0.0:8080->8080 바인딩 여부 확인
2컨테이너 리소스와 로그 모니터링

컨테이너 리소스 사용량을 확인합니다.

로컬 터미널
# 리소스 사용량 스냅샷
docker stats --no-stream

# 실시간 모니터링
docker stats

# 최근 에러 로그 확인
docker-compose logs app | grep -E "ERROR|Exception|WARN" | tail -20

# 컨테이너 내부 프로세스 확인
docker exec myapp ps aux
docker stats --no-stream
🔍실행 후 확인할 것
  • docker stats --no-stream 에서 먼저 MEM USAGE / LIMIT 비율 확인, 그 다음 CPU % 확인 — MEM이 LIMIT의 80% 이상이면 OOM 위험, CPU 지속적 100% 이상이면 컨테이너 안에서 무한루프 또는 과부하
  • 리소스 기준: MEM USAGE LIMIT 80% 이상=OOM Killer 작동 위험(컨테이너 강제 종료), CPU % 100 이상=다른 컨테이너 영향 가능(cpu 제한 확인), NET I/O가 수 GB이면 로그를 파일 대신 stdout으로 과도하게 출력 중
  • MEM이 LIMIT 근접하면서 에러 로그에 "java.lang.OutOfMemoryError: GC overhead limit exceeded"가 보이면 → JVM 힙 설정이 컨테이너 메모리 한도를 인식 못 한 것 — JAVA_OPTS=-XX:MaxRAMPercentage=75.0 환경변수 추가

트러블슈팅

원인: 애플리케이션 시작 실패. 주로 필수 환경변수 미설정, DB 연결 실패, 포트 충돌이 원인입니다.

로컬 터미널
# 종료된 컨테이너 로그 확인 (가장 중요)
docker logs myapp

# 컨테이너 상세 정보 확인
docker inspect myapp | grep -A 10 '"State"'

# 환경변수 확인
docker exec myapp env | grep -E "DB|SPRING|APP"

# 포트 충돌 확인
ss -tlnp | grep 8080

해결: docker logs에서 에러 메시지 확인 → 환경변수 추가 또는 수정 → docker-compose up -d --force-recreate로 재시작

원인: 호스트의 디렉터리 소유자와 컨테이너 내 프로세스 실행 UID가 다른 경우 발생합니다.

로컬 터미널
# 호스트 디렉터리 권한 확인
ls -la /opt/app/logs

# 컨테이너 내 실행 사용자 확인
docker exec myapp id
docker exec myapp whoami

# 컨테이너 내 마운트 경로 권한 확인
docker exec myapp ls -la /app/logs

해결:

로컬 터미널
# 방법 1: 호스트 디렉터리 소유자를 컨테이너 UID에 맞춤
sudo chown -R 1000:1000 /opt/app/logs

# 방법 2: 컨테이너 UID에 맞춘 최소 권한 디렉터리 생성
sudo install -d -o 1000 -g 1000 -m 0750 /opt/app/logs

심화 — healthcheck는 '표시'일 뿐, '조치'가 아니다

💡개념

심화: healthcheck의 경계 — standalone Docker는 unhealthy를 스스로 처리하지 않는다

compose에 healthcheck를 넣으면 알아서 죽은 컨테이너를 살리고 트래픽을 끊어 줄 거라 기대하기 쉽습니다. 하지만 순수 Docker에서 healthcheck는 '상태 라벨'을 붙일 뿐 아무 행동도 하지 않습니다. 이 경계를 알아야 무중단 배포를 잘못 설계하지 않습니다.

  • 내부 동작: Docker는 interval마다 test 명령을 컨테이너 안에서 실행하고, 연속 retries회 실패하면 상태를 unhealthy로 바꿉니다. start_period 동안의 실패는 세지 않아(웜업 유예), 이 상태는 docker ps의 STATUS와 docker inspect.State.Health에 나타납니다.
  • 표시 ≠ 조치: standalone Docker(compose 포함)는 컨테이너가 unhealthy가 돼도 자동으로 재시작하지 않습니다. restart 정책은 '프로세스가 종료(exit)될 때'만 작동하지, 프로세스는 살아 있는 unhealthy에는 반응하지 않습니다. 그래서 unhealthy인 컨테이너가 계속 떠서 포트로 트래픽을 받습니다.
  • depends_on의 함정: depends_on은 기본적으로 '컨테이너가 시작됐는지'만 봅니다. 의존 서비스가 healthy가 될 때까지 기다리게 하려면 condition: service_healthy를 명시해야 합니다. 안 그러면 DB가 준비되기 전에 앱이 먼저 떠 커넥션 실패로 crash loop에 빠집니다.
  • 한계와 해법: unhealthy에 실제로 반응하려면 오케스트레이터(Kubernetes의 liveness/readiness probe)나 autoheal 같은 사이드카가 필요합니다. 롤링 배포에서 새 컨테이너가 healthy가 될 때까지 옛 컨테이너를 유지하는 것도 compose 단독으론 안 되고, 리버스 프록시·오케스트레이터가 그 역할을 합니다. 즉 compose의 healthcheck는 '상태를 알려 줄' 뿐, '무중단'과 '자가 치유'는 그 위 레이어의 몫입니다.

상황: healthcheck를 interval: 30s로 걸어 뒀습니다. 장애 시 앱이 DB 커넥션 풀 고갈로 /actuator/health를 실패하기 시작했고, docker ps의 STATUS가 Up 2 hours (unhealthy)로 바뀌었습니다. 그런데 컨테이너는 안 죽고 502가 계속 나갑니다. restart: unless-stopped를 걸어 뒀는데도 재시작이 되지 않습니다.

원인: standalone Docker와 compose는 healthcheck 결과로 컨테이너를 재시작하거나 트래픽을 끊지 않습니다. restart: unless-stopped는 프로세스 exit에만 반응하는데, 앱 프로세스는 '살아 있지만 요청을 못 처리하는' 상태라 exit가 아닙니다. 앞단 Nginx도 Docker의 health 상태와 연동돼 있지 않아 계속 그 컨테이너로 보냅니다.

진단: docker inspect --format '{{json .State.Health}}' myapp으로 최근 healthcheck 실패 로그를 봅니다. docker events에서 health_status 이벤트가 unhealthy로 찍혔는지, 그런데도 restart 카운트는 늘지 않았는지를 대조하면 '표시만 되고 조치는 없었다'가 확정됩니다.

해결: unhealthy에 실제로 반응할 레이어를 둡니다 — 오케스트레이터(K8s liveness probe가 unhealthy 시 컨테이너 재시작)나 autoheal 컨테이너로 unhealthy를 감지해 재시작합니다. 리버스 프록시는 자체 upstream 헬스체크(Nginx max_fails·active health check)로 죽은 백엔드를 빼도록 설정합니다. 근본적으로는 앱이 회복 불가 상태일 때 프로세스를 스스로 종료(fail fast)해 exit로 만들면 restart 정책이 작동하니, healthcheck 표시에만 의존하지 않도록 설계합니다.

💼
실무 맥락
현업 패턴

기존 Tomcat 서비스를 Docker로 전환할 때 체크포인트:

SI 프로젝트에서 "이번 버전부터 Docker로 납품한다"는 요청이 늘고 있습니다. 기존 WAS 운영 방식과 달라지는 핵심 사항:

  1. 설정 파일 위치 — server.xml, context.xml 대신 환경변수와 볼륨 마운트로 외부화
  2. 로그 경로/opt/tomcat/logs/ 대신 볼륨 마운트 경로로 통일
  3. 프로세스 관리systemctl restart tomcat 대신 docker-compose restart
  4. 헬스체크 — URL 헬스체크를 docker-compose healthcheck로 설정
  5. 네트워크 — 포트 바인딩(-p)으로 외부 노출, 내부 서비스끼리는 docker network로 통신
로컬 터미널
# 운영 서버에서 자주 쓰는 Docker 명령어 모음
alias dc="docker-compose"
alias dps="docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"
alias dlogs="docker logs --tail 100 -f"

명령어·단축키 빠른 참조

이 모듈에서 다룬 Docker·compose 운영 명령을 기존 WAS 명령과 대응해 모았습니다. "예" 열은 운영 서버에서 alias로 묶어 두면 편합니다.

명령어/단축키용도자주 쓰는 예
docker pull이미지 내려받기docker pull myregistry/myapp:1.4.2 (명시 버전 태그)
docker ps실행 중 컨테이너·상태 확인docker ps --format 'table {{.Names}}\t{{.Status}}'
docker logs컨테이너 로그(=tail -f)docker logs --tail 100 -f myapp
docker stats실시간 리소스(MEM/CPU)docker stats --no-stream
docker exec -it컨테이너 내부 진입·디버깅docker exec -it myapp /bin/sh
docker inspect --format상태·헬스 필드 추출docker inspect --format '{{.State.Health.Status}}' myapp
docker-compose up -d서비스 백그라운드 기동docker-compose up -d --force-recreate
docker-compose pull && up -d이미지 교체 배포docker-compose pull && docker-compose up -d
docker-compose ps서비스 State 확인Up / Exited(1) / Restarting 판별
docker-compose logs서비스 로그docker-compose logs --tail 30 -f app
--restart unless-stopped재부팅·크래시 자동 재시작docker run --restart unless-stopped ...
docker image prune -f미사용 이전 이미지 정리배포 후 디스크 확보

관련 모듈로 더 깊이:

다음 모듈에서는 systemd 서비스 등록과 자동 재시작 설정 — 컨테이너 외 환경에서의 서비스 관리를 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

Docker 컨테이너 배포가 WAR 배포보다 환경 일관성이 높은 이유는?

Q2

`docker-compose up -d`와 `docker-compose up`의 차이는?

Q3

실행 중인 컨테이너의 로그를 실시간으로 보는 명령어는?

Q4

컨테이너 재시작 정책 `restart: unless-stopped`의 의미는?

Q5

운영 이미지를 myapp:latest 태그로만 빌드·배포해 왔다. 이 방식의 문제와 올바른 태깅은?

Q6

docker run에 포트·볼륨·환경변수·의존서비스를 매번 긴 옵션으로 붙이다 실수가 잦았다. docker-compose.yml로 옮기면 좋은 점은?

Q7

[심화] docker-compose.yml에 healthcheck를 설정했습니다. 오케스트레이터 없이 순수 Docker(compose) 환경에서 컨테이너가 unhealthy 상태가 되면 기본적으로 어떤 일이 일어나나요?

Q8

[심화] 앱이 내부적으로 죽어 docker ps의 STATUS가 (unhealthy)로 바뀌었는데도, restart: unless-stopped인 컨테이너가 재시작되지 않고 502가 계속 나갑니다. 가장 정확한 원인은?

0 / 8 답변

🧪 실습으로 확인하기

Nginx 설치 및 기동

초급

Linux 서버에 Nginx를 설치하고 systemd 서비스로 등록하여 80포트에서 응답하는 상태까지 만든다.

40📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요