1. 기본 네트워킹
java에서 TCP/IP 네트워킹은 ServerSocket과 Socket을 이용한다. ServerSocket은 특정 포트에 바인딩해서 클라이언트의 연결 요청을 기다린다. Socket은 클라이언트가 서버에게 연결을 요청하고 통신을 수행하는 용도로 사용하거나 서버에서 클라이언트와 통신하기 위한 용도로 쓰인다.
ServerSocket을 생성하는 방법은 다음과 같고, 이미 사용 중인 포트에 바인딩을 하면 BindException이 발생할 수 있으므로 주의하자.
// ServerSocket 생성과 바인딩을 동시에 수행
ServerSocket serverSocket = new ServerSocket(5001);
// 따로 수행
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(5001));
// 만약에 서버에 멀티 IP가 할당되어 있을 때, 특정 IP로 오는 요청만 연결 수락을 하고 싶은 경우
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("localhost", 5001));
위에 코드에서 쓰인 InetAddress는 자바에서 IP 주소를 표현할 때 쓰는 객체이다. 로컬 컴퓨터의 IP 주소 뿐만 아니라 도메인 이름을 DNS에서 검색한 후 IP 주소를 가져오는 기능을 제공한다.
ServerSocket이 생성되었다면 클라이언트 연결 수락을 위해 accept() 메소드를 실행해야 한다. accept() 메소드는 클라이언트가 연결 요청하기 전까지 스레드가 블로킹된다. 따라서 UI를 생성하는 스레드나, 이벤트를 처리하는 스레드에서 accept() 메소드를 호출하지 않도록 해야한다.
클라이언트가 연결 요청을 하면 accept()는 클라이언트와 통신하기 위한 Socket을 생성하고 리턴한다. 이것이 연결 수락이고, 만약 accept() 상태에서 블로킹되어 있을 때 ServerSocket을 닫기 위해 close()를 수행하는 경우 SocketException이 발생한다. 따라서 예외처리도 필요하다.
다음은 가장 기본적인 서버 예제이다.
public class ServerBasicTCPExample {
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket()) {
serverSocket.bind(new InetSocketAddress("localhost", 5001));
while(true) {
System.out.println("[연결 기다리는 중]");
Socket socket = serverSocket.accept();
InetSocketAddress isa = (InetSocketAddress) socket.getRemoteSocketAddress();
System.out.printf("[연결 수락]: %s\n", isa.getAddress());
byte[] bytes = null;
String message = null;
InputStream is = socket.getInputStream();
bytes = new byte[100];
int readByteCount = is.read(bytes);
message = new String(bytes, 0, readByteCount, "UTF-8");
System.out.printf("[데이터 받기 성공]: %s\n", message);
is.close();
socket.close();
}
}
catch (Exception e) {
System.out.println(e);
}
}
}
- try-with-resourse를 이용해서 ServerSocket을 생성하였다.
- Socket의 InputStream을 얻어서 바이트 데이터를 읽은 다음에 문자열로 변환하여 출력하고 있다.
- 읽기 작업이 끝나면 InputStream을 닫고 Socket도 닫는다.
- ServerSocket은 Closeable을 구현하고 있기 때문에 try-with-resourse 문이 끝나면 자동으로 close() 메서드가 호출된다.
클라이언트의 연결 요청은 Socket의 connect()를 이용하는데 이때 잘못된 IP 주소를 이용하면 UnknownHostException이 발생할 수 있고 주어진 포트로 접속할 수 없는 경우에는 IOException이 발생할 수 있다. 또한 connect()는 서버와 연결 될 때 까지 블로킹되므로 UI를 생성하는 메서드나, 이벤트를 처리하는 스레드에서 사용하지 않도록 주의해야한다.
다음은 가장 기본적인 클라이언트 예제이다.
public class ClientBasicTCPExample {
public static void main(String[] args) {
try (Socket socket = new Socket()) {
System.out.println("[연결 요청]");
socket.connect(new InetSocketAddress("localhost", 5001));
System.out.println("[연결 성공]");
byte[] bytes = null;
String message = null;
OutputStream os = socket.getOutputStream();
message = "Hello Server";
bytes = message.getBytes("UTF-8");
os.write(bytes);
os.flush();
System.out.println("[데이터 보내기 성공]");
os.close();
}
catch (Exception e) {
System.out.println(e);
}
}
}
- Socket을 생성하고 서버 주소에 connect()를 수행한다.
- 문자열을 바이트로 변환하였고 Socket의 OutputStream을 얻어서 write()를 수행했다.
- OutputStream을 close() 해준다.
2. 멀티 스레드를 이용해서 클라이언트 메시지 처리하기
설명했던대로 ServerSocket의 accept()는 클라이언트의 연결 요청이 처리 될 때 까지 스레드의 동작이 Block 된다. 따라서 멀티 스레드를 이용해서 연결 요청을 처리하는 것이 좋은데, 요청 때 마다 스레드를 생성하면 성능 이슈가 발생할 수 있으므로 스레드 풀을 이용하는 것이 좋다.
아래는 스레드 풀을 이용한 멀티 스레드 서버 예제이다. CPU 코어의 수 만큼 스레드를 가질 수 있는 스레드 풀을 생성했고 클라이언트의 연결 요청을 처리하는 작업을 각 스레드에게 할당해주었다. ServerSocket을 먼저 닫게 되면 클라이언트의 연결 요청을 정상적으로 처리할 수 없기 때문에 while문을 돌면서 사용자로부터 "quit"을 입력받게 되면 ServerSocket이 닫히게 하였다. 그리고 닫히기 전까지는 스레드 풀이 클라이언트의 연결 요청을 처리하도록 해두었다. 원래 서버 쪽 UI를 구현하고 서버 시작과 종료 기능을 함수적으로 분리하면 더 깔끔하겠지만 지금은 main 스레드와 스레드 풀의 역할을 분리해서 최대한 간단하게 구성해 보았다. 그리고 마지막으로 스레드 풀을 종료해주어야 한다. 스레드 풀의 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되어도 계속 유지된다. 따라서 "quit"가 입력된 경우 모든 작업을 끝내도록 shutdownNow()를 호출해주었다.
스레드 풀에 대한 자세한 내용은 멀티 스레드를 다루면서 살펴보도록 하겠습니다.
public class ServerMultiThreadTCPExample {
public static void main(String[] args) {
// CPU 코어의 수만큼 최대 스레드를 사용하는 스레드풀을 생성한다.
final int MAX_THREAD = Runtime.getRuntime().availableProcessors();
System.out.println("최대 스레드 수: " + MAX_THREAD);
ExecutorService executorService = Executors.newFixedThreadPool(MAX_THREAD);
try {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("localhost", 5001));
System.out.println("[연결 준비 완료]");
Runnable poolTask = () -> {
try {
while(true) {
String curThread = Thread.currentThread().getName();
// accept()는 작업이 완료될 때 까지 스레드가 Block 된다.
Socket socket = serverSocket.accept();
InetSocketAddress isa = (InetSocketAddress) socket.getRemoteSocketAddress();
System.out.printf("%s > [연결 수락]: %s\n", curThread, isa.getAddress());
byte[] bytes = null;
String message = null;
InputStream is = socket.getInputStream();
bytes = new byte[100];
int readByteCount = is.read(bytes);
message = new String(bytes, 0, readByteCount, "UTF-8");
System.out.printf("%s > [데이터 받기 성공]: %s\n", curThread, message);
Thread.sleep(500);
is.close();
socket.close();
}
}
catch (Exception e) {
System.out.println(e);
}
};
for(int i = 0; i < MAX_THREAD; i++) executorService.submit(poolTask);
while(true) {
InputStream is = System.in;
Reader reader = new InputStreamReader(is);
char[] cbuf = new char[100];
System.out.printf("커맨드를 입력해주세요: ");
int readCharNo = reader.read(cbuf);
String command = String.valueOf(cbuf, 0, readCharNo);
System.out.println("입력한 커맨드: " + command);
if(command.equals("quit\n")) break;
}
executorService.shutdownNow();
serverSocket.close();
} catch (Exception e) {
System.out.println(e);
}
}
}
이제 서버 쪽에서 최대 12명의 클라이언트 연결을 한 번에 처리할 수 있기 때문에 12명의 클라이언트 동시 연결 요청 코드를 작성해보았다. Runnuable의 익명 구현 클래스를 작성해주었고 내부 동작은 이전 코드와 다를 것이 없다. Stream.generate()로 12개의 Thread를 생성해주었고 병렬로 실행하도록 parallel()을 사용했다.
public class ClientMultiThreadTCPExample {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
try (Socket socket = new Socket()) {
System.out.println("[연결 요청]");
socket.connect(new InetSocketAddress("localhost", 5001));
System.out.println("[연결 성공]");
byte[] bytes = null;
String message = null;
OutputStream os = socket.getOutputStream();
message = "Hello Server";
bytes = message.getBytes("UTF-8");
os.write(bytes);
os.flush();
System.out.println("[데이터 보내기 성공]");
os.close();
}
catch (Exception e) {
System.out.println(e);
}
};
Stream.generate(() -> new Thread(task)).limit(12).parallel().forEach(T -> T.start());
}
}
실행 로그는 다음과 같다.
// 서버 측 로그
최대 스레드 수: 12
[연결 준비 완료]
커맨드를 입력해주세요:
pool-1-thread-2 > [연결 수락]: /127.0.0.1
pool-1-thread-12 > [연결 수락]: /127.0.0.1
pool-1-thread-11 > [연결 수락]: /127.0.0.1
pool-1-thread-10 > [연결 수락]: /127.0.0.1
pool-1-thread-9 > [연결 수락]: /127.0.0.1
pool-1-thread-7 > [연결 수락]: /127.0.0.1
pool-1-thread-6 > [연결 수락]: /127.0.0.1
pool-1-thread-4 > [연결 수락]: /127.0.0.1
pool-1-thread-8 > [연결 수락]: /127.0.0.1
pool-1-thread-3 > [연결 수락]: /127.0.0.1
pool-1-thread-5 > [연결 수락]: /127.0.0.1
pool-1-thread-1 > [연결 수락]: /127.0.0.1
pool-1-thread-1 > [데이터 받기 성공]: Hello Server
pool-1-thread-5 > [데이터 받기 성공]: Hello Server
pool-1-thread-3 > [데이터 받기 성공]: Hello Server
pool-1-thread-8 > [데이터 받기 성공]: Hello Server
pool-1-thread-4 > [데이터 받기 성공]: Hello Server
pool-1-thread-6 > [데이터 받기 성공]: Hello Server
pool-1-thread-7 > [데이터 받기 성공]: Hello Server
pool-1-thread-9 > [데이터 받기 성공]: Hello Server
pool-1-thread-11 > [데이터 받기 성공]: Hello Server
pool-1-thread-12 > [데이터 받기 성공]: Hello Server
pool-1-thread-10 > [데이터 받기 성공]: Hello Server
pool-1-thread-2 > [데이터 받기 성공]: Hello Server
quit
입력한 커맨드: quit
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
java.net.SocketException: Interrupted function call: accept failed
Process finished with exit code 0
// 클라이언트 측 로그
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 요청]
[연결 성공]
[연결 성공]
[연결 성공]
[연결 성공]
[연결 성공]
[연결 성공]
[연결 성공]
[연결 성공]
[연결 성공]
[데이터 보내기 성공]
[연결 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[연결 성공]
[연결 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
[데이터 보내기 성공]
Process finished with exit code 0
'JAVA' 카테고리의 다른 글
[기본 시리즈] java.nio 버퍼 (1) | 2022.09.19 |
---|---|
[기본 시리즈] JAVA 스레드에 대하여 (0) | 2022.09.08 |
[기본 시리즈] java.io 입력 스트림과 출력 스트림 (with 보조 스트림) (0) | 2022.09.02 |
[기본 시리즈] JVM의 메모리 사용 구조 : Thread 영역 (0) | 2022.08.23 |
[기본 시리즈] 해시 테이블과 해시 충돌 그리고 JAVA의 HashMap (0) | 2022.08.18 |