728x90

Java에서 NIO에 대해 알아보자.

 

java New Input/Output의 줄인말이며, 파일과 네트워크에 데이터를 읽고 쓸 수 있는 API를 제공한다.

buffer기반이며, non-blocking을 지원한다.

 

java IO는 InputStream, OutputStream을 사용해서 방향이 정해져있었다면, NIO는 Channel을 이용해 읽고 쓰기가 한번에 가능하다.

또한 NIO는 아예 시작부터 buffer의 단위로 데이터를 주고 받는다.

 

Channel, Buffer

데이터를 읽을 때는 Channel read()를 사용하여 버퍼에 데이터를 저장한다.

데이터를 쓸 때는 Channel write()를 사용하여 버퍼에 데이터를 쓴다.

 

이 버퍼에서도 ByteBuffer부터 CharBuffer까지 다양한 타입의 버퍼가 존재한다.

 

Buffer의 위치

버퍼의 현재 커서를 나타내는 다양한 속성이 있다.

capacity: Buffer의 크기이다. Buffer 생성시에 결정된다.

position: Buffer의 현재 위치이다. 버퍼에서 데이터를 읽거나 쓸 때 시작하는 위치이며, 1Byte가 추가될 때마다 1씩 증가한다.

limit: Buffer에서 데이터를 읽거나 쓸 수 있는 마지막 위치이다. limit 이후로 데이터를 읽거나 쓰기 불가능하다.

mark: 현재 position의 위츠를 지정한다.

 

Buffer의 위치 메서드

flip: Buffer의 limit 위치를 현재 position으로 이동시키고, position을 0으로 리셋한다.

rewind: Buffer의 position 위치를 0으로 리셋. limit은 유지한다.

clear: Buffer의 limit 위치를 capacity 위치로 이동시키고, position을 0으로 리셋한다.

 

Buffer

Java IO는 JVM의 버퍼에만 접근이 가능하고, 시스템 자체에는 접근이 불가능했다.

하지만 Java NIO는 시스템 버퍼에도 접근이 가능하다.

NIO의 버퍼들이다.

DirectByteBuffer HeapByteBuffer
시스템 자체 메모리에 저장 JVM Heap 메모리에 저장
자체 메모리에서 JVM으로 복사를 하지 않기 때문에 속도가 빠르다. JVM으로 복사가 일어나므로 데이터를 읽고 쓰는 속도가 느리다.
allocate, deallocate가 느리다. gc에서 관리하기에 allocate, deallocate가 빠르다.

 

DirectByteBuffer는 allocateDirect() 함수로 생성이 가능하고, HeapByteBuffer는 allocate(), wrap() 함수로 생성 가능하다.

isDirect인지 DirectBuffer인지 아닌지 확인 가능하다.

 

FileChannel - Read

@Slf4j
public class FileChannelReadExample {

    public static void main(String[] args) throws IOException {
        log.info("start");

        var file = new File("path");

        try(var fileChannel = FileChannel.open(file.toPath())){
            var byteBuffer = ByteBuffer.allocateDirect(1024);
            fileChannel.read(byteBuffer);
            byteBuffer.flip();

            var result = StandardCharsets.UTF_8.decode(byteBuffer);
            log.info("result: {}", result);
        }
        log.info("end");
    }
}

 

allocateDirect로 커널을 사용하여 파일을 읽어온다.

NIO를 사용하기 때문에 FileChannel에서 읽고 쓰기가 가능하다.

 

아래는 ByteBuffer.wrap()으로 JVM버퍼를 사용하는 예제이다.

@Slf4j
public class FileChannelWriteExample {

    public static void main(String[] args) throws IOException {
        var file = new File("path");

        var mode = StandardOpenOption.WRITE;
        try(var fileChannel = FileChannel.open(file.toPath(), mode)){
            var buffer = ByteBuffer.wrap("Hi Seungkyu~~".getBytes());
            var result = fileChannel.write(buffer);
            log.info("result: {}", result);
        }
    }
}

 

SocketChannel

이제 배운것들로 소켓 서버와 클라이언트를 만들어보자.

 

우선 서버이다.

@Slf4j
public class ServerSocketChannelExample {

    public static void main(String[] args) throws IOException {

        log.info("start");

        try(var serverChannel = ServerSocketChannel.open()){
            var address = new InetSocketAddress("localhost", 8080);
            serverChannel.bind(address);

            try(var clientSocket = serverChannel.accept()){
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
                clientSocket.read(byteBuffer);
                byteBuffer.flip();

                var request = new String(byteBuffer.array()).trim();
                log.info("request: {}", request);

                var response = "I am Server";
                var responseBuffer = ByteBuffer.wrap(response.getBytes());
                clientSocket.write(responseBuffer);
                responseBuffer.flip();
            }

        }

        log.info("end");
    }
}

 

