자바에서 InputStream을 다루다 보면 파일의 헤더만 살짝 읽어 파일의 형식을 확인한 뒤 다시 처음부터 전체 데이터를 읽어야 하는 상황이 생기는 경우가 있다. 이 때 직관적으로 reset() 메서드를 떠올리지만 막상 코드를 실행하면 IOException이 발생하는 경우가 있다. 이번 포스팅에서는 스트림에서 이전 위치로 돌아가는 방법과 함께 BufferedInputStream의 mark/reset/fill의 동작에 대해서 분석하고 RandomAccessFile의 사용법에 대해서 정리해보고자 한다.
InputStream
자바의 java.io.InputStream은 모든 바이트 입력 스트림의 최상위 추상 클래스다. InputStream은 read() 메서드를 정의하지만 데이터를 되돌리는 기능에 대해서는 매우 보수적이다. 이유는 스트림의 Source가 다양하기 때문이다. 파일과 같이 데이터가 고정된 저장소에 있다면 되돌아가는 것이 가능하지만, 키보드 입력이나 네트워크 소켓과 같이 휘발성이 강한 스트림의 경우에는 물리적으로 되돌아 가는 것이 불가능하기 때문이다. 최상위 추상 클래스인 InputStream은 모든 스트림을 고려한 추상메서드를 정의해야 하기 때문에 기본적으로 되돌리는 기능을 지원하지 않도록 하고 이를 구현하는 구현체에게 되돌리는 기능을 Overwrite 하도록 한다.
markSupported – 되돌릴 수 있어?
reset() 메서드를 호출하여 스트림을 이전 지점으로 되돌리려 할 때 가장 먼저 해야 할 것은 해당 스트림 인스턴스가 reset() 기능을 지원하는지 여부를 확인하는 것이다. 이를 확인하는 메서드가 바로 markSupported 메서드다.
우리가 사용하는 스트림 인스턴스의 markSupported 호출 결과 true인 것은 되돌리기를 지원한다는 것이다. 만약 markSupported가 false를 반환하는 스트림 인스턴스에 mark를 호출하면 아무일도 일어나지 않고, reset을 호출하면IOException이 발생한다.
public class StreamTest {
private File file = new File("test.txt");
@BeforeEach
void setUp() {
if (file.exists()) {
file.delete();
}
// 테스트용 파일 생성
try ( FileOutputStream fos = new FileOutputStream(file)) {
fos.write("Hello Java IO".getBytes());
} catch ( IOException e) {
e.printStackTrace();
}
}
@Test
void file_input_stream_test() {
// FileInputStream으로 읽기 시도
try ( InputStream is = new FileInputStream(file)) {
// 1. mark/reset 지원 여부 확인
// FileInputStream은 내부 버퍼가 없으므로 false를 반환합니다.
boolean supported = is.markSupported();
System.out.println("Mark Supported: " + supported); // 출력: false
if (!supported) {
System.out.println("이 스트림은 mark/reset을 지원하지 않습니다.");
}
// 2. 강제로 mark 호출 (지원하지 않아도 예외는 발생하지 않고 무시됨)
is.mark(100);
// 3. 5바이트 읽기 ("Hello")
byte[] buffer = new byte[5];
is.read(buffer);
System.out.println("Read Data: " + new String(buffer));
// 4. reset 시도 -> IOException 발생
try {
is.reset();
} catch (IOException e) {
System.out.println("IOException, 예상된 에러: " + e.getMessage());
// 출력: java.io.IOException: reset() not supported 등
}
} catch (IOException e) {
e.printStackTrace();
}
}
}Java코드 실행 결과는 다음과 같다.
Mark Supported: false
이 스트림은 mark/reset을 지원하지 않습니다.
Read Data: Hello
IOException, 예상된 에러: mark/reset not supportedPlaintextFileInputStream과 같은 기본적인 노드 스트림은 되돌리기 기능을 제공하지 않는다. 되돌리기를 위해서는 읽었던 데이터를 어딘가에 기억해둬야 하는데 FileInputStream은 데이터를 읽자마자 사용자에게 전달하고 잊어버리기 때문이다.
mark(int readlimit) 계약
mark 메서드는 쉽게 말하자면 다음과 같은 동작을 정의한다.
지금 현재 위치에 마킹(mark)을 해두고 앞으로 최대 readlimit 바이트만큼 더 읽을 때까지는 mark를 유지해줘. 만약 readlimit 바이트만큼 더 읽으면 그 때는 mark를 제거해도 좋아.
mark 기능을 지원하는 스트림 (예. BufferedInputStream, ByteArrayInputStream)을 사용할 때 mark 메서드의 파라미터인 readlimit의 정확한 의미를 다음과 같다.
- readlimit은 “마크(mark)한 위치를 무효화하지 않고 앞으로 읽을 수 있는 최대 바이트 수“를 의미한다.
- mark(100)을 호출했다면 스트림은 “최소한 100바이트를 더 읽을 때까지는 현재 위치를 기억해 주겠다“라고 약속하는 것이다.
readlimit 파라미터가 필요한 이유는 메모리(Buffer) 때문이다. 기본적으로 스트림은 한번 읽은 데이터를 기억하지 않지만 markSupported를 지원하는 경우 mark를 호출하는 순간 스트림 인스턴스는 현재 위치 이후 들어오는 데이터를 내부 버퍼에 저장하는데 나중에 reset을 호출했을 때 저장해둔 데이터를 다시 꺼내올 수 있기 때문이다. 하지만 버퍼 메모리 상에 무한정 데이터를 쌓아둘 수 없기 때문에 readlimit 값으로 버퍼의 크기를 제한하기 위해서다.
내가 얼만큼의 데이터를 기억하고 있어야 해?
readlimit은 100바이트라고 하면 스트림은 100바이트까지 버퍼에 저장하고 101바이트를 읽는 순간 계약은 깨지게 된다.
mark 메서드에 readlimit을 설정할 때는 값을 넉넉하게 잡는 것이 안전하다. readlimit은 “최소한 이만큼은 보장해라“라는 의미이기 때문에 읽을 바이트 수보다 넉넉하게 잡는 것이 좋다. 만약 readlimit을 초과하여 데이터를 읽은 후 reset을 호출하면 IOException 예외가 발생할 수 있다.
ByteArrayInputStream
byte[] 로부터 데이터를 읽는 ByteArrayInputStream을 살펴보자. markSupported를 지원하기 때문에 mark, reset으로 데이터를 다시 읽을 수 있다.
@Test
void byte_array_input_stream_test() {
String content = "header:byte array input stream test";
byte[] data = content.getBytes();
try ( ByteArrayInputStream bis = new ByteArrayInputStream(data) ) {
// 'header' 부분만 먼저 읽어서 확인
byte[] buf = new byte[7];
bis.read(buf);
System.out.println("헤더 확인: " + new String(buf));
// 현재 위치 마킹
// ByteArrayInputStream은 이미 소스 자체가 buffer 역할을 하므로
// readlimit 파라미터 값이 의미가 없다. 여기서는 0을 입력했다. (사실 아무값이나 상관이 없음)
System.out.println("Marking!");
// 'header:' 다음 부분에 marking
bis.mark( 0 );
// 다음 7byte read
bis.read(buf);
System.out.println("header 다음 7문자: " + new String(buf));
// 위치 리셋
System.out.println("Resetting!");
bis.reset();
byte[] allContents = bis.readAllBytes();
System.out.println("내용 전체: " + new String(allContents));
}
catch ( IOException e ) {
// ByteArrayInputStream에서는 사실상 IOException이 발생하지 않지만
// InputStream 규약상 예외 처리에 대한 정의는 필요함
throw new RuntimeException( e );
}
}Java코드 실행 결과는 다음과 같다.
헤더 확인: header:
Marking!
header 다음 7문자: byte ar
Resetting!
내용 전체: byte array input stream testPlaintext- 먼저 처음 7byte읽는다. (‘header:’)
- 현재 위치에 marking 한다. (ByteArrayInputStream은 소스 자체가 메모리 상에 있기 때문에 readlimit 값은 의미없다)
- 그 다음 7byte를 읽는다. (‘byte ar’)
- reset()을 호출하여 위치 포인터를 marking 했던 자리로 옮긴다.
- 전체를 읽는다. -> marking 했던 위치부터 끝까지 읽는다. (‘byte array input stream test’)
BufferedInputStream
BufferedInputStream 역시 markSupported를 지원하기 때문에 mark, reset을 활용할 수 있다. ByteArrayInputStream과 달리 BufferedInputStream의 mark readlimit 값은 중요하다. 참고로 BufferedInputStream의 디폴트 버퍼 크기는 8192byte(8KB)다. BufferedInputStream의 버퍼 크기는 생성자에서 지정하여 변경할 수 있다. 별도로 지정하지 않으면 디폴트 사이즈가 적용된다.
public class StreamTest {
private File file = new File("test.txt");
@BeforeEach
void setUp() {
if (file.exists()) {
file.delete();
}
// 테스트용 파일 생성
try ( FileOutputStream fos = new FileOutputStream(file)) {
fos.write("header:buffered input stream test".getBytes());
} catch ( IOException e) {
e.printStackTrace();
}
}
@Test
void buffered_input_stream_test() {
try ( BufferedInputStream bis = new BufferedInputStream( new FileInputStream( file ) )) {
// 처음 7byte read
byte[] buf = new byte[7];
bis.read(buf);
System.out.println("헤더 확인: " + new String(buf));
// 스트림에서 읽어들일 수 있는 남은 byte 만큼 readlimit으로 적용했다.
// 디폴트 버퍼 크기가 8192 byte 이므로 8192 byte를 넘지 않는 것이 좋다.
int available = bis.available();
if ( available > 8192 ) {
available = 8192;
}
// 현재 위치 Marking!
System.out.println("Marking!");
bis.mark( available );
// 다음 7byte read
bis.read(buf);
System.out.println("header 다음 7문자: " + new String(buf));
// 위치 리셋
System.out.println("Resetting!");
bis.reset();
byte[] allContents = bis.readAllBytes();
System.out.println("내용 전체: " + new String(allContents));
}
catch ( IOException e ) {
throw new RuntimeException( e );
}
}
}Java샘플 코드는 BufferedInputStream의 디폴트 버퍼 크기를 사용하였다.
실행 결과는 다음과 같다.
헤더 확인: header:
Marking!
header 다음 7문자: buffere
Resetting!
내용 전체: buffered input stream testPlaintextBufferedInputStream fill() 동작
mark(readlimit)을 지정한 이후에 BufferedInputStream 버퍼의 마지막까지 읽게되면 fill() 함수가 호출되는데 내부 동작은 다음과 같다.
슬라이딩 : 앞쪽 공간 재활용
marking된 위치(버퍼내 인덱스 위치)가 > 0 경우에 슬라이딩 처리를 한다.
if (markpos > 0) { /* can throw away early part of the buffer */
int sz = pos - markpos;
System.arraycopy(buffer, markpos, buffer, 0, sz);
pos = sz;
markpos = 0;
}Java즉 marking된 위치부터 버퍼의 끝까지 복사하여 버퍼의 맨 앞에서부터 덮어쓰고 marking 위치를 0으로 변경한다.
이 때는 marking 무효화를 하지 않는다.
(marking 된 위치를 무효화 한다는 것은 marking된 위치 값을 -1로 설정하여 mark를 없었던 일로 만드는 동작을 말한다.)
위 샘플 코드를 약간 수정하여 확인해 보자.
@Test
void buffered_input_stream_test() {
try ( BufferedInputStream bis = new BufferedInputStream( new FileInputStream( file ), 10 )) {
// 처음 7byte read
byte[] buf = new byte[7];
bis.read(buf);
System.out.println("헤더 확인: " + new String(buf));
// 현재 위치 Marking! (readlimit: 5)
System.out.println("Marking!");
bis.mark( 5 );
// 다음 7byte read
bis.read(buf);
System.out.println("header 다음 7문자: " + new String(buf));
// 위치 리셋
System.out.println("Resetting!");
bis.reset();
byte[] allContents = bis.readAllBytes();
System.out.println("내용 전체: " + new String(allContents));
}
catch ( IOException e ) {
throw new RuntimeException( e );
}
}Java테스트를 위해 BufferedInputStream의 버퍼의 크기를 10으로 주어 버퍼의 끝까지 읽도록 하여 fill을 발생시킨다.
처음 read 호출 후 버퍼의 상태는 다음과 같다.
header:buf (10byte)
^
|
marking (현재 pos)Java이후 mark(5) 지정 후 7byte를 읽은 결과는 다음과 같다. 버퍼의 marking 위치 부터 읽은 3byte(‘buf’)가 버퍼의 0번째 위치로 overwrite 되고 marking 위치를 0으로 변경 후 버퍼의 나머지 7byte는 파일로 부터 데이터를 읽어서 채운다.
buffered i
^
|
marking 위치Java이후 reset()을 호출하면 정상적으로 marking 위치로 돌아가 다시 데이터를 읽게 된다. (marking 무효화가 되지 않았으므로)
Marking 무효화
marking된 위치가 > 0이 아닌 경우 BufferedInputStream의 버퍼의 크기가 readlimit 값보다 더 큰 경우 Marking은 무효화 된다. (markpos == 0 && buffer.length >= marklimit)
else if (buffer.length >= marklimit) {
markpos = -1; /* buffer got too big, invalidate mark */
pos = 0; /* drop buffer contents */
}Java이유는 markpos == 0 인 경우 (슬라이딩 조건이 아니므로 > 0) 인데 marking 위치가 0이므로 버퍼의 0번째 위치에 덮어쑬 수 없기 때문이다. 이 경우에는 markpost를 -1로 지정하여 marking 무효화를 선언한다.
(이후 reset() 호출시 IOException의 발생 원인)
샘플 코드를 다음과 같이 수정하여 확인해 보자.
@Test
void buffered_input_stream_exception_test() {
try ( BufferedInputStream bis = new BufferedInputStream( new FileInputStream( file ), 10 )) {
// markpos == 0, readlimit(marklimit) == 5, buffer size == 10
bis.mark( 5 );
// 7 byte read
byte[] buf = new byte[7];
bis.read(buf);
System.out.println("헤더 확인: " + new String(buf));
// 여기서 fill 호출 발생 (markpos == 0 && buffer size >= readlimit) 이므로 marking 무효화 발생
byte[] allContents = bis.readAllBytes();
System.out.println("나머지 읽음: " + new String(allContents));
// 위치 리셋 (marking 무효화로 예외 발생함!!!!)
System.out.println("Resetting!");
bis.reset();
}
catch ( IOException e ) {
throw new RuntimeException( e );
}
}Java읽기 전에 mark를 지정하여 marking 위치를 0으로 설정하였다. 버퍼 크기는 10byte, readlimit은 5byte다.
첫번째 7 byte를 읽은 후 두번째 readAllBytes()를 호출하여 나머지를 모두 읽도록 하였다. 이 과정에서 버퍼의 끝까지 읽어야 하므로 fill()이 발생하고 (markpos == 0 && buffer.length >= marklimit) 조건에 의해서 marking이 무해화 되어 -1로 변경된다.
이후 reset()을 호출하면 marking이 무해화 되었기 때문에 돌아갈 수 없어서 IOException 예외가 발생한다.
헤더 확인: header:
나머지 읽음: buffered input stream test
Resetting!
java.io.IOException: Resetting to invalid mark
...
...Plaintext버퍼 확장 (Growing)
슬라이딩과 Marking 무효화 조건에 해당되지 않는 경우 BufferedInputStream의 크기를 동적으로 확장한다.
(markpos == 0 && buffer.length < marklimit)
else { /* grow buffer */
int nsz = ArraysSupport.newLength(pos,
1, /* minimum growth */
pos /* preferred growth */);
if (nsz > marklimit)
nsz = marklimit;
byte[] nbuf = new byte[nsz];
System.arraycopy(buffer, 0, nbuf, 0, pos);
if (!U.compareAndSetReference(this, BUF_OFFSET, buffer, nbuf)) {
// Can't replace buf if there was an async close.
// Note: This would need to be changed if fill()
// is ever made accessible to multiple threads.
// But for now, the only way CAS can fail is via close.
// assert buf == null;
throw new IOException("Stream closed");
}
buffer = nbuf;
}Java버퍼의 맨 앞으로 덮어쓸 수는 없지만 (markpos == 0이므로) 사용자가 readlimit을 buffer크기 보다 더 크게 지정했으니까 더 늘려주자는 의미의 동작이다. 이 경우 역시 marking 무효화는 발생하지 않는다.
marking 무효화 샘플 코드를 수정하여 동작을 확인해보자.
@Test
void buffered_input_stream_no_exception_test() {
try ( BufferedInputStream bis = new BufferedInputStream( new FileInputStream( file ), 10 )) {
// markpos == 0, readlimit == 40, buffer size == 10
bis.mark(40);
// 7 byte read
byte[] buf = new byte[7];
bis.read(buf);
System.out.println("헤더 확인: " + new String(buf));
// 여기서 fill 호출 발생 (markpos == 0 && buffer size < readlimit) 이므로 buffer 확장이 일어난다.
// readlimit size 만큼.
byte[] allContents = bis.readAllBytes();
System.out.println("나머지 읽음: " + new String(allContents));
// 위치 리셋
System.out.println("try Resetting!");
bis.reset();
System.out.println("Success Resetting!");
}
catch ( IOException e ) {
throw new RuntimeException( e );
}
}
Java실행 결과는 다음과 같다.
헤더 확인: header:
나머지 읽음: buffered input stream test
try Resetting!
Success Resetting!Plaintext데이터를 읽기 전에 mark(40)을 지정하여 markpos = 0, readlimit(marklimit) = 40이 되도록 했다. (buffer size = 10)
두번째 readAllBytes() 호출시 역시 fill() 호출이 일어나며 (markpos == 0 && buffer.length < marklimit) 조건에 따라서 버퍼의 크기가 10 -> 40byte로 확장 된다.
처음 read() 호출 이후 버퍼 상태는 다음과 같다.
[104, 101, 97, 100, 101, 114, 58, 98, 117, 102] --> 10byteJava두번째 readAllBytes() 호출 이후 버퍼 상태는 다음과 같다.
[104, 101, 97, 100, 101, 114, 58, 98, 117, 102, 102, 101, 114, 101, 100, 32, 105, 110, 112, 117, 116, 32, 115, 116, 114, 101, 97, 109, 32, 116, 101, 115, 116, 0, 0, 0, 0, 0, 0, 0] --> 40byteJava동적으로 버퍼의 크기가 readlimit만큼 확장되면서 marking 무효화는 발생하지 않는다.
결론적으로 reset() 호출시 marking이 무효화 된 상태면 IOException 예외가 발생하는데 marking이 무효화 되는 조건은 다음과 같다.
marking이 무효화 되는 조건 (markpos = -1)
버퍼의 끝까지 읽게 되면 fill 동작이 발생
marking 위치가 버퍼의 0 위치고 buffer의 크기가 readlimt보다 크거나 같은 경우
(markpos == 0 && buffer.length < marklimit)
RandomAccessFile
InputStream이 순차적인 데이트 흐름을 다룬다면 RandomAccessFile은 파일 시스템의 본질적인 특성인 랜덤 접근을 그대로 사용할 수 있는 강력한 도구다.
RandomAccessFile은 InputStream이나 OutputStream을 상속받지 않는다. 대신 DataInput과 DataOutput 인터페이스를 모두 구현한다. 이는 RandomAccessFile은 파일을 읽고 쓰는 양방향 통로임을 의미한다.
RandomAccessFile은 파일 데이터를 마치 거대한 바이트 배열 처럼 취급하는데 배열의 인덱스에 접근하듯이 파일의 임의의 위치로 커서(File Pointer)를 이동 시킬 수 있다.
핵심 메카니즘
RandomAccessFile의 모든 동작은 파일 포인터를 중심으로 이루어진다.
- getFilePointer(): 현재 커서의 위치(바이트 오프셋)을 반환한다.
- seek(long pos): 커서를 파일의 pos 위치로 이동시킨다.
- length(): 파일의 전체 크기를 반환한다.
seek() 메서드는 운영체제의 시스템 콜(ex. lseek())에 직접 매핑된다. 이는 매우 강력하지만 OS레벨의 컨텍스트 스위칭 비용이 존재한다. BufferedInputStream의 reset()과 RandomAccessFile의 seek()은 이전 위치로 돌아간다는 결과는 같지만 동작 방식에는 큰 차이가 있다.
| BufferedInputStream (reset) | RandomAccessFile (seek) | |
| 동작원리 | 메모리 상의 배열 인덱스만 변경 | OS에게 파일 오프셋 변경 요청 (system call) |
| 속도 | 매우 빠름 (나노초) | 상대적으로 느림 (밀리초 ~ 마이크로초) |
| 메모리 사용 | 높음(되돌아갈 데이터를 모두 힙 메모리에 저장) | 매우 낮음(데이터를 메모리에 저장하지 않음) |
| 데이터 소스 | 파일, 네트워크, 메모리등 모든 스트림 | 오직 로컬 파일 시스템의 파일만 가능 |
RandomAccessFile은 데이터를 버퍼에 담아두고 이동하지 않고 시스템 콜을 이용하여 위치를 이동하기 때문에 대용량 파일에서 임의의 위치로 되돌아가야 할 때 사용하는 것이 좋다.
아래 샘플 코드는 파일의 마지막 지정된 n byte만큼 읽는 코드다. 처음 부터 데이터를 읽을 필요 없이 커서를 바로 마지막 부분으로 이동하여 데이터를 읽을 수 있다.
@Test
@DisplayName( "RAF를 이용하여 파일의 마지막 500 바이트 읽기" )
void random_access_file_test() throws IOException {
ClassPathResource resource = new ClassPathResource("gc.log");
int lastNBytes = 500;
// 읽기 전용으로 파일을 연다.
try( RandomAccessFile raf = new RandomAccessFile(resource.getFile(), "r") ) {
// 전체 크기
long totalSize = raf.length();
System.out.println("파일 전체 크기: " + totalSize);
long startPos = totalSize - lastNBytes;
// lastNBytes가 파일의 전체 크기보다 크면 처음부터 읽음
if ( startPos < 0 ) {
startPos = 0;
}
// 파일 포인터를 startPos 위치로 이동
raf.seek( startPos );
System.out.println("커서 위치 이동: " + raf.getFilePointer());
byte[] buf = new byte[lastNBytes];
// buf가 모두 채워질 때까지 읽음을 보장
raf.readFully( buf );
System.out.println("---- 마지막 " + lastNBytes + " 바이트 내용 ----");
System.out.println( new String(buf) );
System.out.println("---- 끝 ----");
}
catch ( IOException e ) {
e.printStackTrace();
}
}Javatest/resources/gc.log 파일의 마지막 500byte 내용을 읽어 출력하는 코드다. 스트림 방식은 파일의 처음부터 마지막까지 읽어야 하지만 RandomAccessFile은 바로 마지막 500 byte 위치로 바로 이동할 수 있다.
어떤 걸 사용하는 것이 좋을까?
각각의 상황에 따라 어떤 파일 처리 방식을 사용하는 것이 좋은지 표로 정리해봤다.
| 추천 클래스 | 설명 | |
| 순차적인 파일 읽기 | BufferedInputStream (with FileInputStream) | 버퍼링을 통한 시스템 콜 최소화로 높은 성능 제공 (default 8KB 버퍼 활용) |
| 파일 포맷 확인 | BufferedInputStream (mark / reset) | 파일의 첫 바이트를 읽어 파일 시그니처 확인 후 다시 읽기 |
| 대용량 파일 임의 접근 | RandomAccessFile | 메모리 소모 없이 디스크의 어느 위치로든 이동 가능 (seek) |
| 데이터 수정 | RandomAccessFile | 파일 중간의 특정 바이트열에 대해서 수정 가능 (스트림은 불가) |
스트림에서 파일을 읽은 후에 다시 이전으로 되돌려서 읽어야 하는 경우 BufferedInputStream 이나 ByteArrayInputStream 사용을 검토해 보는 것이 좋다. ByteInputStream의 경우에는 파일 내용 전체를 로딩하면 파일 내용 전체가 메모리에 올라가므로 힙 사용량이 늘어날 수 있으므로 작은 파일 처리에서만 고려하기 바란다.
또한 대용량 파일이나 잦은 위치 이동이 필요한 경우에는 RandomAccessFile을 사용하는 것이 더 좋겠다.
참고로 Java NIO의 FileChannel을 사용하면 메모리 매핑(MappedByteBuffer)등을 통해 대용량 파일을 더 효율적으로 랜덤 액세스 할 수 있다. 이 부분은 추후 포스팅으로 정리해 보도록 하겠다.
많은 도움이 되었길 바라며..
끝.
참고
https://www.baeldung.com/java-inputstream-reset-file-read