Java 네트워크 프로그래밍 – TCP-IP 소켓 통신 기초

TCP/IP 핸드쉐이크, 패킷 처리는 OSJVM이 대신 해주기 때문에, 개발자는 소켓을 만들고 스트림으로 데이터를 주고받는 것에만 집중하면 된다

로깅 유틸리티 준비

멀티스레드 환경에서 디버깅할 때 스레드 이름과 시각이 함께 출력되면 흐름을 파악하기 훨씬 쉽다. 네트워크 프로그램도 결국 멀티스레드와 함께 쓰이므로, 아래 유틸리티를 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를 통해 localhost127.0.0.1 IP로 변환
  • 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)를 실행하면 OS12345 포트를 점유하고, 클라이언트의 접속을 받을 준비를 마친다

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단계 – 스트림으로 통신

  • 클라이언트와 서버 각자가 SocketInputStream / OutputStream을 통해 데이터를 주고받는다. 방향은 자신을 기준으로 생각하면 된다
  • 클라이언트 OutputStream → (네트워크) → 서버 InputStream
  • 서버 OutputStream → (네트워크) → 클라이언트 InputStream

클라이언트 포트는 왜 랜덤인가?

  • TCP 통신은 양쪽 모두 IP와 포트가 있어야 한다. 서버는 클라이언트가 알고 접속해야 하므로 포트를 고정한다. 반면 클라이언트는 포트를 명시하지 않으면 OS가 사용 가능한 포트 중 하나를 자동으로 할당한다. 그래서 실행할 때마다 localport 값이 달라진다

주의 사항

  • ConnectException: Connection refused 서버를 먼저 실행하지 않은 채 클라이언트를 실행하면 발생한다. 12345 포트에 수신 대기 중인 서버가 없기 때문이다.
  • BindException: Address already in use 이미 다른 프로세스가 12345 포트를 점유하고 있을 때 발생한다. 포트 번호를 변경하거나, 해당 프로세스를 종료해야 한다. IntelliJ에서 서버를 종료할 때는 반드시 Terminate를 선택해야 한다. Disconnect를 선택하면 자바 프로세스가 백그라운드에 살아남아 포트를 계속 점유한다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션