Step 77. 소켓 통신 1 — TCP echo 서버와 클라이언트

Step 77. 소켓 통신 1 — TCP echo 서버와 클라이언트

Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 73의 HTTP 개념을 안다. 파이썬 함수와 while 반복문을 쓸 수 있다. 터미널 두 개를 동시에 띄울 수 있다.

  • 준비물: 파이썬, 터미널 두 개. 리눅스/WSL이 있다면 3-5의 netcat 실험까지 할 수 있습니다(없어도 본문 진행에는 지장 없습니다).
  • 주의: 오늘 실험은 전부 내 컴퓨터 안(127.0.0.1)에서만 일어나는 안전한 실습입니다. 다만 오늘 배우는 코드가 공격 기술의 뿌리라는 사실은 7번 섹션에서 정직하게 짚고 갑니다.

지금까지 우리가 만든 프로그램은 혼자 일했습니다. 그런데 컴퓨터의 진짜 힘은 연결에서 나옵니다 — 웹, 게임, 메신저 전부 "두 프로그램이 네트워크로 대화하는 것"입니다. 그 대화의 전화기가 소켓(socket)입니다. 지난 챕터들의 requests가 편리한 리모컨이라면, 소켓은 리모컨 안의 회로 기판입니다. 오늘은 그 기판을 직접 만져, 가장 단순한 네트워크 프로그램인 echo(메아리) 서버를 만듭니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 소켓이 "프로그램과 프로그램 사이의 통신 끝점"임을 코드로 설명한다
  • 서버의 네 단계(socket/bind/listen/accept)와 클라이언트의 connect를 순서대로 쓴다
  • sendall/recv로 바이트를 주고받고, encode/decode가 필요한 이유를 말한다
  • Connection refused 오류의 의미를 알고, 그것이 포트 스캔의 원리임을 안다
  • netstat으로 "열린 포트"를 운영체제 차원에서 확인한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 표준 라이브러리 socket (설치 불필요), 터미널 두 개
오늘의 함수 socket(), bind(), listen(), accept(), connect(), sendall(), recv(), encode()/decode()
필요한 개념 IP 주소와 포트, 127.0.0.1(localhost), TCP, bytes와 str의 구분
오늘의 산출물 server.py + client.py — 메아리로 대답하는 첫 네트워크 프로그램

2-1. 주소와 포트 — 건물과 방 번호

네트워크에서 프로그램을 찾으려면 두 가지가 필요합니다. IP 주소(어느 컴퓨터인가 — 건물 주소)와 포트 번호(그 안의 어느 프로그램인가 — 방 번호)입니다. 포트는 0~65535번까지 있고, 80번(HTTP), 22번(SSH)처럼 유명한 서비스는 관례적으로 정해진 번호를 씁니다. 우리는 오늘 아무도 안 쓰는 9999번을 빌려 씁니다.

127.0.0.1은 "이 컴퓨터 자신"을 가리키는 약속된 주소로, localhost(로컬호스트)라는 이름도 붙어 있습니다. 오늘 실험의 무대입니다 — 내 컴퓨터 안에서 서버와 클라이언트가 대화하니, 어떤 외부에도 닿지 않는 완전한 랩입니다.

2-2. 서버의 네 단계, 클라이언트의 한 단계

TCP(신뢰성 있는 연결 규약) 통신에서 두 쪽의 동작은 이렇게 갈립니다.

서버(기다리는 쪽)

  1. socket() — 전화기를 만든다
  2. bind(("0.0.0.0", 9999)) — 전화기에 번호를 부여한다 ("0.0.0.0"은 "이 컴퓨터의 모든 주소에서 받겠다")
  3. listen(1) — 수신 대기 상태에 들어간다
  4. accept() — 전화가 올 때까지 멈춰서 기다리다가, 오면 통화용 전화기(conn)를 넘겨받는다

클라이언트(거는 쪽)

  1. socket()connect(("127.0.0.1", 9999)) — 그 번호로 전화를 건다

연결된 뒤에는 양쪽 모두 sendall(보내기)과 recv(받기)로 대화합니다. accept가 "기다리며 멈추는" 함수라는 점이 중요합니다. 서버 프로그램은 여기서 잠든 것처럼 멈추고, 클라이언트가 접속하는 순간 깨어납니다.

2-3. bytes와 str — 네트워크의 언어

