FTP 프로토콜 실전 활용 | Active·Passive·FTPS·SFTP와 파일 전송 운영

이 글의 핵심

로그인과 디렉터리 목록은 되는데 파일 전송만 멈춘다면 대개 데이터 채널 포트가 막힌 경우입니다. 레거시 연동 때문에 FTP를 아직 써야 하는 상황을 전제로 PASV 포트 범위 지정, 계정 권한과 chroot, 평문 자격 증명 노출 위험을 짚고, 흔한 오류별 확인 순서를 정리해 운영 중 막혔을 때 바로 참고할 수 있게 했습니다.

FTP(File Transfer Protocol)는 1970년대부터 쓰인 파일 전송 프로토콜로, 웹이 보급되기 전부터 이어진 배치 작업, 레거시 시스템, 기관 간 파일 교환에서 지금도 볼 수 있습니다. 명령을 주고받는 제어 채널과 실제 파일이 오가는 데이터 채널이 별도의 TCP 연결로 열리고, Active/Passive 모드에 따라 누가 데이터 연결을 여는지가 달라집니다. 이 구조 때문에 “로컬에서는 되는데 운영 환경에서만 안 된다”는 문제가 자주 생깁니다.

또 기본 FTP는 아이디, 비밀번호, 파일 내용을 모두 평문으로 보냅니다. 그래서 새로 만드는 시스템은 HTTPS, 객체 스토리지 API, SFTP를 쓰는 것이 일반적이고, FTP를 유지해야 한다면 FTPS(FTP over TLS)로 암호화하거나 SFTP로 옮기는 계획을 함께 세웁니다. 은행, 제조, 산업 장비처럼 FTP 연동 명세가 남아 있는 곳에서는 프로토콜 자체보다 평문 전송, 방화벽, NAT와의 조합이 실제 문제가 됩니다. 이 글은 FTP의 동작 방식을 이해하고, 클라이언트와 서버 설정, 보안 대안을 한 번에 정리합니다.


프로토콜 개요

역사

FTP는 RFC 114(1971)에서 시작해 RFC 959(1985)가 현재까지 기준 명세로 쓰이고 있습니다. 이후 IPv6와 NAT를 위한 확장(RFC 2428의 EPRT/EPSV), TLS를 붙인 FTPS(RFC 4217) 등이 더해졌습니다.

계층과 포트

FTP는 응용 계층 프로토콜이며 전송 계층으로 TCP를 씁니다. 제어 연결은 기본 21번 포트이고, 데이터 연결의 포트는 모드에 따라 달라집니다. SFTP는 이름이 비슷하지만 SSH 위에서 동작하는 별도 프로토콜입니다.

특징

FTP는 명령과 응답을 제어 연결로, 파일 내용과 디렉터리 목록을 데이터 연결로 주고받는 이중 채널 구조입니다. 로그인 후 현재 디렉터리(CWD) 같은 상태를 서버가 기억하는 대화형 세션이며, 명령은 USER, PASS, RETR, STOR 같은 텍스트 문자열입니다. 전송 모드로는 스트림, 블록, 압축이 정의되어 있지만 실제로는 거의 스트림 모드만 쓰입니다.


동작 원리

FTP의 제어 연결과 별도의 데이터 연결, 액티브와 패시브 모드 차이

그림: FTP는 명령을 주고받는 제어 연결과 파일을 나르는 데이터 연결을 따로 쓰며, 데이터 연결을 누가 거는지가 액티브·패시브의 차이다.

제어 채널과 데이터 채널

제어 채널(기본 21/tcp)은 로그인 동안 계속 유지되며 인증, 디렉터리 이동, 전송 준비 명령을 주고받습니다. 데이터 채널은 디렉터리 목록(LIST)이나 파일 전송이 있을 때마다 새로 열리고, 전송이 끝나면 닫힙니다. 로그인과 cd는 되는데 ls나 파일 전송에서 멈추는 증상은 데이터 채널만 막혔다는 뜻입니다.

Active 모드

  1. 클라이언트가 서버의 21번 포트로 제어 연결을 엽니다.
  2. 데이터를 주고받을 때 클라이언트가 자기 쪽 포트를 열고, PORT 명령으로 자기 IP와 포트를 서버에 알립니다.
  3. 서버가 클라이언트의 그 포트로 데이터 연결을 엽니다. 전통적으로 서버 쪽 출발 포트는 20/tcp입니다.

