본문 바로가기
Old Posts/Java

Java NIO - Buffers

by A6K 2020. 12. 30.

Java NIO에서 버퍼(Buffer)는 채널(Channel)과 함께 사용된다. 데이터는 채널에서 읽혀 버퍼로 쓰여지거나 버퍼에 있는 데이터가 채널로 쓰여진다.

Java NIO 버퍼(Buffer)는 본질적으로 메모리 블럭이다. 메모리의 한 공간에 할당되어 있는 공간을 NIO Buffer객체로 래핑(Wrapping)한 것으로 메모리로의 접근, 사용을 추상화 해 사용하기 편한 메소드를 제공해준다. 사용자는 버퍼에서 데이터를 얻고, 버퍼로 데이터를 저장한 다음 채널에 쓰거나 읽는다.

버퍼를 이용해 채널에서 데이터를 읽는 패턴은 다음과 같다.

  1. 버퍼가 채워진다(read from channel)
  2. buffer.flip() 메소드를 호출한다
  3. 버퍼에서 데이터를 꺼낸다
  4. buffer.clear() 메소드 혹은 buffer.compact() 메소드를 호출한다.

버퍼에 데이터를 쓰면 얼마만큼의 데이터가 버퍼에 쓰여졌는지 관리된다. buffer.flip() 메소드는 버퍼를 쓰기모드에서 읽기 모드로 전환해준다. 데이터를 버퍼에서 다 읽으면 buffer.clear() 메소드 혹은 buffer.compact() 메소드를 이용해서 버퍼를 비워줘야한다.

버퍼는 메모리 블럭으로 데이터를 읽거나 쓰기 위해 몇 가지 편리한 메소드를 제공한다. 버퍼는 내부적으로 3가지 중요한 값을 가지고 있다.

  • capacity
  • position
  • limit

이 3가지 값을 가지고 버퍼에 데이터를 쓰거나 버퍼에서 데이터를 읽어 오게 된다.

Buffer의 3가지 핵심 값

capacity

버퍼의 capacity 값은 버퍼가 쓰기 모드인지, 읽기 모드인지와 관계없이 같은 의미를 갖는다. 앞서 버퍼는 고정된 크기의 메모리 블럭을 래핑(Wrapping)한 객체라고 했다. 여기서 고정된 크기에 해당하는 값이 capacity다. 버퍼에는 최대 capacity 만큼의 데이터를 쓸 수 있다.

버퍼를 사용하기 위해서는 우선 버퍼를 할당(allocate)해야한다. NIO에서 사용할 수 있는 주요 버퍼들은 다음과 같다.

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer
  • MappedByteBuffer

버퍼에 저장되는 데이터의 타입에 따라 선택해주면된다. 버퍼를 사용하기 위해서는 우선 버퍼를 할당해야한다.

ByteBuffer buffer = ByteBuffer.allocate(64)

각 버퍼 클래스에 있는 allocate() 정적 메소드를 사용하면 메모리 블럭을 할당하고 래핑해서 객체로 만들어준다. 위 코드를 실행하면 capacity가 64 바이트인 ByteBuffer를 할당해서 리턴해준다.

position

버퍼 객체의 position 값은 메모리 블럭의 어느 부분에 접근해야하는지를 의미한다. 버퍼가 처음 할당되면 position 값은 0으로 초기화된다. 버퍼에 데이터를 쓰면 position 값이 바이트마다 하나씩 늘어난다. position 값은 최대 capacity - 1 값을 갖을 수 있다. 비슷하게 데이터를 버퍼에서 읽을 때는 position부터 읽는다.

buffer.flip() 메소드를 호출하면 버퍼가 쓰기모드에서 읽기 모드로 전환된다. 동시에 position 값이 0으로 초기화 된다. 이후 데이터를 버퍼에서 꺼내면 꺼낸만큼 position 값이 증가한다.

limit

버퍼에 읽기 혹은 쓰기를 수행할 때 limit 값은 얼마나 많은 양의 데이터를 버퍼에 읽거나 쓸 수 있는지를 의미한다. 쓰기 모드에서 limit 값은 capacity 값과 같다.

buffer.flip() 메소드를 실행하면 쓰기 모드의 position 값이 limit 값으로 치환되고, position 값은 0으로 초기화 된다.

버퍼 메소드

버퍼 채우기 - read / put()

채널에서 데이터를 읽어 버퍼에 저장하는 코드는 일반적으로 다음과 같다.

int bytesRead = channel.read(buffer);

 

혹은 채널에 데이터를 쓰기 위해서 버퍼를 채울 수도 있다.

buffer.put(27);

버퍼 클래스는 다양한 put() 메소드를 제공한다. 특정 포지션에 데이터를 쓰는 메소드도 제공되고, 바이트 배열을 쓰는 메소드도 제공된다.

채널에서 읽거나 직접 버퍼를 채우는 경우, 버퍼의 position 부터 데이터를 채우게 된다. 'data'라는 글자를 채널에서 읽은 경우 위 그림처럼 data라는 값이 채워지고 그 만큼 position이 이동하게 된다.

flip()

flip() 메소드는 버퍼를 쓰기 모드에서 읽기 모드로 전환해준다. (정확히는 limit 값과 position 값을 세팅해주는 메소드다. 읽기 모드와 쓰기 모드가 별도로 존재하는 것은 아니다.)

flip() 메소드를 호출하면 현재 position 값이 limit 값에 할당되고, position 값은 0으로 초기화된다. 그림으로 그려보면 위와 같다.