소켓 위를 오가는 것은 글자(str)가 아니라 바이트(bytes)입니다. 보낼 때는 .encode()로 포장하고, 받을 때는 .decode()로 풉니다. Step 50에서 배운 인코딩이 여기서 실전이 됩니다. 오늘 생길 오류의 대부분이 이 포장/풀기를 깜빡한 데서 나옵니다.


3. 따라 하기

3-1. echo 서버 만들기

server.py를 만듭니다.

입력 (server.py)

import socket

s = socket.socket()
s.bind(("0.0.0.0", 9999))
s.listen(1)
print("대기 중... (클라이언트를 기다립니다)", flush=True)
conn, addr = s.accept()
print("접속:", addr, flush=True)

while True:
    data = conn.recv(1024)
    if not data:
        break
    print("받은 말:", data.decode(), flush=True)
    conn.sendall(b"echo: " + data)

conn.close()
s.close()
print("연결 종료, 서버를 마칩니다")

입력 (터미널 1)

python server.py

출력 (2026-09-09 실측):

대기 중... (클라이언트를 기다립니다)

읽는 법: 여기서 화면이 멈춘 것처럼 보이면 정상입니다. accept가 전화를 기다리는 중입니다. recv(1024)는 "최대 1024바이트 받겠다"는 뜻이고, 상대가 연결을 끊으면 빈 데이터가 와서 반복이 끝납니다. (flush=True는 출력을 즉시 화면에 밀어 넣는 장치로, 뒤처짐 없이 로그를 보기 위한 습관입니다.)

: 서버의 생애(만들고 → 번호 달고 → 기다리고 → 대화하고 → 끝내기)를 한 프로그램에 담았습니다. 이 뼈대는 앞으로 만드는 모든 서버의 조상입니다.

3-2. 클라이언트로 전화 걸기

client.py를 만듭니다.

입력 (client.py)

import socket

s = socket.socket()
s.connect(("127.0.0.1", 9999))

msg = input("> ")
s.sendall(msg.encode())
answer = s.recv(1024)
print("서버의 대답:", answer.decode())
s.close()

입력 (터미널 2 — 서버는 켜 둔 채)

python client.py
> 안녕, 서버!

출력 (2026-09-09 실측):

서버의 대답: echo: 안녕, 서버!

그리고 터미널 1의 서버 화면에는 이렇게 찍힙니다 (2026-09-09 실측):

접속: ('127.0.0.1', 52378)
받은 말: 안녕, 서버!
연결 종료, 서버를 마칩니다

읽는 법: 클라이언트가 connect하는 순간 서버의 accept가 깨어나 "접속"을 찍었고, 본낸 말이 메아리(echo:)가 되어 돌아왔습니다. 접속 정보의 52378 같은 숫자는 클라이언트 쪽의 임시 포트입니다. 대화에는 양쪽 끝에 번호가 필요하므로, 운영체제가 클라이언트에게 임의의 빈 번호를 붙여 준 것입니다 — 실행할 때마다 바뀝니다. 클라이언트가 끊자 서버도 빈 데이터를 받고 while을 빠져나와 종료했습니다.

: 방금 두 프로그램이 네트워크로 대화하게 만들었습니다. 비록 같은 컴퓨터 안이지만, 이 코드는 구조적으로 인터넷 반대편과 대화하는 코드와 완전히 같습니다. 주소만 바꾸면 됩니다.

3-3. 예측해 보기 — 클라이언트 없이 서버를 켜 두면

예측입니다. 서버만 켜 두고 클라이언트를 실행하지 않으면 서버 프로그램은 어떻게 될까요? ① 오류를 내며 종료 ② 메모리 부족으로 다운 ③ 그대로 기다림.

직접 확인 (2026-09-09 실측): 서버를 켜 둔 채 다른 작업을 하는 동안에도 "대기 중…" 그대로였습니다. accept는 무한정 기다립니다.

: "기다리는 것이 서버의 일"이라는 감각을 몸에 새기는 실험입니다. 서버는 손님이 올 때까지 잠들어 있다가, 손님이 오면 깨어나는 존재입니다. 이 "항상 켜져 있으며 기다리는" 특성이, 서버가 공격의 표적이 되는 이유이기도 합니다.

3-4. 예측해 보기 — 서버 없이 클라이언트를 먼저 켜면

