13. 버퍼 01. 버퍼 개요

01. 버퍼 개요

img 버퍼는 운영체제의 커널이 관리하는 시스템 메모리를 직접 사용할 수 있다. 기존에는 버퍼 대신 주로 바이트 배열(byte[], 배열도 객체다)을 사용했기 때문에 JVM의 힙 영역에 메모리가 할당되고 맨 처음으로 바이트 배열의 초기 값이 시스템 메모리로 한 번 복사되어야 했다. 이렇게 초기 한 번 뿐이지만 복사가 이루어진다는 점도 성능에 나쁘게 작용했지만 결정적으로 DMA, 가상 메모리, 메모리 맵 파일 등의 운영체제 수준에서 제공해주는 유용한 기능들까지도 사용할 수 없다는 점이 가장 큰 단점이었다.

이에 대한 해법으로 C/C++에서 포인터를 사용하듯이 자바에서도 직접 시스템 메모리를 사용할 수 있는 버퍼(Buffer) 클래스를 java.nio 패키지에 포함시켰다.

img2 그림에서 볼 수 있듯이 java.nio 패키지는 크게 Buffer 클래스와 ByteOrder 클래스로 나눠진다. 먼저 ByteOrder 클래스에 대해 간단히 살펴보자.

컴퓨터마다 바이트를 저장하는 순서가 다르다. Big Endian과 Little Endian으로 구분할 수 있다. 자바는 기본적으로 네트워크에서 사용하는 빅 엔디안을 사용하고 대부분의 유닉스 운영체제 역시 빅 엔디안 방식을 사용한다. 반면 윈도우 운영체제의 경우에는 리틀 엔디안을 사용한다. ByteOrder 클래스는 버퍼, 즉 메모리의 바이트 순서를 조정해 주기 위해 사용되는데 사실 거의 사용되지 않는다고 봐도 무방하다.

Buffer

buffer 클래스는 자신을 상속하는 하위 클래스들에서 공통적으로 사용할 메소드들을 정의한 부모 클래스다. buffer를 상속하는 각각의 하위 클래스들은 Buffer 계층 구조에서 클래스 명을 볼 수 있듯이 boolean을 제외하고 모두 기본 형식의 버퍼들이다. 버퍼는 어떤 하나의 데이터 형태들을 저장하는 컨테이너라고 할 수 있다. 버퍼는 시작과 끝이 있는 일직선 모양의 데이터 구조를 갖고 내부적으로 자신의 상태 정보를 네 가지 기본 속성 값에 저장한다. 또한 이렇게 각 버퍼에 있는 네 가지 기본 속성들을 편리하게 제어할 수 있는 다양한 유틸리티 메소드들을 제공해준다. 따라서 배열보다 훨씬 사용하기 편하고 강력한 기능들을 갖추고 있다.

02. 버퍼의 네 가지 기본 속성

모든 버퍼들의 부모 클래스인 Bufer는 네가지 속성이 있다. 각각의 버퍼는 자체적으로 이 속성들을 저장하고 있고 이 속성을 이용해서 버퍼 자체를 제어하는 것이다.

img3 mark를 제외한 나머지 세 개의 속성 값은 음수를 가질 수 없다. 단, mark 속성만이 초기 값으로 -1을 갖는다. 눈여겨볼 또 다른 점은 capacity 속성을 착각하면 안 된다는 점이다. capacity 속성은 버퍼의 바이트 크기를 정하는 것이 아니라 각 형식 버퍼에 맞는 기본 형식의 데이터를 각각의 기본형 버퍼 클래스에 몇 개나 넣을 수 있는지를 나타내는 것이다.

즉, IntBuffer의 Capacity를 100으로 정했다면 이 버퍼에는 int를 100개 넣을 수 있다는 의미다. 따라서 1024를 초기 값으로 지정해준 경우, ByteBuffer는 실제 1024Byte의 메모리를 점유하고, int는 4byte이므로 IntBuffer는 4096Byte를 long은 8Byte이므로 LongBuffer는 8192Byte를 갖게 된다.

이 네가지의 속성의 관계는 다음과 같다.

0 <= mark <= position <= limit <= capacity

img4 capacity가 10인 버퍼를 생성하면 위와 같은 모습이 된다. 앞서 버퍼의 속성을 통해 본것처럼 position은 0으로 설정되어 있고 limit와 capacity는 10으로 설정되어 있다. 이제 앞으로 이렇게 생성된 Buffer 클래스의 position, limit, mark 속성을 적절히 제어하면서 버퍼를 이용할 것이다. Buffer 클래스는 이 기본 속성들을 제어하는 다양한 메소드들을 제공하는데, 우선 다음의 API를 살펴보자

public abstract class Buffer {
    public final int position();
    public final int limit();
    public final int capacity();

