왜 굳이 포트 스캐너를 직접 작성해야 할까요?
포트 스캐너를 작성하는 것은 TCP 프로토콜의 기본 원리를 배우는 훌륭한 방법입니다. TCP는 HTTP, SSH를 비롯한 대부분의 인터넷 프로토콜이 사용하는 전송 계층(transport layer)입니다.
또한 Ruby로 네트워크 프로그래밍이 어떻게 동작하는지 깊이 이해할 수 있는 좋은 연습 과제이기도 합니다.
그럼 포트가 무엇인지부터 차근차근 살펴보겠습니다!
포트란 무엇일까?
포트라고 하면 무엇을 떠올려야 할까요? 운영체제(OS) 수준에서 포트는 사실 특정 프로세스와 연결된 하나의 "파일 디스크립터(file descriptor)"에 불과합니다.
파일 디스크립터란 열려 있는 입출력(I/O) 채널을 참조하기 위해 사용하는 단순한 숫자입니다. 표준 출력(stdout), 네트워크 소켓, 파일 등이 모두 파일 디스크립터로 관리됩니다.
운영체제가 TCP/IP 패킷을 수신하면 먼저 목적지 포트를 확인한 뒤, 해당 포트에서 대기(listening) 중인 프로세스를 찾습니다.
대기 중인 프로세스가 존재한다면, 패킷은 그 프로세스로 전달됩니다.
포트 범위
사용할 수 있는 포트는 총 65,535개이지만, 실제로 모든 포트가 자주 사용되는 것은 아닙니다.
포트는 크게 세 가지 그룹으로 나눌 수 있습니다:
| 범위 | 이름 | 예시 |
|---|---|---|
1-1023 |
잘 알려진 포트(Well-known ports) | 22 (SSH), 80 (HTTP), 443 (HTTPS) |
1024-49151 |
등록된 포트(Registered ports) | 3306 (MySQL), 5432 (PostgreSQL) |
49152-65535 |
임시 포트(Ephemeral ports) | Chrome, Firefox |
두 번째 범위의 포트는 IANA(Internet Assigned Numbers Authority, 국제 인터넷 할당 번호 기구)가 관리·할당합니다.
세 번째 범위는 "동적(dynamic)" 또는 "임시(ephemeral)" 포트로 불리며, 클라이언트 측이 서버로부터 데이터를 수신할 때 사용하는 포트입니다.
TCP 통신의 기본 원리
이제 포트에 대해서는 잘 이해했지만, 아직 포트 스캐너를 작성할 준비는 끝나지 않았습니다.
먼저 "포트가 열려 있다"는 것이 정확히 무엇을 의미하는지 알아야 하고, 열린 포트와 닫힌 포트가 네트워크 수준에서 각각 어떻게 동작하는지 살펴봄으로써 두 상태를 구별할 수 있어야 합니다.
포트가 열려 있는지 어떻게 알 수 있을까요?
열린 포트란 단순히 말하면, 반대편에서 애플리케이션이 대기(listening) 중이고 우리가 접근할 수 있는(방화벽에 차단되지 않은) 상태를 의미합니다.
그럼 새로운 TCP 연결이 어떻게 시작되는지 살펴보겠습니다.

새로운 TCP 연결은 SYN 패킷으로 시작됩니다. 이 패킷은 새로운 연결의 시작을 알리는 신호입니다.
가능한 결과는 세 가지입니다:
- 서버가
SYN/ACK로 응답 – 연결을 맺을 준비가 되었다는 의미 - 서버가
RST패킷으로 응답 – 연결 요청이 거부되었다는 의미 - 서버가 아무런 응답도 하지 않음 – 일부 방화벽이 DROP 정책으로 구현하는 방식
클라이언트가 SYN/ACK 패킷을 받으면, ACK 패킷을 보내 연결 설정을 마무리할 수 있습니다.
이 과정을 흔히 "3-way 핸드셰이크(three-way handshake)"라고 부릅니다.
참고: tcpdump나 wireshark 같은 네트워크 분석 도구를 사용하면 이 과정을 직접 눈으로 확인할 수 있습니다. 이 도구들을 활용하면 시스템에 들어오고 나가는 모든 패킷을 볼 수 있습니다.
다음은 tshark(wireshark의 명령줄 인터페이스)로 캡처한 실제 연결 예시입니다:

이제 TCP 연결이 어떻게 성립되는지 기본적인 이해를 갖추었으니, 간단한 포트 스캐너를 작성해 보겠습니다.
포트 스캐너 작성하기
가장 간단한 방법은 TCPSocket으로 새로운 TCP 연결을 시도하고, 거부된 연결에서 발생하는 Errno::ECONNREFUSED 예외를 활용하는 것입니다.
코드는 다음과 같습니다:
require 'socket'
PORT = ARGV[0] || 22
HOST = ARGV[1] || 'localhost'
begin
socket = TCPSocket.new(HOST, PORT)
status = "open"
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
status = "closed"
end
puts "Port #{PORT} is #{status}."
하지만 이 코드에는 몇 가지 한계가 있습니다.
첫째, 한 번에 하나의 포트만 검사할 수 있습니다. 실제로는 특정 호스트에서 어떤 서비스가 노출되어 있는지 파악하기 위해 여러 포트를 한꺼번에 검사하고 싶을 때가 많습니다.
둘째, 응답하지 않는 포트(앞서 언급한 세 번째 경우)를 만나면 연결이 타임아웃될 때까지 약 20초를 기다려야 합니다.
이 문제는 connect_nonblock, IO.select, 그리고 Socket(객체 생성 즉시 연결을 시도하는 TCPSocket 대신)을 조합하면 해결할 수 있습니다.
예시 코드:
require 'socket'
TIMEOUT = 2
def scan_port(port)
socket = Socket.new(:INET, :STREAM)
remote_addr = Socket.sockaddr_in(port, 'www.example.com')
begin
socket.connect_nonblock(remote_addr)
rescue Errno::EINPROGRESS
end
_, sockets, _ = IO.select(nil, [socket], nil, TIMEOUT)
if sockets
p "Port #{port} is open"
else
# Port is closed
end
end
PORT_LIST = [21,22,23,25,53,80,443,3306,8080]
threads = []
PORT_LIST.each { |i| threads << Thread.new { scan_port(i) } }
threads.each(&:join)
IO.select 호출은 소켓이 데이터를 받을 준비가 될 때까지(즉, 포트가 열려 있을 때까지), 또는 TIMEOUT 시간이 지날 때까지 대기합니다. 타임아웃이 발생하면 해당 포트가 닫혀 있거나 연결 요청을 무시하고 있다고 판단할 수 있습니다.
학습용 예제로는 충분하지만, 실무에서 제대로 된 포트 스캐너가 필요하다면 nmap 같은 전문 도구를 사용하는 것이 좋습니다.
Nmap은 오픈소스 프로젝트로, 15년 넘게 꾸준히 개발되어 왔습니다.
nmap 실행 결과는 다음과 같습니다:

내용이 많았는데요, 기억에 오래 남도록 이 글에서 배운 새로운 내용 두 가지를 댓글로 남겨주세요 🙂
정리
이 글에서는 포트가 무엇인지, 포트의 종류와 범위, TCP 연결이 시작되는 과정(3-way 핸드셰이크), 그리고 Ruby로 기본적인 포트 스캐너를 작성하는 방법까지 배웠습니다.
글이 유익했다면 주변에 공유해서 더 많은 사람들이 함께 볼 수 있도록 해주세요 🙂