본문 바로가기
Language/자바&코틀린

[I/O 이해하기-2] Stream vs Channel

by 코딩공장공장장 2024. 5. 9.

I/O


스트림과 채널은 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를 호출한 스레드에서는 다른 작업을 할 수 없다.

 

이러한 방식의 특징이자 단점이 될 수 있는 부분이 두가지가 있다.

  1. 사용자의 요청마다 스레드 생성
    요청 데이터를 읽기 위해 어느 작업도 하지 못하므로 일반적으로 요청 마다 하나의 스레드를 생성하는 방식을 선택한다.
    클라이언트의 요청 수와 스레드가 1대1 관계를 이루고 요청 수가 늘어나게 되면 그만큼 많은 스레드를 필요로 한다.
    스레드 마다 메모리를 할당 받고 CPU의 컨텍스트 스위칭 작업 또한 많이 발생하기에 그만큼 컴퓨터 자원을 더욱 필요로 하게 된다.

  2. CPU의 비효율적인 대기시간
    클라이언트가 요청한 데이터인 패킷이 도착하지 않으면 CPU는 대기하고 있어야 한다.
    만일 클라이언트의 데이터 전송이 중간에 끊긴다면 connection timout 시간까지 대기하는 상황이 생길수도 있다.
    CPU의 대기 시간이 발생한다는 것은 다른 작업을 하는 시간을 뺏는것과 같다.
    이는 우리 서버의 전반적인 작업의 효율을 떨어트리는 원인이 될 수 있다.

 

Channel


Channel은 동기와 비동기를 모두 지원한다. 동기적 사용은 Stream과 큰 차이가 없기에 비동기-NonBlocking 방식에 집중하여 설명하겠다.

Channel은 데이터를 buffer라는 공간에 담아 buffer 크기만큼 데이터가 차면 이를 한번에 프로그래밍 서버로 가져올 수 있다.

buffer는 양방향 읽기 쓰기를 지원하여 입출력 Channel을 따로 쓰지 않고 하나로 처리가 가능하다.

buffer 크기 만큼 데이터를 한번에 가져오는 Channel으 방식은 1byte 단위로 전송계층과 애플리케이션 계층을 넘나드는 Stream에 비해 확식히 큰 장점이다.

하지만 Buffer는 Buffer 크기만큼 메모리 할당을 필요로 하므로 클라이언트 요청이 많지 않고 대용량 데이터를 전송하는게 주 사용이라면 Stream을 사용하는게 더 효율적일 수 있다.

 

Buffer


buffer란 데이터를 담는 큐 공간이다.

임시로 데이터를 담아 두는 공간으로 buffer에 데이터를 담아 한번에 읽어들이거나 써내려갈 수 있다.

1byte단위로 전송계층이나 디스크에 접근하는 방식에 비해 효율을 얻을 수 있다.

 

허나, buffer를 사용하는 것이 비효율적인 상황 또한 존재한다. buffer를 사용하면 buffer 크기만큼 메모리를 할당하게 된다. 

대용량 데이터의 입출력이 많은 작업이라면 입출력하는 동안 buffer가 지속적으로 메모리를 점유하는 상황을 초래할 수 있다.

이는 컴퓨터가 다른 작업을 위해 필요로한 메모리 할당이 부족해지는 상황을 초래할 수 있다.

 

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이 된다.

byteBuffer.flip();
  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() 메서드가 존재한다.

keys()를 통해 반환되는 타입은 Set<SelectionKey>인데 SelectionKey는 소켓과 interestOps를 속성으로 갖는다.

interestOps는 IO작업 이벤트 관심사로 소켓에 관심 이벤트를 등록한다.

예를 들어 A라는 클라이언트 소켓에 interestOps에 데이터읽기(OP_READ)를 등록하면 A 소켓에 데이터가 전달되면 이벤트를 감지할 수 있다.

이 이벤트를 select를 통해 가져올 수가 있다. 

 

따라서 Selector를 통해 복수개의 channel(소켓)과 이 channel들이 관심있는 IO작업을 관리할 수 있는 것이다.

반응형