일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 2024 정보처리기사 시나공 필기
- 데이터 접근 기술
- 자바의 정석 기초편 ch2
- 자바로 키오스크 만들기
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch6
- 자바 기초
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch11
- 자바 중급2편 - 컬렉션 프레임워크
- 스프링 입문(무료)
- 자바 중급1편 - 날짜와 시간
- @Aspect
- 스프링 mvc2 - 검증
- 스프링 트랜잭션
- 람다
- 자바의 정석 기초편 ch4
- 스프링 mvc1 - 스프링 mvc
- 자바로 계산기 만들기
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch1
- 자바 고급2편 - 네트워크 프로그램
- 스프링 mvc2 - 로그인 처리
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch13
- 2024 정보처리기사 수제비 실기
- 자바의 정석 기초편 ch9
- 자바 고급2편 - io
- 자바의 정석 기초편 ch14
- Today
- Total
개발공부기록
네트워크 - 프로그램, 네트워크 프로그램 - 자원정리 적용, 네트워크 예외(연결 예외, 타임아웃, 정상 종료, 강제 종료) 본문
네트워크 - 프로그램, 네트워크 프로그램 - 자원정리 적용, 네트워크 예외(연결 예외, 타임아웃, 정상 종료, 강제 종료)
소소한나구리 2025. 2. 26. 19:13출처 : 인프런 - 김영한의 실전 자바 - 고급2편 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
네트워크 프로그램 - 자원정리 적용
네트워크 프로그램4 - finally로 자원을 정리
SocketCloseUtil
여러 곳에서 사용할 소켓과 스트림을 종료하기 위한 간단한 유틸리티 클래스를 network.tcp 패키지에 생성
기본적인 null 체크와 각 자원을 종료 시 예외를 잡아서 처리하는 코드가 들어가 있음
- 자원 정리 과정에서 문제가 발생해도 코드에서 직접 대응할 수 있는 부분은 거의 없으므로 간단히 로그를 남겨서 이후에 개발자가 인지할 수 있는 정도면 충분함
각각의 예외를 잡아서 처리했기 때문에 Socket, InputStream, OutputStream 중 하나를 닫는 과정에서 예외가 발생해도 다음 자원을 닫을 수 있음
closeAll() 메서드를 보면 Socket을 먼저 생성하고 Socket을 기반으로 InputStream, OutputStream을 생성하기 때문에 자원을 닫을 때는 InputStream, OutputStream을 먼저 닫고 Socket을 마지막에 닫도록 함
- InputStream, OutputStream의 닫는 순서는 상관이 없음
closeAll() 메서드로 자원을 한 번에 정리할 수도 있지만, 각각의 자원을 개별적으로 종료하고 싶을 수 있기 때문에 close() 메서드도 public으로 제공하도록 작성
package network.tcp;
public class SocketCloseUtil {
public static void closeAll(Socket socket, InputStream input, OutputStream output) {
close(input);
close(output);
close(socket);
}
public static void close(Socket socket) {
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
log(e.getMessage());
}
}
}
public static void close(InputStream input) {
if (input != null) {
try {
input.close();
} catch (IOException e) {
log(e.getMessage());
}
}
}
public static void close(OutputStream output) {
if (output != null) {
try {
output.close();
} catch (IOException e) {
log(e.getMessage());
}
}
}
}
ClientV4
자원 정리 시 finally 코드 블록에서 SocketCloseUtil.closeAll()을 호출하여 자원을 정리함
- finally 블록에서 변수에 접근해야 하기 때문에 각 자원의 변수 선언을 try 밖에서 선언하였음
- IOException이 발생할 경우에는 로그를 남겨서 이후에 대처할 수 있도록 작성
package network.tcp.v4;
public class ClientV4 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("클라이언트 시작");
// finally 블록에서 변수에 접근해야 하기 때문에 try 블록 안에서 선언할 수 없음
Socket socket = null;
DataOutputStream output = null;
DataInputStream input = null;
try {
socket = new Socket("localhost", PORT);
input = new DataInputStream(socket.getInputStream());
output = new DataOutputStream(socket.getOutputStream());
log("소켓 연결: " + socket);
// ... 통신 코드는 ClientV3과 동일
} catch (IOException e) {
log(e);
} finally {
// 자원 정리
SocketCloseUtil.closeAll(socket, input, output);
log("연결 종료: " + socket);
}
}
}
SessionV4
- ClientV4 코드와 마찬가지로 finally 블록 안에서 자원을 정리하도록 수정
package network.tcp.v4;
public class SessionV4 implements Runnable {
// 소켓 생성 코드 SessionV3와 동일
@Override
public void run() {
DataInputStream input = null;
DataOutputStream output = null;
try {
input = new DataInputStream(socket.getInputStream());
output = new DataOutputStream(socket.getOutputStream());
// ... 통신 코드는 SessionV3와 동일
} catch (IOException e) {
log(e);
} finally {
// 자원 정리
SocketCloseUtil.closeAll(socket, input, output);
log("연결 종료: " + socket);
}
}
}
ServerV4
기존의 ServerV3와 완전히 동일한 코드로 SessionV4를 사용하도록 수정
실행 결과 - 클라이언트 직접 종료 시 서버의 로그
10:05:39.700 [ main] 서버 시작
10:05:39.703 [ main] 서버 소켓 시작 - 리스닝 포트: 12345
10:05:42.343 [ main] 소켓 연결: Socket[addr=/127.0.0.1,port=53212,localport=12345]
10:05:47.697 [ Thread-0] java.io.EOFException
10:05:47.699 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=53212,localport=12345]
- 기존 V3의 코드의 문제는 클라이언트를 직접 종료하면 서버의 Session에 EOFException(windows의 경우 SocketException)이 발생하면서 자원을 제대로 정리하지 못했음
- V4에서는 서버에 접속한 클라이언트를 직접 종료해도 finally 블록을 통해 자원을 무조건 종료하도록 하였으므로 발생한 예외를 로그로 남겨두고 자원을 잘 정리하는 것을 확인할 수 있음
네트워크 프로그램5 - try-with-resources로 자원 정리
ClientV5
자원 정리 시 try-with-resources를 적용하여 finally 블록이 없어도 try구문이 끝나면 자동으로 자원이 종료됨
- try-with-resources에 선언되는 순서의 반대로 자원 정리가 적용되어 예제에서는 output, input, socket 순으로 close()가 호출됨
- OutputStream, InputStream, Socket 모두 상위를 쭉 따라가 보면 AutoCloseable을 구현하고 있어 try-with-resources를 사용할 수 있음
package network.tcp.v5;
public class ClientV5 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("클라이언트 시작");
try (Socket socket = new Socket("localhost", PORT);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream())){
log("소켓 연결: " + socket);
// ... 통신 코드는 ClientV4와 동일
} catch (IOException e) {
log(e);
}
}
}
SessionV5
서버에도 try-with-resources를 적용하여 자원을 정리하도록 변경
Socket객체는 Session에서 직접 생성하지 않고 외부에서 주입되는데 이 경우에는 try 선언부에 객체의 참조변수를 넣어두면 자원 정리 시점에 AutoCloseable이 호출됨
AutoCloseable이 호출되어서 정말 소켓의 close() 메서드가 호출되었는지 확인하기 위해 socket.isClosed()를 호출하여 출력하는 코드를 추가함
package network.tcp.v5;
public class SessionV5 implements Runnable {
// 소켓 생성 코드 SessionV4와 동일
@Override
public void run() {
try (socket;
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {
// ... 통신 코드 SessionV4와 동일
} catch (IOException e) {
log(e);
}
log("연결 종료: " + socket + " isClosed: " + socket.isClosed());
}
}
ServerV5
SessionV5를 사용하도록 수정
실행 결과 - 클라이언트 직접 종료 시 서버의 로그
10:29:53.374 [ main] 서버 시작
10:29:53.377 [ main] 서버 소켓 시작 - 리스닝 포트: 12345
10:29:56.523 [ main] 소켓 연결: Socket[addr=/127.0.0.1,port=53338,localport=12345]
10:30:00.628 [ Thread-0] java.io.EOFException
10:30:00.631 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=53338,localport=12345] isClosed: true
- 마지막의 isClosed: true 로그를 통해 소켓의 close() 메서드가 try-with-resources를 통해 잘 호출되어 자원이 정리된 것을 확인할 수 있음
네트워크 프로그램6 - 자원정리3
지금까지 예제는 서버와 클라이언트가 서로 연결되는 ServerSocket은 종료하지 않았음
서버를 종료할 때, 서버 소켓과 연결된 모든 소켓 자원을 다 반납하고 서버를 안정적으로 종료하려면 서버에 종료라는 신호를 전달해야 함
예를 들어 서버도 콘솔 창을 통해서 입력을 받도록 만들고 "종료"라는 메시지를 입력하면 모든 자원을 정리하면서 서버가 종료되도록 하면 되지만, 보통 서버에서 콘솔 입력은 잘하지 않기 때문에 서버를 직접 종료하면서 자원도 함께 정리하는 방법으로 실습
셧다운 훅(Shutdown Hook)
자바는 프로세스가 종료될 때, 자원 정리나 로그 기록과 같은 종료 작업을 마무리할 수 있는 셧다운 훅이라는 기능을 지원함
프로세스 종료는 크게 2가지로 분류할 수 있으며, 정상 종료의 경우에는 셧다운 훅이 작동해서 프로세스 종료 전에 필요한 후 처리를 할 수 있는 반면, 강제 종료의 경우에는 셧다운 훅이 동작하지 않음
- 정상 종료
- 모든 non 데몬 스레드의 실행 완료로 자바 프로세스 정상 종료
- 사용자가 Ctrl+C로 프로그램을 중단
- kill 명령 전달 (kill -9 제외)
- IntelliJ의 stop 버튼
- 강제 종료
- 운영체제에서 프로세스를 더 이상 유지할 수 없다고 판단할 때 사용(컴퓨터의 전원을 강제로 종료시킨 것과 동일하다고 보면 됨)
- 리눅스/유닉스의 kill-9나 Windows의 taskkill /F 명령
ClientV6
clientV5 코드와 이름만 다르고 완전히 동일함
SessionManagerV6
서버는 세션을 관리하는 세션 매니저가 필요함
각 세션은 소켓과 연결 스트림을 가지고 있으므로 서버를 종료할 때 사용하는 세션들도 함께 종료해야 하는데, 모든 세션을 찾아서 종료하려면 생성한 세션을 보관하고 관리할 객체가 필요함
- add(): 클라이언트의 새로운 연결을 통해, 세션이 새로 만들어지는 경우 add()를 호출해서 세션 매니저에 세션을 추가
- remove(): 클라이언트의 연결이 끊어지면 세션도 함께 정리됨, 이 경우 remove()를 호출해서 세션 매니저에서 세션을 제거함
- closeAll(): 서버를 종료할 때 사용하는 세션들도 모두 닫고 정리함
- 각각의 메서드는 여러 스레드가 동일한 자원을 생성하면서 삭제하는 등의 문제가 있을 수 있으므로 동시에 호출할 수 없도록 모두 synchronized를 적용
package network.tcp.v6;
public class SessionManagerV6 {
List<SessionV6> sessions = new ArrayList<>();
public synchronized void add(SessionV6 session) {
sessions.add(session);
}
public synchronized void remove(SessionV6 session) {
sessions.remove(session);
}
public synchronized void closeAll() {
for (SessionV6 session : sessions) {
session.close();
}
sessions.clear();
}
}
SessionV6
SessionV6에서는 서버를 종료하는 시점에도 Session의 자원을 정리해야 하기 때문에 try-with-resources를 사용할 수 없음
try-with-resources는 사용과 해제를 함께 묶어서 처리할 때 사용함
- try 선언부에서 사용한 자원을 try가 끝나는 시점에 정리하기 때문에 try에서 자원의 선언과 자원 정리를 묶어서 처리할 때 사용할 수 있음
- 하지만 지금은 서버를 종료하는 시점에도 Session이 사용하는 자원을 정리해야 하기 때문에 Session 안에 있는 try-with-resources를 통해 처리할 수 없음
동시성 문제 - close() 메서드
- 자원을 정리하는 close() 메서드는 클라이언트와 연결이 종료되었을 때(exit 혹은 예외 발생), 서버를 종료할 때 2곳에서 호출될 수 있으므로 close()가 다른 스레드에서 동시에 중복 호출 될 수 있음
- 이런 문제를 막기 위해 synchronized 키워드를 사용하여 동시 호출을 막고, 자원 정리 코드가 중복 호출 되는 것을 막기 위해 closed 변수를 플래그로 사용하였음
package network.tcp.v6;
public class SessionV6 implements Runnable {
private final Socket socket;
private final DataInputStream input;
private final DataOutputStream output;
private final SessionManagerV6 sessionManager;
private boolean closed = false;
public SessionV6(Socket socket, SessionManagerV6 sessionManager) throws IOException {
this.socket = socket;
this.input = new DataInputStream(socket.getInputStream());
this.output = new DataOutputStream(socket.getOutputStream());
this.sessionManager = sessionManager;
this.sessionManager.add(this);
}
@Override
public void run() {
try {
// ... 통신 코드는 기존과 동일
}
} catch (IOException e) {
log(e);
} finally {
sessionManager.remove(this); // 세션매니저에서 참조 삭제
close(); // 자원 정리
}
}
/**
* 세션 종료, 서버 종료 시 동시에 호출되거나 2번 호출 될 수 있음
* - synchronized 로 동시에 호출 방지
* - closed 변수를 활용하여 중복 호출 방지
*/
public synchronized void close() {
if (closed) {
return;
}
SocketCloseUtil.closeAll(socket, input, output);
closed = true;
log("연결 종료: " + socket);
}
}
ServerV6
static 내부 클래스로 셧다운 훅을 생성하고 등록하여 정상 종료 시에 자원을 모두 종료하도록 하는 코드를 추가
serverSocket.accept()를 실행하려고 스레드가 대기 중일 때 자원이 모두 종료되면 예외가 발생하여 try-catch로 예외를 처리
package network.tcp.v6;
public class ServerV6 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
SessionManagerV6 sessionManager = new SessionManagerV6();
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
// shutdownHook 생성 및 등록
ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));
try {
while (true) {
Socket socket = serverSocket.accept();
log("소켓 연결: " + socket);
SessionV6 session = new SessionV6(socket, sessionManager);
Thread thread = new Thread(session);
thread.start();
}
} catch (IOException e) {
log("서버 소켓 종료: " + e);
}
}
// 자바 프로그램 정상 종료 시 수행되는 스레드
static class ShutdownHook implements Runnable {
private final ServerSocket serverSocket;
private final SessionManagerV6 sessionManager;
public ShutdownHook(ServerSocket serverSocket, SessionManagerV6 sessionManager) {
this.serverSocket = serverSocket;
this.sessionManager = sessionManager;
}
@Override
public void run() {
log("shutdownHook 실행");
try {
sessionManager.closeAll(); // 자원 모두 종료
serverSocket.close(); // 서버 소켓 종료
// 자원 정리 대기 -> 살아있는 스레드가 정상 종료 되도록 대기 시간을 부여
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
System.out.println("e = " + e);
}
}
}
}
셧다운 훅 등록
// shutdownHook 생성 및 등록
ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));
- Runtime.getRuntime().addShutdownHook()을 사용하면 자바 종료 시 호출되는 셧다운 훅을 등록할 수 있으며 여기에 셧다운이 발생했을 때 처리할 작업과 스레드를 등록하면 됨
- 자바가 제공하는 문법이므로 셧다운 훅을 등록할 때는 이처럼 작성하면 됨
셧다운 훅 실행 코드
- 셧다운 훅이 실행될 때 모든 자원을 정리함
- sessionManager.closeAll(): 모든 세션이 사용하는 자원(Socket, InputStream, OutputStream)을 정리함
- serverSocket.close(): 서버 소켓을 닫음
@Override
public void run() {
log("shutdownHook 실행");
try {
sessionManager.closeAll(); // 자원 모두 종료
serverSocket.close(); // 서버 소켓 종료
// 자원 정리 대기 -> 살아있는 스레드가 정상 종료 되도록 대기 시간을 부여
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
System.out.println("e = " + e);
}
}
자원 정리 대기 이유
보통 모든 non 데몬 스레드의 실행이 완료되면 자바 프로세스가 정상 종료 되지만 정상 종료로 간주하는 그 외의 종료(kill 명령, IDE의 stop버튼 등)는 non 데몬 스레드의 종료 여부와 관계없이 자바 프로세스가 종료됨
단, 정상 종료이므로 셧다운 훅의 실행이 끝날 때 까지는 기다려주기 때문에 다른 스레드가 자원을 정리하거나 필요한 로그를 남길 수 있도록 셧다운 훅의 실행을 잠시 대기해 주고, 셧다운 훅의 실행이 끝나면 non 데몬 스레드의 실행 여부와 상관없이 자바 프로세스는 모두 종료됨
실행 결과 - 서버 종료 결과
12:15:29.828 [ main] 서버 시작
12:15:29.832 [ main] 서버 소켓 시작 - 리스닝 포트: 12345
12:15:31.308 [ main] 소켓 연결: Socket[addr=/127.0.0.1,port=54632,localport=12345]
12:15:32.392 [ shutdown] shutdownHook 실행
12:15:32.394 [ shutdown] 연결 종료: Socket[addr=/127.0.0.1,port=54632,localport=12345]
12:15:32.395 [ Thread-0] java.net.SocketException: Socket closed
12:15:32.395 [ main] 서버 소켓 종료: java.net.SocketException: Socket closed
서버를 종료하면 shutdown 스레드가 shutdownHook을 실행하고, 세션의 Socket의 연결을 close()로 닫음
- 12:15:32.395 [ Thread-0] java.net.SocketException: Socket closed
- Session의 input.readUTF()에서 입력을 대기하는 Thread-0 스레드가 서버의 shutdownHook으로 인해 자신의 소켓이 닫혔으므로 SocketException 예외가 발생하며 종료됨
shutdown 스레드는 서버 소켓을 close()로 닫음
- 12:15:32.395 [ main] 서버 소켓 종료: java.net.SocketException: Socket closed
- 클라이언트의 요청을 받아 소켓을 생성하기 위해 serverSocket.accept()에서 대기하고 있던 main 스레드도 마찬가지로 SocketException 예외를 받고 종료됨
이로써 자원 정리까지 모두 깔끔하게 해결한 서버 프로그램이 완성되었음
네트워크 예외
네트워크 연결과 예외
네트워크 연결 시 발생할 수 있는 예외들
ConnectMain
java.net.UnknownHostException
- 호스트를 알 수 없음
- 999.999.999.999라는 IP와 이상한도메인.coooom 이라는 도메인은 존재하지 않음
java.net.ConnectException: Connection refused
- Connection refused 메시지는 연결이 거절되었다는 뜻임
- 연결이 거절되었다는 것은 우선은 네트워크를 통해 해당 IP의 서버 컴퓨터에 접속은 했으나 해당 서버 컴퓨터가 45678 포트를 사용하지 않기 때문에 TCP 연결을 거절한 것임
- IP에 해당하는 서버는 켜져 있지만 사용하는 PORT가 없을 때 주로 발생하며 네트워크 방화벽 등에서 무단 연결로 인지하고 연결을 막을 때도 발생함
- 서버 컴퓨터의 OS는 TCP RST(Reset)라는 패킷을 보내서 연결을 거절하면 클라이언트가 연결 시도 중에 RST 패킷을 받으면 이 예외가 발생함
TCP RST(Reset) 패킷
- TCP 연결에 문제가 있다는 뜻으로 이 패킷을 받으면 받은 대상은 바로 연결을 해제해야 함
** 참고
- 윈도우의 경우 Connection refused 뒤에 connect라는 메시지가 하나 더 붙음
package network.exception.connect;
public class ConnectMain {
public static void main(String[] args) throws IOException {
unknownHostEx1();
unknownHostEx2();
connectionRefused();
}
private static void unknownHostEx1() throws IOException {
try {
Socket socket = new Socket("999.999.999.999", 80);
} catch (UnknownHostException e) {
e.printStackTrace();
}
}
private static void unknownHostEx2() throws IOException {
try {
Socket socket = new Socket("이상한도메인.coooom", 80);
} catch (UnknownHostException e) {
e.printStackTrace();
}
}
private static void connectionRefused() throws IOException {
try {
Socket socket = new Socket("localhost", 45678); // 미사용 포트
} catch (ConnectException e) {
e.printStackTrace();
}
}
}
/* 실행 결과 일부
java.net.UnknownHostException: 999.999.999.999
...
java.net.UnknownHostException: 이상한도메인.coooom
...
java.net.ConnectException: Connection refused
...
*/
타임아웃 - 중요
네트워크 연결을 시도해서 서버 IP에 연결 패킷을 전달했지만 응답이 없는 경우들
실무에서 이와 관련된 장애가 상당히 많이 발생함
ConnectTimeoutMain1 - TCP 연결 타임아웃, OS 기본
- 사설 IP 대역(주로 공유기에서 사용하는 IP 대역)의 192.168.1.250을 사용함(만약 해당 IP로 무언가 연결되어있으면 다른 결과가 나올 수 있는데 그런 경우 마지막 3자리를 변경하면 됨)
- 아래의 코드를 실행해서 해당 IP로 연결 패킷을 보내면 IP를 사용하는 서버가 없으므로 TCP 응답이 오지 않아 계속 대기하다가 OS에 설정된 대기 타임아웃 시간이 지나게 되어 ConnectException: Operation timed out 예외가 발생하는 것을 확인할 수 있음
- IP로 연결 패킷을 보내지만 해당 서버가 너무 바쁘거나 문제가 있어서 연결 응답 패킷을 보내지 못하는 경우에도 동일한 예외가 발생하게 됨
OS 기본 대기 시간
- TCP 연결을 시도했는데 연결 응답이 없다면 OS에는 연결 대기 타임이 설정되어 있으며 해당 시간이 지나며 java.net.ConnectException: Operation timed out이 발생함
- Linux: 약 75 ~ 180초 사이
- mac: 75초
- Windows: 약 21초, 윈도우의 경우에는 예외 발생 메시지 끝에 connect라는 메시지가 추가로 붙음
package network.exception.connect;
public class ConnectTimeoutMain1 {
public static void main(String[] args) throws IOException {
long start = System.currentTimeMillis();
try {
Socket socket = new Socket("192.168.1.250", 45678);
} catch (ConnectException e) {
// ConnectException: Operation timed out
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("end = " + (end - start));
}
}
/* 실행 결과
java.net.ConnectException: Operation timed out
at java.base/sun.nio.ch.Net.connect0(Native Method)
at java.base/sun.nio.ch.Net.connect(Net.java:589)
at java.base/sun.nio.ch.Net.connect(Net.java:578)
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:583)
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
at java.base/java.net.Socket.connect(Socket.java:751)
at java.base/java.net.Socket.connect(Socket.java:686)
at java.base/java.net.Socket.<init>(Socket.java:555)
at java.base/java.net.Socket.<init>(Socket.java:324)
at network.exception.connect.ConnectTimeoutMain1.main(ConnectTimeoutMain1.java:12)
end = 75018
*/
ConnectTimeoutMain2 - 직접 설정
TCP 연결을 클라이언트가 이렇게 오래 대기하는 것은 좋은 방법이 아니기에 연결이 안되면 고객에게 빠르게 현재 연결에 문제가 있다고 알려주는 것이 더 나은 방법임
아래처럼 코드를 작성하고 실행해 보면 지정한 시간이 지난 후 SocketTimeoutException이 발생하는 것을 확인할 수 있음
new Socket()
- Socket 객체를 생성할 때 인자로 IP, PORT를 모두 전달하면 생성자에서 바로 TCP 연결을 시도하지만 IP, PORT를 모두 빼고 객체를 생성하면 객체만 생성되고 연결은 시도하지 않음
- 추가적으로 필요한 설정을 더 한 다음에 socket.connect()를 호출하면 그때 TCP 연결을 시도하는데, 이 방식을 사용하면 추가적인 설정을 더 할 수 있음
- 대표적으로 타임아웃 설정이 있음
- InetSocketAddress: SocketAddress의 자식으로 IP, PORT 기반의 주소를 객체로 제공함
- timeout: 밀리초 단위로 연결 타임아웃을 지정할 수 있음
package network.exception.connect;
public class ConnectTimeoutMain2 {
public static void main(String[] args) throws IOException {
long start = System.currentTimeMillis();
try {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("192.168.1.250", 45678), 3000);
} catch (SocketTimeoutException e) {
// SocketTimeoutException: Connect timed out
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("end = " + (end - start));
}
}
/* 실행 결과
java.net.SocketTimeoutException: Connect timed out
at java.base/sun.nio.ch.NioSocketImpl.timedFinishConnect(NioSocketImpl.java:546)
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:592)
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
at java.base/java.net.Socket.connect(Socket.java:751)
at network.exception.connect.ConnectTimeoutMain2.main(ConnectTimeoutMain2.java:14)
end = 3013
*/
TCP 소켓 타임아웃, red 타임 아웃
타임아웃 중 또 하나 중요한 타임아웃이 있는데, 바로 소켓 타임아웃 또는 read 타임 아웃이라고 부르는 타임아웃임
앞에서 설명한 연결 타임아웃은 TCP 연결이 잘 안 되었을 때 발생하는 타임아웃임
연결이 잘 된 이후에 클라이언트가 서버에 어떤 요청을 했는데, 서버에 사용자가 폭주하고 매우 느려져서 응답을 계속 주지 못하는 상황에서 사용할 수 있는 것이 바로 소켓 타임아웃(read 타임아웃) 임
SoTimeoutServer
- 서버는 소켓을 연결은 하지만 응답을 주지 않도록 sleep() 메서드에 시간을 길게 설정
package network.exception.connect;
public class SoTimeoutServer {
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket(12345);
Socket socket = serverSocket.accept();
Thread.sleep(1000000000); // 응답이 매우 느림
}
}
SoTimeoutClient
- socket.setSoTimeout()을 사용하면 밀리초 단위로 타임아웃 시간을 설정할 수 있어 무한정 서버의 응답을 대기하지 않도록 할 수 있음
- 타임아웃 시간을 설정하지 않으면 read()는 응답이 올 때까지 대기하는데, OS와 시스템 상황에 따라 다르지만 한 시간 ~ 하루 이상 대기할 수도 있음
- 서버를 실행하고 클라이언트를 실행해 보면 서버와의 연결은 잘 되었지만 서버에서 응답을 주지 않아 설정한 3초가 지난 후 SocketTimeoutException: Read timed out이 발생하는 것을 확인할 수 있음
package network.exception.connect;
public class SoTimeoutClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 12345);
InputStream input = socket.getInputStream();
try {
socket.setSoTimeout(3000); // 타임아웃 시간을 설정
int read = input.read(); // 기본은 무한 대기
System.out.println("read = " + read);
} catch (Exception e) {
e.printStackTrace();
}
socket.close();
}
}
/* 실행 결과
java.net.SocketTimeoutException: Read timed out
at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:278)
at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:304)
at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:346)
at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:796)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1099)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1093)
at network.exception.connect.SoTimeoutClient.main(SoTimeoutClient.java:13)
*/
실무 이야기
실무에서 자주 발생하는 장애 원인 중 하나가 바로 연결 타임아웃, 소켓 타임아웃(read 타임 아웃)을 누락하기 때문에 발생함
서버도 외부에 존재하는 데이터를 네트워크를 통해 불러와야 하는 경우가 있음
예를 들어 주문을 처리하는 서버가 외부에 있는 서버를 통해 고객의 신용카드 결제를 처리하는 구조일 때 신용카드 A, 신용카드 B 회사의 서버는 문제가 없고 신용카드 C회사 서버에 문제가 발생해서 응답을 주지 못하는 상황이라고 가정하면, 고객이 주문 서버를 통해 신용카드 C회사를 통해 결제를 요청하면 주문 서버는 계속 신용카드 C회사 서버의 응답을 기다리게 됨
여기서 문제는 신용카드 C의 결제에 대해서 주문 서버도 고객에게 응답을 주지 못하고 계속 대기하게 되고, 신용카드 C로 주문하는 고객이 누적될수록 주문 서버의 요청은 계속 쌓이고 신용카드 C 회사 서버의 응답을 기다리는 스레드도 점점 늘어남
결국 주문 서버에 너무 많은 사용자가 접속하게 되면서 주문 서버에 장애가 발생하게 되면 결과적으로 신용카드 C 때문에 신용카드 A, 신용카드 B를 사용하는 고객까지 모두 주문을 할 수 없는 매우 큰 사태가 벌어짐
이런 장애는 신용카드 C회사의 문제라기 보단 주문 서버 개발자가 적절한 조치를 하지 않았기 때문임
만약 주문 서버에 연결, 소켓 타임아웃을 적절히 설정했다면 신용카드 C회사 서버가 연결이 오래 걸리거나 응답을 주지 않을 때 타임아웃으로 처리할 수 있음
이렇게 되면 요청이 쌓이지 않기 때문에 주문 서버에 장애가 발생하지 않고 타임아웃이 발생하는 신용카드 C 사용자에게는 현재 문제가 있다는 안내를 하면 되고 나머지 신용카드는 모두 정상적으로 이용할 수 있게 됨
이렇게 예외 처리를 하려고 해도 주문 서버가 살아야 예외처리를 할 수 있기 때문에 장애를 막는 장치를 두어야 함
백엔드 개발자는 기본적으로 상대 서버에 문제가 있다는 가정을 하고 개발을 해야 장애를 막을 수 있음
외부 서버와 통신을 하는 경우 반드시 연결 타임아웃과 소켓 타임아웃을 지정해서 장애를 방지해야 함
정상 종료
TCP에서 A, B가 서로 통신한다고 가정했을 때 TCP 연결을 종료하려면 서로 FIN 메시지를 보내야 함
- A (FIN) -> B: A 가 B로 FIN 메시지를 보냄
- A <-(FIN) B: FIN 메시지를 받은 B도 A에게 FIN 메시지를 보냄
socket.close()를 호출하면 TCP에서 종료의 의미인 FIN 패킷을 상대방에게 전달하고, FIN 패킷을 받으면 상대방도 socket.close()를 호출해서 FIN 패킷을 상대방에게 전달해야 함
- 서버가 연결 종료를 위해 socket.close()를 호출하면 서버는 클라이언트에 FIN 패킷을 전달함
- 클라이언트는 FIN 패킷을 받고 클라이언트의 OS에서 FIN에 대한 ACK 패킷을 전달함
- 클라이언트도 종료를 위해 socket.close()를 호출하면 클라이언트는 서버에 FIN 패킷을 전달함
- 서버의 OS는 FIN 패킷에 대한 ACK 패킷을 전달함
- TCP 서버 연결 시에는 3 way handshake이지만, 종료 시에는 4 way handshake임
NormalCloseServer
- 서버는 소켓이 연결되면 1초 뒤에 연결을 종료함
- 종료를 위해 서버에서 socket.close()를 호출하면 클라이언트에 FIN 패킷을 보냄
package network.exception.close;
public class NormalCloseServer {
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket(12345);
Socket socket = serverSocket.accept();
log("소켓 연결: " + socket);
Thread.sleep(1000);
socket.close();
log("소켓 종료");
}
}
NormalCloseClient
- 클라이언트는 서버의 메시지를 3가지 방법으로 읽으며 서버가 종료하여 FIN을 전달하면 각각의 메서드의 EOF 반환값에 따라 자원을 종료함
- read(): 1byte 단위로 읽음, 읽을 파일이 없으면 -1을 반환함
- readLine(): 라인 단위로 String으로 읽음, 읽을 파일이 없으면 null을 반환함
- readUTF(): DataInputStream을 통해 String단위로 읽음, 읽을 파일이 없으면 EOFException이 발생함
- 클라이언트에 정의된 각 메서드는 모두 socket.close()가 있어서 동시에 실행하면 socket()도 동시에 호출되는데, 내부에서 중복 호출이 되지 않도록 예방 코드가 들어갔어서 중복 호출해도 문제가 발생하지 않음
package network.exception.close;
public class NormalCloseClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 12345);
log("소켓 연결: " + socket);
InputStream input = socket.getInputStream();
readByInputStream(input, socket);
readByBufferedReader(input, socket);
readByDataInputStream(input, socket);
log("연결 종료: " + socket.isClosed());
}
private static void readByInputStream(InputStream input, Socket socket) throws IOException {
int read = input.read();
log("read = " + read);
if (read == -1) {
input.close();
socket.close();
}
}
private static void readByBufferedReader(InputStream input, Socket socket) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(input));
String readString = br.readLine();
log("readString = " + readString);
if (readString != null) {
br.close();
socket.close();
}
}
private static void readByDataInputStream(InputStream input, Socket socket) throws IOException {
DataInputStream dis = new DataInputStream(input);
try {
dis.readUTF();
} catch (EOFException e) {
log(e);
} finally {
dis.close();
socket.close();
}
}
}
실행 결과 - 서버로그
17:25:54.260 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=56188]
17:25:55.261 [ main] read = -1
17:25:55.265 [ main] readString = null
17:25:55.266 [ main] java.io.EOFException
17:25:55.266 [ main] 연결 종료: true
- 클라이언트와 서버가 연결된 후 1초가 지나면 서버가 종료되고 클라이언트가 FIN을 응답받아서 서버의 값을 읽는 메서드의 EOF 반환값에 따라 동작하여 자원을 종료하는 것을 확인할 수 있음
전체 과정
- 클라이언트가 서버에 접속
- 클라이언트는 input.read()로 서버의 데이터를 읽기 위해 대기함
- 1초 뒤에 서버에서 연결을 종료하고 서버에서 socket.close()를 호출하여 클라이언트에 FIN 패킷을 보냄
- 클라이언트는 FIN 패킷을 받음
- 서버가 소켓을 종료했다는 의미이므로 클라이언트는 더는 읽을 데이터가 없음
- FIN 패킷을 받을 클라이언트는 소켓은 더는 서버를 통해 읽을 데이터가 없다는 의미로 EOF를 반환함
- 여기서 각각의 상황에 따라 EOF를 해석하는 방법이 달라짐
- read()는 EOF의 의미를 숫자 -1로 반환함
- BufferedReader의 readLine()는 문자 String을 반환하므로 -1을 표현할 수 없어서 null을 반환함
- DataInputStream의 readUTF()는 EOFException 예외를 던짐(바이트를 읽는 메서드는 -1을 반환함)
여기서 중요한 점은 EOF가 발생하면 상대방이 FIN 메시지를 보내면서 소켓 연결을 끊었다는 뜻임
이 경우 소켓에 다른 작업을 하면 안 되고 FIN 메시지를 받은 클라이언트도 close()를 호출해서 상대방에 FIN 메시지를 보내고 소켓 연결을 끊어야 서로 FIN 메시지를 주고받으면서 TCP 연결이 정상 종료 됨
강제 종료
TCP 연결 중에 문제가 발생하면 RST라는 패킷이 발생하는데 이 경우 연결을 즉시 종료해야 함
ResetCloseServer
- 서버는 소켓이 연결되면 단순히 연결을 종료함
package network.exception.close;
public class ResetCloseServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
Socket socket = serverSocket.accept();
log("소켓 연결: " + socket);
socket.close();
serverSocket.close();
log("소켓 종료");
}
}
ResetCloseClient
- 서버와 연결되면 바로 서버가 종료되어 FIN 메시지를 받지만, 이를 무시하고 메시지를 보내고 받을 때의 결과를 확인
- 처음 FIN 메시지를 받았을 때 이를 무시하고 다른 메시지를 보내면 서버에서 잘못된 응답이라고 판단하여 서버에서 RST를 보내고 응답이 온 값을 읽으면 SocketException: Connection reset 예외가 발생하고 연결이 끝남
- RST를 받은 상태에서 한 번 더 서버에 메시지를 보내면 그때는 SocketException: Broken pipe 예외가 발생함
** 참고
- 윈도우에서는 예외는 같지만 오류 메시지가 "현재 연결은 사용자의 호스트 시스템의 소프트웨어에 의해 중단되었습니다"처럼 나온다고 함
package network.exception.close;
public class ResetCloseClient {
public static void main(String[] args) throws IOException, InterruptedException {
Socket socket = new Socket("localhost", 12345);
log("소켓 연결: " + socket);
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream();
// client <- server: FIN
Thread.sleep(1000); // 서버가 close() 호출할 때 까지 잠시 대기
// client -> server: PUSH[1]
output.write(1); // 서버가 종료했지만 클라이언트가 그냥 메시지를 보냄, TCP 규약에 벗어남
// client <- server: RST
Thread.sleep(1000); // RST 메시지 전송 대기
try {
// java.net.SocketException: Connection reset 발생
int read = input.read(); // RST를 받은 후 값을 읽음
System.out.println("read = " + read);
} catch (SocketException e) {
e.printStackTrace();
}
try {
output.write(1); // RST를 받았는데 또 메시지를 보냄
} catch (SocketException e) {
// java.net.SocketException: Broken pipe
e.printStackTrace();
}
}
}
/* 실행 결과
17:47:29.219 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=56303]
java.net.SocketException: Connection reset
...
java.net.SocketException: Broken pipe
...
*/
예제 설명
- 서버는 종료를 위해 socket.close()를 호출하면 클라이언트에 FIN 패킷을 전달함
- 클라이언트는 FIN 패킷을 받으면 OS에서 FIN에 대한 ACK 패킷을 전달함
- 여기서 클라이언트가 FIN이 아니라 output.write(1)로 서버에 메시지를 전달하면 데이터를 전송하는 PUSH 패킷이 서버에 전달됨
- 서버는 이미 FIN으로 종료를 요청했는데 서버가 기대하는 FIN 패킷이 아닌 PUSH 패킷으로 데이터가 전송되었으므로 서버는 TCP 연결에 문제가 있다고 판단하고 즉각 연결을 종료하라는 RST 패킷을 클라이언트에 전송함
RST 패킷이 도착했다는 것은 현재 TCP 연결에 심각한 문제가 있으므로 해당 연결을 더는 사용하면 안 된다는 의미임
RST 패킷이 도착하면 자바는 read()로 메시지를 읽거나, write()로 메시지를 전송할 때 예외를 던짐
** 참고 - RST(Reset)
- TCP에서 RST 패킷은 연결 상태를 초기화(리셋)해서 더 이상 현재의 연결을 유지하지 않겠다는 의미를 전달함
- 여기서 Reset은 현재의 세션을 강제로 종료하고 연결을 무효화하라는 뜻임
- RST 패킷은 TCP 연결에 문제가 있는 다양한 상황에 발생함
- TCP 스펙에 맞지 않는 순서로 메시지가 전달될 때
- TCP 버퍼에 있는 데이터를 아직 다 읽지 않았는데 연결을 종료할 때
- 방화벽 같은 곳에서 연결을 강제로 종료할 때
** 참고 - java.net.SocketException: Socket is Closed
- 자기 자신의 소켓을 닫은 이후에 read(), write() 메서드를 호출하면 해당 예외가 발생함
정리
상대방이 연결을 종료한 경우 데이터를 읽으면 EOF가 발생함
- -1, null, EOFException 등이 발생하고 이 경우 연결을 끊어서 정상 종료 해야 함
java.net.SocketException: Connection reset
- RST 패킷을 받은 이후에 read()를 호출하면 발생함
java.net.SocketException: Broken pipe
- RST 패킷을 받은 이후에 write()를 호출하면 발생함
java.net.SocketException: Socket is closed
- 자신이 소켓을 닫은 이후에 read(), write()를 호출하면 발생함
네트워크 종료와 예외 정리
네트워크에서 이런 예외를 모두 따로따로 이해하고 어떤 문제가 언제 발생할지 자세하게 다 구분해서 처리하기는 어려움
기본적으로 정상 종료, 강제 종료 모두 자원을 정리하고 닫도록 설계하면 됨
예를 들어 SocketException, EOFException은 모두 IOException의 자식이기 때문에 네트워크 프로그램에서 다루었던 것처럼 IOException이 발생하면 자원을 정리하면 됨
만약 더 자세히 분류해야 하는 경우가 발생하면 그때 예외를 구분해서 처리하면 됨
'자바 로드맵 강의 > 고급 2 - 입출력, 네트워크, 리플렉션' 카테고리의 다른 글
네트워크 - 채팅 프로그램, 채팅 프로그램(설계, 클라이언트, 서버) (0) | 2025.03.01 |
---|---|
네트워크 - 프로그램, 문제 - 직접 채팅 프로그램 만들기1 (0) | 2025.02.28 |
네트워크 - 프로그램, 네트워크 프로그램1, 네트워크 프로그램2, 네트워크 프로그램3, 자원 정리 (0) | 2025.02.25 |
네트워크 - 기본이론, 클라이언트와 서버, 인터넷 통신, IP(인터넷 프로토콜), TCP, UDP, PORT, DNS (0) | 2025.02.24 |
File, Files, 경로 표시, Files로 문자 파일 읽기, 파일 복사 최적화 (0) | 2025.02.24 |