AI 웹 개발 교실

03-0 · 소켓으로 친구와 대화하기

에코에서 작은 채팅과 메시지 규칙으로 이어가고, 함수 실행 실험은 궁금할 때 골라 봅니다.

이 단원의 실습 파일 52 KB ZIP 다운로드

내 소켓 실습실 · 03 실습 ZIP

오늘의 질문: 내 터미널에 쓴 글이 친구의 터미널까지 어떻게 갈까? 예상 → 실행 → 작은 변경 → 설명을 반복합니다. 한 번에 모두 끝낼 필요는 없습니다. HTTP 전에 연결·중계·메시지 규칙을 자기 말로 설명하면 성공입니다.

준비

Node.js 24와 터미널을 사용합니다. 03-통신과웹 폴더에서 실행하며 외부 패키지와 npm install은 필요 없습니다. 처음에는 영문·숫자·기호인 ASCII만 보냅니다. 실제 아이디·비밀번호는 쓰지 않습니다.

서버와 클라이언트는 별도 터미널에서 실행합니다. 이 문서의 서버는 모두 4000번 포트를 사용하므로 서버를 바꿀 때 이전 서버를 Ctrl+C로 종료합니다. 파일을 저장한 뒤에는 서버를 재시작하고 클라이언트도 다시 연결합니다.

진행 묶음: 1번 개인 에코 → 2번 개인 중계 후 교실 공동 채팅 → 3번 각자 조각 실험 후 공동 한 줄 채팅입니다. 각 묶음 끝에서 설명을 남기고 멈춰도 됩니다. 4번 nc·telnet은 선택이며, 5번에서 다시 개인 HTTP로 돌아옵니다.

1. 혼자 해보기 · 에코

예상: 서버가 아무 응답도 보내지 않으면 터미널에 입력한 글이 보일까요?

터미널 A에서 npm run echo:server, B에서 npm run echo:client를 실행합니다. B에서 Hello를 입력하고 Enter를 누릅니다. 입력한 줄은 터미널 자체가 보여 주며, 서버가 돌려준 내용이 한 번 더 출력됩니다.

01-echo/server.mjs의 socket.write(data)를 socket.write('OK\n')로 바꿉니다. 저장 → 서버 재시작 → 클라이언트 재접속 후 Hello를 다시 보냅니다. 응답을 확인한 뒤 코드를 되돌립니다.

한 문장 기록: “내 입력을 화면에 보여 준 것은 ___, OK를 보낸 것은 ___이다.”

여기서 멈춰도 됩니다. 응답을 만든 코드를 가리킬 수 있으면 첫 묶음 성공입니다. 다음 채팅 전에는 에코 서버와 클라이언트를 Ctrl+C로 종료합니다.

에코에서 채팅으로

01-echo/server.mjs의 socket.on('data', ...) 한 줄만 함께 봅니다. on은 함수를 등록하고, 데이터가 도착하면 Node가 안쪽 함수를 호출합니다. 등록하자마자 Received가 출력되는 것은 아닙니다. 이 흐름을 1~2분 짚고 친구와 채팅으로 이어갑니다.

시간이 남거나 심심할 때, 궁금한 사람만 가볍게 해 보세요. 건너뛰고 바로 채팅해도 괜찮아요. 별도 제출이나 완료 확인은 없습니다.

선택학습 (필수 아님) · 궁금하면 펼쳐보기 · 함수는 누가 실행할까?

함수를 넘기는 모습이나 실행 순서가 궁금하면 하나만 골라도 됩니다. 두 예제는 네트워크 연결 없이 실행되며 새 서버를 띄우지 않습니다. 같은 03-통신과웹 폴더의 다른 터미널을 사용하세요. 나중에 다시 펼쳐 봐도 됩니다.

실험 1 · 함수를 실행하기와 전달하기

10-execution/callback.mjs를 읽고 Hello가 몇 번, 어디 사이에 나올지 예상합니다.

function greet() {
  console.log('Hello');
}

function run(callback) {
  console.log('Before callback');
  callback();
  console.log('After callback');
}

console.log('1. Direct');
greet();
console.log('2. Pass');
run(greet);
npm run execution:callback

