Java에서 Socket 다루기

dev_314·2023년 1월 9일
0

Java - Trial and Error

목록 보기
2/4

Unix, TCP/IP, Java Socket

Unix sockets

유닉스 소켓은 유닉스 기반 OS의 IPC(inter process communication) 메커니즘의 일종이다.

유닉스 소켓은 같은 머신에서 작동하는 프로세스들이, local file descriptor을 가지고 통신할 수 있도록 도와준다. file descriptor가 소켓 파일의 위치를 refer하는 기능을 수행한다. (유닉스는 모든 자원을 '파일'형태로 관리한다.)

통신이 커널 영역에서 이뤄지므로, 통신에 참여하는 프로세서는 file system에서 생성된 socket file에 접근할 수 있는 권한을 가져야 한다.

TCP/IP 소켓은 네트워크 스택을 거치는 반면, 유닉스 소켓은 커널을 통해 직접 소통하므로 상대적으로 빠르다.

EX) 같은 머신에서 작동하는 웹 서버와 데이터베이스 서버가 소통

TCP/IP sockets

TCP/IP socket은 TCP/IP protocol suite의 근본적인 부분이다.
소켓은 네트워크를 통해 데이터를 전송하고 수신하는 endpoint이다.

소켓은 IP 주소와 포트 번호로 구성된다. 이 둘을 통해, 특정 머신에서 작동하는 특정 프로세스를 식별할 수 있다.

소켓은 직접 초기화를 하는가, 연결을 허용하는가에 따라 클라이언트, 서버 소켓 둘 다 될 수 있다.

TCP/IP 소켓은 네트워크를 통해 데이터를 전송할 수 있도록 하는 신뢰할 수 있고, 스트림 기반의 연결을 제공한다.

TCP/IP 소켓은 OS의 커널에서 구현되고, 다양한 프로그래밍 언어로 활용할 수 있다.

Java sockets

자바 소켓TCP/IP 소켓을 생성하고, 이를 통해 네트워크에서 프로그램들이 통신을 할 수 있도록 도와주는 Java API이다.

자바 소켓은 네트워크에서 TCP/IP protocol에 따라 통신한다.
즉, 자바 소켓은 OS에서 제공하는 기능들을 사용할 수 있도록 도와주는 인터페이스 역할을 하는 TCP/IP의 구현체이다.

이를 사용해서 다른 머신에서 동작하는 자바 프로그램끼리 통신을 할 수 있다.

자바 소켓은 SW 형태로 구현되고, TCP/IP를 지원하는 모든 네트워크 주소에 연결될 수 있다.

자바 소켓은 일반적으로 Clinet가 네트워크를 통해 Server에 연결되는 Client-Server App을 만들때 사용된다.

자바 Socket API 살펴보기

ServerSocket

package java.net;

class ServerSocket implements java.io.Closeable {
	// 실제 소켓 작업을 수행하는 구현체를 필드로 가진다.
	private SocketImpl impl;
	// 소켓 팩토리 객체
    private static SocketImplFactory factory = null;

	public Socket accept() throws IOException {...}
}

ServerSocket은 네트워크를 통해 요청이 오기를 기다린다. 요청에 기반한 operation을 수행한 뒤, 요청자에게 결과를 반환한다.

실제 ServerSocket의 작업은 SocketImpl 인스턴스가 수행한다.
SocketImpl도 사실은 추상 클래스이고, 내부적으로 SocketImpl를 구현한 인스턴스들이상황에 맞춰 SocketImpl객체에 할당된다.

App은 로컬 방화벽에 알맞은 소켓을 만들기 위해, socket구현체를 만드는 SocketFactory를 변경할 수 있다.

ServerSocket.accpet()

class ServerSocket implements java.io.Closeable {
	
    ...
    
    public Socket accept() throws IOException {
		// ServerSocket이 close되어있으면 에러
		if (isClosed()) 
            throw new SocketException("Socket is closed");
		// Socket이 특정 port에 bound되어있지 않으면 에러
		if (!isBound()) 
            throw new SocketException("Socket is not bound yet");
		// Socket이 내부적으로 사용할 SocketImpl을 null로 set
		Socket s = new Socket((SocketImpl) null); 
        // 1. SocketImplFactory보유 여부에 따라 socketImpl객체 생성후 초기화
        // 2. 여러가지 정보 (request ip address 등) 초기화
        // 3. Socket 상태 정보 초기화 (connected, created, bound)
        implAccept(s); (throws SecurityException)
        return s;
    }
}

