포스트

채팅 프로그램

채팅 프로그램

요구 사항

  • 입장: /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 라이센스를 따릅니다.