Article/OS - Deep Dive

Java I/O vs Java NIO - 왜 NIO는 단일 버퍼 모델을 채택했을까?

조금씩 차근차근 2025. 4. 12. 18:33

글의 목적

JAVA I/O 와 Java NIO는 목적 자체가 다르고, 상황에 맞게 써야 한다는데, “그럼 대체 뭐가 달라서, 어떤 기준으로 사용해야 하는가?”에 대한 탐구를 수행한 과정을 기록한다.

Java NIO 버퍼 입출력 메소드 - flip, clear, compact

기본 개념 - ByteBuffer 동작 방식

쓰기 모드 (Writing mode)

데이터를 버퍼에 쓸 때 사용한다.

  • position: 현재 쓸 위치
  • limit: 쓰기 가능한 최대 범위(일반적으로 버퍼의 capacity)

로 설정된다.

읽기 모드 (Reading mode)

버퍼에 쓰여진 데이터를 읽어올 때 사용한다.

  • position: 현재 읽을 위치
  • limit: 읽기 가능한 최대 범위(일반적으로 write 모드때의 마지막 position)

로 설정된다.

쓰기→읽기 전환

flip()

기존에 작성된 데이터를 읽기 위한 flip()

  • 기능
    • 쓰기 → 읽기 전환 시 사용한다.
  • 동작방식
    1. limit(최대 읽기 가능한 범위)를 현재 position으로 설정한다.
      • 지금까지 쓴 위치를 최대로 하여, 이 이상 읽지 못하도록
    2. position을 0으로 설정한다.
      • 쓴 데이터를 처음부터 읽을 수 있도록

읽기→쓰기 전환

clear()

남은 데이터에 관계없이 맨 앞부터 쓰기 시작하는 clear()

  • 기능
    • 버퍼를 완전히 초기화한다.
    • 전체 용량을 다시 쓸 수 있도록 한다.
  • 동작방식
    1. position을 0으로 설정한다.
      • 버퍼의 맨 앞에서부터 쓸 수 있도록
    2. limit을 버퍼의 전체 용량(capacity)로 리셋한다.
      • 버퍼의 최대 크기까지 쓸 수 있도록

compact()

남은 데이터를 유지한 채, 쓰기 작업을 시작하는 compact()

  • 기능
    • 아직 읽지 않은 데이터를 유지하며 버퍼를 정리한다.
    • 남은 공간을 쓰기 위해 뒤에 있는 아직 안읽은 데이터를 앞으로 당겨온다.
    • 읽지 않은 남은 데이터의 끝부터, 다시 쓰기를 시작할 수 있도록 한다.
  • 동작방식
    1. position부터 limit까지의 데이터를 버퍼의 시작 부분으로 복사한다.
      • 안읽은 데이터가 버퍼에 계속 남아있도록
    2. position을 복사된 데이터의 길이로 설정한다.
      • 남은 데이터의 끝부터 다시 쓸 수 있도록
    3. limit을 버퍼의 전체 용량(capacity)로 리셋한다.
      • 버퍼의 최대 크기까지 쓸 수 있도록

최초 의문

  • JAVA nio의 채널이 하나의 버퍼를 가지고 flip(), compact()/clear()을 통해 읽기/쓰기 전환을 하며 사용하는 이유가 뭐지?
  • Java I/O 처럼, input Buffer Output Buffer 따로 두는게 사용하기도 쉽고 명시적인데, 왜 굳이 하나의 버퍼를 이용해, flip(), compact()/clear() 을 이용해 통신을 하게 만들었을까?
    • 어렵고 불편하기만 한데, 왜 이런 결정을 한거지?

1) 버퍼를 하나로 두고 관리하는게, 더 적게 메모리를 관리할 수 있는 것 아닐까?

  • 소켓 인터페이스는 inputStream, outputStream 두개를 사용하는데, BufferedInputStream 같은 개념을 사용하면 Java NIO의 버퍼를 쓸 이유가 없다.
  • 그렇다면, 버퍼가 하나임으로 얻는 장점이 있기에, 해당 방식을 선택한 것 아닐까?

1.1) 버퍼의 갯수를 줄이는게 성능 효율이 좋다면, TCP도 입력버퍼/출력버퍼 따로 두고 쓰는데, OS TCP 버퍼를 직접 사용한다면 성능이 더 좋지 않을까?

  • 소켓 인터페이스가 관리하는 메모리 관리를, 개발자가 직접 해야 한다.
    • 네트워크 스택에 직접 접근
    • 파일 I/O와 네트워크 I/O의 접근 방식이 달라진다.
  • 이런 I/O 관리를 추상화하기 위해서 "소켓 인터페이스"가 등장한 것을 잊지 말자.
  • 한마디로 이 논리는, 완벽하지 않다!

