이번 강의에서는 간단한 클라이언트-서버 네트워크 프로그램을 만들어보자.
⬛ 클라이언트와 서버 간 대화
컴퓨터가 "메시지"를 보내면 "메시지 + 반가워!" 라고 응답을 주는 프로그램을 만들어보자.
🔷 클라이언트 코드
public class ClientV1 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("클라이언트 시작");
Socket socket = new Socket("localhost", PORT);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
log("소켓 연결: " + socket);
// 서버에게 문자 보내기
String toSend = "Hello";
output.writeUTF(toSend);
log("client -> sever: " + toSend);
// 서버로부터 문자 받기
String received = input.readUTF();
log("client <- server: " + received);
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
}
}
코드 핵심부분
Socket socket = new Socket("localhost", 12345); // 서버와 연결
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
output.writeUTF("Hello"); // 서버로 "Hello" 메시지 전송
🔷 서버 코드
public class ServerV1 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
Socket socket = serverSocket.accept();
log("소켓 연결: " + socket);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
// 클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
// 클라이언트에게 문자 보내기
String toSend = received + "World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
serverSocket.close();
}
}
코드 핵심 부분
ServerSocket serverSocket = new ServerSocket(12345); // 서버 준비
Socket socket = serverSocket.accept(); // 클라이언트 연결 수락
DataInputStream input = new DataInputStream(socket.getInputStream());
String message = input.readUTF(); // 클라이언트로부터 메시지 수신
output.writeUTF(message + " World!"); // "Hello World!"로 응답
🟢 호스트 이름으로 IP 주소를 찾는 방법
- InetAddress.getByName("호스트명") 메서드를 사용해 IP 주소를 조회.
- 이때 먼저 호스트 파일을 확인:
- 리눅스/macOS: /etc/hosts
- 윈도우: C:\Windows\System32\drivers\etc\hosts
- 호스트 파일에 정보가 없으면 DNS 서버에 요청해 IP 주소를 얻음.
🟢 클라이언트와 서버의 연결
- 서버 준비: 서버는 특정 포트(예: 12345)를 열고, ServerSocket을 통해 클라이언트의 연결을 기다린다.
- ServerSocket serverSocket = new ServerSocket(12345);
- 클라이언트 연결 시도: 클라이언트는 서버의 IP 주소와 포트를 사용해 Socket 객체로 서버에 연결을 시도한다.
- Socket socket = new Socket("서버 IP 주소", 12345);
- 연결 수락: 서버는 serverSocket.accept()를 호출하여 클라이언트의 연결을 수락하고, 연결된 클라이언트를 위한 Socket 객체를 생성한다.
- 데이터 통신: 클라이언트와 서버는 각자의 Socket 객체에서 제공하는 스트림을 통해 데이터를 주고받는다. 클라이언트는 데이터를 서버로 보내고, 서버는 해당 데이터를 처리한 후 응답을 보낸다.
- 연결 종료: 데이터 전송이 끝나면, 클라이언트와 서버는 close() 메서드를 호출하여 Socket을 닫고 연결을 종료한다.
서버가 12345 포트로 소켓을 열면, 클라이언트는 이 포트를 통해 서버에 연결할 수 있다. 클라이언트가 연결을 시도할 때 TCP 3-way handshake로 연결이 완료되며, OS의 backlog queue에 클라이언트와 서버의 IP와 포트 정보가 저장된다.
서버가 accept()를 호출하면 backlog queue에서 연결 정보를 조회하여 Socket 객체를 생성하고, 사용한 연결 정보는 queue에서 제거된다. 만약 연결 정보가 없다면, 새로운 연결이 생길 때까지 대기한다.
⬛ 계속 대화하기
클라이언트와 서버가 한 번 대화로 끝나는 게 아니라, 서로 계속 주고받을 수 있도록 반복문을 해보자. 클라이언트가 "exit"이라고 입력하면 대화가 종료되도록 하자.
🔷 수정된 클라이언트 부분 코드
while (true) {
String message = scanner.nextLine(); // 사용자 입력 받기
output.writeUTF(message); // 서버로 메시지 전송
if ("exit".equals(message)) break; // "exit" 입력 시 종료
System.out.println(input.readUTF()); // 서버로부터 메시지 받기
}
🔷 수정된 서버 부분 코드
while (true) {
String received = input.readUTF(); // 클라이언트로부터 메시지 받기
if ("exit".equals(received)) break; // "exit" 입력 시 종료
output.writeUTF(received + " World!"); // 클라이언트에게 응답
}
🔷 전체 코드
public class ClientV2 {
public static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("클라이언트 시작");
Socket socket = new Socket("localhost", PORT);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
log("소캣 연결: " + socket);
Scanner sc = new Scanner(System.in);
while (true) {
System.out.print("전송 문자: ");
String toSend = sc.nextLine();
// 서버에게 문자 보내기
output.writeUTF(toSend);
log("client -> server: " + toSend);
if(toSend.equals("exit")) {
break;
}
// 서버로부터 문자 받기
String received = input.readUTF();
log("client <- server: " + received);
}
// 자원 정리
log("연결 종료: " + socket); input.close(); output.close(); socket.close();
}
}
public class ServerV2 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
Socket socket = serverSocket.accept();
log("소켓 연결: " + socket);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
while (true) {
// 클라이언트로부터 문자 받기
String received = input.readUTF();
log("client -> server: " + received);
// 클라이언트 종료시 서버도 함께 종료
if (received.equals("exit")) break;
// 클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
serverSocket.close();
}
}
하지만 위 코드는 새로운 클라이언트가 연결해 문자를 전송하려하면 전송되지않는다.
⬛ 여러 클라이언트가 동시에 연결할 수 있도록 하기
원인이 무엇일지 추측이 가는가? ServerSocket.accpet() 메서드를 호출하면 backlog 큐의 정보를 기반으로 소켓 객체를 하나 생성한다.
그럼 위 50000포트 클라이언트와 같이 스트림을 통해 데이터를 주고받을 수 있게된다.
60000번은 TCP연결은 되었다. 따라서 서버로 메시지는 보낼 수 있다.
즉, 60000 포트의 클라이언트는 서버와 TCP 연결은 완료되었지만, 소켓 객체가 없기 때문에 메시지를 주고받을 수는 없다.
TCP 연결만으로는 연결이 성립되지만, 실제 데이터 통신을 위해서는 소켓 객체가 반드시 필요하다.
즉, accept() 를 호출해야 소켓 객체를 생성하고 클라이언트와 메시지를 주고 받을 수 있다.
위 V2 서버코드를 다시보면, accept()는 클라이언트 연결이 올 때까지 기다리고, read()는 데이터가 올 때까지 기다린다.
이런 블로킹 작업들을 모두 하나의 스레드에서 처리하면, 하나의 작업이 끝날 때까지 다른 작업이 멈춰있어야 한다.
각각의 블로킹 작업은 별도의 스레드에서 처리해야 한다. (그렇지 않으면 다른 블로킹 메서드 때문에 계속 대기)
위의 내용을 종합하면,동시접속 문제는 역할을 분리하면 해결될문제같다.
main은 연결이 추가될때마다 Session객체와 별도 스레드를 생성한다.
Session담당스레드는 메시지를 반복해서 주고받는다.
🔷 세션(Session) 코드
public class SessionV3 implements Runnable {
private final Socket socket;
public SessionV3(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try{
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
while(true) {
// 클라이언트로부터 문자 받기
String received = input.readUTF(); // 블로킹
log("client -> server: " + received);
if (received.equals("exit")) {
break;
}
// 클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
🔷 메인 코드
public class ServerV3 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
while(true){
Socket socket = serverSocket.accept(); // 블로킹
log("소켓 연결: " + socket);
SessionV3 session = new SessionV3(socket);
Thread thread = new Thread(session);
thread.start();
}
}
}
🟢 동시접속 연결 과정
- 클라이언트가 서버에 접속 → Socket 객체 생성
- Session 객체 생성 → 클라이언트와 메시지를 주고받는 역할
- 새 스레드 (예: Thread-0) → Session이 클라이언트와의 대화를 독립적으로 처리
⬛ 자원정리
네트워크 프로그램에서 Socket이나 InputStream, OutputStream과 같은 자원은 사용 후 반드시 닫아줘야 한다. 그렇지 않으면 메모리 누수나 다른 프로그램의 자원 사용에 문제가 생길 수 있다.
🔷 try-catch
ublic class ResourceCloseMainV1 {
public static void main(String[] args) {
try {
logic();
} catch (CallException e){
System.out.println("CallException 예외 처리");
e.printStackTrace();
} catch (CloseException e){
System.out.println("CloseException 예외 처리");
e.printStackTrace();
}
}
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = new ResourceV1("resource1");
ResourceV1 resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx(); // CallException;
System.out.println("자원 정리"); // 호출 안됨
resource2.closeEx();
resource1.closeEx();
}
}
위의경우, try-catch를 이용해 자원을 정리한다. 하지만 이경우 CallEx();가 터지면, 자원정리 부분 코드는 호출되지않는다.
🔷 try-catch-finally
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = null;
ResourceV1 resource2 = null;
try {
resource1 = new ResourceV1("resource1");
resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx(); // 예외 발생
} finally {
if (resource2 != null) resource2.closeEx(); // 예외 발생 가능
if (resource1 != null) resource1.closeEx();
}
}
finally 블록을 추가하여 예외 발생 여부와 관계없이 자원이 닫히도록 개선했다.
그러나, 자원을 정리하는 중에도 예외가 발생할 수 있어 여전히 예외 처리에 문제가 남아 있다.
자원 정리 중 예외가 발생하면 핵심 예외(callEx)가 사라지고 정리 과정에서 발생한 예외(closeEx)가 나타나는 문제가 있다.
🔷 자원 정리 중 발생하는 예외 catch
private static void logic() throws CallException, CloseException {
ResourceV1 resource1 = null;
ResourceV1 resource2 = null;
try {
resource1 = new ResourceV1("resource1");
resource2 = new ResourceV1("resource2");
resource1.call();
resource2.callEx(); // 예외 발생
} finally {
if (resource2 != null) {
try {
resource2.closeEx();
} catch (CloseException e) {
System.out.println("close ex: " + e);
}
}
if (resource1 != null) {
try {
resource1.closeEx();
} catch (CloseException e) {
System.out.println("close ex: " + e);
}
}
}
}
이번에는 자원 정리 중 발생하는 예외를 catch하여 주요 예외가 사라지지 않도록 개선했다.
finally 블록에서 각각의 자원 정리 시 예외가 발생하면 해당 예외를 무시하거나 로그에 남겨 처리한다.
이로써 핵심 예외를 유지하면서 자원 정리 중 발생하는 예외를 따로 처리할 수 있게 되었다.
하지만 위 코드에는 몇가지 아쉬운점들이 보인다.
🟢 동시접속 연결 과정
- 변수 스코프 문제
try 블록 안에서 자원을 선언하면 finally에서 사용할 수 없는 경우가 있다.
즉, try 블록과 finally 블록이 변수 스코프(유효 범위)가 다르기 때문에, 자원 변수를 한 번에 선언하고 할당하기 어렵다. - 자원 정리 지연
예외가 발생하면 catch 블록이 실행된 후에 finally가 호출된다. 이로 인해 자원 정리가 조금 늦어질 수 있다. - 자원 정리 누락 가능성
개발자가 실수로 close()를 호출하지 않을 가능성이 있다. 이를 방지하려면 항상 자원을 닫는 코드를 작성해야 한다. - 자원 정리 순서
자원 정리는 생성한 순서의 반대로 닫아야 한다. 이 순서를 잘못 지정하면 예기치 않은 문제가 발생할 수 있다.
🔷 try-with-resources를 통한 최적화된 자원 정리
public class ResourceCloseMainV4 {
public static void main(String[] args) {
try {
logic();
} catch (CallException e) {
System.out.println("CallException 예외 처리");
Throwable[] suppressed = e.getSuppressed();
for (Throwable throwable : suppressed) {
System.out.println("suppressedEx = " + throwable);
}
e.printStackTrace();
} catch (CloseException e) {
System.out.println("CloseException 예외 처리");
e.printStackTrace();
}
}
private static void logic() throws CallException, CloseException {
try (ResourceV2 resource1 = new ResourceV2("resource1");
ResourceV2 resource2 = new ResourceV2("resource2"))
{
resource1.call();
resource2.callEx(); // CallException;
} catch (CallException e) {
System.out.println("ex: " + e);
throw e; // CallException;
}
}
}
try-with-resources 구문을 사용하여 자원을 자동으로 닫아준다.
자원 정리 중 발생하는 예외는 Suppressed 예외로 저장되어, 주요 예외와 함께 확인할 수 있다.
이 구문을 통해 자원 관리를 간결하고 안전하게 수행할 수 있으며, 핵심 예외와 Suppressed 예외를 함께 처리할 수 있게 되었다.
⬛ try-with-resources
기본적인 try-with-resources 구문의 구조는 다음과 같다.
try (ResourceType resource = new ResourceType()) {
// 자원 사용
} catch (Exception e) {
// 예외 처리
}
try 블록에서 자원을 선언하고 사용하면, 블록이 끝난 후 자원을 자동으로 닫아준다.
🟢 try-with-resources의 장점
- 자원 자동 정리: try-with-resources 구문을 사용하면 try구문이 끝난 후 자원을 자동으로 닫아주므로 close() 호출을 잊을 위험이 없다.
- 간결한 코드: finally 블록 없이 자원을 안전하게 닫을 수 있어 코드가 간결해진다.
- Suppressed 예외 처리: 자원 닫기 중 발생한 예외도 주요 예외와 함께 관리할 수 있어 디버깅이 쉬워진다.
- 에러 방지: 자원을 생성한 순서와 반대로 닫아야 하는 번거로움을 해결하고, 자원 누수 문제를 방지할 수 있다.
'Java' 카테고리의 다른 글
[Java] 김영한의 실전 자바 - 고급 2편 섹션13 리플렉션 (0) | 2024.11.25 |
---|---|
[Java] 김영한의 실전 자바 - 고급 2편 섹션12 HTTP 서버 만들기, 서블릿 (3) | 2024.11.21 |
[Java] 김영한의 실전 자바 - 고급 2편 섹션5 네트워크 기본이론 / HTTP (2) | 2024.10.31 |
[Java] 김영한의 실전 자바 - 고급 2편 섹션5 File,Files (0) | 2024.10.28 |
[Java] 김영한의 실전 자바 - 고급 2편 섹션2 I/O 기본1,2 (0) | 2024.10.28 |