08청크 일시정지·재개 — 보낸 조각만큼만 이어 붙는 바
파일을 Blob.slice 로 고정 크기 조각으로 나눠 순서대로 보내다가 일시정지하면 마지막으로 확인된 조각 번호를 저장하고, 재개는 그 번호부터 다시 계산해 이어붙입니다.
쓰임대용량 백업 업로드, 모바일 네트워크 전환
Blob.slicefetchAbortController

<main class="cp is-demo" id="cpRoot">
<div class="cp__row">
<span class="cp__label">6개 조각 중 <b class="cp__done" id="cpDone">0</b>개 전송</span>
<button class="cp__toggle" id="cpToggle" type="button" aria-label="업로드 일시정지">
<svg class="cp__icon-pause" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><g fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"></rect><rect x="14" y="4" width="4" height="16" rx="1"></rect></g></svg>
<svg class="cp__icon-play" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"></path></svg>
</button>
</div>
<ul class="cp__segs" id="cpSegs" aria-hidden="true">
<li class="cp__seg"><span class="cp__segfill"></span></li>
<li class="cp__seg"><span class="cp__segfill"></span></li>
<li class="cp__seg"><span class="cp__segfill"></span></li>
<li class="cp__seg"><span class="cp__segfill"></span></li>
<li class="cp__seg"><span class="cp__segfill"></span></li>
<li class="cp__seg"><span class="cp__segfill"></span></li>
</ul>
<div class="cp__bar">
<span class="cp__meta">Blob.slice · fetch · AbortController</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 청크 일시정지·재개(08) — 6개 칸이 순서대로 채워지다가 중간에 멈추고(재생 아이콘으로 스왑),
// 다시 그 칸부터 이어 채워진다. 칸은 색 전환이 아니라 opacity 오버레이라 GPU 합성만 쓴다.
@property --n {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
@include stage($stage-paper);
$cp-text: $subject-ink;
$cp-fill: $subject-mint;
$duration: $dur-loop;
$easing: linear;
$color: $cp-fill;
.cp {
display: flex; flex-direction: column; gap: $sp-3;
width: 100%; height: 100%; min-height: 164px;
justify-content: center;
}
.cp__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.cp__label {
font-weight: 500; font-size: 11px; line-height: 1.3;
color: rgba($cp-text, .78);
}
.cp__done {
font-weight: 700; font-size: 11px;
color: $cp-text;
position: relative;
}
.is-demo .cp__done { color: transparent; counter-reset: cpn var(--n); }
.is-demo .cp__done::after {
content: counter(cpn);
position: absolute; inset: 0;
color: $cp-text;
}
.cp__toggle {
position: relative;
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px;
border: 0; border-radius: $r-pill;
background: rgba($cp-text, .1);
color: $cp-text;
cursor: pointer;
padding: 0;
}
.cp__icon-pause, .cp__icon-play { position: absolute; inset: 0; margin: auto; }
.cp__icon-play { opacity: 0; }
.cp.is-paused .cp__icon-pause { opacity: 0; }
.cp.is-paused .cp__icon-play { opacity: 1; }
.cp__segs {
display: flex; gap: $sp-2;
list-style: none; margin: 0; padding: 0;
}
.cp__seg {
position: relative;
flex: 1;
height: $sp-3;
border-radius: $r-xs;
background: rgba($cp-text, .14);
overflow: hidden;
}
.cp__segfill {
position: absolute; inset: 0;
border-radius: $r-xs;
background: $cp-fill;
opacity: 0;
}
.cp.is-paused .cp__seg { animation: cpPausePulse $dur-loop $ease-out infinite; }
.cp__bar { display: flex; justify-content: flex-end; }
.cp__meta { font-weight: 500; font-size: 10px; color: rgba($cp-text, .6); }
@keyframes cpPausePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba($cp-text, 0); }
50% { box-shadow: 0 0 0 2px rgba($cp-text, .18); }
}
// ── 데모 루프 — 3칸을 채우고 멈춘 뒤(재생 아이콘), 이어서 나머지 3칸을 채우고 리셋 ──
@mixin seg-reveal($on, $off) {
0% { opacity: 0; }
#{$on} { opacity: 1; animation-timing-function: steps(1, end); }
91% { opacity: 1; }
#{$off} { opacity: 0; animation-timing-function: steps(1, end); }
100% { opacity: 0; }
}
@keyframes cpSeg1 { @include seg-reveal(4%, 92%); }
@keyframes cpSeg2 { @include seg-reveal(10%, 92%); }
@keyframes cpSeg3 { @include seg-reveal(16%, 92%); }
@keyframes cpSeg4 { @include seg-reveal(60%, 92%); }
@keyframes cpSeg5 { @include seg-reveal(68%, 92%); }
@keyframes cpSeg6 { @include seg-reveal(76%, 92%); }
@keyframes cpCount {
0% { --n: 0; }
4% { --n: 1; animation-timing-function: steps(1, end); }
10% { --n: 2; animation-timing-function: steps(1, end); }
16% { --n: 3; animation-timing-function: steps(1, end); }
54% { --n: 3; }
60% { --n: 4; animation-timing-function: steps(1, end); }
68% { --n: 5; animation-timing-function: steps(1, end); }
76% { --n: 6; animation-timing-function: steps(1, end); }
88% { --n: 6; }
92% { --n: 0; animation-timing-function: steps(1, end); }
100% { --n: 0; }
}
@keyframes cpIconPause {
0% { opacity: 1; }
18% { opacity: 1; }
18.1% { opacity: 0; animation-timing-function: steps(1, end); }
54% { opacity: 0; animation-timing-function: steps(1, end); }
54.1% { opacity: 1; }
88% { opacity: 1; }
88.1% { opacity: 0; animation-timing-function: steps(1, end); }
100% { opacity: 0; }
}
@keyframes cpIconPlay {
0% { opacity: 0; }
18% { opacity: 0; }
18.1% { opacity: 1; animation-timing-function: steps(1, end); }
54% { opacity: 1; animation-timing-function: steps(1, end); }
54.1% { opacity: 0; }
88% { opacity: 0; }
88.1% { opacity: 1; animation-timing-function: steps(1, end); }
100% { opacity: 1; }
}
.is-demo .cp__seg:nth-child(1) .cp__segfill { animation: cpSeg1 $duration $easing infinite; }
.is-demo .cp__seg:nth-child(2) .cp__segfill { animation: cpSeg2 $duration $easing infinite; }
.is-demo .cp__seg:nth-child(3) .cp__segfill { animation: cpSeg3 $duration $easing infinite; }
.is-demo .cp__seg:nth-child(4) .cp__segfill { animation: cpSeg4 $duration $easing infinite; }
.is-demo .cp__seg:nth-child(5) .cp__segfill { animation: cpSeg5 $duration $easing infinite; }
.is-demo .cp__seg:nth-child(6) .cp__segfill { animation: cpSeg6 $duration $easing infinite; }
.is-demo .cp__done { animation: cpCount $duration steps(1, end) infinite; }
.is-demo .cp__icon-pause { animation: cpIconPause $duration linear infinite; }
.is-demo .cp__icon-play { animation: cpIconPlay $duration linear infinite; }
@media (max-width: 340px) {
.cp__toggle { width: 22px; height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .cp__segfill, .is-demo .cp__done, .is-demo .cp__icon-pause, .is-demo .cp__icon-play {
animation: none !important;
}
.is-demo .cp__seg:nth-child(-n+3) .cp__segfill { opacity: 1 !important; }
}@property --n {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 164px;
justify-content: center;
}
.cp__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cp__label {
font-weight: 500;
font-size: 11px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.78);
}
.cp__done {
font-weight: 700;
font-size: 11px;
color: #17141a;
position: relative;
}
.is-demo .cp__done {
color: transparent;
counter-reset: cpn var(--n);
}
.is-demo .cp__done::after {
content: counter(cpn);
position: absolute;
inset: 0;
color: #17141a;
}
.cp__toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
cursor: pointer;
padding: 0;
}
.cp__icon-pause, .cp__icon-play {
position: absolute;
inset: 0;
margin: auto;
}
.cp__icon-play {
opacity: 0;
}
.cp.is-paused .cp__icon-pause {
opacity: 0;
}
.cp.is-paused .cp__icon-play {
opacity: 1;
}
.cp__segs {
display: flex;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.cp__seg {
position: relative;
flex: 1;
height: 12px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
overflow: hidden;
}
.cp__segfill {
position: absolute;
inset: 0;
border-radius: 4px;
background: #4cd4a6;
opacity: 0;
}
.cp.is-paused .cp__seg {
animation: cpPausePulse 2s ease-out infinite;
}
.cp__bar {
display: flex;
justify-content: flex-end;
}
.cp__meta {
font-weight: 500;
font-size: 10px;
color: rgba(23, 20, 26, 0.6);
}
@keyframes cpPausePulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(23, 20, 26, 0);
}
50% {
box-shadow: 0 0 0 2px rgba(23, 20, 26, 0.18);
}
}
@keyframes cpSeg1 {
0% {
opacity: 0;
}
4% {
opacity: 1;
animation-timing-function: steps(1, end);
}
91% {
opacity: 1;
}
92% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpSeg2 {
0% {
opacity: 0;
}
10% {
opacity: 1;
animation-timing-function: steps(1, end);
}
91% {
opacity: 1;
}
92% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpSeg3 {
0% {
opacity: 0;
}
16% {
opacity: 1;
animation-timing-function: steps(1, end);
}
91% {
opacity: 1;
}
92% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpSeg4 {
0% {
opacity: 0;
}
60% {
opacity: 1;
animation-timing-function: steps(1, end);
}
91% {
opacity: 1;
}
92% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpSeg5 {
0% {
opacity: 0;
}
68% {
opacity: 1;
animation-timing-function: steps(1, end);
}
91% {
opacity: 1;
}
92% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpSeg6 {
0% {
opacity: 0;
}
76% {
opacity: 1;
animation-timing-function: steps(1, end);
}
91% {
opacity: 1;
}
92% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpCount {
0% {
--n: 0;
}
4% {
--n: 1;
animation-timing-function: steps(1, end);
}
10% {
--n: 2;
animation-timing-function: steps(1, end);
}
16% {
--n: 3;
animation-timing-function: steps(1, end);
}
54% {
--n: 3;
}
60% {
--n: 4;
animation-timing-function: steps(1, end);
}
68% {
--n: 5;
animation-timing-function: steps(1, end);
}
76% {
--n: 6;
animation-timing-function: steps(1, end);
}
88% {
--n: 6;
}
92% {
--n: 0;
animation-timing-function: steps(1, end);
}
100% {
--n: 0;
}
}
@keyframes cpIconPause {
0% {
opacity: 1;
}
18% {
opacity: 1;
}
18.1% {
opacity: 0;
animation-timing-function: steps(1, end);
}
54% {
opacity: 0;
animation-timing-function: steps(1, end);
}
54.1% {
opacity: 1;
}
88% {
opacity: 1;
}
88.1% {
opacity: 0;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
}
}
@keyframes cpIconPlay {
0% {
opacity: 0;
}
18% {
opacity: 0;
}
18.1% {
opacity: 1;
animation-timing-function: steps(1, end);
}
54% {
opacity: 1;
animation-timing-function: steps(1, end);
}
54.1% {
opacity: 0;
}
88% {
opacity: 0;
}
88.1% {
opacity: 1;
animation-timing-function: steps(1, end);
}
100% {
opacity: 1;
}
}
.is-demo .cp__seg:nth-child(1) .cp__segfill {
animation: cpSeg1 2s linear infinite;
}
.is-demo .cp__seg:nth-child(2) .cp__segfill {
animation: cpSeg2 2s linear infinite;
}
.is-demo .cp__seg:nth-child(3) .cp__segfill {
animation: cpSeg3 2s linear infinite;
}
.is-demo .cp__seg:nth-child(4) .cp__segfill {
animation: cpSeg4 2s linear infinite;
}
.is-demo .cp__seg:nth-child(5) .cp__segfill {
animation: cpSeg5 2s linear infinite;
}
.is-demo .cp__seg:nth-child(6) .cp__segfill {
animation: cpSeg6 2s linear infinite;
}
.is-demo .cp__done {
animation: cpCount 2s steps(1, end) infinite;
}
.is-demo .cp__icon-pause {
animation: cpIconPause 2s linear infinite;
}
.is-demo .cp__icon-play {
animation: cpIconPlay 2s linear infinite;
}
@media (max-width: 340px) {
.cp__toggle {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .cp__segfill, .is-demo .cp__done, .is-demo .cp__icon-pause, .is-demo .cp__icon-play {
animation: none !important;
}
.is-demo .cp__seg:nth-child(-n+3) .cp__segfill {
opacity: 1 !important;
}
}// 청크 일시정지·재개 — 파일을 Blob.slice 로 고정 크기 조각으로 나눠 순서대로 fetch 로 보낸다.
// 일시정지는 AbortController 로 진행 중인 요청만 끊고 마지막으로 확인된 조각 번호를 저장하며,
// 재개는 그 번호부터 계산을 다시 시작해 이어붙인다(처음부터 다시 보내지 않는다).
(function () {
var SEED = 0x2f81dd;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = mulberry32(SEED);
var CHUNK_BYTES = 256 * 1024;
var TOTAL_CHUNKS = 6;
var root = document.getElementById('cpRoot');
var toggle = document.getElementById('cpToggle');
var segs = document.getElementById('cpSegs');
var doneEl = document.getElementById('cpDone');
if (!root || !toggle || !segs || !doneEl) return;
var blob = new Blob([new Uint8Array(CHUNK_BYTES * TOTAL_CHUNKS)]);
var segEls = segs.querySelectorAll('.cp__seg');
var lastConfirmed = 0, paused = false, controller = null, running = false;
function delay(ms) { return new Promise(function (res) { setTimeout(res, ms); }); }
function paint(n) {
doneEl.textContent = String(n);
for (var i = 0; i < segEls.length; i++) segEls[i].classList.toggle('is-filled', i < n);
}
async function sendFrom(startIdx) {
running = true;
controller = new AbortController();
for (var i = startIdx; i < TOTAL_CHUNKS; i++) {
if (paused) { running = false; return; }
var chunk = blob.slice(i * CHUNK_BYTES, (i + 1) * CHUNK_BYTES);
try {
await fetch(location.href, { method: 'HEAD', signal: controller.signal });
} catch (err) {
// 이 데모엔 실제 업로드 서버가 없다 — 실패해도 다음 조각으로 진행한다.
}
if (paused) { running = false; return; }
lastConfirmed = i + 1;
paint(lastConfirmed);
await delay(160 + rand() * 140);
}
running = false;
}
toggle.addEventListener('click', function () {
if (root.classList.contains('is-demo')) root.classList.remove('is-demo');
if (running) {
paused = true;
root.classList.add('is-paused');
if (controller) controller.abort();
} else {
paused = false;
root.classList.remove('is-paused');
sendFrom(lastConfirmed);
}
});
['pointerdown', 'focusin'].forEach(function (ev) {
root.addEventListener(ev, function () {
if (!root.classList.contains('is-demo')) return;
root.classList.remove('is-demo');
sendFrom(0);
});
});
})();// UploadProgressChunkPauseResume — vanilla 판(run/329/demo/08-upload-progress-chunk-pause-resume/index.html) 의 React 포트.
// 파일을 Blob.slice 로 고정 크기 조각으로 나눠 순서대로 fetch 로 보낸다. 일시정지는 AbortController 로
// 진행 중인 요청만 끊고 마지막으로 확인된 조각 번호를 저장하며, 재개는 그 번호부터 다시 시작해 이어붙인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UploadProgressChunkPauseResume.module.scss";
export type UploadProgressChunkPauseResumeProps = {
duration?: string;
easing?: string;
color?: string;
seed?: number; // mulberry32 시드
chunkBytes?: number; // 조각 하나의 크기(바이트)
totalChunks?: number; // 총 조각 수
delayBase?: number; // 조각당 기본 지연(ms)
delayJitter?: number; // 조각당 랜덤 지연 상한(ms)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const LABEL_PREFIX = "6개 조각 중 ";
const LABEL_SUFFIX = "개 전송";
const TOGGLE_LABEL = "업로드 일시정지";
const META = "Blob.slice · fetch · AbortController";
const SEED = 0x2f81dd;
const CHUNK_BYTES = 256 * 1024;
const TOTAL_CHUNKS = 6;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
function delay(ms: number): Promise<void> {
return new Promise((res) => { setTimeout(res, ms); });
}
export default function UploadProgressChunkPauseResume({
duration = "2s",
easing = "linear",
color = "#38b48b",
seed = SEED,
chunkBytes = CHUNK_BYTES,
totalChunks = TOTAL_CHUNKS,
delayBase = 160,
delayJitter = 140
}: UploadProgressChunkPauseResumeProps) {
const [demo, setDemo] = useState(true);
const [paused, setPaused] = useState(false);
const [done, setDone] = useState(0);
const randRef = useRef<(() => number) | null>(null);
const blobRef = useRef<Blob | null>(null);
const lastConfirmedRef = useRef(0) as Box<number>;
const pausedRef = useRef(false) as Box<boolean>;
const runningRef = useRef(false) as Box<boolean>;
const controllerRef = useRef<AbortController | null>(null);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
useEffect(() => {
blobRef.current = new Blob([new Uint8Array(chunkBytes * totalChunks)]);
return () => {
if (controllerRef.current) controllerRef.current.abort();
};
}, [chunkBytes, totalChunks]);
async function sendFrom(startIdx: number): Promise<void> {
runningRef.current = true;
const controller = new AbortController();
controllerRef.current = controller;
const rand = randRef.current!;
const blob = blobRef.current!;
for (let i = startIdx; i < totalChunks; i++) {
if (pausedRef.current) { runningRef.current = false; return; }
const chunk = blob.slice(i * chunkBytes, (i + 1) * chunkBytes);
void chunk;
try {
await fetch(location.href, { method: "HEAD", signal: controller.signal });
} catch (err) {
// 이 데모엔 실제 업로드 서버가 없다 — 실패해도 다음 조각으로 진행한다.
}
if (pausedRef.current) { runningRef.current = false; return; }
lastConfirmedRef.current = i + 1;
setDone(lastConfirmedRef.current);
await delay(delayBase + rand() * delayJitter);
}
runningRef.current = false;
}
function onToggle(): void {
if (demo) setDemo(false);
if (runningRef.current) {
pausedRef.current = true;
setPaused(true);
if (controllerRef.current) controllerRef.current.abort();
} else {
pausedRef.current = false;
setPaused(false);
if (!randRef.current) randRef.current = mulberry32(seed);
void sendFrom(lastConfirmedRef.current);
}
}
function live(): void {
if (!demo) return;
setDemo(false);
randRef.current = mulberry32(seed);
void sendFrom(0);
}
const rootCls = [styles.cp, demo ? styles.isDemo : "", paused ? styles.isPaused : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div
className={rootCls}
style={vars}
role="group"
tabIndex={0}
aria-label="파일 업로드 조각 일시정지와 재개"
onPointerDown={live}
onFocus={live}
>
<div className={styles.row}>
<span className={styles.label}>
{LABEL_PREFIX}
<b className={styles.done}>{done}</b>
{LABEL_SUFFIX}
</span>
<button className={styles.toggle} type="button" aria-label={TOGGLE_LABEL} onClick={onToggle}>
<svg className={styles.iconPause} viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><g fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"></rect><rect x="14" y="4" width="4" height="16" rx="1"></rect></g></svg>
<svg className={styles.iconPlay} viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"></path></svg>
</button>
</div>
<ul className={styles.segs} aria-hidden="true">
{Array.from({ length: totalChunks }, (_, i) => (
<li key={i} className={done > i ? `${styles.seg} is-filled` : styles.seg}>
<span className={styles.segfill}></span>
</li>
))}
</ul>
<div className={styles.bar}>
<span className={styles.meta}>{META}</span>
</div>
</div>
</main>
);
}08 청크 일시정지·재개 — 보낸 조각만큼만 이어 붙는 바 — 측정 누적면적 3.703% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 파일 업로드 진행률 로더 9종 — 드래그부터 체크까지 계산
이 데모가 속한 글
파일 업로드 진행률 로더 9종 — 드래그부터 체크까지 계산파일 업로드 진행률 UI는 계산으로 그리는 로더입니다. 파일을 끌어다 놓으면 바이트와 시간을 계산해 링이 돌고 속도가 표시되고 끝나면 체크로 바뀝니다. 9가지 상태를 라이브러리 없이 계산으로 만드는 로더 — 코드는 이 글의 데모 전부 보기

