Posts 학원 #52일차: 계산기 Network App 만들기, 스레드
Post
Cancel

학원 #52일차: 계산기 Network App 만들기, 스레드

계산기 프로그램 만들기

요구사항

  • 계산기 서버와 클라이언트 만들기

  • 구현 조건: 최소 +, -, *, % 연산자는 지원한다.

  • 실행 예: 클라이언트가 계산기 서버에 접속한 후

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    계산기 서버에 오신  환영합니다!         <== 서버의 응답 
    계산식을 입력하세요!                      <== 서버의 응답 
    ) 23 + 7                                <== 서버의 응답 
    계산식> 23 + 7                            <== 사용자의 입력. '계산식>' 문자는 클라이언트에서 출력한다. 
    결과는 23 + 7 = 30 입니다.                <== 서버의 응답 
    계산식> 23 ^ 7                            <== 사용자의 입력. '계산식>' 문자는 클라이언트에서 출력한다. 
    ^ 연산자를 지원하지 않습니다.             <== 서버의 응답 
    계산식> 23                                <== 사용자의 입력 
    입력 형식이 올바르지 않습니다. ) 23 + 5 <== 클라이언트에서 처리
    계산식> 23 + 2 - 1                        <== 사용자의 입력 
    입력 형식이 올바르지 않습니다. ) 23 + 5 <== 클라이언트에서 처리 
    계산식> ok + no                           <== 사용자의 입력 
    계산  오류 발생 - (예외 메시지)         <== 서버의 응답 
    계산식> 23 / 0                            <== 사용자의 입력 
    계산  오류 발생 - (예외 메시지)         <== 서버의 응답 
    계산식> quit                              <== 사용자의 입력. '계산식>' 문자는 클라이언트에서 출력한다. 
    안녕히 가세요!                            <== 서버의 응답
    

1단계

Client: 단순히 서버에 요청하고 응답을 받아 출력한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CalculatorClient {
  public static void main(String[] args) {

    try (Socket socket = new Socket("localhost", 8888);
        PrintStream out = new PrintStream(socket.getOutputStream());
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

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

      input = in.readLine();
      System.out.println(input);

      input = in.readLine();
      System.out.println(input);

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

Server: 단순히 클라이언트 요청에 응답한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CalculatorServer {
  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()));
          PrintStream out = new PrintStream(socket.getOutputStream());) {

        out.println("계산기 서버에 오신 걸 환영합니다!");
        out.println("계산식을 입력하세요!");
        out.println("예) 23 + 7");
        out.flush();
      }

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

2단계: 응답의 종료 조건을 설정

  • 응답의 종료 조건을 설정하면 언제까지 읽어야 할 지 결정하기 쉽다.
  • 빈 줄을 받을 때까지 응답을 읽어 출력한다.

Client

1
2
3
4
5
6
7
while (true) {
  String input = in.readLine();
  if (input.length() == 0)
    // 빈 줄을 읽었다면 읽기를 끝낸다.
    break;
  System.out.println(input);
}

Server

1
2
3
4
5
out.println("계산기 서버에 오신 걸 환영합니다!");
out.println("계산식을 입력하세요!");
out.println("예) 23 + 7");
out.println(); // 응답의 끝을 표시하는 빈 줄을 보낸다.
out.flush();

3단계: 메서드 분리

기능 별로 메서드를 분리하면 코드를 관리하기가 편하다.

Client: 응답을 읽는 코드를 별도의 메서드로 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) {
  try (Socket socket = new Socket("localhost", 8888);
      PrintStream out = new PrintStream(socket.getOutputStream());
      BufferedInputStream = new BufferedInputStream(new InputStreamREader(socket.getInputStream()))) {
    
    readResponse(in);
  } catch (Exception e) {
    e.printStackTrace();
  }
}

static void readResponse(BufferedInputStream in) throws Exception {
  while (true) {
    String input = in.readLine();
    if (input.length() == 0)
      break;
    System.out.println(input);
  }
}

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
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()));
        PrintStream out = new PrintStream(socket.getOutputStream())) {
      
      sendIntroMessage(out);
      
    }
  } catch (Exception e) {
    e.printStackTrace();
  }
} 