반대의 실험입니다. 서버가 꺼져 있는데 클라이언트를 실행하면? 예측 후, 서버를 Ctrl+C로 끄고 client.py를 실행해 보세요.

출력 (2026-09-09 실측):

ConnectionRefusedError: [WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

(리눅스/WSL에서는 [Errno 111] Connection refused라고 영문으로 뜹니다. 같은 사건의 다른 언어입니다.)

읽는 법: "연결이 거절됐다"는 오류입니다. 전화를 걸었는데 받는 쪽 전화기가 없는 상황입니다. 운영체제가 즉시 "그 포트에는 아무도 없습니다"라고 대답한 것입니다.

: 이 거절 신호가 Step 79 포트 스캐너의 핵심 재료가 됩니다. "연결되면 열린 것, 거절되면 닫힌 것" — 포트 스캔의 원리는 이 오류 메시지에서 태어납니다.

3-5. netcat — 코드 없이 대화하기

netcat(nc)은 소켓 통신의 만능칼로, 우리가 만든 클라이언트와 같은 일을 설치만으로 해 줍니다. 리눅스/WSL 터미널이 있다면 해 보세요(윈도우만 있다면 읽고 넘어가도 됩니다).

입력 (서버는 켜 둔 채, WSL 터미널에서)

nc 127.0.0.1 9999

아무 반응이 없어 보이지만 연결된 상태입니다. 글자를 치고 엔터를 눌러 보세요.

출력 (2026-09-09 실측, "hello nc" 입력):

echo: hello nc

서버 쪽 로그에도 [받은 말: hello nc]가 찍혔습니다 (2026-09-09 실측).

읽는 법: 우리가 파이썬으로 만든 클라이언트와 nc가 서버 입장에서는 똑같은 손님입니다. 서버는 상대가 파이썬인지 nc인지 구분하지 못합니다. 소켓으로 말을 걸기만 하면 모두 같은 고객입니다.

: "프로토콜(대화 규칙)만 맞으면 도구는 무엇이든 된다"를 체감하는 단계입니다. 침투 테스터들이 nc를 사랑하는 이유이기도 합니다 — 어떤 서비스든 손으로 말을 걸어 볼 수 있으니까요.

3-6. 관찰 — 열린 포트를 다른 눈으로 보기

서버가 켜져 있는 동안, 세 번째 터미널에서 확인해 봅니다.

입력 (윈도우)

netstat -ano | findstr :9999

출력 (2026-09-09 실측):

  TCP    0.0.0.0:9999           0.0.0.0:0              LISTENING       21420

읽는 법: netstat은 소켓 목록을 보여 주는 명령입니다. 우리 서버가 9999번 포트에서 LISTENING(기다리는 중) 상태로 앉아 있고, 맨 끝 숫자 21420은 이 포트를 연 프로세스의 PID(프로세스 번호)입니다. 리눅스/WSL에서는 ss -tlnp | grep 9999로 같은 것을 봅니다.

: "프로그램이 포트를 연다"는 말이 운영체제 차원에서 어떻게 보이는지 확인하는 단계입니다. 나중에 "이 서버에 수상한 열린 포트가 있나?"를 점검하는 침해사고 조사가 바로 이 명령으로 시작합니다.


4. 미션과 연습문제

미션 — echo 서버 업그레이드

  1. 서버가 한 손님을 보낸 후 종료되지 않고, 다음 손님을 계속 받도록 바꿉니다(힌트: accept를 while 반복 안에)
  2. 서버가 받은 말에 번호를 붙여 돌려주게 합니다("1번째 메아리: …")
  3. 클라이언트도 "quit"을 입력할 때까지 계속 대화하게 바꿉니다
  4. 대화 내용 전체를 서버 쪽에서 chat_log.txt에 시간과 함께 기록하게 합니다
  5. "서버와 클라이언트의 차이는 단 하나, 기다리는가 거는가이다"를 증명하는 코드 줄을 찾아 README에 적습니다

연습문제

문제 1. 서버의 네 단계(socket/bind/listen/accept)를 각각 전화에 비유해 설명해 보세요.

문제 2. s.sendall("안녕")이 오류를 내는 이유와 고치는 법을 말해 보세요.

문제 3. 접속 정보 ('127.0.0.1', 52378)에서 두 번째 숫자는 무엇이며, 왜 실행할 때마다 바뀌나요?

문제 4. ConnectionRefusedError가 떴을 때 확인해야 할 것 두 가지를 말해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

업그레이드된 서버의 뼈대:

import socket
from datetime import datetime

s = socket.socket()
s.bind(("0.0.0.0", 9999))
s.listen(1)
print("대기 중...", flush=True)

count = 0
while True:                       # 손님을 계속 받는다
    conn, addr = s.accept()
    print("접속:", addr, flush=True)
    while True:
        data = conn.recv(1024)
        if not data:
            break
        count += 1
        text = data.decode().strip()
        with open("chat_log.txt", "a", encoding="utf-8") as fp:
            fp.write(f"{datetime.now()} {addr} {text}\n")
        conn.sendall(f"{count}번째 메아리: {text}".encode())
    conn.close()

클라이언트의 대화 반복:

import socket

s = socket.socket()
s.connect(("127.0.0.1", 9999))
while True:
    msg = input("> ")
    if msg == "quit":
        break
    s.sendall(msg.encode())
    print("서버의 대답:", s.recv(1024).decode())
s.close()

5번의 답: conn, addr = s.accept()(서버, 기다림)와 s.connect(...)(클라이언트, 걸기) 두 줄이 증명합니다. 나머지 코드 — sendall, recv, encode, decode — 는 서버와 클라이언트가 서로 같은 것을 씁니다.

검증하는 법: ① 클라이언트를 두 번 연달아 실행해도 서버가 안 죽는가. ② 메아리에 번호가 1, 2, 3… 으로 붙는가. ③ quit 입력 시 클라이언트만 끝나는가. ④ chat_log.txt에 시간·주소·내용이 쌓이는가. 넷이 전부 ‘예’이면 완성입니다.

연습문제 해답

문제 1 해답. socket()은 전화기를 사 오는 것, bind()는 그 전화기에 전화번호(포트)를 부여하는 것, listen()은 벨이 울리기를 기다리는 상태로 두는 것, accept()는 벨이 울릴 때 수화기를 드는 것입니다. accept가 돌려주는 conn은 그 손님과의 통화 전용 회선입니다.

문제 2 해답. 소켓은 바이트(bytes)만 오가는데 "안녕"은 글자(str)이기 때문입니다 (2026-09-09 실측: TypeError: a bytes-like object is required, not 'str'). s.sendall("안녕".encode())로 바이트로 포장해 보내야 합니다.

문제 3 해답. 클라이언트 쪽의 임시 포트입니다. 대화는 "누가-누구에게" 두 끝점이 필요한데, 클라이언트는 포트를 지정하지 않았으므로 운영체제가 빈 번호를 임의로 골라 붙입니다. 그래서 실행할 때마다 다른 숫자가 나옵니다.

문제 4 해답. 첫째, 서버 프로그램이 정말 켜져 있는가(netstat으로 LISTENING 확인). 둘째, 클라이언트가 건 주소와 포트가 서버가 bind한 것과 같은가 — 특히 서버가 127.0.0.1에만 묶여 있는데 외부 주소로 거는 경우입니다.

완료 기준 체크리스트

  • [ ] 서버의 네 단계(socket/bind/listen/accept)를 순서대로 말할 수 있다
  • [ ] echo 서버와 클라이언트를 만들어 대화시켰다
  • [ ] encode/decode가 필요한 이유를 설명할 수 있다
  • [ ] ConnectionRefusedError의 의미를 설명할 수 있다
  • [ ] netstat으로 LISTENING 중인 포트를 찾을 수 있다
  • [ ] 미션: 업그레이드된 서버와 대화형 클라이언트를 완성했다

6. 흔한 실수와 해결

벽 1. str과 bytes 오류가 난다

증상 (2026-09-09 실측):

TypeError: a bytes-like object is required, not 'str'

원인: 소켓은 바이트만 오갑니다. 문자열을 그대로 보냈거나, 바이트를 그대로 문자열처럼 썼습니다.
해결: 보낼 때 .encode(), 받을 때 .decode(). "포장과 풀기"로 기억하세요. 오늘 오류의 대부분이 여기서 나옵니다.

벽 2. Address already in use 오류가 난다

증상 (2026-09-09 실측):

OSError: [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다

원인: 이전에 켰던 서버가 아직 9999번을 붙들고 있습니다. Ctrl+C로 안 끝낸 창이 살아 있거나, 종료 직후라 운영체제가 포트를 정리하는 중입니다.
해결: 이전 서버를 확실히 끄거나, 잠시 기다리거나, 다른 포트로 바꾸세요. netstat으로 누가 붙들고 있는지(PID) 확인할 수 있습니다.

벽 3. recv가 아무것도 안 받고 멈춰 있다

증상: 보냈는데도 받는 쪽이 조용합니다.
원인: recv도 accept처럼 "올 때까지 기다리는" 함수입니다. 상대가 정말 보냈는지부터 의심하세요. 둘이 서로 받기만 기다리는 교착 상태는 초보의 단골 함정입니다.
해결: 양쪽 코드의 대화 순서(주고받는 순번)를 손으로 그려 보세요. 서버가 sendall하기 전에 클라이언트가 recv에 먼저 들어가 있으면 둘 다 영원히 기다립니다.

벽 4. ConnectionRefusedError가 계속 난다

증상 (2026-09-09 실측):

ConnectionRefusedError: [WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

원인: 그 포트에서 기다리는 서버가 없습니다. 서버를 안 켰거나, 포트 번호가 서로 다르거나, 서버가 먼저 종료된 경우입니다.
해결: 서버 창에 "대기 중…"이 떠 있는지 확인하고, 양쪽 코드의 포트 번호가 같은지 보세요. 서버는 반드시 먼저 켭니다.

벽 5. 다른 기기에서 접속이 안 된다

증상: 같은 공유기의 다른 컴퓨터에서 접속했는데 시간 초과가 납니다.
원인: 방화벽이 그 포트를 막고 있거나, 서버에 bind한 주소가 "127.0.0.1"(자기 자신만 허용)입니다.
해결: bind 주소를 "0.0.0.0"으로 했는지 확인하고, 운영체제 방화벽에서 해당 포트를 허용하세요. 그리고 기억하세요 — 이 실험도 오직 내 랩 안의 기기끼리만입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
소켓(socket) 프로그램 사이의 통신 끝점 — 네트워크의 전화기
IP 주소 / 포트 어느 컴퓨터인가(건물) / 그 안의 어느 프로그램인가(방)
127.0.0.1 (localhost) "이 컴퓨터 자신"을 가리키는 약속된 랩 주소
TCP 받았는지 확인하며 순서를 맞춰 주는 연결 규약
LISTENING 서버가 포트를 열고 접속을 기다리는 상태
임시 포트 클라이언트에게 운영체제가 임의로 붙여 주는 번호

오늘의 함수와 명령어

함수/명령 하는 일
socket() 통신 끝점 만들기
bind((주소, 포트)) / listen(n) 번호 부여 / 수신 대기
accept() 전화가 올 때까지 기다렸다가 통화용 소켓 받기
connect((주소, 포트)) 그 번호로 전화 걸기
sendall(바이트) / recv(크기) 보내기 / 받기
netstat -ano (윈도우) / ss -tlnp (리눅스) 열린 포트와 그 주인 프로그램 보기

명령어보다 중요한 감각

오늘 만든 서버를 조금만 비틀면 위험한 물건이 됩니다. echo 대신 "받은 말을 명령으로 실행해 결과를 돌려주는" 서버를 만들면, 그것이 바로 바인드 쉘(피해자 컴퓨터에 문을 열어 두고 공격자가 접속하는 원격 조종)의 원형이고, 반대로 피해자 쪽이 공격자에게 connect하게 만들면 리버스 쉘입니다. 둘 다 오늘 배운 다섯 함수의 조합일 뿐입니다. 이 원리를 아는 이유는 공격을 위해서가 아니라, 침해 흔적 속에서 이런 코드를 알아보기 위해서입니다. 모든 공격 실습은 내 랩·합법 플랫폼에서만 — 오늘의 모든 연결은 127.0.0.1 안에서만 맺어졌습니다.

하나 더. 잘 알려진 포트 번호(21 FTP, 22 SSH, 80 HTTP, 443 HTTPS)를 오늘부터 하나씩 친해지세요. 이 번호들을 볼 때마다 서비스 이름이 자동으로 떠오르면, 뒤의 포트 스캔 챕터에서 결과를 읽는 속도가 달라집니다. 그리고 오늘의 server.py와 client.py는 버리는 연습장이 아닙니다 — 다음 챕터의 채팅 서버도 이 씨앗에서 자랍니다.


전부 체크되면 Step 77 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.