greet()는 그 자리에서 호출합니다. run(greet)는 함수를 값으로 전달하고, 받은 run이 자기 실행 중 callback()으로 호출합니다. 이렇게 다른 함수에 전달해 호출하게 하는 함수를 콜백이라고 합니다. 이 콜백은 동기적으로 실행됩니다. “콜백이면 나중에 실행된다”는 뜻은 아닙니다.

실험 2 · 등록과 실행은 같은 순간일까?

10-execution/order.mjs의 다섯 출력 순서를 먼저 예상합니다. 02의 이벤트를 이미 설명할 수 있으면 이 비교만 해도 됩니다.

import { EventEmitter } from 'node:events';

const bell = new EventEmitter();
bell.on('ring', () => {
  console.log('Listener');
});

console.log('Start');
setTimeout(() => {
  console.log('Timer');
}, 0);
console.log('Before emit');
bell.emit('ring');
console.log('End');
npm run execution:order
실행 후 비교할 순서와 이유

Start → Before emit → Listener → End → Timer입니다. on은 등록만 합니다. Node의 EventEmitter.emit()은 등록된 리스너를 현재 호출 안에서 동기적으로 실행하므로 Listener가 End보다 먼저 나옵니다. 타이머 콜백은 현재 동기 코드가 끝난 뒤 실행될 기회를 얻습니다. 0은 즉시 실행이나 정확한 0ms를 보장하지 않습니다.

여기서 동기 실행은 호출한 함수가 일을 마치고 돌아온 뒤 다음 줄로 진행하는 방식입니다. 비동기 처리는 완료 처리를 나중에 이어 가도록 맡기고 다음 코드를 먼저 진행하는 방식입니다. 타이머를 기다리는 동안 End까지 실행하지만, 두 JavaScript 함수가 동시에 실행된다는 뜻은 아닙니다.

작은 변경: bell.emit('ring') 한 줄을 주석 처리합니다. 등록은 남아 있는데 Listener가 출력될지 예상하고 실행한 뒤 복원합니다.

에코 서버와 연결해 보기

01-echo/server.mjs의 socket.on('data', ...)에서 등록하는 줄과 데이터가 도착했을 때 Node가 호출할 함수 몸체를 각각 가리킵니다. 등록하자마자 Received가 출력되는 것은 아닙니다. 데이터를 기다리는 동안 현재 JavaScript 실행 흐름을 붙잡고 있는 것도 아닙니다. 콜백 하나마다 새 스레드를 만드는 것으로 이해하지 마세요.

함수가 들어 있는 자리Node가 그 함수를 호출하는 때
net.createServer((socket) => { ... })새 연결이 들어왔을 때. 그 연결의 data 리스너도 이 안에서 등록
socket.on('data', (data) => { ... })그 연결에서 받은 바이트 조각을 읽을 수 있을 때
server.listen(4000, '0.0.0.0', () => { ... })서버가 연결을 받을 준비를 마쳤을 때
용어같은 함수를 어떤 관점에서 부를까?
콜백다른 코드가 호출하도록 전달한 함수
리스너이벤트에 등록되어 그 이벤트가 발생하면 호출되는 함수
핸들러발생한 일을 처리하는 역할을 맡은 함수

여기서 data 리스너는 콜백이며, 받은 데이터를 처리하는 핸들러 역할도 합니다. 세 단어가 서로 완전히 다른 종류의 함수를 뜻하지는 않습니다. data 한 번을 메시지 한 개로 해석하는 것은 별개의 문제이며 뒤에서 실험합니다.

더 넓은 용어·이름의 뜻이 궁금하면 02의 선택 참고 자료를 찾아봅니다. 실험을 모두 끝내지 않아도 아래 채팅으로 이어갈 수 있습니다.

2. 함께 해보기 · 채팅

먼저 내 컴퓨터에서 서버 터미널 하나와 클라이언트 터미널 두 개를 사용합니다.

# 서버 터미널
npm run chat:server
# 다른 터미널 두 곳에서 각각 실행
npm run chat:client

한쪽에서 Hello from A를 보내면 다른 클라이언트에 나타납니다. 이번 서버는 보낸 사람에게 다시 보내지 않습니다. 08-socket-chat/server.mjs에서 에코 서버와 달라진 부분을 찾습니다.

