F-LAB JAVA · 2주차 · Phase 7 · Buffer
🏁 2주차의 마지막 Unit — 졸업 직전
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
Buffer는 "고정된 크기의 메모리 블록 + 읽기/쓰기 위치 추적"이다.
1주차 NIO Unit에서 본 ByteBuffer가 가장 흔한 예이며,
이번 Unit에서는 메모리 관점에서 정밀하게 다시 본다.
Heap Buffer는 JVM 안, Direct Buffer는 JVM 밖 — 이 차이가 운영 성능을 좌우한다.
| 시스템 | 비유 |
|---|---|
| capacity | 전체 전시 공간 크기 |
| position | 현재 작업 중인 위치 |
| limit | 작업 가능한 끝 위치 |
| mark | 북마크 (나중에 돌아갈 지점) |
| flip | "쓰기 끝, 이제 읽을 차례" 전환 |
| clear | 모든 것 리셋 |
| Heap Buffer | 박물관 본관 (JVM Heap) |
| Direct Buffer | 별관 (Native Memory) |
1. Buffer의 본질 — 1주차 복습 + 깊이
2. 4가지 속성 정밀 추적
3. 6가지 핵심 메서드
4. ByteBuffer와 타입별 Buffer
5. Heap Buffer vs Direct Buffer
6. Memory-Mapped File
7. ILIC 실무 — Buffer 활용
8. 흔한 실수 + 디버깅
9. 면접 + 🏆 2주차 졸업 시험
박승제씨가 1주차 Unit 7.3에서 본 것:
→ API의 큰 그림.
Phase 7의 추가:
- 메모리 관점에서 Buffer 정밀
- Heap vs Direct의 메모리 메커니즘
- Memory-Mapped File의 OS 레벨 동작
- 운영 시 Direct Buffer 모니터링
- GC와 Buffer의 관계
Buffer = 두 가지의 결합:
1. 메모리 블록 (Heap 또는 Native)
2. 4가지 정수 속성 (위치 추적)
// 단순화한 Buffer 모델
abstract class Buffer {
private int capacity; // 총 크기
private int limit; // 작업 한계
private int position; // 현재 위치
private int mark = -1; // 북마크
}
→ 메모리 블록 + 4개 정수.
→ 이 단순한 구조가 모든 NIO의 기반.
Buffer의 4가지 속성을 종이에 그려보세요.
capacity = 10 인 Buffer:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ │ │ │ │ │ │ │ │ │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
0 1 2 3 4 5 6 7 8 9
capacity = 10
position = 3, limit = 7:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│XX│XX│XX│ │ │ │ │ │ │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
position limit
의미:
- 0~2: 이미 처리됨
- 3~6: 현재 작업 영역
- 7~9: 사용 불가 (limit 너머)
Buffer (abstract)
├── ByteBuffer
│ ├── HeapByteBuffer (Heap에 byte[])
│ ├── DirectByteBuffer (Native memory)
│ ├── MappedByteBuffer (memory-mapped file)
│ └── ReadOnlyByteBuffer
├── CharBuffer
├── ShortBuffer
├── IntBuffer
├── LongBuffer
├── FloatBuffer
└── DoubleBuffer
→ primitive 타입별로 별도 Buffer.
→ ByteBuffer가 가장 기본 (다른 타입은 ByteBuffer로 변환 가능).
ByteBuffer buf = ByteBuffer.allocate(10);
buf.capacity(); // 10
ByteBuffer buf = ByteBuffer.allocate(10);
buf.position(); // 0 (초기)
buf.put((byte) 1);
buf.position(); // 1
buf.put((byte) 2);
buf.position(); // 2
buf.position(5)ByteBuffer buf = ByteBuffer.allocate(10);
buf.limit(); // 10 (초기 = capacity)
buf.limit(7);
buf.limit(); // 7
ByteBuffer buf = ByteBuffer.allocate(10);
buf.position(3);
buf.mark(); // 현재 position(3) 기록
buf.position(7);
buf.reset(); // position을 mark(3)로 복원
reset()으로 그 위치로 복원규칙: 0 ≤ mark ≤ position ≤ limit ≤ capacity
위반 시:
- limit > capacity: IllegalArgumentException
- position > limit: 자동 조정
- mark > position: 자동 -1 (무효화)
// 1만 bytes 데이터 쓰기 → 읽기 시나리오
ByteBuffer buf = ByteBuffer.allocate(1000);
// 상태 1: 초기
// position=0, limit=1000, capacity=1000
buf.put("BL-001".getBytes());
// 상태 2: position=6, limit=1000, capacity=1000
buf.put(",10".getBytes());
// 상태 3: position=9, limit=1000, capacity=1000
buf.flip();
// 상태 4: position=0, limit=9, capacity=1000
// ↑ 0~8 영역이 읽기 가능
byte[] result = new byte[9];
buf.get(result);
// 상태 5: position=9, limit=9, capacity=1000
buf.clear();
// 상태 6: position=0, limit=1000, capacity=1000
// 다시 쓰기 모드
→ 4가지 속성이 상태 머신처럼 작동.
Buffer의 position과 limit이 같으면 무슨 의미?
답:
hasRemaining():
boolean hasRemaining() {
return position < limit;
}
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
동작:
Before flip (쓰기 모드, 9 bytes 썼음):
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
position=9 limit=10
After flip:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
position=0 limit=9
→ 0~8을 읽기 가능
→ flip = "방향 전환".
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
After clear:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │ │ ← 데이터는 남아있음
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
position=0 limit=capacity
→ "처음부터 다시 쓰기" 상태.
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
After rewind (이전: 다 읽음):
position=0, limit=9 (그대로)
→ 0~8을 다시 읽기 가능
→ "처음부터 다시 읽기".
public ByteBuffer compact() {
System.arraycopy(hb, position, hb, 0, remaining());
position(remaining());
limit(capacity);
discardMark();
return this;
}
동작:
Before compact (일부 읽었음, 5~9는 안 읽음):
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ ?│ ?│ ?│ ?│ ?│E │F │G │H │I │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
position=5 limit=10
After compact:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│E │F │G │H │I │ ?│ ?│ ?│ ?│ ?│ ← 5~9를 0~4로 복사
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
position=5 limit=10
(다시 쓰기 시작 위치)
→ 읽다 만 데이터 유지 + 추가 쓰기 가능 모드.
→ 스트림 처리에 유용.
buf.position(5);
buf.mark(); // mark=5 저장
buf.position(8); // 어디론가 이동
buf.reset(); // position=5 복원
→ 임시 북마크.
| 메서드 | position | limit | mark | 데이터 |
|---|---|---|---|---|
flip() | 0 | 이전 position | -1 | 유지 |
clear() | 0 | capacity | -1 | 유지 (덮어쓰기 가능) |
rewind() | 0 | 유지 | -1 | 유지 |
compact() | 남은 수 | capacity | -1 | 압축 |
mark() | 유지 | 유지 | 현재 position | 유지 |
reset() | mark 값 | 유지 | 유지 | 유지 |
ByteBuffer buf = ByteBuffer.allocate(1024);
// 1. 쓰기
buf.put(data);
// 2. flip — 읽기 모드로
buf.flip();
// 3. 읽기
while (buf.hasRemaining()) {
process(buf.get());
}
// 4. clear — 다시 쓰기 모드로
buf.clear();
→ flip - 읽기 - clear 사이클.
ByteBuffer buf = ByteBuffer.allocate(1024);
// 다양한 타입 쓰기
buf.putInt(100); // 4 bytes
buf.putLong(1000L); // 8 bytes
buf.putDouble(3.14); // 8 bytes
buf.put((byte) 1); // 1 byte
buf.flip();
// 다양한 타입 읽기
int i = buf.getInt();
long l = buf.getLong();
double d = buf.getDouble();
byte b = buf.get();
→ byte 단위지만 다양한 primitive 타입 입출력 가능.
IntBuffer ib = IntBuffer.allocate(100);
ib.put(1);
ib.put(2);
ib.put(3);
ib.flip();
int val = ib.get(); // 1
CharBuffer cb = CharBuffer.allocate(100);
cb.put('A');
cb.put('B');
→ 각 primitive 타입마다 Buffer 존재.
ByteBuffer bb = ByteBuffer.allocate(1024);
// View Buffer 생성
IntBuffer ib = bb.asIntBuffer();
CharBuffer cb = bb.asCharBuffer();
DoubleBuffer db = bb.asDoubleBuffer();
같은 메모리를 다른 타입으로 해석.
→ 메모리 효율적.
→ ByteBuffer 변경 시 View Buffer에도 반영.
ByteBuffer buf = ByteBuffer.allocate(4);
buf.order(ByteOrder.BIG_ENDIAN); // 네트워크 표준
// 또는
buf.order(ByteOrder.LITTLE_ENDIAN); // x86
buf.putInt(0x12345678);
ByteBuffer 기본은 BIG_ENDIAN.
→ 네트워크 통신엔 BIG_ENDIAN.
→ 파일 포맷에 따라 다름.
byte[] data = "Hello".getBytes();
ByteBuffer buf = ByteBuffer.wrap(data);
// data 배열을 그대로 Buffer로
// 또는
ByteBuffer buf2 = ByteBuffer.wrap(data, 0, 3);
// 일부만
→ 메모리 복사 없이 Buffer 만듦.
→ Buffer 변경 = 원본 배열 변경.
// Heap Buffer — JVM Heap 안
ByteBuffer heap = ByteBuffer.allocate(1024);
// Direct Buffer — JVM Heap 밖 (Native memory)
ByteBuffer direct = ByteBuffer.allocateDirect(1024);
JVM Heap:
┌──────────────────────────┐
│ HeapByteBuffer 객체 │
│ - byte[] hb (실제 메모리) │
│ - position, limit, ... │
└──────────────────────────┘
→ GC 대상
→ 일반 자바 객체와 동일한 메모리 관리
JVM Heap:
┌──────────────────────────┐
│ DirectByteBuffer 객체 │
│ - address (Native 주소) │ ──┐
│ - position, limit, ... │ │
└──────────────────────────┘ │
│
Native Memory (JVM Heap 밖): │
┌──────────────────────────┐ │
│ 실제 byte 데이터 │ ◄─┘
└──────────────────────────┘
→ GC 대상 아님 (자동 회수 X)
→ malloc / free 방식으로 관리
| 항목 | Heap Buffer | Direct Buffer |
|---|---|---|
| 위치 | JVM Heap | Native Memory |
| GC | 자동 | 객체만 GC, 메모리는 finalizer |
| 할당 비용 | 낮음 | 높음 |
| I/O 성능 | 보통 (복사 필요) | 빠름 (직접 OS 호출) |
| 메모리 한계 | -Xmx | OS 메모리 |
| 디버깅 | 쉬움 | 어려움 |
파일 / 네트워크 I/O 시:
Heap Buffer 사용:
1. JVM이 일시적으로 Direct Buffer 만듦
2. Heap Buffer → Direct Buffer 복사
3. Direct Buffer를 OS에 전달
4. OS가 I/O 수행
→ 추가 복사 = 비용
Direct Buffer 사용:
1. OS에 직접 전달
2. OS가 I/O 수행
→ 복사 없음 = 빠름
→ I/O 빈번한 환경에선 Direct Buffer.
// ❌ 매번 새 Direct Buffer
for (int i = 0; i < 10000; i++) {
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
// ... 사용
}
문제:
해결:
// ✓ 풀링
private static final ByteBuffer[] POOL = new ByteBuffer[16];
// 또는 Netty의 PooledByteBufAllocator 같은 라이브러리
# Direct Buffer 사용량 확인
jcmd <PID> VM.native_memory summary
# 출력:
# Internal (reserved=8MB, committed=8MB)
# (malloc=8MB)
# ...
# ByteBuffer (reserved=256MB, committed=256MB)
# (malloc=256MB)
또는 옵션:
-XX:NativeMemoryTracking=summary
ILIC 운영에서 Native Memory 모니터링 권장.
일반 데이터 처리:
→ HeapByteBuffer 충분
I/O 빈번 (Netty, NIO 서버):
→ DirectByteBuffer 풀링
큰 파일 처리:
→ MappedByteBuffer (다음 섹션)
RandomAccessFile file = new RandomAccessFile("large.dat", "rw");
FileChannel channel = file.getChannel();
MappedByteBuffer mapped = channel.map(
FileChannel.MapMode.READ_WRITE, 0, channel.size());
// 이제 mapped는 파일 자체를 가리킴!
mapped.put(0, (byte) 'A'); // 파일에 즉시 반영
byte b = mapped.get(100); // 파일에서 직접 읽음
OS의 Virtual Memory:
┌──────────────────────────┐
│ 프로세스 가상 주소 공간 │
│ │
│ 일반 메모리 │
│ Heap │
│ ... │
│ ┌──────────────────────┐│
│ │ Memory-Mapped Region ││ ◄── 이 영역이 파일과 매핑
│ └──────────────────────┘│
└──────────────────────────┘
▲
│
▼ (OS가 자동으로)
┌──────────────────────────┐
│ 디스크의 파일 │
└──────────────────────────┘
→ 파일을 메모리처럼 접근.
→ 읽기/쓰기 = 가상 메모리 접근.
→ OS가 페이지 단위로 자동 로드/저장.
1. 매우 큰 파일 처리
- 전체를 메모리에 로딩 안 함
- 필요한 부분만 OS가 로드
2. 빠름
- OS 캐시 활용
- 시스템 콜 감소
3. 동시 접근
- 여러 프로세스가 같은 파일 매핑 가능
- 공유 메모리처럼 사용
1. 운영 체제 종속
2. 매핑 비용 (작은 파일엔 비효율)
3. 매핑 해제 어려움 (Java에선 GC 의존)
4. 32-bit 환경 제한 (4GB)
5. 디버깅 어려움
적합:
- 큰 CSV 파일 (수 GB) 부분 분석
- 큰 로그 파일 검색
- 데이터베이스 파일 직접 접근
부적합:
- 작은 파일 (1MB 이하)
- 자주 열고 닫는 파일
- 일반 비즈니스 로직
public boolean searchInFile(Path path, byte[] pattern) throws IOException {
try (RandomAccessFile file = new RandomAccessFile(path.toFile(), "r");
FileChannel channel = file.getChannel()) {
MappedByteBuffer mapped = channel.map(
FileChannel.MapMode.READ_ONLY, 0, channel.size());
// 매핑된 영역에서 검색
for (long i = 0; i < mapped.capacity() - pattern.length; i++) {
boolean match = true;
for (int j = 0; j < pattern.length; j++) {
if (mapped.get((int) (i + j)) != pattern[j]) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
}
→ 10GB 파일도 빠르게 검색.
→ 메모리에 전체 로딩 안 함.
ILIC에서 Buffer 사용:
- 파일 입출력 (배치)
- 네트워크 통신 (HTTP, Socket)
- PDF/Excel 생성
- JSON 직렬화
- 압축/암호화 처리
→ 대부분 라이브러리가 내부적으로 처리.
// 1. 큰 파일 청크 단위 읽기
try (FileChannel channel = FileChannel.open(path)) {
ByteBuffer buf = ByteBuffer.allocate(8192);
while (channel.read(buf) > 0) {
buf.flip();
processChunk(buf);
buf.clear();
}
}
// 2. HTTP Response 스트리밍
HttpClient client = HttpClient.newHttpClient();
HttpResponse<InputStream> response = client.send(
request, HttpResponse.BodyHandlers.ofInputStream());
byte[] chunk = new byte[4096];
try (InputStream is = response.body()) {
int n;
while ((n = is.read(chunk)) > 0) {
process(chunk, n);
}
}
// 3. Netty 같은 프레임워크
// 내부적으로 ByteBuf (Netty 자체 Buffer) 사용
Spring Boot 앱 내부:
- DispatcherServlet의 요청/응답 Buffer
- Jackson의 직렬화 Buffer
- Tomcat/Undertow의 네트워크 Buffer
- JPA의 데이터베이스 입출력
- SLF4J의 로그 Buffer
→ 박승제씨는 거의 의식 안 함.
# Native Memory 누수 의심 시
jcmd <PID> VM.native_memory summary
# 변화 추적
jcmd <PID> VM.native_memory baseline
# ... 시간 경과 후
jcmd <PID> VM.native_memory summary.diff
Direct Buffer 누수 패턴:
# Direct Buffer 최대 크기 제한
-XX:MaxDirectMemorySize=256m
# 기본값은 Heap 크기와 비슷
# 명시적 설정 권장 (특히 큰 힙)
// 작은 데이터: HeapBuffer
ByteBuffer small = ByteBuffer.allocate(1024);
// I/O 위주: DirectBuffer 풀링
private static final ThreadLocal<ByteBuffer> IO_BUFFER =
ThreadLocal.withInitial(() -> ByteBuffer.allocateDirect(8192));
// 큰 파일: MappedByteBuffer
try (FileChannel ch = ...) {
MappedByteBuffer mapped = ch.map(READ_ONLY, 0, ch.size());
}
// ❌
buf.put(data);
// flip 없이
buf.get(); // 0번 위치부터 읽기 시작, 잘못된 데이터
해결:
buf.put(data);
buf.flip(); // 반드시!
buf.get();
// ❌ 부분 읽기 후 clear
buf.get(part1); // 일부만 읽음
buf.clear(); // 안 읽은 데이터 손실!
해결:
buf.get(part1);
buf.compact(); // 안 읽은 데이터 보존
// ❌
public void process() {
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
// ... 사용
// buf 해제 안 함 → Native memory 누적
}
해결:
// 다른 시스템과 통신 시
ByteBuffer buf = ByteBuffer.allocate(4);
buf.putInt(1234);
// 받는 쪽이 LITTLE_ENDIAN 가정하면 깨짐
해결:
buf.order(ByteOrder.LITTLE_ENDIAN); // 명시적
ByteBuffer buf = ByteBuffer.allocate(10);
// buf의 capacity 늘리고 싶음
// ❌ Buffer는 immutable capacity
해결:
private final ByteBuffer shared = ByteBuffer.allocate(1024);
// Thread A
shared.put(...);
// Thread B (동시)
shared.put(...);
// → 데이터 깨짐
→ Buffer는 thread-unsafe.
→ ThreadLocal 또는 동기화.
// ❌ 작은 데이터에 Direct
ByteBuffer buf = ByteBuffer.allocateDirect(64);
Direct 할당은 비쌈 (수 μs).
작은 데이터엔 HeapBuffer가 빠름.
→ Direct는 재사용 + 큰 I/O 시.
# Direct Buffer 사용량
jcmd <PID> VM.native_memory summary
# Heap dump (Buffer는 객체로 보임)
jmap -dump:format=b,file=heap.hprof <PID>
# 운영 옵션
-XX:NativeMemoryTracking=summary
-XX:MaxDirectMemorySize=256m
| Q | 핵심 답변 |
|---|---|
| Buffer의 4가지 속성? | capacity, position, limit, mark |
| flip()의 동작? | limit=position, position=0 |
| Heap vs Direct? | JVM Heap 안 vs Native Memory |
| Direct Buffer 빠른 이유? | OS와 직접 통신, 복사 없음 |
| Direct Buffer 위험? | 할당 비싸고, GC 지연으로 누수 |
| Memory-Mapped File? | 파일을 가상 메모리로 매핑 |
| Byte Order 의미? | BIG/LITTLE ENDIAN. 바이트 저장 순서 |
| flip vs rewind? | flip은 limit도 변경, rewind는 position만 |
| clear vs compact? | 전체 리셋 vs 안 읽은 데이터 보존 |
| MaxDirectMemorySize? | Direct Buffer 최대. Heap과 별개 |
1. Buffer = 메모리 블록 + 4가지 속성
2. Heap vs Direct
3. ILIC 실무
jcmd VM.native_memory2주차 전체 진행:
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주, 정점)
✅ Phase 4 — G1 GC 심화 (4.1 ~ 4.5 완주, 운영 마스터)
✅ Phase 5 — 컬렉션 내부 구조 (5.1 ~ 5.4 완주, 자료구조 마스터)
✅ Phase 6 — Reflection & Iterator (6.1 ~ 6.2 완주, 동적 마스터)
✅ Phase 7 — Buffer (7.1 완주, 메모리 I/O 마스터) ← 여기
🏆 2주차 완주 — 모든 Phase 마스터
총 Unit 수: 26개
Phase별:
Phase 1: 6 Unit (메모리)
Phase 2: 4 Unit (메서드 실행)
Phase 3: 4 Unit (바이트코드 정점)
Phase 4: 5 Unit (GC 마스터)
Phase 5: 4 Unit (컬렉션 마스터)
Phase 6: 2 Unit (동적 메커니즘)
Phase 7: 1 Unit (Buffer)
총 페이지: 추정 1000+
다음 질문에 즉답할 수 있다면 2주차 졸업:
new Shipment("BL-001").calculate(100) 한 줄이 JVM에서 일어나는 모든 일은?javap -c -v Shipment.class 출력의 모든 줄을 해석할 수 있는가?@Autowired 가 private 필드에 어떻게 주입하는가?모두 답할 수 있다면, 박승제씨는 자바 메모리와 JVM 마스터.
박승제씨가 작성한 모든 자료:
1주차:
Unit 6.4 HashMap과 LoadFactor (MD + PPT)
Unit 7.1 try-with-resources
Unit 7.2 NIO2 Files/Path
Unit 7.3 NIO Channel/Buffer
Unit 7.4 Serializable/transient
2주차:
Phase 1 (6개): 자바 변수 ↔ 메모리 매핑
Phase 2 (4개): JVM 메서드 실행
Phase 3 (4개): 바이트코드와 상수 풀 (정점)
Phase 4 (5개): G1 GC 심화
Phase 5 (4개): 컬렉션 내부 구조
Phase 6 (2개): Reflection & Iterator
Phase 7 (1개): Buffer
총: 31개 학습 자료 (markdown) + 1 PPT (HashMap)