- [I/O 이해하기-1] 동기와 비동기 VS Blocking과 Non Blocking
- [I/O 이해하기-2] Stream vs Channel
- [I/O 이해하기-3] 톰캣의 요청 수신 방식
I/O
I/O란 Input/Ouput의 약자로 입력값을 읽어들이는 작업과 출력값을 쓰는 작업을 말한다.
I/O 작업은 애플리케이션 내에서만 이뤄지는 작업이 아니다.
- 클라이언트의 요청을 읽어들이는 서버(전송계층 -> 응용 계층)
클라이언트가 서버로 요청을 보내게되면 요청 데이터가 전송계층에 패킷 형태로 존재한다.
서버의 애플리케이션에서 시스템 콜을 실행하는 프로그래밍 명령어를 통해 이를 애플리케이션으로 가져온다. - 디스크에 저장된 파일 읽어들이는 경우 (하드웨어 -> 애플리케이션 서버)
파일이 존재하는 디스크 경로에 접근하여 해당 데이터를 읽어들여 메모리에 적재함
그 다음 애플리케이션에서 시스템 콜을 수행하는 명령어를 통해 파일을 읽어들인다.
위의 작업처럼 I/O 작업은 응용 계층 범위를 벗어난다.
전송계층에 도달한 패킷을 가져오기 위한 시스템 콜(OS 작업)을 필요로하며 물리적 장치에 접근까지 해야한다.
애플리케이션 내에서 이뤄지는 프로그래밍 연산작업과 비교하면 IO 작업은 컴퓨터 자원을 많이 필요로 하는 작업이다.
이번 포스팅은 응용 프로그램에서 전송계층에 접근하여 데이터를 읽어들이는 작업에 초점을 맞춰 설명할 것이다.
(우리는 응용프로그램 개발자이기 때문에)
Stream
Stream이란 ‘연속적인 데이터의 흐름이라는 추상적인 개념’이다.
클라이언트의 요청이 전송되면 전송계층에 패킷을 응용 프로그램으로 가져오기 위해 Stream이 사용된다.
Stream을 통한 데이터의 흐름은 단방향이다. 읽기 방향과 쓰기 방향이 다르다.
Input 데이터를 읽어 들이는 Stream과 Output 데이터를 써내려가는 Stream이 별개로 존재한다.
이때, 데이터를 읽어들이는 입구를 InputStream, 데이터를 보내는 출구를 OutputStream이라고 한다.
데이터를 읽고 쓰는 작업은 1byte 단위로 처리 된다. 전송계층과 애플리케이션 계층의 데이터 이동 단위가 1바이트다.
네트워크 통신이 아닌 디스크 파일을 읽고 쓸 때에도 1바이트 단위로 입출력 된다.
벽돌을 한장만 들고 1층에서 2층으로 계속 이동시킨다고 생각하면 된다.
(물론, 버퍼를 사용한 Stream도 존재하지만 Stream과 Channel의 차이를 명확하게 설명하기 위해 이를 생략하겠다.)
데이터를 한번 읽어들이기 시작하면 모든 데이터를 다 읽어들일 때가지 다른 작업은 진행할 수 없는 blocking 방식으로 진행된다.
InputStream의 read라는 명령어를 통해 데이터를 읽어들이는데 전송계층으로 클라이언트의 데이터가 도착하고 이를 애플리케이션 계층으로 모두 가져오기 전까지 read를 호출한 스레드에서는 다른 작업을 할 수 없다.
[Stream의 장점]
- 대용량 데이터 IO 유리
I/O 작업을 여러 번 호출하지 않기 때문에, 각각의 I/O 호출에 드는 시간과 리소스를 절약할 수 있다.
대용량 데이터의 IO 작업을 여러번 나눠서 수행하게 되는 경우 구현의 복잡성과 추가적인 처리로 인한 오버헤드가 더 클 수 있다. - 낮은 비용의 초기화
Stream은 별도의 임시 메모리 공간을 사용하지 않기에 초기화시 필요로한 메모리 용량이 적다.
IO 작업을 자주 수행하지 않는다면 Stream을 사용하는 것이 메모리 관리 측면에 유리할 수 있다.
[Stream의 단점]
- 사용자의 요청마다 스레드 할당
요청 데이터를 읽기 위해 어느 작업도 하지 못하므로 일반적으로 요청마다 하나의 스레드를 생성하는 방식을 선택한다.
클라이언트의 요청 수와 스레드가 1대1 관계를 이루고 요청 수가 늘어나게 되면 그만큼 많은 스레드를 필요로 한다.
스레드 마다 메모리를 할당 받고 CPU의 컨텍스트 스위칭 작업 또한 많이 발생하기에 그만큼 컴퓨터 자원을 더욱 필요로 하게 된다. - CPU의 비효율적인 대기시간
클라이언트가 요청한 데이터인 패킷이 도착하지 않으면 CPU는 대기하고 있어야 한다.
만일 클라이언트의 데이터 전송이 중간에 끊긴다면 connection timout 시간까지 대기하는 상황이 생길 수도 있다.
CPU의 대기 시간이 발생한다는 것은 다른 작업을 하는 시간을 뺏는것과 같다.
이는 우리 서버의 전반적인 작업의 효율을 떨어트리는 원인이 될 수 있다.
Channel
Channel은 비동기-NonBlocking 방식으로 동작하여 IO 작업시 Blocking 되지 않고 다른 작업을 수행할 수 있다.
이벤트 객체와 함께 사용되어 IO 이벤트가 발생했을 때만 IO 작업을 요청하고 이외에는 이벤트가 수신 됬는지 또는 읽어들인 데이터를 토대로 스레드에게 작업을 맡길 수 있다.
채널은 buffer라는 임시 메모리 공간을 통해 효율을 향상 시킨다.
buffer 공간에 데이터를 담아 buffer 크기만큼 데이터가 차면 이를 한번에 프로그래밍 서버로 가져올 수 있다.
buffer는 양방향 읽기 쓰기를 지원하여 입출력 Channel을 따로 쓰지 않고 하나로 처리가 가능하다.
[Channel의 장점]
- 사용자의 요청마다 스레드 생성하지 않아도 됨
요청 데이터를 읽으며 다른 작업을 수행할 수 있기에 하나의 스레드에서 IO 작업이 가능함 - CPU의 유휴시간이 적음
NonBlocking 방식으로 동작 되기에 데이터가 도착했다는 이벤트가 왔을 때에만 IO 작업을 수행한다.
Stream의 경우 데이터가 도착하지 않더라도 대기하고 있어야하는데 Channel은 이 유휴시간을 줄일 수 있다. - 애플리케이션과 전송계층을 넘나드는 비용이 저렴
버퍼 크기만큼 데이터를 한번에 가져올 수 있으므로 1byte 단위로 IO 자원을 활용하는 Stream에 비해 효율적이다. - 리소스 절약
inputStream과 outputStream을 모두 사용하는 방식에 비해 channel 하나만 관리하고 channel은 스레드 세이프하여 여러 스레드에서 접근이 가능하기에 IO 리소스를 보다 효율적으로 사용할 수 있다.
[Channel의 단점]
- 대용량 데이터 IO 성능 저하
대용량 데이터의 IO 작업을 여러번 나눠서 수행하게 되는 경우 구현의 복잡성과 추가적인 처리로 인한 오버헤드가 더 클 수 있다. - 높은 비용의 초기화
클라이언트의 요청이 많지 않거나, 요청 데이터의 크기에 비해 Buffer 크기를 크게 할당해놓으면 다른 곳에서 사용되어야할 메모리들이 버퍼에 의해 비효율적으로 사용될 수 있다.
[참고]
웹 프로그래밍은 IO 방식과 관계없이 클라이언트의 요청을 독립적으로 처리하기 위해 요청마다 스레드를 할당할 수 있다.
톰캣은 클라이언트 요청 수신과 request 헤더를 읽어들이는데 Stream이 아닌 Channel을 사용하지만 클라이언트의 요청마다 스레드를 할당한다.
동작 방식으로는 사용자의 요청마다 스레드를 할당하지 않아도 다른 작업에 부하를 주지 않지만,
클라이언트의 요청을 독립적으로 구분하기 위한 목적으로 요청 마다 스레드를 할당한다.
Buffer
buffer란 임시로 데이터를 담아 두는 메모리 공간으로 buffer에 데이터를 담아 한번에 읽어들이거나 써내려갈 수 있다.
1byte단위로 전송계층이나 디스크에 접근하는 방식에 비해 효율을 얻을 수 있다.
Buffer의 동작 방식
buffer는 capacity, limit, position으로 이루어져 있다.
- capacity : 버퍼의 크기로 수용할 수 있는 요소(바이트)의 크기이다.
- limit : 읽기나 쓰기 불가능한 첫번째 인덱스로 읽기/쓰기 모드, 데이터 크기에 따라 limit이 달라진다.
- position : 읽기 쓰기 가능한 현재 인덱스
예제
ByteBuffer byteBuffer = ByteBuffer.allocate(8);
P L
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| | | | | | | | |
buffer에 위와 같이 8byte 크기의 큐 공간이 할당된다.
이때 postion과 limit을 출력하면
System.out.println(byteBuffer.position());
System.out.println(byteBuffer.limit());
0과 8이 각각 출력된다.
P는 읽기 쓰기 가능한 현재 인덱스로 0번째이고 Limit은 한계 인덱스로 8이다.
읽기 쓰기가 가능한 인덱스가 0부터 7이므로 읽기 쓰기 불가한 인덱스 중 첫번째 8이 출력된다.
byteBuffer.put((byte) 'a').put((byte) 'b').put((byte) 'c').put((byte) 'd');
P L
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| a | b | c | d | | | | |
4byte만큼 buffer에 데이터를 넣으면 이제 P는 3이 된다.
P L
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| a | b | c | d | | | | |
여기서 flip 명령어는 읽기모드를 바꾸는 명령어이다.
기존 쓰기에서 읽기가 되었으니 poistion과 limit도 바뀌게 되어 poistion은 0, limit은 4가 된다.
buffer에 존재하는 데이터 3번 인덱스까지 존재하고 4번 인덱스는 읽지 못하기 때문에 limit이 4가 된다.
버퍼는 위 그림에서 뒤부분이 입구, 앞부분이 출구이다.
char firstData = (char) byteBuffer.get();
char secondData = (char) byteBuffer.get();
System.out.println(firstData);
System.out.println(secondData);
읽기모드로 바뀌었으니 buffer의 데이터를 읽어들이면
0번째 인덱스 a와 1번째 인덱스 b가 출력됨을 확인할 수 있다.
P L
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| | | c | d | | | | |
이때 아직 읽기 모드이므로 P는 남아있는 데이터의 첫번째 인덱스인 2이고, L은 4가 된다.
byteBuffer.compact();
여기서 compact 명령어를 통해 다시 쓰기모드로 바꾸면
P L
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| c | d | | | | | | |
P는 쓰기 가능한 2가 되고 L은 쓰기 불가한 첫번째 인덱스 8이 된다.
compact 명령어는 남아있는 데이터를 0번째 인덱스로 복사시키며 쓰기모드로 변경한다.
Buffer는 위와 같이 하나의 공간에서 읽기와 쓰기가 모두 가능하다.
이러한 동작방식이 channel이 양방향 통로를 가질 수 있는 이유이다.
Selector
Selector는 이벤트 기반 방식으로 동작하여 channel이 비동기-NonBlocking 방식으로 동작하도록 한다.
Selector에는 select(), keys() 메서드가 존재한다.
select() 메서드는 반환형이 int로 발생한 이벤트의 수를 알려준다.
keys()는 반환형이 Set<SelectionKey>으로 SelectionKey는 소켓과 interestOps를 속성으로 갖는다.
interestOps는 수신 받을 수 있는 IO 작업 이벤트 관심사이다.
A라는 클라이언트 소켓의 interestOps에 데이터읽기(OP_READ)를 등록하면,
A 소켓에 데이터가 도착시 이벤트를 감지할 수 있다.
이벤트가 존재하는지 select를 통해 확인하고 keys()를 통해 가져올 수 있다.
따라서 Selector를 통해 복수개의 channel(소켓)과 channel들의 관심 IO 작업을 관리할 수 있는 것이다.
'Language > 자바&코틀린' 카테고리의 다른 글
Garbage Collection의 동작 방식과 종류 (0) | 2024.07.06 |
---|---|
객체지향 프로그래밍이란 : OOP (0) | 2024.06.30 |
[I/O 이해하기-1] 동기와 비동기 VS Blocking과 Non Blocking (0) | 2024.05.05 |
코틀린 querydsl, mapstruct 생성자에 따른 동작 방식 (0) | 2024.03.16 |
자바 예외의 종류와 처리방식 (0) | 2024.02.19 |