ServerSocket객체에 connection이 생성되는 것을 listen하고, accept한다.
accpet메서드는 connection이 생성될 때 까지 block한다.
securityManager가 요청을 검증한 뒤, 새로운 Socket 객체를 반환한다.
서버와 클라이언트의 Connection에서, 새로 생성된 Socket은 클라이언트의 소켓으로 사용된다.

Socket

public
class Socket implements java.io.Closeable {

	...
    
    public InputStream getInputStream() throws IOException {...}

    public OutputStream getOutputStream() throws IOException {...}
}

Socket클래스는 클라이언트측 소켓을 구현한다.
socket은 두 머신간의 소통의 양 끝단이다.
ServerSocket과 마찬가지로

  1. 실제 작업은 필드값으로 가지고 있는 SocketImpl객체가 수행한다.
  2. SocketFactory를 통해 로컬 방화벽에 맞춘 Socket구현체를 변경할 수 있다.

ServerSocket과 Socket의 차이점

참고: Difference between Socket and ServerSocket Class

짧게 요약하자면
1. Socket은 ClientSide의 Socket으로 사용된다.
2. ServerSocket는 ServerSide의 Socket으로 사용된다.

Request읽고 Response 작성하기

// 요청을 처리하는 Class가 Runnable을 implement한 상황
public void run(Socket socket) {
	sout(socket.getInetAddress()); // Client Socket에 저장된 Client IP address
	sout(socket.getPort()); // Client Socket에 저장된 Client Port 

	// Client Socket에서 InputStream, OutputStream가져오기
	try (InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) {
		InputStreamReader inputStreamReader = new InputStreamReader(in);
		BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
		
        // InputStream에서 bufferedReader를 꺼내와서 Request message를 읽는다.
		String line;
		while (!(line = bufferedReader.readLine()).isEmpty()) {
			System.out.println(line);
		}

		// OutputStream에서 DataOutputStream을 꺼내와서 Response message를 작성한다.
		DataOutputStream dos = new DataOutputStream(out);
		byte[] body = "Hello World".getBytes();
        dos.writeByte("HTTP/1.1 200 OK");
        dos.writeByte("Content-type:text/html");
		dos.writeByte("Content-length:" + body.length);
        ...
        dos.write(body, 0, body.length)
        dos.flush();
	} catch (IOException e) {
		logger.error(e.getMessage());
	}
}

getInputStream, getOutputStream

SocketgetInputStream(), outputStream() 메서드를 통해 socket의 InputStream, OutputStream을 불러온다.

정확히는 socket내부의 socketImpl의 InputStream, OutputStream을 불러오는 것이다.

public InputStream getInputStream() throws IOException {
	if (isClosed()) // Socket이 닫혀있으면 에러
		throw new SocketException("Socket is closed");
	if (!isConnected()) // Socket이 연결이 끊겨있으면 에러
		throw new SocketException("Socket is not connected");
	// InpuStream이 닫혀있으면 에러 (ShutIn 필드 사용)
	// 실제 InpuStream객체의 상태를 변경하지 않고, Socket 내부의 필드 값만 변경하는 듯
	if (isInputShutdown()) 
		throw new SocketException("Socket input is shutdown");
	// 반환할 InputStream객체 초기화
	InputStream is = null;
	try {
		is = AccessController.doPrivileged(
			new PrivilegedExceptionAction<>() {
				public InputStream run() throws IOException {
					return impl.getInputStream();
				}
			});
	} catch (java.security.PrivilegedActionException e) {
		throw (IOException) e.getException();
	}
	return is;
}

public OutputStream getOutputStream() throws IOException {
	if (isClosed())
		throw new SocketException("Socket is closed");
	if (!isConnected())
		throw new SocketException("Socket is not connected");
	if (isOutputShutdown())
		throw new SocketException("Socket output is shutdown");
	OutputStream os = null;
	try {
		os = AccessController.doPrivileged(
			new PrivilegedExceptionAction<>() {
				public OutputStream run() throws IOException {
					return impl.getOutputStream();
				}
			});
	} catch (java.security.PrivilegedActionException e) {
		throw (IOException) e.getException();
	}
	return os;
}

getInputStream메서드는 소켓의 InputStream을 반환한다.

네트워크 SW가 Connection이 broke되었음을 감지하면 다음 내용이 InputStream에 적용된다.

  1. 네트워크 SW가 소켓에 버퍼링된 바이트들을 버린다. 버려지지 않은 바이트들은 read메서드로 읽을 수 있다.
  2. 소켓에 버퍼링된 바이트가 더이상 없거나 모든 버퍼링된 바이트들이 읽혔을때(소비됐다면), 다음번의 읽기 작업을 수행하면 IOException을 던진다.
  3. 소켓에 버퍼링된 바이트가 더이상 없지만 아직 소켓이 닫히지 안았다면, available 메서드는 0을 반환한다.

