MCP 도구에서 파일이나 이미지의 일부 바이트를 꺼내 가공했는데 캐시에 둔 원본까지 달라진다면, Buffer.slice()로 만든 값을 복사본으로 취급했는지 확인한다. Buffer의 slice는 원본 바이트를 공유하는 뷰를 만든다. 잘라낸 쪽에 쓰면 겹치는 원본 구간도 바뀐다. 새 변수에 담았거나 서로 다른 객체라는 사실만으로는 원본을 보호할 수 없다.
원본을 유지한 채 부분 데이터를 수정하려면 Buffer.from(source.subarray(start, end))처럼 필요한 구간의 바이트를 복사한다. 반면 원본과 같은 저장 공간을 의도적으로 읽고 쓰는 경우에는 subarray()가 그 의도를 드러낸다. 두 선택을 구분하지 않고 slice를 subarray로 치환하면 원본 변경 문제는 남는다.
확인 기준일은 2026년 11월 6일 KST다. Node.js v24.22.1의 공식 문서와 같은 버전의 실행 결과를 대조했다. 대상은 Node 기반 AI·MCP 어댑터 안의 바이너리 가공 코드이며, 특정 MCP 프로토콜 버전이나 SDK의 전송 동작을 설명하는 글은 아니다. 아래 코드는 숫자 네 개로 만든 합성 검사다. 실제 이미지 손상이나 운영 장애를 재현한 기록은 아니다.
slice라는 이름보다 실제 타입을 먼저 본다
Node의 Buffer·TypedArray 문서는 두 API의 차이를 명시한다. 일반 Uint8Array의 slice는 선택한 값을 복사하지만, Buffer의 slice는 기존 메모리를 공유한다. Buffer가 Uint8Array 계열이라는 이유로 메서드의 동작까지 같다고 가정하면 이 차이를 놓친다.
Buffer.slice 문서는 이 메서드를 deprecated로 표시하며 subarray 사용을 권한다. 이것은 공유 구간을 표현하는 API 선택에 관한 권고다. 원본을 보존하려는 수정에서는 Buffer.from(buffer)의 복사 동작이 별도로 필요하다.
원본과 부분 구간을 양쪽에서 바꿔 보는 검사
다음 코드를 buffer-ownership-check.mjs로 저장하고, Node.js가 설치된 환경에서 node buffer-ownership-check.mjs로 실행한다. 외부 API, 실제 파일 내용, 자격 증명은 사용하지 않는다. 각 공유 방식에는 새 원본을 주고, 자른 값에서 원본으로 전파되는 변경과 원본에서 자른 값으로 전파되는 변경을 각각 확인한다.
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
const bytes = value => [...value];
const seed = () => Buffer.from([10, 20, 30, 40]);
console.log(`Node ${process.version}`);
for (const method of ['slice', 'subarray']) {
const source = seed();
const part = source[method](1, 3);
assert.notEqual(part, source);
assert.deepEqual(bytes(part), [20, 30]);
part[0] = 99;
assert.deepEqual(bytes(source), [10, 99, 30, 40]);
source[2] = 77;
assert.deepEqual(bytes(part), [99, 77]);
console.log(`${method}: source=${bytes(source)} part=${bytes(part)}`);
}
const source = seed();
const copy = Buffer.from(source.subarray(1, 3));
copy[0] = 99;
assert.deepEqual(bytes(source), [10, 20, 30, 40]);
source[2] = 77;
assert.deepEqual(bytes(copy), [99, 30]);
console.log(`copy: source=${bytes(source)} copy=${bytes(copy)}`);
const typedSource = Uint8Array.from([10, 20, 30, 40]);
const typedCopy = typedSource.slice(1, 3);
typedCopy[0] = 99;
assert.deepEqual(bytes(typedSource), [10, 20, 30, 40]);
console.log(`Uint8Array.slice: source=${bytes(typedSource)} copy=${bytes(typedCopy)}`);
const owner = seed();
const shared = Buffer.from(owner.buffer, owner.byteOffset + 1, 2);
shared[0] = 99;
assert.deepEqual(bytes(owner), [10, 99, 30, 40]);
console.log(`from(ArrayBuffer): source=${bytes(owner)} view=${bytes(shared)}`);
console.log('PASS: shared writes and independent copies checked');
Node.js v24.22.1에서 실행한 출력은 다음과 같다. assert 검사를 모두 통과해야 마지막 PASS가 나온다.
Node v24.22.1
slice: source=10,99,77,40 part=99,77
subarray: source=10,99,77,40 part=99,77
copy: source=10,20,77,40 copy=99,30
Uint8Array.slice: source=10,20,30,40 copy=99,30
from(ArrayBuffer): source=10,99,30,40 view=99,30
PASS: shared writes and independent copies checked
slice와 subarray 검사에서는 처음에 서로 다른 객체임을 확인한 뒤 부분 구간의 첫 바이트를 99로 바꾼다. 원본의 두 번째 바이트도 99가 된다. 이어 원본의 세 번째 바이트를 77로 바꾸면 부분 구간의 두 번째 값에도 반영된다. 객체 둘이 같은 바이트 범위를 보고 있기 때문이다.
copy 검사에서는 변경이 갈라진다. 복사본의 첫 바이트를 바꾼 뒤에도 원본은 처음 값 그대로이고, 이후 원본의 세 번째 바이트를 77로 바꿔도 복사본의 두 번째 값은 30으로 남는다. 출력의 원본에 77이 있는 것은 복사 실패가 아니라 테스트가 원본에 직접 쓴 결과다. 이 순서를 구분해야 검사 결과를 거꾸로 읽지 않는다.
일반 Uint8Array의 slice에서도 원본은 바뀌지 않았다. 다만 이 비교는 기본 생성자와 메서드를 사용하는 보통의 Uint8Array에 한정한다. 사용자 정의 생성자나 메서드가 개입한 객체까지 한 번의 검사로 보장하지 않는다.
Buffer.from에도 공유하는 호출 형태가 있다
Buffer.from(existingBuffer)와 Buffer.from(existingBuffer.buffer, offset, length)는 다른 선택이다. 뒤의 형태는 ArrayBuffer를 받는 API로, 지정한 저장 공간을 공유한다. 위 출력의 from(ArrayBuffer) 행이 이 경우다. from이라는 이름만 보고 복사라고 판단해서는 안 된다.
예제는 원본 Buffer의 byteOffset에 시작 위치를 더하고 길이를 2로 제한한다. Buffer.from(source.buffer)처럼 원본의 범위를 생략하면, backing ArrayBuffer 안에서 원래 Buffer가 보던 범위를 넘어서는 바이트까지 포함할 수 있다. 이 글의 수정 예제에서는 그러한 변환을 거치지 않고, 복사할 Buffer 구간 자체를 전달한다.
수정 지점에서 복사 범위를 명시한다
아래 함수는 Buffer의 일부를 수정용 복사본으로 만드는 예다. 범위의 시작은 포함하고 끝은 제외한다. 음수 인덱스나 범위를 벗어난 값을 조용히 보정하지 않도록, 이 함수의 계약에서는 정수와 범위를 먼저 검사한다. 일반 subarray API가 원래 허용하는 모든 입력을 받아 주는 래퍼는 아니다.
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
function copyRange(source, start, end) {
if (!Buffer.isBuffer(source)) throw new TypeError('Buffer required');
if (!Number.isInteger(start) || !Number.isInteger(end) ||
start < 0 || end < start || end > source.length) {
throw new RangeError('Invalid byte range');
}
return Buffer.from(source.subarray(start, end));
}
const original = Buffer.from([10, 20, 30, 40]);
const work = copyRange(original, 1, 3);
work.fill(0);
assert.deepEqual([...original], [10, 20, 30, 40]);
original.fill(88);
assert.deepEqual([...work], [0, 0]);
assert.equal(copyRange(original, 2, 2).length, 0);
assert.deepEqual(copyRange(original, 0, 4), original);
for (const [start, end] of [[-1, 2], [3, 2], [0, 5], [0.5, 2]]) {
assert.throws(() => copyRange(original, start, end), RangeError);
}
assert.throws(() => copyRange([10, 20], 0, 1), TypeError);
console.log('PASS: copyRange isolation, bounds and input type');
이 두 번째 검사도 같은 Node 버전에서 실행해 PASS를 확인했다. 부분 복사 후 양방향 변경이 분리되는지, 빈 구간과 전체 구간, 음수·역전·범위 초과·소수 인덱스, Buffer가 아닌 입력을 점검한다. 전송 라이브러리와 연결하는 테스트는 포함하지 않는다.
복사는 원본이 바뀌기 전에 해야 한다. 이미 공유 뷰를 통해 원본을 수정한 다음 복사하면 바뀐 값을 별도로 보관할 뿐, 과거 값이 복구되지는 않는다. 원본을 보존해야 하는 가공 함수의 입구에서 구간을 복사하고, 복사본만 수정하도록 경계를 잡는다.
반대로 읽기만 하고 소유자가 그동안 바이트를 변경하지 않는다고 보장할 수 있다면 공유 뷰를 유지할 수 있다. 큰 데이터를 매 단계에서 무조건 복제하기보다는, 어떤 단계가 쓰고 누가 원본을 보존해야 하는지 먼저 정한다. 이 글은 복사 비용이나 처리량을 측정하지 않았으므로 성능 우열을 수치로 제시하지 않는다.
실제 어댑터에서는 이렇게 원인을 좁힌다
1. 타입과 생성 경로를 확인한다. 문제가 생기는 값에 Buffer.isBuffer(value)를 적용하고, 처음 만들어진 곳부터 slice·subarray·ArrayBuffer 변환을 따라간다. 타입 선언이나 변수 이름 대신 실행 시 값과 실제 호출 형태를 확인한다.
2. 원본에 써야 하는 코드인지 구분한다. part[0] = ..., fill(), write() 같은 쓰기뿐 아니라 부분 구간을 넘겨받는 함수가 입력을 수정하는지도 살핀다. 쓰기 함수 안에서 새 객체를 만들었다는 설명만으로 입력 바이트가 보존됐다고 결론 내리지 않는다.
3. 작은 합성 입력으로 변경 경계를 고정한다. 운영 원문을 로그에 남기지 말고, 위처럼 식별 가능한 숫자 바이트를 넣어 한 단계씩 통과시킨다. 가공 전 원본을 별도 복사해 두고 가공 후 값이 같은지 검사한다. 다르면 어느 호출에서 처음 바뀌는지 좁힌다.
4. 복사 후에도 전송 계약을 확인한다. 전송 함수가 입력을 즉시 복사하는지, 완료될 때까지 참조하는지는 해당 API의 계약으로 확인해야 한다. 복사본을 전송 함수에 넘긴 뒤 완료 전에 다시 수정하면 또 다른 문제가 생길 수 있다. 이 합성 검사만으로 전송 시점과 소유권까지 검증됐다고 말할 수는 없다.
같은 ArrayBuffer인지보다 실제 바이트 변경을 확인한다
작은 Buffer는 내부 메모리 풀을 이용할 수 있다. 그래서 두 객체의 .buffer가 같다는 관찰만으로 두 객체가 같은 바이트 범위를 공유한다고 단정하면 안 된다. 서로 떨어진 구간일 수 있다. 진단에서는 객체 동일성, 각 객체의 시작 위치와 길이, 겹치는 바이트에 대한 실제 쓰기 결과를 함께 본다.
이 글의 검사는 같은 스레드의 일반 Buffer와 Uint8Array를 대상으로 한다. 다른 스레드에서 동시에 수정하는 SharedArrayBuffer, 네이티브 확장, 사용자 정의 하위 클래스, 파일 포맷 자체의 유효성은 검사하지 않았다. 복사본이 생겼다는 사실은 이미지 인코딩이 올바르거나 저장·전송이 완료됐다는 증거가 아니다.
문자 경계에서 데이터가 깨지는 문제라면 UTF-8 청크 경계와 StringDecoder 진단을 함께 본다. 배열 정렬 뒤 캐시가 바뀌는 경우는 sort와 배열·객체 참조 진단이 대상이다. 이번 검사의 핵심은 바이트 구간의 소유권이다. 부분 값을 따로 담았다는 사실 대신, 그 값을 수정해도 원본을 보존한다는 검사를 남겨야 한다.