1.2) TCP 통신

  • 기본적으로 full-duplex 방식이다.
  • OS 레벨에 입력 버퍼와 출력 버퍼가 따로 존재한다.
  • 입력 버퍼
    • 네트워크를 통해 도착한 데이터는 먼저 운영체제의 커널에 있는 수신 버퍼에 저장된다.
    • 애플리케이션은 이 버퍼에서 데이터를 읽어 들인다.
    • 이 방식은 네트워크 속도나 지연 등으로 인해 데이터가 도착하는 속도가 일정하지 않아도
      • 애플리케이션이 필요할 때 데이터를 가져올 수 있도록 해준다.
  • 출력 버퍼
    • 애플리케이션이 소켓에 데이터를 쓰면, 이 데이터는 출력 버퍼에 저장된 후 실제 네트워크를 통해 전송된다.
    • TCP의 경우, 이 버퍼는 전송한 데이터가 상대측으로 확인(ACK)되기 전까지 유지된다.
    • 재전송이나 흐름 제어, 혼잡 제어 등의 역할을 수행한다.

2) 통신 방식 분류 - NIO 너는 대체 뭔데?

Java I/O

  • 자바 파일 I/O
  • 자바 소켓 I/O
    • 소켓 통신에 대해서는, 해당 글을 참고해주세요.

Java NIO

  • 채널 I/O
    • 기존의 자바 소켓 인터페이스와 차별화된, 새로운 패러다임이다.
    • 네이티브 소켓 인터페이스와 OS 레벨의 멀티플렉싱 시스템 콜을 사용하여, 새로운 방식의 I/O를 도입했다.
      여러개의 소켓을 "멀티플렉싱 시스템 콜(epoll 계열, 이벤트 기반)"을 사용해 동시에 관리하면서, 새로운 형태의 I/O를 도입한 것이다.
  • Direct ByteBuffer 개념을 도입한 새로운 파일 I/O 또한 도입했다.

2.1) Direct ByteBuffer 란?

  • AllocateDirect()
    • 네이티브 메모리에 메모리 할당을 요구하여, 힙 메모리가 아닌 외부에 새로운 공간을 할당받는 메소드이다.
    • OS에 다시 직접 요청해야 하기 때문에, 할당에 비용이 많이 소모된다.
      • 따라서, 한번 할당받고 재사용하는 것이 효율이 좋다.
    • 힙 영역을 벗어났기 때문에, GC가 관리해주지 않고, 따라서 관리에 신경써야 한다.
  • Direct ByteBuffer의 강점은?
    • Zero-Copy
      • 네이티브 메모리(커널 메모리) 레벨에서, 시스템 콜을 즉시 이용해 힙 영역에 데이터를 끌고오지 않고 즉시 다른 파일 디스크립터로 파일을 복사할 수 있게 하는 기법이다.

추측) Java NIO가 단일 버퍼 모델을 채택하고, flip() | clear()/compact() 으로 현재 상태를 관리하며 입출력을 수행하는 이유

  1. 단일 버퍼 모델 채택
    • Native 메모리는 OS에 직접 요청해야 하기 때문에, 할당받는 비용이 높다.
    • 따라서, 버퍼의 할당 자체를 최소화하는 것을 목적으로 한다.
    • 이는 입출력에 단일 버퍼 모델을 채택하기 위한 근거가 된다.
  2. 버퍼 상태 전환 메소드 도입
    • 단일 버퍼 모델로 입력과 출력을 모두 받기 위해선, 현재 버퍼의 입력/출력 상태를 결정해야 한다.
    • 이는 버퍼 상태 전환 메소드를 도입하는 근거가 된다.
  • 따라서, 버퍼 상태 전환 메소드로 버퍼의 상태를 추가하여 입출력을 수행하는 이유는
    • 메모리 최적화를 극한까지 끌어올려야 하기 때문이다.

결론

Java I/O

  • 단순한 I/O가 필요할 때
  • 기존 I/O로도 충분히 해결이 가능할 때

Java NIO로는 무엇을 할 수 있는가?

  • 논블로킹
    • 논블로킹 작업에 특화
    • 멀티플렉싱 구현 가능
  • Direct ByteBuffer
    • Zero-Copy 기능을 도입할 수 있게 된다.