09랜덤 슬롯 뽑기
이름 줄이 빠르게 흘러가다 멈춘 자리의 이름을 뽑는 picker 입니다. 후보 목록은 저장되어 새로고침해도 그대로입니다.
쓰임댓글 이벤트 당첨자 뽑기, 발표자 정하기
animation-timing-functionstepsaria-live

<main class="stage">
<div class="sl is-demo" id="sl">
<header class="sl__bar">
<p class="sl__title">랜덤 슬롯 뽑기</p>
<p class="sl__count"><b class="sl__countNum" id="slCount">8</b><span class="sl__countLabel">명 후보</span></p>
</header>
<div class="sl__body">
<div class="sl__window">
<div class="sl__track" id="slTrack"></div>
<span class="sl__frame" aria-hidden="true"></span>
</div>
<div class="sl__side">
<button class="sl__btn sl__btn--go" id="slGo" type="button">뽑기</button>
<button class="sl__btn" id="slDrop" type="button">당첨자 빼기</button>
<button class="sl__btn" id="slReset" type="button">후보 되돌리기</button>
</div>
</div>
<div class="sl__out">
<p class="sl__banner" id="slBanner" aria-live="polite">뽑기를 누르면 시작합니다</p>
<p class="sl__ghost sl__ghost--a" aria-hidden="true">돌리는 중</p>
<p class="sl__ghost sl__ghost--b" aria-hidden="true">서연 당첨!</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 랜덤 슬롯 뽑기 — 세 칸짜리 창(overflow: hidden) 안에서 이름 트랙이 칸 단위로 움직인다.
// 칸 높이는 --row 하나로 잡고 창 높이·강조 틀·미리보기 이동거리가 전부 그 값을 쓴다.
// 미리보기 루프는 후보 수(--rows)만큼 정확히 올라가므로 한 바퀴 뒤 이음새가 튀지 않는다.
// 실제 뽑기는 JS 가 transform 을 걸고 transition 이 감속한다 — CSS 시계로 재는 격자 미리보기와는 별개 경로다.
// 글자 대비: 먹 무대 위 글자는 크림(17.11:1), 크림 창 안 이름은 잉크(17.11:1), 노란 배너 위는 잉크(12.63:1).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
// 토큰 이징에는 슬롯이 필요한 긴 감속 꼬리가 없다 — 이 항목 전용으로 하나 더 둔다
$reel-ease: cubic-bezier(.1, .7, .1, 1);
@include stage($stage-ink);
.sl {
--row: clamp(24px, 9vw, 44px);
--rows: 8;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 400px; max-height: 100%;
}
.sl__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sl__title { margin: 0; font-weight: 800; font-size: clamp(11px, 2.8vw, 15px); color: $subject-cream; }
.sl__count { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 12px); color: $subject-cream; }
.sl__countNum { font-weight: 800; font-size: clamp(11px, 2.8vw, 14px); color: $subject-cream; }
.sl__countLabel { font-weight: 700; }
.sl__body { display: flex; align-items: center; gap: clamp(8px, 3vw, 16px); }
.sl__window {
position: relative; flex: 0 0 auto;
width: clamp(104px, 34vw, 160px); height: calc(var(--row) * 3); // 창 높이는 칸 높이의 정수배
overflow: hidden; border-radius: $r-card;
background-color: $subject-cream;
}
// 실제 뽑기(감속 중)에는 칸이 창 경계에 걸린다 — 위아래를 크림으로 흐려 글자가 잘린 채 끝나지 않게
.sl__window::before,
.sl__window::after {
content: ""; position: absolute; left: 0; right: 0; z-index: 1;
height: 14%; pointer-events: none;
}
.sl__window::before { top: 0; background-image: linear-gradient($subject-cream, rgba($subject-cream, 0)); }
.sl__window::after { bottom: 0; background-image: linear-gradient(rgba($subject-cream, 0), $subject-cream); }
.sl__track {
display: flex; flex-direction: column;
transition-property: transform;
transition-duration: 2200ms;
transition-timing-function: $reel-ease;
}
.sl__item {
display: grid; place-items: center;
height: var(--row);
font-weight: 800; font-size: clamp(12px, 3.4vw, 18px); line-height: 1;
color: $subject-ink;
}
// 당첨 자리는 **가운데 밴드 하나**로 표시한다 — 선 두 줄은 어느 칸이 당첨인지 모호했다 (poster 육안 검사 2026-09-14).
// 테두리는 inset box-shadow 라 box-sizing 과 무관하게 칸 높이를 어긋나게 하지 않는다
.sl__frame {
position: absolute; left: 0; right: 0; top: var(--row); height: var(--row); z-index: 2;
box-sizing: border-box; border-radius: $r-sm;
background-color: rgba($subject-blue-deep, .14);
box-shadow: inset 0 0 0 2px $color;
pointer-events: none;
}
.sl__side { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: $sp-2; }
.sl__btn {
padding: clamp(8px, 2vw, 12px) $sp-3;
border: 2px solid $subject-cream; border-radius: $r-pill;
background: none; color: $subject-cream;
font-weight: 700; font-size: clamp(10px, 2.6vw, 13px); line-height: 1;
cursor: pointer;
transition: transform $dur-quick $easing;
}
.sl__btn:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.sl__btn:active { transform: scale(.97); }
.sl__btn--go {
border-color: $color;
background-color: $color; color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 15px);
}
.sl__btn[disabled] { cursor: default; opacity: .82; }
.sl__out { display: grid; }
.sl__banner, .sl__ghost {
grid-area: 1 / 1; justify-self: start; margin: 0;
padding: $sp-1 $sp-3; border-radius: $r-pill;
font-weight: 700; font-size: clamp(10px, 2.6vw, 13px); line-height: 1.3;
color: $subject-cream;
}
.sl__banner.is-win { background-color: $stage-yellow; color: $subject-ink; }
.sl__ghost { opacity: 0; }
.sl__ghost--b { background-color: $stage-yellow; color: $subject-ink; }
// ── 격자 미리보기 ───────────────────────────────────────────
// 트랙이 2초에 정확히 후보 수만큼(= 한 벌) 올라간다 — 벌이 넷이라 이음새가 안 보인다.
// 칸 수만큼의 steps 로 끊어 올리므로 **모든 프레임에서 칸이 창에 딱 맞는다** — 글자가 잘린 채 찍히지 않는다
.sl.is-demo .sl__track {
animation-name: slRoll; animation-duration: $duration;
animation-timing-function: steps(var(--rows), end); animation-iteration-count: infinite;
}
.sl.is-demo .sl__banner {
animation-name: slBannerOff; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.sl.is-demo .sl__ghost--a {
animation-name: slGhostA; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.sl.is-demo .sl__ghost--b {
animation-name: slGhostB; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
// 0~62% 에 한 벌을 올리고 62% 뒤에는 **멈춰 선다** — 발표 구간에 릴이 계속 돌면
// 정지 프레임(poster·썸네일)에서 가운데 칸과 발표 이름이 어긋난다.
// 한 벌(= --rows 칸)을 올려 멈추므로 가운데 칸은 다시 목록 두 번째(서연)다
@keyframes slRoll {
0% { transform: translateY(0); }
62% { transform: translateY(calc(var(--row) * var(--rows) * -1)); }
100% { transform: translateY(calc(var(--row) * var(--rows) * -1)); }
}
@keyframes slBannerOff {
0% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes slGhostA {
0% { opacity: 1; }
62% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes slGhostB {
0% { opacity: 0; }
62% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.sl { gap: $sp-1; }
.sl__side { gap: $sp-1; }
.sl__btn { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sl__track { transition: none !important; }
.sl__btn { transition: none !important; }
.sl.is-demo .sl__track,
.sl.is-demo .sl__banner,
.sl.is-demo .sl__ghost { animation: none !important; }
.sl.is-demo .sl__ghost { opacity: 0 !important; }
.sl.is-demo .sl__banner { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sl {
--row: clamp(24px, 9vw, 44px);
--rows: 8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 400px;
max-height: 100%;
}
.sl__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sl__title {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 15px);
color: #fff7e6;
}
.sl__count {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 12px);
color: #fff7e6;
}
.sl__countNum {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 14px);
color: #fff7e6;
}
.sl__countLabel {
font-weight: 700;
}
.sl__body {
display: flex;
align-items: center;
gap: clamp(8px, 3vw, 16px);
}
.sl__window {
position: relative;
flex: 0 0 auto;
width: clamp(104px, 34vw, 160px);
height: calc(var(--row) * 3);
overflow: hidden;
border-radius: 16px;
background-color: #fff7e6;
}
.sl__window::before,
.sl__window::after {
content: "";
position: absolute;
left: 0;
right: 0;
z-index: 1;
height: 14%;
pointer-events: none;
}
.sl__window::before {
top: 0;
background-image: linear-gradient(#fff7e6, rgba(255, 247, 230, 0));
}
.sl__window::after {
bottom: 0;
background-image: linear-gradient(rgba(255, 247, 230, 0), #fff7e6);
}
.sl__track {
display: flex;
flex-direction: column;
transition-property: transform;
transition-duration: 2200ms;
transition-timing-function: cubic-bezier(0.1, 0.7, 0.1, 1);
}
.sl__item {
display: grid;
place-items: center;
height: var(--row);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 18px);
line-height: 1;
color: #17141a;
}
.sl__frame {
position: absolute;
left: 0;
right: 0;
top: var(--row);
height: var(--row);
z-index: 2;
box-sizing: border-box;
border-radius: 8px;
background-color: rgba(10, 79, 232, 0.14);
box-shadow: inset 0 0 0 2px #0a4fe8;
pointer-events: none;
}
.sl__side {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.sl__btn {
padding: clamp(8px, 2vw, 12px) 12px;
border: 2px solid #fff7e6;
border-radius: 999px;
background: none;
color: #fff7e6;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 13px);
line-height: 1;
cursor: pointer;
transition: transform 220ms ease-out;
}
.sl__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.sl__btn:active {
transform: scale(0.97);
}
.sl__btn--go {
border-color: #0a4fe8;
background-color: #0a4fe8;
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 15px);
}
.sl__btn[disabled] {
cursor: default;
opacity: 0.82;
}
.sl__out {
display: grid;
}
.sl__banner, .sl__ghost {
grid-area: 1/1;
justify-self: start;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 13px);
line-height: 1.3;
color: #fff7e6;
}
.sl__banner.is-win {
background-color: #ffd23f;
color: #17141a;
}
.sl__ghost {
opacity: 0;
}
.sl__ghost--b {
background-color: #ffd23f;
color: #17141a;
}
.sl.is-demo .sl__track {
animation-name: slRoll;
animation-duration: 2s;
animation-timing-function: steps(var(--rows), end);
animation-iteration-count: infinite;
}
.sl.is-demo .sl__banner {
animation-name: slBannerOff;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sl.is-demo .sl__ghost--a {
animation-name: slGhostA;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sl.is-demo .sl__ghost--b {
animation-name: slGhostB;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes slRoll {
0% {
transform: translateY(0);
}
62% {
transform: translateY(calc(var(--row) * var(--rows) * -1));
}
100% {
transform: translateY(calc(var(--row) * var(--rows) * -1));
}
}
@keyframes slBannerOff {
0% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes slGhostA {
0% {
opacity: 1;
}
62% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes slGhostB {
0% {
opacity: 0;
}
62% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sl {
gap: 4px;
}
.sl__side {
gap: 4px;
}
.sl__btn {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sl__track {
transition: none !important;
}
.sl__btn {
transition: none !important;
}
.sl.is-demo .sl__track,
.sl.is-demo .sl__banner,
.sl.is-demo .sl__ghost {
animation: none !important;
}
.sl.is-demo .sl__ghost {
opacity: 0 !important;
}
.sl.is-demo .sl__banner {
opacity: 1 !important;
}
}// 랜덤 슬롯 — 후보 이름을 네 벌 이어 붙인 세로 트랙을 칸 단위로 밀어 올린다.
// 창은 세 칸이고 가운데 칸이 당첨 자리다. 감속은 CSS transition 이 맡고 JS 는 목표 칸만 정한다.
// 멈춘 뒤 offset 을 후보 수로 나눈 나머지로 되돌려 놓아 트랙이 끝으로 밀려나지 않게 한다.
// 후보 목록은 localStorage 키 하나(p080-09)에 JSON 으로 넣는다.
(function () {
var KEY = 'p080-09';
var BASE = ['지민', '서연', '도윤', '하은', '민준', '수아', '예준', '유나'];
var WIN = '{n} 당첨!';
var SPINNING = '돌리는 중';
var HELP = '뽑기를 누르면 시작합니다';
var root = document.getElementById('sl');
var track = document.getElementById('slTrack');
var countEl = document.getElementById('slCount');
var banner = document.getElementById('slBanner');
var goBtn = document.getElementById('slGo');
var dropBtn = document.getElementById('slDrop');
var resetBtn = document.getElementById('slReset');
var list, offset = 0, busy = false, winner = '';
function load() {
try {
var raw = JSON.parse(window.localStorage.getItem(KEY) || 'null');
if (raw && raw.names && raw.names.length > 1) { return raw.names.slice(); }
} catch (e) { /* 저장소가 막힌 창은 기본 후보로 */ }
return BASE.slice();
}
function save() {
try { window.localStorage.setItem(KEY, JSON.stringify({ names: list })); } catch (e) { /* 건너뛴다 */ }
}
function put(o) { track.style.transform = 'translateY(calc(var(--row) * ' + (-o) + '))'; }
function build() {
track.textContent = '';
for (var c = 0; c < 4; c++) {
for (var i = 0; i < list.length; i++) {
var el = document.createElement('div');
el.className = 'sl__item';
el.textContent = list[i];
track.appendChild(el);
}
}
root.style.setProperty('--rows', String(list.length));
countEl.textContent = String(list.length);
offset = 0;
put(0);
}
function idle() {
banner.textContent = HELP;
banner.classList.remove('is-win');
winner = '';
}
function spin() {
if (busy || list.length < 2) { return; }
root.classList.remove('is-demo');
busy = true;
goBtn.disabled = true;
banner.textContent = SPINNING;
banner.classList.remove('is-win');
offset += list.length + Math.floor(Math.random() * list.length);
put(offset);
}
track.addEventListener('transitionend', function (e) {
if (e.propertyName !== 'transform') { return; }
offset = offset % list.length;
winner = list[(offset + 1) % list.length];
track.style.transitionProperty = 'none';
put(offset);
void track.offsetHeight; // 리플로를 한 번 강제해 되돌림이 애니메이션되지 않게
track.style.transitionProperty = 'transform';
banner.textContent = WIN.replace('{n}', winner);
banner.classList.add('is-win');
busy = false;
goBtn.disabled = false;
});
goBtn.addEventListener('click', spin);
dropBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = list.indexOf(winner);
if (busy || i < 0 || list.length < 3) { return; }
list.splice(i, 1);
save();
build();
idle();
});
resetBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (busy) { return; }
list = BASE.slice();
save();
build();
idle();
});
list = load();
build();
})();// RandomSlotPicker — vanilla 판(run/080/demo/09-random-slot-picker/index.html) 의 React 포트.
// 후보 이름을 네 벌 이어 붙인 세로 트랙을 칸 단위로 밀어 올린다. 창은 세 칸이고 가운데가 당첨 자리다.
// 멈춘 뒤 offset 을 후보 수로 나눈 나머지로 되돌리는데, 그 한 번만 transition 을 끊어야 한다 —
// vanilla 는 리플로를 강제했고 여기서는 noTrans 를 한 프레임만 켠다(같은 결과, React 방식).
// 후보 목록은 localStorage 키 하나(p080-09)에 JSON 으로 넣는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RandomSlotPicker.module.scss";
export type RandomSlotPickerProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string; "--rows"?: string;
};
type TransEvt = { propertyName: string }; // React 제네릭 대신 로컬 타입
const KEY = "p080-09";
const BASE = ["지민", "서연", "도윤", "하은", "민준", "수아", "예준", "유나"];
const TITLE = "랜덤 슬롯 뽑기";
const COUNT_LABEL = "명 후보";
const GO_LABEL = "뽑기";
const DROP_LABEL = "당첨자 빼기";
const RESET_LABEL = "후보 되돌리기";
const WIN = "{n} 당첨!";
const SPINNING = "돌리는 중";
const HELP = "뽑기를 누르면 시작합니다";
const GHOST_WIN = "서연 당첨!";
const COPIES = 4;
function loadNames(): string[] {
try {
const raw = JSON.parse(window.localStorage.getItem(KEY) || "null");
if (raw && raw.names && raw.names.length > 1) { return raw.names.slice(); }
} catch (e) { /* 저장소가 막힌 창은 기본 후보로 */ }
return BASE.slice();
}
function saveNames(names: string[]) {
try { window.localStorage.setItem(KEY, JSON.stringify({ names: names })); } catch (e) { /* 건너뛴다 */ }
}
export default function RandomSlotPicker({
duration = 2000,
easing = "ease-out",
accentColor = "#0a4fe8",
}: RandomSlotPickerProps) {
const [list, setList] = useState<string[]>(BASE);
const [offset, setOffset] = useState(0);
const [noTrans, setNoTrans] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(HELP);
const [winner, setWinner] = useState("");
const [demo, setDemo] = useState(true);
const busyRef = useRef(false);
const vars: Vars = {
"--duration": `${duration}ms`, "--easing": easing,
"--color": accentColor, "--rows": String(list.length),
};
useEffect(() => { setList(loadNames()); }, []);
useEffect(() => {
if (!noTrans) { return; }
const id = window.requestAnimationFrame(() => setNoTrans(false));
return () => { window.cancelAnimationFrame(id); };
}, [noTrans]);
function spin() {
if (busyRef.current || list.length < 2) { return; }
busyRef.current = true;
setDemo(false);
setBusy(true);
setMsg(SPINNING);
setWinner("");
setOffset(offset + list.length + Math.floor(Math.random() * list.length));
}
function onEnd(e: TransEvt) {
if (e.propertyName !== "transform") { return; }
const norm = offset % list.length;
const name = list[(norm + 1) % list.length];
setNoTrans(true);
setOffset(norm);
setWinner(name);
setMsg(WIN.replace("{n}", name));
busyRef.current = false;
setBusy(false);
}
function drop() {
setDemo(false);
const i = list.indexOf(winner);
if (busyRef.current || i < 0 || list.length < 3) { return; }
const next = list.filter((_, k) => k !== i);
saveNames(next);
setNoTrans(true);
setList(next);
setOffset(0);
setWinner("");
setMsg(HELP);
}
function reset() {
setDemo(false);
if (busyRef.current) { return; }
saveNames(BASE);
setNoTrans(true);
setList(BASE.slice());
setOffset(0);
setWinner("");
setMsg(HELP);
}
const rows: string[] = [];
for (let c = 0; c < COPIES; c++) { list.forEach((n) => rows.push(n)); }
return (
<main className={styles.stage}>
<div className={[styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<header className={styles.bar}>
<p className={styles.title}>{TITLE}</p>
<p className={styles.count}>
<b className={styles.countNum}>{list.length}</b>
<span className={styles.countLabel}>{COUNT_LABEL}</span>
</p>
</header>
<div className={styles.body}>
<div className={styles.window}>
<div
className={[styles.track, noTrans ? styles.noTrans : ""].filter(Boolean).join(" ")}
style={{ transform: `translateY(calc(var(--row) * ${-offset}))` }}
onTransitionEnd={onEnd}
>
{rows.map((n, i) => (<div key={i} className={styles.item}>{n}</div>))}
</div>
<span className={styles.frame} aria-hidden="true" />
</div>
<div className={styles.side}>
<button className={`${styles.btn} ${styles.btnGo}`} type="button" disabled={busy} onClick={spin}>{GO_LABEL}</button>
<button className={styles.btn} type="button" onClick={drop}>{DROP_LABEL}</button>
<button className={styles.btn} type="button" onClick={reset}>{RESET_LABEL}</button>
</div>
</div>
<div className={styles.out}>
<p className={[styles.banner, winner ? styles.isWin : ""].filter(Boolean).join(" ")} aria-live="polite">{msg}</p>
<p className={`${styles.ghost} ${styles.ghostA}`} aria-hidden="true">{SPINNING}</p>
<p className={`${styles.ghost} ${styles.ghostB}`} aria-hidden="true">{GHOST_WIN}</p>
</div>
</div>
</main>
);
}09 랜덤 슬롯 뽑기 — 측정 누적면적 2.549% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웹 미니게임 만들기 9종 — 계산기·타이머·퍼즐 복붙 코드
이 데모가 속한 글
웹 미니게임 만들기 9종 — 계산기·타이머·퍼즐 복붙 코드웹 미니게임 만들기는 브라우저에서 바로 손이 가는 작은 놀잇감과 업무 도구를 HTML·CSS·JS 로 짜는 일입니다. 라이브러리 없이 파일 하나로 도는 아홉 개를 아래 격자에 늘어놓았고, 지금 그대로 움직입니다. 이 글의 데모 전부 보기