코드역할
clients.add(socket)새로 연결된 상대를 모아 둔다
for (const peer of clients)연결된 상대를 하나씩 확인한다
if (peer !== socket) peer.write(data)보낸 사람을 제외한 상대에게 받은 바이트를 전달한다
clients.delete(socket)종료된 연결을 목록에서 뺀다

이제 각자 실행한 서버와 클라이언트를 Ctrl+C로 종료하고 공동 채팅으로 모입니다. 교사 또는 한 학생만 npm run chat:server를 켜고 npm run doctor로 실제 Wi-Fi IPv4 주소를 알려 줍니다. 나머지는 클라이언트만 실행하며 다음 예시 IP를 실제 주소로 바꿉니다. 서버 역할 학생도 별도 터미널로 접속하면 대화에 참여할 수 있습니다.

npm run chat:client -- 192.168.43.10

각자 S1: Hello처럼 순서대로 보낸 뒤 두 사람이 함께 보내 봅니다. 글이 섞이거나 누가 보냈는지 불분명해도 아직 문장 단위 처리를 하지 않았기 때문입니다. 이 서버는 받은 바이트 조각을 전달할 뿐, 한 조각을 한 메시지로 해석하지 않습니다.

작은 변경은 지금 공용 서버를 실행하는 사람만 합니다. peer !== socket 조건을 없애서 보낸 사람에게도 전달해 봅니다. 모두 클라이언트를 Ctrl+C로 종료한 뒤 서버 담당자가 저장 → 서버 종료 → npm run chat:server로 재실행합니다. 나머지는 같은 서버 IP로 다시 접속해 보냅니다. 입력과 서버가 돌려준 내용이 중복되는 이유를 설명한 뒤, 서버 담당자가 코드를 복원하고 같은 순서로 재시작·재접속합니다. 다른 학생의 로컬 파일을 바꾸어도 공용 서버는 바뀌지 않습니다.

그려 볼 것: A → 서버 → B, C, D, E. 모든 학생이 서로에게 직접 연결한 그림과 어떻게 다른가요? 서버가 꺼지면 나머지끼리 대화할 수 있을까요?

127.0.0.1은 명령을 실행하는 자기 컴퓨터입니다. 0.0.0.0은 서버의 수신 설정이며 접속 주소가 아닙니다. 같은 네트워크여도 방화벽·기기 격리로 막힐 수 있습니다. 서버 실행·IP·4000번 포트를 확인하고, 오래 막히면 각자의 터미널 세 개: 서버 하나 + 클라이언트 둘로 돌아갑니다. 서버는 npm run chat:server, 두 클라이언트는 IP 인자 없이 npm run chat:client로 실행합니다. 이때는 자기 서버를 수정·재시작하고 두 클라이언트를 다시 연결합니다.

여기서 멈춰도 됩니다. A → 서버 → B의 연결과 중계 코드를 설명하면 두 번째 묶음 성공입니다.

3. 규칙 붙이기 · 한 줄 메시지

공동 채팅의 클라이언트와 서버를 종료하고, 각자 자기 컴퓨터에서 조각 실험을 실행합니다. 이 실험에는 서버가 필요 없습니다.

예상: ['Hel', 'lo\nWor', 'ld\n'] 세 조각을 차례로 붙이고 줄바꿈까지 꺼내면, 완성된 메시지는 몇 개이고 각각 무엇일까요?

npm run lab:frames

05-labs/frames.mjs는 Hel, lo\nWor, ld\n 세 조각을 배열로 재현해 Hello, World 두 메시지를 만듭니다. 실제 TCP 패킷 분할을 강제하는 코드는 아닙니다. 배열을 ['Hello\nWorld\n']로 바꾸어도 결과가 같은지 확인한 뒤 복원합니다.

TCP는 순서 있는 바이트 스트림입니다. write() 횟수, data 이벤트 횟수, 패킷 수, 메시지 수는 같은 단위가 아닙니다. 이제 “줄바꿈 \n까지 한 메시지”라는 우리 규칙을 서버에 넣습니다.

이제 공동 서버 담당자만 npm run chat:lines를 실행하고 나머지는 npm run chat:client -- 서버-IP로 다시 모입니다. 담당자가 자기 서버의 IP를 다시 알려 줍니다. LAN 연결이 막혔다면 각자 서버 하나와 클라이언트 둘의 세 터미널로 같은 실험을 합니다. Hello를 보내면 다른 터미널에 [1] Hello처럼 연결 번호가 붙습니다. 번호는 로그인한 사람의 신원이 아니며, 다시 접속하면 새 번호를 받습니다.