    public final Buffer position(int newPosition);
    public final Buffer limit(int newLimit);

    public final Buffer mark();
    public final Buffer reset();

    public final int remaining();
    public final boolean hasRemainig();
    public abstract boolean isReadOnly();
}

Buffer 클래스에서 제공하는 메소드 중 일부분을 나타냈다. 버퍼 계열 클래스 모두가 공통적으로 사용하는 메소드들이기 때문에 오버라이딩되는 것을 막기 위해서 메소드들을 final로 지정했다는 것이 눈여겨볼 점이다. 다만 생성하는 시점에 결정되는 읽기 전용 버퍼인지를 나타내는 isReadOnly() 메소드는 추상(abstract) 메소드로 지정해서 나중에 구현 클래스에서 오버라이딩해서 구현하게 해놨다.

첫째 문단의 메소드 세 개는 position, limit, capacity 위치 값을 리턴하는 간단한 getter 메소드다. 둘째 문단의 메소드 두 개는 position과 limit의 위치 값을 지정해주는 setter 메소드다. 셋째 문단에 위치한 mark() 메소드는 현재 포지션을 mark 속성에 저장하게 한다. 그리고 이렇게 저장된 mark 위치로 나중에 이동하고 싶을때 reset() 메소드를 사용하면 된다. 하지만 mark() 메소드로 mark 속성 값을 설정하지 않고 reset() 메소드를 호출하면 InvalidMarkException이 발생한다. 또한 Position이나 limit 값이 mark 위치보다 작은데도 reset() 메소드를 호출하면 InvalidMarkException이 발생한다. 다음은 mark() 메소드와 reset() 메소드의 간단한 사용 템플릿 예다.

buf.position(1);    // position = 1
buf.mark();         // mark = 1
buf.position(5);    // position = 5
buf.reset();        // position = mark = 1

Buffer 계열 클래스들은 체이닝을 이용하도록 디자인되어 있다. API를 보면 알 수 있겠지만 대부분의 메소드에는 Buffer 또는 각 구현 형식별 버퍼 클래스가 리턴 된다. 버퍼 클래스에 이렇게 체이닝 디자인을 도입한 이유는 체이닝이 코드량을 줄여주며 코드를 명확하고 이해하기 쉽게 해주기 때문이다. 쳉닝을 이용해서 앞서 설명한 mark(), reset() 메소드를 이용하는 코드 템플릿을 만들면 다음과 같이 한 줄로 표현할 수 있다.

buf.position(1).mark().position(5).reset();

Buffer 클래스는 효율성을 높이기 위해 디자인까지 신경을 썻다.

넷째 문단에 위치한 remaining() 메소드는 “limit - position”과 같은 뜻이다. 즉, 현재 position을 기준으로 해서 버퍼에 읽을 수 있는 데이터가 몇 개나 남았는지를 알려주는 메소드다. hasRemaining() 메소드는 “(limit - position > 0)“을 의미한다. 즉, 현재 position을 기준으로 해서 버퍼에 읽을 수 있는 데이터가 있는지 없는지를 boolean형 값으로 리턴해주는 것이다. isReadOnly() 메소드는 버퍼가 읽기 전용인지 아닌지를 알려주는 메소드다.

필자는 앞으로 ByteBuffer를 기준으로 해서 설명할 것이다. 그 이유는 ByteBuffer가 가장 많이 사용되고 있으며 또한 다른 버퍼들도 거의 동일한 형식의 메소드를 제공하고 사용법도 유사하므로 ByteBuffer만 제대로 활용할 줄 안다면 다른 형식의 버퍼도 API를 보고 쉽게 이용할 수 있기 때문이다.

03. 버퍼에서 데이터 읽고 쓰기

Buffer 클래스를 상속하는 모든 하위 클래스들을 다양한 형식으로 데이터를 읽거나 쓸 수 있는 메소드를 제공한다. 다음은 ByteBuffer가 제공하는 메소드의 일부분이다. 하지만 다른 버퍼들도 메소드 파라미터와 리턴 값만이 다른 동일한 메소드를 제공하고 있으므로 사용법은 동일하다.

//상대적 위치로 읽고 쓰기
public abstract byte get();
public abstract ByteBuffer put(byte b);

//절대적 위치로 읽고 쓰기
public abstract byte get(int index);
public abstract ByteBuffer put(int index, byte b);

//버퍼의 데이터를 주어진 배열로 읽고 쓰기
public ByteBuffer get(byte[] dst);
public ByteBuffer get(byte[] dst, int offset, int length);
public final ByteBuffer put(byte[] src);
public ByteBuffer put(byte[] src, int offset, int length);

//파라미터로 주어진 버퍼의 내용을 쓰기
public ByteBuffer put(ByteBuffer src);

