Posts 학원 #57일차: 네트워크 API를 활용한 C/S 아키텍처
Post
Cancel

학원 #57일차: 네트워크 API를 활용한 C/S 아키텍처

네트워크 API를 활용한 C/S 아키텍처

클라이언트/서버 프로젝트 준비

git/eomcs-java-project/mini-pms-34-a-client/server

데스크톱 애플리케이션

  • 다른 애플리케이션과 연동하지 않고 단독적으로 실행한다.
  • 보통 PC나 노트북에 설치해서 사용한다.
  • MS-Word, Adobe Photoshop, 메모장 등

Client/Server 애플리케이션

  • 클라이언트는 서버에게 서비스나 자원을 요청하는 일을 한다.
  • 서버는 클라이언트에게 자원이나 서비스를 제공하는 일을 한다.

실습

  • 1단계: 서버/클라이언트 프로젝트 폴더 생성
  • 2단계: 서버/클라이언트 프로젝트 폴더를 Maven 기본 자바 디렉토리 구조로 초기화
    • gradle init 실행
  • 3단계: 이클립스 IDE로 임포트
    • build.gradleeclipse gradle 플러그인 추가
    • gradle eclipse: 이클립스 설정 파일 생성
    • 이클립스에서 프로젝트 폴더 임포트
  • 4단계: 애플리케이션 메인 클래스를 변경
    • App.javaServerApp.javaClientApp.java로 변경

간단한 메시지 송수신

git/eomcs-java-project/mini-pms-34-b-client/server

Server

  • ServerSocket을 준비해 클라이언트를 기다린다.
  • accept()를 통해 리턴된 소켓 객체로부터 입출력 스트림을 얻는다.
  • 클라이언트가 보낸 데이터를 그대로 반송한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ServerApp {
  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");
      
      try (Socket socket = serverSocket.accept();
          BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
          PrintWriter out = new PrintWriter(socket.getOutputStream())) {
        String request = in.readLine();
        
        out.println(request);
        out.flush();
      } 
    } catch (Exception e) {
      e.printStackTrace;
    }
  }
}