서버가 클라이언트 쪽으로 연결을 여는 구조라서, 클라이언트 방화벽이 들어오는 연결을 막으면 실패합니다. 요즘 클라이언트는 대부분 공유기(NAT) 뒤에 있어서 PORT로 알려 주는 주소가 192.168.0.10 같은 사설 IP가 되고, 서버는 인터넷 너머에서 그 주소로 연결할 방법이 없습니다. Active 모드가 사실상 사내망이나 서버 간 전송에서만 쓰이는 이유입니다.

Passive 모드

  1. 제어 연결은 Active와 같습니다.
  2. 클라이언트가 PASV(또는 EPSV)를 보내면 서버가 데이터용 포트를 열고 그 주소와 포트를 알려 줍니다.
  3. 클라이언트가 서버의 그 포트로 데이터 연결을 엽니다.

이번에는 서버 쪽 데이터 포트로 들어오는 연결을 받아야 하므로, 서버 방화벽에서 데이터 포트 범위를 열어 두어야 합니다. 21번만 열어 두면 로그인은 되지만 전송에서 멈춥니다.

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: 제어 연결 (21)
  C->>S: PASV
  S->>C: 227 데이터 주소와 포트 안내
  C->>S: 데이터 연결 (안내받은 포트)
  S->>C: 파일 전송

PASV 응답은 227 Entering Passive Mode (203,0,113,10,195,80) 같은 형태입니다. 앞의 네 숫자가 IP 주소(203.0.113.10)이고, 뒤의 두 숫자로 포트를 계산합니다. 포트는 195 × 256 + 80 = 50000입니다. 전송이 멈출 때 이 줄을 직접 읽어 보면 원인이 바로 보이는 경우가 많습니다. 10,0,0,5처럼 서버의 내부 IP가 찍혀 있다면 NAT 뒤 공인 주소 설정(vsftpd의 pasv_address) 문제이고, 포트가 방화벽에서 연 범위 밖이라면 pasv_min_port/pasv_max_port 설정 문제입니다. IPv6와 NAT 환경을 위해 만들어진 EPSV(RFC 2428)는 229 Entering Extended Passive Mode (|||50000|)처럼 IP 없이 포트만 알려 주므로, 클라이언트가 제어 연결과 같은 주소로 붙게 되어 이런 주소 문제를 피할 수 있습니다.

PASV 응답의 주소가 틀리면 항상 실패하는지는 클라이언트에 따라 달랐습니다. Ubuntu 24.04 컨테이너에서 vsftpd 3.0.5를 pasv_address=203.0.113.10, pasv_min_port=50000, pasv_max_port=50100으로 띄우고 127.0.0.1로 접속했더니 응답은 227 Entering Passive Mode (203,0,113,10,195,174)였고(포트 195×256+174=50094, 설정한 범위 안), 응답의 IP가 접속 대상과 다른데도 curl 8.5.0은 목록과 업로드에 성공했습니다. curl이 PASV 응답의 IP를 무시하고 제어 연결과 같은 주소를 쓰기 때문이고, --no-ftp-skip-pasv-ip로 이 동작을 끄자 8초 제한에서 종료 코드 28(시간 초과)로 실패했습니다. lftp 4.9.2도 ftp:fix-pasv-address가 기본 yes인 상태에서는 성공했고 no로 바꾸면 max-retries exceeded로 실패했습니다. 따라서 “227에 내부 IP가 보이면 반드시 실패한다”가 아니라, 응답의 주소를 그대로 믿는 클라이언트나 장비에서 실패한다고 읽어야 합니다. 이 실험은 방화벽이나 실제 NAT를 거치지 않고 한 컨테이너 안에서 한 것이므로, 포트 범위 차단이나 NAT 동작은 재현하지 않았습니다.

자주 쓰는 명령

명령의미
USER / PASS인증(평문으로 전송됨)
PWD / CWD현재 경로 확인, 이동
LIST / NLST디렉터리 목록(상세/이름만)
RETR다운로드
STOR / APPE업로드, 기존 파일 뒤에 이어 쓰기
REST지정한 바이트 위치부터 다음 전송 재개
TYPEASCII(A) 또는 바이너리(I) 전송 형식
PASV / EPSVPassive 모드 요청

서버 응답은 세 자리 숫자 코드로 시작하며, 운영 중 자주 보는 코드는 몇 개 되지 않습니다. 530 Login incorrect는 인증 실패, 425 Can't open data connection은 데이터 채널 연결 실패(대개 방화벽이나 NAT), 550 Permission denied나 550 Failed to open file은 파일 권한이나 경로 문제, 553 Could not create file은 업로드 디렉터리 쓰기 권한 문제입니다. 425와 550만 구분해도 네트워크 문제인지 권한 문제인지를 첫 단계에서 가를 수 있습니다.