'data'라는 값을 버퍼에 저장하고 flip()을 호출하면 position 값이 limit 값으로 설정되고, position 값은 0으로 초기화된다. 읽을 준비를 하는 것이다.

버퍼 사용하기 - write / get()

버퍼에 있는 데이터를 채널에 쓰는 코드는 다음과 같다.

int bytesWritten = channel.write(buffer);

혹은 채널에서 가져온 데이터를 읽을 수도 있다.

byte readByte = buffer.get();

put() 메소드와 마찬가지로 다양한 형태의 get() 메소드가 제공된다.

rewind()

버퍼에서 데이터를 꺼내는 경우 position 값이 증가한다고 했다. rewind() 메소드는 position 값을 다시 0으로 초기화 시켜주는 메소드다. 버퍼에서 데이터를 처음부터 다시 읽고 싶은 경우 사용할 수 있다.

clear()

버퍼에서 데이터를 모두 다 읽었으면 다시 쓰기를 위해 버퍼를 정리해줘야한다. clear() 메소드 혹은 compact() 메소드는 버퍼에 쓰기위해 준비하는 메소드다.

clear() 메소드를 호출하면 position 값이 0으로 초기화된다. 동시에 limit 값은 buffer의 capacity 값으로 설정된다. 다만 메모리에 저장되어 있던 데이터를 명시적으로 지워주지는 않는다. position 값을 0으로 돌리기만 할 뿐 데이터는 그대로 남아있긴하다. 버퍼를 채우게 되면 그 데이터들을 덮어쓰게 된다.

compact()

읽기 모드에서 아직 읽어내지 않은 데이터가 버퍼에 있는 상태에서 clear() 메소드를 호출하면 읽지 않은 데이터는 사라지게 된다. 아직 읽지 않은 데이터를 살리면서 쓰기를 준비하려면 compact() 메소드를 이용하면 된다.

버퍼에 저장된 'data'라는 값 중 'da'만 읽은 상태에서 compact()를 호출하면 아직 읽히지 않은 'ta'라는 데이터가 버퍼의 앞쪽으로 복사가 되고, position이 0이 아닌 'ta' 다음 부분을 가리키게 된다. 이 상태에서 새로운 데이터가 버퍼로 들어오면 'ta' 다음에 쓰이게 된다.

버퍼의 limit은 여전히 capacity로 설정된다.

mark() / reset()

버퍼에는 mark라는 값이 존재하기도 한다. 버퍼에 있는 데이터를 소비하다가 특정 포지션에 마킹을 해놓을 수도 있다.

buffer.mark()

... 

buffer.reset()

버퍼를 소비하다가 특정 포지션 값을 mark 값으로 저장해 놓고, 계속 버퍼를 소비하다가 reset() 메소드를 호출하면 저장해놨던 mark 값으로 position 값을 다시 설정해준다.

equals(), compareTo()

버퍼 클래스들은 equals(), compareTo() 메소드를 제공하기도한다. 두 개의 버퍼를 비교할 수 있다. 두 개의 버퍼가 같다고 판단되는 조건은 다음과 같다.

  • 타입이 같다
  • 남은 데이터의 양이 같다
  • 남은 모든 바이트가 같다

한 버퍼가 다른 버퍼보다 작다고 판단될 조건은

  • 남아있는 데이터의 양이 작다
  • 남아있는 바이트들을 순회하면서 비교, 작은 바이트가 먼저 나오는 쪽이 작다

Scatter Read / Gather Write

NIO 채널은 하나 이상의 버퍼를 가지고 데이터를 읽거나 쓸 수 있다.

채널에서 데이터를 여러 버퍼에 나누어 저장하는 것을 Scatter Read라고 한다.

반대로 여러 버퍼에 있는 데이터를 모아서 하나의 채널에 쓰는 것을 Gather Write라고 한다.

네트워크를 통해 메시지를 전송할 때, 메시지 헤더(Message Header)와 메시지 바디(Message Body)를 별도의 버퍼에서 다루는 경우 Scatter Read와 Gather Write를 쓸 수 있다.

네트워크 연결을 담당하는 SocketChannel에서 헤드와 바디를 읽는 Scatter Read는 다음과 같이 사용할 수 있다.

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body   = ByteBuffer.allocate(1024);

ByteBuffer[] message = { header, body };

channel.read(message);

헤더와 바디를 저장할 버퍼 배열을 만들어서 채널의 read 메소드에 넘겨준다. 채널은 버퍼배열에 등장하는 순서에 따라 읽은 데이터를 채워준다. 처음에 등장하는 버퍼에 데이터를 꽉 채운다음 다음 버퍼에 데이터를 저장한다.

때문에 헤더가 가변 사이즈인 경우에는 사용할 수 없다. 헤더는 무조건 128 바이트여야한다. (header와 body의 사이즈를 미리 고정된 메시지로 전송하고 그때 그때 할당해서 쓰는 방법을 사용하면 된다)

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body   = ByteBuffer.allocate(1024);

//write data into header and body

ByteBuffer[] message = { header, body };

channel.write(message);

마찬가지로 Gather Write 역시 채널에 쓸 버퍼들을 버퍼 배열로 만들어서 write() 메소드로 넘겨준다.

Scatter Read와 다르게 Gather Write는 각 버퍼마다 limit과 position 값으로 관리되기 때문에 가변 사이즈도 가능하다. 버퍼 배열에 등장하는 순서 대로 채널에 쓴다. header 버퍼에 저장된 데이터가 모두 전송된 다음 body 버퍼에 있는 값이 전송된다.

댓글