Direct 버퍼를 만들어서 읽어온다.

Socket에서도 read와 flip을 사용해서 읽어노는 것을 볼 수 있다.

 

다음은 클라이언트이다.

@Slf4j
public class ClientSocketChannelExample {

    public static void main(String[] args) throws IOException {
        log.info("start");

        try(var socketChannel = SocketChannel.open()){
            var address = new InetSocketAddress("localhost", 8080);
            var connected = socketChannel.connect(address);
            log.info("connected: {}", connected);

            String request = "I am Client";
            ByteBuffer requestBuffer = ByteBuffer.wrap(request.getBytes());
            socketChannel.write(requestBuffer);
            requestBuffer.clear();
            
            ByteBuffer responseBuffer = ByteBuffer.allocateDirect(1024);
            while (socketChannel.read(responseBuffer) > 0){
                responseBuffer.flip();
                log.info("response: {}", StandardCharsets.UTF_8.decode(responseBuffer));
                responseBuffer.clear();
            }
        }
        log.info("end");
    }
}

 

 

'백엔드 > 리액티브 프로그래밍' 카테고리의 다른 글

Java AIO  (0) 2024.03.19
Java IO Server 서버를 NIO로 변경  (0) 2024.03.19
JAVA IO  (0) 2024.03.18
Mutiny  (0) 2024.03.14
RxJava Reactor  (2) 2024.03.14
728x90

3주차에는 주소체계와 TCP 기반 서버/클라이언트에 대하여 공부한다.

 

  • 인터넷 주소

인터넷 주소는 당연히 인터넷 상에서 컴퓨터끼리 구분하기 위해 사용되는 주소이다.

4바이트 주소체계인 IPv4와 16바이트 주소체계인 IPv6가 존재한다.

네트워크 주소와 호스트 주소로 나뉘는데, 이름 그대로 네트워크 주소를 이용해서 네트워크를 찾고 호스트 주소를 이용해서 해당 네트워크에서 호스트를 검색한다.

클래스 A 1 Byte (네트워크 ID) 1 Byte(호스트 ID) 1 Byte(호스트 ID) 1 Byte(호스트 ID)
클래스 B 1 Byte (네트워크 ID) 1 Byte (네트워크 ID) 1 Byte(호스트 ID) 1 Byte(호스트 ID)
클래스 C 1 Byte (네트워크 ID) 1 Byte (네트워크 ID) 1 Byte (네트워크 ID) 1 Byte(호스트 ID)
클래스 D 멀티캐스트 IP 주소

 

클래스 A의 첫 번재 바이트 범위는 0이상     127이하  -> 클래스 A의 첫 번째 비트는 항상 0으로 시작

클래스 B의 첫 번째 바이트 범위는 128이상 191이하  -> 클래스 B의 첫 두 비트는 항상 10으로 시작

클래스 C의 첫 번째 바이트 범위는 192이상 223이하 -> 클래스 C의 첫 세 비트는 항상 110으로 시작

 

첫 번째 바이트 정보만 참조해도 IP주소의 클래스 구분이 가능하므로, 네트워크 주소와 호스트 주소의 경계를 구분할 수 있다.

 

  • Port

IP는 컴퓨터끼리 구분하기 위해 사용된다.

그러면 한 컴퓨터 내의 프로그램끼리는 어떻게 구분을 할까?

바로 Port를 사용해서 구분한다.

Port 번호는 소켓을 구분하는 용도로 사용이 된다.

둘 이상의 포트가 하나의 프로그램에 할당될 수도 있기는 하다.

2의 16제곱인 0 ~ 65535의 수를 포트로 지정을 할 수 있지만, 그 중에서 0 ~ 1023은 이미 용도가 지정되어 있는 포트들이라 사용을 피하는 것이 좋다.

 

  • 주소 표현을 위한 구조체
struct sockaddr_in {
	__uint8_t       sin_len;
	sa_family_t     sin_family;
	in_port_t       sin_port;
	struct  in_addr sin_addr;
	char            sin_zero[8];
};

sin_family: 주소체계 정보를 저장한다.

주소체계 의미
AF_INET IPv4 인터넷 프로토콜
AF_INET6 IPv6 인터넷 프로토콜
AF_LOCAL 로컬 통신을 위한 유닉스 프로토콜의 주소체계

