[Java] 네트워크 기초 ②

kiteB·2022년 5월 5일
1

Java2

목록 보기
28/36
post-thumbnail

[ TCP 네트워킹 ]

TCP(Transmission Control Protocol, 연결 지향적 프로토콜)이란 클라이언트와 서버가 연결된 상태에서 데이터를 주고받는 프로토콜이다.

  • 클라이언트가 연결 요청을 하고, 서버가 연결을 수락하면 통신 선로가 고정되고, 모든 데이터는 고정된 통신 선로를 통해서 순차적으로 전달된다. 그렇기 때문에 TCP는 데이터를 정확하고 안정적으로 전달한다.
  • TCP의 단점은
    • 데이터를 보내기 전에 반드시 연결이 형성되어야 하고(가장 시간이 많이 걸리는 작업),
    • 고정된 통신 선로가 최단선(네트워크 길이 측면)이 아닐 경우 상대적으로 UDP(User Datagram Protocol)보다 데이터 전송 속도가 느릴 수 있다.
  • 자바는 TCP 네트워킹을 위해 java.net.ServerSocketjava.net.Socket 클래스를 제공하고 있다.

1. ServerSocket과 Socket의 용도

TCP 서버의 역할은 클라이언트가 연결 요청을 해오면 연결을 수락하고, 연결된 클라이언트와 통신하는 것이다.

자바에서는 이 두 역할별로 별도의 클래스를 제공하고 있다.

  • 클라이언트의 연결 요청을 기다리면서 연결 수락을 담당하는 것이 java.net.ServerSocket 클래스이고,
  • 연결된 클라이언트와 통신을 담당하는 것이 java.net.Socket 클래스이다.

클라이언트가 연결 요청을 해오면 ServerSocket은 연결을 수락하고 통신용 Socket을 만든다.

서버는 클라이언트가 접속할 포트를 가지고 있어야 하는데, 이 포트를 바인딩(binding) 포트라고 한다. 서버는 고정된 포트 번호에 바인딩해서 실행하므로, ServerSocket을 생성할 때 포트 번호 하나를 지정해야 한다.

서버가 실행되면 클라이언트는 서버의 IP 주소와 바인딩 포트 번호로 Socket을 생성해서 연결 요청을 할 수 있다. ServerSocket은 클라이언트가 연결 요청을 해오면 accept() 메소드로 연결 수락을 하고 통신용 Socket을 생성한다. 그러고 나서 클라이언트와 서버는 각각의 Socket을 이용해서 데이터를 주고받게 된다.


2. ServerSocket 생성과 연결 수락

서버를 개발하려면 우선 ServerSocket 객체를 얻어야 한다.

✅ ServerSocket을 얻는 방법

1. 생성자에 바인딩 포트를 대입하고 객체 생성하기

  • Ex) 5001번 포트에 바인딩하는 ServerSocket을 생성한다.
ServerSocket serverSocket = new ServerSocket(5001);

2. 디폴트 생성자 이용

  • 디폴트 생성자로 객체를 생성하고 포트 바인딩을 위해 bind() 메소드를 호출한다.
  • bind() 메소드의 매개값은 포트 정보를 가진 InetSocketAddress이다.
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(5001));
  • 만약 서버 PC에 멀티 IP가 할당되어 있을 경우, 특정 IP로 접속할 때만 연결 수락을 하고 싶다면, "localhost" 대신 정확한 IP를 주면 된다.
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("localhost", 5001));

✅ BindException

  • ServerSocket을 생성할 때 해당 포트가 이미 다른 프로그램에서 사용 중이라면 BindException이 발생한다.
  • 이 경우에는 다른 포트로 바인딩하거나, 다른 프로그램을 종료하고 다시 실행하면 된다.

✅ accept()

  • 포트 바인딩까지 끝났다면 ServerSocket은 클라이언트 연결 수락을 위해 accept() 메소드를 실행해야 한다.
  • accept() 메소드는 클라이언트가 연결 요청하기 전까지 블로킹되는데, 블로킹이란 스레드가 대기 상태가 된다는 뜻이다.
    • 블로킹이 되면 UI 갱신이나 이벤트 처리를 할 수 없기 때문에 UI를 생성하는 스레드나, 이벤트를 처리하는 스레드에서 accept() 메소드를 호출하면 안 된다.
  • 클라이언트가 연결 요청을 하면 accept()클라이언트와 통신할 Socket을 만들고 리턴한다. (연결 수락)
  • 만약 accept()에서 블로킹되어 있을 때 ServerSocket을 닫기 위해 close() 메소드를 호출하면 SocketException이 발생하기 때문에 예외 처리가 필요하다.
