[Java & Network] 버퍼의 생성과 할당, 채우기와 내보내기

스트림과 채널의 차이

충분히 큰 버퍼의 사용은 네트워크 프로그램의 성능에 가장 큰 영향을 미치는 부분이다. 그래서 스트림을 항상 버퍼링할 것을 권장했다. 버퍼는 버퍼링 스트림에서와 같이 단순한 바이트 배열로도 표현될 수 있다. 


프로그래밍 관점에서 스트림과 채널의 가장 큰 차이점은 채녈은 블록 기반인 데 반해 스트림은 바이트 기반이다. 스트림은 순서대로 한 바이트씩 제공하도록 설계하였다. 단지 성능을 위해 바이트 배열을 전달할 수는 있지만, 기본 개념은 한 번에 1바이트 데이터를 전달하는 것이다. 반면에 채널은 버퍼 안에 있는 데이터의 블록을 전달한다. 바이트는 채널에서 읽고 쓰기 전에 먼저 버퍼에 저장되어야 한다. 그리고 데이터는 한 번에 하나의 버퍼씩 읽고 쓴다. 


스트림과 채널/버퍼의 두 번째 큰 차이점은 채널과 버퍼는 같은 객체에 대해서 읽기와 쓰기를 모두 지원하는 경향이 있다. 예외도 있긴하지만 네트워크 프로그램은 대체로 같은 채널에서 읽기와 쓰기가 모두 가능하다.



자바에서 버퍼의 구현

버퍼의 내부적인 자세한 구현 방식을 몰라도, 배열과 같이 일반적인 기본 데이터 타입을 요소로 가지는 고정된 크기의 목록으로 생각할 수 있다. 그러나 실제 구현이 배열일 필요는 없다. 배열인 경우도 있고 아닌 경우도 있다. 자바의 기본 데이터 타입 중에서 boolean 타입을 제외한 나머지 모든 타입에 대해 Buffer의 서브클래스가 존재한다. ByteBuffer, CharBuffer, ShortBuffer, IntBuffer, LongBuufer, FloatBuffer, DoubleBuffer. 각 서브클래스에서 제공되는 메소드는 적절한 타입에 맞는 인자와 반환값을 갖는다. 예를 들어 DoubleBuffer 클래스는 double 타입을 인자로 전달하거나 반환하는 메소드를 제공한다. 네트워크의 프로그램인 경우 거의 항상 ByteBuffer를 사용한다.



버퍼의 기능

버퍼는 데이터를 나열하는 것 이외에도 네 가지 중요한 정보를 관리한다. 모든 버퍼들은 버퍼의 타입에 상관없이 이러한 값들을 설정하거나 확인할 수 있는 동일한 메소드를 제공한다. 


1. 위치(position)

- 버퍼에서 읽거나 쓸 다음 위치를 나타낸다. 이 값은 0에서 시작하며 최대값은 버퍼의 크기와 같다. 


2. 용량(capacity)

- 버퍼가 보유할 수 있는 최대 요소들의 수. 이 값은 버퍼가 생성될 때 설정되며 그 후로 변경할 수 없다. 


3. 한도(limit)

- 버퍼에서 접근할 수 있는 데이터의 끝. 버퍼의 용량이 더 남아 있더라도 한도 지점을 변경하기 전에는 한도를 넘어서서 읽거나 쓸 수 없다. 


4. 표시(mark)

- 버퍼에서 클라이언트에 제한된 인덱스. mark() 메소드를 호출하면 현재 위치에 표시가 설정된다.

- reset() 메소드를 호출하면 혀재 위치가 표시된 위치로 설정된다.

- 위치(position)가 설정된 표시(mark) 이하로 설정되면, 표시는 버려진다.


* InputStream에서 읽는 것과는 달리 버퍼로부터 읽으면 실제로 버퍼 안에 있는 데이터는 어떤 식으로든 변경되지 않는다. 버퍼의 이러한 특성으로 인해 버퍼의 위치를 앞뒤로 이동하여 특정 위치에서부터 읽는 것이 가능하다. 마찬가지로 프로그램은 읽을 수 있는 데이터의 끝을 제어하기 위해 한도(limit)를 조정할 수 있다. 오직 용량만이 고정되어 있는 것이다.


공통의 Buffer 슈퍼클래스는 또한 이들 공통 속성들을 참조하여 동작하는 몇몇 다른 메소드를 제공한다.