1. 상대적 위치를 이용해서 1바이트씩 읽고 쓰기

상대적 위치를 이용해서 버퍼에 데이터를 읽거나 쓰는 메소드는 다음과 같다.

//상대적 위치로 읽고 쓰기
public abstract byte get();
public abstract ByteBuffer put(byte b);

ByteBuffer buf = ....
byte b = buf.get();

이렇게 buf.get()을 호출하게 되면 position은 현재 위치의 요소를 가져오고 position은 1이 더해진 값으로 이동한다. 그러나 limit와 capacity 위치는 변화가 없다.

//버퍼에 쓰는 것
ByteBuffer buf = ...
but.put((byte) z);

put을 호출하게 되면 현재 위치의 요소에 z가 삽입되게 되고 position은 1이 ㄷㅓ해진다. limit와 capacity는 변동이 없다. 지속적으로 get, put 메소드를 호출하게 되면 총 크기의 범위를 벗어나게 되면 각각 BufferUnderflowException과 BufferOverflowException이 발생한다는 점이다. 따라서 읽고 쓰기 전에 항상 현재 position과 limit를 비교한 후에 버퍼에 데이터를 읽거나 쓰는 것이 바람직하다.

import java.nio.ByteBuffer;

public class RelativeBufferTest {
    public static void main(String[] args){
        //크기가 10인 ByteBuffer를 생성한다.
        ByteBuffer buf = ByteBuffer.allocate(10);
        System.out.println("Init Position : " + buf.position());
        System.out.println(", Init Limit : " + buf.limit());
        System.out.println(", Init Capacity : " + buf.capacity());

        // 현재 position이 0인데, 이곳에 mark를 해둔다.
        buf.mark();
        // a, b, c를 순서대로 버퍼에 넣는다.
        buf.put((byte) 10).put((byte) 11).put((byte) 12);
        // mark해둔 0 인덱스로 position을 되돌린다.
        buf.reset();

        //현재 position의 버퍼에 있는 데이터를 출력한다.
        System.out.println("Value : " + buf.get() + ", Position : " + buf.position());
        System.out.println("Value : " + buf.get() + ", Position : " + buf.position());
        System.out.println("Value : " + buf.get() + ", Position : " + buf.position());
        //position 4는 아무 값도 넣지 않았지만 기본적으로 0이 입력됨을 볼 수 있다.
        System.out.println("Value : " + buf.get() + ", Position : " + buf.position());
    }
}

초기 값이 position은 0, limit와 capacity는 10으로 설정되어 있다. 이 코드에서 눈여겨볼 것은 마지막 출력 행에서 버퍼의 인덱스가 4인 위치에서 아무런 데이터를 넣지 않았는데 Null이 아닌 0이 나온다.

2. 절대적 위치를 이용해서 1바이트씩 읽고 쓰기

절대적 위치를 이용해서 버퍼에 데이터를 읽거나 쓰기 위한 메소드.

//절대적 위치로 읽고 쓰기
public abstract byte get(int index);
public abstract ByteBuffer put(int index, byte b);

상대적인 방법이 현재 position을 이용해 데이터를 읽거나 썻다면 절대적인 방법은 어느 위치의 데이터를 읽고 쓸지 그 위치를 직접 지정해서 읽거나 쓰는 방식이다.

ByteBuffer but = ....
byte b = buf.get(5);

이처럼 5의 위치의 요소를 읽는다. 하지만 상대적 위치와는 달리 Position의 위치에는 변화가 없다.

ByteBuffer but = ....
buf.put(5, (byte) z);

지정한 위치에 데이터를 쓰면 5번 위치의 요소에 z가 삽입되며 Position 위치 또한 변화가 없다.

import java.nio.ByteBuffer;

public class AbsoluteBufferTest {
    public static void main(String[] args) throws Exception {
        //크기가 10인 ByteBuffer 배열을 생성한다.
        ByteBuffer buf = ByteBuffer.allocate(10);
        System.out.print("Init Position : " + buf.position());
        System.out.print(", Init Limit : " + buf.limit());
        System.out.println(", Init Capacity : " + buf.capacity());

        buf.put(3, (byte) 3);
        buf.put(4, (byte) 4);
        buf.put(5, (byte) 5);
        //위치를 지정해서 쓴 경우에는 position이 변하지 않는다.
        System.out.println("Position : " + buf.capacity());

        buf.put(3, (byte) 3);
        buf.put(4, (byte) 4);
        buf.put(5, (byte) 5);
        //위치를 지정해서 쓴 경우에는 position이 변하지 않는다.
        System.out.println("Position : " + buf.position());

        // 버퍼에 있는 데이터를 출력한다. 역시 position이 변하지 않는다.
        System.out.println("Value : " + buf.get(3) + ", Position : " + buf.position());
        System.out.println("Value : " + buf.get(4) + ", Position : " + buf.position());
        System.out.println("Value : " + buf.get(5) + ", Position : " + buf.position());
        // position 9에는 아무 값도 넣지 않았지만 기본적으로 0이 입력됨을 볼 수 있다.
        System.out.println("Value : " + buf.get(9) + ", Position : " + buf.position());
    }
}

