본문으로 건너뛰기

Ch 16.3 TCP 소켓 프로그래밍

TCP(Transmission Control Protocol) 소켓 프로그래밍은 클라이언트와 서버 간에 신뢰성 있는 1:1 연결 기반 의 통신을 구현하는 방식입니다. 전화기를 통해 상대방과 직접 연결되어 대화를 나누는 것과 매우 유사합니다.

TCP 3-way Handshake

TCP 연결은 다음 3단계로 수립됩니다.

  1. 클라이언트 → 서버: SYN (연결 요청)
  2. 서버 → 클라이언트: SYN+ACK (수락 + 확인)
  3. 클라이언트 → 서버: ACK (확인)

1. TCP 소켓의 핵심 구조

TCP 통신을 위해서는 서버 측의 역할과 클라이언트 측의 역할이 명확히 나뉩니다.

역할자바 클래스설명
서버ServerSocket특정 포트에서 클라이언트 연결 대기
서버-클라이언트 통신Socket연결 수락 후 1:1 통신 담당
클라이언트Socket서버 IP/포트로 연결 요청
서버 쪽:
new ServerSocket(8080) ← 특정 포트에서 리스닝 시작

serverSocket.accept() ← 블로킹 대기

Socket socket = ... ← 클라이언트 연결 수락 → 1:1 Socket 생성

클라이언트 쪽:
new Socket("127.0.0.1", 8080) ← 서버에 연결 요청 → Socket 반환

2. 간단한 에코(Echo) 서버 구현

클라이언트가 보낸 메시지를 받아서 그대로 돌려주는 에코 서버입니다.

서버 측 구현

import java.io.*;
import java.net.*;

public class EchoServer {
public static void main(String[] args) {
int port = 7777;

try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("에코 서버 시작. 포트: " + port);

// 클라이언트 연결 수락 (블로킹)
try (Socket socket = serverSocket.accept()) {
System.out.println("클라이언트 연결: " + socket.getInetAddress().getHostAddress());

// 소켓에서 입출력 스트림 획득
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true);

String message;
while ((message = in.readLine()) != null) {
System.out.println("수신: " + message);

if ("bye".equalsIgnoreCase(message)) {
out.println("서버: 연결을 종료합니다.");
break;
}

out.println("에코: " + message); // 그대로 돌려줌
}

System.out.println("클라이언트 연결 종료");
}

} catch (IOException e) {
e.printStackTrace();
}
}
}

클라이언트 측 구현

import java.io.*;
import java.net.*;
import java.util.Scanner;

public class EchoClient {
public static void main(String[] args) {
String host = "127.0.0.1";
int port = 7777;

try (Socket socket = new Socket(host, port)) {
System.out.println("서버 연결 완료: " + host + ":" + port);

PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));

Scanner scanner = new Scanner(System.in);
System.out.println("메시지를 입력하세요 ('bye'로 종료):");

while (true) {
System.out.print("> ");
String input = scanner.nextLine();
out.println(input); // 서버로 전송

String response = in.readLine(); // 서버 응답 수신
System.out.println(response);

if ("bye".equalsIgnoreCase(input)) break;
}

} catch (IOException e) {
e.printStackTrace();
}
}
}

3. 타임아웃 설정

import java.net.*;
import java.io.*;

public class SocketTimeout {
public static void main(String[] args) throws Exception {
// 서버 소켓 accept 타임아웃
ServerSocket serverSocket = new ServerSocket(7778);
serverSocket.setSoTimeout(5000); // 5초 내 연결 없으면 SocketTimeoutException

try {
Socket socket = serverSocket.accept();
} catch (SocketTimeoutException e) {
System.out.println("5초 내 연결 없음, 타임아웃");
}

// 클라이언트 소켓 읽기 타임아웃
Socket socket = new Socket();
socket.connect(new InetSocketAddress("127.0.0.1", 7777), 3000); // 연결 타임아웃 3초
socket.setSoTimeout(5000); // 읽기 타임아웃 5초

serverSocket.close();
socket.close();
}
}

4. 멀티클라이언트 처리: 요청마다 새 Thread

단일 스레드 서버는 한 번에 하나의 클라이언트만 처리할 수 있습니다. 실제 서버는 여러 클라이언트를 동시에 처리해야 합니다.

import java.io.*;
import java.net.*;