TYPE은 사소해 보이지만 데이터 손상의 흔한 원인입니다. ASCII 모드(TYPE A)는 전송 중에 줄바꿈 문자를 서버와 클라이언트 OS 형식에 맞게 변환하므로, zip이나 이미지 같은 바이너리를 ASCII 모드로 받으면 0x0D 0x0A 바이트가 바뀌어 파일이 깨집니다. 현대 클라이언트는 대부분 기본으로 바이너리(TYPE I)를 쓰지만, 오래된 배치 스크립트는 ASCII가 기본인 경우가 있어 “가끔 압축 파일이 손상된다”는 증상으로 나타납니다.


실전 사용

명령줄 클라이언트(lftp)

lftp는 기본으로 Passive 모드를 쓰며, 설정 이름은 ftp:passive-mode입니다. 현재 값은 세션 안에서 set -a | grep passive로 확인할 수 있습니다.

# 대화형 세션
lftp -u username,password ftp.example.com
# 세션 안에서: ls, cd, get file.bin, put local.bin, mirror -R 등

# Passive 모드 명시 + 재시도 횟수 제한 후 목록 조회
lftp -e 'set ftp:passive-mode on; set net:max-retries 2; ls; bye' -u username,password ftp.example.com

# FTPS(명시적 TLS) 강제
lftp -e 'set ftp:ssl-force true; set ftp:ssl-protect-data true; ls; bye' -u username ftp.example.com

명령줄에 비밀번호를 쓰면 셸 히스토리와 ps 출력에 남습니다. 배치 작업이라면 ~/.netrc(권한 600)에 자격 증명을 두는 편이 낫습니다. ftp:ssl-protect-data를 켜지 않으면 FTPS에서도 제어 채널만 암호화되고 파일 내용은 평문으로 갈 수 있습니다.

curl로도 단일 파일을 주고받을 수 있습니다.

curl -u user:pass 'ftp://ftp.example.com/pub/readme.txt' -o readme.txt
# FTPS: 제어·데이터 채널 모두 TLS를 요구 (실패하면 평문으로 내려가지 않음)
curl --ssl-reqd -u user:pass 'ftp://ftp.example.com/pub/readme.txt' -o readme.txt

curl은 기본적으로 EPSV를 먼저 시도하고 실패하면 PASV로 넘어갑니다. 방화벽이나 오래된 서버가 EPSV를 이해하지 못해 멈춘다면 --disable-epsv를 줍니다.

로컬 FTP 서버에 curl -v로 접속해 EPSV, TYPE I, RETR 명령이 오가는 화면

그림: 실제 실행 결과(curl 8.5.0, pyftpdlib 2.2.0 로컬 서버, Ubuntu 24.04) — 인증 뒤 EPSV를 먼저 보내고 TYPE I(바이너리)로 RETR한다. --disable-epsv를 주면 PASV로 바뀌고, TLS를 지원하지 않는 서버에 --ssl-reqd를 주면 평문으로 내려가지 않고 curl: (64) Requested SSL level failed로 끝난다.

vsftpd 설정 예시

/etc/vsftpd.conf(배포판에 따라 /etc/vsftpd/vsftpd.conf)의 예입니다.

listen=YES
anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022
pasv_enable=YES
pasv_min_port=50000
pasv_max_port=50100
# NAT 뒤 서버일 때 공인 IP 지정
# pasv_address=203.0.113.10
# FTPS (인증서 경로는 환경에 맞게)
ssl_enable=YES
rsa_cert_file=/etc/ssl/certs/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.key
chroot_local_user=YES

vsftpd 설정 파일은 줄 끝 주석을 지원하지 않습니다. ssl_enable=YES # FTPS처럼 쓰면 YES # FTPS 전체가 값으로 읽혀 500 OOPS: bad bool value in config file for: ssl_enable로 데몬이 시작하지 않습니다. Ubuntu 24.04 컨테이너(vsftpd 3.0.5)에서 같은 형태의 줄(ssl_enable=NO # FTPS)로 실행하면 데몬이 곧바로 종료(종료 코드 2)했고, 주석을 별도 줄로 옮기면 정상 기동했습니다. 다만 이 환경에서는 위 500 OOPS 문구가 터미널에 출력되지 않아 메시지 자체는 확인하지 못했습니다. 다른 설정 파일 형식에 익숙하면 쉽게 저지르는 실수이므로 주석은 항상 별도 줄에 둡니다. = 양옆에 공백을 넣은 설정(local_enable = YES)도 같은 컨테이너에서 데몬이 기동하지 못했습니다.