sin_port: 16비트 Port 번호를 저장한다.

 

sin_addr: 32비트의 IP주소 정보를 저장한다.

in_addr 구조체는 그냥 32비트 정수 자료형이다.

 

sin_zero: 특별한 의미를 지니지 않으며, 0으로 채운다.

struct sockaddr_in의 크기를 struct_sockaddr에 맞춰주기 위해 만든 멤버이다.

 

이 sockaddr_in은 bind 함수의 인자로 전달을 하는데 bind 함수의 매개변수 타입은 sockaddr이기 때문에 형 변환이 필요하다.

sockaddr도 같은 16바이트이다.

구조체를 살펴보면

struct sockaddr {
	__uint8_t       sa_len;         /* total length */
	sa_family_t     sa_family;      /* [XSI] address family */
	char            sa_data[14];
};

이렇게 작성이 되어 있다.

sockaddr에 IPv4의 정보를 담기가 불편해서 동일한 바이트 크기를 가지는 sockaddr_in 구조체를 정의하고 sin_zero를 통해서 구조체의 크기를 맞춘 것이다.

 

  • 바이트 순서와 네트워크 바이트 순서

컴퓨터구조 시간에 빅 엔디안과 리틀 엔디안에 대해서 공부 했을 것이다.

 

- 빅 엔디안(Big Endian)

상위 바이트의 값을 작은 번지수에 저장하는 방식

 

- 리틀 엔디안(Little Endian)

상위 바이트의 값을 큰 번지수에 저장하는 방식

 

우리가 쓰는 컴퓨터가 빅 엔디안을 쓸 수도 있고, 리틀 엔디안을 쓸 수도 있다.

하지만 네트워크 바이트 순서는 빅 엔디안이 기준이다.

데이터를 전송 할 때는 바이트 단위로 데이터를 전송하기 때문에 상관 없지만 sockaddr_in 구조체의 내용을 전송할 때는 빅 엔디안으로 바꾸어 주어야 한다.

 

그럴 때는 아래의 함수들을 사용한다.

#include <stdint.h>
#include <netinet/in.h>

unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);

h는 호스트를 의미하고, n은 네트워크를 의미한다.

htons를 예로 설명하면 호스트에서 네트워크로 short를 변경한다는 의미이다.

만약 현재 내 컴퓨터가 Big Endian 컴퓨터라면 원래의 값을 리턴해주기 때문에 본인의 컴퓨터를 신경쓰지 않고 저 코드들을 사용해주면 된다.

 

#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, char *argv[]){
    
    unsigned short host_port = 0x1234;
    unsigned short net_port;
    unsigned long host_addr = 0x12345678;
    unsigned long net_addr;

    net_port = htons(host_port);
    net_addr = htons(host_addr);

    printf("Host ordered port: %#x \n", host_port);
    printf("Network ordered port : %#x \n", net_port);
    printf("Host ordered address: %#lx \n", host_addr);
    printf("Network ordered address: %#lx \n", net_addr);

    return 0;
}

해당 코드를 실행해보았을 때

Host ordered port: 0x1234 
Network ordered port : 0x3412 
Host ordered address: 0x12345678 
Network ordered address: 0x7856

이런 식으로 순서가 바뀌어 출력되게 된다면 해당 컴퓨터는 리틀 엔디안임을 의미한다.

 

우리는 IP주소를 적을 때 111.111.111.111 처럼 문자열로 작성을 하게 된다.

이렇게만 작성을 하고 우리는 수로 변환해주지 않았는 데, 이 때 inet_addr() 함수를 사용하게 된다.

#include <arpa/inet.h>

in_addr_t inet_addr(const char * string);

해당 문자열을 32비트 정수형으로 변환해준다.

 

#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, char *argv[]){
    char *addr1 = "111.111.111.111";
    char *addr2 = "123.123.1.1";

    unsigned long conv_addr = inet_addr(addr1);
    if(conv_addr == INADDR_NONE) printf("Error!!!\n");
    else printf("Network ordered integer addr:  %#lx\n", conv_addr);

    conv_addr = inet_addr(addr2);
    if(conv_addr == INADDR_NONE) printf("Error!!!\n");
    else printf("Network ordered integer addr:  %#lx\n", conv_addr);

    return 0;
}

이렇게 코드를 실행해보면

Network ordered integer addr:  0x6f6f6f6f
Network ordered integer addr:  0x1017b7b

우리가 필요로 하는 32비트로 값을 얻게 된다.

 

이번에는 반환하는 것이 아니라 구조체에 저장하는 함수이다.

