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

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

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

I/O


스트림과 채널은 I/O 작업을 처리하는데 사용된다.

I/O란 Input/Ouput의 약자로 입력값을 읽어들이는 작업과 출력값을 쓰는 작업을 말한다.

 

I/O 작업은 대게 애플리케이션 내에서만 이뤄지는 작업이 아니다.

대표적인 I/O 작업을 통해 어떻게 동작하는지 알아보자.

  • 클라이언트의 요청을 읽어들이는 서버(전송계층 -> 애플리케이션 계층)
    클라이언트가 서버로 요청을 보내게되면 요청 데이터가 전송계층에 패킷 형태로 존재한다.
    서버의 애플리케이션에서 시스템 콜을 실행하는 프로그래밍 명령어를 통해 이를 애플리케이션으로 가져온다.

  • 디스크에 저장된 파일 읽어들이는 경우 (하드웨어 -> 애플리케이션 서버)
    파일이 존재하는 디스크 경로에 접근하여 해당 데이터를 읽어들여 메모리에 적재함
    그다음 애플리케이션에서 시스템 콜을 수행하는 명령어를 통해 파일을 읽어들인다.

위의 작업처럼 I/O 작업은 애플리케이션 프로세스 범위를 벗어난다.

전송계층에 도달한 패킷을 가져오기 위한 시스템 콜(OS 작업)을 필요로하며 물리적 장치에 접근까지 해야한다.
애플리케이션 내에서 이뤄지는 프로그래밍 연산작업과 비교하면 컴퓨터 자원을 많이 사용하는 작업이다.

 

Stream


Stream이란 연속적인 데이터의 흐름이라는 추상적인 개념이다.

전송계층에 도달한 패킷이 애플리케이션 계층으로 연속적으로 전달되는 것을 스트림이라고 할 수 있다.

또한 물리계층에 도달한 전기신호가 패킷으로 변환되어 전송계층에 전달되는 것 또한 스트림이라고 할 수 있다.

 

Stream은 위와 같이 다양한 계층에서 존재하는 개념이지만
우리는 전송계층에 도달한 패킷을 애플리케이션 프로세스로 가져오는 동작방식에 집중하여 설명하겠다.

 

클라이언트의 요청이 전송되면 전송계층에 패킷의 형태로 데이터가 존재한다.


이 전송된 데이터를 읽기 위해서 Stream을 사용한다.

Stream을 통한 데이터의 흐름은 단방향이다. 읽기 방향과 쓰기 방향이 다르다. 

Input 데이터를 읽어 들이는 Stream과 Output 데이터를 써내려가는 Stream이 별개로 존재한다.

이때, 데이터를 읽어들이는 입구를 InputStream, 데이터를 보내는 출구를 OutputStream이라고 한다.

 

데이터를 읽고 쓰는 작업은 1byte 단위로 처리 된다.

전송계층과 애플리케이션 계층의 데이터 이동 단위가 1바이트다.

네트워크 통신이 아닌 디스크 파일을 읽고 쓸 때에도 1바이트 단위로 입출력 된다.

벽돌을 한장만 들고 1층에서 2층으로 계속 이동시킨다고 생각하면 된다.

(물론, 버퍼를 사용한 Stream도 존재하지만 Stream과 Channel의 차이를 명확하게 설명하기 위해 이를 생략하겠다.)


데이터를 한번 읽어들이기 시작하면 모든 데이터를 다 읽어들일 때가지 어떠한 작업도 진행할 수 없다.

이를 blocking 방식이라고 하는데 blocking이란 함수 호출자가 피호출자에게 제어권을 넘기고 피호출자가 작업 완료 후 제어권을 다시 반환할 때까지 다른 작업을 할 수 없는 것을 말한다.

 

InputStream의 read라는 명령어를 통해 데이터를 읽어들이는데 전송계층으로 클라이언트의 데이터가 모두 도착하고 이를 애플리케이션 계층으로 모두 가져오기 전까지 read를 호출한 스레드에서는 다른 작업을 할 수 없다.

작업이 완료되기 전까지 read 명령어에서 block 되어있는 것이다.

 

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

  1. 사용자의 요청마다 스레드 생성
    요청 데이터를 읽기 위해 어느 작업도 하지 못하므로 일반적으로 요청 마다 하나의 스레드를 생성하는 방식을 선택한다.
    Blocking 방식의 특징이다.
    클라이언트의 요청 수와 스레드가 1대1 관계를 이루고 요청 수가 늘어나게 되면 그만큼 많은 스레드를 필요로 한다.
    스레드 마다 메모리를 할당 받고 CPU의 컨텍스트 스위칭 작업 또한 많이 발생하기에 그만큼 컴퓨터 자원을 더욱 필요로 하게 된다.
  2. CPU의 비효율적인 대기시간
    클라이언트가 요청한 데이터인 패킷이 도착하지 않으면 CPU는 대기하고 있어야 한다.
    만일 클라이언트의 데이터 전송이 중간에 끊긴다면 connection timout 시간까지 대기하는 상황이 생길수도 있다.
    CPU의 대기 시간이 발생한다는 것은 다른 작업을 하는 시간을 뺏는것과 같다.
    이는 우리 서버의 전반적인 작업의 효율을 떨어트리는 원인이 될 수 있다.

 

Channel


Channel은 연속적인 데이터의 입출력을 양방향으로 처리할 수 있는 방식이다.

 

Channel은 데이터를 읽고 쓰는데 Blocking 뿐만 아니라 Non blocking으로도 진행할 수 있다.

Non blocking은 제어권을 넘기지 않는다. 함수를 호출하더라도 다른 작업을 진행할 수 있다.

 

전송계층에 데이터가 전달되면 이벤트를 제공하여 이벤트를 수신하는 곳에서 데이터를 읽어들일 수 있다.

데이터가 모두 도착하기를 기다리지 않아도 된다. 이는 CPU의 대기시간을 줄여 다른 작업을 할 수 있게 한다는 것이다.

 

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

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이 양방향 통로를 가질 수 있는 이유이다.

 

반응형