08-socket-chat/lines.mjs의 다음 흐름을 읽습니다.

  1. 각 연결의 pending에 아직 완성되지 않은 내용을 모읍니다.
  2. 줄바꿈이 있으면 그 앞부분을 메시지로 꺼냅니다.
  3. 한 조각에 두 줄이 들어와도 while로 모두 처리합니다.
  4. 남은 조각은 다음 데이터를 기다립니다. 줄바꿈 없이 연결이 끝나면 그 조각은 버립니다.

pending을 연결 함수 바깥으로 옮기면 다른 학생의 조각이 섞일 수 있습니다. 첫 예제는 ASCII 한정입니다. 한글을 조각마다 toString()하면 글자가 깨질 수 있으므로 문자와 바이트에서 따로 실험합니다.

남길 기록: 세 조각이 두 메시지가 되는 과정 / 줄바꿈이 필요한 이유 / 연결 번호와 학생 계정의 차이.

여기서 멈추거나 HTTP로 이어갑니다. 에코·중계·한 줄 규칙을 설명하면 소켓 기본 실습을 마친 것입니다.

4. 선택학습 (필수 아님) · 접속 프로그램을 바꾸기

건너뛰고 5번 HTTP로 가도 됩니다. 선택했다면 서버는 chat:lines를 유지하고, 준비된 Node 클라이언트 대신 설치되어 있는 도구를 하나만 사용해 봅니다. 친구 서버라면 아래 127.0.0.1을 실제 IP로 바꿉니다.

nc 127.0.0.1 4000
telnet 127.0.0.1 4000

Hello from nc 또는 Hello from telnet을 보내고 Node 클라이언트에서 확인합니다. Node·nc는 Ctrl+C로 종료합니다. telnet은 Ctrl+] → quit → Enter로 종료합니다. telnet에서 Ctrl+C는 종료 대신 제어 신호가 될 수 있습니다.

Enter는 도구·환경에 따라 \n(LF) 또는 \r\n(CRLF)을 보낼 수 있습니다. 서버는 LF로 줄을 끝내고 그 앞의 CR 하나를 제거합니다. CR만 보내거나 CR+NUL을 보내는 모드는 이 예제가 지원하지 않습니다. Enter를 눌렀는데 전달되지 않으면 Node 클라이언트로 돌아옵니다.

Telnet에는 옵션 협상·제어 바이트도 있습니다. 이 채팅은 완전한 Telnet 서버가 아닙니다. 낯선 문자가 보이거나 입력 방식이 다르면 협상 코드를 더 붙이기보다 “같은 TCP 연결이어도 응용 규칙은 다를 수 있다”는 관찰을 남깁니다. 처음의 바이트 중계 서버는 Telnet 제어 바이트도 그대로 전달할 수 있습니다.

command not found 또는 명령 인식 오류가 나면 설치에 시간을 쓰지 않고 npm run chat:client를 씁니다. 강사가 미리 준비할 때 macOS는 Homebrew가 이미 있다면 brew install telnet, Windows는 선택 기능의 Telnet Client, Ubuntu 계열은 sudo apt install telnet netcat-openbsd를 사용할 수 있습니다. 학생 공통 준비 조건은 Node뿐입니다.

한 문장 기록: “클라이언트 프로그램이 달라도 ___와 ___가 맞으면 대화할 수 있다.”

5. 우리가 만든 규칙에서 HTTP로

공동 채팅을 마치면 각자 클라이언트를 종료하고 서버 담당자도 채팅 서버를 종료합니다. 이제 각자 자기 컴퓨터에서 HTTP 서버와 브라우저를 실행하며 127.0.0.1을 사용합니다.

채팅에서는 한 줄을 메시지 하나로 약속했습니다. 브라우저와 웹 서버는 HTTP라는 공통 규칙을 사용합니다. HTTP 학생 실습지로 이동해 npm run http:observe를 실행하고, 주소·상태·응답 본문을 확인합니다. HTTP는 여기서부터 Node 내장 http가 해석합니다.

