TCP(Transmission Control Protocol, 연결 지향적 프로토콜)
이란 클라이언트와 서버가 연결된 상태에서 데이터를 주고받는 프로토콜이다.
UDP(User Datagram Protocol)
보다 데이터 전송 속도가 느릴 수 있다.java.net.ServerSocket
과 java.net.Socket
클래스를 제공하고 있다.TCP 서버의 역할은 클라이언트가 연결 요청을 해오면 연결을 수락하고, 연결된 클라이언트와 통신하는 것이다.
자바에서는 이 두 역할별로 별도의 클래스를 제공하고 있다.
java.net.ServerSocket
클래스이고,java.net.Socket
클래스이다.클라이언트가 연결 요청을 해오면 ServerSocket
은 연결을 수락하고 통신용 Socket을 만든다.
서버는 클라이언트가 접속할 포트를 가지고 있어야 하는데, 이 포트를 바인딩(binding) 포트
라고 한다. 서버는 고정된 포트 번호에 바인딩해서 실행하므로, ServerSocket
을 생성할 때 포트 번호 하나를 지정해야 한다.
서버가 실행되면 클라이언트는 서버의 IP 주소와 바인딩 포트 번호로 Socket을 생성해서 연결 요청을 할 수 있다. ServerSocket
은 클라이언트가 연결 요청을 해오면 accept()
메소드로 연결 수락을 하고 통신용 Socket을 생성한다. 그러고 나서 클라이언트와 서버는 각각의 Socket을 이용해서 데이터를 주고받게 된다.
서버를 개발하려면 우선 ServerSocket
객체를 얻어야 한다.
- Ex) 5001번 포트에 바인딩하는
ServerSocket
을 생성한다.ServerSocket serverSocket = new ServerSocket(5001);
- 디폴트 생성자로 객체를 생성하고 포트 바인딩을 위해
bind()
메소드를 호출한다.bind()
메소드의 매개값은 포트 정보를 가진InetSocketAddress
이다.ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(5001));
"localhost"
대신 정확한 IP를 주면 된다.ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("localhost", 5001));
ServerSocket
을 생성할 때 해당 포트가 이미 다른 프로그램에서 사용 중이라면 BindException
이 발생한다. ServerSocket
은 클라이언트 연결 수락을 위해 accept()
메소드를 실행해야 한다.accept()
메소드는 클라이언트가 연결 요청하기 전까지 블로킹되는데, 블로킹이란 스레드가 대기 상태가 된다는 뜻이다. accept()
메소드를 호출하면 안 된다. accept()
는 클라이언트와 통신할 Socket을 만들고 리턴한다. (연결 수락)accept()
에서 블로킹되어 있을 때 ServerSocket
을 닫기 위해 close()
메소드를 호출하면 SocketException
이 발생하기 때문에 예외 처리가 필요하다.try {
Socket socket = serverSocket.accept();
} catch(Exception e) { }
getRemoteSocketAddress()
메소드를 호출해서 SocketAddress
를 얻으면 된다. InetSocketAddress
객체이므로 다음과 같이 타입 변환할 수 있다.InetSocketAddress socketAddress = (InetSocketAddress) socket.getRemoteSocketAddress();
InetSocketAddress
에는 IP와 포트 정보를 리턴하는 다음과 같은 메소드들이 있다.리턴 타입 | 메소드명(매개 변수) | 설명 |
---|---|---|
String | getHostName() | 클라이언트 IP 리턴 |
int | getPort() | 클라이언트 포트 번호 리턴 |
String | toString() | "IP:포트번호" 형태의 문자열 리턴 |
close()
메소드를 호출해서 포트를 언바인딩시켜야 한다. 그래야 다른 프로그램에서 해당 포트를 재사용할 수 있다.serverSocket.close();
클라이언트가 서버에 연결 요청을 하려면 java.net.Socket
을 이용해야 한다.
Socket
객체 생성과 연결 요청을 동시에 하려면, 생성자의 매개값으로 서버의 IP 주소와 바인딩 포트 번호를 제공하면 된다.
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를 입력하면 된다. InetSocketAddress
객체를 이용해야 한다.
Socket
생성과 동시에 연결 요청을 하지 않고, 기본 생성자로 Socket을 생성한 후,connect()
메소드로 연결 요청을 할 수도 있다.socket = new Socket(); socket.connect(new InetSocketAddress("localhost", 5001));
연결 요청을 할 때는 두 가지 예외가 발생할 수 있다.
UnknownHostException
은 잘못 표기된 IP 주소를 입력했을 경우에 발생하고,IOException
은 주어진 포트로 접속할 수 없을 때 발생한다.
Socket 생성자
및 connect()
메소드는 서버와 연결이 될 때까지 블로킹된다. Socket 생성자
및 connect()
를 호출하면 안 된다.close()
메소드를 다음과 같이 호출하면 된다. close()
메소드도 IOException
이 발생할 수 있기 때문에 예외 처리가 필요하다.try {
socket.close();
} catch (IOException e) { }
클라이언트가 연결 요청(
connect()
)하고 서버가 연결 수락(accept()
)했다면,
양쪽의 Socket 객체로부터 각각 입력 스트림(InputStream)과 출력 스트림(OutputStream)을 얻을 수 있다.
//입력 스트림 얻기
InputStream is = socket.getInputStream();
//출력 스트림 얻기
OutputStream os = socket.getOutputStream();
byte[]
배열로 생성하고, 이것을 매개값으로 해서 OutputStream
의 write()
메소드를 호출하면 된다.write()
메소드로 전송한다.String data = "보낼 데이터";
byte[] byteArr = data.getBytes("UTF-8");
OutputStream outputStream = socket.getOutputStream();
outputStream.write(byteArr);
outputStream.flush();
byte[]
배열을 하나 생성하고, 이것을 매개값으로 해서 InputStream
의 read()
메소드를 호출하면 된다. read()
메소드는 읽은 데이터를 byte[]
배열에 저장하고 읽은 바이트 수를 리턴한다.byte[] byteArr = new byte[100];
InputStream inputStream = socket.getInputStream();
int readByteCount = inputStream.read(byteArr);
String data = new String(byteArr, 0, readByteCount, "UTF-8");
데이터를 받기 위해 InputStream
의 read()
메소드를 호출해서 상대방이 데이터를 보내기 전까지는 블로킹(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) { }
}
연결 수락을 위해 ServerSocket
의 accept()
를 실행하거나, 서버 연결 요청을 위해 Socket
생성자 또는 connect()
를 실행할 경우에는 해당 작업이 완료되기 전까지 블로킹(blocking)된다.
데이터 통신을 할 때에도 마찬가지로, InputStream
의 read()
메소드는 상대방이 데이터를 보내기 전까지 블로킹되고, OutputStream
의 write()
메소드는 데이터를 완전학 보내기 전까지 블로킹된다. 결론적으로 ServerSocket
과 Socket
은 동기(블로킹) 방식으로 구동된다.
만약 서버를 실행시키는 main
스레드가 직접 입출력 작업을 담당하게 되면 입출력이 완료될 때까지 다른 작업을 할 수 없는 상태가 된다. 서버 애플리케이션은 지속적으로 클라이언트의 연결 수락 기능을 수행해야 하는데, 입출력에서 블로킹되면 이 작업을 할 수 없게 된다. 또한 클라이언트1과 입출력하는 동안에는 클라이언트2와 입출력을 할 수 없게 된다. 그렇기 때문에 accept()
, connect()
, read()
, write()
는 별도의 작업 스레드를 생성해서 병렬적으로 처리하는 것이 좋다.
스레드로 병렬 처리를 할 경우, 수천 개의 클라이언트가 동시에 연결되면 서버에서 수천 개의 스레드가 생성되기 때문에 서버 성능이 급격히 저하되고, 다운되는 현상이 발생할 수 있다. 클라이언트의 폭증으로 인해 서버의 과도한 스레드 생성을 방지하려면 스레드풀을 사용하는 것이 바람직하다.
① 클라이언트가 연결 요청을 하면
② 서버의 스레드풀에서 연결 수락을 하고 Socket을 생성한다.
③ 클라이언트가 작업 처리 요청을 하면
④ 서버의 스레드풀에서 요청을 처리하고
⑤ 응답을 클라이언트로 보낸다.
스레드풀은 스레드 수를 제한해서 사용하기 때문에 갑작스러운 클라이언트의 폭증은 작업 큐의 작업량만 증가시킬 뿐 스레드 수는 변함이 없기 때문에 서버 성능은 완만히 저하된다. 다만 대기하는 작업량이 많을 경우 개별 클라이언트에서 응답을 늦게 받을 수 있다.
이것이 자바다 책