#include <arpa/inet.h>

int inet_aton(const char * string, struct in_addr * addr);

-string

변환할 IP 주소 정보를 담고 있는 문자열

- addr

변환된 정보를 저장할 in_addr 구조체 변수의 주소 값 전달

return: 1(success), 0(fail)

 

#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>

void error_handling(char *message);

int main(int argc, char *argv[]){
    struct sockaddr_in addr_inet;

    if(inet_aton(argv[1], &addr_inet.sin_addr)) printf("Network ordered integer addr: %#x\n", addr_inet.sin_addr.s_addr);
    else error_handling("Conversion error");

    return 0;
}

void error_handling(char *message){
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

이 코드를 사용해보면 &addr_inet에 데이터가 들어가게 된다.

 

이번에는 반대로 정수형태를 문자열로 바꾸는 함수이다.

#include <arpa/inet.h>

char *inet_ntoa(struct in_addr adr);

- adr

변환할 정수 값

return: 변환된 문자열의 주소 값, -1(fail)

#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>

int main(int argc, char *argv[]){
    struct sockaddr_in addr1, addr2;
    char *str_ptr;
    char str_arr[20];

    addr1.sin_addr.s_addr=htonl(0x1020304);
    addr2.sin_addr.s_addr=htonl(0x1010101);

    str_ptr = inet_ntoa(addr1.sin_addr);
    strcpy(str_arr, str_ptr);
    printf("Dotted-Decimal notation1: %s \n", str_ptr);

    str_ptr = inet_ntoa(addr2.sin_addr);
    strcpy(str_arr, str_ptr);
    printf("Dotted-Decimal notation2: %s \n", str_ptr);

    return 0;
}

실행해보면 문자열로 출력이 되는 것을 볼 수 있다.

 

이제 우리가 인터넷 주소를 초기화 했던 코드들을 살펴보도록 하자.

struct sockaddr_in addr;
//IP 주소를 문자열로 선언, 보통은 받아옴
char *serv_ip = "111.111.111.111";
//PORT 번호를 문자열로 선언, 이것도 보통은 받아옴
char *serv_port = "1204";
//memset을 이용하여 현재 구조체의 모든 값을 0으로 초기화
memset(&addr, 0, sizeof(addr));
//주소 체계를 지정, 보통은 IPV4로 지정
addr.sin_family = AF_INET;
//문자열로 작성된 IP 주소를 입력
addr.sin_addr.s_addr = inet_addr(serv_ip);
//문자열로 작성된 port 번호를 입력
addr.sin_port=htons(atoi(serv_port));

이제 주소 초기화 한 함수들을 이해할 수 있을 것이다.

 

728x90

저번 주에는 서버와 클라이언트들의 소켓을 열고 통신하는 실습을 해보았다.

 

이번 주에는 그 때 사용했던 socket 함수에 대하여 더 자세히 알아보도록 하자.

 

socket

#include <sys/socket.h>

int socket(int domain, int type, int protocol);

- domain

소켓이 사용할 프로토콜 체계의 정보

- type

소켓 데이터 전송방식에 대한 정보 전달(TCP, UDP)

- protocol

두 컴퓨터간 통신에 사용되는 프로토콜 정보

return: file descriptor(success), -1(error)

 

여기에 들어가는 인자들을 하나씩 알아보자.

 

프로토콜 체계

프로토콜 체계는 Protocol Family로 줄여서 PF라고도 부른다.

프로토콜도 종류에 따라서 부류가 나뉘는데, 그 부류들을 가리켜 프로토콜 체계라고 한다.

많이 사용하는 프로토콜 쳬계들이다.

이름 프로토콜 체계(Protocol Family)
PF_INET IPv4 인터넷 프로토콜 체계
PF_INET6 IPv6 인터넷 프로토콜 체계
PF_LOCAL 로컬 통신을 위한 UNIX 프로토콜 체계
PF_PACKET Low Level 소켓을 위한 프로토콜 체계
PF_IPX IPX 노벨 프로토콜 체계

우리는 이 중에서 IPv4에 해당하는 PF_INET을 사용해 학습할 것이다.

 

소켓 타입

소켓의 타입은 데이터 전송 방식을 의미한다.

socket 함수로 소켓을 생성할 때 소켓의 타입도 같이 결정이 되어야 한다.

우리가 사용하는 PF_INET의 대표적인 소켓 타입은 TCP와 UDP에 해당하는 연결 지향형 소켓 타입과 비 연결 지향형 소켓 타입이 있다.

연결지향형(SOCK_STREAM) - TCP 비 연결지향형(SOCK_DGRAM) - UDP
중간에 데이터가 소멸되지 않는다. 순서 상관없이 빠르 속도로 전송한다.
전송 순서대로 데이터가 수신된다.(순서가 유지된다.) 데이터 손실 및 파손의 우려가 있다.
데이터의 경계가 존재하지 않는다. 데이터의 경계가 존재한다.
소켓: 소켓의 연결은 1:1의 구조로 이루어진다. 한번에 전송할 수 있는 데이터의 크기가 제한된다.

 

연결지향형 소켓을 사용시

int SOCKET = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
int SOCKET = socket(PF_INET, SOCK_STREAM, 0);

 

비 연결지향형 소켓을 사용시

int SOCKET = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
int SOCKET = socket(PF_INET, SOCK_DGRAM, 0);

첫 번째, 두 번째 인자로 사실상 소켓이 결정되기 때문에 세 번째 인자는 0으로 전달 해도 된다.

 

2주차에는 1주차에서 실습했던 내용에 기능을 추가하려 한다.

1. Server에서 메시지를 전송하는 것이 아닌, Client에서 메시지를 전송
2. Server는 수신한 Message를 출력

3. 수신한 문자열의 길이를 strlen() 함수를 이용하여 출력
4. Server는 수신한 Message에 ("-> from Server")라는 문자열을 추가하여 다시 클라이언트로 전송
5. Client는 수신한 Message를 출력

 

그렇게 작성한 Server 프로그램의 코드이다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock;
	int clnt_sock;

	struct sockaddr_in serv_addr;
	struct sockaddr_in clnt_addr;
	socklen_t clnt_addr_size;

	char message[BUFSIZ];
	
	if(argc!=2){
		printf("Usage : %s port\n", argv[0]);
		exit(1);
	}
	
	serv_sock=socket(PF_INET, SOCK_STREAM, 0);

	if(serv_sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=htonl(INADDR_ANY); //IP 주소 할당(자기 자신의 IP 주소로)
	serv_addr.sin_port=htons(atoi(argv[1])); //PORT 번호 할당
	
	if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1) //bind 함수로 IP, PORT 할당
		error_handling("bind() error"); 
	
	if(listen(serv_sock, 5)==-1)
		error_handling("listen() error");
	
	clnt_addr_size=sizeof(clnt_addr);  
	clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);
	if(clnt_sock==-1)
		error_handling("accept() error");  
	
    //client로 부터 데이터를 읽어옴
	read(clnt_sock, message, BUFSIZ);
	printf("Received message: %s\n", message); 
    //BUFSIZ의 버퍼 중에서 문자열의 길이를 구함
	printf("Length of message : %lu\n", strlen(message));
	strcat(message, "-> from Server");
    //write 함수를 이용해 descriptor에 문자열을 작성
	write(clnt_sock, message, strlen(message) + 1);

	close(clnt_sock);
	close(serv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

진짜 파일 입출력 하던 것처럼 read, write를 사용했다.

 

이번엔 client 코드이다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char* argv[])
{
	int sock;
	struct sockaddr_in serv_addr;
	char sendMSG[BUFSIZ], recvMSG[BUFSIZ];
	
	if(argc!=3){
		printf("Usage : %s IP port\n", argv[0]);
		exit(1);
	}

	printf("Please send message: ");
	scanf("%s", sendMSG);
	
	sock= socket(PF_INET, SOCK_STREAM, 0);
	if(sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=inet_addr(argv[1]); //IP 주소 할당
	serv_addr.sin_port=htons(atoi(argv[2])); // PORT 번호 할당
		
	if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
		error_handling("connect() error!");

	//write 함수를 이용하여 해당 descriptor에 문자열을 작성
	write(sock, sendMSG, strlen(sendMSG));
    //read 함수를 이용하여 해당 descriptor에서 버퍼로 문자열을 읽어옴
	read(sock, recvMSG, BUFSIZ);

	printf("Receive message: %s", recvMSG);

	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

이렇게 잘 작동하는 것을 볼 수 있다.

여기서 server 코드를 작성하면서 socket과 accept의 리턴값이 같은지 다른지 궁금해졌다.

 

그래서 어차피 int 값의 descriptor들이기 때문에 %d로 출력해보았다.

출력 값들을 보면 다른 것을 볼 수 있다.

서버에서 socket 함수의 리턴 값은 서버 자체의 소켓이고

그 소켓을 이용해서 client들과 통신할 수 있는 개별 소켓들을 열어주어야 한다.

+ Recent posts