FTP·SFTP·FTPS·SMB·NFS·SCP·rsync 비교: 동작 방식, 보안, 성능과 선택 기준
이 글의 핵심
파일을 옮기는 프로토콜(FTP·FTPS·SFTP·SCP·rsync)과 파일 시스템을 공유하는 프로토콜(SMB·NFS)은 목적이 다릅니다. 이 글은 각 프로토콜의 동작 방식과 포트·방화벽 특성, 보안 수준을 비교하고, OpenSSH 9부터 scp가 내부적으로 SFTP를 쓰게 된 변화, Windows 11 24H2에서 SMB 서명이 기본 필수가 되어 오래된 NAS·Samba 공유가 안 붙는 문제, 인터넷에 떠도는 Samba 튜닝 옵션이 지금은 효과가 없거나 해로운 이유, rsync의 끝 슬래시와 --delete 사고까지 운영하면서 실제로 부딪히는 지점을 정리합니다.
들어가며: 파일 전송의 역사와 현대
서버에 파일을 업로드하거나, 팀원과 대용량 파일을 공유하거나, 백업을 원격 서버로 전송할 때 어떤 프로토콜을 사용하시나요? FTP는 오래되었지만 여전히 널리 사용되고, SFTP는 보안이 강화되었으며, SMB는 Windows 네트워크 공유의 표준입니다. 각 프로토콜마다 보안, 성능, 호환성이 다르며, 잘못 선택하면 데이터 유출이나 성능 저하가 발생합니다.
이 글에서 다루는 프로토콜:
- FTP (File Transfer Protocol)
- SFTP (SSH File Transfer Protocol)
- FTPS (FTP over SSL/TLS)
- SMB/CIFS (Server Message Block)
- NFS (Network File System)
- SCP (Secure Copy Protocol)
- rsync (Remote Sync)
FTP (File Transfer Protocol)
FTP란?
FTP는 1971년에 개발된 파일 전송 프로토콜로, TCP/IP 기반으로 클라이언트와 서버 간 파일을 주고받습니다.
FTP 동작 원리
sequenceDiagram
participant C as Client
participant CS as Control\nPort 21
participant DS as Data\nPort 20
C->>CS: USER username
CS-->>C: 331 Password required
C->>CS: PASS password
CS-->>C: 230 Login successful
C->>CS: PASV (Passive Mode)
CS-->>C: 227 Entering Passive Mode (IP,Port)
C->>DS: Connect to data port
C->>CS: LIST
CS->>DS: Send file list
DS-->>C: File list data
CS-->>C: 226 Transfer complete
C->>CS: RETR file.txt
CS->>DS: Send file data
DS-->>C: File content
CS-->>C: 226 Transfer complete
C->>CS: QUIT
CS-->>C: 221 Goodbye
FTP 모드
Active Mode (능동 모드)
flowchart LR
subgraph Client["Client\nIP: 192.168.1.100"]
CP["Control Port\nRandom: 50000"]
DP["Data Port\nRandom: 50001"]
end
subgraph Server["Server\nIP: 203.0.113.1"]
CS["Control Port\n21"]
DS["Data Port\n20"]
end
CP -->|1. Control Connection| CS
DS -->|2. Data Connection\nServer → Client| DP
style DS fill:#f96
style DP fill:#6cf
문제점: 클라이언트 방화벽이 서버의 연결을 차단할 수 있음
Passive Mode (수동 모드)
flowchart LR
subgraph Client["Client\nIP: 192.168.1.100"]
CP["Control Port\nRandom: 50000"]
DP["Data Port\nRandom: 50001"]
end
subgraph Server["Server\nIP: 203.0.113.1"]
CS["Control Port\n21"]
DS["Data Port\nRandom: 60000"]
end
CP -->|1. Control Connection| CS
DP -->|2. Data Connection\nClient → Server| DS
style DP fill:#6cf
style DS fill:#f96
장점: 클라이언트가 모든 연결을 시작하므로 방화벽 친화적
FTP 명령어
# FTP 연결
ftp ftp.example.com
# 또는
ftp 203.0.113.1
# 로그인
Name: username
Password: ******
# 기본 명령어
ftp> ls # 디렉토리 목록
ftp> cd /path/to/dir # 디렉토리 이동
ftp> pwd # 현재 디렉토리
ftp> mkdir newdir # 디렉토리 생성
ftp> rmdir olddir # 디렉토리 삭제
# 파일 전송
ftp> get remote.txt # 다운로드
ftp> put local.txt # 업로드
ftp> mget *.txt # 여러 파일 다운로드
ftp> mput *.log # 여러 파일 업로드
# 전송 모드
ftp> binary # 바이너리 모드 (파일 전송에는 항상 이것)
ftp> ascii # ASCII 모드 (줄바꿈 변환, 텍스트 전용)
# 수동 모드
ftp> passive # Passive 모드 활성화
# 종료
ftp> bye
FTP에서 가장 오래된 사고 원인은 ASCII 모드입니다. ASCII 모드는 전송하면서 줄바꿈(CRLF/LF)을 상대 OS 형식으로 바꾸는데, 일부 클라이언트는 기본값이 ASCII라서 zip이나 이미지 파일을 이 모드로 받으면 바이트가 바뀌어 파일이 깨집니다. 크기가 원본과 몇 바이트 다르다면 거의 이 문제입니다. 스크립트에서는 전송 전에 항상 binary(프로토콜 명령으로는 TYPE I)를 명시하세요.
FTP 서버 구현 (Python)
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
def setup_ftp_server():
"""FTP 서버 설정"""
# 사용자 인증
authorizer = DummyAuthorizer()
# 사용자 추가 (username, password, homedir, perm)
authorizer.add_user("user", "12345", "/home/ftp", perm="elradfmw")
# 익명 사용자
authorizer.add_anonymous("/home/ftp/public", perm="elr")
# 핸들러 설정
handler = FTPHandler
handler.authorizer = authorizer
# 배너 설정
handler.banner = "Welcome to My FTP Server"
# Passive 모드 포트 범위
handler.passive_ports = range(60000, 60100)
# 서버 시작
server = FTPServer(("0.0.0.0", 21), handler)
# 연결 제한
server.max_cons = 256
server.max_cons_per_ip = 5
print("FTP Server running on port 21...")
server.serve_forever()
if __name__ == "__main__":
setup_ftp_server()
FTP 클라이언트 구현
from ftplib import FTP
import os
class FTPClient:
def __init__(self, host, username, password):
self.ftp = FTP()
self.ftp.connect(host, 21)
self.ftp.login(username, password)
print(f"✅ Connected to {host}")
def list_files(self, path='/'):
"""파일 목록 조회"""
self.ftp.cwd(path)
files = []
self.ftp.retrlines('LIST', files.append)
return files
def download_file(self, remote_path, local_path):
"""파일 다운로드"""
with open(local_path, 'wb') as f:
self.ftp.retrbinary(f'RETR {remote_path}', f.write)
print(f"✅ Downloaded: {remote_path} → {local_path}")
def upload_file(self, local_path, remote_path):
"""파일 업로드"""
with open(local_path, 'rb') as f:
self.ftp.storbinary(f'STOR {remote_path}', f)
print(f"✅ Uploaded: {local_path} → {remote_path}")
def download_directory(self, remote_dir, local_dir):
"""디렉토리 재귀 다운로드"""
os.makedirs(local_dir, exist_ok=True)
self.ftp.cwd(remote_dir)
for item in self.ftp.nlst():
local_path = os.path.join(local_dir, item)
remote_path = f"{remote_dir}/{item}"
try:
# 디렉토리인지 확인
self.ftp.cwd(remote_path)
self.ftp.cwd('..')
# 디렉토리면 재귀 호출
self.download_directory(remote_path, local_path)
except:
# 파일이면 다운로드
self.download_file(item, local_path)
def close(self):
self.ftp.quit()
print("✅ Connection closed")
# 사용 예시
client = FTPClient('ftp.example.com', 'user', 'pass')
client.list_files('/')
client.download_file('report.pdf', 'local_report.pdf')
client.upload_file('data.csv', 'remote_data.csv')
client.close()
Passive 모드가 NAT 뒤에서 자주 실패하는 이유도 알아 둘 만합니다. 서버는 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) 응답에 자기 IP 주소를 문자열로 넣어 보내는데, 서버가 사설 IP를 가진 채 NAT 뒤에 있으면 클라이언트는 도달할 수 없는 사설 주소로 데이터 연결을 시도합니다. 클라우드 VM에 FTP를 띄울 때 로그인과 pwd는 되는데 ls에서 멈추는 증상이 대표적입니다. vsftpd라면 pasv_address에 공인 IP를, pyftpdlib라면 handler.masquerade_address를 지정해야 합니다. 제어 채널이 암호화된 FTPS에서는 중간의 방화벽·NAT 장비가 이 응답을 들여다보고 고쳐 줄 수도 없어서 문제가 더 자주 생깁니다.
FTP 보안 문제
flowchart TB
FTP[FTP 프로토콜]
subgraph Issues[보안 취약점]
I1["🔓 평문 전송\nID/PW 노출"]
I2["🔓 데이터 암호화 없음\n파일 내용 노출"]
I3["🔓 중간자 공격\nMan-in-the-Middle"]
I4["🔓 스니핑 가능\nWireshark로 캡처"]
end
subgraph Solutions[해결책]
S1["✅ SFTP 사용\nSSH 암호화"]
S2["✅ FTPS 사용\nTLS 암호화"]
S3["✅ VPN 터널링\n네트워크 암호화"]
end
FTP --> Issues
Issues --> Solutions
SFTP (SSH File Transfer Protocol)
SFTP란?
SFTP는 SSH 프로토콜 위에서 동작하는 파일 전송 프로토콜로, 모든 데이터가 암호화됩니다. FTP와 이름이 비슷하지만 완전히 다른 프로토콜입니다.
SFTP vs FTP
| 특성 | FTP | SFTP |
|---|---|---|
| 프로토콜 | 독립 프로토콜 | SSH 기반 |
| 포트 | 21 (제어), 20 (데이터) | 22 (단일) |
| 암호화 | ❌ 평문 | ✅ SSH 암호화 |
| 인증 | ID/PW | ID/PW, 공개키 |
| 방화벽 | 복잡 (2개 포트) | 간단 (1개 포트) |
| 속도 | 빠름 | 대부분 비슷, 고지연 구간에서는 느려질 수 있음 |
“암호화 때문에 SFTP가 느리다”는 말은 요즘 CPU에서는 대체로 맞지 않습니다. AES-NI 같은 하드웨어 가속 덕분에 1Gbps 수준에서는 암호화 비용이 병목이 되는 경우가 드뭅니다. SFTP가 실제로 느려지는 곳은 왕복 지연이 큰 구간입니다. SFTP는 요청-응답 단위로 데이터를 읽는데, 클라이언트가 동시에 걸어 두는 요청 수가 적으면 해외 서버처럼 RTT가 큰 경로에서 대역폭을 다 쓰지 못합니다. OpenSSH sftp는 -R(동시 요청 수)과 -B(요청 크기)로 조절할 수 있고, 대용량을 멀리 보낼 때는 rsync나 여러 파일을 병렬로 보내는 방식이 더 빠른 경우가 많습니다.
SFTP 동작 원리
sequenceDiagram
participant C as Client
participant S as Server\nPort 22
C->>S: SSH 연결 요청
S-->>C: 서버 공개키
C->>S: 클라이언트 인증 (ID/PW 또는 키)
S-->>C: 인증 성공
Note over C,S: SSH 터널 내부에서 SFTP 세션
C->>S: SFTP 세션 시작
S-->>C: SFTP 프로토콜 버전
C->>S: SSH_FXP_OPENDIR /path
S-->>C: SSH_FXP_HANDLE (핸들)
C->>S: SSH_FXP_READDIR (핸들)
S-->>C: SSH_FXP_NAME (파일 목록)
C->>S: SSH_FXP_OPEN file.txt
S-->>C: SSH_FXP_HANDLE
C->>S: SSH_FXP_READ (핸들, offset, length)
S-->>C: SSH_FXP_DATA (파일 데이터)
C->>S: SSH_FXP_CLOSE (핸들)
S-->>C: SSH_FXP_STATUS OK
SFTP 명령어
# SFTP 연결
sftp [email protected]
# 또는 포트 지정
sftp -P 2222 [email protected]
# 공개키 인증
sftp -i ~/.ssh/id_rsa [email protected]
# 기본 명령어
sftp> ls # 원격 디렉토리 목록
sftp> lls # 로컬 디렉토리 목록
sftp> cd /remote/path # 원격 디렉토리 이동
sftp> lcd /local/path # 로컬 디렉토리 이동
sftp> pwd # 원격 현재 디렉토리
sftp> lpwd # 로컬 현재 디렉토리
# 파일 전송
sftp> get remote.txt # 다운로드
sftp> put local.txt # 업로드
sftp> get -r remotedir # 디렉토리 다운로드
sftp> put -r localdir # 디렉토리 업로드
# 파일 관리
sftp> mkdir newdir # 디렉토리 생성
sftp> rmdir olddir # 디렉토리 삭제
sftp> rm file.txt # 파일 삭제
sftp> rename old.txt new.txt # 파일 이름 변경
# 권한 관리
sftp> chmod 644 file.txt # 권한 변경
sftp> chown user file.txt # 소유자 변경
# 종료
sftp> exit
SFTP 클라이언트 구현 (Python)
import paramiko
import os
from pathlib import Path
class SFTPClient:
def __init__(self, host, port, username, password=None, key_file=None):
"""SFTP 클라이언트 초기화"""
self.ssh = paramiko.SSHClient()
# 알려진 호스트 키로 검증 (AutoAddPolicy는 중간자 공격을 막지 못하므로 운영에서는 쓰지 않음)
self.ssh.load_system_host_keys()
self.ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
if key_file:
# 공개키 인증 (key_filename은 ed25519·ECDSA·RSA 키를 자동 판별)
self.ssh.connect(host, port, username, key_filename=os.path.expanduser(key_file))
else:
# 비밀번호 인증
self.ssh.connect(host, port, username, password)
self.sftp = self.ssh.open_sftp()
print(f"✅ Connected to {host}:{port}")
def list_files(self, remote_path='/'):
"""파일 목록 조회"""
return self.sftp.listdir(remote_path)
def download_file(self, remote_path, local_path):
"""파일 다운로드"""
self.sftp.get(remote_path, local_path)
print(f"✅ Downloaded: {remote_path} → {local_path}")
def upload_file(self, local_path, remote_path):
"""파일 업로드"""
self.sftp.put(local_path, remote_path)
print(f"✅ Uploaded: {local_path} → {remote_path}")
def download_directory(self, remote_dir, local_dir):
"""디렉토리 재귀 다운로드"""
os.makedirs(local_dir, exist_ok=True)
for item in self.sftp.listdir_attr(remote_dir):
remote_path = f"{remote_dir}/{item.filename}"
local_path = os.path.join(local_dir, item.filename)
if self._is_directory(item):
self.download_directory(remote_path, local_path)
else:
self.download_file(remote_path, local_path)
def upload_directory(self, local_dir, remote_dir):
"""디렉토리 재귀 업로드"""
try:
self.sftp.mkdir(remote_dir)
except IOError:
pass
for item in os.listdir(local_dir):
local_path = os.path.join(local_dir, item)
remote_path = f"{remote_dir}/{item}"
if os.path.isdir(local_path):
self.upload_directory(local_path, remote_path)
else:
self.upload_file(local_path, remote_path)
def _is_directory(self, item):
"""디렉토리 여부 확인"""
import stat
return stat.S_ISDIR(item.st_mode)
def close(self):
"""연결 종료"""
self.sftp.close()
self.ssh.close()
print("✅ Connection closed")
# 사용 예시
client = SFTPClient('example.com', 22, 'user', password='pass')
# 또는 공개키 인증
# client = SFTPClient('example.com', 22, 'user', key_file='~/.ssh/id_rsa')
files = client.list_files('/uploads')
print(files)
client.download_file('/uploads/report.pdf', 'local_report.pdf')
client.upload_file('data.csv', '/uploads/data.csv')
client.download_directory('/uploads/project', './project')
client.close()
SFTP 서버 설정 (OpenSSH)
# /etc/ssh/sshd_config
# SFTP 서브시스템 활성화
Subsystem sftp /usr/lib/openssh/sftp-server
# SFTP 전용 사용자 생성
Match User sftpuser
ChrootDirectory /home/sftpuser
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
# 설정 검사 후 적용 (문법 오류로 sshd가 안 뜨면 원격 접속이 끊김)
sudo sshd -t && sudo systemctl restart ssh # Ubuntu/Debian은 서비스명이 ssh, RHEL 계열은 sshd
ChrootDirectory로 지정한 디렉터리와 그 모든 상위 디렉터리는 root 소유이고 그룹·기타 쓰기 권한이 없어야 합니다. 이 조건이 안 맞으면 sshd가 로그인 직후 연결을 끊는데, 클라이언트에는 Connection closed만 보이고 이유는 서버의 journalctl -u ssh(또는 /var/log/auth.log)에 bad ownership or modes for chroot directory로만 남습니다. 사용자가 쓰기를 해야 한다면 chroot 안에 사용자 소유의 하위 디렉터리(예: uploads)를 따로 만듭니다. 처음 SFTP 전용 계정을 만들 때 거의 누구나 한 번은 이 문제로 시간을 쓰게 되고, 저도 클라이언트 쪽 설정만 계속 의심하다가 서버 로그를 보고서야 원인을 알았습니다.
FTPS (FTP over SSL/TLS)
FTPS란?
FTPS는 FTP에 SSL/TLS 암호화를 추가한 프로토콜로, HTTPS와 유사하게 보안 계층을 제공합니다.
FTPS 모드
Explicit FTPS (FTPES)
sequenceDiagram
participant C as Client
participant S as Server\nPort 21
C->>S: Connect (평문)
S-->>C: 220 Welcome
C->>S: AUTH TLS
S-->>C: 234 Proceed with negotiation
Note over C,S: TLS 핸드셰이크
C->>S: TLS ClientHello
S-->>C: TLS ServerHello, Certificate
C->>S: TLS Finished
Note over C,S: 암호화된 연결
C->>S: USER username (암호화)
S-->>C: 331 Password required
C->>S: PASS password (암호화)
S-->>C: 230 Login successful
포트: 21 (제어), 평문으로 시작 후 TLS로 업그레이드
Implicit FTPS
sequenceDiagram
participant C as Client
participant S as Server\nPort 990
Note over C,S: 처음부터 TLS 암호화
C->>S: TLS ClientHello
S-->>C: TLS ServerHello, Certificate
C->>S: TLS Finished
Note over C,S: 암호화된 FTP 세션
C->>S: USER username (암호화)
S-->>C: 331 Password required
C->>S: PASS password (암호화)
S-->>C: 230 Login successful
포트: 990 (제어), 처음부터 TLS 암호화. Implicit 방식은 표준화 과정에서 정식으로 채택되지 않았고 지금은 Explicit(AUTH TLS) 방식이 일반적입니다.
FTPS 운영에서 흔한 문제는 두 가지입니다. 첫째, 제어 채널만 암호화하고 데이터 채널은 평문으로 두는 설정이 가능하므로, 클라이언트는 PROT P(아래 코드의 prot_p())로 데이터 채널 암호화를 명시해야 합니다. 둘째, 최근 서버(vsftpd의 require_ssl_reuse, FileZilla Server 등)는 데이터 연결이 제어 연결의 TLS 세션을 재사용하도록 요구하는데, Python ftplib.FTP_TLS는 기본적으로 세션을 재사용하지 않아서 로그인은 되는데 LIST나 RETR에서 425나 TLS 오류가 납니다. 이 경우 서버 설정을 완화하거나 세션 재사용을 지원하는 클라이언트를 써야 합니다.
FTPS 클라이언트 (Python)
from ftplib import FTP_TLS
import ssl
class FTPSClient:
def __init__(self, host, username, password, implicit=False):
"""FTPS 클라이언트 초기화"""
context = ssl.create_default_context() # 인증서 검증 활성화
if implicit:
# 주의: 표준 ftplib.FTP_TLS는 Implicit FTPS를 직접 지원하지 않음.
# 소켓을 연결 직후 TLS로 감싸는 서브클래스가 필요하므로 여기서는 생략
raise NotImplementedError("Implicit FTPS needs a custom FTP_TLS subclass")
# Explicit FTPS (포트 21, AUTH TLS)
self.ftp = FTP_TLS(context=context)
self.ftp.connect(host, 21)
self.ftp.login(username, password)
# 데이터 연결도 암호화
self.ftp.prot_p()
print(f"✅ Secure connection to {host}")
def download_file(self, remote_path, local_path):
"""파일 다운로드"""
with open(local_path, 'wb') as f:
self.ftp.retrbinary(f'RETR {remote_path}', f.write)
print(f"✅ Downloaded: {remote_path}")
def upload_file(self, local_path, remote_path):
"""파일 업로드"""
with open(local_path, 'rb') as f:
self.ftp.storbinary(f'STOR {remote_path}', f)
print(f"✅ Uploaded: {local_path}")
def close(self):
self.ftp.quit()
# 사용
client = FTPSClient('ftps.example.com', 'user', 'pass')
client.download_file('secure.pdf', 'local.pdf')
client.close()
SMB/CIFS (Server Message Block)
SMB란?
SMB는 Windows 네트워크 파일 공유 프로토콜로, Samba는 Linux에서 SMB를 구현한 오픈소스입니다.
SMB 버전 비교
| 버전 | 출시 | 주요 특징 |
|---|---|---|
| SMB 1.0 | 1984 | 레거시, 보안 취약점 다수 |
| SMB 2.0 | 2006 | 성능 개선, 파이프라이닝 |
| SMB 2.1 | 2010 | 대용량 MTU 지원 |
| SMB 3.0 | 2012 | 암호화, 멀티채널, RDMA |
| SMB 3.1.1 | 2015 | AES-128-GCM, 사전 인증 무결성 |
SMB 1.0은 WannaCry(2017)가 악용한 EternalBlue 취약점의 대상이었고, 최신 Windows와 Samba(4.11 이상)에서는 기본으로 비활성화되어 있습니다. Windows 11과 Windows Server 2025 세대에서는 AES-256 암호화, 인터넷 구간용 SMB over QUIC(UDP 443) 같은 기능이 추가됐습니다.
실무에서 최근 가장 많이 부딪히는 변화는 Windows 11 24H2부터 SMB 서명이 기본 필수가 된 것입니다. 여기에 Pro 이상 에디션은 게스트(인증 없는) 접속도 기본으로 막습니다. 그래서 업데이트 직후 “어제까지 되던 NAS 공유 폴더가 갑자기 안 열린다”는 문의가 흔하고, 원인은 대개 서명을 지원하지 않는 오래된 NAS 펌웨어이거나 guest ok = yes로 열어 둔 Samba 공유입니다. 인터넷에는 레지스트리나 그룹 정책으로 서명 요구를 끄는 방법이 돌지만, 이건 서명이 막으려던 릴레이 공격을 다시 허용하는 것이라 권하지 않습니다. NAS 펌웨어를 업데이트하고, 게스트 공유는 사용자 계정 인증으로 바꾸는 쪽이 맞습니다.
SMB 동작 원리
sequenceDiagram
participant C as Client
participant S as Server\nPort 445
C->>S: SMB Negotiate Protocol
S-->>C: SMB2/SMB3 지원 확인
C->>S: Session Setup (인증)
S-->>C: Session ID
C->>S: Tree Connect (\\server\share)
S-->>C: Tree ID
C->>S: Create (파일 열기)
S-->>C: File ID
C->>S: Read (File ID, offset, length)
S-->>C: File Data
C->>S: Write (File ID, offset, data)
S-->>C: Write Complete
C->>S: Close (File ID)
S-->>C: Close Complete
C->>S: Tree Disconnect
S-->>C: Disconnect OK
Samba 서버 설정
# Samba 설치 (Ubuntu/Debian)
sudo apt update
sudo apt install samba samba-common-bin
# Samba 사용자 추가
sudo smbpasswd -a username
# /etc/samba/smb.conf 설정
sudo nano /etc/samba/smb.conf
# /etc/samba/smb.conf
[global]
workgroup = WORKGROUP
server string = Samba Server
security = user
map to guest = Bad User
# SMB 버전 제한: Samba 4.11+는 기본값이 이미 SMB2 이상이지만 명시해 둠
server min protocol = SMB2_10
# 로깅
log file = /var/log/samba/%m.log
max log size = 50
# 서명·암호화 (Samba 4.15+ 옵션명)
server signing = mandatory
server smb encrypt = desired
[public]
comment = Public Share (읽기 전용)
path = /srv/samba/public
browseable = yes
read only = yes
# guest ok = yes 는 Windows 11 Pro 이상에서 기본 차단되므로 계정 인증을 권장
valid users = @staff
[private]
comment = Private Share
path = /srv/samba/private
browseable = yes
read only = no
valid users = @staff
create mask = 0660
directory mask = 0770
force group = staff
[homes]
comment = Home Directories
browseable = no
read only = no
create mask = 0700
directory mask = 0700
valid users = %S
# 디렉토리 생성 및 권한 설정
sudo mkdir -p /srv/samba/{public,private}
sudo chmod 755 /srv/samba/public
sudo chmod 770 /srv/samba/private
sudo chown -R root:staff /srv/samba/private
# Samba 서비스 재시작
sudo systemctl restart smbd
sudo systemctl enable smbd
# 방화벽 설정
sudo ufw allow from 192.168.1.0/24 to any port 445 proto tcp # 445는 절대 인터넷에 열지 않음
# 설정 문법 검사
testparm
139번 포트는 SMB1 시절 NetBIOS 세션용이라, SMB1을 끈 환경에서는 열 필요가 없습니다. 445번 포트를 인터넷에 노출하는 것은 대부분의 ISP가 차단할 만큼 위험한 설정이므로, 원격에서 써야 한다면 VPN을 거치거나 SMB over QUIC을 검토합니다.
SMB 클라이언트 (Python)
from smb.SMBConnection import SMBConnection
import tempfile
class SMBClient:
def __init__(self, host, username, password, domain='WORKGROUP', share_name='public'):
"""SMB 클라이언트 초기화"""
self.conn = SMBConnection(
username,
password,
'client_machine',
host,
domain=domain,
use_ntlm_v2=True,
is_direct_tcp=True
)
if not self.conn.connect(host, 445):
raise Exception("Connection failed")
self.share_name = share_name
print(f"✅ Connected to \\\\{host}\\{share_name}")
def list_files(self, path='/'):
"""파일 목록 조회"""
files = self.conn.listPath(self.share_name, path)
return [f.filename for f in files if f.filename not in ['.', '..']]
def download_file(self, remote_path, local_path):
"""파일 다운로드"""
with open(local_path, 'wb') as f:
self.conn.retrieveFile(self.share_name, remote_path, f)
print(f"✅ Downloaded: {remote_path}")
def upload_file(self, local_path, remote_path):
"""파일 업로드"""
with open(local_path, 'rb') as f:
self.conn.storeFile(self.share_name, remote_path, f)
print(f"✅ Uploaded: {local_path}")
def create_directory(self, path):
"""디렉토리 생성"""
self.conn.createDirectory(self.share_name, path)
print(f"✅ Created directory: {path}")
def delete_file(self, path):
"""파일 삭제"""
self.conn.deleteFiles(self.share_name, path)
print(f"✅ Deleted: {path}")
def close(self):
"""연결 종료"""
self.conn.close()
print("✅ Connection closed")
# 사용 예시
client = SMBClient('192.168.1.100', 'user', 'pass', share_name='shared')
files = client.list_files('/')
print(files)
client.download_file('/documents/report.pdf', 'report.pdf')
client.upload_file('data.csv', '/uploads/data.csv')
client.close()
Windows에서 SMB 공유 마운트
# Windows (CMD)
net use Z: \\192.168.1.100\shared /user:username password
# Windows (PowerShell)
New-PSDrive -Name "Z" -PSProvider FileSystem -Root "\\192.168.1.100\shared" -Credential (Get-Credential)
# Linux (CIFS 마운트) — 비밀번호를 명령줄에 쓰면 셸 히스토리와 ps에 남으므로 credentials 파일 사용
sudo apt install cifs-utils
sudo mount -t cifs //192.168.1.100/shared /mnt/shared -o credentials=/etc/samba/credentials,vers=3.1.1,seal
# 영구 마운트 (/etc/fstab)
//192.168.1.100/shared /mnt/shared cifs credentials=/etc/samba/credentials,uid=1000,gid=1000 0 0
# /etc/samba/credentials (chmod 600, root 소유)
username=user
password=pass
vers=3.1.1은 협상 버전을 고정하고, seal은 SMB3 암호화를 요구합니다. 오래된 서버와 붙지 않으면 vers=3.0으로 낮춰 보고, 그래도 안 되면 서버가 SMB1만 지원하는 것이므로 서버를 교체할 때가 된 것입니다.
NFS (Network File System)
NFS란?
NFS는 Unix/Linux 환경에서 파일 시스템을 네트워크로 공유하는 프로토콜입니다. 로컬 파일 시스템처럼 투명하게 사용할 수 있습니다.
NFS 버전
| 버전 | 특징 |
|---|---|
| NFSv3 | UDP/TCP, Stateless, 부가 서비스(mountd·lockd·statd) 포트가 따로 있어 방화벽이 번거로움 |
| NFSv4 | TCP만, Stateful, 방화벽 친화적 (단일 포트 2049), ACL 지원 |
| NFSv4.1 | pNFS (병렬 NFS), 세션 관리 |
| NFSv4.2 | 서버 사이드 복사, 희소 파일 |
NFS를 쓸 때 가장 먼저 이해해야 할 것은 기본 보안 모델입니다. 기본값인 sec=sys(AUTH_SYS)에서는 서버가 클라이언트가 보내는 UID/GID를 그대로 믿습니다. 즉 export를 허용한 IP의 머신에서 root 권한을 가진 사람은 사용자를 만들어 UID를 맞추는 것만으로 다른 사람의 파일에 접근할 수 있습니다. 그래서 sec=sys의 NFS는 신뢰할 수 있는 서버끼리만 쓰고, 사용자 단위 인증과 전송 암호화가 필요하면 Kerberos(sec=krb5, krb5i, krb5p)를 붙여야 합니다. 서버와 클라이언트의 UID가 서로 다르면 파일 소유자가 엉뚱하게 보이는 것도 같은 이유입니다.
NFS 동작 원리
flowchart TB
subgraph Client[NFS Client]
App[Application]
VFS[VFS Layer]
NFSC[NFS Client]
end
subgraph Network[Network]
RPC[RPC/XDR]
end
subgraph Server[NFS Server]
NFSD[NFS Daemon]
FS[File System]
Disk[Disk]
end
App -->|read/write| VFS
VFS -->|NFS call| NFSC
NFSC -->|RPC| RPC
RPC -->|Network| NFSD
NFSD -->|I/O| FS
FS -->|Storage| Disk
NFS 서버 설정
# NFS 서버 설치 (Ubuntu/Debian)
sudo apt install nfs-kernel-server
# 공유 디렉토리 생성
sudo mkdir -p /srv/nfs/shared
sudo chown nobody:nogroup /srv/nfs/shared
sudo chmod 755 /srv/nfs/shared
# /etc/exports 설정
sudo nano /etc/exports
# /etc/exports
# 형식: 공유경로 클라이언트(옵션)
# 특정 IP만 허용
/srv/nfs/shared 192.168.1.100(rw,sync,no_subtree_check)
# 서브넷 허용
/srv/nfs/shared 192.168.1.0/24(rw,sync,no_subtree_check)
# 읽기 전용
/srv/nfs/public 192.168.1.0/24(ro,sync,no_subtree_check)
# 여러 클라이언트
/srv/nfs/shared 192.168.1.100(rw,sync) 192.168.1.101(rw,sync)
# 옵션 설명:
# rw: 읽기/쓰기 허용
# ro: 읽기만 허용
# sync: 동기 쓰기 (데이터 무결성)
# async: 비동기 쓰기 (성능 향상, 위험)
# no_subtree_check: 서브트리 검사 비활성화 (성능)
# root_squash: root를 nobody로 매핑 (보안)
# no_root_squash: root 권한 유지 (위험)
# exports 적용
sudo exportfs -ra
# 현재 공유 목록 확인
sudo exportfs -v
# NFS 서버 시작
sudo systemctl start nfs-kernel-server
sudo systemctl enable nfs-kernel-server
# 방화벽 설정
sudo ufw allow from 192.168.1.0/24 to any port nfs
NFS 클라이언트 마운트
# NFS 클라이언트 설치
sudo apt install nfs-common
# 마운트
sudo mkdir -p /mnt/nfs/shared
sudo mount -t nfs 192.168.1.100:/srv/nfs/shared /mnt/nfs/shared
# 옵션 지정 (NFSv4.2 고정)
sudo mount -t nfs -o rw,hard,vers=4.2,timeo=600 192.168.1.100:/srv/nfs/shared /mnt/nfs/shared
# 영구 마운트 (/etc/fstab)
192.168.1.100:/srv/nfs/shared /mnt/nfs/shared nfs defaults,_netdev 0 0
# 마운트 확인
df -h | grep nfs
mount | grep nfs
# 언마운트
sudo umount /mnt/nfs/shared
NFS 성능 최적화
# /etc/fstab
192.168.1.100:/srv/nfs/shared /mnt/nfs/shared nfs rw,hard,vers=4.2,timeo=600,retrans=2,nconnect=4,_netdev 0 0
# 옵션 설명:
# hard: 서버 응답이 없으면 계속 재시도 (데이터 무결성, 기본값)
# soft: 타임아웃 후 I/O 에러 반환 — 쓰기 중이면 파일이 조용히 손상될 수 있어 쓰기용으론 비권장
# timeo=600: 타임아웃 (0.1초 단위, 60초), retrans=2: 재전송 횟수
# nconnect=4: 한 마운트에 TCP 연결 여러 개 사용 (리눅스 커널 5.3+), 고대역폭 링크에서 처리량 향상
# _netdev: 네트워크가 올라온 뒤 마운트
인터넷의 오래된 예제에는 intr과 rsize=32768,wsize=32768이 자주 나오는데, 둘 다 지금은 쓸 이유가 없습니다. intr은 리눅스 커널 2.6.25부터 무시되고(대신 SIGKILL로 hard 마운트의 대기를 끊을 수 있음), rsize/wsize는 지정하지 않으면 클라이언트와 서버가 지원하는 최댓값(보통 1MB)으로 협상되므로 32KB로 고정하면 오히려 느려집니다. 성능이 기대보다 낮으면 옵션을 추가하기 전에 nfsstat -m으로 실제 협상된 값부터 확인하세요.
SCP와 rsync
SCP (Secure Copy Protocol)
SCP는 SSH를 사용하여 파일을 안전하게 복사하는 명령입니다. 알아 둘 변화가 하나 있습니다. OpenSSH 9.0(2022년)부터 scp 명령은 기본적으로 옛 SCP 프로토콜이 아니라 SFTP 프로토콜로 전송합니다. 옛 SCP 프로토콜은 원격 쪽 셸이 파일명을 해석하는 구조라 파일명에 셸 메타문자가 들어가면 의도치 않은 명령이 실행될 수 있는 등 설계상 문제가 있어 OpenSSH 프로젝트가 사용 중단을 권고해 왔습니다. 대부분의 사용자는 차이를 느끼지 못하지만, SFTP 서브시스템을 꺼 둔 오래된 서버로는 scp가 실패하므로 그때만 -O로 옛 프로토콜을 강제합니다. 원격 경로의 ~ 확장이나 와일드카드 해석이 조금 달라질 수 있어 오래된 배포 스크립트는 한 번 확인해 볼 만합니다.
# 기본 사용법
scp local.txt [email protected]:/remote/path/
# 원격 → 로컬
scp [email protected]:/remote/file.txt ./local.txt
# 디렉토리 복사 (재귀)
scp -r localdir/ [email protected]:/remote/path/
# 포트 지정
scp -P 2222 file.txt [email protected]:/path/
# 공개키 인증
scp -i ~/.ssh/id_rsa file.txt [email protected]:/path/
# 대역폭 제한 (KB/s)
scp -l 1024 large.zip [email protected]:/path/
# 압축 전송
scp -C large.tar [email protected]:/path/
# 연결 문제 디버깅 (진행률은 터미널에서 기본으로 표시됨, -v는 상세 로그)
scp -v file.txt [email protected]:/path/
# 옛 SCP 프로토콜 강제 (SFTP가 없는 구형 서버)
scp -O file.txt user@legacy-host:/path/
# 여러 파일
scp file1.txt file2.txt [email protected]:/path/
# 와일드카드
scp *.log [email protected]:/logs/
rsync (Remote Sync)
rsync는 효율적인 파일 동기화 도구로, 변경된 부분만 전송합니다.
# 기본 사용법
rsync -avz localdir/ [email protected]:/remote/path/
# 옵션 설명:
# -a: archive (권한, 타임스탬프, 심볼릭 링크 유지)
# -v: verbose (진행 상황 표시)
# -z: compress (압축 전송)
# -h: human-readable (사람이 읽기 쉬운 크기)
# -P: progress + partial (진행률 + 중단된 전송 재개)
# --delete: 원본에 없는 파일 삭제 (동기화)
# 진행률 표시
rsync -avzP localdir/ [email protected]:/remote/path/
# 삭제 동기화
rsync -avz --delete localdir/ [email protected]:/remote/path/
# Dry-run (실제 전송 없이 테스트)
rsync -avzn localdir/ [email protected]:/remote/path/
# 특정 파일 제외
rsync -avz --exclude='*.log' --exclude='node_modules/' localdir/ [email protected]:/remote/
# SSH 포트 지정
rsync -avz -e "ssh -p 2222" localdir/ [email protected]:/remote/
# 대역폭 제한 (KB/s)
rsync -avz --bwlimit=1024 localdir/ [email protected]:/remote/
# 로컬 동기화
rsync -avz /source/ /destination/
# 백업 (타임스탬프 디렉토리)
rsync -avz --backup --backup-dir=/backup/$(date +%Y%m%d) /source/ /destination/
rsync에서 가장 흔하고 가장 비싼 실수는 소스 경로 끝의 슬래시입니다. rsync -a src/ dest/는 src 안의 내용을 dest에 넣고, rsync -a src dest/는 dest/src라는 디렉터리를 만들어 넣습니다. 이 차이를 --delete와 섞으면 사고가 됩니다. 슬래시 하나를 빠뜨려 백업 대상 디렉터리의 기존 파일이 전부 “원본에 없는 파일”로 판정되어 지워지는 경우를 실제로 봤고, 그래서 저는 --delete가 들어간 명령은 항상 -n(dry-run)으로 먼저 돌려 삭제 목록(deleting ...)을 눈으로 확인한 뒤 실행합니다. 스크립트라면 --delete 대신 --delete-after와 --max-delete=N을 걸어 예상보다 많이 지우려 하면 중단되게 하는 것도 방법입니다.
-z 압축은 이미 압축된 파일(동영상, zip, jpg)이나 빠른 LAN에서는 CPU만 쓰고 이득이 없습니다. rsync 3.2 이상이면 --compress-choice=zstd로 더 가벼운 압축을 고를 수 있고, 대상 경로의 상위 디렉터리가 없을 때 만들어 주는 --mkpath도 추가되었습니다. 873번 포트의 rsync 데몬 모드는 전송이 암호화되지 않으므로, 네트워크를 신뢰할 수 없다면 SSH 경유(-e ssh, 기본값)로 씁니다.
rsync 고급 사용
# 증분 백업 (하드링크 사용)
#!/bin/bash
BACKUP_DIR="/backup"
DATE=$(date +%Y%m%d_%H%M%S)
LATEST="$BACKUP_DIR/latest"
CURRENT="$BACKUP_DIR/$DATE"
rsync -avz --delete \
--link-dest="$LATEST" \
/source/ \
"$CURRENT/"
# 심볼릭 링크 업데이트
rm -f "$LATEST"
ln -s "$CURRENT" "$LATEST"
echo "✅ Backup completed: $CURRENT"
# 원격 서버 백업
#!/bin/bash
rsync -avz --delete \
-e "ssh -i ~/.ssh/backup_key" \
--exclude='*.tmp' \
--exclude='cache/' \
/var/www/ \
[email protected]:/backups/www/
# 양방향 동기화 (주의: 충돌 가능)
rsync -avz --delete /local/ user@remote:/remote/
rsync -avz --delete user@remote:/remote/ /local/
프로토콜 비교
종합 비교표
| 프로토콜 | 포트 | 암호화 | 사용 사례 | 장점 | 단점 |
|---|---|---|---|---|---|
| FTP | 21 + 데이터 포트 | 없음 | 폐쇄망 레거시 | 단순, 어디서나 지원 | 자격 증명·데이터 평문, NAT·방화벽 문제 |
| SFTP | 22 | SSH | 안전한 파일 전송 기본값 | 단일 포트, 키 인증 | 고지연 구간에서 튜닝 필요 |
| FTPS | 21(Explicit) | TLS | FTP 기반 시스템 보안화 | 기존 FTP 워크플로 유지 | 데이터 포트 범위·NAT·TLS 세션 재사용 문제 |
| SMB 3 | 445 | AES (3.0+) | Windows·혼합 환경 공유 | 사용자 인증, ACL, 잠금 | 445를 외부에 열 수 없음 |
| NFSv4 | 2049 | Kerberos(krb5p) 사용 시 | 리눅스 서버 간 공유 | 가볍고 투명한 마운트 | 기본 보안 모델이 호스트 신뢰 |
| SCP | 22 | SSH | 일회성 복사 | 간단 | 재개·동기화 기능 없음 |
| rsync | 22(SSH), 873(데몬) | SSH 경유 시 | 동기화, 백업 | 변경분만 전송, 재개 | 슬래시·—delete 실수 위험 |
속도는 표에 넣지 않았습니다. 같은 1Gbps LAN이라면 프로토콜보다 디스크 속도, 파일 개수(작은 파일이 많으면 파일당 왕복이 지배), 왕복 지연이 결과를 좌우하고, 인터넷의 “FTP가 가장 빠르다”류 순위는 조건을 알 수 없는 경우가 대부분입니다.
보안 비교
flowchart TB
subgraph Insecure[🔓 암호화 없음]
FTP["FTP\n평문 전송"]
NFSv3["NFSv3\n기본 암호화 없음"]
end
subgraph Secure[🔒 암호화 지원]
SFTP["SFTP\nSSH 암호화"]
FTPS["FTPS\nTLS 암호화"]
SMB3["SMB 3.0+\nAES 암호화"]
NFSv4["NFSv4\nKerberos"]
SCP["SCP\nSSH 암호화"]
RSYNC["rsync over SSH\nSSH 암호화"]
end
style Insecure fill:#fcc
style Secure fill:#cfc
직접 재 보기
자기 환경에서 비교하고 싶다면 아래처럼 같은 파일을 각 방식으로 보내 보면 됩니다. 결과는 네트워크·디스크·CPU에 따라 크게 달라지므로 숫자는 직접 확인하세요. 무작위 데이터는 압축되지 않으니 rsync는 -z 없이 재는 것이 공정합니다. 작은 파일 수천 개를 옮기는 경우도 따로 재 보면, 큰 파일 하나와는 순위가 완전히 달라지는 것을 볼 수 있습니다.
#!/bin/bash
# 1GB 파일 전송 비교
FILE="test_1gb.bin"
dd if=/dev/urandom of=$FILE bs=1M count=1024
echo "=== FTP ==="
time ftp -n <<EOF
open ftp.example.com
user username password
binary
put $FILE
bye
EOF
echo "=== SFTP ==="
time sftp [email protected] <<EOF
put $FILE
bye
EOF
echo "=== SCP ==="
time scp $FILE [email protected]:/tmp/
echo "=== rsync ==="
time rsync -a $FILE [email protected]:/tmp/
echo "=== SMB ==="
time cp $FILE /mnt/smb/
echo "=== NFS ==="
time cp $FILE /mnt/nfs/
실전 구현
시나리오 1: 자동 백업 스크립트
#!/bin/bash
# backup.sh - 일일 백업 스크립트
BACKUP_DATE=$(date +%Y%m%d)
SOURCE="/var/www"
REMOTE_HOST="backup.example.com"
REMOTE_USER="backup"
REMOTE_PATH="/backups/www"
LOG_FILE="/var/log/backup.log"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
log "Starting backup..."
# rsync로 증분 백업
rsync -avz --delete \
--exclude='*.tmp' \
--exclude='cache/' \
--exclude='node_modules/' \
--backup --backup-dir="$REMOTE_PATH/deleted_$BACKUP_DATE" \
-e "ssh -i /root/.ssh/backup_key -o StrictHostKeyChecking=accept-new" \
"$SOURCE/" \
"$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/current/" \
2>&1 | tee -a "$LOG_FILE"
# StrictHostKeyChecking=no는 호스트 키가 바뀌어도(중간자 공격 포함) 그냥 접속하므로 쓰지 않음.
# accept-new는 처음 보는 호스트만 등록하고, 이미 등록된 키가 바뀌면 거부함.
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log "✅ Backup completed successfully"
# 원격 서버에서 7일 이상 된 백업 삭제
ssh -i /root/.ssh/backup_key "$REMOTE_USER@$REMOTE_HOST" \
"find $REMOTE_PATH/deleted_* -mtime +7 -delete"
log "✅ Old backups cleaned"
else
log "❌ Backup failed"
exit 1
fi
# crontab 등록
crontab -e
# 매일 새벽 2시 백업
0 2 * * * /usr/local/bin/backup.sh
시나리오 2: 파일 업로드 서버
#!/usr/bin/env python3
"""
멀티 프로토콜 파일 업로드 서버
"""
import paramiko
from ftplib import FTP_TLS
import os
from pathlib import Path
class FileUploadServer:
def __init__(self, protocol='sftp', host='localhost', username='user', password='pass'):
self.protocol = protocol
self.host = host
self.username = username
self.password = password
self.client = None
if protocol == 'sftp':
self._connect_sftp()
elif protocol == 'ftps':
self._connect_ftps()
else:
raise ValueError(f"Unsupported protocol: {protocol}")
def _connect_sftp(self):
"""SFTP 연결"""
ssh = paramiko.SSHClient()
ssh.load_system_host_keys()
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
ssh.connect(self.host, 22, self.username, self.password)
self.client = ssh.open_sftp()
print(f"✅ SFTP connected to {self.host}")
def _connect_ftps(self):
"""FTPS 연결"""
self.client = FTP_TLS()
self.client.connect(self.host, 21)
self.client.login(self.username, self.password)
self.client.prot_p()
print(f"✅ FTPS connected to {self.host}")
def upload_with_progress(self, local_path, remote_path):
"""진행률 표시 업로드"""
file_size = os.path.getsize(local_path)
uploaded = 0
def callback(data):
nonlocal uploaded
uploaded += len(data)
progress = (uploaded / file_size) * 100
print(f"\rUploading: {progress:.1f}%", end='')
with open(local_path, 'rb') as f:
if self.protocol == 'sftp':
# paramiko의 콜백은 (전송된 누적 바이트, 전체 바이트)를 받음
self.client.putfo(
f, remote_path,
callback=lambda done, total: print(f"\rUploading: {done / total * 100:.1f}%", end=''),
)
elif self.protocol == 'ftps':
self.client.storbinary(f'STOR {remote_path}', f, callback=callback)
print(f"\n✅ Uploaded: {local_path} → {remote_path}")
def upload_directory(self, local_dir, remote_dir):
"""디렉토리 재귀 업로드"""
for root, dirs, files in os.walk(local_dir):
rel_path = os.path.relpath(root, local_dir)
remote_root = os.path.join(remote_dir, rel_path).replace('\\', '/')
# 디렉토리 생성
try:
if self.protocol == 'sftp':
self.client.mkdir(remote_root)
elif self.protocol == 'ftps':
self.client.mkd(remote_root)
except:
pass
# 파일 업로드
for file in files:
local_path = os.path.join(root, file)
remote_path = os.path.join(remote_root, file).replace('\\', '/')
self.upload_with_progress(local_path, remote_path)
def close(self):
"""연결 종료"""
if self.client:
self.client.close()
print("✅ Connection closed")
# 사용 예시
uploader = FileUploadServer('sftp', 'example.com', 'user', 'pass')
uploader.upload_with_progress('large_file.zip', '/uploads/large_file.zip')
uploader.upload_directory('./project', '/uploads/project')
uploader.close()
시나리오 3: 파일 동기화 데몬
#!/usr/bin/env python3
"""
파일 변경 감지 및 자동 동기화
"""
import time
import subprocess
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class SyncHandler(FileSystemEventHandler):
def __init__(self, local_dir, remote_host, remote_user, remote_dir):
self.local_dir = local_dir
self.remote_host = remote_host
self.remote_user = remote_user
self.remote_dir = remote_dir
self.last_sync = 0
self.sync_interval = 5
def on_any_event(self, event):
"""파일 변경 감지"""
current_time = time.time()
if current_time - self.last_sync < self.sync_interval:
return
if event.is_directory:
return
print(f"📁 File changed: {event.src_path}")
self.sync()
self.last_sync = current_time
def sync(self):
"""rsync로 동기화"""
cmd = [
'rsync',
'-avz',
'--delete',
'--exclude=.git/',
'--exclude=node_modules/',
f'{self.local_dir}/',
f'{self.remote_user}@{self.remote_host}:{self.remote_dir}/'
]
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print("✅ Sync completed")
else:
print(f"❌ Sync failed: {result.stderr}")
except Exception as e:
print(f"❌ Error: {e}")
def main():
"""파일 감시 시작"""
local_dir = '/var/www/html'
remote_host = 'backup.example.com'
remote_user = 'backup'
remote_dir = '/backups/www'
event_handler = SyncHandler(local_dir, remote_host, remote_user, remote_dir)
observer = Observer()
observer.schedule(event_handler, local_dir, recursive=True)
observer.start()
print(f"👀 Watching {local_dir} for changes...")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
print("\n✅ Stopped watching")
observer.join()
if __name__ == "__main__":
main()
실전 시나리오
시나리오 4: Docker 컨테이너에서 SFTP 서버
# Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
openssh-server \
&& rm -rf /var/lib/apt/lists/*
# SFTP 전용 사용자 생성
RUN useradd -m -d /home/sftpuser -s /bin/bash sftpuser && \
echo "sftpuser:password" | chpasswd && \
mkdir -p /home/sftpuser/uploads && \
chown root:root /home/sftpuser && \
chmod 755 /home/sftpuser && \
chown sftpuser:sftpuser /home/sftpuser/uploads
# SSH 설정
RUN mkdir /var/run/sshd && \
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin no/' /etc/ssh/sshd_config
# SFTP 전용 설정 추가
RUN echo "\n\
Match User sftpuser\n\
ChrootDirectory /home/sftpuser\n\
ForceCommand internal-sftp\n\
AllowTcpForwarding no\n\
X11Forwarding no\n\
" >> /etc/ssh/sshd_config
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
# compose.yaml
services:
sftp:
build: .
ports:
- "2222:22"
volumes:
- ./uploads:/home/sftpuser/uploads
- ./ssh_host_keys:/etc/ssh/keys # 호스트 키를 보존하지 않으면 재빌드마다 키가 바뀌어 클라이언트가 경고를 냄
restart: unless-stopped
컨테이너로 SFTP를 띄울 때 놓치기 쉬운 것이 호스트 키입니다. 이미지 빌드 때 생성된 호스트 키는 이미지를 다시 빌드할 때마다 바뀌어서, 클라이언트는 매번 REMOTE HOST IDENTIFICATION HAS CHANGED 경고를 받고 자동화 스크립트는 멈춥니다. 키를 볼륨에 두고 sshd_config의 HostKey가 그 경로를 가리키게 하세요. 또 이 예제의 비밀번호 인증은 테스트용이고, 실제로는 authorized_keys를 마운트해 키 인증만 허용하는 것이 맞습니다.
# 빌드 및 실행
docker-compose up -d
# 연결 테스트
sftp -P 2222 sftpuser@localhost
시나리오 5: FTPS + HTTP 다운로드 서버
SFTP 서버는 파이썬으로 직접 구현하기보다 OpenSSH의 internal-sftp(위 설정)를 쓰는 것이 안전하므로, 여기서는 FTPS 업로드와 HTTP 다운로드만 묶었습니다.
#!/usr/bin/env python3
"""
FTPS 업로드 + HTTP 다운로드 서버
"""
import asyncio
import os
import threading
from aiohttp import web
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import TLS_FTPHandler
from pyftpdlib.servers import FTPServer
class MultiProtocolFileServer:
def __init__(self, base_dir='/srv/files'):
self.base_dir = base_dir
def start_ftps_server(self):
"""FTPS 서버 시작"""
authorizer = DummyAuthorizer()
authorizer.add_user("user", "pass", self.base_dir, perm="elradfmw")
handler = TLS_FTPHandler
handler.authorizer = authorizer
handler.certfile = '/etc/ssl/certs/server.crt'
handler.keyfile = '/etc/ssl/private/server.key'
handler.tls_control_required = True
handler.tls_data_required = True
handler.passive_ports = range(60000, 60100)
# NAT 뒤라면 공인 IP 지정: handler.masquerade_address = '203.0.113.1'
server = FTPServer(('0.0.0.0', 21), handler) # Explicit FTPS (AUTH TLS)
print("✅ FTPS Server running on port 21")
server.serve_forever()
async def start_http_server(self):
"""HTTP 파일 다운로드 서버"""
async def download(request):
filename = request.match_info['filename']
filepath = os.path.realpath(os.path.join(self.base_dir, filename))
# 경로 탈출(../) 방지: base_dir 밖의 파일은 거부
if not filepath.startswith(os.path.realpath(self.base_dir) + os.sep):
return web.Response(status=403, text="Forbidden")
if not os.path.isfile(filepath):
return web.Response(status=404, text="File not found")
return web.FileResponse(filepath)
app = web.Application()
app.router.add_get('/download/{filename}', download)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '0.0.0.0', 8080)
await site.start()
print("✅ HTTP Server running on port 8080")
await asyncio.Event().wait()
def start(self):
"""모든 서버 시작"""
# FTPS 서버 (별도 스레드)
ftps_thread = threading.Thread(target=self.start_ftps_server, daemon=True)
ftps_thread.start()
# HTTP 서버 (asyncio)
asyncio.run(self.start_http_server())
if __name__ == "__main__":
server = MultiProtocolFileServer('/srv/files')
server.start()
시나리오 6: 파일 전송 모니터링
#!/usr/bin/env python3
"""
파일 전송 모니터링 및 알림
"""
import paramiko
import time
from datetime import datetime
import smtplib
from email.mime.text import MIMEText
class TransferMonitor:
def __init__(self, sftp_host, sftp_user, sftp_pass, watch_dir='/uploads'):
self.sftp_host = sftp_host
self.sftp_user = sftp_user
self.sftp_pass = sftp_pass
self.watch_dir = watch_dir
self.known_files = set()
def connect(self):
"""SFTP 연결"""
ssh = paramiko.SSHClient()
ssh.load_system_host_keys()
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
ssh.connect(self.sftp_host, 22, self.sftp_user, self.sftp_pass)
self.sftp = ssh.open_sftp()
def check_new_files(self):
"""새 파일 확인"""
try:
files = set(self.sftp.listdir(self.watch_dir))
new_files = files - self.known_files
if new_files:
for file in new_files:
file_path = f"{self.watch_dir}/{file}"
stat = self.sftp.stat(file_path)
size = stat.st_size
mtime = datetime.fromtimestamp(stat.st_mtime)
print(f"📥 New file: {file} ({size} bytes) at {mtime}")
self.send_notification(file, size, mtime)
self.known_files = files
except Exception as e:
print(f"❌ Error: {e}")
def send_notification(self, filename, size, mtime):
"""이메일 알림"""
msg = MIMEText(f"""
새 파일이 업로드되었습니다.
파일명: {filename}
크기: {size:,} bytes
시간: {mtime}
경로: {self.watch_dir}/{filename}
""")
msg['Subject'] = f'[파일 업로드] {filename}'
msg['From'] = '[email protected]'
msg['To'] = '[email protected]'
try:
with smtplib.SMTP('localhost', 25) as smtp:
smtp.send_message(msg)
print(f"✅ Notification sent for {filename}")
except Exception as e:
print(f"❌ Failed to send notification: {e}")
def monitor(self, interval=60):
"""주기적 모니터링"""
self.connect()
print(f"👀 Monitoring {self.watch_dir} every {interval}s...")
try:
while True:
self.check_new_files()
time.sleep(interval)
except KeyboardInterrupt:
print("\n✅ Monitoring stopped")
finally:
self.sftp.close()
# 사용
monitor = TransferMonitor('sftp.example.com', 'user', 'pass')
monitor.monitor(interval=60)
보안 베스트 프랙티스
공개키 인증 설정
# 클라이언트에서 키 생성
ssh-keygen -t ed25519 -C "[email protected]"
# 또는 RSA
ssh-keygen -t rsa -b 4096 -C "[email protected]"
# 공개키를 서버에 복사
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
# 또는 수동 복사
cat ~/.ssh/id_ed25519.pub | ssh [email protected] "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
# 서버에서 비밀번호 인증 비활성화
# /etc/ssh/sshd_config
PasswordAuthentication no
PubkeyAuthentication yes
# 적용 전 문법 검사. 키 로그인이 되는 것을 다른 세션에서 확인한 뒤 끊을 것
sudo sshd -t && sudo systemctl restart ssh
Chroot Jail 설정
# SFTP 사용자를 특정 디렉토리에 격리
# /etc/ssh/sshd_config
Match User sftpuser
ChrootDirectory /home/sftpuser
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
PermitTunnel no
# 디렉토리 권한 (중요!)
sudo chown root:root /home/sftpuser
sudo chmod 755 /home/sftpuser
# 쓰기 가능한 하위 디렉토리
sudo mkdir /home/sftpuser/uploads
sudo chown sftpuser:sftpuser /home/sftpuser/uploads
sudo chmod 755 /home/sftpuser/uploads
방화벽 설정
# UFW (Ubuntu)
# SFTP (SSH)
sudo ufw allow 22/tcp
# FTPS (Explicit)
sudo ufw allow 21/tcp
sudo ufw allow 60000:60100/tcp
# FTPS (Implicit)
sudo ufw allow 990/tcp
# SMB (내부망만)
sudo ufw allow from 192.168.1.0/24 to any port 445 proto tcp
# NFS
sudo ufw allow 2049/tcp
# 특정 IP만 허용
sudo ufw allow from 192.168.1.0/24 to any port 22
성능 최적화
대용량 파일 전송
# rsync 최적화
rsync -avz \
--compress-level=1 \
--partial \
--progress \
--bwlimit=10000 \
--timeout=300 \
large_file.zip [email protected]:/path/
# SCP 압축 비활성화 (이미 압축된 파일)
scp -o "Compression no" archive.zip [email protected]:/path/
# 병렬 전송 (GNU Parallel)
find . -type f | parallel -j 4 scp {} [email protected]:/path/{}
네트워크 튜닝
# TCP 윈도우 크기 증가
sudo sysctl -w net.ipv4.tcp_window_scaling=1
sudo sysctl -w net.core.rmem_max=134217728
sudo sysctl -w net.core.wmem_max=134217728
sudo sysctl -w net.ipv4.tcp_rmem="4096 87380 67108864"
sudo sysctl -w net.ipv4.tcp_wmem="4096 65536 67108864"
# /etc/sysctl.conf에 영구 적용
net.ipv4.tcp_window_scaling = 1
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
SMB 성능 최적화
인터넷에서 “Samba 속도 올리는 설정”으로 돌아다니는 socket options = ... SO_RCVBUF=... SO_SNDBUF=..., read raw, write raw, max xmit 같은 옵션은 대부분 SMB1 시절 것이고, 지금은 효과가 없거나 오히려 해롭습니다. read raw/write raw/max xmit은 SMB1에서만 의미가 있고, 소켓 버퍼 크기를 고정하면 리눅스 커널의 TCP 자동 튜닝이 꺼져서 빠른 네트워크에서 속도가 떨어집니다. Samba 문서도 socket options는 건드리지 말라고 안내합니다. 저도 예전에 복사해 둔 튜닝 설정을 지우는 것만으로 속도가 올라간 경험이 있어서, 느리다면 설정을 더하기 전에 먼저 빼 보기를 권합니다.
현대 Samba(4.15 이상)에서 의미 있는 것은 이 정도입니다.
# /etc/samba/smb.conf
[global]
# SMB3 멀티채널: 4.15부터 기본 활성화. NIC가 여러 개거나 RSS를 지원하면 처리량 향상
server multi channel support = yes
# 암호화가 필요 없는 내부망이라면 desired/off로 CPU 부담을 줄일 수 있음 (보안 요구와 저울질)
server smb encrypt = desired
실제 병목은 대개 디스크, 작은 파일의 메타데이터 처리, 클라이언트 쪽 백신 실시간 검사, 그리고 여전히 SMB1로 붙고 있는 클라이언트입니다. smbstatus로 접속 중인 클라이언트의 프로토콜 버전을 확인하고, 파일 하나를 복사할 때와 작은 파일 수천 개를 복사할 때의 속도를 따로 재 보면 병목이 네트워크인지 메타데이터인지 구분됩니다.
문제 해결
문제 1: FTP Passive 모드 연결 실패
# 증상
ftp> ls
425 Failed to establish connection
# 원인: 방화벽이 데이터 포트 차단
# 해결: 서버에서 Passive 포트 범위 설정
# /etc/vsftpd.conf
pasv_enable=YES
pasv_min_port=60000
pasv_max_port=60100
# 방화벽 허용
sudo ufw allow 60000:60100/tcp
# 재시작
sudo systemctl restart vsftpd
문제 2: SFTP 권한 거부
# 증상
sftp> put file.txt
Couldn't open file "/file.txt": Permission denied
# 원인: Chroot 디렉토리 권한 문제
# 해결: 상위 디렉토리는 root 소유, 하위는 사용자 소유
sudo chown root:root /home/sftpuser
sudo chmod 755 /home/sftpuser
sudo chown sftpuser:sftpuser /home/sftpuser/uploads
sudo chmod 755 /home/sftpuser/uploads
문제 3: SMB 느린 전송 속도
# 증상: 10MB/s 이하의 느린 속도
# 원인 1: SMB 1.0으로 접속 중 (smbstatus로 프로토콜 확인)
# 해결: SMB2 이상 강제
# /etc/samba/smb.conf
server min protocol = SMB2_10
# 원인 2: 예전에 넣어 둔 socket options / SO_RCVBUF 고정값
# 해결: 삭제하고 커널 TCP 자동 튜닝에 맡김
# 원인 3: 디스크·작은 파일 메타데이터 병목
# 해결: 큰 파일 하나와 작은 파일 다수를 따로 재서 구분
# 참고: 점보 프레임(MTU 9000)은 스위치·NIC·상대편 모두 지원할 때만 효과가 있고,
# 한쪽만 바꾸면 오히려 연결이 끊기거나 느려짐
문제 4: NFS Stale File Handle
# 증상
ls: cannot access '/mnt/nfs': Stale file handle
# 원인: NFS 서버 재시작 또는 네트워크 끊김
# 해결 1: 언마운트 후 재마운트
sudo umount -f /mnt/nfs
sudo mount /mnt/nfs
# 해결 2: 강제 언마운트
sudo umount -l /mnt/nfs
# 원인이 서버 쪽 export 경로 변경·파일 시스템 재생성인 경우가 많음
# (서버의 파일 핸들이 가리키던 inode가 사라짐). 서버의 exportfs 설정을 바꿀 때 클라이언트 재마운트 계획을 같이 세울 것
# /etc/fstab
192.168.1.100:/srv/nfs /mnt/nfs nfs hard,vers=4.2,timeo=600 0 0
프로토콜 선택 가이드
의사 결정 플로우차트
flowchart TD
Start[파일 전송 프로토콜 선택] --> Q1{용도는?}
Q1 -->|파일 전송| Q2{보안 필요?}
Q1 -->|파일 공유| Q3{OS는?}
Q1 -->|백업/동기화| RSYNC["✅ rsync\n증분 전송"]
Q2 -->|Yes| Q4{기존 인프라는?}
Q2 -->|No| FTP["⚠️ FTP\n레거시만"]
Q4 -->|SSH 있음| SFTP["✅ SFTP\n가장 안전"]
Q4 -->|FTP 있음| FTPS["✅ FTPS\nTLS 추가"]
Q4 -->|새로 구축| SFTP
Q3 -->|Windows| SMB["✅ SMB/CIFS\n네트워크 드라이브"]
Q3 -->|Linux| NFS["✅ NFS\n고성능"]
Q3 -->|혼합| SMB2["✅ SMB\n크로스 플랫폼"]
사용 사례별 추천
✅ 웹 호스팅 파일 업로드:
→ SFTP (보안) 또는 rsync (자동화)
✅ 팀 파일 공유:
→ SMB (Windows) 또는 NFS (Linux)
✅ 서버 간 백업:
→ rsync over SSH (증분 백업)
✅ 레거시 시스템 연동:
→ FTPS (FTP 호환 + 보안)
✅ 대용량 미디어 파일:
→ NFS (성능) 또는 SMB 3.0 (멀티채널)
✅ 간단한 파일 복사:
→ SCP (한 번만 전송)
✅ CI/CD 파이프라인:
→ rsync 또는 SFTP (자동화)
✅ 클라우드 스토리지 동기화:
→ rclone (S3, GCS, Azure 지원)
고급 주제
rclone (클라우드 스토리지)
# rclone 설치
curl https://rclone.org/install.sh | sudo bash
# 설정
rclone config
# S3 동기화
rclone sync /local/path s3:bucket-name/path
# Google Drive
rclone sync /local/path gdrive:folder
# 양방향 동기화 (주의: 충돌 가능)
rclone bisync /local/path remote:path
# 암호화 래퍼
rclone sync /local/path crypt:encrypted-remote
SSHFS (SSH File System)
# SSHFS 설치
sudo apt install sshfs
# 마운트
sshfs [email protected]:/remote/path /mnt/sshfs
# 언마운트
fusermount -u /mnt/sshfs
# 옵션
sshfs -o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
[email protected]:/remote /mnt/sshfs
WebDAV
# WebDAV 서버 (Nginx)
location /webdav {
root /srv/webdav;
client_body_temp_path /tmp;
dav_methods PUT DELETE MKCOL COPY MOVE;
dav_ext_methods PROPFIND OPTIONS;
dav_access user:rw group:rw all:r;
auth_basic "WebDAV";
auth_basic_user_file /etc/nginx/.htpasswd;
create_full_put_path on;
client_max_body_size 0;
}
# 클라이언트 (Linux)
sudo apt install davfs2
sudo mount -t davfs https://example.com/webdav /mnt/webdav
자주 묻는 질문 (FAQ)
Q. 외부 업체와 대용량 파일을 주고받아야 하는데 무엇을 열어 줘야 하나요?
A. 가장 무난한 것은 SFTP 전용 계정입니다. 22번(또는 다른 포트) 하나만 열면 되고, 업체별 계정을 ChrootDirectory로 격리하고 키 인증만 허용하면 됩니다. 상대가 FTP 클라이언트만 쓸 수 있다고 하면 Explicit FTPS를 열되 Passive 포트 범위와 공인 IP 설정을 같이 해야 합니다. SMB나 NFS를 외부에 여는 것은 피하고, 상대가 브라우저만 쓸 수 있다면 만료 기간이 있는 S3 presigned URL 같은 HTTP 기반 공유가 더 간단합니다.
참고 자료
- RFC 959 - FTP
- RFC 4253 - SSH Protocol
- SMB Protocol Documentation
- NFS RFC 7530
- Samba Documentation
- rsync Manual 보안이 중요하면 SFTP, Windows 공유는 SMB, Linux 서버는 NFS, 백업은 rsync를 사용하되, 각 프로토콜의 특성을 이해하고 요구사항에 맞게 선택하세요.
같이 보면 좋은 글
- HTTP vs FTP vs SSH 프로토콜 비교
- FTP 프로토콜 실전: Active·Passive·FTPS·SFTP 운영