이번 포스팅은 소켓을 직접활용해서 어떻게 클라이언트와 서버간의 연결을 통해 채팅 프로그램을 구현할 것이다
이번 프로그램은 단순히 잘 동작만 하는것이 아닌
책임, 역활, 협력의 관점으로 바라보며 코드를 작성하겠다.
사용자들이 채팅을 하기 위해서 어떤 객체가 필요한지
각 객체들이 어떤역활을 수행해야하는지 어떤 역활을 맡아야 하고 어떤 데이터를 책임져야하는지에 대해
자세히 서술하고자 한다.
이번 포스팅의 목적은 소캣프로그래밍에 대해 이해도를 높이고 또한 객체지향적인 시점을 얻기 위해 작성되었다.
채팅 프로그램 설계
- 서버에 접속한 사용자들은 모두 대화할 수 있어야한다.
- 입장 /join | {name}
- 처음 채팅 서버에 접속할 때 사용자의 이름을 입력해야합니다.
- 이름 변경 /change | {name}
- 사용자의 이름을 변경한다.
- 전체 사용자 /users
- 채팅 서버에 접속한 전체 사용자 목록을 출력한다.
- 종료 /exit
- 채팅 서버의 접속을 종료한다.
클라이언트 설계
- 채팅은 실시간으로 대화를 주고 받아야 한다.
- 메세지를 수신, 송신 할 수 있어야 한다.
- 클라이언트에서 송신한 메세진는 모든 사용자가 볼 수 있어야 한다.
위 이미지는 클라이언트가 송,수신 해야하는 설계도다.
위 이미지와 같이 송, 수신에는 독립적인 스레드가 필요하다.
ReadHandler, WriteHandler 객체를 통해 수신, 송신을 하도록 한다
그 말은 즉 client는 서버와 통신하기 위해서 송신, 수신 객체를 알아야 한다.
또한 서버와 연결하기 위한 필수 데이터인 host, port 번호를 직접 관리하도록 한다.
클라이언트 설계코드
package NetworkProgram.chat.client;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import static NetworkProgram.util.MyLogger.log;
import static NetworkProgram.util.network.tcp.SocketCloseUtil.closeAll;
public class Client {
private final String host;
private final int port;
private Socket socket;
private DataInputStream input;
private DataOutputStream output;
private ReadHandler readHandler;
private WriteHandler writeHandler;
private boolean closed = false;
public Client(String host, int port) {
this.host = host;
this.port = port;
}
public void start() throws IOException {
log("클라이언트 시작");
// 만약 좀더 유연한 설계를 원한다면 추상객체를 선언하고 생성자 함수를 통해 실제로 들어올 객체를 받으면 된다.
socket = new Socket(host, port);
input = new DataInputStream(socket.getInputStream());
output = new DataOutputStream(socket.getOutputStream());
readHandler = new ReadHandler(input, this);
writeHandler = new WriteHandler(output, this);
Thread readThread = new Thread(readHandler, "readHandler");
Thread writeThread = new Thread(writeHandler, "writeHandler");
readThread.start();
writeThread.start();
}
public void close() {
if (closed) {
return;
}
writeHandler.close();
readHandler.close();
closeAll(socket, input, output);
closed = true;
log("연결 종료 : " + socket);
}
}
Client 객체는 통신을 위해 전달받은 host, port 번호를 통해 소켓객체를 생성하여 서버와 연결을 시도한다.
Client 객체는 송신과 수신을 위해 DataInputStream, DataOutStream 을 관리하고 직접 송,수신 하는 객체에 주입해주었다.
마지막으로 송,수신 객체들에게 필요한 input, output 파이프를 건내준 후 독립적으로 Thread.start() 하여 각 블록킹 메서드들이 독립적으로
송, 수신을 이룰 수 있도록 구현했다
이때 Client 객체의 책임 대해서 살펴보자.
Client의 책임
- 서버연결에 필요한 host, port 번호를 알아야한다. -> Socket 객체를 생성할 의무가 있다.
- 필수적인 정보이기에 생성자를 통해 host,port를 반드시 받았으며 만약 유요하지 않거나 해당 데이터가 없다면 IllegalArgumentException 예외를 던져도 좋아보인다.
- 메세지를 송신 하고 수신할 객체를 알아야 하며 송,수신 하라고 명령해야한다.
- ReadHandler, WriteHandler를 생성하고 참조해야하는 의무가 있다.
- ReadHandler 객체에게 메세지를 수신하라 명령한다. readThread.start();
- WriteHandler 객체에게 메세지를 송신하라고 명령한다. writeThread.start();
좀더 나아가자면 각 참조하는 객체들을 변경할 일이 없기 때문에 final 키워드를 추가해서 Client 객체를 불변객체로 견고하게 설계해도 좋아보인다.
ReadHandler 객체의 책임
- ReadClient 객체는 Client 가 생성해준 input 스트림을 참조하여 수신의 책임을 다해야한다.
- 생성자를 통해 Client 가 소켓을 통해 생성해준 DataInputStream을 알아야 한다.
- 클라이언트가 연결 종료 시 자원정리의 의무가 있다.
- 자원정리를 하기 위해서는 ReadHanlder는 Client 객체를 알아야 한다.
이제 ReadHandler 객체는 DataInputStrea 객체를 통해 메세지를 계속 수신하며
클라이언트가 연결을 종료할 시 client 에게 자원을 정리하라고 메세지를 보낸다 (close 메서드를 호출한다.)
ReadHandler 구현 코드
package NetworkProgram.chat.client;
import NetworkProgram.util.network.tcp.v6.ClientV6;
import java.io.DataInputStream;
import java.io.IOException;
import static NetworkProgram.util.MyLogger.log;
public class ReadHandler implements Runnable {
private final DataInputStream input;
private final Client client;
public boolean closed = false;
public ReadHandler(DataInputStream input, Client client) {
this.input = input;
this.client = client;
}
public synchronized void close() {
if (closed) {
return;
}
closed = true;
log("readHandler closed");
}
@Override
public void run() {
try {
while (true) {
String received = input.readUTF();
System.out.println(received);
}
} catch (IOException e) {
log(e);
} finally {
client.close();
}
}
}
이제 Writehandler의 경우에도 마찬가지다 메세지를 송신하는 책임을 가진다 따라서 여기서는 생략하겠다.
서버 설계
우선 서버의 설계도를 살펴보겠다.
서버의 목표는 무엇일까
서버는 각 클라이언트에게 받은 메세지를 모든 클라이언트에게 다시 송신해야하는 책임이 있다.
해당 설계도에 나와있는
서버, 세션매니저, 세션 객체들의 협력을 통해 위 목표를 이뤄내고자 한다.
세션매니저가 모든 메세지를 각 세션에게 전달하는 설계도
서버의 책임
- 서버는 post 번호를 통해 클라이언트와 통신을 연결해야하는 의무가 있다.
- 따라서 생성자를 통해 직접적으로 받고 running 메서드에서 클라이언트의 연결을 한다.
- 세션 매니저를 각 세션에게 전달하여서 각 세션이 생성될 때 세션매니저에 관리될 수 있도록 한다
- running 메서드를 통해서 세션을 생성하고 세션매니저를 주입한다.
- CommandManagr 인터페이스 (역활을) 각 세션에게 메세지를 보낸다 (각 세션에게 메세지를 보내라고 명령한다.)
- 이때 CommandManager 는 인터페이스로 명령매니저 라는 역활을 부여 받고 실제로 명령을 이행할 객체를 주입받아야 한다
- 이렇게 역활을 나눔으로써 손쉽게 명령을 확장할 수 있다.
- 스레드에 shutDownHook을 등록함으로 세션매니저에게 모든 세션을 종료하라고 명령한다.
package NetworkProgram.chat.server;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import static NetworkProgram.util.MyLogger.log;
public class Server {
private final int port;
private final CommandManager commandManager;
private final SessionManager sessionManager;
private ServerSocket serverSocket;
public Server(int port, CommandManager commandManager, SessionManager sessionManager) {
this.port = port;
this.commandManager = commandManager;
this.sessionManager = sessionManager;
}
public void start() throws IOException {
log("서버 시작 : " + commandManager.getClass());
serverSocket = new ServerSocket(port);
log("서버 소켓 시작 - 리스닝 포인트: " + port);
addShutDownHook();
running();
}
public void addShutDownHook() {
ShutdownHook target = new ShutdownHook(serverSocket, sessionManager);
Runtime.getRuntime().addShutdownHook(new Thread(target, "shutdown"));
}
public void running() {
try {
while(true) {
Socket socket = serverSocket.accept();
log("소캣 연결 : " + socket);
Session session = new Session(socket, commandManager, sessionManager);
Thread thread = new Thread(session);
thread.start();
}
} catch (IOException e) {
log("서버 소캣 종료 : " + e);
}
}
static class ShutdownHook implements Runnable {
private final ServerSocket serverSocket;
private final SessionManager sessionManager;
public ShutdownHook(ServerSocket serverSocket, SessionManager sessionManager) {
this.serverSocket = serverSocket;
this.sessionManager = sessionManager;
}
@Override
public void run() {
log("shutdown hook 실행");
try {
sessionManager.closeAll();
serverSocket.close();
Thread.sleep(1000); // 자원정리 대기
} catch (Exception e) {
e.printStackTrace();
System.out.println("e = " + e.getMessage());
}
}
}
}
세션 구현
세션의 목표
위 설계도를 통해 우리가 아는 세션은 결국에는 클라이언트에게 메세지를 전달받고
모든 메세지를 다른 클라이언트에게 모두 전달하는 것이 목표가 되겠다
그렇다면 세션은 어느 객체를 알아야 하며 세션의 책임은 무엇일까
세션이 알아야하는 객체
- 우선 세션은 클라이언트에게 메세지를 송,수신을 해야하는 책임을 가지고 있다.
- 클라이언트와 연결이 된 서버 소켓을 받고 input, output을 직접 관리한다.
- 각 세션은 세션매니제에게 등록되어야 하기 때문에 세션매니저를 알아야 한다
- 생성자를 통해 세션매니저를 받고 객체 스스로가 세션매니저에게 등록한다.
- 세션은 단지 송, 수신하는 책임을 가지고 있고 CommandManager 모든 메세지를 다시 클라이언트에게 보내는 동작은 캡슐화 되어 알 수 없도록 해야한다.
- CommandManager를 추상적으로 알고 있어 결합도가 낮다 해당 로직을 수정할 때 세션에서 수정할 일은 없다.
결국 서버에게 연결된 소켓을 전달 받은 세션은 클라이언트의 메세지를 기다리고 해당 메세지가 온다면
CommandManager 에게 메세지를 처리하라는 명령을 할뿐이다. 해당 메세지를 어떻게 처리할 지는 세션은 모른다.
세션구현코드
package NetworkProgram.chat.server;
import NetworkProgram.util.network.tcp.v5.SessionV5;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import static NetworkProgram.util.MyLogger.log;
import static NetworkProgram.util.network.tcp.SocketCloseUtil.closeAll;
public class Session implements Runnable {
private final Socket socket;
private final DataInputStream input;
private final DataOutputStream output;
private final CommandManager commandManager;
private final SessionManager sessionManager;
private boolean closed = false;
private String username;
public Session(Socket socket, CommandManager commandManager, SessionManager sessionManager) throws IOException {
this.socket = socket;
this.input = new DataInputStream(socket.getInputStream());
this.output = new DataOutputStream(socket.getOutputStream());
this.commandManager = commandManager;
this.sessionManager = sessionManager;
sessionManager.add(this);
}
@Override
public void run() {
try {
while(true) {
// 클라이언트에게 문자받기
String received = input.readUTF();
log("client -> server received: " + received);
commandManager.execute(received, this);
}
} catch (IOException e) {
log(e);
}
finally {
sessionManager.remove(this);
sessionManager.sendAll(username + " - 님이 퇴장했습니다.");
close();
}
}
public void send(String message) throws IOException {
log("server -> client" + message);
output.writeUTF(message);
}
public synchronized void close() {
if (closed) return;
closeAll(socket, input, output);
closed = true;
log("연결 종료 : " + socket);
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
아래는 세션매니저의 구현코드
package NetworkProgram.chat.server;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static NetworkProgram.util.MyLogger.log;
public class SessionManager {
private final List<Session> sessions = new ArrayList<>();
public SessionManager(Session session) {
sessions.add(session);
}
public synchronized void add(Session session) {
sessions.add(session);
}
public synchronized void remove(Session session) {
sessions.remove(session);
}
public synchronized void closeAll() {
for (Session session : sessions) {
session.close();
}
sessions.clear();
}
public synchronized void sendAll(String message) {
try {
for (Session session : sessions) {
session.send(message);
}
} catch(IOException e) {
log(e);
}
}
public synchronized List<String> getAllUsername() {
List<String> usernames = new ArrayList<>();
for (Session session : sessions) {
if (session.getUsername() != null) {
usernames.add(session.getUsername());
}
}
return usernames;
}
}
CommandManger 인터페이스
package NetworkProgram.chat.server;
import java.io.IOException;
public interface CommandManager {
void execute(String totalMessage, Session session) throws IOException;
}
실제 구현객체
package NetworkProgram.chat.server;
import java.io.IOException;
public class CommandManagerV1 implements CommandManager {
private final SessionManager sessionManager;
public CommandManagerV1(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String totalMessage, Session session) throws IOException {
if (totalMessage.startsWith("/exit")) {
throw new IOException("exit");
}
sessionManager.sendAll(totalMessage);
}
}
이로써 모든 채팅프로그램에 필요한 객체들을 구현했다.
실제 객체들은 사용자들간의 채팅을 위해 서로 협력하며
명령하고, 역활을 두어 각자 자율성 있게 로직을 처리한다.
소켓을 활용한 채팅프로그램을 통해
소켓을 다루는 기본지식과, 객체지향을 바라보는 시점을 좀더 견고할 수 있는 시간이 되었다.
'Java' 카테고리의 다른 글
Java 리플랙션<Reflection> (2) | 2025.02.21 |
---|---|
[Java] 스레드 풀과 Excutor 프레임워크 (4) | 2024.09.29 |
[Java]ReentrantLock (9) | 2024.09.18 |
자바의 고오급 동기화 concurrent.Lock (7) | 2024.09.14 |
Java 프로세스와 스레드 (4) | 2024.08.30 |