Java Socket 통신으로 채팅 구현하기 — 삽질 기록과 핵심 개념 정리

scminy0527·2026년 3월 9일
post-thumbnail

소켓 프로그래밍을 공부하면서 겪은 실수들과, 그 과정에서 이해하게 된 핵심 개념들을 정리합니다.


📌 전체 코드 구조

소켓 채팅 프로그램은 서버(Server)클라이언트(Client) 두 프로세스로 구성됩니다.

  • 서버는 특정 포트에서 대기하다가 클라이언트의 연결 요청을 수락합니다.
  • 클라이언트는 서버의 IP와 포트로 접속을 시도합니다.
  • 연결이 성립되면 양쪽 모두 BufferedReader / BufferedWriter를 통해 메시지를 주고받습니다.

서버 코드

package scoketPractice;

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

public class Server {
    public static void main(String[] args) {
        String clientMsg = null, serverMsg = null;
        System.out.println("클라이언트로 부터 받을 준비");

        try (
            ServerSocket serverSocket = new ServerSocket(1100);
            Socket socket = serverSocket.accept();
            BufferedReader bufferedReader =
                new BufferedReader(new InputStreamReader(socket.getInputStream()));
            BufferedWriter bufferedWriter =
                new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
            Scanner sc = new Scanner(System.in);
        ) {
            System.out.println("클라이언트가 연결 되었습니다.");

            while (true) {
                clientMsg = bufferedReader.readLine();
                System.out.println("[클라이언트]: " + clientMsg);

                System.out.println("클라이언트로 보낼 답변을 입력하시오...");
                serverMsg = sc.nextLine();

                bufferedWriter.write(serverMsg + "\n");
                bufferedWriter.flush();

                System.out.println("[서버]: " + serverMsg);
            }

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

클라이언트 코드

package scoketPractice;

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

public class Client {
    public static void main(String[] args) {
        String clientMsg = null, serverMsg = null;
        int port = 1100;
        String ip = "192.168.215.105";

        System.out.println("서버에 연결 준비");

        try (
            Socket socket = new Socket(ip, port);
            BufferedReader bufferedReader =
                new BufferedReader(new InputStreamReader(socket.getInputStream()));
            BufferedWriter bufferedWriter =
                new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
            Scanner sc = new Scanner(System.in);
        ) {
            System.out.println("서버에 연결 완료");

            while (true) {
                System.out.println("서버에 보낼 메세지를 입력하세요");

                clientMsg = sc.nextLine();
                bufferedWriter.write(clientMsg + "\n");
                bufferedWriter.flush();

                System.out.println("[클라이언트]: " + clientMsg);

                serverMsg = bufferedReader.readLine();
                System.out.println("[서버]: " + serverMsg);
            }

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

🔍 삽질 포인트 1 — "\n" 없이 readLine()이 동작하지 않는 이유

처음에 아래처럼 작성했을 때 수신 측에서 메시지가 오지 않는 문제가 발생했습니다.

// ❌ 문제 코드
bufferedWriter.write(serverMsg);
bufferedWriter.flush();

수정 후:

// ✅ 올바른 코드
bufferedWriter.write(serverMsg + "\n");
bufferedWriter.flush();

"\n"이 필요한가?

BufferedReader.readLine()줄 구분자(line terminator)를 만날 때까지 스트림을 블로킹(blocking) 상태로 읽습니다.

Java 공식 문서(BufferedReader.readLine())에 따르면:

"Reads a line of text. A line is considered to be terminated by any one of a line feed ('\n'), a carriage return ('\r'), a carriage return followed immediately by a line feed, or by reaching the end-of-file (EOF)."

즉, readLine()은 다음 세 가지 중 하나가 나타날 때까지 기다립니다.

종류표기설명
Line Feed\nUnix / Linux / macOS
Carriage Return\r구형 Mac
CRLF\r\nWindows

"\n"을 붙이지 않으면 readLine()이 "아직 줄이 끝나지 않았다"고 판단해 계속 대기하게 되고, 결국 수신 측이 영원히 블로킹 상태에 빠집니다.

💡 실무 팁: 실제 프로덕션 코드에서는 명시적 줄 구분자 대신 PrintWriterprintln() 메서드를 활용하거나, JSON/프로토콜 버퍼 같은 구조화된 데이터 포맷과 데이터 길이 헤더를 조합하는 방식이 더 안전합니다.


🔍 삽질 포인트 2 — IOException은 왜 반드시 처리해야 하나?

처음에 예외 처리를 생략했다가 컴파일 오류를 만났습니다. 단순히 "컴파일러가 요구하니까" 추가한 것이었는데, 그 이유를 제대로 이해하는 것이 중요합니다.

Checked Exception이란?

Java에는 두 가지 예외 계층이 있습니다.

  • Unchecked Exception (RuntimeException 하위): 컴파일러가 처리를 강제하지 않음. 예) NullPointerException, ArrayIndexOutOfBoundsException
  • Checked Exception (Exception 하위, RuntimeException 제외): 컴파일러가 반드시 처리하도록 강제. 예) IOException, SQLException

Socket, ServerSocket, BufferedReader, BufferedWriter 등 I/O 및 네트워크 관련 클래스들은 모두 IOException(Checked Exception)을 던질 수 있습니다.

네트워크는 항상 불확실하다

로컬 파일 I/O와 달리, 네트워크 소켓은 다음과 같은 이유로 언제든 실패할 수 있습니다.

  • 대상 서버가 꺼져 있거나 존재하지 않는 경우
  • 방화벽이나 네트워크 장비에 의해 연결이 차단된 경우
  • 연결 중 상대방이 갑자기 접속을 끊는 경우 (SocketException: Connection reset)
  • 네트워크 타임아웃 발생

실습 환경에서는 항상 서버가 켜져 있어서 체감이 어렵지만, 실제 환경에서는 연결 자체가 보장되지 않습니다. Java가 IOException을 Checked Exception으로 설계한 이유가 바로 이것입니다 — 개발자가 예외 상황을 반드시 인식하고 처리하도록 강제하는 것입니다.


🔍 핵심 개념 — try-with-resources로 자원을 자동으로 닫기

문제: 자원 누수(Resource Leak)

소켓, 스트림 등의 자원은 사용 후 반드시 close()를 호출해야 합니다. 그렇지 않으면 파일 디스크립터(File Descriptor)나 포트가 계속 점유되는 자원 누수 문제가 발생합니다.

기존 방식(finally 블록 사용)은 코드가 복잡해지는 단점이 있었습니다.

// ❌ 기존 방식 — finally에서 수동으로 close
Socket socket = null;
try {
    socket = new Socket(ip, port);
    // ... 로직
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (socket != null) {
        try { socket.close(); } catch (IOException e) { e.printStackTrace(); }
    }
}

해결: try-with-resources (Java 7+)

Java 7부터 도입된 try-with-resources 문법을 사용하면, try(...) 괄호 안에 선언된 자원이 블록 종료 시 자동으로 close() 됩니다.

// ✅ try-with-resources 방식
try (
    ServerSocket serverSocket = new ServerSocket(1100);
    Socket socket = serverSocket.accept();
    BufferedReader bufferedReader = new BufferedReader(...);
    BufferedWriter bufferedWriter = new BufferedWriter(...);
) {
    // ... 로직
} catch (IOException e) {
    e.printStackTrace();
}

이것이 가능한 이유는 ServerSocket, Socket, BufferedReader, BufferedWriter 모두 java.lang.AutoCloseable 인터페이스를 구현하고 있기 때문입니다. Java 공식 스펙(JLS §14.20.3)에 따르면, try-with-resources 블록이 종료될 때 자원은 선언된 역순으로 자동 close됩니다.

항목기존 방식try-with-resources
자원 해제수동 (finally에서 close())자동
코드 가독성복잡간결
누수 위험있음 (실수 가능)없음
Java 버전모든 버전Java 7 이상

✅ 정리

이번 소켓 채팅 실습을 통해 이해한 핵심 내용을 세 줄로 정리하면 다음과 같습니다.

  1. readLine()은 줄 구분자(\n)를 기준으로 동작한다. 송신 측에서 "\n"을 반드시 붙여야 수신 측에서 블로킹 없이 메시지를 읽을 수 있다.
  2. 네트워크 I/O는 항상 실패 가능성이 있다. IOException은 단순한 컴파일 요구사항이 아니라, 불안정한 네트워크 환경에 대한 방어적 설계이다.
  3. try-with-resources를 사용하면 자원 해제를 자동화할 수 있다. AutoCloseable을 구현한 자원은 try(...) 안에 선언하는 것이 Best Practice이다.

학습 목적으로 작성된 코드입니다. 실무에서는 멀티스레딩, 타임아웃 설정, 보안(TLS) 등 추가적인 고려가 필요합니다.

profile
비전공자에서 시작해 Flutter로 실서비스를 만들었고, 더 깊은 개발을 향해 계속 성장 중인 개발자

0개의 댓글