static void sendIntroMessage(PrintStream out) throws Exception {
    out.println("[비트캠프 계산기]");
    out.println("계산기 서버에 오신 걸 환영합니다!");
    out.println("계산식을 입력하세요!");
    out.println("예) 23 + 7");
    out.println(); // 응답의 끝을 표시하는 빈 줄을 보낸다.
    out.flush();
}

4단계

Client: 사용자로부터 계산식을 입력받아서 서버에 전달

1
2
3
4
5
6
7
8
readResponse(in); // 서버의 인삿말을 읽기

while(true) {
  String input = keyBoardScanner.nextLine();
  out.println(input);
  out.flush();
  readResponse(); // 서버의 실행 결과를 출력
}

Server: 클라이언트가 보낸 요청을 받아 그대로 되돌려 준다.

1
2
3
4
5
6
7
8
sendIntroMessage(out);

while (true) {
  String request = in.readLine();
  out.println(request);
  out.println();
  out.flush();
}

5단계: 코드 리팩토링

Client

  • 응답을 보내는 코드를 분리하여 sendRequest() 메서드로 만든다.
  • 기존 readResponse() 메서드명을 receiveResponse()로 바꾼다.
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 static void main(String[] args) {

  try (
    Scanner keyboardScanner = new Scanner(System.in);
    Socket socket = new Socket("localhost", 8888);
    PrintStream out = new PrintStream(socket.getOutputStream());
    BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

    sendIntroMessage(out);

    while (true) {
      String input = keyboardScanner.nextLine();
      sendRequest(out, input); // 서버에 요청을 보내기
      receiveResponse(in); // 서버의 실행 결과를 받기
    }

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

// 메서드 분리
static void sendRequest(PrintStream out, String message) {
  out.println(message);
  out.flush();
}

static void receiveResponse(BufferedReader in) throws Exception {
  while (true) {
    String input = in.readLine();
    if (input.length() == 0) {
      // 빈 줄을 읽었다면 읽기를 끝낸다.
      break;
    }
    System.out.println(input);
  }
}

Server

  • 클라이언트에게 응답하는 코드를 sendResponse() 메서드로 추출
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
public class CalculatorServer {
  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()));
          PrintStream out = new PrintStream(socket.getOutputStream());) {
        
        sendIntroMessage(out);
        
        while (true) {
          String request = in.readLine();
          sendResponse(out, request); // 클라이언트에게 응답한다.
        }
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
  static void sendResponse(printStream out, String message) {
    out.println(message);
    out.println();
    out.flush();
  }
  
  static void sendIntroMessage(PrintStream out) throws Exception {
    out.println("[비트캠프 계산기]");
    out.println("계산기 서버에 오신 걸 환영합니다!");
    out.println("계산식을 입력하세요!");
    out.println("예) 23 + 7");
    out.println(); // 응답의 끝을 표시하는 빈 줄을 보낸다.
    out.flush();
  }
}

6단계

Client: 사용자에게 프롬프트 제시하고 계산식을 입력 받기

  • prompt()
    • 사용자로부터 계산식을 입력받는 메서드
    • 사용자가 입력한 값을 검증한다. (올바르지 않은 값이면 null)
  • prompt()의 리턴값이 null이라면 continue를 통해 값을 다시 입력받게 한다.

main()

1
2
3
4
5
6
7
8
9
10
11
//..
while(true) {
  String input = prompt(keyboardScanner);
  
  if (input == null) 
    continue;
  
  sendRequest(out, input);
  receiveResponse(in);
}
//..

prompt()

1
2
3
4
5
6
7
8
9
static void prompt(PrintStream out, Scanner keyboardScanner) {
  System.out.print("계산식> ");
  String input = keyboardScanner.readLine();
  if (input.split(" ").length != 3) { // 사용자가 입력한 값을 검증
    System.out.println("입력 형식이 올바르지 않습니다. 예) 23 + 5");
    return null;
  }
  return input;
}

Server: 클라이언트가 보내온 계산식을 실행하여 결과를 리턴

main()

1
2
3
4
5
6
7
8
//..
sendIntroMessage(out);

while(true) {
  String request = in.readLine();
  String message = compute(request);
  sendResponse(out, message); // 클라이언트에게 응답한다.
}

