본문 바로가기
빌드노트 검색

빌드노트

ByteBuffer에 쓴 값 대신 0이 읽히는 이유

ByteBuffer에 값을 넣은 뒤 엉뚱한 영역을 읽는 이유를 position·limit·remaining으로 확인한다. 작은 heap 버퍼와 메모리 채널의 실제 비교로 flip, clear, rewind의 선택 기준과 내용 보존을 설명한다.

ByteBuffer에 10, 20, 30을 넣었는데 곧바로 get()을 하니 0이 나올 수 있다. channel.write(buffer)로 내보내도 방금 넣은 바이트 대신 뒤쪽 영역을 보낼 수 있다. put은 데이터를 쓴 만큼 position을 앞으로 옮긴다. 쓰기를 마친 뒤 flip()으로 읽을 범위를 맞춰야 한다. 여기서는 새 heap 버퍼의 인덱스 0부터 순서대로 바이트를 넣는 경우를 다룬다.

읽기 직전에 position과 limit을 본다

문제가 난 버퍼 변수를 buf라고 하면, put이 끝난 뒤 get()이나 channel.write(buf)를 호출하기 직전에 다음 값을 출력한다. 버퍼 내용을 읽지 않고 현재 범위만 확인하는 코드다.

System.out.printf("position=%d limit=%d remaining=%d%n",
        buf.position(), buf.limit(), buf.remaining());

인덱스 없는 get()은 현재 position에서 읽고 위치를 한 칸 옮긴다. limit은 접근 가능한 범위의 끝 다음 인덱스다. 따라서 순차적으로 읽을 수 있는 범위는 [position, limit)이고 remaining()은 limit - position이다. remaining이 곧 방금 쓴 데이터 길이라는 뜻은 아니다. Java SE 21 Buffer 범위 설명 (새 탭에서 열림), ByteBuffer.get 계약 (새 탭에서 열림)

용량 6에 세 바이트를 넣었다면 position=3 limit=6 remaining=3이다. 읽기 시작점은 이미 인덱스 3으로 이동했다. 원하는 데이터는 인덱스 0~2에 있지만, 다음 get()이 보는 곳은 아직 쓰지 않은 뒤쪽이다. 새로 allocate한 버퍼의 바이트는 0으로 초기화되므로 이 조건에서는 0이 읽힌다. 재사용한 버퍼라면 이전 값일 수도 있다. ByteBuffer.allocate 계약 (새 탭에서 열림)

같은 입력을 flip 전후로 비교한다

JDK의 jshell --execution local --feedback concise를 실행하고 아래를 순서대로 입력하면, 잘못 읽는 버퍼와 읽을 범위를 맞춘 버퍼를 따로 비교할 수 있다. 이 글의 실행 환경은 Windows의 OpenJDK 17.0.2다.

import java.nio.ByteBuffer;
import java.util.Arrays;
var wrong = ByteBuffer.allocate(6);
wrong.put(new byte[]{10, 20, 30});
wrong.position() + "/" + wrong.limit() + "/" + wrong.remaining()
wrong.get()
var b = ByteBuffer.allocate(6);
b.put(new byte[]{10, 20, 30});
b.flip();
b.position() + "/" + b.limit() + "/" + b.remaining()
byte[] data = new byte[b.remaining()];
b.get(data);
Arrays.toString(data)

직접 실행한 결과, 상태 문자열은 먼저 "3/6/3", wrong.get()의 값은 0이었다. 별도 버퍼 b에 같은 값을 넣고 flip()한 뒤에는 상태가 "0/3/3"으로 바뀌었고, 마지막 배열 문자열은 "[10, 20, 30]"이었다. JShell이 붙이는 변수 번호와 중간 버퍼 표시보다 이 값들을 비교하면 된다.

flip()은 호출 직전의 position을 새 limit으로 삼은 뒤 position을 0으로 옮긴다. 세 바이트를 썼다면 읽을 범위가 [0, 3)이 된다. 데이터를 복사하거나 순서를 뒤집는 동작은 아니다. 자신의 코드에서는 순차 쓰기를 끝내고 읽기를 시작하는 경계에서 한 번 호출한다. 이미 flip()해서 위치가 0인 버퍼에 다시 호출하면 limit도 0이 된다. Buffer.flip 계약 (새 탭에서 열림)

진단하려고 먼저 get()을 했다면 그 호출도 position을 옮겼다는 점에 주의한다. 위 비교에서 버퍼를 따로 만든 이유다. 용량을 꽉 채운 조건도 별도로 실행했는데, flip 없이 get()하면 남은 범위가 없어 BufferUnderflowException이 났다. 오류 없이 0이 읽히는 경우만 flip 누락의 증상은 아니다.

채널의 write도 버퍼에서는 데이터를 꺼내는 쪽이다. ByteArrayOutputStream에 연결한 메모리 채널로 같은 조건을 실행하자, flip 전에는 [0, 0, 0], 뒤에는 [10, 20, 30]이 기록됐다. 공식 WritableByteChannel.write 계약도 현재 위치부터 남은 범위 안에서 가져오며, 실제 쓴 수만큼 위치를 옮기고 limit은 유지한다. 다만 실제 채널에서는 일부 바이트만 쓰거나 0을 반환할 수 있으므로 반환값과 남은 범위는 계속 확인해야 한다. WritableByteChannel.write 계약 (새 탭에서 열림)

clear는 전체 재사용, rewind는 같은 범위 다시 읽기다

앞의 b를 읽은 뒤 다음을 이어 입력하면, clear()가 범위와 내용에 각각 어떤 영향을 주는지 확인할 수 있다. array()는 이 예제의 allocate로 만든 배열 기반 버퍼에서 내용을 살펴보기 위해 쓴다.

b.clear();
b.position() + "/" + b.limit() + "/" + b.remaining()
Arrays.toString(b.array())

관찰값은 "0/6/6"과 "[10, 20, 30, 0, 0, 0]"이었다. clear()는 바이트를 지우지 않는다. 위치를 0으로, 한계를 용량으로 돌려 전체 공간에 다시 쓸 수 있게 한다. 기존 데이터를 다 읽었거나 버려도 될 때 사용하고, 새 데이터를 쓴 뒤에는 다시 그 길이에 맞춰 flip()한다. 읽지 않은 데이터를 보존해야 한다면 무조건 clear()해서는 안 된다. Buffer.clear 계약 (새 탭에서 열림)

rewind()는 position만 0으로 돌리고 기존 limit을 유지한다. 별도 비교 실행에서도 이 차이가 드러났다.

  • 세 바이트를 쓴 직후 flip 대신 rewind하면 0/6/6이어서 [10, 20, 30, 0, 0, 0]까지 읽혔다.
  • flip으로 limit=3을 정하고 읽은 뒤 rewind하면 0/3/3이어서 같은 세 바이트만 다시 읽혔다.

따라서 쓰기를 마치고 유효 길이를 정할 때는 flip, 정해 둔 범위를 처음부터 다시 읽을 때는 rewind, 전체 공간을 새 쓰기에 재사용할 때는 clear를 고른다. Buffer.rewind 계약 (새 탭에서 열림)

이 비교는 작은 합성 배열과 단일 스레드의 heap 버퍼로 실행했다. 인덱스를 지정하는 put(index, value)는 위치를 전진시키지 않으므로 여기의 순차 쓰기 처방을 그대로 적용하지 않는다. 참고 문서는 Java SE 21이며 실행 확인은 OpenJDK 17.0.2에 한정한다. direct·mapped 버퍼, 동시 접근, 실제 파일·네트워크 채널과 부분 쓰기 처리는 실행하지 않았다.

← 전체 글 보기