public class MultiClientServer {
static int clientCount = 0;

public static void main(String[] args) throws IOException {
int port = 7777;
ServerSocket serverSocket = new ServerSocket(port);
System.out.println("멀티클라이언트 서버 시작. 포트: " + port);

// 무한 루프: 연결이 올 때마다 새 스레드 생성
while (true) {
Socket socket = serverSocket.accept(); // 블로킹
clientCount++;
System.out.println("클라이언트 " + clientCount + " 연결");

// 각 클라이언트를 별도 스레드로 처리
Thread clientThread = new Thread(new ClientHandler(socket, clientCount));
clientThread.start();
}
}
}

// 각 클라이언트를 처리하는 Runnable
class ClientHandler implements Runnable {
private final Socket socket;
private final int clientId;

ClientHandler(Socket socket, int clientId) {
this.socket = socket;
this.clientId = clientId;
}

@Override
public void run() {
System.out.println("[Client " + clientId + "] 처리 시작. 스레드: " + Thread.currentThread().getName());

try (
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true)
) {
out.println("서버: 연결 완료 (클라이언트 #" + clientId + ")");

String message;
while ((message = in.readLine()) != null) {
System.out.println("[Client " + clientId + "] 수신: " + message);

if ("bye".equalsIgnoreCase(message.trim())) {
out.println("서버: 안녕히 가세요!");
break;
}

// 처리 시뮬레이션
out.println("[응답] " + message.toUpperCase());
}

} catch (IOException e) {
System.out.println("[Client " + clientId + "] 연결 오류: " + e.getMessage());
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("[Client " + clientId + "] 연결 종료");
}
}
}

5. ExecutorService로 스레드 풀 관리

요청마다 새 스레드를 생성하면 클라이언트가 많아질 때 메모리가 부족해집니다. 스레드 풀로 최대 스레드 수를 제한합니다.

import java.io.*;
import java.net.*;
import java.util.concurrent.*;

public class ThreadPoolServer {
public static void main(String[] args) throws IOException {
int port = 7777;
int maxThreads = 10; // 최대 동시 처리 클라이언트 수

// 고정 크기 스레드 풀 생성
ExecutorService pool = Executors.newFixedThreadPool(maxThreads);

// 다양한 스레드 풀 유형:
// Executors.newCachedThreadPool() - 필요할 때 스레드 생성, 유휴 시 재사용
// Executors.newSingleThreadExecutor() - 단일 스레드
// Executors.newWorkStealingPool() - ForkJoinPool 기반 (Java 8+)

try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("스레드풀 서버 시작. 포트: " + port + ", 최대스레드: " + maxThreads);

while (true) {
Socket clientSocket = serverSocket.accept();

// 스레드 풀에 작업 제출 (풀이 가득 차면 큐에 대기)
pool.submit(() -> {
try (
BufferedReader in = new BufferedReader(
new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)
) {
out.println("연결됨. 활성 스레드: " + Thread.activeCount());

String line;
while ((line = in.readLine()) != null) {
if ("quit".equalsIgnoreCase(line)) break;
out.println("처리: " + line);
}

} catch (IOException e) {
System.err.println("클라이언트 처리 오류: " + e.getMessage());
} finally {
try { clientSocket.close(); } catch (IOException ignored) {}
}
});
}

} finally {
pool.shutdown();
}
}
}

6. 실전 예제: 채팅 서버

여러 클라이언트가 서로 메시지를 주고받는 채팅 서버입니다.

채팅 서버

import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.*;

public class ChatServer {
// 연결된 모든 클라이언트의 출력 스트림 관리 (동시성 안전)
private static final Set<PrintWriter> clients =
Collections.synchronizedSet(new HashSet<>());

public static void main(String[] args) throws IOException {
int port = 9090;
ExecutorService pool = Executors.newCachedThreadPool();

try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("채팅 서버 시작. 포트: " + port);

while (true) {
Socket socket = serverSocket.accept();
pool.submit(new ChatClientHandler(socket));
}
}
}

// 모든 클라이언트에게 메시지 브로드캐스트
static void broadcast(String message, PrintWriter exclude) {
synchronized (clients) {
for (PrintWriter client : clients) {
if (client != exclude) {
client.println(message);
}
}
}
}

