-
Web Worker에 ArrayBuffer를 보냈더니 원본을 못 쓰는 이유Programming 2026. 9. 21. 12:28728x90반응형

데이터의 소유권이 한쪽으로 이동하는 모습을 비유한 AI 생성 이미지입니다. ArrayBuffer를 transfer할 때는 전송 후 원본을 계속 쓸지 먼저 정하고 소유권이 이동하는 경계를 코드에 드러내세요.
워커에 데이터를 보냈는데 원래 배열의 길이가 0처럼 보이거나 버퍼를 다시 사용하는 코드에서 오류가 납니다. 데이터를 보냈으니 복사본이 하나 생겼다고 생각했다면 당황스럽지만, transfer를 사용했다면 의도된 소유권 이동일 수 있습니다.
중요한 질문은 “전송이 성공했나”에서 끝나지 않습니다. 지금 누가 그 메모리를 사용할 수 있는지, 같은 버퍼를 보던 다른 배열은 어떻게 됐는지까지 확인해야 합니다. 이 글은 2026년 9월 13일 MDN 기준으로 복제와 이동을 설명합니다. 작은 코드는 의미를 보여 주는 예시이며 실제 워커 간 통신이나 성능 측정 결과가 아닙니다.
TypedArray는 데이터를 보는 창이고 버퍼는 그 바탕입니다
Uint8Array 같은 TypedArray는 바이트를 특정 형식으로 읽고 쓰는 뷰입니다. 그 아래에 실제 바이트를 담는 ArrayBuffer가 있습니다. 여러 뷰가 하나의 버퍼를 함께 볼 수 있으므로 배열 변수 하나만 추적하면 소유권 변화를 놓칠 수 있습니다.
일반적인 메시지 전달에서는 복제 가능한 값이 구조화된 복제 방식으로 전달됩니다. 하지만 transfer 목록에 ArrayBuffer를 넣으면 해당 자원의 소유권을 받는 쪽으로 넘깁니다. 보낸 쪽의 버퍼는 분리된 상태인 detached가 되어 예전처럼 사용할 수 없습니다. 복제와 전송 가능한 자원
TypedArray 자체는 직렬화할 수 있지만 transfer 목록에 넣을 대상은 그 아래의 버퍼입니다. 메시지에는 뷰를 넣고 전송 목록에는 뷰의 buffer를 넣는 코드가 나오는 이유입니다.
이 선택은 단순한 속도 옵션이 아닙니다. 전송 뒤에도 미리보기나 재시도에 원본을 써야 한다면 소유권을 넘기는 코드가 다른 기능을 깨뜨릴 수 있습니다. 복제 비용을 줄이는 목적과 원본 보존 요구를 함께 비교해야 합니다.
작은 값으로 복제와 이동을 나눠 봅니다
워커를 만들지 않고도 structuredClone으로 두 의미를 비교할 수 있습니다. 다음은 각각 독립된 예시입니다. structuredClone의 기본 동작은 복제이고, transfer 옵션을 지정하면 해당 자원을 이동할 수 있습니다. structuredClone의 transfer 옵션
먼저 원본을 유지하는 복제입니다.
const original = new Uint8Array([5, 15, 25, 35]); const copied = structuredClone(original); copied[0] = 99; console.assert(original[0] === 5); console.assert(copied[0] === 99); console.assert(original.buffer !== copied.buffer);복사한 배열의 첫 값을 바꿔도 원본의 첫 값은 그대로라는 조건을 확인합니다. 두 변수에 같은 뷰를 대입하는 별칭과 다르게, 이 예시에서는 별도의 바이트 저장소를 갖게 됩니다.
다음은 소유권을 이동하는 경우입니다.
const original = new Uint8Array([5, 15, 25, 35]); const oldBuffer = original.buffer; const moved = structuredClone(original, { transfer: [oldBuffer] }); console.assert(moved[0] === 5); console.assert(moved.byteLength === 4); console.assert(oldBuffer.byteLength === 0);받은 moved에는 데이터가 있지만 oldBuffer는 분리됩니다. 원래 변수가 스코프에 남아 있다는 사실과 그 변수가 가리키던 자원을 계속 사용할 수 있다는 사실은 다릅니다. 이 뒤에 original을 미리보기 렌더러에 다시 넘기는 흐름은 계약을 바꿔야 합니다.
byteLength가 0이라는 값만으로 모든 상황에서 detached를 판정하지는 않습니다. 처음부터 길이 0으로 만든 정상 버퍼도 있기 때문입니다. 지원하는 런타임에서는 ArrayBuffer의 detached 속성으로 분리 여부를 읽을 수 있습니다. 이 속성의 지원 여부는 대상 환경에서 확인합니다. ArrayBuffer.detached
subarray의 두 칸을 보낸다고 두 칸만 이동하지는 않습니다
전체 데이터 중 일부만 워커에 보내려다 생기는 실수도 있습니다. subarray는 새 뷰를 만들지만 바이트를 별도로 복사하지 않습니다. 원래 배열과 같은 버퍼를 사용합니다. TypedArray.subarray
다음은 가운데 두 값만 보는 뷰를 만든 경우입니다.
const whole = new Uint8Array([5, 15, 25, 35]); const middle = whole.subarray(1, 3); console.assert(middle.buffer === whole.buffer); const received = structuredClone(middle, { transfer: [middle.buffer], }); console.assert(received.length === 2); console.assert(received[0] === 15); console.assert(whole.buffer.byteLength === 0);수신한 뷰의 길이는 두 칸이어도 이동한 자원은 middle과 whole이 공유하던 전체 버퍼입니다. 보낸 쪽의 whole도 사용할 수 없게 된다는 점이 핵심입니다. 다른 뷰가 같은 버퍼를 참조하고 있다면 그쪽도 영향을 받습니다.
또한 수신 측은 전달받은 뷰의 buffer를 통해 전체 바이트 저장소에 접근할 수 있습니다. 일부 뷰만 건넸다는 이유로 바깥 구간을 숨겼다고 생각해서는 안 됩니다. 이 예시에서 subarray는 접근 범위를 편리하게 표현하는 도구이지 데이터 공개 범위를 제한하는 보안 장치가 아닙니다.
실제 앱에서는 썸네일을 만드는 코드와 원본을 내보내는 코드가 같은 큰 버퍼를 볼 수 있습니다. 일부 처리 작업이 transfer를 쓰기 시작했다면 그 호출부뿐 아니라 같은 버퍼를 쓰는 다른 경로도 확인해야 합니다.
필요한 구간만 보내려면 별도 버퍼를 만듭니다
원본 전체는 보존하고 가운데 두 값만 넘기려면 이 예시에서는 slice로 별도 복사본을 만들 수 있습니다. TypedArray의 slice는 지정 범위의 값을 새 TypedArray로 복사합니다. subarray와 이름이 비슷하지만 저장소 관계는 다릅니다. TypedArray.slice
const whole = new Uint8Array([5, 15, 25, 35]); const part = whole.slice(1, 3); const received = structuredClone(part, { transfer: [part.buffer] }); console.assert(whole.length === 4); console.assert(whole[1] === 15); console.assert(received.length === 2); console.assert(received.buffer.byteLength === 2);이제 이동하는 것은 part의 별도 버퍼이므로 whole은 그대로 남습니다. 필요한 두 값만 담긴 버퍼를 만들었기 때문에 수신 측이 그 버퍼에서 바깥 두 값을 읽을 수도 없습니다.
대신 복사 비용이 생깁니다. transfer를 사용했다는 이유만으로 전체 파이프라인이 복사 없이 처리됐다고 말하면 안 됩니다. 앞에서 slice로 데이터를 복사했다는 사실을 비용 계산에 포함해야 합니다.
선택은 목적에 따라 달라집니다. 큰 원본을 더 이상 쓰지 않는다면 전체 소유권 이동이 단순할 수 있습니다. 원본을 유지하며 일부만 처리해야 한다면 별도 복사가 필요할 수 있습니다. 필요한 범위와 이후 사용자를 먼저 정하면 최적화 때문에 원본이 사라지는 사고를 줄일 수 있습니다.
워커 메시지에는 데이터와 이동 대상을 함께 적습니다
실제 워커로 보낼 때의 핵심 형태는 다음과 같습니다. worker는 이미 생성돼 있고, pixels는 이 작업 뒤 송신 측에서 다시 사용하지 않을 Uint8Array라는 전제입니다. 이 코드는 전송 호출의 일부만 보여 줍니다.
worker.postMessage( { jobId: "preview-1", pixels }, [pixels.buffer], );첫 인자는 받는 쪽이 사용할 메시지이고 두 번째 인자는 소유권을 이동할 자원 목록입니다. 목록에 버퍼를 적더라도 메시지에서 그 자원에 접근할 길이 없다면 받는 쪽에서 원하는 데이터를 얻을 수 없습니다. postMessage의 transfer 목록
함수 이름에도 소유권 계약을 드러내는 편이 좋습니다. 예를 들어 입력을 소비하는 함수라면 호출 뒤 다시 읽지 않아야 한다는 설명을 남기고, 코드 리뷰에서는 재시도·다운로드·다른 뷰의 사용 여부를 함께 확인합니다. 언어가 소유권 이동을 정적으로 막아 주는 것처럼 가정하지 않습니다.
메시지를 보냈다는 사실은 계산 완료도 아닙니다. 워커가 해당 작업 번호에 대한 결과를 돌려줬는지, 오류가 발생했는지 별도로 처리해야 합니다. 버퍼 이동과 작업 완료라는 두 상태를 하나로 합치지 않습니다.
버퍼를 돌려받아도 예전 객체가 살아나는 것은 아닙니다
워커가 결과 버퍼를 다시 transfer하면 메인 쪽은 새로 받은 메시지의 버퍼나 뷰를 사용해야 합니다. 처음 보낼 때 detached가 된 oldBuffer 객체가 자동으로 다시 연결되는 방식으로 생각하면 안 됩니다. detached가 된 버퍼는 더 이상 사용할 수 없다는 상태가 유지됩니다. 분리된 버퍼의 상태
따라서 화면 상태에는 새로 받은 뷰를 반영합니다. 버퍼 풀을 운영하더라도 “작업에 넘김 → 응답으로 받음 → 다음 작업에 사용”의 소유자를 명시하고, 보내기 전 객체 참조를 계속 재사용하지 않습니다.
SharedArrayBuffer를 사용해 공유 메모리를 설계하는 것은 다른 선택입니다. transfer 예시를 단어만 바꿔 적용할 수 있는 해결책이 아니며 동시 접근과 동기화 조건을 별도로 다뤄야 합니다. 이 글에서는 일반 ArrayBuffer의 복제와 소유권 이동만 다룹니다.
원본이 필요한 순간을 먼저 찾습니다
전송 후 오류가 났다면 보내기 직전의 뷰와 buffer 관계, 전송 목록, 전송 이후 원본을 읽는 코드를 차례로 봅니다. subarray를 썼다면 그 뷰가 새 메모리를 가진다는 가정부터 확인합니다. 받은 데이터가 정상이라는 사실만으로 송신 측의 이후 사용까지 올바르다고 판단하지 않습니다.
복제와 transfer 중 하나가 항상 더 좋은 것은 아닙니다. 원본을 유지해야 하는 기능, 이동할 실제 바이트 범위, 결과를 받을 때의 소유자와 메모리 비용이 선택 기준입니다. 이 계약을 코드에 드러내면 전송 후 원본을 못 쓰는 현상이 예상한 동작인지, 필요한 데이터를 너무 일찍 넘긴 설계 오류인지 구분할 수 있습니다.
728x90반응형'Programming' 카테고리의 다른 글
취소 버튼을 누르면 서버 작업도 취소될까: AbortController의 범위 (0) 2026.09.22 SSE 연결이 다시 붙을 때 메시지가 중복되는 이유 (0) 2026.09.21 큰 CSV를 처리할 때 웹 화면이 멈춘다면: Web Worker로 옮길 일 (0) 2026.09.21 뒤로 가기했더니 예전 화면이 그대로 나타나는 이유: bfcache (0) 2026.09.21 304 Not Modified는 오류일까: ETag로 갱신 여부 읽기 (1) 2026.09.20