Blog

  • 서버 접속이 안 될 때 확인해야 할 체크리스트

    서버 접속이 안 될 때 확인해야 할 체크리스트

    서버에 접속이 안 되면 머릿속이 하얘집니다. 사이트가 안 열리는 건지, SSH가 안 되는 건지, 아니면 서버 자체가 죽은 건지 구분이 안 되고 뭐부터 해야 할지 모르겠습니다. 이런 상황에서 이것저것 무작정 건드리면 오히려 문제가 커질 수 있습니다. 중요한 건 당황하지 않고 체계적으로 하나씩 확인해나가는 것입니다. 이번 글에서는 서버 접속이 안 될 때 확인해야 할 항목들을 순서대로 정리해보겠습니다. 원인을 빠르게 좁혀나갈 수 있도록 바깥쪽부터 안쪽으로 진단하는 흐름입니다.

    먼저 문제의 범위를 파악하기

    가장 먼저 해야 할 일은 문제가 어디에서 발생하는지 범위를 좁히는 겁니다.

    내 컴퓨터에서만 안 되는 건지, 다른 사람도 안 되는 건지 확인합니다. 휴대폰 데이터로 접속해보거나, 다른 네트워크에서 접속을 시도합니다. 나만 안 된다면 내 네트워크나 DNS 캐시 문제일 가능성이 높습니다. 모든 곳에서 안 된다면 서버 쪽 문제입니다.

    웹 사이트 접속이 안 되는 건지, SSH 접속이 안 되는 건지도 구분해야 합니다. 웹은 안 되는데 SSH는 되면 Nginx나 앱 서버 문제입니다. 둘 다 안 되면 서버 자체가 다운됐거나 네트워크 단에서 막히고 있는 겁니다.

    외부 모니터링 서비스를 이용하는 방법도 있습니다. 웹 브라우저에서 “is it down for everyone” 같은 사이트에 내 도메인을 입력하면 전 세계 여러 지점에서 접속을 테스트해줍니다. 여기서도 안 된다고 나오면 확실히 서버 쪽 문제입니다.

    1단계 — DNS 확인

    도메인으로 접속하는 경우 가장 먼저 DNS를 확인합니다. 도메인이 올바른 IP를 가리키고 있는지 확인하는 겁니다.

    nslookup today-play.com

    결과에서 나오는 IP가 내 서버의 IP와 일치하는지 확인합니다. IP가 안 나오거나 엉뚱한 IP가 나오면 DNS 설정에 문제가 있는 겁니다.

    DNS 문제일 때 확인해야 할 것들이 있습니다. 도메인 등록 기간이 만료되지 않았는지 확인합니다. 도메인이 만료되면 DNS가 풀리면서 접속이 안 됩니다. 도메인 등록업체 사이트에서 만료일을 확인합니다.

    네임서버가 올바르게 설정되어 있는지 확인합니다. Cloudflare로 DNS를 관리하고 있다면 네임서버가 Cloudflare를 가리키고 있어야 합니다. 네임서버를 변경한 직후라면 전파에 시간이 걸릴 수 있습니다.

    A레코드가 정확한 IP를 가리키고 있는지 확인합니다. 서버를 새로 만들면서 IP가 바뀌었는데 DNS를 업데이트하지 않은 경우가 의외로 많습니다.

    구글 DNS에서도 조회해봅니다.

    nslookup today-play.com 8.8.8.8

    로컬 DNS에 캐시된 옛날 정보 때문에 문제가 생기는 경우도 있습니다. 구글 DNS에서 정상적으로 나오는데 로컬에서 안 된다면 내 컴퓨터나 공유기의 DNS 캐시를 초기화하면 됩니다.

    도메인이 아니라 IP 주소로 직접 접속을 시도해봅니다. 브라우저에 서버 IP를 입력해서 접속이 되면 DNS 문제가 확실합니다. IP로도 안 되면 DNS가 아닌 서버 쪽 문제입니다.

    2단계 — 네트워크 연결 확인

    DNS가 정상이면 서버까지 네트워크가 연결되는지 확인합니다.

    ping -c 5 서버IP

    응답이 오면 서버까지 네트워크는 열려 있는 겁니다. 응답이 안 오면 두 가지 가능성이 있습니다. 서버가 진짜 다운됐거나, 서버나 클라우드에서 ICMP 패킷을 차단하고 있는 경우입니다. AWS 같은 클라우드에서는 보안 그룹에서 ICMP를 허용하지 않으면 ping이 안 됩니다. 그래서 ping이 안 된다고 바로 서버가 죽은 거라고 단정하면 안 됩니다.

    이전 글에서 다뤘던 traceroute로 경로를 추적합니다.

    traceroute 서버IP

    어느 구간에서 패킷이 막히는지 확인할 수 있습니다. 내 네트워크 바로 다음 홉에서 막히면 내 ISP 문제이고, 중간 어딘가에서 막히면 경로상의 네트워크 문제이고, 마지막 홉 직전에서 막히면 서버 쪽 방화벽이나 보안 그룹 문제입니다.

    특정 포트로의 연결을 직접 테스트하려면 telnet이나 nc 명령어를 사용합니다.

    nc -zv 서버IP 80
    nc -zv 서버IP 443
    nc -zv 서버IP 22

    Connection succeeded가 나오면 해당 포트가 열려 있는 겁니다. Connection refused가 나오면 포트가 닫혀 있거나 해당 포트에서 서비스가 실행되고 있지 않은 겁니다. Connection timed out이 나오면 방화벽에서 차단하고 있을 가능성이 높습니다.

    3단계 — 클라우드 보안 그룹 확인

    클라우드 서비스를 사용하고 있다면 보안 그룹 설정을 확인해야 합니다. 서버의 UFW에서 포트를 열어도 클라우드의 보안 그룹에서 차단하면 접속이 안 됩니다.

    AWS EC2를 사용하는 경우 AWS 콘솔에서 해당 인스턴스의 보안 그룹을 확인합니다. 인바운드 규칙에 22번(SSH), 80번(HTTP), 443번(HTTPS)이 허용되어 있는지 확인합니다.

    SSH가 안 되는 경우 보안 그룹의 22번 포트 소스가 0.0.0.0/0인지, 아니면 특정 IP로 제한되어 있는지 확인합니다. 집에서 작업하다가 카페로 옮겨서 접속하는데 안 된다면 보안 그룹에 집 IP만 등록되어 있기 때문일 수 있습니다. 내 현재 IP를 추가하면 해결됩니다.

    탄력적 IP를 사용하고 있는지도 확인합니다. AWS EC2 인스턴스를 중지했다 시작하면 공인 IP가 바뀝니다. 탄력적 IP를 할당하지 않은 상태에서 인스턴스를 재시작했다면 IP가 변경됐을 수 있습니다. DNS에 등록된 IP와 현재 인스턴스의 IP가 다르면 접속이 안 됩니다.

    4단계 — 서버 상태 확인

    SSH로 서버에 접속이 가능하다면 서버 내부 상태를 확인합니다. SSH마저 안 된다면 클라우드 콘솔의 시리얼 콘솔이나 웹 터미널로 접속을 시도합니다.

    서버에 접속했으면 먼저 서버가 제대로 돌아가고 있는지 기본적인 것부터 확인합니다.

    uptime

    서버가 최근에 재부팅됐는지 확인할 수 있습니다. 업타임이 몇 분밖에 안 된다면 서버가 재시작됐다는 뜻입니다. 클라우드에서 하드웨어 유지보수 때문에 자동 재시작되는 경우도 있습니다.

    디스크 용량을 확인합니다.

    df -h

    디스크가 100%로 차 있으면 온갖 문제가 발생합니다. 로그를 못 쓰니까 서비스가 멈추고, 데이터베이스가 쓰기를 못 해서 죽고, 심하면 SSH 접속도 느려집니다. 이전 글에서 다뤘던 du 명령어로 어떤 디렉토리가 용량을 차지하는지 찾아서 정리합니다.

    메모리 상태를 확인합니다.

    free -h

    스왑이 거의 다 찼거나 available 메모리가 극도로 적으면 OOM Killer가 프로세스를 종료시켰을 수 있습니다. 이전 글에서 다뤘던 것처럼 dmesg로 확인합니다.

    sudo dmesg | grep -i "oom\|killed"

    5단계 — 방화벽 확인

    서버 자체는 살아있는데 외부에서 특정 포트로 접속이 안 되면 방화벽을 확인합니다.

    sudo ufw status

    이전 글에서 다뤘던 UFW 설정을 확인합니다. 필요한 포트가 허용되어 있는지 봅니다. UFW가 active 상태인데 규칙에 해당 포트가 없으면 차단되고 있는 겁니다.

    실수로 규칙을 잘못 설정해서 SSH까지 차단됐다면 클라우드 콘솔로 접속한 후에 UFW를 비활성화하거나 규칙을 수정합니다.

    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'

    방화벽 문제인지 확인하는 빠른 방법은 UFW를 임시로 끄고 접속해보는 겁니다.

    sudo ufw disable

    UFW를 끄니까 접속이 된다면 방화벽 규칙에 문제가 있는 겁니다. 필요한 포트를 허용한 뒤 다시 활성화합니다. 테스트가 끝나면 반드시 다시 켜야 합니다.

    sudo ufw enable

    6단계 — 웹 서버 확인

    SSH는 되는데 웹 사이트가 안 열리면 Nginx를 확인합니다.

    Nginx가 실행 중인지 확인합니다.

    sudo systemctl status nginx

    active (running)이 아니면 Nginx가 꺼져 있는 겁니다. 시작합니다.

    sudo systemctl start nginx

    시작에 실패한다면 설정 파일에 문법 에러가 있을 수 있습니다.

    sudo nginx -t

    에러 메시지가 나오면 해당 파일의 해당 줄을 확인해서 수정합니다.

    Nginx가 실행 중인데도 접속이 안 되면 Nginx가 올바른 포트에서 리스닝하고 있는지 확인합니다.

    sudo ss -tlnp | grep nginx

    80번과 443번 포트에서 리스닝하고 있어야 합니다.

    에러 로그도 확인합니다.

    sudo tail -50 /var/log/nginx/error.log

    로그에 구체적인 에러 메시지가 있으면 원인을 더 빠르게 파악할 수 있습니다.

    7단계 — 앱 서버 확인

    Nginx가 정상인데 502 에러가 나오면 이전 글에서 다뤘던 것처럼 뒤쪽 앱 서버를 확인합니다.

    pm2 list

    앱이 online 상태인지 확인합니다. 꺼져 있으면 재시작하고 로그에서 에러 원인을 확인합니다.

    pm2 logs 앱이름

    앱이 실행 중이라면 올바른 포트에서 돌아가고 있는지 확인합니다.

    ss -tlnp | grep node

    Nginx 설정의 proxy_pass 포트와 일치하는지 확인합니다.

    앱에 직접 요청을 보내서 응답하는지 테스트합니다.

    curl http://localhost:3000

    여기서 정상 응답이 오면 앱 자체는 문제없고 Nginx 설정 쪽을 다시 봐야 합니다. 여기서도 응답이 안 오면 앱 내부에 문제가 있는 겁니다.

    8단계 — SSL 인증서 확인

    HTTPS로 접속할 때만 문제가 생기고 HTTP로는 되는 경우 SSL 인증서를 확인합니다.

    sudo certbot certificates

    인증서의 만료일을 확인합니다. 만료됐으면 갱신합니다.

    sudo certbot renew

    이전 글에서 다뤘던 자동 갱신이 제대로 동작하고 있는지도 확인합니다.

    sudo systemctl status certbot.timer

    브라우저에서 “이 연결은 안전하지 않습니다” 같은 경고가 나온다면 인증서가 만료됐거나 인증서의 도메인이 접속하려는 도메인과 일치하지 않는 경우입니다. www를 포함하지 않고 인증서를 발급받았는데 www로 접속하면 이런 문제가 생길 수 있습니다.

    9단계 — 데이터베이스 확인

    사이트는 열리는데 특정 페이지에서 에러가 나거나 로딩이 안 끝나면 데이터베이스를 확인합니다.

    sudo systemctl status mysql

    데이터베이스가 꺼져 있으면 시작합니다.

    sudo systemctl start mysql

    실행 중인데 앱에서 연결이 안 되면 접속 정보를 확인합니다.

    mysql -u 사용자명 -p -h localhost

    직접 접속이 되면 앱의 환경 변수에 적힌 접속 정보와 비교합니다. 비밀번호가 바뀌었거나, 데이터베이스 이름이 틀렸거나, 호스트가 잘못 설정된 경우가 있습니다.

    빠른 진단 체크리스트

    위 내용을 상황별로 빠르게 확인할 수 있도록 정리하겠습니다.

    SSH 접속이 안 될 때 확인하는 순서입니다. 서버 IP가 맞는지 확인합니다. 클라우드 보안 그룹에서 22번 포트가 열려 있는지 확인합니다. UFW에서 SSH가 허용되어 있는지 확인합니다. SSH 키 파일이 맞는지, 권한이 600인지 확인합니다. 클라우드 콘솔로 접속해서 서버가 살아있는지 확인합니다.

    웹 사이트가 안 열릴 때 확인하는 순서입니다. DNS가 올바른 IP를 가리키는지 nslookup으로 확인합니다. 서버 IP로 직접 접속해봅니다. 보안 그룹에서 80번과 443번 포트가 열려 있는지 확인합니다. UFW에서 해당 포트가 허용되어 있는지 확인합니다. Nginx가 실행 중인지 확인합니다. 앱 서버가 실행 중인지 확인합니다. Nginx 에러 로그를 확인합니다.

    사이트가 느리거나 간헐적으로 안 될 때 확인하는 순서입니다. 서버 리소스 상태를 htop으로 확인합니다. 디스크 용량을 df -h로 확인합니다. 앱 로그에서 에러가 반복되고 있는지 확인합니다. 데이터베이스 연결과 쿼리 상태를 확인합니다. Nginx 액세스 로그에서 비정상적인 트래픽이 있는지 확인합니다.

    예방을 위해 해둘 것들

    문제가 터진 뒤에 진단하는 것도 중요하지만 미리 대비해두는 게 더 좋습니다.

    모니터링 알림을 설정해둡니다. 서버가 다운되면 알림을 받을 수 있도록 외부 모니터링 서비스를 연결해둡니다. UptimeRobot 같은 무료 서비스에 도메인을 등록하면 사이트가 안 열릴 때 이메일이나 슬랙으로 알림을 보내줍니다. 사용자가 알려주기 전에 내가 먼저 알 수 있습니다.

    서버 접속 정보를 정리해둡니다. 서버 IP, SSH 키 파일 위치, 클라우드 콘솔 로그인 정보, 도메인 관리 페이지 접속 정보를 한곳에 정리해둡니다. 문제가 터졌을 때 이 정보를 찾느라 시간을 낭비하는 경우가 의외로 많습니다.

    정기적으로 백업을 합니다. 서버가 완전히 복구 불가능한 상태가 되더라도 백업이 있으면 새 서버에 복원할 수 있습니다. 데이터베이스 백업은 이전 글에서 다뤘던 크론탭으로 자동화해두는 게 좋습니다.

    마무리

    서버 접속이 안 될 때 가장 중요한 건 순서대로 확인하는 것입니다. DNS부터 시작해서 네트워크, 보안 그룹, 방화벽, 웹 서버, 앱 서버, 데이터베이스 순서로 바깥에서 안쪽으로 좁혀가면 대부분의 문제를 찾을 수 있습니다. 처음에는 이 과정이 오래 걸리지만 몇 번 경험하면 몸에 익어서 빠르게 대응할 수 있게 됩니다. 이번 글에서 다룬 체크리스트를 어딘가에 저장해두고 필요할 때 꺼내 보시길 권합니다. 서버가 안 될 때 멘탈을 잡아주는 건 경험과 체크리스트입니다.

  • UFW(방화벽) 기본 설정 방법 정리

    UFW(방화벽) 기본 설정 방법 정리

    서버를 인터넷에 공개하는 순간부터 전 세계 어디에서든 접근 시도가 들어옵니다. 서버를 배포하고 몇 시간만 지나도 SSH 로그에 모르는 IP에서 접속을 시도한 기록이 쌓여 있는 걸 볼 수 있습니다. 자동화된 봇들이 쉬지 않고 서버를 스캔하면서 열린 포트와 취약점을 찾고 있기 때문입니다. 방화벽은 이런 불필요한 접근을 차단하는 가장 기본적인 보안 장치입니다. 리눅스에는 iptables라는 방화벽이 기본으로 내장되어 있지만 설정이 복잡합니다. UFW는 이 iptables를 쉽게 다룰 수 있도록 만든 도구입니다. 이번 글에서는 UFW로 서버 방화벽을 설정하는 방법을 처음부터 정리해보겠습니다.

    UFW란 무엇인가

    UFW는 Uncomplicated Firewall의 약자입니다. 이름 그대로 복잡하지 않은 방화벽이라는 뜻입니다. Ubuntu에 기본으로 설치되어 있고, 직관적인 명령어로 방화벽 규칙을 관리할 수 있습니다.

    리눅스 방화벽의 실체는 커널에 내장된 netfilter이고, 이걸 제어하는 도구가 iptables입니다. iptables는 강력하지만 명령어가 길고 복잡해서 규칙 하나 추가하는 데도 여러 옵션을 기억해야 합니다. UFW는 iptables 위에 올라가는 인터페이스로, 간단한 명령어만으로 동일한 기능을 수행합니다. UFW에서 규칙을 추가하면 내부적으로 iptables 규칙이 생성됩니다.

    UFW 설치 확인과 상태 확인

    Ubuntu에는 UFW가 기본 설치되어 있습니다. 설치 여부를 확인하려면 이렇게 합니다.

    sudo ufw version

    버전 정보가 나오면 설치되어 있는 겁니다. 혹시 없다면 설치합니다.

    sudo apt install ufw

    현재 방화벽 상태를 확인합니다.

    sudo ufw status

    처음에는 “Status: inactive”라고 나옵니다. UFW가 설치되어 있지만 아직 활성화되지 않은 상태입니다. 활성화하기 전에 반드시 SSH 허용 규칙부터 추가해야 합니다. 그렇지 않으면 방화벽이 켜지면서 SSH 연결이 끊기고 서버에 접속할 수 없게 됩니다.

    SSH부터 허용하고 활성화하기

    UFW를 켜기 전에 가장 먼저 SSH를 허용합니다. 이 순서가 매우 중요합니다.

    sudo ufw allow OpenSSH

    OpenSSH는 UFW에 미리 등록되어 있는 애플리케이션 프로필입니다. 이 명령어 하나로 SSH 기본 포트인 22번이 허용됩니다.

    SSH 포트를 기본 22번이 아닌 다른 포트로 변경해서 사용하고 있다면 해당 포트를 직접 지정해야 합니다.

    sudo ufw allow 2222/tcp

    SSH를 허용한 뒤에 UFW를 활성화합니다.

    sudo ufw enable

    활성화할 때 “기존 SSH 연결이 끊길 수 있다”는 경고가 나옵니다. SSH 허용 규칙을 이미 추가했으니 y를 눌러서 진행합니다.

    활성화 후 상태를 확인합니다.

    sudo ufw status verbose

    Status가 active로 바뀌어 있고, 기본 정책과 추가한 규칙이 표시됩니다. 기본 정책은 들어오는 트래픽은 전부 차단하고 나가는 트래픽은 전부 허용하는 상태입니다. 허용 규칙에 추가한 포트만 외부에서 접속할 수 있습니다.

    웹 서버 포트 허용

    이전 글들에서 다뤘던 것처럼 웹 서비스를 운영하려면 HTTP(80번)와 HTTPS(443번) 포트를 열어야 합니다.

    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp

    또는 Nginx가 설치되어 있다면 애플리케이션 프로필을 사용할 수 있습니다.

    sudo ufw allow 'Nginx Full'

    Nginx Full은 80번과 443번을 동시에 허용합니다. HTTP만 허용하고 싶으면 Nginx HTTP, HTTPS만 허용하고 싶으면 Nginx HTTPS를 사용합니다.

    등록된 애플리케이션 프로필 목록을 확인하려면 이렇게 합니다.

    sudo ufw app list

    설치된 서비스에 따라 프로필이 다르게 나옵니다. OpenSSH, Nginx Full, Nginx HTTP, Nginx HTTPS 같은 프로필이 있을 수 있습니다.

    포트 허용과 차단

    특정 포트를 허용하는 기본 형식입니다.

    sudo ufw allow 포트번호

    프로토콜을 지정할 수도 있습니다. TCP만 허용하거나 UDP만 허용할 때 사용합니다.

    sudo ufw allow 3000/tcp
    sudo ufw allow 53/udp

    포트를 차단하려면 allow 대신 deny를 사용합니다.

    sudo ufw deny 3306

    이렇게 하면 MySQL 기본 포트인 3306번으로의 외부 접근이 차단됩니다. 데이터베이스 포트는 외부에서 직접 접근할 필요가 없는 경우가 대부분이기 때문에 차단해두는 게 좋습니다. 앱 서버가 같은 서버에서 localhost로 접속하는 건 방화벽과 무관하게 됩니다.

    포트 범위를 지정할 수도 있습니다.

    sudo ufw allow 8000:8100/tcp

    8000번부터 8100번까지의 TCP 포트를 허용합니다.

    IP 기반 규칙 설정

    특정 IP에서만 접근을 허용하는 설정이 실무에서 매우 유용합니다. SSH는 내 IP에서만 접속 가능하게 하고, 나머지는 전부 차단하는 식입니다.

    특정 IP에서의 모든 접근을 허용하려면 이렇게 합니다.

    sudo ufw allow from 203.0.113.50

    특정 IP에서 특정 포트로의 접근만 허용하려면 이렇게 합니다.

    sudo ufw allow from 203.0.113.50 to any port 22

    이건 203.0.113.50에서 오는 SSH 접속만 허용한다는 뜻입니다. 다른 IP에서는 22번 포트에 접근할 수 없습니다.

    이전 글에서 다뤘던 CIDR 표기법을 사용해서 IP 대역을 지정할 수도 있습니다.

    sudo ufw allow from 203.0.113.0/24 to any port 22

    203.0.113.0부터 203.0.113.255까지의 IP 대역에서 SSH 접속을 허용합니다. 회사 네트워크 대역을 지정할 때 유용합니다.

    특정 IP를 차단하는 것도 가능합니다.

    sudo ufw deny from 192.168.1.100

    악의적인 접속 시도가 반복되는 IP를 차단할 때 사용합니다.

    규칙 확인과 삭제

    현재 설정된 규칙을 번호와 함께 확인하려면 이렇게 합니다.

    sudo ufw status numbered

    결과가 이런 식으로 나옵니다.

    Status: active
    
         To                         Action      From
         --                         ------      ----
    [ 1] OpenSSH                    ALLOW IN    Anywhere
    [ 2] 80/tcp                     ALLOW IN    Anywhere
    [ 3] 443/tcp                    ALLOW IN    Anywhere
    [ 4] 3306                       DENY IN     Anywhere

    규칙을 삭제하려면 번호를 지정합니다.

    sudo ufw delete 4

    4번 규칙인 3306 차단 규칙이 삭제됩니다. 삭제 전에 확인 메시지가 나오니 실수로 잘못 삭제할 걱정은 적습니다.

    규칙을 번호 대신 내용으로 삭제할 수도 있습니다.

    sudo ufw delete allow 80/tcp

    규칙이 많아지면 번호가 바뀔 수 있기 때문에 삭제 전에 항상 sudo ufw status numbered로 현재 번호를 확인하는 게 안전합니다.

    기본 정책 설정

    UFW의 기본 정책은 들어오는 트래픽을 어떻게 처리할지 결정합니다.

    sudo ufw default deny incoming
    sudo ufw default allow outgoing

    첫 번째 줄은 들어오는 트래픽을 기본적으로 차단합니다. 명시적으로 허용한 포트만 접근 가능합니다. 두 번째 줄은 나가는 트래픽을 기본적으로 허용합니다. 서버에서 외부로 나가는 요청은 자유롭게 가능합니다. 패키지 업데이트나 외부 API 호출이 정상적으로 동작하려면 나가는 트래픽은 허용되어야 합니다.

    이 설정이 UFW를 처음 활성화할 때 기본으로 적용되는 정책입니다. 보안상 이 기본 정책을 바꿀 필요는 거의 없습니다. 필요한 포트만 하나씩 열어주는 화이트리스트 방식이 가장 안전합니다.

    로그 설정

    UFW의 로그를 활성화하면 차단된 접근 시도를 기록으로 남길 수 있습니다.

    sudo ufw logging on

    로그 레벨을 지정할 수도 있습니다.

    sudo ufw logging medium

    low, medium, high, full 네 단계가 있습니다. low는 차단된 패킷만 기록하고, high와 full은 허용된 패킷까지 기록합니다. 일반적으로 medium이면 충분합니다.

    로그는 /var/log/ufw.log에 기록됩니다.

    sudo tail -50 /var/log/ufw.log

    어떤 IP에서 어떤 포트로 접근을 시도했는데 차단됐는지 확인할 수 있습니다. 특정 IP에서 반복적으로 접근을 시도하고 있다면 해당 IP를 deny 규칙으로 추가할 수 있습니다. 하지만 이런 수동 차단보다는 Fail2ban 같은 도구를 사용하는 게 효율적입니다. Fail2ban은 로그를 감시하다가 일정 횟수 이상 접속에 실패한 IP를 자동으로 차단해줍니다.

    실무에서 자주 사용하는 설정 조합

    웹 서버를 운영하는 일반적인 구성에서 UFW 설정은 이런 식이 됩니다.

    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw enable

    이렇게 하면 SSH, HTTP, HTTPS만 열리고 나머지는 전부 차단됩니다. 앱 서버가 사용하는 3000번 포트는 외부에 열 필요가 없습니다. 이전 글에서 다뤘던 것처럼 Nginx가 리버스 프록시로 80번과 443번 포트를 받아서 내부적으로 3000번에 전달하기 때문입니다.

    SSH를 특정 IP에서만 허용하고 싶다면 좀 더 엄격하게 설정할 수 있습니다.

    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow from 내IP주소 to any port 22
    sudo ufw allow 'Nginx Full'
    sudo ufw enable

    이렇게 하면 SSH는 내 IP에서만 접속 가능하고, 웹 트래픽은 모든 곳에서 접근 가능합니다. 가장 안전한 구성 중 하나입니다.

    데이터베이스를 외부에서 접근해야 하는 특수한 경우에도 모든 IP에 열지 말고 필요한 IP만 허용합니다.

    sudo ufw allow from 앱서버IP to any port 3306

    앱 서버의 IP에서만 데이터베이스 포트에 접근할 수 있게 합니다.

    UFW 비활성화와 초기화

    UFW를 임시로 끄려면 이렇게 합니다.

    sudo ufw disable

    규칙은 유지되지만 방화벽이 비활성화됩니다. 다시 enable하면 이전 규칙이 그대로 적용됩니다.

    규칙을 전부 초기화하고 처음부터 다시 설정하고 싶으면 이렇게 합니다.

    sudo ufw reset

    모든 규칙이 삭제되고 UFW가 비활성화됩니다. 다시 설정할 때는 SSH 허용부터 시작해야 한다는 걸 잊지 말아야 합니다.

    주의할 점들

    가장 중요한 건 SSH 허용 없이 UFW를 활성화하지 않는 것입니다. 원격으로 서버를 관리하는 상황에서 SSH가 차단되면 서버에 접속할 방법이 없어집니다. 클라우드 서비스에서는 콘솔 접속 기능을 제공하기도 하지만, 직접 관리하는 서버라면 물리적으로 가서 모니터와 키보드를 연결해야 할 수도 있습니다.

    규칙 순서도 중요합니다. UFW는 규칙을 위에서부터 순서대로 확인하고, 먼저 매칭되는 규칙을 적용합니다. 같은 포트에 대해 allow와 deny가 둘 다 있으면 먼저 나오는 규칙이 적용됩니다. 특정 IP를 차단하고 싶은데 그 위에 해당 포트를 전부 허용하는 규칙이 있으면 차단이 동작하지 않습니다.

    특정 규칙을 목록의 특정 위치에 삽입하고 싶으면 insert를 사용합니다.

    sudo ufw insert 1 deny from 악성IP

    1번 위치에 규칙을 삽입해서 다른 규칙보다 먼저 적용되게 합니다.

    클라우드 서비스를 사용하고 있다면 클라우드의 보안 그룹과 UFW가 이중으로 동작한다는 점도 알아둬야 합니다. AWS의 보안 그룹에서 포트를 열어도 UFW에서 차단하면 접근이 안 되고, 반대로 UFW에서 열어도 보안 그룹에서 차단하면 접근이 안 됩니다. 양쪽 모두에서 허용되어야 실제로 접근이 가능합니다.

    마무리

    UFW는 서버 보안의 가장 기본적인 계층입니다. 필요한 포트만 열고 나머지를 전부 닫는 단순한 원칙만 지켜도 상당수의 불필요한 접근을 차단할 수 있습니다. SSH, HTTP, HTTPS 세 가지만 열면 일반적인 웹 서버 운영에는 충분하고, SSH를 특정 IP로 제한하면 보안이 한층 더 강화됩니다. 서버를 배포한 직후에 UFW 설정을 하는 걸 습관으로 만들어두면 좋습니다. 다음 글에서는 서버에 접속이 안 될 때 확인해야 할 전체 체크리스트를 정리해보겠습니다.

  • 502 Bad Gateway 오류 원인과 해결법

    502 Bad Gateway 오류 원인과 해결법

    서버를 운영하다가 브라우저에 502 Bad Gateway라는 에러가 뜨면 심장이 덜컥합니다. 방금까지 잘 되던 사이트가 갑자기 이 화면을 보여주면 뭐부터 해야 할지 막막해집니다. 502 에러는 서버가 완전히 죽은 건 아닌데 뭔가 내부에서 꼬인 상태라서, 원인을 모르면 한참 헤매게 됩니다. 이번 글에서는 502 Bad Gateway가 정확히 무슨 뜻인지, 어떤 상황에서 발생하는지, 각 원인별로 어떻게 해결하는지를 정리해보겠습니다.

    502 Bad Gateway가 의미하는 것

    502 에러를 이해하려면 먼저 서버 구조를 떠올려야 합니다. 이전 글에서 다뤘던 배포 구성을 생각해보면, 사용자의 요청은 Nginx가 먼저 받고 뒤쪽의 앱 서버로 전달합니다. Nginx가 앞단에서 리버스 프록시 역할을 하고, Node.js나 Python 같은 앱 서버가 뒤에서 실제 처리를 하는 구조입니다.

    502 Bad Gateway는 Nginx가 뒤쪽의 앱 서버에 요청을 전달하려고 했는데 유효한 응답을 받지 못했을 때 발생합니다. Nginx 자체는 정상적으로 동작하고 있지만 뒤쪽 서버가 응답을 주지 않는 상황입니다. 그래서 502 에러가 뜨면 Nginx는 살아있고 문제는 뒤쪽에 있다는 뜻입니다.

    비유하자면 식당의 홀 직원은 정상 출근해서 주문을 받고 있는데, 주방에서 요리가 안 나오는 상태입니다. 손님한테 “주방에 문제가 있습니다”라고 알리는 게 502 에러입니다.

    가장 흔한 원인 — 앱 서버가 꺼져 있는 경우

    502 에러의 가장 흔한 원인은 앱 서버가 돌아가고 있지 않은 겁니다. PM2로 관리하던 앱이 에러로 종료됐거나, 서버를 재시작한 후에 앱이 자동으로 뜨지 않은 경우입니다.

    가장 먼저 앱이 실행 중인지 확인합니다.

    pm2 list

    상태가 online이 아니라 stopped나 errored로 나오면 앱이 꺼져 있는 겁니다. 로그를 확인해서 왜 종료됐는지 파악합니다.

    pm2 logs 앱이름

    로그에 에러 메시지가 나오면 해당 에러를 수정해야 합니다. 환경 변수가 빠져 있거나, 데이터베이스 접속이 실패하거나, 코드에 문법 에러가 있는 경우가 많습니다.

    앱을 다시 시작합니다.

    pm2 restart 앱이름

    시작 후에 다시 pm2 list로 상태가 online인지 확인합니다. 시작하자마자 바로 errored로 바뀐다면 앱 자체에 문제가 있는 것이니 로그를 꼼꼼히 봐야 합니다.

    PM2를 사용하지 않는 환경이라면 앱 프로세스가 실행 중인지 직접 확인합니다.

    ps aux | grep node

    Node.js 프로세스가 목록에 없으면 앱이 꺼져 있는 겁니다.

    포트 불일치 문제

    Nginx 설정에서 proxy_pass에 적힌 포트와 앱이 실제로 실행되는 포트가 다르면 502 에러가 납니다.

    Nginx 설정을 확인합니다.

    sudo cat /etc/nginx/sites-enabled/today-play

    proxy_pass 부분을 봅니다.

    proxy_pass http://localhost:3000;

    여기서 3000번 포트로 요청을 보내도록 되어 있다면, 앱도 3000번에서 돌아가고 있어야 합니다. 앱이 실제로 어떤 포트에서 실행 중인지 확인합니다.

    ss -tlnp | grep node

    이 명령어는 node 프로세스가 어떤 포트에서 리스닝하고 있는지 보여줍니다. 앱이 8080 포트에서 돌아가고 있는데 Nginx가 3000으로 보내고 있다면 당연히 연결이 안 됩니다.

    해결 방법은 둘 중 하나입니다. 앱의 포트를 Nginx 설정에 맞추거나, Nginx 설정의 포트를 앱에 맞추면 됩니다. 앱의 포트는 보통 환경 변수로 설정하니까 .env 파일에서 PORT 값을 확인합니다.

    Nginx 설정을 수정했으면 반드시 문법 확인 후 리로드합니다.

    sudo nginx -t
    sudo nginx -s reload

    앱 서버가 과부하인 경우

    앱이 실행 중이고 포트도 맞는데 502가 발생한다면 앱 서버가 요청을 처리할 여유가 없는 상태일 수 있습니다. 요청이 갑자기 몰렸거나, 특정 요청이 너무 오래 걸려서 다른 요청까지 밀리는 경우입니다.

    이전 글에서 다뤘던 htop으로 서버 상태를 확인합니다.

    htop

    CPU가 100%에 가깝거나 메모리가 거의 다 찼다면 과부하 상태입니다. 어떤 프로세스가 리소스를 많이 사용하는지 확인하고, 이전 글에서 다뤘던 서버 느려짐 진단 방법을 따라가면 됩니다.

    Node.js는 기본적으로 싱글 스레드이기 때문에 CPU를 많이 사용하는 작업이 하나라도 있으면 다른 요청이 전부 대기하게 됩니다. 이런 경우 PM2의 클러스터 모드를 사용하면 CPU 코어 수만큼 프로세스를 띄워서 부하를 분산할 수 있습니다.

    pm2 start app.js -i max

    -i max 옵션은 CPU 코어 수에 맞춰서 프로세스를 자동으로 생성합니다.

    데이터베이스 연결 실패

    앱이 데이터베이스에 접속하지 못하면 요청을 처리할 수 없고, 결과적으로 Nginx에 응답을 주지 못해서 502가 발생합니다.

    데이터베이스가 실행 중인지 확인합니다.

    sudo systemctl status mysql

    active 상태가 아니면 데이터베이스가 꺼져 있는 겁니다. 다시 시작합니다.

    sudo systemctl start mysql

    데이터베이스가 실행 중인데도 앱에서 연결이 안 된다면 몇 가지를 확인해야 합니다. 앱의 환경 변수에 적힌 데이터베이스 접속 정보가 맞는지, 데이터베이스 사용자에게 접속 권한이 있는지, 최대 연결 수에 도달하지 않았는지 확인합니다.

    MySQL의 현재 연결 수를 확인하려면 이렇게 합니다.

    mysql -u root -p -e "SHOW STATUS LIKE 'Threads_connected';"

    최대 허용 연결 수와 비교합니다.

    mysql -u root -p -e "SHOW VARIABLES LIKE 'max_connections';"

    현재 연결 수가 최대 연결 수에 가깝다면 앱에서 연결을 제대로 반환하지 않고 있거나, 커넥션 풀 설정이 맞지 않는 겁니다.

    메모리 부족으로 앱이 죽는 경우

    서버 메모리가 부족하면 리눅스의 OOM Killer(Out of Memory Killer)가 메모리를 많이 사용하는 프로세스를 강제 종료합니다. 앱 서버가 OOM Killer에 의해 죽으면 502 에러가 발생합니다.

    OOM Killer가 동작했는지 확인하려면 시스템 로그를 봅니다.

    sudo dmesg | grep -i "oom\|killed"

    “Out of memory: Killed process”라는 메시지가 보이면 메모리 부족으로 프로세스가 종료된 겁니다. 어떤 프로세스가 죽었는지도 로그에 나옵니다.

    이 경우 해결 방법은 몇 가지가 있습니다. 서버의 메모리를 늘리는 게 가장 확실합니다. 당장 메모리를 늘릴 수 없다면 이전 글에서 다뤘던 스왑 파일을 추가해서 임시로 대응할 수 있습니다. 앱에서 메모리 누수가 발생하고 있다면 코드를 수정해야 합니다.

    Node.js의 메모리 사용량을 제한하는 방법도 있습니다. PM2에서 max_memory_restart 옵션을 설정하면 앱이 지정한 메모리를 넘었을 때 자동으로 재시작합니다.

    pm2 start app.js --max-memory-restart 512M

    512MB를 넘으면 앱을 재시작해서 메모리를 반환합니다. 근본적인 해결은 아니지만 OOM Killer에 의해 갑자기 죽는 것보다는 나은 대응입니다.

    Nginx 버퍼 크기 문제

    앱 서버가 보내는 응답이 Nginx의 버퍼 크기보다 크면 502 에러가 발생할 수 있습니다. 응답 헤더가 매우 크거나 응답 본문이 큰 경우에 생기는 문제입니다.

    Nginx 에러 로그에 “upstream sent too big header” 같은 메시지가 있으면 이 문제입니다.

    sudo tail -50 /var/log/nginx/error.log

    해결하려면 Nginx 설정에서 버퍼 크기를 늘려줍니다.

    server {
        ...
        location / {
            proxy_pass http://localhost:3000;
            proxy_buffer_size 128k;
            proxy_buffers 4 256k;
            proxy_busy_buffers_size 256k;
            ...
        }
    }

    proxy_buffer_size는 응답 헤더를 읽을 때 사용하는 버퍼 크기이고, proxy_buffers는 응답 본문을 읽을 때 사용하는 버퍼입니다. 기본값이 작아서 문제가 생기는 경우 위처럼 늘려주면 해결됩니다.

    설정을 변경한 후에는 반드시 확인하고 리로드합니다.

    sudo nginx -t
    sudo nginx -s reload

    타임아웃 문제

    앱 서버가 응답을 보내기는 하는데 너무 오래 걸리면 Nginx가 기다리다가 포기합니다. 이때는 502가 아니라 504 Gateway Timeout이 나오는 경우가 더 많지만, 상황에 따라 502가 나올 수도 있습니다.

    Nginx의 타임아웃 설정을 확인하고 필요하면 늘려줍니다.

    server {
        ...
        location / {
            proxy_pass http://localhost:3000;
            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;
            ...
        }
    }

    proxy_connect_timeout은 앱 서버에 연결을 맺기까지의 대기 시간이고, proxy_read_timeout은 앱 서버가 응답을 보내기까지의 대기 시간입니다. 기본값은 60초인데, 특별히 오래 걸리는 작업이 있다면 늘려줄 수 있습니다.

    다만 타임아웃을 무작정 늘리는 건 좋지 않습니다. 응답이 60초 이상 걸린다는 건 근본적으로 뭔가 문제가 있는 겁니다. 쿼리가 너무 무겁거나 외부 API 호출이 느리거나 코드에 비효율적인 부분이 있을 수 있습니다. 타임아웃을 늘리는 건 임시 대응이고, 원인을 찾아서 응답 시간 자체를 줄이는 게 맞습니다.

    소켓 파일 문제

    앱 서버와 Nginx가 TCP 포트 대신 유닉스 소켓으로 통신하는 경우, 소켓 파일의 권한이나 경로 문제로 502가 발생할 수 있습니다.

    Nginx 설정에서 proxy_pass가 이런 형태라면 소켓 통신을 사용하고 있는 겁니다.

    proxy_pass http://unix:/var/run/myapp.sock;

    이 경우 해당 소켓 파일이 존재하는지, Nginx 프로세스가 소켓 파일에 접근할 수 있는 권한이 있는지 확인해야 합니다.

    ls -l /var/run/myapp.sock

    파일이 없으면 앱이 소켓을 생성하지 못한 겁니다. 파일은 있는데 권한이 맞지 않으면 Nginx가 읽을 수 없습니다. 이전 글에서 다뤘던 chmod와 chown으로 권한을 조정합니다.

    502 에러 진단 순서 정리

    502 에러가 발생했을 때 확인하는 순서를 정리하면 이렇습니다.

    첫째, 앱 서버가 실행 중인지 확인합니다. pm2 list 또는 ps aux로 프로세스가 살아있는지 봅니다. 꺼져 있으면 재시작하고 로그에서 종료 원인을 확인합니다.

    둘째, 앱이 올바른 포트에서 실행 중인지 확인합니다. ss -tlnp로 리스닝 포트를 확인하고 Nginx 설정의 proxy_pass 포트와 일치하는지 봅니다.

    셋째, Nginx 에러 로그를 확인합니다. sudo tail -50 /var/log/nginx/error.log에서 구체적인 에러 메시지를 찾습니다.

    넷째, 서버 리소스를 확인합니다. htop으로 CPU와 메모리 상태를 보고, dmesg로 OOM Killer가 동작했는지 확인합니다.

    다섯째, 데이터베이스 등 외부 의존성을 확인합니다. 앱이 의존하는 서비스들이 전부 정상인지 점검합니다.

    이 순서대로 확인하면 대부분의 502 에러 원인을 찾을 수 있습니다.

    마무리

    502 Bad Gateway는 Nginx와 앱 서버 사이의 통신에 문제가 생겼다는 신호입니다. 대부분의 경우 앱 서버가 꺼져 있거나, 포트가 안 맞거나, 서버 리소스가 부족한 게 원인입니다. Nginx 에러 로그가 가장 중요한 단서를 제공하기 때문에 502가 보이면 가장 먼저 에러 로그를 확인하는 습관을 들이면 좋습니다. 원인만 알면 해결은 대부분 간단합니다. 다음 글에서는 서버의 기본적인 방화벽 설정 도구인 UFW 사용법을 정리해보겠습니다.

  • 서버가 느려졌을 때 원인 찾는 방법

    서버가 느려졌을 때 원인 찾는 방법

    어제까지 잘 돌아가던 서버가 갑자기 느려지면 당황스럽습니다. 페이지 로딩이 몇 초씩 걸리거나, API 응답이 타임아웃 나거나, 심하면 SSH 접속조차 버벅거립니다. 이런 상황에서 무작정 서버를 재시작하면 당장은 나아질 수 있지만 원인을 모르면 또 같은 일이 반복됩니다. 서버가 느려지는 원인은 크게 CPU, 메모리, 디스크, 네트워크 네 가지로 나뉩니다. 이번 글에서는 서버가 느려졌을 때 원인을 체계적으로 찾아가는 방법을 정리해보겠습니다.

    가장 먼저 할 일 — 전체 상태 훑어보기

    서버에 SSH로 접속했으면 가장 먼저 전체적인 상태를 확인합니다. 이전 글에서 다뤘던 htop이 설치되어 있으면 htop을 실행하고, 없으면 top을 실행합니다.

    htop

    htop 화면에서 바로 확인할 수 있는 것들이 있습니다. 화면 상단의 CPU 막대가 전부 빨간색으로 꽉 차 있으면 CPU 문제입니다. 메모리 막대가 거의 다 차 있고 스왑도 많이 사용 중이면 메모리 문제입니다. CPU와 메모리가 둘 다 여유가 있는데 느리다면 디스크 I/O나 네트워크 쪽을 의심해야 합니다.

    htop만으로 대략적인 방향이 잡히는 경우가 많습니다. 여기서 어떤 쪽이 문제인지 파악하고 해당 영역을 더 깊게 파고들면 됩니다.

    uptime 명령어로 로드 애버리지도 확인합니다.

    uptime

    결과에 세 개의 숫자가 나오는데 각각 1분, 5분, 15분 평균 로드입니다. 이 숫자가 서버의 CPU 코어 수보다 높으면 서버에 부하가 걸려 있다는 뜻입니다. 코어가 2개인 서버에서 로드가 4라면 처리 대기 중인 작업이 쌓이고 있는 겁니다. 1분 평균이 높고 15분 평균이 낮으면 최근에 갑자기 부하가 생긴 것이고, 15분 평균도 높으면 한동안 계속 부하 상태였다는 뜻입니다.

    CPU가 원인인 경우

    htop에서 특정 프로세스가 CPU를 거의 다 차지하고 있다면 원인이 명확합니다. CPU 사용률 순으로 정렬해서 어떤 프로세스가 CPU를 많이 먹고 있는지 확인합니다. htop에서는 기본으로 CPU 순 정렬이 되어 있고, top에서는 Shift+P를 누르면 됩니다.

    Node.js 앱이 CPU를 100% 가까이 사용하고 있다면 코드에 무한 루프가 있거나, 매우 무거운 연산을 동기적으로 처리하고 있거나, 요청이 갑자기 폭증한 경우입니다.

    MySQL이나 PostgreSQL 같은 데이터베이스가 CPU를 잡아먹고 있다면 느린 쿼리가 실행되고 있을 가능성이 높습니다. 인덱스 없이 대량의 데이터를 풀스캔하는 쿼리가 대표적입니다.

    의도하지 않은 프로세스가 CPU를 사용하고 있다면 보안 문제일 수 있습니다. 서버가 해킹당해서 암호화폐 채굴 프로그램이 돌아가는 경우가 실제로 있습니다. 모르는 프로세스가 CPU를 점유하고 있다면 해당 프로세스의 경로를 확인해야 합니다.

    ls -l /proc/프로세스PID/exe

    이 명령어로 해당 프로세스의 실행 파일 경로를 확인할 수 있습니다.

    특정 프로세스를 당장 중지해야 한다면 kill 명령어를 사용합니다.

    kill 프로세스PID

    강제 종료가 필요하면 -9 옵션을 붙입니다.

    kill -9 프로세스PID

    메모리가 원인인 경우

    메모리 문제는 두 가지로 나뉩니다. 물리 메모리가 부족한 경우와 메모리 누수가 발생하는 경우입니다.

    먼저 전체 메모리 상태를 확인합니다.

    free -h

    결과에서 봐야 할 항목은 available 값입니다. 이 값이 전체 메모리의 10% 미만이면 메모리가 부족한 상태입니다. used가 높더라도 available이 충분하면 괜찮습니다. 리눅스는 사용하지 않는 메모리를 캐시로 활용하기 때문에 used가 높게 나오는 게 정상입니다.

    Swap 항목도 확인합니다. 스왑은 디스크를 메모리처럼 사용하는 건데, 디스크는 메모리보다 훨씬 느리기 때문에 스왑이 많이 사용되면 서버가 급격히 느려집니다. 스왑 사용량이 지속적으로 높다면 물리 메모리가 부족하다는 신호입니다.

    어떤 프로세스가 메모리를 많이 사용하는지 확인하려면 htop에서 F6을 눌러 메모리 순으로 정렬하거나, top에서 Shift+M을 누릅니다.

    메모리 누수는 시간이 지남에 따라 특정 프로세스의 메모리 사용량이 계속 증가하는 현상입니다. 앱을 처음 시작했을 때는 100MB를 사용하다가 며칠 후에 2GB까지 올라갔다면 메모리 누수를 의심해야 합니다. Node.js에서는 이벤트 리스너를 해제하지 않거나, 전역 변수에 데이터를 계속 쌓거나, 클로저에서 참조를 놓지 않는 경우에 많이 발생합니다.

    메모리가 당장 부족해서 급한 상황이라면 메모리를 많이 사용하는 불필요한 프로세스를 종료하거나, 앱을 재시작해서 메모리를 반환받는 게 임시 조치입니다. 근본적으로는 메모리 누수를 코드에서 찾아 수정해야 합니다.

    스왑이 아예 없는 서버라면 스왑 파일을 추가하는 것도 방법입니다. 메모리가 꽉 차면 앱이 바로 죽는 것보다는 느리더라도 돌아가는 게 낫기 때문입니다.

    sudo fallocate -l 2G /swapfile
    sudo chmod 600 /swapfile
    sudo mkswap /swapfile
    sudo swapon /swapfile

    영구적으로 적용하려면 /etc/fstab에 추가합니다.

    echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

    디스크가 원인인 경우

    CPU와 메모리가 둘 다 여유가 있는데 서버가 느리다면 디스크 I/O를 확인해야 합니다.

    먼저 디스크 용량을 확인합니다.

    df -h

    Use%가 90%를 넘는 파티션이 있으면 디스크가 거의 찬 겁니다. 디스크가 꽉 차면 로그를 못 쓰거나 데이터베이스가 멈추면서 서비스가 중단될 수 있습니다. 이전 글에서 다뤘던 du 명령어로 어떤 디렉토리가 용량을 많이 차지하는지 찾습니다.

    sudo du -sh /var/log/*
    sudo du -sh /var/www/*

    로그 파일이 몇 기가바이트씩 쌓여 있는 경우가 가장 흔합니다. PM2 로그, Nginx 액세스 로그, 시스템 로그가 대표적입니다. 오래된 로그를 삭제하고, 이전 글에서 다뤘던 크론탭으로 주기적으로 정리하는 설정을 추가합니다.

    디스크 용량은 충분한데 I/O 자체가 느린 경우도 있습니다. 이전 글에서 다뤘던 iotop으로 확인합니다.

    sudo iotop

    어떤 프로세스가 디스크를 많이 읽고 쓰는지 실시간으로 보여줍니다. 데이터베이스가 디스크 I/O를 과도하게 사용하고 있다면 쿼리 최적화나 인덱스 추가가 필요합니다. 백업 작업이 I/O를 잡아먹고 있다면 백업 시간을 트래픽이 적은 시간대로 옮기는 걸 고려합니다.

    vmstat으로도 I/O 상태를 확인할 수 있습니다.

    vmstat 2 10

    wa 칼럼이 I/O 대기 시간의 비율을 나타냅니다. 이 값이 지속적으로 20% 이상이면 디스크 I/O가 병목입니다.

    네트워크가 원인인 경우

    서버 자체는 멀쩡한데 응답이 느리다면 네트워크 문제일 수 있습니다.

    먼저 서버에서 외부로의 연결을 확인합니다.

    ping -c 5 8.8.8.8

    응답 시간이 비정상적으로 높거나 패킷 유실이 있으면 서버의 네트워크 연결 자체에 문제가 있는 겁니다.

    이전 글에서 다뤘던 nethogs로 프로세스별 네트워크 사용량을 확인합니다.

    sudo nethogs

    의도하지 않은 프로세스가 대역폭을 차지하고 있다면 원인을 파악해야 합니다.

    현재 서버에 연결된 네트워크 연결 수를 확인하는 것도 유용합니다.

    ss -s

    이 명령어는 현재 연결 상태의 요약을 보여줍니다. TCP 연결 수가 비정상적으로 많다면 DDoS 공격이나 커넥션 누수를 의심할 수 있습니다.

    어떤 IP에서 연결이 많이 들어오고 있는지 확인하려면 이렇게 합니다.

    ss -tn | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -20

    특정 IP에서 비정상적으로 많은 연결이 들어오고 있다면 해당 IP를 방화벽으로 차단할 수 있습니다.

    sudo ufw deny from 공격IP주소

    데이터베이스가 원인인 경우

    앱 서버의 CPU, 메모리, 디스크가 전부 정상인데 응답이 느리다면 데이터베이스를 확인해야 합니다. 웹 애플리케이션에서 응답이 느린 원인의 상당수가 데이터베이스 쿼리입니다.

    MySQL에서 현재 실행 중인 쿼리를 확인합니다.

    mysql -u root -p -e "SHOW PROCESSLIST;"

    Time 값이 큰 쿼리가 있으면 그 쿼리가 오래 걸리고 있는 겁니다. State가 Sending data이거나 Sorting result이면 대량의 데이터를 처리하고 있다는 뜻입니다.

    느린 쿼리를 찾으려면 슬로우 쿼리 로그를 활성화합니다. MySQL 설정 파일을 열어서 다음 내용을 추가합니다.

    slow_query_log = 1
    slow_query_log_file = /var/log/mysql/slow.log
    long_query_time = 2

    2초 이상 걸리는 쿼리를 전부 로그로 남깁니다. 이 로그를 분석해서 문제가 되는 쿼리를 찾고, 인덱스를 추가하거나 쿼리를 개선합니다.

    데이터베이스 연결 수가 한계에 도달한 경우도 있습니다. 앱에서 데이터베이스 연결을 열고 닫지 않으면 연결이 쌓여서 새 요청을 처리할 수 없게 됩니다. 커넥션 풀을 사용하는지, 풀 크기가 적절한지 확인해야 합니다.

    Nginx 로그로 원인 좁히기

    어떤 요청이 느린지 Nginx 액세스 로그에서 확인할 수 있습니다.

    sudo tail -f /var/log/nginx/access.log

    실시간으로 들어오는 요청을 볼 수 있습니다. 특정 URL로 요청이 폭주하고 있다면 해당 엔드포인트의 코드를 확인해야 합니다.

    에러 로그도 확인합니다.

    sudo tail -100 /var/log/nginx/error.log

    502 Bad Gateway 에러가 많이 찍혀 있다면 앱 서버가 응답을 못 하고 있다는 뜻입니다. PM2에서 앱이 계속 재시작되고 있는지 확인합니다. 504 Gateway Timeout이 많다면 앱은 살아있지만 처리가 너무 오래 걸려서 Nginx가 기다리다 포기한 겁니다.

    응답 시간을 로그에 기록하고 싶으면 Nginx 설정에서 로그 포맷에 $request_time을 추가할 수 있습니다. 어떤 요청이 몇 초 걸렸는지 기록되기 때문에 느린 요청을 특정하기 쉬워집니다.

    진단 순서 정리

    서버가 느려졌을 때 확인하는 순서를 정리하면 이렇습니다.

    첫째, htop이나 top으로 전체 상태를 훑어봅니다. CPU, 메모리 사용률과 어떤 프로세스가 리소스를 많이 쓰는지 확인합니다.

    둘째, free -h로 메모리와 스왑 상태를 확인합니다.

    셋째, df -h로 디스크 용량을 확인합니다.

    넷째, CPU와 메모리가 정상이면 iotop으로 디스크 I/O를 확인합니다.

    다섯째, 서버 리소스가 전부 정상이면 데이터베이스 쿼리와 네트워크 연결을 확인합니다.

    여섯째, Nginx 로그에서 어떤 요청이 문제인지 좁혀갑니다.

    이 순서대로 하면 대부분의 경우 원인을 찾을 수 있습니다. 원인을 찾으면 해결 방법은 상황마다 다르지만, 최소한 어디가 문제인지 모르고 이것저것 건드리는 것보다는 훨씬 효율적입니다.

    마무리

    서버가 느려지는 원인은 결국 CPU, 메모리, 디스크, 네트워크 중 하나입니다. htop 하나로 대략적인 방향을 잡고, 상황에 맞는 도구로 세부 원인을 파고들면 됩니다. 중요한 건 평소에 서버의 정상 상태를 알고 있는 것입니다. CPU가 보통 몇 퍼센트인지, 메모리가 얼마나 사용되는지를 알고 있어야 비정상을 감지할 수 있습니다. 이전 글에서 다뤘던 모니터링 도구를 활용해서 평소 상태를 파악해두는 습관이 결국 문제 대응 속도를 높여줍니다. 다음 글에서는 서버 운영 중에 자주 만나는 502 Bad Gateway 오류의 원인과 해결법을 다뤄보겠습니다.

  • 서버에 Node.js 앱 배포하는 전체 과정

    서버에 Node.js 앱 배포하는 전체 과정

    로컬에서 개발한 앱을 실제 서버에 올려서 누구나 접속할 수 있게 만드는 과정이 배포입니다. 처음 해보면 막막하게 느껴지지만 전체 흐름을 알고 나면 그렇게 복잡하지 않습니다. 서버를 만들고, 환경을 세팅하고, 코드를 올리고, 실행하고, 외부에서 접속할 수 있게 연결하는 것이 전부입니다. 이번 글에서는 클라우드 서버에 Node.js 앱을 배포하는 전체 과정을 처음부터 끝까지 순서대로 정리해보겠습니다. 이전 글들에서 다뤘던 개념들이 실제로 어떻게 연결되는지 확인할 수 있을 겁니다.

    전체 흐름 미리 보기

    배포 과정을 큰 단계로 나누면 이렇습니다. 서버를 생성하고, SSH로 접속해서 기본 환경을 세팅하고, 코드를 서버에 올리고, 앱을 실행하고, Nginx로 외부 요청을 연결하고, 도메인과 SSL을 적용합니다. 하나씩 순서대로 진행하겠습니다.

    1단계 — 서버 생성

    클라우드 서비스에서 서버를 하나 만듭니다. AWS EC2, Vultr, DigitalOcean, Oracle Cloud 등 어디서든 상관없습니다. 이 글에서는 일반적인 클라우드 서버를 기준으로 설명합니다.

    서버를 만들 때 선택해야 하는 것들이 있습니다. 운영체제는 Ubuntu 22.04 LTS를 선택합니다. LTS는 장기 지원 버전이라 안정적이고 관련 자료도 가장 많습니다. 사양은 소규모 프로젝트라면 CPU 1코어, 메모리 1GB 정도면 충분합니다. 트래픽이 늘어나면 나중에 업그레이드하면 됩니다.

    서버를 만들면 공인 IP 주소가 하나 할당됩니다. 이 IP로 서버에 접속합니다. AWS EC2를 사용한다면 탄력적 IP를 할당받아서 고정 IP를 확보해야 합니다. 서버를 재시작해도 IP가 바뀌지 않게 하기 위해서입니다.

    보안 그룹이나 방화벽 설정에서 22번 포트(SSH), 80번 포트(HTTP), 443번 포트(HTTPS)를 열어둡니다. 22번은 서버에 접속하기 위해, 80번과 443번은 웹 트래픽을 받기 위해 필요합니다.

    2단계 — SSH로 서버 접속

    서버가 만들어지면 SSH로 접속합니다. 서버 생성 시 다운받은 키 파일을 사용합니다.

    chmod 600 my-key.pem
    ssh -i my-key.pem ubuntu@서버IP주소

    이전 글에서 다뤘던 것처럼 키 파일의 권한이 600이 아니면 SSH 접속이 거부됩니다.

    접속이 되면 먼저 시스템 패키지를 업데이트합니다.

    sudo apt update
    sudo apt upgrade -y

    새 서버를 받으면 패키지가 오래된 상태일 수 있기 때문에 가장 먼저 업데이트를 해주는 게 좋습니다.

    3단계 — Node.js 설치

    Ubuntu에 기본으로 설치된 Node.js는 버전이 낮은 경우가 많습니다. 최신 LTS 버전을 설치하려면 NodeSource 저장소를 추가하는 게 편합니다.

    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt install -y nodejs

    설치가 끝나면 버전을 확인합니다.

    node -v
    npm -v

    Node.js 20 버전대가 나오면 정상입니다. npm도 함께 설치됩니다.

    여러 프로젝트에서 다른 Node.js 버전을 사용해야 한다면 nvm(Node Version Manager)을 설치하는 방법도 있습니다. 하지만 서버 하나에 앱 하나만 돌린다면 위 방법이 더 간단합니다.

    4단계 — 프로젝트 코드 가져오기

    로컬에서 개발한 코드를 서버로 가져와야 합니다. 가장 일반적인 방법은 Git을 사용하는 겁니다.

    서버에 Git이 설치되어 있는지 확인합니다. Ubuntu에는 보통 기본 설치되어 있습니다.

    git --version

    프로젝트를 배포할 디렉토리를 만들고 코드를 클론합니다.

    sudo mkdir -p /var/www
    cd /var/www
    sudo git clone https://github.com/사용자명/프로젝트명.git today-play

    비공개 저장소라면 SSH 키를 서버에 등록하거나 Personal Access Token을 사용해야 합니다.

    코드를 가져왔으면 디렉토리 소유자를 현재 사용자로 변경합니다.

    sudo chown -R ubuntu:ubuntu /var/www/today-play

    이전 글에서 다뤘던 chown 명령어입니다. 소유자를 바꿔두지 않으면 나중에 파일 수정이나 npm install에서 권한 문제가 생길 수 있습니다.

    프로젝트 디렉토리로 이동해서 의존성을 설치합니다.

    cd /var/www/today-play
    npm install

    5단계 — 환경 변수 설정

    이전 글에서 다뤘던 것처럼 .env 파일은 저장소에 올리지 않기 때문에 서버에서 직접 만들어야 합니다.

    nano .env

    프로젝트에서 필요한 환경 변수를 입력합니다.

    NODE_ENV=production
    PORT=3000
    DB_HOST=localhost
    DB_PORT=3306
    DB_USER=myuser
    DB_PASSWORD=실제비밀번호
    DB_NAME=myapp

    저장한 뒤 파일 권한을 설정합니다.

    chmod 600 .env

    앱을 간단히 실행해서 에러 없이 돌아가는지 확인합니다.

    node app.js

    정상적으로 실행되면 Ctrl+C로 종료합니다. 여기까지는 테스트 실행이고, 실제 운영에서는 이렇게 직접 실행하면 안 됩니다. 터미널을 닫으면 앱도 같이 꺼지기 때문입니다.

    6단계 — PM2로 앱을 안정적으로 실행

    서버에서 Node.js 앱을 운영할 때는 PM2라는 프로세스 매니저를 사용합니다. PM2는 앱을 백그라운드에서 실행하고, 앱이 에러로 종료되면 자동으로 재시작해주고, 서버가 재부팅되어도 앱이 자동으로 다시 뜨게 해줍니다.

    PM2를 전역으로 설치합니다.

    sudo npm install -g pm2

    앱을 PM2로 실행합니다.

    cd /var/www/today-play
    pm2 start app.js --name today-play

    실행 중인 앱 목록을 확인합니다.

    pm2 list

    상태가 online으로 나오면 정상입니다.

    로그를 확인하고 싶으면 이렇게 합니다.

    pm2 logs today-play

    서버가 재부팅됐을 때 PM2가 자동으로 앱을 시작하게 설정합니다.

    pm2 startup
    pm2 save

    pm2 startup을 실행하면 시스템 시작 시 PM2를 자동 실행하는 명령어가 출력됩니다. 출력된 명령어를 복사해서 실행하면 됩니다. pm2 save는 현재 실행 중인 앱 목록을 저장해서 재부팅 후에 복원할 수 있게 합니다.

    앱을 재시작하거나 중지하는 명령어도 알아두면 좋습니다.

    pm2 restart today-play
    pm2 stop today-play
    pm2 delete today-play

    7단계 — Nginx 설정

    지금 상태에서는 앱이 3000번 포트에서 돌아가고 있습니다. 브라우저에서 서버IP:3000으로 접속하면 앱이 보입니다. 하지만 실제 서비스에서는 사용자가 포트 번호를 입력하지 않습니다. 도메인만 입력하면 접속되어야 합니다.

    이전 글에서 다뤘던 Nginx를 리버스 프록시로 사용해서 80번 포트로 들어오는 요청을 3000번 포트의 앱으로 전달합니다.

    Nginx를 설치합니다.

    sudo apt install nginx

    사이트 설정 파일을 만듭니다.

    sudo nano /etc/nginx/sites-available/today-play

    다음 내용을 입력합니다.

    server {
        listen 80;
        server_name today-play.com www.today-play.com;
    
        location / {
            proxy_pass http://localhost:3000;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_cache_bypass $http_upgrade;
        }
    }

    proxy_pass가 핵심입니다. 80번 포트로 들어온 요청을 localhost:3000으로 넘기는 설정입니다. proxy_set_header들은 원래 요청의 정보를 앱에 전달하기 위한 헤더 설정입니다. X-Real-IP는 실제 클라이언트의 IP를 전달하는데, 이게 없으면 앱에서 모든 요청의 IP가 127.0.0.1로 보입니다.

    Upgrade와 Connection 헤더는 WebSocket 연결을 지원하기 위한 설정입니다. 이전 글에서 다뤘던 WebSocket을 사용하는 앱이라면 이 헤더가 반드시 있어야 합니다.

    설정 파일을 활성화하고 기본 설정을 비활성화합니다.

    sudo ln -s /etc/nginx/sites-available/today-play /etc/nginx/sites-enabled/
    sudo rm /etc/nginx/sites-enabled/default

    설정 문법을 확인하고 Nginx를 재시작합니다.

    sudo nginx -t
    sudo systemctl restart nginx

    이제 브라우저에서 서버 IP 주소만 입력하면 포트 번호 없이 앱에 접속됩니다.

    8단계 — 도메인 연결

    이전 글에서 다뤘던 도메인 연결을 진행합니다. DNS 관리 페이지에서 A레코드를 추가합니다.

    타입: A    호스트: @    값: 서버IP주소
    타입: CNAME  호스트: www  값: today-play.com

    DNS 전파까지 시간이 좀 걸릴 수 있지만 보통 몇 분이면 됩니다. nslookup으로 확인합니다.

    nslookup today-play.com

    서버 IP가 나오면 연결이 완료된 겁니다.

    9단계 — SSL 인증서 적용

    이전 글에서 다뤘던 Let’s Encrypt로 SSL 인증서를 적용합니다.

    sudo apt install certbot python3-certbot-nginx
    sudo certbot --nginx -d today-play.com -d www.today-play.com

    이메일을 입력하고 약관에 동의하면 인증서가 발급되고 Nginx 설정이 자동으로 업데이트됩니다. HTTP 요청을 HTTPS로 리다이렉트할지 물어보면 리다이렉트를 선택합니다.

    브라우저에서 https://todayfunplay.com으로 접속해서 자물쇠 아이콘이 뜨는지 확인합니다.

    자동 갱신도 확인합니다.

    sudo certbot renew --dry-run

    에러가 없으면 90일마다 자동으로 갱신됩니다.

    10단계 — 기본 보안 설정

    배포가 끝났으면 보안 설정을 해야 합니다. 이전 글에서 다뤘던 서버 보안 기본 설정을 적용합니다.

    방화벽을 설정합니다.

    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw enable
    sudo ufw status

    SSH, HTTP, HTTPS만 허용하고 나머지 포트는 전부 차단합니다. 앱이 사용하는 3000번 포트는 외부에서 직접 접근할 필요가 없습니다. Nginx가 내부에서 연결해주기 때문입니다.

    SSH 포트 변경이나 root 로그인 비활성화 같은 추가 보안 설정도 이전 글을 참고해서 적용하면 좋습니다.

    배포 후 업데이트 과정

    코드를 수정하고 다시 배포할 때는 이 과정을 반복합니다.

    cd /var/www/today-play
    git pull origin main
    npm install
    pm2 restart today-play

    코드를 최신으로 받고, 새로 추가된 패키지가 있으면 설치하고, 앱을 재시작합니다. 이 네 줄이면 업데이트가 끝납니다.

    이 과정이 반복되면 이전 글에서 다뤘던 GitHub Actions로 자동화할 수 있습니다. main 브랜치에 푸시하면 서버에 SSH로 접속해서 위 명령어들을 자동으로 실행하는 워크플로우를 만들면 됩니다.

    문제가 생겼을 때 확인하는 순서

    배포 후에 사이트가 안 열릴 때 당황하지 않으려면 확인 순서를 알아두는 게 좋습니다.

    먼저 앱이 돌아가고 있는지 확인합니다.

    pm2 list
    pm2 logs today-play

    앱 상태가 online이 아니거나 로그에 에러가 있으면 앱 자체의 문제입니다. 환경 변수가 빠져 있거나 의존성 설치가 안 됐거나 코드에 에러가 있을 수 있습니다.

    앱이 정상이면 Nginx를 확인합니다.

    sudo nginx -t
    sudo systemctl status nginx

    설정 문법에 에러가 없고 Nginx가 active 상태인지 확인합니다.

    Nginx도 정상이면 방화벽을 확인합니다.

    sudo ufw status

    80번과 443번 포트가 열려 있는지 확인합니다.

    방화벽도 정상이면 DNS를 확인합니다.

    nslookup today-play.com

    도메인이 올바른 IP를 가리키고 있는지 확인합니다.

    이 순서대로 안쪽부터 바깥쪽으로 확인해나가면 대부분의 문제를 찾을 수 있습니다.

    마무리

    서버 배포의 전체 과정을 정리하면 서버 생성, 환경 세팅, 코드 배포, PM2로 실행, Nginx 리버스 프록시, 도메인 연결, SSL 적용, 보안 설정입니다. 한 번에 보면 단계가 많아 보이지만 각 단계는 이전 글들에서 다뤘던 내용의 조합입니다. 처음 한 번은 시간이 걸리지만 두세 번 해보면 흐름이 손에 익고, 그 이후에는 자동화할 부분이 보이기 시작합니다. 다음 글에서는 서버가 느려졌을 때 원인을 찾는 방법을 다뤄보겠습니다.

  • 환경 변수(.env)란 무엇이고 왜 중요한가?

    환경 변수(.env)란 무엇이고 왜 중요한가?

    코드를 작성하다 보면 데이터베이스 비밀번호, API 키, 서버 주소 같은 값들을 어딘가에 적어야 하는 순간이 옵니다. 처음에는 코드 안에 직접 넣게 됩니다. 데이터베이스 접속 정보를 코드에 하드코딩하고 잘 돌아가니까 넘어갑니다. 그런데 이 코드를 GitHub에 올리는 순간 비밀번호가 전 세계에 공개됩니다. 실제로 GitHub에 AWS 키를 올렸다가 몇 분 만에 수백만 원이 과금된 사례도 있습니다. 환경 변수는 이런 문제를 해결하는 가장 기본적인 방법입니다. 이번 글에서는 환경 변수가 무엇인지, 왜 중요한지, 실제로 어떻게 관리하는지를 정리해보겠습니다.

    환경 변수란 무엇인가

    환경 변수는 운영체제나 실행 환경에서 프로그램에게 전달하는 설정값입니다. 프로그램 코드 바깥에 존재하면서 프로그램이 실행될 때 참조할 수 있는 값입니다.

    리눅스 터미널에서 환경 변수를 확인해볼 수 있습니다.

    echo $HOME
    /home/ubuntu
    
    echo $PATH
    /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin

    HOME은 현재 사용자의 홈 디렉토리 경로이고, PATH는 명령어를 찾을 디렉토리 목록입니다. 이런 값들이 환경 변수입니다. 운영체제가 미리 설정해둔 것도 있고, 사용자가 직접 만들 수도 있습니다.

    터미널에서 환경 변수를 만들고 사용하는 건 간단합니다.

    export DB_PASSWORD=mysecret
    echo $DB_PASSWORD
    mysecret

    export로 변수를 설정하면 현재 셸 세션에서 실행되는 프로그램들이 이 값을 읽을 수 있습니다. Node.js에서는 process.env.DB_PASSWORD로, Python에서는 os.environ[‘DB_PASSWORD’]로 접근합니다.

    코드에 직접 값을 넣으면 안 되는 이유

    환경 변수가 왜 필요한지를 이해하려면 코드에 직접 값을 넣었을 때 어떤 문제가 생기는지를 알아야 합니다.

    첫 번째 문제는 보안입니다. 데이터베이스 비밀번호나 API 키를 코드에 적으면 코드가 곧 비밀번호입니다. 이 코드를 GitHub에 올리면 해당 정보가 저장소에 접근할 수 있는 모든 사람에게 노출됩니다. 공개 저장소라면 전 세계에 공개되는 겁니다. GitHub에는 공개 저장소를 자동으로 스캔해서 API 키를 찾아내는 봇이 돌아다닙니다. AWS 키가 노출되면 몇 분 안에 암호화폐 채굴에 악용되어 엄청난 과금이 발생하는 사례가 실제로 있습니다.

    한 번 커밋에 포함된 비밀번호는 커밋을 삭제해도 git 히스토리에 남아 있습니다. 완전히 제거하려면 별도의 작업이 필요하고, 이미 누군가가 본 후라면 의미가 없습니다. 처음부터 코드에 넣지 않는 게 최선입니다.

    두 번째 문제는 환경별 설정 차이입니다. 개발할 때는 로컬 데이터베이스에 접속하고, 테스트 서버에서는 테스트 데이터베이스에 접속하고, 운영 서버에서는 운영 데이터베이스에 접속해야 합니다. 접속 주소와 비밀번호가 전부 다릅니다. 이 값들이 코드에 박혀 있으면 환경마다 코드를 수정해야 합니다. 배포할 때마다 개발용 주소를 운영용 주소로 바꾸고, 개발 환경으로 돌아오면 다시 바꾸는 건 실수가 나기 딱 좋은 구조입니다.

    세 번째 문제는 협업입니다. 팀원마다 로컬 환경이 다를 수 있습니다. 데이터베이스 비밀번호가 다르거나 API 키가 개인별로 다를 수 있는데, 이 값이 코드에 있으면 팀원 한 명이 자기 설정으로 커밋할 때마다 다른 사람의 환경이 깨집니다.

    환경 변수를 사용하면 이 세 가지 문제가 전부 해결됩니다. 코드에는 변수 이름만 적고, 실제 값은 각 환경에서 따로 설정합니다. 코드를 아무리 공개해도 비밀번호는 노출되지 않고, 환경마다 다른 값을 넣을 수 있고, 팀원마다 자기 설정을 독립적으로 관리할 수 있습니다.

    .env 파일이란

    터미널에서 export로 환경 변수를 설정하는 건 셸을 닫으면 사라집니다. 매번 서버에 접속할 때마다 설정하는 것도 번거롭습니다. 그래서 환경 변수를 파일로 관리하는 방법이 생겼고, 그 파일이 .env입니다.

    .env 파일은 프로젝트 루트 디렉토리에 만들고, 키=값 형태로 한 줄씩 적습니다.

    DB_HOST=localhost
    DB_PORT=3306
    DB_USER=root
    DB_PASSWORD=mysecretpassword
    DB_NAME=myapp
    
    REDIS_HOST=localhost
    REDIS_PORT=6379
    
    API_KEY=sk-abc123def456
    NODE_ENV=development
    PORT=3000

    파일 이름 앞에 점이 붙어 있기 때문에 리눅스에서는 숨김 파일로 취급됩니다. ls만 치면 안 보이고 ls -a를 해야 보입니다.

    Node.js에서 .env 파일을 사용하려면 dotenv 패키지를 설치합니다.

    npm install dotenv

    코드 맨 위에서 불러오면 .env 파일의 내용이 환경 변수로 로드됩니다.

    javascript

    require('dotenv').config();
    
    const dbHost = process.env.DB_HOST;
    const dbPassword = process.env.DB_PASSWORD;
    ```
    
    Python에서는 python-dotenv 패키지를 사용합니다.
    ```
    pip install python-dotenv

    python

    from dotenv import load_dotenv
    import os
    
    load_dotenv()
    
    db_host = os.getenv('DB_HOST')
    db_password = os.getenv('DB_PASSWORD')
    ```
    
    이렇게 하면 코드에는 변수 이름만 있고 실제 값은 .env 파일에서 가져옵니다. .env 파일만 환경에 맞게 바꾸면 코드는 수정할 필요가 없습니다.
    
    .env 파일은 절대 저장소에 올리면 안 된다
    
    .env 파일에는 비밀번호와 키가 들어 있기 때문에 Git 저장소에 올리면 안 됩니다. 프로젝트의 .gitignore 파일에 반드시 추가해야 합니다.
    ```
    # .gitignore
    .env
    .env.local
    .env.production
    ```
    
    대신 .env.example이라는 파일을 만들어서 저장소에 올립니다. 실제 값 대신 어떤 변수가 필요한지 형식만 적어둡니다.
    ```
    DB_HOST=
    DB_PORT=3306
    DB_USER=
    DB_PASSWORD=
    DB_NAME=
    
    REDIS_HOST=
    REDIS_PORT=6379
    
    API_KEY=
    NODE_ENV=development
    PORT=3000
    ```
    
    새로운 팀원이 프로젝트를 받으면 .env.example을 복사해서 .env로 만들고 자기 환경에 맞는 값을 채우면 됩니다. 어떤 환경 변수가 필요한지 문서를 따로 만들 필요 없이 .env.example 파일이 곧 문서 역할을 합니다.
    
    환경별 .env 파일 관리
    
    프로젝트가 커지면 환경별로 다른 설정이 필요합니다. 개발, 테스트, 운영 환경에서 각각 다른 값을 사용해야 합니다.
    
    흔한 방법은 환경별로 .env 파일을 나누는 겁니다.
    ```
    .env                 # 기본값 또는 로컬 개발용
    .env.development     # 개발 환경
    .env.test            # 테스트 환경
    .env.production      # 운영 환경

    프레임워크에 따라 환경별 .env 파일을 자동으로 로드하는 기능이 있습니다. Next.js는 NODE_ENV에 따라 해당 환경의 .env 파일을 자동으로 읽습니다. 직접 구현해야 하는 경우에는 dotenv에서 파일 경로를 지정할 수 있습니다.

    javascript

    require('dotenv').config({ path: `.env.${process.env.NODE_ENV}` });

    운영 환경의 .env 파일은 서버에 직접 만들거나 CI/CD 파이프라인에서 시크릿으로 관리합니다. 이전 글에서 다뤘던 GitHub Actions의 시크릿 기능이 이런 용도입니다.

    Docker 환경에서의 환경 변수

    이전 글에서 다뤘던 Docker Compose에서도 환경 변수를 여러 방식으로 사용합니다.

    docker-compose.yml에서 직접 지정하는 방법이 있습니다.

    yaml

    services:
      app:
        image: my-app
        environment:
          - NODE_ENV=production
          - DB_HOST=db

    .env 파일을 통째로 전달하는 방법도 있습니다.

    yaml

    services:
      app:
        image: my-app
        env_file:
          - .env
    ```
    
    Docker에서 환경 변수가 중요한 이유가 하나 더 있습니다. Docker 이미지는 한 번 빌드하면 여러 환경에서 동일하게 사용하는 게 원칙입니다. 개발용 이미지와 운영용 이미지를 따로 빌드하는 게 아니라 하나의 이미지를 만들고, 환경 변수로 동작을 다르게 합니다. 같은 이미지가 환경 변수에 따라 개발 모드로도, 운영 모드로도 동작하는 구조입니다.
    
    자주 사용하는 환경 변수 이름 관례
    
    환경 변수 이름을 짓는 데 정해진 규칙은 없지만 관례가 있습니다. 대문자와 언더스코어를 사용하는 게 일반적입니다.
    
    데이터베이스 관련은 보통 DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME 같은 이름을 씁니다. DATABASE_URL처럼 접속 정보를 하나의 문자열로 합쳐서 쓰는 경우도 있습니다. PostgreSQL이나 일부 프레임워크에서는 이 형식을 선호합니다.
    ```
    DATABASE_URL=mysql://root:password@localhost:3306/myapp
    ```
    
    앱 설정은 NODE_ENV, PORT, APP_SECRET 같은 이름을 씁니다. NODE_ENV는 development, test, production 중 하나의 값을 가지는 게 관례이고, 많은 라이브러리가 이 값을 보고 동작을 바꿉니다.
    
    외부 서비스 키는 서비스명을 접두어로 붙이는 게 일반적입니다. AWS_ACCESS_KEY_ID, STRIPE_SECRET_KEY, SENDGRID_API_KEY 같은 식입니다. 접두어가 있으면 어떤 서비스의 키인지 바로 알 수 있습니다.
    
    환경 변수에 넣으면 안 되는 것
    
    환경 변수에 모든 설정을 넣는 건 좋지 않습니다. 환경 변수는 간단한 문자열 값에 적합하지, 복잡한 구조의 데이터를 넣기에는 맞지 않습니다.
    
    긴 JSON 객체나 여러 줄에 걸친 설정은 환경 변수보다 설정 파일로 관리하는 게 낫습니다. 환경 변수에 JSON을 넣으면 이스케이프 문제가 생기기 쉽고 가독성도 떨어집니다.
    
    SSL 인증서 내용이나 SSH 키 같은 긴 텍스트도 환경 변수에 직접 넣기보다는 파일 경로를 환경 변수로 지정하는 게 일반적입니다.
    ```
    SSL_CERT_PATH=/etc/ssl/certs/my-cert.pem
    SSH_KEY_PATH=/home/ubuntu/.ssh/id_rsa
    ```
    
    환경 변수에는 접속 정보, 키, 모드 설정 같은 짧고 단순한 값을 넣고, 복잡한 설정은 별도 파일로 관리하는 게 깔끔합니다.
    
    보안 관련 주의사항
    
    .env 파일을 .gitignore에 추가하는 것만으로는 보안이 완전하지 않습니다. 몇 가지 더 신경 써야 할 부분이 있습니다.
    
    서버에 있는 .env 파일의 권한을 확인해야 합니다. 이전 글에서 다뤘던 chmod를 사용해서 소유자만 읽을 수 있게 설정합니다.
    ```
    chmod 600 .env

    다른 사용자가 .env 파일을 읽을 수 없게 해야 합니다.

    로그에 환경 변수 값이 출력되지 않도록 주의해야 합니다. 디버깅 목적으로 환경 변수를 전부 출력하는 코드를 넣었다가 로그 파일에 비밀번호가 기록되는 경우가 있습니다. 로그에 민감한 정보가 남으면 로그 파일이 유출됐을 때 함께 노출됩니다.

    API 키나 비밀번호는 주기적으로 교체하는 게 좋습니다. 키가 유출됐을 때 피해를 줄이려면 하나의 키를 오래 사용하는 것보다 주기적으로 바꾸는 게 안전합니다.

    프로젝트 규모가 커지면 AWS Secrets Manager, HashiCorp Vault 같은 전문 시크릿 관리 도구를 도입하기도 합니다. 이런 도구들은 환경 변수 값을 암호화해서 저장하고, 접근 권한을 세밀하게 관리하고, 키 교체를 자동화해줍니다. 개인 프로젝트나 소규모 팀에서는 .env 파일만으로 충분하지만, 서비스가 커지면 검토해볼 만합니다.

    마무리

    환경 변수는 코드와 설정을 분리하는 가장 기본적인 방법입니다. 비밀번호와 키를 코드 밖으로 빼내서 보안을 지키고, 환경마다 다른 값을 주입해서 하나의 코드로 여러 환경을 대응합니다. .env 파일에 값을 정리하고, .gitignore에 추가하고, .env.example로 형식을 공유하는 게 기본 패턴입니다. 간단하지만 이 습관 하나가 보안 사고를 예방하고 팀 협업을 원활하게 만듭니다. 다음 글에서는 서버에 실제 애플리케이션을 배포하는 전체 과정을 처음부터 끝까지 정리해보겠습니다.

  • Docker Compose란 무엇인가? 다중 컨테이너 관리하기

    Docker Compose란 무엇인가? 다중 컨테이너 관리하기

    Docker를 사용하다 보면 컨테이너 하나만으로는 부족한 상황이 금방 옵니다. 웹 애플리케이션을 돌리려면 앱 서버 컨테이너가 필요하고, 데이터를 저장하려면 데이터베이스 컨테이너가 필요하고, 캐시를 쓰려면 Redis 컨테이너도 필요합니다. 이걸 하나씩 docker run 명령어로 실행하면 매번 옵션을 기억해야 하고, 컨테이너 간의 네트워크도 수동으로 연결해야 합니다. 컨테이너가 세 개만 돼도 관리가 번거로워집니다. Docker Compose는 이런 다중 컨테이너 환경을 하나의 파일로 정의하고 한 번에 실행할 수 있게 해주는 도구입니다. 이번 글에서는 Docker Compose가 무엇인지, 어떻게 사용하는지, 실제 프로젝트에서 어떤 식으로 구성하는지를 정리해보겠습니다.

    Docker Compose가 필요한 이유

    Docker로 컨테이너를 하나씩 실행하는 명령어를 생각해봅시다. Node.js 앱 컨테이너를 실행하려면 이런 명령어를 칩니다.

    docker run -d --name app -p 3000:3000 --env-file .env my-app

    MySQL 컨테이너도 필요합니다.

    docker run -d --name db -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret -v db-data:/var/lib/mysql mysql:8

    Redis 캐시도 추가합니다.

    docker run -d --name cache -p 6379:6379 redis:7

    그리고 이 세 컨테이너가 서로 통신할 수 있게 네트워크를 만들어야 합니다.

    docker network create my-network
    docker network connect my-network app
    docker network connect my-network db
    docker network connect my-network cache

    컨테이너가 세 개밖에 안 되는데도 명령어가 이렇게 많습니다. 서버를 다시 세팅하거나 다른 개발자가 같은 환경을 구성하려면 이 명령어들을 전부 기억하고 순서대로 실행해야 합니다. 실수 한 번이면 컨테이너가 서로 연결이 안 되거나 데이터가 날아갈 수 있습니다.

    Docker Compose를 사용하면 이 모든 설정을 docker-compose.yml 파일 하나에 적어두고, 명령어 한 줄로 전부 실행할 수 있습니다.

    docker-compose.yml 기본 구조

    Docker Compose의 설정 파일은 YAML 형식입니다. 위에서 명령어로 했던 것과 동일한 구성을 파일로 작성하면 이렇습니다.

    yaml

    version: '3.8'
    
    services:
      app:
        build: .
        ports:
          - "3000:3000"
        env_file:
          - .env
        depends_on:
          - db
          - cache
    
      db:
        image: mysql:8
        ports:
          - "3306:3306"
        environment:
          MYSQL_ROOT_PASSWORD: secret
          MYSQL_DATABASE: myapp
        volumes:
          - db-data:/var/lib/mysql
    
      cache:
        image: redis:7
        ports:
          - "6379:6379"
    
    volumes:
      db-data:
    ```
    
    파일 구조를 하나씩 살펴보겠습니다.
    
    version은 Docker Compose 파일의 형식 버전입니다. 3.8이 현재 많이 사용되는 버전입니다. 최신 Docker Compose에서는 이 항목을 생략해도 되지만 명시적으로 적어두는 게 호환성 면에서 안전합니다.
    
    services 아래에 실행할 컨테이너들을 정의합니다. app, db, cache라는 이름으로 세 개의 서비스를 만들었습니다. 이 이름은 자유롭게 정할 수 있고, 같은 Compose 안에서 컨테이너끼리 통신할 때 이 이름을 호스트명으로 사용합니다.
    
    app 서비스의 build: .은 현재 디렉토리의 Dockerfile을 사용해서 이미지를 빌드하라는 뜻입니다. 직접 만든 앱은 build를 사용하고, MySQL이나 Redis처럼 공식 이미지를 사용하는 서비스는 image를 사용합니다.
    
    ports는 호스트와 컨테이너의 포트를 연결합니다. "3000:3000"은 호스트의 3000번 포트를 컨테이너의 3000번 포트에 매핑합니다.
    
    depends_on은 서비스 간의 실행 순서를 지정합니다. app이 db와 cache에 의존한다고 선언하면 db와 cache가 먼저 시작된 후에 app이 시작됩니다. 다만 depends_on은 컨테이너가 시작되는 순서만 보장하지, 서비스가 실제로 준비될 때까지 기다려주지는 않습니다. MySQL 컨테이너가 시작됐더라도 데이터베이스 초기화가 끝나기 전에 앱이 접속을 시도할 수 있습니다. 이 문제는 뒤에서 다시 다루겠습니다.
    
    volumes는 데이터를 영속적으로 저장하기 위한 설정입니다. 컨테이너는 삭제하면 안에 있던 데이터도 사라지는데, 볼륨을 연결해두면 컨테이너를 삭제하고 다시 만들어도 데이터가 유지됩니다. db-data라는 이름의 볼륨을 만들어서 MySQL 데이터 디렉토리에 연결한 겁니다.
    
    기본 명령어
    
    docker-compose.yml 파일이 있는 디렉토리에서 다음 명령어들을 사용합니다.
    
    모든 서비스를 시작하려면 이렇게 합니다.
    ```
    docker compose up -d
    ```
    
    -d 옵션은 백그라운드에서 실행한다는 뜻입니다. 이 명령어 하나로 이미지 빌드, 네트워크 생성, 볼륨 생성, 컨테이너 실행이 전부 처리됩니다.
    
    실행 중인 서비스 상태를 확인하려면 이렇게 합니다.
    ```
    docker compose ps
    ```
    
    각 컨테이너의 이름, 상태, 포트 매핑 정보가 나옵니다.
    
    모든 서비스를 중지하려면 이렇게 합니다.
    ```
    docker compose down
    ```
    
    컨테이너와 네트워크가 삭제됩니다. 볼륨은 기본적으로 삭제되지 않습니다. 볼륨까지 삭제하고 싶으면 -v 옵션을 추가합니다. 데이터베이스 데이터를 완전히 초기화하고 싶을 때만 사용해야 합니다.
    
    특정 서비스의 로그를 확인하려면 이렇게 합니다.
    ```
    docker compose logs app
    docker compose logs -f db
    ```
    
    -f 옵션을 붙이면 실시간으로 로그가 출력됩니다. 서버 모니터링 글에서 다뤘던 것처럼 로그를 확인하는 건 문제 진단의 기본입니다.
    
    이미지를 다시 빌드하면서 시작하려면 이렇게 합니다.
    ```
    docker compose up -d --build
    ```
    
    코드를 수정한 후에 변경 사항을 반영하려면 --build 옵션을 추가해야 합니다. 이 옵션이 없으면 이전에 빌드한 이미지를 그대로 사용합니다.
    
    이전 버전에서는 docker-compose라고 하이픈을 붙여서 사용했는데, 최신 Docker에서는 docker compose로 공백을 사용합니다. 둘 다 동작하지만 새 프로젝트에서는 docker compose를 사용하는 게 좋습니다.
    
    컨테이너 간 네트워크 통신
    
    Docker Compose로 실행한 서비스들은 같은 네트워크에 자동으로 연결됩니다. 별도로 네트워크를 만들거나 연결할 필요가 없습니다.
    
    중요한 건 컨테이너끼리 통신할 때 서비스 이름을 호스트명으로 사용한다는 점입니다. 앱에서 데이터베이스에 접속할 때 localhost:3306이 아니라 db:3306으로 접속합니다. 마찬가지로 Redis에 접속할 때는 cache:6379를 사용합니다.
    
    앱의 환경 변수 파일에서 이런 식으로 설정합니다.
    ```
    DB_HOST=db
    DB_PORT=3306
    REDIS_HOST=cache
    REDIS_PORT=6379

    docker-compose.yml에서 정의한 서비스 이름 그대로 호스트명으로 쓰면 됩니다. Docker의 내부 DNS가 서비스 이름을 해당 컨테이너의 IP로 자동 변환해줍니다.

    환경 변수 관리

    환경 변수를 설정하는 방법이 여러 가지 있습니다.

    docker-compose.yml 안에서 직접 지정하는 방법입니다.

    yaml

    services:
      db:
        image: mysql:8
        environment:
          MYSQL_ROOT_PASSWORD: secret
          MYSQL_DATABASE: myapp

    간단하지만 비밀번호 같은 민감한 정보가 파일에 그대로 노출됩니다. 개인 프로젝트에서는 괜찮지만 팀 프로젝트에서는 좋지 않습니다.

    .env 파일을 사용하는 방법이 더 안전합니다.

    yaml

    services:
      db:
        image: mysql:8
        environment:
          MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
          MYSQL_DATABASE: ${DB_NAME}
    ```
    
    프로젝트 루트에 .env 파일을 만들어서 값을 넣어둡니다.
    ```
    DB_PASSWORD=secret
    DB_NAME=myapp

    docker-compose.yml에서 ${변수명} 형식으로 참조하면 .env 파일의 값이 들어갑니다. .env 파일은 .gitignore에 추가해서 저장소에 올라가지 않게 해야 합니다. 이전 글에서 다뤘던 환경 변수 관리의 원칙과 같습니다.

    서비스별로 다른 env 파일을 사용할 수도 있습니다.

    yaml

    services:
      app:
        build: .
        env_file:
          - ./app.env

    app.env 파일에 앱에서 사용하는 환경 변수를 넣어두면 해당 컨테이너에만 적용됩니다.

    depends_on과 헬스체크

    아까 depends_on이 컨테이너 시작 순서만 보장하고 서비스 준비 상태는 보장하지 않는다고 했습니다. MySQL 컨테이너가 시작됐어도 초기화가 끝나기 전에 앱이 접속을 시도하면 에러가 납니다.

    이 문제를 해결하려면 헬스체크를 설정합니다.

    yaml

    services:
      app:
        build: .
        depends_on:
          db:
            condition: service_healthy
    
      db:
        image: mysql:8
        environment:
          MYSQL_ROOT_PASSWORD: secret
        healthcheck:
          test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
          interval: 10s
          timeout: 5s
          retries: 5

    db 서비스에 healthcheck를 추가했습니다. 10초 간격으로 mysqladmin ping 명령어를 실행해서 MySQL이 응답하는지 확인합니다. 5번 재시도해서 응답이 오면 healthy 상태가 됩니다.

    app 서비스의 depends_on에 condition: service_healthy를 추가하면 db가 healthy 상태가 된 후에만 app이 시작됩니다. 이렇게 하면 데이터베이스가 실제로 준비된 후에 앱이 뜨기 때문에 연결 에러를 방지할 수 있습니다.

    볼륨 활용하기

    볼륨은 데이터를 영속적으로 보관하는 것 외에도 개발 환경에서 유용하게 쓸 수 있습니다.

    개발할 때 코드를 수정하면 컨테이너를 다시 빌드해야 변경 사항이 반영됩니다. 매번 빌드하는 건 시간이 걸리기 때문에 로컬 소스 코드 디렉토리를 컨테이너에 직접 마운트하면 코드 수정이 즉시 반영됩니다.

    yaml

    services:
      app:
        build: .
        ports:
          - "3000:3000"
        volumes:
          - .:/app
          - /app/node_modules

    첫 번째 볼륨 .:/app은 현재 디렉토리를 컨테이너의 /app 디렉토리에 마운트합니다. 로컬에서 파일을 수정하면 컨테이너 안에서도 바로 반영됩니다.

    두 번째 볼륨 /app/node_modules는 컨테이너의 node_modules를 로컬에서 덮어쓰지 않게 보호하는 설정입니다. 로컬의 node_modules와 컨테이너의 node_modules가 충돌하는 걸 방지합니다.

    이런 볼륨 마운트는 개발 환경에서만 사용하고, 운영 환경에서는 이미지 안에 코드를 포함시켜서 배포하는 게 일반적입니다.

    개발용과 운영용 설정 분리

    개발 환경과 운영 환경의 설정이 다를 수 있습니다. 개발 환경에서는 소스 코드를 마운트하고 디버그 모드를 켜지만, 운영 환경에서는 이미지에 코드를 넣고 최적화된 설정으로 돌려야 합니다.

    Docker Compose는 여러 파일을 조합해서 사용할 수 있습니다. 기본 설정을 docker-compose.yml에 넣고, 개발용 추가 설정을 docker-compose.dev.yml에 넣는 식입니다.

    yaml

    # docker-compose.dev.yml
    services:
      app:
        volumes:
          - .:/app
        environment:
          NODE_ENV: development
    ```
    
    개발 환경에서 실행할 때는 두 파일을 조합합니다.
    ```
    docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d

    -f 옵션으로 파일을 여러 개 지정하면 뒤의 파일이 앞의 파일을 덮어씌웁니다. 기본 설정에 개발용 설정이 추가되는 구조입니다.

    자주 하는 실수와 주의사항

    포트 충돌이 가장 흔한 문제입니다. 로컬에서 MySQL이 이미 3306 포트를 사용하고 있는데 Docker Compose에서도 3306으로 매핑하면 충돌이 납니다. 이럴 때는 호스트 포트를 바꾸면 됩니다. “3307:3306″으로 설정하면 로컬에서는 3307로 접속하고 컨테이너 내부에서는 3306을 사용합니다.

    볼륨 데이터가 남아있어서 혼란이 생기는 경우도 있습니다. 데이터베이스 설정을 변경했는데 이전 데이터가 남아있으면 변경 사항이 적용되지 않을 수 있습니다. 깨끗하게 시작하고 싶으면 docker compose down -v로 볼륨까지 삭제한 뒤 다시 시작합니다.

    이미지를 다시 빌드하지 않아서 코드 변경이 안 반영되는 경우도 자주 있습니다. docker compose up -d만 실행하면 기존 이미지를 재사용하기 때문에 코드 변경이 반영되지 않습니다. 코드를 수정했으면 –build 옵션을 붙이는 걸 잊지 말아야 합니다.

    마무리

    Docker Compose는 여러 컨테이너로 구성된 환경을 하나의 파일로 관리할 수 있게 해주는 도구입니다. docker-compose.yml에 서비스, 네트워크, 볼륨을 정의하고 docker compose up 한 줄로 전체 환경을 띄울 수 있습니다. 개발 환경을 구성할 때 특히 유용한데, 새로운 팀원이 들어와도 파일 하나만 공유하면 동일한 환경을 바로 만들 수 있습니다. 처음에는 앱 서버와 데이터베이스 두 개로 시작해보고, 익숙해지면 Redis나 Nginx 같은 서비스를 하나씩 추가해나가면 됩니다. 다음 글에서는 서버 운영에서 중요한 환경 변수의 개념과 관리 방법을 더 자세히 다뤄보겠습니다.

  • GitHub Actions로 자동 배포 구성하기 — CI/CD 기초

    GitHub Actions로 자동 배포 구성하기 — CI/CD 기초

    코드를 수정할 때마다 서버에 SSH로 접속해서 git pull을 치고, 빌드하고, 서비스를 재시작하는 과정을 반복하다 보면 점점 귀찮아집니다. 수정 사항이 간단해도 매번 같은 과정을 거쳐야 하고, 바쁠 때는 실수로 빌드를 빼먹거나 잘못된 브랜치를 배포하는 일도 생깁니다. 이런 반복 작업을 자동화하는 게 CI/CD이고, GitHub Actions는 GitHub에서 바로 사용할 수 있는 CI/CD 도구입니다. 이번 글에서는 CI/CD가 무엇인지, GitHub Actions를 어떻게 설정하는지, 실제로 코드를 푸시하면 서버에 자동 배포되는 흐름을 정리해보겠습니다.

    CI/CD란 무엇인가

    CI는 Continuous Integration, 지속적 통합이라는 뜻입니다. 개발자가 코드를 변경해서 저장소에 푸시하면 자동으로 빌드와 테스트가 실행되는 것을 의미합니다. 여러 명이 같은 프로젝트에서 작업할 때 각자의 코드를 합쳤더니 에러가 나는 상황을 미리 잡아내는 게 목적입니다. 코드를 푸시할 때마다 테스트가 자동으로 돌아가니까 문제가 생기면 바로 알 수 있습니다.

    CD는 두 가지 의미로 쓰입니다. Continuous Delivery는 빌드와 테스트를 통과한 코드를 배포 가능한 상태까지 자동으로 만들어두는 것이고, Continuous Deployment는 거기서 한 발 더 나아가 실제 운영 서버에 자동으로 배포하는 것입니다. Delivery는 배포 버튼을 사람이 누르고, Deployment는 그 버튼마저 자동화합니다.

    정리하면 CI/CD는 코드 변경부터 실제 서비스 반영까지의 과정을 자동화하는 것입니다. 수동으로 하던 빌드, 테스트, 배포를 파이프라인으로 구성해서 코드를 푸시하는 것만으로 모든 과정이 알아서 돌아가게 만드는 겁니다.

    GitHub Actions란 무엇인가

    GitHub Actions는 GitHub에서 제공하는 CI/CD 서비스입니다. GitHub 저장소에 코드를 푸시하거나 풀 리퀘스트를 만들거나 특정 이벤트가 발생하면 미리 정의해둔 작업이 자동으로 실행됩니다.

    별도의 CI/CD 서버를 구축할 필요가 없다는 게 가장 큰 장점입니다. Jenkins 같은 도구는 서버를 직접 설치하고 관리해야 하지만, GitHub Actions는 GitHub에 저장소만 있으면 바로 사용할 수 있습니다. 공개 저장소에서는 무료이고, 비공개 저장소도 월 2,000분까지 무료로 사용할 수 있습니다.

    설정은 저장소 안에 YAML 파일을 만드는 것으로 끝납니다. .github/workflows 디렉토리에 워크플로우 파일을 넣으면 GitHub가 자동으로 인식하고 실행합니다.

    기본 개념 정리

    GitHub Actions에서 사용하는 용어가 몇 가지 있습니다.

    워크플로우(Workflow)는 자동화할 전체 과정을 정의한 YAML 파일입니다. 하나의 워크플로우 안에 여러 작업이 들어갈 수 있습니다.

    잡(Job)은 워크플로우 안에서 실행되는 작업 단위입니다. 빌드 잡, 테스트 잡, 배포 잡처럼 나눌 수 있습니다. 기본적으로 각 잡은 독립된 가상 환경에서 실행되고, 병렬로 돌아갑니다. 순서를 지정하고 싶으면 의존성을 설정할 수 있습니다.

    스텝(Step)은 잡 안에서 순서대로 실행되는 개별 명령입니다. 코드를 체크아웃하는 스텝, 의존성을 설치하는 스텝, 테스트를 실행하는 스텝 같은 식으로 나눕니다.

    러너(Runner)는 워크플로우가 실행되는 서버입니다. GitHub에서 제공하는 가상 머신을 사용할 수도 있고, 내 서버를 러너로 등록해서 사용할 수도 있습니다.

    액션(Action)은 자주 사용되는 작업을 재사용 가능하게 패키징한 것입니다. 코드를 체크아웃하는 actions/checkout, Node.js를 설정하는 actions/setup-node 같은 공식 액션이 있고, 커뮤니티에서 만든 액션도 수천 개가 있습니다.

    첫 번째 워크플로우 만들어보기

    간단한 예시부터 시작하겠습니다. main 브랜치에 코드를 푸시하면 자동으로 테스트를 실행하는 워크플로우입니다.

    저장소 안에 .github/workflows 디렉토리를 만들고, 그 안에 ci.yml 파일을 생성합니다.

    yaml

    name: CI
    
    on:
      push:
        branches: [ main ]
      pull_request:
        branches: [ main ]
    
    jobs:
      test:
        runs-on: ubuntu-latest
    
        steps:
          - name: 코드 체크아웃
            uses: actions/checkout@v4
    
          - name: Node.js 설정
            uses: actions/setup-node@v4
            with:
              node-version: '20'
    
          - name: 의존성 설치
            run: npm install
    
          - name: 테스트 실행
            run: npm test

    하나씩 살펴보겠습니다.

    name은 워크플로우의 이름입니다. GitHub Actions 탭에서 이 이름으로 표시됩니다.

    on은 워크플로우가 실행되는 조건입니다. main 브랜치에 push가 발생하거나 main을 대상으로 하는 pull_request가 만들어지면 실행됩니다.

    jobs 아래에 test라는 잡을 정의했습니다. runs-on: ubuntu-latest는 GitHub에서 제공하는 최신 Ubuntu 가상 머신에서 실행한다는 뜻입니다.

    steps에 실행할 단계를 순서대로 적습니다. uses는 미리 만들어진 액션을 사용하는 것이고, run은 셸 명령어를 직접 실행하는 것입니다. 코드를 체크아웃하고, Node.js 환경을 설정하고, 의존성을 설치하고, 테스트를 실행하는 흐름입니다.

    이 파일을 저장소에 푸시하면 GitHub Actions 탭에서 워크플로우가 실행되는 걸 확인할 수 있습니다.

    서버 자동 배포 워크플로우

    테스트까지 자동화했으니 이제 배포도 자동화해보겠습니다. main 브랜치에 푸시하면 테스트를 실행하고, 테스트가 통과하면 운영 서버에 자동으로 배포하는 워크플로우입니다.

    서버 배포는 SSH로 접속해서 명령어를 실행하는 방식이 가장 기본적입니다. 이걸 자동화하려면 GitHub Actions에서 서버로 SSH 접속이 가능해야 합니다. SSH 키를 GitHub 저장소의 시크릿에 등록하면 워크플로우에서 안전하게 사용할 수 있습니다.

    먼저 시크릿을 등록합니다. GitHub 저장소 페이지에서 Settings, Secrets and variables, Actions 순서로 들어가서 New repository secret을 클릭합니다. 서버 접속에 필요한 정보를 등록합니다. SSH_HOST에 서버 IP를, SSH_USERNAME에 접속 사용자명을, SSH_KEY에 SSH 개인키 내용을 넣습니다.

    시크릿으로 등록한 값은 워크플로우 로그에서 자동으로 마스킹 처리되기 때문에 외부에 노출되지 않습니다.

    배포 워크플로우 파일은 이렇게 작성합니다.

    yaml

    name: Deploy
    
    on:
      push:
        branches: [ main ]
    
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - name: 코드 체크아웃
            uses: actions/checkout@v4
    
          - name: Node.js 설정
            uses: actions/setup-node@v4
            with:
              node-version: '20'
    
          - name: 의존성 설치
            run: npm install
    
          - name: 테스트 실행
            run: npm test
    
      deploy:
        runs-on: ubuntu-latest
        needs: test
        steps:
          - name: SSH로 서버에 배포
            uses: appleboy/ssh-action@v1
            with:
              host: ${{ secrets.SSH_HOST }}
              username: ${{ secrets.SSH_USERNAME }}
              key: ${{ secrets.SSH_KEY }}
              script: |
                cd /var/www/today-play
                git pull origin main
                npm install
                pm2 restart all

    여기서 핵심은 deploy 잡의 needs: test 부분입니다. 이건 test 잡이 성공적으로 완료된 후에만 deploy 잡을 실행하라는 뜻입니다. 테스트가 실패하면 배포가 실행되지 않기 때문에 버그가 있는 코드가 운영 서버에 올라가는 걸 방지할 수 있습니다.

    appleboy/ssh-action은 커뮤니티에서 만든 SSH 액션입니다. 지정한 서버에 SSH로 접속해서 script에 적힌 명령어를 실행합니다. 서버에서 git pull로 최신 코드를 받고, 의존성을 설치하고, pm2로 앱을 재시작하는 흐름입니다.

    ${{ secrets.SSH_HOST }} 형식은 아까 등록한 시크릿 값을 참조하는 문법입니다. 워크플로우 파일에 직접 서버 IP나 키를 적으면 안 됩니다.

    환경 변수와 시크릿 관리

    배포할 때 환경 변수가 필요한 경우가 많습니다. 데이터베이스 접속 정보, API 키 같은 값들은 코드에 직접 넣으면 안 되고 환경 변수로 관리해야 합니다.

    GitHub Actions에서 환경 변수를 사용하는 방법은 두 가지입니다.

    워크플로우 파일 안에서 직접 정의하는 방법이 있습니다.

    yaml

    env:
      NODE_ENV: production
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - name: 환경 변수 확인
            run: echo $NODE_ENV

    민감하지 않은 값은 이렇게 워크플로우 파일에 직접 적어도 됩니다.

    민감한 값은 시크릿으로 등록하고 참조합니다. 시크릿은 저장소 설정에서 등록하고, 워크플로우에서 ${{ secrets.시크릿이름 }} 형식으로 사용합니다. 시크릿은 한 번 등록하면 다시 확인할 수 없고, 수정하거나 삭제만 가능합니다.

    배포 워크플로우 실전 팁

    배포 실패에 대비하는 게 중요합니다. 배포 도중에 에러가 나면 서비스가 중단될 수 있습니다. script 안에서 각 명령어 사이에 확인 로직을 넣거나, 배포 전에 현재 상태를 백업하는 스텝을 추가하면 안전합니다.

    yaml

    script: |
      cd /var/www/today-play
      git pull origin main || exit 1
      npm install || exit 1
      npm run build || exit 1
      pm2 restart all

    각 명령어 뒤에 || exit 1을 붙이면 해당 명령어가 실패했을 때 스크립트 전체가 중단됩니다. git pull이 실패했는데 npm install이 실행되는 상황을 방지할 수 있습니다.

    특정 파일이 변경됐을 때만 배포를 실행하고 싶은 경우도 있습니다. README만 수정했는데 배포가 돌아가는 건 낭비니까요.

    yaml

    on:
      push:
        branches: [ main ]
        paths-ignore:
          - '**.md'
          - 'docs/**'

    paths-ignore에 지정한 경로의 파일만 변경됐으면 워크플로우가 실행되지 않습니다. 마크다운 파일이나 문서 폴더의 변경은 배포와 무관하니까 제외하는 겁니다.

    배포 알림을 보내는 것도 유용합니다. 배포가 성공했는지 실패했는지를 슬랙이나 디스코드로 알림 보내는 액션들이 커뮤니티에 많이 있습니다.

    워크플로우 실행 결과 확인하기

    워크플로우가 실행되면 GitHub 저장소의 Actions 탭에서 결과를 확인할 수 있습니다. 각 워크플로우 실행 기록이 목록으로 나오고, 클릭하면 잡별로 스텝 단위의 로그를 볼 수 있습니다.

    초록색 체크 표시는 성공, 빨간색 X 표시는 실패입니다. 실패한 스텝을 클릭하면 에러 로그가 나오기 때문에 원인을 파악할 수 있습니다. npm test에서 실패했다면 어떤 테스트가 통과하지 못했는지, SSH 접속에서 실패했다면 키가 맞는지, 서버 IP가 맞는지를 로그에서 확인합니다.

    워크플로우가 실행 중일 때 취소할 수도 있습니다. 실수로 잘못된 코드를 푸시했을 때 배포가 진행되고 있다면 Actions 탭에서 Cancel workflow run 버튼을 눌러서 중단할 수 있습니다.

    다른 CI/CD 도구와의 비교

    GitHub Actions 외에도 CI/CD 도구는 여러 가지가 있습니다.

    Jenkins는 오래된 오픈소스 CI/CD 도구입니다. 플러그인이 방대하고 자유도가 높지만 직접 서버를 설치하고 관리해야 합니다. 대규모 팀이나 복잡한 파이프라인이 필요한 프로젝트에서 많이 사용합니다.

    GitLab CI/CD는 GitLab에 내장된 CI/CD 서비스입니다. GitHub Actions와 비슷한 개념이지만 GitLab 저장소를 사용할 때 쓸 수 있습니다.

    CircleCI, Travis CI 같은 전문 CI/CD 서비스도 있습니다. 각각의 장단점이 있지만, GitHub에 저장소를 두고 있다면 별도 서비스를 연동하는 것보다 GitHub Actions가 설정도 간편하고 관리할 것도 적습니다.

    개인 프로젝트나 소규모 팀이라면 GitHub Actions만으로 충분하고, 파이프라인이 복잡해지거나 특수한 요구 사항이 생기면 그때 다른 도구를 검토해도 됩니다.

    마무리

    CI/CD는 결국 사람이 반복하던 작업을 자동화하는 것입니다. GitHub Actions를 사용하면 저장소에 YAML 파일 하나 만드는 것만으로 코드 푸시부터 테스트, 배포까지 전체 흐름을 자동화할 수 있습니다. 처음에는 간단한 테스트 자동화부터 시작하고, 익숙해지면 배포까지 연결하면 됩니다. 한 번 설정해두면 이후로는 코드를 푸시하는 것만으로 모든 과정이 알아서 돌아갑니다. 다음 글에서는 여러 개의 컨테이너를 동시에 관리할 수 있는 Docker Compose에 대해 다뤄보겠습니다.

  • ping, traceroute, nslookup 명령어 사용법

    ping, traceroute, nslookup 명령어 사용법

    서버를 운영하다 보면 “사이트가 안 열려요”라는 말을 듣게 되는 순간이 옵니다. 이때 가장 먼저 해야 할 일은 문제가 어디에서 생겼는지 파악하는 겁니다. 서버 자체가 죽은 건지, 네트워크 경로 어딘가가 막힌 건지, DNS가 도메인을 못 찾는 건지에 따라 대응이 완전히 달라집니다. 리눅스에는 이런 네트워크 문제를 진단할 수 있는 기본 명령어들이 있습니다. 이번 글에서는 가장 자주 사용하는 ping, traceroute, nslookup 세 가지 명령어의 사용법과 결과를 읽는 방법을 정리해보겠습니다.

    ping — 서버가 살아있는지 확인하기

    ping은 네트워크 진단에서 가장 먼저 사용하는 명령어입니다. 상대 서버에 작은 패킷을 보내고 응답이 돌아오는지 확인합니다. 응답이 오면 서버가 살아있고 네트워크가 연결되어 있다는 뜻이고, 응답이 안 오면 서버가 다운됐거나 네트워크 어딘가에 문제가 있다는 뜻입니다.

    사용법은 간단합니다.

    ping today-play.com

    실행하면 이런 결과가 반복적으로 나옵니다.

    PING today-play.com (143.198.241.73) 56(84) bytes of data.
    64 bytes from 143.198.241.73: icmp_seq=1 ttl=56 time=32.4 ms
    64 bytes from 143.198.241.73: icmp_seq=2 ttl=56 time=31.8 ms
    64 bytes from 143.198.241.73: icmp_seq=3 ttl=56 time=33.1 ms

    결과에서 봐야 할 항목이 세 가지 있습니다.

    time은 패킷이 상대 서버까지 갔다가 돌아오는 데 걸린 시간입니다. 밀리초 단위이고, 이 값이 작을수록 응답이 빠르다는 뜻입니다. 같은 국내 서버라면 보통 10ms 이내이고, 해외 서버라면 100ms 이상 나올 수 있습니다. 평소에 30ms 정도였는데 갑자기 200ms로 뛰었다면 네트워크 경로에 문제가 생겼거나 서버에 부하가 걸린 겁니다.

    ttl은 Time to Live의 약자로 패킷이 네트워크를 지나면서 거칠 수 있는 최대 라우터 수입니다. 라우터를 하나 지날 때마다 ttl이 1씩 줄어들고, 0이 되면 패킷이 폐기됩니다. 네트워크에서 패킷이 무한히 돌아다니는 걸 방지하는 장치입니다. 일반적인 진단에서 ttl 값을 직접 분석할 일은 많지 않지만, 운영체제마다 기본 ttl이 달라서 상대 서버의 운영체제를 대략 추측할 수 있습니다. 리눅스는 보통 64, 윈도우는 128에서 시작합니다.

    icmp_seq는 보낸 패킷의 순번입니다. 이 번호가 중간에 빠지면 해당 패킷이 유실된 겁니다. 1, 2, 4, 5처럼 3이 빠져 있다면 세 번째 패킷이 도중에 사라졌다는 뜻입니다. 간헐적으로 패킷이 빠지면 네트워크가 불안정한 상태이고, 전부 다 빠지면 연결 자체가 안 되는 겁니다.

    리눅스에서 ping은 중지할 때까지 계속 실행됩니다. Ctrl+C를 누르면 중지되면서 통계가 나옵니다. 보낸 패킷 수, 받은 패킷 수, 유실률, 평균 응답 시간을 보여줍니다.

    횟수를 지정하고 싶으면 -c 옵션을 사용합니다.

    ping -c 5 today-play.com

    이렇게 하면 5번만 보내고 자동으로 끝납니다.

    ping 결과가 안 나올 때도 있습니다. “Request timed out”이나 “Destination Host Unreachable” 같은 메시지가 나오는 경우입니다. 서버가 다운된 건 아닌데 ping 응답이 안 올 수도 있습니다. 보안상의 이유로 서버나 방화벽에서 ICMP 패킷을 차단하는 경우가 있기 때문입니다. AWS의 보안 그룹에서 ICMP를 허용하지 않으면 서버가 정상이어도 ping이 안 됩니다. 그래서 ping이 안 된다고 무조건 서버가 죽은 거라고 판단하면 안 됩니다.

    IP 주소로도 직접 ping을 보낼 수 있습니다.

    ping 143.198.241.73

    도메인으로 ping이 안 되는데 IP로는 되면 DNS에 문제가 있다는 뜻입니다. 이때는 nslookup으로 DNS를 확인해야 합니다.

    traceroute — 네트워크 경로 추적하기

    ping이 상대 서버까지 도달할 수 있는지만 확인한다면, traceroute는 그 사이에 어떤 경로를 거치는지를 보여줍니다. 내 컴퓨터에서 서버까지 패킷이 어떤 라우터들을 지나가는지 한 단계씩 추적합니다.

    traceroute today-play.com

    실행하면 이런 결과가 나옵니다.

    traceroute to today-play.com (143.198.241.73), 30 hops max
     1  192.168.1.1 (192.168.1.1)  1.234 ms  1.112 ms  0.987 ms
     2  10.0.0.1 (10.0.0.1)  3.456 ms  3.321 ms  3.211 ms
     3  172.16.50.1 (172.16.50.1)  8.765 ms  8.654 ms  8.543 ms
     4  * * *
     5  203.0.113.1 (203.0.113.1)  25.432 ms  25.321 ms  25.210 ms
     6  143.198.241.73 (143.198.241.73)  32.123 ms  31.987 ms  31.876 ms

    왼쪽 숫자는 홉(hop) 번호입니다. 패킷이 거치는 라우터의 순서를 나타냅니다. 첫 번째 홉은 보통 내 공유기이고, 그다음은 ISP의 라우터, 그 이후에는 인터넷 백본 라우터를 거쳐서 최종 목적지에 도달합니다.

    각 홉에 세 개의 시간 값이 나오는 건 traceroute가 각 홉에 패킷을 세 번 보내기 때문입니다. 세 번의 응답 시간이 표시되는데, 이 값이 갑자기 크게 뛰는 구간이 있으면 그 구간에서 지연이 발생하고 있다는 뜻입니다. 3번 홉까지 8ms였는데 5번 홉에서 갑자기 25ms로 뛰었다면 4번에서 5번 사이 구간에서 지연이 생기고 있는 겁니다.

    별표 세 개가 나오는 홉은 해당 라우터가 응답을 보내지 않은 겁니다. 보안 정책으로 ICMP를 차단해둔 라우터입니다. 중간에 별표가 나오더라도 이후 홉에서 정상 응답이 오면 크게 신경 쓸 필요 없습니다. 하지만 특정 홉부터 끝까지 전부 별표가 나오면 그 지점에서 패킷이 막히고 있다는 뜻입니다.

    traceroute는 문제 구간을 특정할 때 유용합니다. ping은 최종 목적지까지 되는지 안 되는지만 알려주지만, traceroute는 어디서 문제가 생기는지를 보여줍니다. ISP 구간에서 막히는 건지, 클라우드 업체 쪽에서 막히는 건지, 내 네트워크에서부터 문제인지를 구분할 수 있습니다.

    Ubuntu에서 traceroute가 설치되어 있지 않으면 이렇게 설치합니다.

    sudo apt install traceroute

    윈도우에서는 traceroute 대신 tracert라는 명령어를 사용합니다.

    tracert today-play.com

    기능은 동일합니다.

    nslookup — DNS 조회하기

    nslookup은 도메인의 DNS 정보를 조회하는 명령어입니다. 도메인이 어떤 IP를 가리키고 있는지, MX 레코드는 뭐가 설정되어 있는지 확인할 수 있습니다.

    가장 기본적인 사용법입니다.

    nslookup today-play.com

    결과가 이렇게 나옵니다.

    Server:		127.0.0.53
    Address:	127.0.0.53#53
    
    Non-authoritative answer:
    Name:	today-play.com
    Address: 143.198.241.73

    위쪽의 Server는 조회에 사용된 DNS 서버입니다. 127.0.0.53은 로컬 DNS 리졸버의 주소입니다. 아래쪽의 Address가 도메인에 연결된 IP 주소입니다.

    Non-authoritative answer라는 문구가 나오는데, 이건 캐시된 결과라는 뜻입니다. 직접 해당 도메인의 네임서버에서 가져온 게 아니라 중간 DNS 서버가 캐시해둔 정보를 돌려준 겁니다. 정상적인 상황이고 걱정할 필요 없습니다.

    특정 DNS 서버를 지정해서 조회할 수도 있습니다.

    nslookup today-play.com 8.8.8.8

    구글의 공개 DNS(8.8.8.8)에서 조회한 결과를 보여줍니다. DNS 설정을 변경한 직후에 로컬 DNS에는 아직 예전 값이 캐시되어 있을 수 있는데, 외부 DNS로 조회하면 전파 상태를 확인할 수 있습니다.

    특정 레코드 타입을 조회하고 싶으면 이렇게 합니다.

    nslookup -type=MX today-play.com
    nslookup -type=TXT today-play.com
    nslookup -type=NS today-play.com

    MX 레코드를 조회하면 메일 서버 설정을 확인할 수 있고, TXT 레코드를 조회하면 SPF 설정이나 도메인 인증 값을 확인할 수 있습니다. 이전 글에서 다뤘던 DNS 레코드들을 실제로 확인할 때 nslookup을 사용합니다.

    nslookup 대신 dig 명령어를 쓸 수도 있습니다. dig이 더 자세한 정보를 보여주기 때문에 실무에서는 dig을 선호하는 경우가 많습니다.

    dig today-play.com A
    dig today-play.com MX +short

    +short 옵션을 붙이면 핵심 값만 간결하게 보여줍니다. 스크립트에서 결과를 파싱할 때 편리합니다.

    세 명령어를 조합한 문제 진단 흐름

    실제로 서버에 접속이 안 될 때 이 세 명령어를 순서대로 사용하면 문제를 체계적으로 진단할 수 있습니다.

    첫 번째 단계로 nslookup으로 DNS를 확인합니다. 도메인이 올바른 IP를 가리키고 있는지 확인합니다. IP가 안 나오거나 엉뚱한 IP가 나오면 DNS 설정에 문제가 있는 겁니다. A레코드가 잘못 설정되어 있거나 네임서버가 변경된 후 전파가 안 됐을 수 있습니다.

    nslookup today-play.com

    두 번째 단계로 ping으로 서버 응답을 확인합니다. DNS가 정상이면 IP 주소가 나올 테니, 그 IP로 직접 ping을 보내봅니다. 응답이 오면 서버까지의 네트워크는 정상입니다. 응답이 안 오면 서버가 다운됐거나 방화벽에서 차단하고 있을 수 있습니다.

    ping -c 5 143.198.241.73

    세 번째 단계로 traceroute로 경로를 추적합니다. ping이 안 될 때 어디서 막히는지 확인합니다. 특정 홉부터 응답이 없어지면 그 구간이 문제입니다. 내 네트워크 문제인지, ISP 문제인지, 서버 쪽 문제인지를 구분할 수 있습니다.

    traceroute today-play.com

    이 순서를 따라가면 대부분의 네트워크 문제에서 원인이 DNS인지, 서버인지, 중간 네트워크인지를 빠르게 좁힐 수 있습니다.

    추가로 알아두면 좋은 옵션들

    ping에서 패킷 크기를 지정할 수 있습니다.

    ping -s 1400 -c 5 today-play.com

    -s 옵션으로 패킷 크기를 바이트 단위로 지정합니다. 기본값은 56바이트인데, 큰 패킷을 보내서 네트워크의 MTU 문제를 확인할 때 사용합니다. 작은 패킷은 잘 가는데 큰 패킷이 유실된다면 경로 중간에 MTU 제한이 있는 장비가 있다는 뜻입니다.

    traceroute에서 TCP를 사용하는 옵션도 있습니다.

    sudo traceroute -T -p 443 today-play.com

    기본 traceroute는 UDP를 사용하는데, 일부 방화벽이 UDP를 차단해서 결과가 부정확할 수 있습니다. -T 옵션을 사용하면 TCP로 추적하고, -p로 포트를 지정할 수 있습니다. 웹 서버 443 포트로 TCP traceroute를 하면 실제 HTTPS 트래픽과 같은 경로를 확인할 수 있습니다.

    mtr이라는 명령어도 있는데, ping과 traceroute를 합친 도구입니다.

    sudo apt install mtr
    mtr today-play.com

    실행하면 traceroute처럼 각 홉을 보여주면서 동시에 ping처럼 실시간으로 응답 시간과 패킷 유실률을 갱신합니다. 네트워크 문제를 모니터링할 때 매우 유용합니다. 한 화면에서 어느 구간에서 패킷이 유실되는지, 어느 구간에서 지연이 큰지를 한눈에 볼 수 있습니다.

    마무리

    ping은 서버가 살아있는지 확인하고, traceroute는 경로 중 어디에서 문제가 생기는지 추적하고, nslookup은 DNS 설정이 올바른지 확인합니다. 이 세 가지만 알아도 네트워크 문제가 발생했을 때 원인을 좁히는 데 충분합니다. 서버가 안 될 때 무작정 설정을 바꾸기 전에 이 명령어들로 먼저 진단하는 습관을 들이면 불필요한 삽질을 많이 줄일 수 있습니다. 다음 글에서는 코드 변경을 서버에 자동으로 반영하는 CI/CD의 기초, GitHub Actions 사용법을 다뤄보겠습니다.

  • DNS 레코드 종류 정리 — A, AAAA, MX, TXT 등

    DNS 레코드 종류 정리 — A, AAAA, MX, TXT 등

    도메인을 서버에 연결할 때 A레코드와 CNAME을 설정하는 방법은 이전 글에서 다뤘습니다. 그런데 DNS 관리 페이지에 들어가보면 A와 CNAME 말고도 MX, TXT, AAAA, NS 같은 레코드 타입이 여러 개 있습니다. 처음 보면 뭐가 뭔지 알 수 없고, 건드리면 안 될 것 같아서 그냥 넘어가게 됩니다. 하지만 이메일 설정을 하거나 도메인 소유권을 인증하거나 보안 설정을 할 때 이 레코드들을 직접 다뤄야 하는 상황이 생깁니다. 이번 글에서는 실무에서 만나게 되는 DNS 레코드 종류를 하나씩 정리해보겠습니다.

    DNS 레코드가 하는 일

    DNS 레코드는 도메인에 대한 정보를 담고 있는 설정 항목입니다. 누군가 브라우저에 도메인을 입력하면 DNS 서버가 해당 도메인의 레코드를 찾아서 적절한 응답을 돌려줍니다. 어떤 레코드냐에 따라 IP 주소를 알려줄 수도 있고, 메일 서버 주소를 알려줄 수도 있고, 도메인의 소유자가 누구인지 증명하는 텍스트를 돌려줄 수도 있습니다.

    하나의 도메인에 여러 종류의 레코드가 동시에 존재할 수 있습니다. today-play.com이라는 도메인에 A레코드로 서버 IP를 연결해두고, MX레코드로 메일 서버를 지정하고, TXT레코드로 소유권 인증 코드를 넣어두는 식입니다. 각 레코드는 서로 독립적으로 동작합니다.

    A 레코드

    A레코드는 도메인을 IPv4 주소에 연결하는 가장 기본적인 레코드입니다. 이전 글에서 자세히 다뤘기 때문에 간단하게만 짚겠습니다.

    타입: A
    호스트: @
    값: 143.198.241.73
    TTL: 3600

    호스트에 @을 넣으면 도메인 자체를 의미하고, 특정 서브도메인을 넣을 수도 있습니다. 호스트에 blog를 넣으면 blog.today-play.com이 해당 IP를 가리키게 됩니다. 하나의 도메인에 A레코드를 여러 개 등록하면 DNS가 요청마다 다른 IP를 돌려주는 라운드 로빈 방식으로 동작합니다. 간단한 로드 밸런싱 용도로 쓸 수 있지만 실무에서는 전용 로드 밸런서를 사용하는 게 일반적입니다.

    AAAA 레코드

    AAAA 레코드는 A레코드의 IPv6 버전입니다. 도메인을 IPv6 주소에 연결합니다.

    타입: AAAA
    호스트: @
    값: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
    TTL: 3600

    IPv4 주소는 32비트라서 약 43억 개밖에 안 되는데, 인터넷에 연결되는 장비가 늘어나면서 주소가 부족해지고 있습니다. IPv6는 128비트라서 사실상 무한에 가까운 주소를 제공합니다. AAAA라는 이름은 A레코드가 32비트 주소를 가리키니까 그 4배인 128비트를 가리킨다는 의미에서 A를 네 번 쓴 것입니다.

    아직 대부분의 서비스가 IPv4로 운영되고 있어서 AAAA 레코드를 당장 설정해야 하는 경우는 많지 않습니다. 하지만 IPv6 지원이 점점 확대되고 있기 때문에 서버가 IPv6를 지원한다면 AAAA 레코드도 함께 등록해두는 게 좋습니다. A레코드와 AAAA레코드가 둘 다 있으면 클라이언트가 자신의 환경에 맞는 쪽으로 접속합니다.

    CNAME 레코드

    CNAME 레코드는 도메인을 다른 도메인에 연결하는 별명 레코드입니다. 이것도 이전 글에서 다뤘으니 핵심만 짚겠습니다.

    타입: CNAME
    호스트: www
    값: today-play.com
    TTL: 3600

    www.today-play.com으로 접속하면 today-play.com을 거쳐서 최종적으로 A레코드의 IP에 도달합니다. 외부 서비스에 연결할 때도 많이 사용합니다. Vercel이나 Netlify에서 커스텀 도메인을 연결할 때 CNAME으로 해당 서비스의 주소를 가리키도록 설정합니다.

    주의할 점은 루트 도메인(@)에는 CNAME을 설정하지 못하는 DNS 업체가 많다는 것입니다. CNAME은 해당 이름에 다른 레코드가 존재하면 안 된다는 DNS 표준 규칙이 있는데, 루트 도메인에는 SOA와 NS 레코드가 필수로 존재하기 때문입니다. Cloudflare는 CNAME Flattening이라는 기술로 이 제한을 우회해줍니다.

    MX 레코드

    MX 레코드는 Mail Exchanger의 약자로, 해당 도메인으로 이메일이 들어왔을 때 어떤 메일 서버로 보낼지 지정하는 레코드입니다.

    타입: MX
    호스트: @
    값: mail.today-play.com
    우선순위: 10
    TTL: 3600

    누군가 admin@today-play.com으로 메일을 보내면, 보내는 쪽의 메일 서버가 today-play.com의 MX 레코드를 조회합니다. MX 레코드에 적힌 메일 서버 주소로 메일을 전달합니다.

    MX 레코드에는 우선순위 값이 있습니다. 숫자가 낮을수록 우선순위가 높습니다. MX 레코드를 여러 개 등록할 수 있는데, 우선순위가 높은 서버로 먼저 시도하고 그 서버가 응답하지 않으면 다음 우선순위 서버로 시도합니다.

    타입: MX  호스트: @  값: mail1.today-play.com  우선순위: 10
    타입: MX  호스트: @  값: mail2.today-play.com  우선순위: 20

    이렇게 설정하면 평소에는 mail1으로 메일이 가고, mail1이 다운되면 mail2로 갑니다.

    구글 워크스페이스나 네이버 웍스 같은 기업용 메일 서비스를 사용하면 해당 서비스에서 안내하는 MX 레코드를 그대로 등록하면 됩니다. 구글 워크스페이스의 경우 다섯 개 정도의 MX 레코드를 등록하라고 안내합니다.

    TXT 레코드

    TXT 레코드는 도메인에 텍스트 정보를 추가하는 레코드입니다. 이름 그대로 임의의 텍스트 값을 넣을 수 있습니다. 용도가 다양하지만 실무에서 가장 많이 마주치는 세 가지가 있습니다.

    첫 번째는 도메인 소유권 인증입니다. 구글 서치 콘솔에 사이트를 등록하거나, 외부 서비스에서 도메인 소유자를 확인할 때 TXT 레코드에 특정 값을 넣으라고 안내합니다.

    타입: TXT
    호스트: @
    값: google-site-verification=AbCdEf123456
    TTL: 3600

    서비스 제공자가 이 TXT 레코드를 조회해서 값이 일치하면 도메인 소유자가 맞다고 인증합니다. 도메인의 DNS 설정을 변경할 수 있다는 건 도메인을 관리할 권한이 있다는 뜻이니까요.

    두 번째는 SPF(Sender Policy Framework) 설정입니다. 이메일 스푸핑을 방지하기 위한 설정인데, 이 도메인에서 메일을 보낼 수 있는 서버가 어디인지를 TXT 레코드로 선언합니다.

    타입: TXT
    호스트: @
    값: v=spf1 include:_spf.google.com ~all
    TTL: 3600

    이렇게 설정하면 구글의 메일 서버에서 보낸 메일만 today-play.com의 정당한 메일로 인정한다는 뜻입니다. 다른 서버에서 today-play.com을 사칭해서 메일을 보내면 수신 측에서 스팸으로 처리할 수 있습니다.

    세 번째는 DKIM(DomainKeys Identified Mail)과 DMARC 설정입니다. 둘 다 이메일 인증과 관련된 것으로 TXT 레코드를 사용합니다. DKIM은 메일에 디지털 서명을 추가해서 메일이 변조되지 않았음을 증명하고, DMARC는 SPF와 DKIM 검증이 실패했을 때 어떻게 처리할지 정책을 정의합니다. 기업 메일을 운영한다면 SPF, DKIM, DMARC 세 가지를 전부 설정해야 메일이 스팸함에 들어가는 걸 방지할 수 있습니다.

    NS 레코드

    NS 레코드는 Name Server의 약자로, 이 도메인의 DNS 정보를 어떤 네임서버에서 관리하는지를 지정합니다.

    타입: NS
    호스트: @
    값: ns1.example-dns.com
    TTL: 86400

    도메인을 구매하면 등록업체의 기본 네임서버가 설정되어 있습니다. 가비아에서 도메인을 사면 가비아의 네임서버가 기본으로 들어갑니다. Cloudflare로 DNS 관리를 옮기고 싶으면 NS 레코드를 Cloudflare의 네임서버로 변경하면 됩니다. 네임서버를 변경한 후에는 A레코드, CNAME, MX 같은 모든 DNS 설정을 새 네임서버 쪽에서 관리해야 합니다.

    NS 레코드는 직접 수정하기보다는 도메인 등록업체의 네임서버 변경 기능을 통해 바꾸는 게 일반적입니다. 잘못 건드리면 도메인 전체가 응답하지 않을 수 있으니 주의해야 합니다.

    SOA 레코드

    SOA 레코드는 Start of Authority의 약자로, 해당 도메인의 DNS 영역에 대한 기본 정보를 담고 있습니다. 주 네임서버 이름, 관리자 이메일, 시리얼 번호, 갱신 주기 같은 정보가 들어 있습니다.

    SOA 레코드는 모든 도메인에 반드시 하나 존재해야 하는 필수 레코드입니다. DNS 서버 간의 동기화에 사용되는 기술적인 레코드라서 일반적인 서버 운영에서 직접 수정할 일은 거의 없습니다. DNS 관리 서비스에서 자동으로 설정해줍니다.

    SRV 레코드

    SRV 레코드는 특정 서비스의 서버 위치를 알려주는 레코드입니다. 어떤 서비스가 어떤 호스트의 어떤 포트에서 동작하는지를 지정합니다.

    타입: SRV
    호스트: _sip._tcp.today-play.com
    값: 10 60 5060 sipserver.today-play.com
    TTL: 3600

    값에 들어가는 숫자들은 순서대로 우선순위, 가중치, 포트 번호, 대상 호스트입니다. VoIP, 마인크래프트 서버, XMPP 같은 서비스에서 사용됩니다. 마인크래프트 서버를 운영할 때 SRV 레코드를 설정하면 사용자가 포트 번호 없이 도메인만으로 접속할 수 있게 됩니다. 일반적인 웹 서비스에서는 거의 사용하지 않습니다.

    CAA 레코드

    CAA 레코드는 Certificate Authority Authorization의 약자로, 이 도메인에 대해 SSL 인증서를 발급할 수 있는 인증 기관을 제한하는 레코드입니다.

    타입: CAA
    호스트: @
    값: 0 issue "letsencrypt.org"
    TTL: 3600

    이렇게 설정하면 Let’s Encrypt만 이 도메인의 인증서를 발급할 수 있습니다. 다른 CA에서 인증서 발급을 시도하면 CAA 레코드를 확인하고 거부합니다. 누군가 내 도메인에 대해 무단으로 인증서를 발급받는 걸 방지할 수 있습니다.

    필수는 아니지만 보안을 강화하고 싶다면 설정해두는 게 좋습니다. 여러 CA를 허용하고 싶으면 CAA 레코드를 여러 개 추가하면 됩니다.

    PTR 레코드

    PTR 레코드는 Pointer의 약자로, A레코드의 반대 방향입니다. IP 주소로 도메인을 찾는 역방향 조회에 사용됩니다. 일반적인 DNS 조회가 도메인에서 IP를 찾는 것이라면 PTR은 IP에서 도메인을 찾는 겁니다.

    PTR 레코드는 주로 이메일 서버에서 중요합니다. 수신 측 메일 서버가 발신 서버의 IP를 역방향 조회해서 도메인과 일치하는지 확인합니다. PTR 레코드가 없거나 도메인과 일치하지 않으면 스팸으로 분류될 수 있습니다.

    PTR 레코드는 DNS 관리 패널이 아니라 IP를 할당해준 호스팅 업체에서 설정합니다. AWS에서는 역방향 DNS 설정을 별도로 요청해야 하고, 다른 클라우드 업체들도 각자의 방식으로 PTR 레코드 설정을 지원합니다.

    레코드 확인하는 방법

    설정한 DNS 레코드가 제대로 반영됐는지 확인하려면 터미널에서 dig 명령어를 사용합니다.

    dig today-play.com A
    dig today-play.com MX
    dig today-play.com TXT

    dig 뒤에 도메인과 레코드 타입을 입력하면 해당 레코드의 현재 값을 보여줍니다. nslookup을 써도 되지만 dig이 더 자세한 정보를 제공합니다.

    특정 네임서버에서의 결과를 확인하고 싶으면 @을 사용합니다.

    dig @8.8.8.8 today-play.com A

    이건 구글의 공개 DNS 서버(8.8.8.8)에서 조회한 결과를 보여줍니다. DNS 설정을 변경한 직후에 로컬 DNS에는 아직 캐시가 남아있을 수 있는데, 이렇게 외부 DNS 서버에서 확인하면 전파 상태를 알 수 있습니다.

    마무리

    DNS 레코드는 종류가 많지만 실무에서 직접 다루는 건 몇 가지로 한정됩니다. 서버 연결에는 A레코드와 CNAME, 이메일 설정에는 MX레코드, 도메인 인증과 메일 보안에는 TXT레코드를 주로 사용합니다. 나머지는 특수한 상황에서 필요할 때 찾아보면 됩니다. 중요한 건 각 레코드가 어떤 역할을 하는지 큰 그림을 알아두는 것입니다. 그래야 DNS 설정을 변경할 때 뭘 건드려야 하는지 판단할 수 있습니다. 다음 글에서는 네트워크 문제를 진단할 때 자주 사용하는 ping, traceroute, nslookup 명령어 사용법을 다뤄보겠습니다.