Client

  • 서버와 연결한 후 메시지를 주고 받는다.
  • ClientApp으로 이름을 변경한 후 서버와 연결된 Socket 객체를 통해 입출력 스트림을 준비하여 메시지를 주고받는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ClientApp {
  public static void main(String[] args) {
    try (Socket socket = new Socket("localhost", 8888);
        PrintWriter out = new PrintWriter(socket.getOutputStream());
         BufferedReader in = BufferedReader(new InputStreamReader(socket.getInputStream())) {
           out.println("Hello!");
           out.flush();
           
           String response = in.readLine();
           System.out.println(response);
           
         } catch (Exception e) {
           e.printStackTrace();
         }
  }
}

사용자가 입력한 명령 처리

git/eomcs-java-project/mini-pms-34-c-client/server

Client

  • 사용자가 입력한 명령을 서버에 전송한다.
  • 사용자가 quit 명령을 입력할 때까지 반복한다.
  • stateful 통신
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ClientApp {
  public static void main(String[] args) {
    try (Socket socket = new Socket("localhost", 8888);
        PrintWriter out = new PrintWriter(socket.getOutputStream());
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

      while (true) {
        String input = Prompt.inputString("명령> ");
        out.println(input);
        out.flush();

        String response = in.readLine();
        System.out.println(response);

        if (input.equalsIgnoreCase("quit"))
          break;
      }

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

Server

  • 클라이언트의 요청을 반복해서 처리한다.
  • 클라이언트가 quit 명령을 보내면 응답한 후 클라이언트와의 연결을 끊는다.
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
public class ServerApp {
  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");

      try (Socket socket = serverSocket.accept();
          BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
          PrintWriter out = new PrintWriter(socket.getOutputStream())) {

        while (true) {
          String request = in.readLine();

          out.println(request);
          out.flush();

          if (request.equalsIgnoreCase("quit"))
            break;
        }
      }

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

요청/응답 프로토콜 정의

git/eomcs-java-project/mini-pms-34-d-client/server

  • 프로토콜이란 클라이언트/서버 간에 데이터를 주고 받는 형식

Server

응답을 종료할 때 빈 줄을 보낸다.

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
public class ServerApp {
  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");

      try (Socket socket = serverSocket.accept();
          BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
          PrintWriter out = new PrintWriter(socket.getOutputStream())) {

        while (true) {
          String request = in.readLine();

          sendResponse(out);

          if (request.equalsIgnoreCase("quit"))
            break;
        }
      }

    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
  public static void sendResponse(PrintWriter out) {
    out.println(request);
    out.println();
    out.flush();
  }
}

Client

서버가 응답의 끝을 알리는 빈 줄을 보낼 때까지 클라이언트는 계속 읽는다.

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
public class ClientApp {
  public static void main(String[] args) {
    try (Socket socket = new Socket("localhost", 8888);
        PrintWriter out = new PrintWriter(socket.getOutputStream());
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

      while (true) {
        String input = Prompt.inputString("명령> ");
        out.println(input);
        out.flush();

        receiveResponse(in);

        if (input.equalsIgnoreCase("quit"))
          break;
      }

    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
 public static void receiveResponse(BufferedReader in) throws Exception {
   while (true) {
     String response = in.readLine();
     if (response.length() == 0)
       break;
     System.out.println(response);
   }
 }
}

다중 클라이언트의 요청 처리

git/eomcs-java-project/mini-pms-34-e-client/server

Server

  • 클라이언트와의 연결을 순차적으로 반복하여 처리한다.
  • 클라이언트가 접속하거나 연결을 끊으면 로그를 남긴다.
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
public class ServerApp {
  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");
      while (true) {
        handleClient(serverSocket.accept());
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
  private static void sendResponse(PrintWriter out) {
    out.println(request);
    out.println();
    out.flush();
  }
  
  private static void handleClient(Socket clientSocket) {
    InetAddress address = clientSocket.getInetAddress();
    System.out.printf("클라이언트(%s)가 연결되었습니다.\n", address.getHostAddress());
    
    try (Socket socket = clientSocket;
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStreamReader()));
        PrintWriter out = new PrintWriter(socket.getOutputStream())) {
      while (true) {
        String request = in.readLine();
        sendResponse(out, request);
        if (request.equalsIgnoreCase("quit"))
          break;
      }
    } catch (Exception e) {
      System.out.println("클라이언트와의 통신 오류!")
    }
    
    System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n", address.getHostAddress());
  }
}

Client

  • 애플리케이션 아규먼트를 통해 서버 주소와 포트번호를 입력받는다.
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
public class ClientApp {
  public static void main(String[] args) {
    
    if (args.length != 2) {
      System.out.println("프로그램 사용법: ");
      System.out.println("	java -cp ... Client 서버주소 포트번호");
      System.exit(0);
    }
    
    try (Socket socket = new Socket(arg[0], Integer.parseInt(arg[1]));
        PrintWriter out = new PrintWriter(socket.getOutputStream());
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

      while (true) {
        String input = Prompt.inputString("명령> ");
        out.println(input);
        out.flush();

        receiveResponse(in);

        if (input.equalsIgnoreCase("quit"))
          break;
      }

    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
 public static void receiveResponse(BufferedReader in) throws Exception {
   while (true) {
     String response = in.readLine();
     if (response.length() == 0)
       break;
     System.out.println(response);
   }
 }
}

다중 클라이언트의 동시 접속 처리

git/eomcs-java-project/mini-pms-34-f-client/server

서버

0단계: 클라이언트 요청 처리 부분을 별도의 스레드로 분리하여 처리

  • ClienthandlerThread 추가
    • Thread를 상속받는다.
    • 클라이언트의 요청 처리를 담당한다.
  • ServerApp 클래스 변경
    • 클라이언트 요청 처리를 ClientHandlerThread에게 맡긴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class ServerApp {
  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");
      
      while (true) {
        new ClientHandlerThread(serverSocket.accept()).start();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}
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
public class ClientHandlerThread extends Thread {
  Socket socket;
  
  public ClientHandler(Socket socket) {
    this.socket = socket;
  }
  
  @Override
  public void run() {
    InetAddress address = this.socket.getInetAddress();
    System.out.printf("클라이언트(%s)가 연결되었습니다.\n", address.getHostAddress());
    
    try (Socket socket = this.socket;
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStreamReader()));
        PrintWriter out = new PrintWriter(socket.getOutputStream())) {
      while (true) {
        String request = in.readLine();
        sendResponse(out, request);
        
        if (request.equalsIgnoreCase("quit"))
          break;
      }
    } catch (Exception e) {
      System.out.println("클라이언트와의 통신 오류!");
    }
    System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n", address.getHostAddress());
  }
  
  private void sendResponse(PrintWriter out, String message) {
    out.println(message);
    out.println();
    out.flush();
  }
}

1단계: 클라이언트의 요청 처리 부분을 Runnable 구현체로 분리하여 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ServerApp {
  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");
      
      while (true) {
        // 외부 클래스 사용
        new Thread(new ClientHandler(serverSocket.accept())).start();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}
1
public class ClientHandler implements Runnable { //..

2단계: ClientHandlerServerApp의 스태틱 중첩 클래스로 선언한다.

ClientHandler는 다른 클래스가 사용할 일이 없기 때문에 private으로 선언한다. 바깥 클래스의 특정 인스턴스 멤버를 사용할 일이 없기 때문에 static nested class로 선언한다.

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
public class ServerApp {

  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");

      while (true) {
        new Thread(new ClientHandler(serverSocket.accept())).start();
      }

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

  // static nested class
  private static class ClientHandler implements Runnable {
    Socket socket;

    public ClientHandler(Socket socket) {
      this.socket = socket;
    }

    @Override
    public void run() {

      InetAddress address = this.socket.getInetAddress();
      System.out.printf("클라이언트(%s)가 연결되었습니다.\n",
          address.getHostAddress());

      try (Socket socket = this.socket; // try 블록을 떠날 때 close()가 자동 호출된다.
          BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
          PrintWriter out = new PrintWriter(socket.getOutputStream())) {

        while (true) {
          String request = in.readLine();

          sendResponse(out, request);

          if (request.equalsIgnoreCase("quit"))
            break;
        }


      } catch (Exception e) {
        System.out.println("클라이언트와의 통신 오류!");
      }

      System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n",
          address.getHostAddress());
    }

    private void sendResponse(PrintWriter out, String message) {
      out.println(message);
      out.println(); // 응답이 끝났음을 알리는 빈 줄을 보낸다.
      out.flush();
    }
  }
}

2단계: ClientHandlermain의 익명 클래스로 정의한다.

익명 클래스는 생성자를 정의해줄 수 없다. 따라서 생성자의 파라미터로 소켓을 받는 것이 아니라, main()의 로컬 변수로 소켓을 선언하고 익명 클래스 내부에서 그 변수에 접근하여 사용하도록 만들어준다.

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
public class ServerApp {

  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");

      while (true) {
        Socket clientSocket = serverSocket.accept();
        new Thread(new Runnable() {
          @Override
          public void run() {

            InetAddress address = clientSocket.getInetAddress();
            System.out.printf("클라이언트(%s)가 연결되었습니다.\n",
                address.getHostAddress());

            try (Socket socket = clientSocket;
                BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                PrintWriter out = new PrintWriter(socket.getOutputStream())) {

              while (true) {
                String request = in.readLine();

                sendResponse(out, request);

                if (request.equalsIgnoreCase("quit"))
                  break;
              }

            } catch (Exception e) {
              System.out.println("클라이언트와의 통신 오류!");
            }

            System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n",
                address.getHostAddress());
          }

          private void sendResponse(PrintWriter out, String message) {
            out.println(message);
            out.println(); // 응답이 끝났음을 알리는 빈 줄을 보낸다.
            out.flush();
          }
        }).start();
      }

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

3단계: main()의 익명 클래스의 코드를 바깥 클래스의 멤버로 만든 후 그 바깥 클래스의 멤버를 호출한다.

중첩이 줄기 때문에 코드를 읽기 쉽다.

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
public class ServerApp {

  public static void main(String[] args) {
    try (ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");

      while (true) {
        Socket clientSocket = serverSocket.accept();
        new Thread(new Runnable() {
          @Override
          public void run() {
            handleClient(clientSocket);
          }
        }).start();
      }

    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
  public static void handleClient(Socket clientSocket) {
    InetAddress address = clientSocket.getInetAddress();
    System.out.printf("클라이언트(%s)가 연결되었습니다.\n",
        address.getHostAddress());

    try (Socket socket = clientSocket;
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        PrintWriter out = new PrintWriter(socket.getOutputStream())) {

      while (true) {
        String request = in.readLine();
        sendResponse(out, request);
        if (request.equalsIgnoreCase("quit"))
          break;
      }


    } catch (Exception e) {
      System.out.println("클라이언트와의 통신 오류!");
    }

    System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n",
        address.getHostAddress());
  }
  
  private static void sendResponse(PrintWriter out, String message) {
    out.println(message);
    out.println(); // 응답이 끝났음을 알리는 빈 줄을 보낸다.
    out.flush();
  }
}

익명 클래스에 정의되어 있던 sendResponse()handleClient()에서 사용하기 때문에 ServerApp의 메서드로 정의한다. 두 메서드는 static 메서드인 main()에서 호출하고 있기 때문에 모두 static 메서드로 선언한다.

4단계: 익명 클래스를 람바 문법으로 변경한다.

1
2
3
4
5
6
new Thread(new Runnable() {
  @Override
  public void run() {
    handleClient(clientSocket);
  }
}).start();

위의 코드를 다음과 같이 바꿔줄 수 있다.

1
new Thread(()  -> handleClient(clientSocket)).start();

PMS 코드를 C/S로 분리

git/eomcs-java-project/mini-pms-34-g-client/server

Server

1단계: JSON 데이터 포맷을 다룰 Gson 라이브러리를 추가한다.

  • build.gradle 파일에 gson 라이브러리 정보를 추가한다.
  • $ gradle eclipse를 실행하여 라이브러리를 프로젝트에 추가한다.
  • 이클립스 IDE에서 프로젝트를 refresh한다.

2단계: 기존 애플리케이션에서 관련된 패키지 및 클래스를 가져온다.

  • com.eomcs.pms.domain 패키지 및 그 하위 클래스를 가져온다.
  • com.eomcs.pms.handler 패키지 및 그 하위 클래스를 가져온다.
  • com.eomcs.context 패키지 및 그 하위 클래스를 가져온다.
  • com.eomcs.pms.listener 패키지 및 그 하위 클래스를 가져온다.
  • com.eomcs.pms.ServerApp 변경
    • com.eomcs.pms.App 클래스에서 옵저버 패턴과 관련된 코드를 가져온다.
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
public class ServerApp {
  
  Map<String,Object> context = new Hashtable<>();

  List<ApplicationContextListener> listeners = new ArrayList<>();

  public void addApplicationContextListener(ApplicationContextListener listener) {
    listeners.add(listener);
  }

  public void removeApplicationContextListener(ApplicationContextListener listener) {
    listeners.remove(listener);
  }

  private void notifyApplicationContextListenerOnServiceStarted() {
    for (ApplicationContextListener listener : listeners) {
      listener.contextInitialized(context);
    }
  }
  
  private void notifyApplicationContextListenerOnServiceStopped() {
    for (ApplicationContextListener listener : listeners) {
      listener.contextDestroyed(context);
    }
  }
  
  public static void main(String[] args) {
    server.service(8888);
  }
  
  public void service(int port) {
    notifyApplicationContextListenerOnServiceStarted();
    try (ServerSocket serverSocket = new ServerSocket(port)) {
      System.out.println("서버 실행 중...");

      while (true) {
        Socket clientSocket = serverSocket.accept();
        new Thread(()  -> handleClient(clientSocket)).start();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
    Prompt.close();
    notifyApplicationContextListenerOnServiceStopped();
  }
  
  public void handleClient(Socket clientSocket) {
    InetAddress address = clientSocket.getInetAddress();
    System.out.printf("클라이언트(%s)가 연결되었습니다.\n",
        address.getHostAddress());

    try (Socket socket = clientSocket;
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        PrintWriter out = new PrintWriter(socket.getOutputStream())) {

      while (true) {
        String request = in.readLine();
        sendResponse(out, request);
        if (request.equalsIgnoreCase("quit"))
          break;
      }
    } catch (Exception e) {
      System.out.println("클라이언트와의 통신 오류!");
    }
    System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n",
        address.getHostAddress());
  }
}

3단계 - 클라이언트의 “stop” 명령을 처리한다.

ServerApp 서버의 종료를 제어하기 위해 stop 필드를 추가하였다.

1
2
3
public class ServerApp {
  boolean stop = false;
  //..

ServerApp.handleClient()

이 메서드에서는 클라이언트에서 stop을 입력했을 때 서버를 종료할 수 있도록 stop 변수를 true로 바꾼다.

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
public void handleClient(Socket clientSocket) {
  InetAddress address = clientSocket.getInetAddress();
  System.out.printf("클라이언트(%s)가 연결되었습니다.\n",
                    address.getHostAddress());

  try (Socket socket = clientSocket;
       BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
       PrintWriter out = new PrintWriter(socket.getOutputStream())) {

    while (true) {
      String request = in.readLine();
      if (request.equalsIgnoreCase("stop")) {
        stop = true;
        out.println("서버를 종료하는 중입니다.");
        out.println();
        out.flush();
        break;
      }
    }

  } catch (Exception e) {
    System.out.println("클라이언트와의 통신 오류!");
  }

  System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n",
                    address.getHostAddress());
}

ServerApp.service()

클라이언트와 연결할 때 “stop”의 상태가 true 이면 서버를 멈춘다. 다만 서버를 멈추기 위해서는 반복문이 다시 한번 돌아야 한다, 즉 stop을 입력한 클라이언트 뒤 다른 클라이언트가 접속했을 때 비로소 서버가 종료된다. 이것은 클라이언트 앱에서 처리해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void service(int port) {
  notifyApplicationContextListenerOnServiceStarted();
  try (ServerSocket serverSocket = new ServerSocket(port)) {
    System.out.prinltn("서버 실행 중...");
    
    while (true) {
      // stop이 true이면 서버를 멈춘다.
      if (stop)
        break;
      Socket clientSocket = serverSocket.accept();
      new Thread(() -> handleClient(clientSocket)).start();
    }
  } catch (Exception e) {
    e.printStackTrace();
  }
  notifyApplicationContextListenerOnServicStopped();
}

4단계 - 파일에서 JSON 데이터를 로딩하고 파일로 저장하는 옵저버를 등록한다.

ServerApp.main()

AppInitListener와 DataHandlerListener를 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ServerApp {
  List<ApplicationContextListener> listeners = new ArrayList<>();
  
  public void addApplicationContextListener(ApplicationContextListener listener) {
    listeners.add(listener);
  }
  
  public void removeApplicationContextListener(ApplicationContextListener listener) {
    listeners.remove(listener);
  }

  //...
  public static void main(String[] args) {
    
    ServerApp server = new ServerApp();
    // 옵저버 등록
    server.addApplicationContextListener(new AppInitListener());
    server.addApplicationContextListener(new DataHandlerListener());

    server.service(8888);
  }
}

5단계 - 클라이언트의 요청을 처리하는 Command 객체를 준비한다.

pms.listener.RequestMappingListener

DataHandlerListener 가 준비한 데이터를 가지고 Command 객체를 생성한다.

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
public class RequestMappingListener implements ApplicationContextListener{

  @SuppressWarnings("unchecked")
  @Override
  public void contextInitialized(Map<String, Object> context) {
    
    List<Board> boardList = (List<Board>) context.get("boardList");
    List<Member> memberList = (List<Member>) context.get("memberList");
    List<Project> projectList = (List<Project>) context.get("projectList");
    List<Task> taskList = (List<Task>) context.get("taskList");
    
    context.put("/board/add", new BoardAddCommand(boardList));
    context.put("/board/list", new BoardListCommand(boardList));
    context.put("/board/update", new BoardUpdateCommand(boardList));
    context.put("/board/delete", new BoardDeleteCommand(boardList));

    MemberListCommand memberListCommand = new MemberListCommand(memberList);
    context.put("/member/add", new MemberAddCommand(memberList));
    context.put("/member/list", memberListCommand);
    context.put("/member/detail", new MemberDetailCommand(memberList));
    context.put("/member/update", new MemberUpdateCommand(memberList));
    context.put("/member/delete", new MemberDeleteCommand(memberList));

    context.put("/project/add", new ProjectAddCommand(projectList, memberListCommand));
    context.put("/project/list", new ProjectListCommand(projectList));
    context.put("/project/detail", new ProjectDetailCommand(projectList));
    context.put("/project/update", new ProjectUpdateCommand(projectList, memberListCommand));
    context.put("/project/delete", new ProjectDeleteCommand(projectList));

    context.put("/task/add", new TaskAddCommand(taskList, memberListCommand));
    context.put("/task/list", new TaskListCommand(taskList));
    context.put("/task/detail", new TaskDetailCommand(taskList));
    context.put("/task/update", new TaskUpdateCommand(taskList, memberListCommand));
    context.put("/task/delete", new TaskDeleteCommand(taskList));

    context.put("/hello", new HelloCommand());
    
  }

  @Override
  public void contextDestroyed(Map<String, Object> context) { 
  } 
}

ServerApp.main()

RequestMappingListener 를 등록한다.

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {

  ServerApp server = new ServerApp();
  
  server.addApplicationContextListener(new AppInitListener());
  server.addApplicationContextListener(new DataHandlerListener());
  // 옵저버 등록
  server.addApplicationContextListener(new RequestMappingListener());

  server.service(8888);
}

6단계 - 클라이언트 명령이 들어오면 커맨드 객체를 찾아 실행한다.

pms.handler.Command 인터페이스

커맨드 객체가 클라이언트에게 응답할 수 있도록 출력 스트림 객체를 넘겨준다.

1
2
3
4
// 사용자의 명령을 처리하는 객체에 대해 호출할 메서드 규칙
public interface Command {
  void execute(PrintWriter out);
}

handler.XxxListCommand 구현체 변경

execute() 메서드의 파라미터로 PrintWriter out을 정의하고, 콘솔 창에 출력하는 코드(System.out.println)을 out.println으로 변경한다. 다음은 BoardListCommand의 코드이다.

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
public class BoardListCommand implements Command {

  List<Board> boardList;

  public BoardListCommand(List<Board> list) {
    this.boardList = list;
  }

  @Override
  public void execute(PrintWriter out) {
    out.println("[게시물 목록]");

    Iterator<Board> iterator = boardList.iterator();

    while (iterator.hasNext()) {
      Board board = iterator.next();
      out.printf("%d, %s, %s, %s, %d\n",
          board.getNo(),
          board.getTitle(),
          board.getWriter(),
          board.getRegisteredDate(),
          board.getViewCount());
    }
  }
}

ServerApp.handleClient

커맨드 객체의 execute()를 호출할 때 클라이언트 출력 스트림을 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  public void handleClient(Socket clientSocket) {
    //...

    try (//...
    ) {

      while (true) {
			 //..
        // 클라이언트가 요청을 보내면 
        // 요청에 해당하는 커맨드 객체를 context에서 찾는다.
        Command command = (Command) context.get(request);
        if (command != null)
          // 출력 스트림 제공
          command.execute(out);
        else
          out.println("해당 명령을 처리할 수 없습니다.");
        
        out.println();
        out.flush();
       
      }

7단계 - 클라이언트에게 입력 값을 요구할 수 있도록 프로토콜을 변경한다.

pms.handler.Command 인터페이스

클라이언트 입력 값을 읽을 수 있도록 파라미터에 입력 스트림을 추가한다.

1
2
3
public interface Command {
  void execute(PrintWriter out, BufferedReader in);
}

pms.handler.XxxListCommand

Command 인터페이스 변경에 따라 구현체의 execute() 메서드의 코드를 수정한다.

util.Prompt

파라미터로 받은 출력 스트림으로 프롬프트 제목을 출력하고, 파라미터로 받은 입력 스트림에서 값을 읽어 리턴하는 메서드를 추가한다. 이때 추가하는 메서드는 기존 메서드와 기능이 같기 때문에 이름을 같게 만들자. 다시 말해, 오버로딩하자. 오버로딩하면 메서드이름을 따로따로 암기할 필요 없이 프로그래밍의 일관성을 유지할 수 있게 해준다.

오버로딩: 파라미터가 다르더라도 같은 일을 하는 메서드에 대해서 같은 이름을 부여함으로써 호출하는 쪽에서 일관적으로 호출하도록 해서 프로그래밍의 일관성을 유지하는 문법

오버라이딩: 상속받은 메서드가 서브 클래스의 역할에 맞지 않을 때 맞게 다시 재정의

oop(상속, 다형성, 캡슐화), 디자인패턴, 리팩토링 이 모든 것의 목표는 소스 코드를 보다 간단하게 만드는 것이다. 소스 코드가 간단하다는 것은 무엇인가? 첫번째, 읽기 쉽다. 두 번째, 기능 추가/변형/삭제(할 때 기존 코드를 최소로 손대야 한다. 그러면 버그가 줄어들고, 편집 시간은 줄어든다.

리팩토링은 최적화와 상관 없다.

클라이언트로 출력할 때는 제목 다음에 !{}! 문자열을 보내 클라이언트가 사용자로부터 값을 입력받아 다시 서버에 보내도록 요청한다.

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
public class Prompt {
  static Scanner keyboardScan = new Scanner(System.in);
  
  public static String inputString(
      String title,
      PrintWriter out,
      BufferedReader in) throws Exception {
    
    out.println(title); // 클라이언트가 출력할 프롬프트 제목
    out.println("!{}!"); // 클라이언트에게 값을 보내라는 요청
    out.flush(); // 출력하면 버퍼에 쌓인다. "클라이언트로 보내고 싶으면" flush() 호출한다.
    return in.readLine(); // 클라이언트가 보낸 값을 읽기
  }

  public static int inputInt(
      String title,
      PrintWriter out,
      BufferedReader in) throws Exception {
    return Integer.parseInt(inputString(title, out, in));
  }

  public static Date inputDate(
      String title,
      PrintWriter out,
      BufferedReader in) throws Exception {
    return Date.valueOf(inputString(title, out, in));
  }
	//...
}

handler.Xxx[Add|Detail|Update|Delete]Command 구현체 변경

Command 인터페이스 변경에 따라 execute() 메서드의 코드를 수정한다.

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
public class BoardAddCommand implements Command {

  List<Board> boardList;

  public BoardAddCommand(List<Board> list) {
    this.boardList = list;
  }

  @Override
  public void execute(PrintWriter out, BufferedReader in) {
    try {
      out.println("[게시물 등록]");

      Board board = new Board();
      board.setNo(Prompt.inputInt("번호? ", out, in));
      board.setTitle(Prompt.inputString("제목? ", out, in));
      board.setContent(Prompt.inputString("내용? ", out, in));
      board.setWriter(Prompt.inputString("작성자? ", out, in));
      board.setRegisteredDate(new Date(System.currentTimeMillis()));
      board.setViewCount(0);

      boardList.add(board);

      out.println("게시글을 등록하였습니다.");
    } catch (Exception e) {
      System.out.println("게시물 등록 중 오류 발생!");
    }
  }
}

ServerApp.handleClient

커맨드 객체의 execute()를 호출할 때, 클라이언트 출력 스트림과 입력 스트림을 제공한다.

1
2
3
4
5
6
7
8
9
10
//...
Command command = (Command) context.get(request);
if (command != null) {
  command.execute(out, in);
} else {
  out.println("해당 명령을 처리할 수 없습니다!");
}

out.println();
out.flush();

8단계 - 디자인 패턴와 C/S 애플리케이션 아키텍처의 유용성을 확인한다.

지금 프로그램은 커맨드 패턴을 적용하였기 때문에 새로운 기능을 추가하기 굉장히 편하다. 기존 코드를 손 대지 않고 새 클래스를 추가만 하면 되기 때문이다. 따라서 이 방식은 명령 당 한 개의 메서드를 만드는 상황에서 적절한 설계기법이고, 유지보수하기 좋다.

계산기 기능을 수행하는 새 명령을 추가해보자.

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 CalculatorCommand implements Command {

  @Override
  public void execute(PrintWriter out, BufferedReader in) {
    try {
      out.println("[계산기]");
      
      String input = Prompt.inputString("계산식?(예: 5 * 3");
      String[] arr = input.split(" ");
      if (arr.length != 3) {
        out.println("계산식이 옳지 않습니다.");
        out.println("계산식 예: 15 * 45");
        return;
      }
      
      int a = Integer.parseInt(arr[0]);
      String op  = arr[1];
      int b = Integer.parseInt(arr[2]);
      int result = 0;
      
      switch (op) {
        case "+": result = a + b; break;
        case "-": result = a - b; break;
        case "/": result = a / b; break;
        case "*": result = a * b; break;
        default:
          out.println("해당 연산을 지원하지 않습니다.");
          return;
      }
      
      out.printf("계산 결과: %d %s %d = %d", a, op, b, result);
      
    } catch (Exception e) {
      System.out.println("작업 처리 중 오류 발생!");
    } 
  }
}

RequestMappingListener 클래스에서 CalculatorCommand를 생성해 context 맵에 보관한다. 옵저버 패턴을 사용하고 있기 때문에 기존 ServerApp 클래스는 손댈 필요가 없다.

1
2
3
4
5
6
7
8
9
10
public class RequestMappingListener implements ApplicationContextListener {

  @SuppressWarnings("unchecked")
  @Override
  public void contextInitialized(Map<String,Object> context) {
    //....
    context.put("/hello", new HelloCommand());
  }
  //....
}

9단계 - “quit”, “stop” 명령어를 처리한다.

ServerApp.handleClient()

  • stop 명령: 클라이언트에게 안내 메시지를 보낸 후 서버를 종료해야 하는 상태로 만든다.
  • quit 명령: 클라이언트에게 안내 메시지를 보낸 후 클라이언트와 연결을 끊는다.
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
  private static void handleClient(Socket clientSocket) {
    InetAddress address = clientSocket.getInetAddress();
    System.out.printf("클라이언트(%s)가 연결되었습니다.\n",
        address.getHostAddress());

    try (Socket socket = clientSocket; // try 블록을 떠날 때 close()가 자동 호출된다.
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        PrintWriter out = new PrintWriter(socket.getOutputStream())) {

      while (true) {
        String request = in.readLine();

        if (request.equalsIgnoreCase("quit")) {
          out.println("안녕!");
          out.println();
          out.flush();
          break;
        } else if (request.equalsIgnoreCase("stop")) {
          stop = true; // 서버의 상태를 멈추라는 의미로 true로 설정한다.
          out.println("서버를 종료하는 중입니다!");
          out.println();
          out.flush();
          break;
        }

        Command command = (Command) context.get(request);
        if (command != null) {
          command.execute(out, in);
        } else {
          out.println("해당 명령을 처리할 수 없습니다!");
        }

        // 응답의 끝을 알리는 빈 문자열을 보낸다.
        out.println();
        out.flush();

      }

    } catch (Exception e) {
      System.out.println("클라이언트와의 통신 오류!");
    }

    System.out.printf("클라이언트(%s)와의 연결을 끊었습니다.\n",
        address.getHostAddress());
  }

초보가 가장 많이 하는 실수가 자기가 만든 코드를 아까워서 안 지우려고 하는 것이다. 초보 개발자에게는 코드가 남는 것이 아니라 코드를 만든 경험이 남는다! 무서워 하지 말고 과감하게 지우고 계속 코딩하자!

아이피 주소가 바뀌는 이유

  • 한 달에 한 번씩 아이피 주소가 바뀐다.
  • 일정 시간마다 아이피 주소가 바뀔 수 있다: 다이나믹 아이피
  • real ip도 접속하는 순간에만 우리 것이지, 꺼버리면 아이피가 다른 사람한테 갈 수 있다. 한편, 서버 아이피는 고정되어 있어야 한다. 그러면 고정 아이피를 받아야 한다. 한달에 일정사용료 내야 한다. 아이피도 real ip이면서 고정 아이피이다. 이제서야 도메인 이름을 부여할 수 있다. 고정 아이피에 대해서 도메인 이름을 부여할 수 있지, 다이나믹 아이피에는 도메인 이름을 부여할 수 없다.

Client

  • 서버에 stop 명령을 보내면 클라이언트를 즉시 종료한다.
  • 서버가 입력 값을 요구하면 사용자로부터 입력 값을 받아 보낸다.
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
public class ClientApp {
  public static void main(String[] args) {
    if (args.length != 2) {
      System.out.println("프로그램 사용법:");
      System.out.println("  java -cp ... ClientApp 서버주소 포트번호");
      System.exit(0);
    }

    // 클라이언트가 서버에 stop 명령을 보내면 다음 변수를 true로 변경한다.
    boolean stop = false;

    try (Socket socket = new Socket(args[0], Integer.parseInt(args[1]));
        PrintWriter out = new PrintWriter(socket.getOutputStream());
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

      while (true) {
        String input = Prompt.inputString("명령> ");
        out.println(input);
        out.flush();

        receiveResponse(out, in);

        if (input.equalsIgnoreCase("quit")) {
          break;
        } else if (input.equalsIgnoreCase("stop")) {
          stop = true;
          break;
        }
      }

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

    if (stop) {
      // 서버를 멈추기 위해 그냥 접속했다가 끊는다.
      try (Socket socket = new Socket(args[0], Integer.parseInt(args[1]))) {
        // 아무것도 안한다.
        // 서버가 stop 할 기회를 주기 위함이다.
      } catch (Exception e) {
        // 아무것도 안한다.
      }
    }
  }

  private static void receiveResponse(PrintWriter out, BufferedReader in) throws Exception {
    while (true) {
      String response = in.readLine();
      if (response.length() == 0) {
        break;
      } else if (response.equals("!{}!")) {
        // 사용자로부터 값을 입력을 받아서 서버에 보낸다.
        out.println(Prompt.inputString(""));
        out.flush(); // 주의! 출력하면 버퍼에 쌓인다. 서버로 보내고 싶다면 flush()를 호출하라!
      } else {
        System.out.println(response);
      }
    }
  }
}

완성된 프로그램을 보면 거의 모든 작업을 서버에서 하고 있다. 모든 작업을 서버에서 한다면 사용자 pc 는 성능이 좋을 필요가 없다. 서버에서 어플리케이션을 돌린다고 해서 어플리케이션 서버라고 한다. 즉 가벼운 클라이언트, Thin Client가 Application Server에 요청을 한다. 이것이 C/S 프로그램으로 바꾼 이유이다.

This post is licensed under CC BY 4.0 by the author.

학원 #56일차: PMS 프로젝트: Observer 패턴의 활용

모두의 네트워크 #2장: 네트워크 기본 규칙

Loading comments from Disqus ...