선택 연결: http:observe 서버를 유지한 채 다른 터미널에서 npm run http:client -- http://127.0.0.1:8080/hello를 실행합니다. 클라이언트가 만든 요청 원문과 서버가 돌려준 상태 줄·HTML을 보고, 같은 주소를 브라우저에서 비교합니다. 요청은 03-http-client/client.mjs가 정확한 CRLF로 보냅니다.

nc로 HTTP까지 보내 보고 싶다면 macOS·Linux 터미널에서 다음을 실행합니다.

printf 'GET /hello HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080

HTTP/1.1의 줄 끝은 CRLF, 헤더 끝은 빈 줄입니다. printf가 위의 \r\n을 실제 바이트로 만들고 nc가 전송합니다. Connection: close는 응답 뒤 연결을 닫도록 요청합니다. nc에 직접 입력한 Enter가 LF만 보내는 환경에서는 400 Bad Request가 나올 수 있습니다. 채팅은 LF를 허용하지만 HTTP는 다른 규칙이라는 차이입니다. Windows에서는 준비된 Node 클라이언트를 사용합니다.

OSI에는 어디에 놓일까?

방금 한 일연결되는 역할
“한 줄을 한 메시지”, [번호] 내용으로 해석응용 계층의 우리 채팅 규칙. HTTP도 응용 계층 프로토콜
순서 있는 바이트 전달·포트전송 계층의 TCP
상대 컴퓨터 IP 주소로 전달네트워크 계층의 IP
Wi-Fi 등으로 가까운 장치와 전달데이터 링크·물리 계층의 역할

소켓은 계층 이름이 아니라 프로그램이 운영체제의 통신 기능을 사용하는 인터페이스입니다. 오늘은 Node의 net으로 TCP 소켓을 사용했습니다. OSI 7계층은 역할을 나누어 설명하는 모델이며 코드 파일 7개와 일대일로 대응하지 않습니다. 응용 계층 전체가 TCP인 것도 아닙니다. HTTP/3은 QUIC·UDP를 사용합니다.

선택학습 (필수 아님) · 더 해 보고 싶다면

시간이 남거나 심심할 때, 해 보고 싶은 것만 골라 보세요. 하지 않아도 괜찮아요.

작은 실험무엇을 확인할까?준비 상태
서버의 카운트다운 방송클라이언트가 보내지 않아도 서버가 먼저 보낼 수 있을까?실행 예제 준비
선착순 퀴즈·버저서버가 먼저 받은 완성 메시지와 실제 키를 누른 순서는 항상 같을까?확장 아이디어, 아직 코드 없음
ADD 2 3 계산기입력 형식·정상 응답·잘못된 요청을 어떻게 정할까?확장 아이디어, 아직 코드 없음
닉네임·귓속말연결 번호와 이름, 특정 상대에게 보내는 규칙은 어떻게 나눌까?확장 아이디어, 아직 코드 없음
한글 채팅문자와 바이트, 수신 조각의 차이는 무엇일까?별도 개념 실험 준비, 한글 채팅 확장은 직접 구현
서버 끄기·틀린 포트연결 실패와 서버가 보낸 실패 응답은 어떻게 다를까?오류 관찰 안내 준비
내 채팅 패킷 찾기TCP 안에서 내가 보낸 글과 포트를 찾을 수 있을까?Wireshark 안내 준비, 현장 캡처 별도

카운트다운: 기존 4000번 서버를 종료하고 npm run chat:countdown을 켭니다. 모두 npm run chat:client -- 서버-IP로 접속한 뒤 서버 터미널에서 Enter를 누릅니다. 입력하지 않은 클라이언트에도 5부터 GO!가 나타납니다. 다시 하려면 서버를 재시작하고 재접속합니다. 카운트다운은 요청·응답만 가능한 것은 아니라는 비교용이며 모든 기기 화면의 표시 시각이 정확히 같다는 보장은 없습니다.

확장할 때 AI에는 완성형 채팅 전체 대신 추가할 규칙 하나와 확인할 입력·결과를 전달합니다. 예: “ASCII 한 줄 채팅에 /name Mina를 추가하고, 이름을 바꾸기 전후 다른 터미널에 무엇이 표시되어야 하는지 먼저 적어 줘.”

공식 참고: Node TCP API, 이벤트와 동기 실행, 타이머, TCP 바이트 스트림, Telnet 규칙, HTTP/3.

이 페이지에서