try {
    Socket socket = serverSocket.accept();
} catch(Exception e) { }

✅ getRemoteSocketAddress()

  • 연결된 클라이언트의 IP와 포트 정보를 알고 싶다면 Socket의 getRemoteSocketAddress() 메소드를 호출해서 SocketAddress를 얻으면 된다.
  • 실제 리턴되는 것은 InetSocketAddress 객체이므로 다음과 같이 타입 변환할 수 있다.
InetSocketAddress socketAddress = (InetSocketAddress) socket.getRemoteSocketAddress();

✅ InetSocketAddress 메소드

  • InetSocketAddress에는 IP와 포트 정보를 리턴하는 다음과 같은 메소드들이 있다.
리턴 타입메소드명(매개 변수)설명
StringgetHostName()클라이언트 IP 리턴
intgetPort()클라이언트 포트 번호 리턴
StringtoString()"IP:포트번호" 형태의 문자열 리턴
  • 더 이상 클라이언트 연결 수락이 필요 없으면 ServerSocket의 close() 메소드를 호출해서 포트를 언바인딩시켜야 한다. 그래야 다른 프로그램에서 해당 포트를 재사용할 수 있다.
serverSocket.close();

3. Socket 생성과 연결 요청

클라이언트가 서버에 연결 요청을 하려면 java.net.Socket을 이용해야 한다.

✅ Socket 객체 생성과 연결 요청 동시에

  • Socket 객체 생성과 연결 요청을 동시에 하려면, 생성자의 매개값으로 서버의 IP 주소와 바인딩 포트 번호를 제공하면 된다.
  • Ex) 로컬 PC의 5001 포트에 연결 요청
try {
    Socekt socket = new Socket("localhost", 5001);	//방법1
    Socket socket = new Socket(new InetSocketAddress("localhost", 5001));	//방법2
} catch (UnknownHostException e) {
    //IP 표기 방법이 잘못되었을 경우
} catch (IOException e) {
    //해당 포트의 서버에 연결할 수 없는 경우
}
  • 외부 서버에 접속하려면 localhost 대신 정확한 IP를 입력하면 된다.
  • 만약 IP 대신 도메인 이름만 알고 있다면, 도메인 이름을 IP 주소로 번역해야 하므로 InetSocketAddress 객체를 이용해야 한다.

✅ Socket 생성 후 연결 요청

  • Socket 생성과 동시에 연결 요청을 하지 않고, 기본 생성자로 Socket을 생성한 후, connect() 메소드로 연결 요청을 할 수도 있다.
socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 5001));

✅ 연결 요청 시 발생하는 예외

연결 요청을 할 때는 두 가지 예외가 발생할 수 있다.

  • UnknownHostException은 잘못 표기된 IP 주소를 입력했을 경우에 발생하고,
  • IOException은 주어진 포트로 접속할 수 없을 때 발생한다.
  • Socket 생성자connect() 메소드는 서버와 연결이 될 때까지 블로킹된다.
    • 블로킹이 되면 UI 갱신이나 이벤트 처리를 할 수 없기 때문에 UI를 생성하는 스레드나, 이벤트를 처리하는 스레드에서 Socket 생성자connect()를 호출하면 안 된다.
  • 연결된 후, 클라이언트 프로그램을 종료하거나, 강제적으로 연결을 끊고 싶다면 Socket의 close() 메소드를 다음과 같이 호출하면 된다.
    • close() 메소드도 IOException이 발생할 수 있기 때문에 예외 처리가 필요하다.
try {
    socket.close();
} catch (IOException e) { }

4. Socket 데이터 통신

클라이언트가 연결 요청(connect())하고 서버가 연결 수락(accept())했다면,
양쪽의 Socket 객체로부터 각각 입력 스트림(InputStream)과 출력 스트림(OutputStream)을 얻을 수 있다.

  • Socket으로부터 InputStream과 OutputStream을 얻는 코드
//입력 스트림 얻기
InputStream is = socket.getInputStream();

//출력 스트림 얻기
OutputStream os = socket.getOutputStream();

✅ 데이터 송신

  • 상대방에게 데이터를 보내기 위해서는 보낼 데이터를 byte[] 배열로 생성하고, 이것을 매개값으로 해서 OutputStreamwrite() 메소드를 호출하면 된다.
  • Ex) 문자열의 UTF-8로 인코딩한 바이트 배열을 얻어내고, write() 메소드로 전송한다.
String data = "보낼 데이터";
byte[] byteArr = data.getBytes("UTF-8");
OutputStream outputStream = socket.getOutputStream();
outputStream.write(byteArr);
outputStream.flush();