각 설정의 의미는 다음과 같습니다. listen=YES는 inetd 없이 독립 데몬으로 포트를 직접 수신하게 합니다(IPv6까지 받으려면 대신 listen_ipv6=YES를 씁니다. 둘을 동시에 켤 수는 없습니다). anonymous_enable=NO는 익명 로그인을 끄며, 공개 미러가 아니라면 보통 끕니다. local_enable과 write_enable은 시스템 사용자로 로그인과 업로드를 허용하므로, 쓰기 범위를 OS 권한과 chroot로 최소화해야 합니다. local_umask=022는 업로드된 파일의 기본 권한 마스크입니다.

pasv_min_port/pasv_max_port는 Passive 데이터 채널에 쓸 포트 범위이고, 방화벽에서 이 범위와 21/tcp를 모두 인바운드 허용해야 합니다. pasv_address는 PASV 응답에 넣을 공인 IP로, NAT 뒤에서 이 값이 없으면 내부 IP가 응답에 들어가고, 그 주소를 그대로 따르는 클라이언트는 연결에 실패합니다(클라이언트에 따라 다르며, 아래 확인 결과를 참고하세요). ssl_enable=YES를 켜면 vsftpd는 기본값(force_local_logins_ssl=YES, force_local_data_ssl=YES)에 따라 로컬 사용자에게 로그인과 데이터 전송 모두 TLS를 요구합니다. 그래서 FTPS를 켜는 순간 평문 FTP 클라이언트는 530 Non-anonymous sessions must use encryption으로 거절됩니다. 전환 기간에 평문을 허용해야 한다면 두 값을 일시적으로 NO로 둘 수 있지만, 그러면 암호화가 선택 사항이 되므로 기간을 정해 두는 것이 좋습니다.

Chroot와 권한

파일 교환용 계정은 전용 시스템 사용자로 만들고, chroot로 접근 가능한 디렉터리를 제한하며, 쓰기 가능한 디렉터리는 필요한 곳에만 둡니다.

chroot_local_user=YES를 켜면 사용자가 자기 홈 디렉터리 밖을 볼 수 없게 되는데, 이때 거의 반드시 만나는 오류가 500 OOPS: vsftpd: refusing to run with writable root inside chroot()입니다. vsftpd는 chroot 루트 디렉터리 자체가 사용자에게 쓰기 가능하면 로그인을 거부합니다. 사용자가 루트에 /etc 같은 디렉터리를 만들어 vsftpd가 읽는 시스템 파일이나 라이브러리를 바꿔치기하는 공격을 막기 위해서입니다. allow_writeable_chroot=YES로 이 검사를 끌 수도 있지만, 권장되는 해결은 홈 디렉터리를 root 소유, 쓰기 불가로 두고 그 안에 upload/ 같은 쓰기 가능한 하위 디렉터리를 만드는 것입니다.

sudo chown root:root /home/ftpuser
sudo chmod 755 /home/ftpuser
sudo mkdir /home/ftpuser/upload
sudo chown ftpuser:ftpuser /home/ftpuser/upload

보안 고려사항

평문 FTP의 위험

기본 FTP는 자격 증명과 파일 내용을 암호화하지 않습니다. 같은 네트워크의 공격자나 중간 경로의 장비가 패킷을 캡처하면 USER/PASS 명령과 파일 내용을 그대로 읽을 수 있습니다.

FTPS(FTP over TLS)

명시적 FTPS는 21번 포트로 평문 연결을 연 뒤 AUTH TLS 명령으로 TLS를 시작합니다. 처음부터 990번 포트에서 TLS로 연결하는 암시적 FTPS는 표준화되지 않은 레거시 방식이라 가능하면 명시적 방식을 씁니다. 인증서 갱신과 허용할 TLS 버전도 일반 TLS 서버처럼 관리해야 합니다.

FTPS로 바꾸면 평문 FTP에서 잘 되던 Passive 전송이 갑자기 멈추는 경우가 있습니다. 방화벽이나 공유기의 FTP ALG(Application Level Gateway)는 제어 채널의 227 응답을 엿보고 데이터 포트를 자동으로 열어 주거나 주소를 고쳐 쓰는데, 제어 채널이 TLS로 암호화되면 그 내용을 읽을 수 없기 때문입니다. 평문 시절에는 ALG 덕분에 우연히 동작하던 구성이 FTPS 전환과 함께 드러나는 것이므로, FTPS에서는 Passive 포트 범위를 방화벽에 명시적으로 열고 pasv_address를 정확히 설정해야 합니다. 또 vsftpd는 기본값 require_ssl_reuse=YES에 따라 데이터 채널에서 제어 채널의 TLS 세션 재사용을 요구하므로, 이를 지원하지 않는 클라이언트는 522 SSL connection failed; session reuse required 오류를 받습니다.