1. clear() 메소드는 위치를 0으로 설정하고 한도를 용량으로 설정하여 버퍼를 비우다. 이렇게 함으로써 버퍼는 완전히 새롭게 채워질 수 있다.


그러나 clear() 메소드는 버퍼로부터 이전 데이터를 제거하지 않는다. 이전 데이터는 여전히 남아 있으며 절대적인 get() 메소드를 사용하거나 한도와 치를 다시 변경하여 읽을 수 있다.


rewind() 메소드는 위치를 0으로 설정하지만, 한도를 변경하지 않는다. 이 메소드는 버퍼를 다시 읽을 수 있도록 한다.


flip() 메소드는 한도를 현재 위치로 설정하고 위치를 0으로 설정한다. 이 메소드는 버퍼를 채운 다음 버퍼의 내용을 내보내야 할 떄 호출된다.


마지막으로, 버퍼의 내용은 변경하지 않지만 버퍼에 관한 정보를 반환하는 두 개의 메소드를 제공한다. 

1. remaining() 메소드는 버퍼에서 현재 위치와 한도 사이에 있는 요소의 수를 반환한다. 

2. hasRemaining() 메소드는 남아 있는요소의 수가 0보다 큰 경우 true를 반환한다.




버퍼 만들기, 할당 방법

각 타입별(IntBuffer, ByteBuffer 등) 버퍼 클래스는 다양한 방법으로 해당 타입의 서브클래스를 생성하는 몇몇 팩토리 메소드를 제공한다. 빈 버퍼는 일반적으로 allocate 메소드에 의해 생성된다. 데이터가 미리 채워진 버퍼는 wrap 메소드를 호출하여 만든다. allocate 메소드는 종종 입력에 유용하게 사용되고, wrap 메소드는 일반적으로 출력에 사용된다. 


기본 allocate() 메소드는 단순히 지정된 고정 용량을 가진 빈 버퍼를 새로 생성하여 반환한다. 다음 코드는 바이트 버퍼와 정수 버퍼를 생성하며 각각의 크기는 100이다. 


ByteBuffer buffer1 = ByteBuffer.allocate(100);
IntBuffer buffer2 = IntBuffer.allocate(100);
cs

  

커서는 버퍼의 시작에 위치한다. (즉 위치가 0이다.) allocate()에 의해 생성된 버퍼는 자바 배열로 구현되며, array()와 arrayOffset() 메소드로 접근할 수 있다. 예를들어, 채널을 이용하여 큰 데이터 덩어리를 버퍼로 읽어 온 다음 버퍼로부터 배열을 가져와서 배열을 인자로 받은 다른 메소드에 사용할 수 있다.


byte[] data1 = buffer1.array();
int[] data2 = buffer2.array();
cs

array() 메소드는 해당 버퍼의 내부 데이터를 그대로 노출시키므로, 주의해서 사용해야 한다. 반환된 배열의 내용을 변경하면 버퍼에 그대로 반영되며, 반대와 마찬가지다. 여기서 일반적인 사용 패턴은 먼저 버퍼에 데이터를 채운다. 그리고 배열을 가져와서 배열을 조작한다. 이러한 패턴은 배열을 조작한 이후에 다시 버퍼에 무언가를 쓰지 않는 한 문제가 되지 않는다. 


직접할당

ByteBuffer 클래스는 버퍼에 대한 백업 배열을 생성하지 않는(즉, array() 메소드로 배열을 반호나받을 수 없다.) allocateDirect() 메소드를 제공한다. (ByteBuffer 클래스 이외의 다른 클래스는 제공하지 않는다.) 가상 머신은 DMA(Direct Memory Access)를 사용하여 이더넷 카드나 커널 메모리 등의 버퍼에 직접적으로 할당된 ByteBuffer를 구현한다.  이 방식을 사용하여 I/O 연산의 성능을 크게 향상시킬 수 있지만 직접 버퍼는 간접 버퍼 보다 생성하는데 많은 비용이 들기 때문에 길게 사용하지 않아야 한다. 성능 문제가 측정될 때 까지 직접 버퍼를 사용하지 않는 것이 좋다. allocateDirect()는 allocate()와 동일하게 사용된다.

 

ByteBuffer buffer3 = ByteBuffer.allocateDirect(100);
cs


직접 버퍼에 array() 메소드와 arrayOffset() 메소드를 호출하면 Unsupported OperationException 예외가 발생한다. 그리고 ByteBuffer이외에 다른 클래스는 제공하지 않는다.