InputStream을 close하면, 해당 InputStream을 반환한 Socket도 close된다.

getOutputStream메서드는 소켓의 OutpuStream을 반환한다.

InputStream, OutputStream

InputStreamOutputStream은 각각 데이터를 읽고, 쓰는 방법을 제공하는 Java IO API의 클래스이다.

InputStream는 파일, 네트워크 연결 등으로부터 데이터를 읽을 때 사용한다.
OutputStream은 파일, 네트워크 연결 등의 목적지로 데이터를 쓰는데 사용한다.
두 클래스 모두 추상 클래스이므로 직접 인스턴스를 생성할 수 없고, 각각을 구현한 서브 클래스를 사용해야한다.(EX: FileInputStream, ByteArrayOutputStream 등)

사용할 서브 클래스는 어떤 형태의 데이터 출처로부터 데이터를 불러올지에 따라 달라진다.

FileInputStream: 파일 읽기
ByteArrayOutputStream: byte[]에 쓰기
...

대략적으로 어떻게 작동하는지 살펴보자

// InputStream
byte[] bytes = {'a', 'b', 'c', 'd'};
// 어디서 데이터를 읽어야할지 파라미터로 넘겨줘야 한다. (EX: FileInputStream은 파일 경로를 입력한다.)
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
// 스트림 하나 읽기
int read = byteArrayInputStream.read(); // 97 ('a'의 ascii code)
System.out.println(read);
// 스트림에서 데이터를 하나 읽었으므로, 스트림에 남은 데이터는 3개

// 모든 스트림 읽기
byte[] readBytes = byteArrayInputStream.readAllBytes();
for (byte readByte : readBytes) {
	System.out.print(readByte + " "); // 98 99 100
}

int empty = byteArrayInputStream.read(); // 스트림이 비어있으면 -1

OutputStream은 언제 데이터를 실제로 저장하는가?

outputStream.write(...);

위 코드는 실제로 데이터를 write하지 않는다.
write에 전달된 argument는 스트림 내부의 버퍼에 저장된다.

그렇다면 OutputStream은 언제 데이터를 실제로 write할까?

1. 버퍼가 꽉 찼을 때

  • 버퍼는 특정 임계치를 가지고 있다. 그리고 버퍼가 꽉 차게되면 출력스트림은 버퍼의 내용을 실제로 write한다.
  • 버퍼의 크기를 explicit하게 알 수 있는 방법은 없다.
  • 버퍼가 꽉 찼는지 explicit하게 알 수 있는 방법은 없다.
  • 그러나 BufferedOutputStream은 생성 당시에 버퍼 사이즈를 지정할 수 있다.

2. 스트림이 closed될 때

  • 출력스트림의 close가 호출되면, 스트림은 자원을 정리하기 전에 우선 버퍼에 남아있는 데이터를 실제로 write한다.

3. flush 메서드가 호출될 때

flush메서드

스트림은 입력받은 데이터를 즉각적으로 write하지 않고, 버퍼에 저장하여 과도한 IO비용을 줄일 수 있다는 장점이 있다.
그러나 network Socket을 통해 즉각적으로 데이터를 전송해야 하는 등의 상황에서는 한계로 작동할 수 있다.
이러한 문제를 해결하기 위해 flush메서드가 등장했다.

flush는 다음의 특징을 갖는다.

  1. OutputStream의 flush메서드를 통해 강제로 버퍼에 저장된 데이터를 write할 수 있다.
  2. flush를 호출하면 버퍼는 empty가 된다.
  3. flush를 호출해도 OutputStream은 closed되지 않는다.

일부 구현체들은 자동으로 flush를 호출하기도 한다.

  1. 버퍼에 특정 임계치의 데이터가 쌓인 경우 자동 flush
  2. 특정 시간 주기로 자동 flush
byte[] bytes = {'a', 'b', 'c', 'd'};
// FileOutputStream은 파라미터로 전달받은 path에, 실제로 스트림에 buffer된 내용을 저장한다.
try (OutputStream outputStream = new FileOutputStream("test.txt")) {
    for (byte aByte : bytes) {
        outputStream.write(aByte);
    }
    outputStream.flush(); // flush가 호출되어도 스트림은 close되지 않는다. -> 스트림 재활용 가능
} catch (IOException e) {
}

DataOutputStream

일반적인 OutputStream은 byte (또는 int)만 write할 수 있다는 사용의 단점을 가진다.

DataOutputStream는 app이 primitive Java data 타입들을 쉽게 write할 수 있도록 도와준다.

profile
블로그 이전했습니다 https://dev314.tistory.com/

0개의 댓글