TCP/IP 핸드쉐이크, 패킷 처리는 OS와 JVM이 대신 해주기 때문에, 개발자는 소켓을 만들고 스트림으로 데이터를 주고받는 것에만 집중하면 된다
로깅 유틸리티 준비
멀티스레드 환경에서 디버깅할 때 스레드 이름과 시각이 함께 출력되면 흐름을 파악하기 훨씬 쉽다. 네트워크 프로그램도 결국 멀티스레드와 함께 쓰이므로, 아래 유틸리티를 util 패키지에 미리 준비한다
package util;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
public abstract class MyLogger {
private static final DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
public static void log(Object obj) {
String time = LocalTime.now().format(formatter);
System.out.printf("%s [%9s] %s\n", time,
Thread.currentThread().getName(), obj);
}
}
System.out.println()대신 MyLogger.log()를 사용한다
프로그램 목표
클라이언트가 "Hello"를 서버에 보내면, 서버는 " World!"를 붙여서 "Hello World!"를 응답하는 단순한 에코 프로그램이다
- 클라이언트 → 서버: “Hello”
- 클라이언트 ← 서버: “Hello World!”
전체 코드
ClientV1
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import static util.MyLogger.log;
public class ClientV1 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("클라이언트 시작");
Socket socket = new Socket("localhost", PORT);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
log("소켓 연결: " + socket);
// 서버에게 문자 보내기
String toSend = "Hello";
output.writeUTF(toSend);
log("client -> server: " + toSend);
// 서버로부터 문자 받기
String received = input.readUTF();
log("client <- server: " + received);
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
}
}
ServerV1
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import static util.MyLogger.log;
public class ServerV1 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
Socket socket = serverSocket.accept();
log("소켓 연결: " + socket);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
// 클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
// 클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
serverSocket.close();
}
}
- 실행 순서: 반드시 서버를 먼저 실행한 뒤 클라이언트를 실행해야 한다
실행 결과
클라이언트
10:36:42.921 [ main] 클라이언트 시작 10:36:42.927 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=57587] 10:36:42.927 [ main] client -> server: Hello 10:36:42.928 [ main] client <- server: Hello World! 10:36:42.929 [ main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=57587]
서버
10:36:39.161 [ main] 서버 시작 10:36:39.163 [ main] 서버 소켓 시작 - 리스닝 포트: 12345 10:36:42.927 [ main] 소켓 연결: Socket[addr=/127.0.0.1,port=57587,localport=12345] 10:36:42.927 [ main] client -> server: Hello 10:36:42.928 [ main] client <- server: Hello World! 10:36:42.928 [ main] 연결 종료: Socket[addr=/127.0.0.1,port=57587,localport=12345]
코드 분석
localhost와 127.0.0.1
localhost는 자기 자신의 컴퓨터를 가리키는 특수한 호스트 이름으로, 127.0.0.1(루프백 주소)로 매핑된다. 이 IP는 네트워크 인터페이스를 통해 외부로 나가지 않고 자신에게 직접 패킷을 보내는 데 쓰인다
// 자바에서 호스트명 → IP 조회
InetAddress localhost = InetAddress.getByName("localhost"); // localhost/127.0.0.1
InetAddress google = InetAddress.getByName("google.com"); // google.com/142.250.199.110
호스트명을 IP로 변환하는 우선순위는 다음과 같다
- OS의 hosts 파일 확인
- hosts 파일에 없으면 DNS 서버의 질의
localhost가 제대로 동작하지 않는다면 hosts 파일의 매핑이 변경된 것이므로, 127.0.0.1을 직접 입력하면 된다
클라이언트 소켓 연결 흐름
Socket socket = new Socket("localhost", PORT);
이 한 줄에서 다음 과정이 내부적으로 일어난다
InetAddress를 통해localhost→127.0.0.1IP로 변환127.0.0.1:12345로 TCP 접속 시도- OS 계층에서 3-way handshake (SYN → SYN-ACK → ACK) 수행
- 연결 성공 시
Socket객체 반환
Socket 객체가 서버와의 연결점이다. 이 객체의 스트림을 통해 데이터를 주고 받는다
DataInputStream input = new DataInputStream(socket.getInputStream()); DataOutputStream output = new DataOutputStream(socket.getOutputStream());
InputStream / OutputStream을 그대로 쓰면 바이트 단위로 직접 다뤄야 하지만, DataInputStream / DataOutputStream으로 감싸면 readUTF(), writeUTF() 같은 편의 메서드를 쓸 수 있다
서버 소켓의 역할 구분
서버 측에서 두 종류의 소켓이 등장한다
- ServerSocket: 특정 포트를 점유하고 클라이언트 접속을 대기한다. 연결 수립만 담당한다
- Socket: 실제 데이터 송수신에 사용된다.
accept()호출로 생성된다
ServerSocket serverSocket = new ServerSocket(PORT); // 12345 포트 오픈 Socket socket = serverSocket.accept(); // 클라이언트 접속 대기 (블로킹)
클라이언트 접속부터 통신까지 전체 흐름
1단계 – 서버 소켓 오픈
- 서버가
new ServerSocket(12345)를 실행하면OS가12345포트를 점유하고, 클라이언트의 접속을 받을 준비를 마친다
2단계 – TCP 연결 수립
- 클라이언트가
new Socket("localhost", 12345)를 호출하면 OS 계층에서3-way handshake가 진행된다. 연결이 완료되면 OS의backlog queue에 연결 정보 (클라이언트 IP:PORT ↔ 서버 IP:PORT)가 쌓인다. 이 단계는 자바가 아닌OS가 처리한다
3단계 – accept()로 Socket 획득
- 서버가
serverSocket.accept()를 호출하면backlog queue에서 연결 정보를 꺼내Socket객체를 생성한다. 큐가 비어 있으면 새 연결이 들어올 때까지 블로킹 된다
4단계 – 스트림으로 통신
- 클라이언트와 서버 각자가
Socket의InputStream/OutputStream을 통해 데이터를 주고받는다. 방향은 자신을 기준으로 생각하면 된다
- 클라이언트
OutputStream→ (네트워크) → 서버InputStream - 서버
OutputStream→ (네트워크) → 클라이언트InputStream
클라이언트 포트는 왜 랜덤인가?
- TCP 통신은 양쪽 모두 IP와 포트가 있어야 한다. 서버는 클라이언트가 알고 접속해야 하므로 포트를 고정한다. 반면 클라이언트는 포트를 명시하지 않으면 OS가 사용 가능한 포트 중 하나를 자동으로 할당한다. 그래서 실행할 때마다
localport값이 달라진다
주의 사항
- ConnectException:
Connection refused서버를 먼저 실행하지 않은 채 클라이언트를 실행하면 발생한다. 12345 포트에 수신 대기 중인 서버가 없기 때문이다. - BindException:
Address already in use이미 다른 프로세스가 12345 포트를 점유하고 있을 때 발생한다. 포트 번호를 변경하거나, 해당 프로세스를 종료해야 한다. IntelliJ에서 서버를 종료할 때는 반드시 Terminate를 선택해야 한다. Disconnect를 선택하면 자바 프로세스가 백그라운드에 살아남아 포트를 계속 점유한다