3. 배열을 이용해서 한꺼번에 많은 데이터를 읽고 쓰기

배열을 이용해서 버퍼에 데이터를 읽거나 쓰기위해 제공 되는 메소드

//버퍼의 데이터를 주어진 배열로 읽고 쓰기
public ByteBuffer get(byte[] dst);
public ByteBuffer get(byte[] dst, int offset, int length);
public final ByteBuffer put(byte[] src);
public ByteBuffer put(byte[] src, int offset, int length);

만약, 버퍼에 데이터를 한꺼번에 많이 읽거나 써야할 때 지금까지 공부한 방법을 이용할 경우, 다음의 코드 템플릿 형태로 읽게 된다.

for(int i = 0; buffer.hasRemaining(); i++) {
    byteArray(i) = buffer.get();
}
//또는
int size = buffer.remaining();
for (int i = 0; i < size; i++){
    byteArray[i] = buffer.get();
}

이렇게 루프를 이용해서 대량의 데이터를 읽거나 쓰는 것은 이제부터 설명할 메소드를 이용하는 것보다 코드가 길다는 것을 제외하고도 매번 반복적으로 get() 또는 put() 메소드를 호출해야 하는 비효율적인 방법이다. 버퍼에서는 이런 경우에 대비해서 배열을 이용해서 데이터를 버퍼에서 읽거나 쓸 수 있게 제공해 준다.

ByteBuffer buf = ByteBuffer.allocate(10);
Byte[] bulk = new byte[5];
...
byte b = buf.get(bulk);

여기에서 buf.get(bulk)는 get(bulk, 0, bulk.length)와 같으며 bulk를 읽어 5개를 한번에 입력한다. 앞전에 본 상대적 입력에나 출력에서 position이 움직이지 않았지만 여기에서 byte 배열을 이용해서 한꺼번에 넣는 방법은 position이 움직여 6을 가리키게 된다.

import java.nio.ByteBuffer;

public class BulkReadTest {
    public static void main(String[] args) throws Exception {
        ByteBuffer buf = ByteBuffer.allocate(10);
        buf.put((byte) 0).put((byte) 1).put((byte) 2).put((byte) 3).put((byte) 4);
        buf.mark();
        buf.put((byte) 5).put((byte) 6).put((byte) 7).put((byte) 8).put((byte) 9);
        buf.reset();

        byte[] b = new byte[15];

        // 버퍼에서 얼마나 쓸 수 있는지를 계산한다.
        int size = buf.remaining();
        if (b.length < size) {
            size = b.length;
        }
        // 버퍼 또는 배열에서 이용할 수 있는 만큼만 이용한다.
        buf.get(b, 0, size);
        System.out.println("Position : " + buf.position() + ", Limit : " + buf.limit());

        //byte[]에 담은 데이터를 처리하기 위한 메소드다.
        doSomething(b, size);
    }
    // byte[]와 함께 배열이 얼마나 사용되었는지 크기를 함께 넘겨줘서 사용한다.
    public static void doSomething(byte[] b, int size) {
        for(int i = 0; i< size; i++) {
            System.out.println("byte = " + b[i]);
        }
    }
}

버퍼에 쓰기도 이와 비슷하게 사용할 수 있다.

ByteBuffer buf = ByteBuffer.allocate(10);
Byte[] bulk = new byte[5];
...
byte b = buf.put(bulk);

4. 버퍼 자체를 파라미터로 받아서 쓰기

버퍼 자체를 파라미터로 받아서 쓰기 위해 제공해주는 메소드

//파라미터로 주어진 버퍼의 내용을 쓰기 public ByteBuffer put(ByteBuffer src);

이전까지 모두 get과 put의 쌍으로 이루어져 있는데 이 방식에는 put() 메소드밖에 존재하지 않는다. 그 이유는 aBuffer.put(bBuffer)와 bBuffer.get(aBuffer)는 같은 결과를 가져오기 때문이다.

ByteBuffer aBuf = ByteBuffer.allocate(10);
ByteBuffer bBuf = ByteBuffer.allocate(5);
....
aBuf.put(bBuf);

출처 : 김성박 송지훈 공저, 『 자바 IO & NIO 네트워크 프로그래밍』, 한빛미디어(2004.9.30), 13장 인용