✅ 데이터 수신

  • 상대방이 보낸 데이터를 받기 위해서는 받은 데이터를 저장할 byte[] 배열을 하나 생성하고, 이것을 매개값으로 해서 InputStreamread() 메소드를 호출하면 된다.
  • read() 메소드는 읽은 데이터를 byte[] 배열에 저장하고 읽은 바이트 수를 리턴한다.
  • Ex) 데이터를 읽고 UTF-8로 디코딩한 문자열을 얻는 코드
byte[] byteArr = new byte[100];
InputStream inputStream = socket.getInputStream();
int readByteCount = inputStream.read(byteArr);
String data = new String(byteArr, 0, readByteCount, "UTF-8");

✅ read() 메소드가 블로킹 해제되고 리턴되는 경우

데이터를 받기 위해 InputStreamread() 메소드를 호출해서 상대방이 데이터를 보내기 전까지는 블로킹(blocking)되는데, read() 메소드가 블로킹 해제되고 리턴되는 경우는 다음 세 가지이다.

블로킹이 해제되는 경우리턴값
상대방이 데이터를 보냄읽은 바이트 수
상대방이 정상적으로 Socket의 close()를 호출-1
상대방이 비정상적으로 종료IOException 발생

상대방이 정상적으로 Socket의 close()를 호출하고 연결을 끊었을 경우와 비정상적으로 종료했을 경우, 모두 예외 처리를 해서 이쪽도 Socket을 닫기 위해 close() 메소드를 호출해야 한다.

try {
    ...
    //상대방이 비정상적으로 종료했을 경우 IOException 발생
    int readByteCount = inputStream.read(byteArr);
    
    //상대방이 정상적으로 Socket의 close()를 호출했을 경우
    if (readByteCount == -1) {
    	throws new IOException();	//강제로 IOException 발생시킴   
    }
    ...
} catch (Exception e) {
    try { socket.close(); } catch(Exception e2) { }
}

5. 스레드 병렬 처리

연결 수락을 위해 ServerSocketaccept()를 실행하거나, 서버 연결 요청을 위해 Socket 생성자 또는 connect()를 실행할 경우에는 해당 작업이 완료되기 전까지 블로킹(blocking)된다.

데이터 통신을 할 때에도 마찬가지로, InputStreamread() 메소드는 상대방이 데이터를 보내기 전까지 블로킹되고, OutputStreamwrite() 메소드는 데이터를 완전학 보내기 전까지 블로킹된다. 결론적으로 ServerSocketSocket은 동기(블로킹) 방식으로 구동된다.

만약 서버를 실행시키는 main 스레드가 직접 입출력 작업을 담당하게 되면 입출력이 완료될 때까지 다른 작업을 할 수 없는 상태가 된다. 서버 애플리케이션은 지속적으로 클라이언트의 연결 수락 기능을 수행해야 하는데, 입출력에서 블로킹되면 이 작업을 할 수 없게 된다. 또한 클라이언트1과 입출력하는 동안에는 클라이언트2와 입출력을 할 수 없게 된다. 그렇기 때문에 accept(), connect(), read(), write()는 별도의 작업 스레드를 생성해서 병렬적으로 처리하는 것이 좋다.


✅ 서버가 별도의 작업 스레드를 생성하고, 다중 클라이언트와 병렬적으로 통신하는 모습

스레드로 병렬 처리를 할 경우, 수천 개의 클라이언트가 동시에 연결되면 서버에서 수천 개의 스레드가 생성되기 때문에 서버 성능이 급격히 저하되고, 다운되는 현상이 발생할 수 있다. 클라이언트의 폭증으로 인해 서버의 과도한 스레드 생성을 방지하려면 스레드풀을 사용하는 것이 바람직하다.


✅ 스레드풀을 이용한 서버 구현 방식

① 클라이언트가 연결 요청을 하면
② 서버의 스레드풀에서 연결 수락을 하고 Socket을 생성한다.
③ 클라이언트가 작업 처리 요청을 하면
④ 서버의 스레드풀에서 요청을 처리하고
⑤ 응답을 클라이언트로 보낸다.

스레드풀은 스레드 수를 제한해서 사용하기 때문에 갑작스러운 클라이언트의 폭증은 작업 큐의 작업량만 증가시킬 뿐 스레드 수는 변함이 없기 때문에 서버 성능은 완만히 저하된다. 다만 대기하는 작업량이 많을 경우 개별 클라이언트에서 응답을 늦게 받을 수 있다.


[ 참고자료 ]

이것이 자바다 책

profile
🚧 https://coji.tistory.com/ 🏠

0개의 댓글