SFTP(SSH)

SFTP는 FTP가 아닙니다. SSH 세션 위에서 동작하는 별도 프로토콜로, 암호화와 키 기반 인증을 SSH에서 그대로 가져옵니다.

구분FTPSSFTP
기반FTP + TLSSSH
포트21 + 데이터 포트 범위, 방화벽 설정 복잡22 하나
클라이언트TLS를 지원하는 FTP 클라이언트SFTP 클라이언트(OpenSSH sftp, WinSCP 등)

SFTP의 가장 큰 운영상 이점은 포트 하나(22)만 열면 된다는 점입니다. 제어와 데이터가 같은 SSH 연결 안에서 다중화되므로 Active/Passive, 포트 범위, pasv_address, ALG 같은 이 글의 문제 대부분이 사라집니다. 대신 상대방이 FTP 명세로만 연동할 수 있는 장비나 기관이라면 선택지가 FTPS뿐이고, SFTP 서버를 열 때는 파일 전송 전용 계정이 셸을 얻지 못하도록 sshd_config의 Match User 블록에 ForceCommand internal-sftp와 ChrootDirectory를 지정해 제한해야 합니다. 이 ChrootDirectory도 vsftpd와 마찬가지로 root 소유이고 다른 사용자가 쓸 수 없어야 합니다.


실무 활용 사례

FTP가 남아 있는 대표적인 경우는 은행이나 제조사와의 배치 파일 교환처럼 연동 명세가 FTP로 고정된 곳, FTP로만 펌웨어나 로그를 주고받는 산업 장비, 공개 소프트웨어 미러입니다. 장비가 FTP만 지원한다면 그 장비를 격리된 네트워크에 두고, 가능하면 FTPS를 쓰는 식으로 위험을 줄입니다.

대용량 전송에서는 클라이언트 기능이 차이를 만듭니다. lftp의 mirror -P N은 여러 파일을 동시에 전송하고, pget -n 4는 큰 파일 하나를 구간으로 나눠 여러 연결로 받습니다. 다만 서버의 IP당 동시 접속 제한(vsftpd의 max_per_ip)에 걸리면 421 There are too many connections from your internet address가 납니다. 전송이 중간에 끊겼다면 REST 명령으로 이어받기를 지원하는 클라이언트(lftp의 get -c, curl의 -C -)를 쓰면 처음부터 다시 받지 않아도 됩니다. 이미지나 압축 파일은 앞에서 설명한 대로 반드시 바이너리 모드(TYPE I)로 전송합니다.


흔한 문제와 해결

증상점검
로그인은 되고 목록·전송에서 타임아웃서버의 Passive 포트 범위가 방화벽에 열려 있는지, NAT 뒤라면 pasv_address 설정
227 응답에 내부 IP가 보임pasv_address를 공인 IP(또는 NAT 외부 주소)로 설정
Active 모드만 안 됨클라이언트 쪽 인바운드 차단. Passive로 전환
평문에서는 되던 것이 FTPS에서 멈춤ALG가 암호화된 제어 채널을 읽지 못함. 포트 범위를 명시적으로 개방
425 Can't open data connection데이터 채널 연결 실패: PASV 포트 범위, 방화벽, pasv_address 순서로 확인
530 Non-anonymous sessions must use encryption서버가 TLS를 강제하는데 클라이언트가 평문으로 접속
522 ... session reuse required클라이언트가 데이터 채널 TLS 세션 재사용을 지원하지 않음
500 OOPS: refusing to run with writable rootchroot 루트가 쓰기 가능: 루트는 쓰기 불가로, 하위 디렉터리만 쓰기 허용
500 OOPS: bad bool value in config file설정 파일의 줄 끝 주석이나 = 주변 공백
압축 파일만 가끔 손상ASCII 모드 전송: 바이너리(TYPE I)로 강제
한글 파일명 깨짐클라이언트의 UTF-8 설정, 서버의 OPTS UTF8 ON 지원 여부

FTPS와 SFTP를 같은 것으로 착각하면 포트, 방화벽, 클라이언트 설정이 서로 엇갈립니다. 운영 문서에 어느 쪽을 쓰는지 명확히 적어 두는 것만으로 이런 혼선을 상당히 줄일 수 있습니다.

참고

  • RFC 959(FTP), RFC 2428(IPv6·NAT 확장), RFC 4217(FTPS)
  • man vsftpd.conf, man lftp, OpenSSH sshd_config 문서

같이 보면 좋은 글