static class ChatClientHandler implements Runnable {
private final Socket socket;
private PrintWriter out;
private String username;

ChatClientHandler(Socket socket) { this.socket = socket; }

@Override
public void run() {
try (
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));
PrintWriter writer = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true)
) {
this.out = writer;
clients.add(out);

// 닉네임 요청
out.println("닉네임을 입력하세요:");
username = in.readLine();
broadcast("[" + username + "] 님이 입장했습니다.", out);
System.out.println(username + " 접속");

// 채팅 루프
String message;
while ((message = in.readLine()) != null) {
if ("/quit".equals(message)) break;
broadcast("[" + username + "] " + message, out);
}

} catch (IOException e) {
System.out.println(username + " 연결 오류");
} finally {
if (out != null) clients.remove(out);
broadcast("[" + username + "] 님이 퇴장했습니다.", null);
try { socket.close(); } catch (IOException ignored) {}
}
}
}
}

채팅 클라이언트

import java.io.*;
import java.net.*;

public class ChatClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 9090);

// 수신 스레드: 서버 메시지를 백그라운드에서 출력
Thread receiveThread = new Thread(() -> {
try (BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"))) {
String msg;
while ((msg = in.readLine()) != null) {
System.out.println(msg);
}
} catch (IOException e) {
System.out.println("서버 연결 끊김");
}
});
receiveThread.setDaemon(true); // 메인 스레드 종료 시 함께 종료
receiveThread.start();

// 송신: 사용자 입력을 서버로 전송
try (PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true);
BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in))) {

String input;
while ((input = keyboard.readLine()) != null) {
out.println(input);
if ("/quit".equals(input)) break;
}
}

socket.close();
}
}

7. try-with-resources로 자원 해제

import java.io.*;
import java.net.*;

public class ProperResourceManagement {
public static void main(String[] args) {
// 권장: try-with-resources로 자동 해제
try (
ServerSocket serverSocket = new ServerSocket(7999);
Socket socket = serverSocket.accept();
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
PrintWriter writer = new PrintWriter(os, true)
) {
String line = reader.readLine();
writer.println("처리됨: " + line);

} catch (IOException e) {
// socket.close()는 자동으로 스트림도 닫음
e.printStackTrace();
}

// Socket 닫기는 연결된 InputStream/OutputStream을 자동으로 닫음
// 단, 스트림을 먼저 닫으면 소켓도 닫힘 (주의 필요)
}
}

8. 소켓 옵션 설정

import java.net.*;

public class SocketOptions {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();

// TCP_NODELAY: Nagle 알고리즘 비활성화 (작은 패킷을 모으지 않고 즉시 전송)
// 게임, 실시간 애플리케이션에서 지연 감소 목적
socket.setTcpNoDelay(true);

// SO_KEEPALIVE: 일정 시간 데이터 없으면 연결 살아있는지 확인
socket.setKeepAlive(true);

// SO_TIMEOUT: 읽기 타임아웃 (밀리초)
socket.setSoTimeout(10000); // 10초

// SO_REUSEADDR: 서버 재시작 시 이전 포트 재사용 허용
ServerSocket serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress(7777));

// SO_RCVBUF, SO_SNDBUF: 수신/송신 버퍼 크기
socket.setReceiveBufferSize(65536); // 64KB
socket.setSendBufferSize(65536);

System.out.println("TCP_NODELAY: " + socket.getTcpNoDelay());
System.out.println("SO_KEEPALIVE: " + socket.getKeepAlive());
System.out.println("SO_TIMEOUT: " + socket.getSoTimeout());
System.out.println("SO_RCVBUF: " + socket.getReceiveBufferSize());

socket.close();
serverSocket.close();
}
}
고수 팁: 실무 TCP 서버 고려사항
  1. 타임아웃 설정: setSoTimeout()으로 응답 없는 클라이언트 정리
  2. 스레드 풀 제한: 무한정 스레드 생성 대신 ExecutorService 사용
  3. 우아한 종료: shutdownInput()/shutdownOutput()으로 반이중 종료
  4. Heartbeat: 장기 연결에서 주기적 ping으로 연결 상태 확인
  5. 백프레셔: 클라이언트가 너무 빨리 보낼 때 처리 속도 조절
  6. NIO: 수만 개 이상의 동시 연결은 java.nio 또는 Netty 사용 검토