랩핑(Wrapping)

이미 출력하고자 하는 데이터의 배열을 가지고 있다면 버퍼를 새로 만들고 채우는 것보다 배열을 버퍼로 감싸(wrapping)는 편이 낫다. 예를 들어


byte[] data = "Some data".getBytes("UTF-8");
ByteBuffer buffer4 = ByteBuffer.wrap(data);
char[] text= "Some data".toCharArray();
CharBuffer buffer5 = CharBuffer.wrap(text);
cs

여기서 버퍼는 배열에 대한 참조를 포함하고 있으며, 배열은 버퍼의 백업 배열처럼 제공된다. 랩핑 메소드로 생성된 버퍼는 직접 버퍼가 될 수 없다. 배열데 대한 변경은 버퍼에 반양된다, 반대도 역시 마찬가지다. 그러므로 해당 배열에 작업이 끝나기 전에 미리 랩핑하지 않도록 해야 한다.



채우기와 내보내기

버퍼는 순차적인 접근을 위해 설계되었다. 각각의 버퍼는 position() 메소드를 확인할 수 있는 현재 위치를 가지고 있으며, 이 위치는 0에서 부터 시작한다. 버퍼의 위치는 버퍼에 하나의 요소를 쓰거나 읽을 때마낟 1씩 증가한다. 예를 들어, 용량 12를 가진 CharBuffer를 할당한다고 가정해 보자, 그리고 다섯 개의 문자로 버퍼를 채운다.


CharBuffer buffer6 = CharBuffer.allocate(12);
buffer6.put('H');
buffer6.put('e');
buffer6.put('l');
buffer6.put('l');
buffer6.put('o');
 
System.out.println(buffer6.position()); // 5
cs


이 버퍼의 위치는 현재 5가 된다. 이와 같은 작업을 "버퍼를 채운다"고 한다.


버퍼는 버퍼가 가진 용량까지만 채울 수 있따. 만약 욜량을 넘어서 채우려고 할 경우 BufferOverflowException 예외를 발생시킨다.


위 상태의 버퍼에 대해 get()을 시도할 경우, 위치 5에 저장된 널(Null) 문자를 반환받게 된다. 이 문자는 자바가 버퍼를 처음 초기화할 때 설정된 값이다. 쓴 데이터를 다시 읽으려고 할 경우. 먼저 해당 버퍼에 대해 flip() 메소드를 이용해야 한다.


System.out.println(buffer6.get()); // 공백
buffer6.flip();
System.out.println(buffer6.get()); // H 출력
cs

책에서는 null이 문자를 반환 받게 될것이라고 했는데 직접 출력해 봤더니 그냥 공백이 출력된다. 아마 그냥 get()할때 출력하는 값이 null 이면 공백이 출력되게 한거 같다. 

flip()을 하면 한도(limit)를 현재 위치로 설정하고(이 예제에서는 5), 위치(position)를 버퍼의 시작 위치인 0으로 설정한다. 


String result = "";
        while (buffer6.hasRemaining()) {
            result += buffer6.get();
        }
System.out.println(result); // Hello 출력
cs

get() 호출 시마다 위치는 앞으로 1씩 이동한다. 위치가 limit에 도달하면, hasRemaining() 메소드는false를 반환한다. 이와 같은 작업을 "버퍼를 내보낸다"고 한다. 



버퍼 클래스는 또한 버퍼의 위치를 변경하지 않고 버퍼 내의 특정 위치에서 데이터를 채우거나 내보내는 절대(absoulte)메소드를 제공한다. 다음 예제를 보자


CharBuffer buf = CharBuffer.allocate(12);
buf.put(0'H');
buf.put(1'e');
buf.put(2'l');
buf.put(3'l');
buf.put(4'o');
 
System.out.println(buf.get(1)); // e 출력
System.out.println(buf.get(4)); // o 출력
cs

buf.put(0, 'H') 에서 앞에 숫자는 인덱스를 뜻한다. 0번째 인덱스에 H 문자를 넣은것이고 get(1) 은 1번째 인덱스를 꺼내온 것이다 이렇게 위치를 변경하지 않고 버퍼 내의 특정 위치에서 데이터를 채우거나 내보낼 수가 있다. 만약 인덱스를 넘어서 접어서 접근 하려는 경우 IndexOutOfBoundsException 예외를 발생시킨다.



앨리엇 러스티 해럴드, 자바 네트워크 프로그래밍, 제이펍

댓글

Designed by JB FACTORY