매우 단순한 클라이언트(메세지 보내기)-서버(메세지 응답) 시나리오를 구현할 것이다. 연결과 전송, 자원정리에 관해 학습하기 위해 서버는 단순히 클라이언트에서 받은 메세지 뒤에 World!를 추가하여 응답하도록 한다.
클라이언트는 우선 소켓을 생성한다. 생성자에 host정보와 PORT정보를 주입한다. 이때 host는 클라이언트가 연결하고자 하는 서버의 ip주소를 의미한다. PORT는 연결하고자 하는 서버의 PORT이다.
이때 클라이언트의 소켓의 포트는 랜덤으로 설정된다.
소켓의 outputStream과 inputStream을 DataOutputStream의 생성자로 주입하여 DataOutputStream을 생성한다.
Socket socket = new Socket("localhost", PORT);
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
DataInputStream input = new DataInputStream(socket.getInputStream());
log("소켓 연결 시작");
이제 output에 무언가를 던지면 host,Port정보를 가진 소켓을 통해 데이터가 날라갈 것이다.
while (!endCondi) {
System.out.println("1.서버로 메세지 보내기 | 2.종료");
System.out.println("선택: ");
int choice = sc.nextInt();
sc.nextLine();
switch (choice) {
case 1:
output.writeUTF(sc.nextLine());
String received = input.readUTF();
log("서버로 부터 응답: " + received);
break;
case 2:
log("소켓 연결 종료 : " + socket);
output.writeUTF("종료");
endCondi = true;
resourceEnd(output, input, socket);
break;
default:
System.out.println("잘못된 선택입니다. 다시 입력하셈요");
}
}
private static void resourceEnd(DataOutputStream output, DataInputStream input, Socket socket) throws IOException {
output.close();
input.close();
socket.close();
}
}
이때 output.writeUTF(sc.nextLine());을 호출한 이후 String received = input.readUTF();를 곧바로 실행시킨다면 안된다는 직감이 들 수 있다.
서버가 아직 데이터를 반환하지 않았을 경우 input.readUTF()는 블로킹(Blocking) 상태로 대기한다. 즉, 자바의 소켓 통신은 기본적으로 블로킹 I/O로 작동하며, 읽기 메서드(readUTF)는 데이터가 도착할 때까지 멈춰 기다린다.
2를 입력할 경우 클라이언트는 종료되는 구조이다. 단순히 종료할 것이 아니라 기존에 연결해두었던 것을 해제해야한다.(소켓, 인풋, 아웃풋)
boolean endCondi = false;
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());
while (!endCondi) {
//클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
if (received.equals("종료")) {
endCondi = true;
log("클라이언트에서 연결 해제 : 서버 종료");
} else {
//클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
}
//자원정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
serverSocket.close();
서버 코드는 위와 같다.
서버는 소켓이 아닌 서버소켓(ServerSocket)을 먼저 생성한다. 클라이언트의 output으로 데이터를 전송하면 우선 인식하는 곳은 이 서버소켓이다.
serverSocket.accept()은 클라이언트에서 데이터가 들어올때까지 코드 진행을 막아준다. 데이터가 들어오면 서버소켓으로부터 소켓이 생성된다.
serverSocket.accept() 원리
클라이언트로부터 요청이 들어오면
OS backlog queue에 요청정보(요청tcp, 도착tcp)가 담긴다. accept()는 백로그큐에서 이 요청정보를 조회하여 큐에서 정보를 지우고 소켓을 생성해준다.
while()문으로 서버 코드가 끝나지 않게 한다. while문이 끝나는 조건을 코딩해주고, while문을 나오면 자원정리를 해준다.
위 코드들에는 많은 문제들이 존재한다. 우선적으로 고려할 것은 서버가 하나의 클라이언트만 감당가능하다는 문제이다. 현재 서버 코드를 살펴보면 accept()로 돌아가지 않고 있기에 accept()를 계속 받을 수 있게 해야한다.
클라이언트를 2개 실행시 1개만 통신이 가능한 것을 볼 수 있다.
서버가 두 스레드의 연결 및 통신을 감당하기 위해 멀티스레드 처리가 필요하다. 서버는 accept()를 지속적으로 시도하게끔 만들고 accept()를 통해 소켓이 생성되고 연결되고 통신하는 과정을 Runnable의 run()에 구현하여 서버는 요청이 올때마다 이 Runnable을 담은 Thread를 생성해서 start시킨다.
이를 위해 Runnable 구현체인 Session이라는 클래스를 설계했다.
public class SessionV3 implements Runnable{
private final Socket socket;
public SessionV3(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
boolean endCondi = false;
try {
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
while (!endCondi) {
//클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
if (received.equals("종료")) {
endCondi = true;
log("클라이언트에서 연결 해제 : 서버 종료");
} else {
//클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
}
//자원정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
세션은 생성자에서 소켓을 주입받는다. 즉 서버는 accept()를 통해 소켓을 생성하면 세션에 생성한 소켓을 넣고 이 세션을 새로운 쓰레드에 넣고 실행시키는 것이다.
이를 통해 서버는 여러 쓰레드를 생성 및 실행한다.
만약 이때 실행중인 클라이언트에 종료를 입력해서 끄는 것이 아니라 강제종료(IntelliJ Stop)을 시키면 문제가 된다.

클라이언트가 비정상 종료되었으므로 해당 서버 쓰레드는 while을 통해 반복적으로 input.readUTF()를 수행하던 코드에서 예외가 발생한다. try문에서 예외가 발생했으므로 catch로 곧장 가버려 런타임예외를 던지고 코드는 끝난다.
이때 자원정리 코드가 실행되지 않는 문제가 발생한다.
try-catch-finally에서 finally는 try,catch와 상관없이 최종적으로 실행되는 공간이다. 우리가 닫아야할 세가지 자원을 종료하기 위해 finally구문에 3개의 close()작업을 해줄 수 있을 것이다.
public class SessionV4 implements Runnable{
private final Socket socket;
public SessionV4(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
boolean endCondi = false;
DataInputStream input = null;
DataOutputStream output = null;
try {
input = new DataInputStream(socket.getInputStream());
output = new DataOutputStream(socket.getOutputStream());
while (!endCondi) {
//클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
if (received.equals("종료")) {
endCondi = true;
log("클라이언트에서 연결 해제 : 서버 종료");
} else {
//클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
}
} catch (IOException e) {
log(e);
} finally {
closeAll(socket, input, output);
log("연결 종료: " + socket);
}
}
}
하지만 이 방법또한 여전히 문제를 가진다. finally영역의 close() 코드들이 모두 안전하게 수행되면 문제가 없겠지만 첫번째 close()에서 예외가 터진다면 또다시 뒤의 close()는 실행되지 않는다.
또한 핵심적인 예외가 묻힐 수 있다. 우리의 처음 예외는 EOF 예외이고 EOF 예외가 발생하여 자원정리하던 도중 close()예외가 발생했다. 두 가지 예외중 자바문법에 따라 후에 나온 예외가 던져질 것이다.
try-catch-finally의 문제를 생각해보자.
finally 부분에서 자원정리 코드에서 다시 예외가 발생할 경우에 대해 곤란한 지점이 생긴다는 것이다.
socket, input, output을 종료하는 finally 부분에서 하나씩 종료하되 종료과정에서 발생하는 예외를 잡아서 처리하도록 하여 3가지의 모든 리소스에 대해 종료로직 호출을 보장하면 될 것같다.
@Override
public void run() {
boolean endCondi = false;
try(socket;
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
) {
while (!endCondi) {
//클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
if (received.equals("종료")) {
endCondi = true;
log("클라이언트에서 연결 해제 : 서버 종료");
} else {
//클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
}
} catch (IOException e) {
log(e);
}
log("연결 종료: " + socket + " isClosed: " + socket.isClosed());
}
try-with-resource에서 AutoCloseable이 구현된 객체를 리소스 부분에 작성할 경우, try 부분이 끝나고(예외로 끝나던 정상종료로 끝나던) 자동으로 리소스를 연결한 역순으로 리소스를 닫아준다.
이를 통해 얻을 수 있는 이점은 다음과 같다.
리소스 누수 방지 : 모든 리소스 종료 보장코드 간결성 및 가독성 향상 : close()코드 부분 호출 필요없어짐더 빠른 자원해제 : try -> catch -> finally 순서로 하여금 catch 이후 자원을 반납하지만 try with resource는 try 블럭이 끝나는 즉시 close()를 호출한다.자원 정리 순서 보장: 직접 코딩할 경우 순서를 잘 고려해서 정리 코드를 짜야하지만 try with resource는 이를 자동처리해준다
public class ServerV6 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
boolean endCondi = false;
log("서버 시작");
SessionManagerV6 sessionManager = new SessionManagerV6();
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
// ShutdownHook 등록
ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook,
"shutdown"));
while (true) {
Socket socket = serverSocket.accept(); // 실행시: 여기서 기다림
log("소캣 연결: " + socket);
SessionV6 session = new SessionV6(socket, new SessionManagerV6());
Thread thread = new Thread(session);
thread.start();
}
}
}
// SHUTDOWN HOOK
public class ShutdownHook implements Runnable {
private final ServerSocket serverSocket;
private final SessionManagerV6 sessionManager;
public ShutdownHook(ServerSocket serverSocket, SessionManagerV6 sessionManager) {
this.serverSocket = serverSocket;
this.sessionManager = sessionManager;
}
@Override
public void run() {
log("shutdown hook started");
try {
sessionManager.closeAll();
serverSocket.close();
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
System.out.println("e = " + e);
}
}
}
셧다운 훅(Shutdown Hook)은 자바 프로세스 종료시 자원 정리나 로그 기록과 같은 종료 직전 작업을 수행하는 로직을 말한다.
자바 프로세스 종료는 정상 종료, 강제 종료로 구분된다.
보통적으로 사용하는 IntelliJ를 통한 stop 버튼은 정상종료에 해당하며 Ctrl+C 혹은 모든 non 데몬 스레드의 실행완료로 종료되는 경우, kill 등은 모두 정상종료에 해당한다.
kill -9이나 윈도우의 taskkill /F는 강제종료에 해당한다.
정상종료의 경우 셧다운 훅이 동작하지만 강제종료의 경우에는 셧다운 훅이 작동하지 않는다.
public class SessionManagerV6 {
private List<SessionV6> sessions = new ArrayList<>();
public synchronized void add(SessionV6 session) {
sessions.add(session);
}
public synchronized void remove(SessionV6 session) {
sessions.remove(session);
}
public synchronized void closeAll() {
// 세션을 모두 닫기
for (SessionV6 session : sessions) {
session.close();
}
sessions.clear();
}
}
세션 매니저는 서버 소켓을 관리하는 클래스로 셧다운 훅은 이를 이용해서 프로세스 종료시 최종적으로 종료로직을 보장하게 된다.
셧다운 훅과 세션 매니저를 사용함으로써 더 이상 try-catch-resource는 사용할 수없게 되었다.
서버를 종료하는 시점에 자원을 정리하는 것은 셧다운 훅을 따로 구현해주어야 하기 때문에 그렇다.
syncronized를 통한 동시성 제어가 필요하다. 세션 매니저는 리스트로 세션을 메모리로 하여금 관리하기 때문에 List상태 관리 부분이 들어가기 때문에 동시성에 대한 고려가 필요하다.