채팅 프로그램
채팅 프로그램
요구 사항
- 입장:
/join|{name}- 처음 채팅 서버에 접속 할 때 사용자의 이름을 입력해야 한다.
- 메시지:
/message|{내용}- 모든 사용자에게 메시지를 전달한다.
- 이름 변경:
/change|{name}- 사용자의 이름을 변경한다.
- 전체 사용자:
/users- 채팅 서버에 접속한 전체 사용자 목록을 출력한다.
- 종료:
/exit- 채팅 서버의 접속을 종료한다.
설계
클라이언트
- 계속해서 읽기를 수행할 스레드 하나
- 계속해서 쓰기를 수행할 스레드 하나
서버
- 누군가 메시지를 입력하면 모두에게 전달
- 세션을 관리할 주체가 필요
코드
자원 정리 유틸 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class SocketCloseUtil {
public static void closeAll(Socket socket, InputStream input, OutputStream output) {
close(input);
close(output);
close(socket);
}
public static void close(InputStream input) {
if (input != null) {
try {
input.close();
} catch (IOException e) {
log(e.getMessage());
}
}
}
public static void close(OutputStream output) {
if (output != null) {
try {
output.close();
} catch (IOException e) {
log(e.getMessage());
}
}
}
public static void close(Socket socket) {
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
log(e.getMessage());
}
}
}
}
클라이언트
- 클라이언트 메인 클래스
1
2
3
4
5
6
7
8
9
public class ClientMain {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
Client client = new Client("localhost", PORT);
client.start();
}
}
- 클라이언트
- 서버와 소켓 연결
- 읽기 스레드와 쓰기 스레드를 실행
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 synchronized void close() {
if (closed) {
return;
}
writeHandler.close();
readHandler.close();
closeAll(socket, input, output);
closed = true;
log("연결 종료: " + socket);
}
}
- 읽기 쓰레드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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;
}
@Override
public void run() {
try {
while (true) { // 무한으로 데이터 읽기
String received = input.readUTF();
System.out.println(received);
}
} catch (IOException e) {
log(e);
} finally {
client.close();
}
}
public synchronized void close() {
if (closed) {
return;
}
// 종료 로직 필요시 작성
closed = true;
log("readHandelr 종료");
}
}
- 쓰기 쓰레드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class WriteHandler implements Runnable {
private static final String DELIMITER = "|";
private final DataOutputStream output;
private final Client client;
private boolean closed = false;
public WriteHandler(DataOutputStream output, Client client) {
this.output = output;
this.client = client;
}
@Override
public void run() {
Scanner scanner = new Scanner(System.in);
try {
String username = inputUsername(scanner); // 이름 입력 받기
output.writeUTF("/join" + DELIMITER + username); // 첫 입장 시 이름 입력
while (true) { // 무한으로 전송할 메시지 쓰기
String toSend = scanner.nextLine(); // 블로킹
if (toSend.isEmpty()) {
continue;
}
if (toSend.equals("/exit")) {
output.writeUTF(toSend);
break;
}
// "/"로 시작하면 명령어, 나머지는 일반 메시지
if (toSend.startsWith("/")) {
output.writeUTF(toSend);
} else {
output.writeUTF("/message" + DELIMITER + toSend);
}
}
} catch (IOException | NoSuchElementException e) {
log(e);
} finally {
client.close();
}
}
private static String inputUsername(Scanner scanner) {
System.out.println("이름을 입력하세요.");
String username;
do {
username = scanner.nextLine();
} while (username.isEmpty());
return username;
}
public synchronized void close() {
if (closed) {
return;
}
try {
System.in.close(); // Scanner 입력 중지 (사용자의 입력을 닫음)
} catch (IOException e) {
log(e);
}
closed = true;
log("writeHandler 종료");
}
}
서버
- 서버 메인 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ServerMain {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
SessionManager sessionManager = new SessionManager();
// CommandManager 점진적으로 변경 예정
CommandManager commandManager = new CommandManagerV2(sessionManager);
Server server = new Server(PORT, commandManager, sessionManager);
server.start();
}
}
- 서버
- 클라이언트와 소켓 연결 후 세션 생성
- 세션 매니저에 세션 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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();
}
private void addShutdownHook() {
ShutdownHook target = new ShutdownHook(serverSocket, sessionManager);
Runtime.getRuntime().addShutdownHook(new Thread(target, "shutdown"));
}
private 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("shutdownHook 실행");
try {
sessionManager.closeAll();
serverSocket.close();
Thread.sleep(1000); // 자원 정리 대기
} catch (Exception e) {
e.printStackTrace();
System.out.println("e = " + e);
}
}
}
}
- 세션: 클라이언트와의 요청 및 응답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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;
this.sessionManager.add(this);
}
@Override
public void run() {
try {
while (true) {
// 클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + 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 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;
}
}
- 세션 매니저: 세션을 관리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class SessionManager {
private List<Session> sessions = new ArrayList<>();
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) {
for (Session session : sessions) {
try {
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;
}
}
- 요구 사항에 해당하는 문자열 처리를 위한 인터페이스
1
2
3
public interface CommandManager {
void execute(String totalMessage, Session session) throws IOException;
}
- 요구 사항에 해당하는 문자열 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class CommandManagerV2 implements CommandManager {
private static final String DELIMITER = "\\|";
private final SessionManager sessionManager;
public CommandManagerV2(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String totalMessage, Session session) throws IOException {
if (totalMessage.startsWith("/join")) {
String[] split = totalMessage.split(DELIMITER);
String username = split[1];
session.setUsername(username);
sessionManager.sendAll(username + "님이 입장했습니다.");
} else if (totalMessage.startsWith("/message")) {
// 클라이언트 전체에게 문자 보내기
String[] split = totalMessage.split(DELIMITER);
String message = split[1];
sessionManager.sendAll("[" + session.getUsername() + "] " + message);
} else if (totalMessage.startsWith("/change")) {
String[] split = totalMessage.split(DELIMITER);
String changeName = split[1];
sessionManager.sendAll(session.getUsername() + "님이 " + changeName + "로 이름을 변경했습니다.");
session.setUsername(changeName);
} else if (totalMessage.startsWith("/users")) {
List<String> usernames = sessionManager.getAllUsername();
StringBuilder sb = new StringBuilder();
sb.append("전체 접속자 : ").append(usernames.size()).append("\n");
for (String username : usernames) {
sb.append(" - ").append(username).append("\n");
}
session.send(sb.toString());
} else if (totalMessage.startsWith("/exit")) {
throw new IOException("exit");
} else {
session.send("처리할 수 없는 명령어 입니다: " + totalMessage);
}
}
}
Command 패턴으로 CommandManager 리팩토링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CommandManagerV4 implements CommandManager {
private static final String DELIMITER = "\\|";
private final Map<String, Command> commands = new HashMap<>();
private final Command defaultCommand = new DefaultCommand();
public CommandManagerV4(SessionManager sessionManager) {
commands.put("/join", new JoinCommand(sessionManager));
commands.put("/message", new MessageCommand(sessionManager));
commands.put("/change", new ChangeCommand(sessionManager));
commands.put("/users", new UsersCommand(sessionManager));
commands.put("/exit", new ExitCommand());
}
@Override
public void execute(String totalMessage, Session session) throws IOException {
String[] args = totalMessage.split(DELIMITER);
String key = args[0];
// NullObject Pattern
Command command = commands.getOrDefault(key, defaultCommand);
command.execute(args, session);
}
}
1
2
3
public interface Command {
void execute(String[] args, Session session) throws IOException;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JoinCommand implements Command {
private final SessionManager sessionManager;
public JoinCommand(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String[] args, Session session) {
String username = args[1];
session.setUsername(username);
sessionManager.sendAll(username + "님이 입장했습니다.");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MessageCommand implements Command {
private final SessionManager sessionManager;
public MessageCommand(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String[] args, Session session) throws IOException {
String message = args[1];
sessionManager.sendAll("[" + session.getUsername() + "] " + message);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ChangeCommand implements Command {
private final SessionManager sessionManager;
public ChangeCommand(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String[] args, Session session) {
String changeName = args[1];
sessionManager.sendAll(session.getUsername() + "님이 " + changeName + "로 이름을 변경했습니다.");
session.setUsername(changeName);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class UsersCommand implements Command {
private final SessionManager sessionManager;
public UsersCommand(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String[] args, Session session) throws IOException {
List<String> usernames = sessionManager.getAllUsername();
StringBuilder sb = new StringBuilder();
sb.append("전체 접속자 : ").append(usernames.size()).append("\n");
for (String username : usernames) {
sb.append(" - ").append(username).append("\n");
}
session.send(sb.toString());
}
}
1
2
3
4
5
6
7
public class ExitCommand implements Command {
@Override
public void execute(String[] args, Session session) throws IOException {
throw new IOException("exit");
}
}
1
2
3
4
5
6
public class DefaultCommand implements Command {
@Override
public void execute(String[] args, Session session) throws IOException {
session.send("처리할 수 없는 명령어 입니다: " + Arrays.toString(args));
}
}
커맨드 패턴으로 변경 시, 요구사항이 늘어나도
Command인터페이스를 하나 구현 후CommandManager에 등록만 하면 되므로 대응하기가 훨씬 쉬워지며 코드 가독성도 높아졌습니다.
하지만 꼭 커맨드 패턴으로 리팩토링을 해야하는 것은 아닙니다. 간단하게 끝낼 수 있는 경우는
if~else구문으로 처리하는 것이 좋으며, 추후 요구사항이 늘어나고 시스템이 커질 거 같을 때 이런 패턴을 사용하는 것이 좋습니다.
참고
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.