compute()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static String compute(String request) {
  String[] values = request.split(" ");
  
  int a = Integer.parseInt(values[0]);
  String op = values[1];
  int b = Integer.parseInt(values[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:
      return String.format("%s 연산자를 지원하지 않습니다.", op);
  }
  return String.format("결과는 %d %s %d = %d 입니다.", a, op, b, result);
}

7단계

Client: 프로그램 종료 명령 ‘quit’ 처리하기

main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//..
receiveResponse(in); // 서버의 인사말을 받기

while (true) {
  String input = prompt(keyboardScanner);
  if (input == null) {
    continue;
  }
  String message = compute(request);
  sendRequest(out, message);
  
  if (input.equalsIgnoreCase("quit"))
    break;
}
//..

Server: 클라이언트의 종료 요청 ‘quit’ 처리

main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//..
sendIntroMessage(out);

while (true) {
  String request = in.readLine();
  if (request.equalsIgnoreCase("quit")) {
    sendResponse(out, "안녕히 가세요!");
    break;
  }
  
  String message = compute(request);
  sendRequest(out, message);
}
//..

8단계: 예외처리 추가

Server

compute()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static String compute(String request) {
  try {
    String[] values = request.split(" ");
    int a = Integer.parseInt(values[0]);
    String op = values[1];
    int b = Integer.parseInt(values[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:
        return String.format("%s 연산자를 지원하지 않습니다.", op);
    }
    return String.format("결과는 %d %s %d = %d 입니다.", a, op, b, result);
  } catch (Exception e) {
    return String.format("계산 중 오류 발생! - %s", e.getMessage());
  }
}

9단계: 리팩토링

Server: 클라이언트 요청을 처리하는 메서드를 별도의 클래스로 분리

  • 소켓에 연결된 클라이언트 요청을 처리하는 RequestProcessor 클래스를 만든다.
  • 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
38
39
public class RequestProcessor {
  Socket socket;
  
  public RequestProcessor (Socket socket) {
    this.socket = socket;
  }
  
  public void service() throws Exception {
    // 소켓을 닫아주기 위해 다시 한 번 받는다.
    try (Socket socket = this.socket;
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
         PrintStream out = new PrintStream(socket.getOutputStream());
        ) {
      sendIntroMessage(out);
      
      while (true) {
        String request = in.readLine();
        if (request.equalsIgnoreCase("quit")) {
          sendResponse(out, "안녕히 가세요!");
          break;
        }
        String message = compute(request);
        sendResponse(out, message);
      }
    }
  }
  
  private String compute(String request) {
    // 요청 문자열을 받아 계산을 하고 결과값을 문자열로 리턴하는 메서드
  }
  
  private void sendResponse(PrintStream out, String message) {
    // 응답을 보내는 메서드
  } 
  
  private void sendIntroMessage(PrintStream out) {
    // 인삿말을 보내는 메서드
  }
}

socket을 static 필드가 아니라 인스턴스 필드로 만든 이유: 실무에서는 인스턴스 멤버를 기본으로 한다. Socket을 static 변수로 할 수도 있지만, 그러면 동시에 여러 클라이언트를 처리해야 할 상황이 생겼을 때 별도로 요청을 동시에 처리할 수 없다. 따라서 확장성을 위해(나중에 어떻게 될 지 모르니까 가능한 가능성을 열어둔다.) 인스턴스 필드로 만든다. 이때, 인스턴스의 값은 나중에 생성자에서 받는다. 이 인스턴스 필드를 사용하는 메서드는 인스턴스 메서드가 될 것이다. 그러면 이제 CalculatorServer에서는 RequestProcessor 인스턴스를 생성하고 사용해야 한다.

requestProcessor는 고객을 응대하는 의사이고, 클라이언트는 대기실에 들어와 있다가 순차적으로 의사를 만날 수 있다.

1
2
3
4
5
6
7
8
9
10
11
public class CalculatorServer {
  public static void main(String[] args) {
    try (ServerSocket serverSocket  new ServerSocket(8888);) {
      
      new RequestProcessor(serverSocket.accept()).service();
    
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

10단계: 다중 클라이언트 접속 처리

Server: 반복문을 이용하여 계속해서 클라이언트 접속을 처리

9단계까지는 한 클라이언트와 연결하고 클라이언트에서 quit 메시지를 보내면 서버도 종료되었다. 여러 클라이언트와 연결하기 위해 클라이언트와 연결하는 문장을 while문으로 감싼다.

1
2
3
while(true) {
  new RequestProcessor(serverSocket.accept()).service();
}

이 경우 클라이언트와 연결될 때마다 RequestProcessor 객체가 생성된다. 그러나 아직 그럴 필요가 없다. 새로운 객체가 필요한 것이 아니라 객체의 소켓 필드의 값만 다른 클라이언트 정보로 설정해주면 된다. 현재 필드 값 변경은 생성자가 초기화할 수 있다. 이 방법이 아니라 Socket 필드 값을 변경하는 setSocket() 메서드를 작성하고, CalculatorServer에서 호출하도록 만들자.

1
2
3
4
5
6
7
public class RequestProcessor {
  Socket socket;
  
  public void setSocket(Socket socket) {
    this.socket = socket;
  }
}

일종의 요리사. 일종의 의사 requestProcessor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CalculatorServer {
  public static void main(String[] args) {
    try (ServerSocket serverSocket  new ServerSocket(8888);) {
      System.out.println("서버 실행 중...");
      
      RequestProcessor requestProcessor = new RequestProcessor();
      
      while (true) {
  			requestProcessor.setSocket(serverSocket.accept()).service();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

현재 방식의 문제점

현재 작업 중인 클라이언트와의 연결이 끝날 때까지 다른 클라이언트는 대기열에서 기다려야 한다.

11단계: Stateful 방식을 Stateless 방식으로 전환

Client

  • 요청할 때 연결해서 요청이 끝나면 즉시 연결을 끊는다.
  • stateless방식은 요청 때마다 연결하기 때문에 요청을 처리하는 데 연결 시간이 추가되는 문제가 있다.
  • 즉 한 번에 한 클라이언트의 요청을 처리하기 때문에 한 클라이언트의 요청을 처리하기 때문에 특정한 클라이언트 서버에 묶이는 현상이 덜하다.
  • 서버 입장에서는 클라이언트의 대기시간을 줄일 수 있다.
  • 클라이언트 입장에서도 다른 클라이언트가 서버를 독점하는 것을 피할 수 있다.

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
public static void main(String[] args) {
  Scanner keyboardScanner = new Scanner(System.in);
  while (true) {

    // 요청할 때마다 연결하기 때문에 서버의 인사말은 더이상 출력하지 않는다.
    String input = prompt(keyboardScanner);
    if (input == null)
      continue;
    else if (input.equalsIgnoreCase("quit"))
      // quit 명령을 입력할 경우 서버에 접속할 필요 없이 즉시 클라이언트를 종료한다.
      break;
    try (
      Socket;
      PrintStream;
      Bufferedin) {

      String input = prompt(keyboardScanner);
      sendRequest(out, input); // 서버에 요청을 보내기
      receiveResponse(in); // 서버의 실행 결과를 받기
    } catch (Exception e) {
      e.printStackTrace;
    }
  }
  keyboardScanner.close();
}

prompt()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static String prompt(Scanner keyboardScanner) {
  System.out.print("계산식> ");
  String input = keyboardScanner.nextLine();

  if (input.equalsIgnoreCase("quit")) {
    return input;

  } else if (input.split(" ").length != 3) { // 사용자가 입력한 값을 검증
    System.out.println("입력 형식이 올바르지 않습니다. 예) 23 + 5");
    return null;
  }

  return input;
}

Server

  • 클라이언트 한 번 접속에 한 번의 요청만 처리한다.

RequestProcessor.service()

  • 더 이상 메서드 안에서 반복문을 실행하지 않는다.
1
2
3
4
5
6
7
8
9
10
public void service() throws Exception {
  try (Socket socket = this.socket;
      BufferedReader;
      PrintStream out;) {
    // 클라이언트 접속에 대해 더이상 안내 메시지를 제공하지 않는다.
    
    // 한 번 접속에 한 번의 요청만 처리한다.
    sendResponse(out, compute(in.readLine()));
  }
}

HTTP 프로토콜은 Stateless방식이다.

image

웹 브라우저에서도 페이지를 바꿀 때마다 (브라우저가 웹 서버로 연결하고-요청하고-응답하고-연결끊고)를 반복한다. 즉 stateless 계산기 프로그램은 웹 어플리케이션과 똑같이 동작하는 프로그램을 만난 것이다.

image

한계

1
2
3
4
5
6
7
8
switch (op) {
  case "+": result = a + b; Thread.sleep(10000); break;
  case "-": result = a - b; break;
  case "*": result = a * b; break;
  case "/": result = a / b; break;
  default:
    return String.format("%s 연산자를 지원하지 않습니다.", op);
}

stateless방식이 stateful 방식보다 클라이언트를 더 많이 수용할 수 있다는 점에서는 좋지만, 특정 요청을 수행할 때 시간이 걸리는 작업이면 그 다음 클라이언트의 요청을 처리하지 못하는 것은 매한가지이다.

12단계: 동시에 여러 클라이언트의 요청 처리

image

  • 스레드를 이용하면 동시에 여러 클라이언트 요청을 처리할 수 있다.
  • 클라이언트 요청을 처리하는 코드를 main 실행과 분리하여 별도로 실행하게 한다.

RequestProcessor

  • 해당 코드를 main 실행과 분리하여 실행한다.
  • Thread를 상속받고 run() 메서드를 오버라이딩한다.
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
public class RequestProcessor extends Thread {
  Socket socket;
  
  // run() 메서드를 재호출 할 수 없기 때문에 각 클라이언트마다 RequestProcessor를 생성해야 한다.
  // 따라서 Socket 객체를 파라미터로 받는 생성자를 정의한다.
  public RequestProcessor(Socket socket) {
    this.socket = socket;
  }
  
  @Override
  public void run() {
    // 이 메서드는 한 번 호출되면 재호출될 수 없다.
    // 따라서 한 스레드당 한 번만 호출될 수 있다.
    
    // => main 실행과 분리하여 독립적으로 실행할 코드가 있다면 이 메서드 안에 둔다.
    try (Socket socket = this.socket;
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        PrintStream out = new PrintStream(socket.getOutputStream());) {
      
      // 한 번 접속에 한 번의 요청만 처리한다.
      sendResponse(out, compute(in.readLine()));
      
    } catch (Exception e) {
      System.out.printf("클라이언트 요청 처리 중 오류 발생! - %s", e.getMessage());
    }
  }

//..

CalculatorServer

클라이언트가 접속하면, 각 클라이언트 요청을 main 실행에서 분리하여 별도로 실행해야 하기 때문에 각 클라이언트에 대해 Thread 객체를 따로 만들어 실행한다. 그래서 이전처럼 한 객체를 사용할 수는 없다.

run() 메서드를 직접 호출하면 안 된다. 스레드에게 독립적으로 실행하라고 명령해야 한다. start()메서드는 main 실행과 분리하여 별도의 실행 모드에서 run()을 호출한다. 그런 후 run() 메서드가 리턴할 때까지 기다리지 않고 즉시 리턴한다. 따라서 기존 클라이언트의 요청을 처리하는 데 시간이 걸리더라도 다른 클라이언트는 영향을 받지 않는다.

public class CalculatorServer {
  public static void main(String[] args) {
    try(ServerSocket serverSocket = new ServerSocket(8888)) {
      System.out.println("서버 실행 중...");
      while (true) {
        RequestProcessor requestProcessor = new RequestProcessor(serverSocket.accept());
        requestProcessor.start();        
      }
      
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

스레드

image

start() 메서드를 호출하는 순간 새로운 실타래, 실행, Thread를 만든다. thread를 start하면 main메서드에서 분리되어서 새로운 실행이 시작된다. 이 실타래는 main() 메서드와 다른 살타래(독립적인 실행)이다. 여기서 메서드를 호출할 수 있겠지만 그 호출도 실행 흐름 안에 있다.

클라이언트 요청을 처리할 조수를 만들고, 대기열에서 스타트를 하고 즉시 대기하고 있는 또다른 클라이언트를 꺼내서 새로운 스레드를 만든다. start()는 호출한 즉시 리턴한다. run 메서드는 직접 호출하면 안된다. 직접 호출하면 run 메서드가 리턴될 때까지 기다려야 하기 때문이다.

서버 프로그램은 반드시 멀티 스레딩 방식을 사용해야 한다.

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

학원 #51일차: HTTP, URL, Base64

학원 #53일차: 계산기 Network App, 채팅 App, 스레드

Loading comments from Disqus ...