뽑으면 비명을 지릅니다: 해리포터 맨드레이크 인터랙션 구현기
꽃 테마 미리보기를 만들면서 재미있는 이스터에그를 넣고 싶었습니다.
해리포터의 맨드레이크처럼, 꽃을 뽑으면 비명을 지르는 뿌리가 등장하는 인터랙션이었습니다.
“그냥 꽃 요소를 드래그하면 되지 않나?” 라고 생각했지만, 실제로는 렌더링을 여러 레이어로 분리해야 한다는 걸 깨달았습니다.
1. 문제 상황
꽃 타입과 색상을 고를 수 있는 FlowerPreview 컴포넌트를 만들고 있었다. 기능적으로는 완성되었는데, 뭔가 평범한 느낌이었다.
그러다 해리포터에 나오는 맨드레이크가 떠올랐다. 맨드레이크는 땅에서 뽑을 때 비명을 지르는 식물이다.

이걸 꽃 미리보기에 녹여보면 어떨까?
- 꽃을 드래그해서 당기면 → 땅에서 뿌리가 올라오고
- 충분히 당기면 → 비명 지르는 맨드레이크가 등장
- 맨드레이크를 클릭하면 → 다시 심어진다
인터랙션 흐름 자체는 명확했다. 문제는 “어떻게 구현하느냐”였다.
2. 내가 처음 한 생각
처음에는 단순하게 생각했다.
- 꽃 이미지 요소에 드래그 이벤트를 달면 되겠다
- 드래그 거리에 따라 opacity나 transform으로 뿌리를 보여주면 되겠다
// 처음 머릿속에 그린 구조
<div draggable onDragStart={...} onDrag={...}>
<img src="flower.png" />
<img src="roots.png" style={{ opacity:
dragProgress }} />
</div>
브라우저 기본 드래그 이벤트를 쓰고, CSS로 뿌리를 페이드인하면 끝날 것 같았다.
그런데 막상 시도해보니 문제가 쌓였다.
- 브라우저 기본 드래그는 고스트 이미지가 생겨 어색했다
- 꽃이 이동할 때 줄기가 원점에서 커서까지 연결된 채 늘어나야 하는데, 하나의 요소로는 불가능했다
- 뿌리는 원점에 남아 있고, 꽃 머리만 커서를 따라가야 하므로 두 위치를 동시에 렌더링해야 했다
- 뽑힌 맨드레이크는 부모 컴포넌트의 overflow: hidden에 잘릴 게 뻔했다
“꽃 하나를 드래그한다”는 단순한 개념이지만, 실제 화면에서 표현하려면 여러 곳에 동시에 렌더링해야 한다는 걸 깨달았다.
3. 실제로 해 본 선택
렌더링을 세 레이어로 분리했다.
Canvas — 원점(땅)을 담당
캔버스에서는 드래그 상태에 따라 세 가지를 그렸다.
- 평상시: 꽃 전체
- 드래그 중: 뿌리가 올라오는 모습 (drawFlowerWithRootsPeeking)
- 뽑힌 후: 빈 화분 (drawEmptyPot)
뿌리는 드래그 거리가 threshold의 20%를 넘으면 서서히 나타난다.
// flower-canvas.ts
if (progress > 0.2) {
const rootAlpha = (progress - 0.2) / 0.8;
ctx.globalAlpha = rootAlpha;
// 뿌리 구근(bulb)과 두 갈래 뿌리선을 그린다
}
// 줄기는 당길수록 짧아진다
const stemHeight = 18 _ (1 - progress _ 0.5);
SVG + Portal — 줄기와 꽃 머리를 담당
커서를 따라가는 꽃 머리와, 원점에서 커서까지 이어지는 줄기는 createPortal로 document.body에 렌더링했다.
// FlowerPreview.tsx
{
mounted &&
isDragging &&
createPortal(
<DraggedFlower
originPos={originPos}
currentPos={currentPos}
stretchProgress={stretchProgress}
tensionProgress={tensionProgress}
/>,
document.body,
);
}
Portal을 쓴 이유는 부모의 overflow나 z-index 맥락을 벗어나기 위해서다. 부모 컴포넌트에 붙여두면 줄기와 꽃 머리가 잘리거나 뒤에 깔릴 수 있었다.
줄기는 SVG
// DraggedFlower.tsx
const stemPath = `M ${originPos.x} ${originPos.y}
Q ${midX} ${midY} ${currentPos.x} ${currentPos.y - 15}`;
<path
d={stemPath}
stroke={tensionProgress > 0.7 ? "#ef4444" : "#15803d"}
strokeDasharray={tensionProgress > 0.5 ? "4 2" : "none"}
/>;
장력이 올라갈수록 줄기가 빨간색으로 변하고, 50%를 넘으면 점선 패턴이 생긴다.
꽃 머리는 간단한 ASCII 아트로 표현했다. 장력에 따라 형태가 바뀐다.
const flowerAscii =
tensionProgress > 0.7
? " @\n/|\\" // 팔까지 벌린 상태
: tensionProgress > 0.4
? " @\n |" // 몸통만 보이는 상태
: " @"; // 꽃 머리만
Web Audio API — 비명 소리를 담당
뽑히는 순간과 이후 주기적으로 비명 소리를 냈다. 외부 음원 파일 없이 Web Audio API로 절차적으로 생성했다.
// UprootedMandrake.tsx
function playMandrakeScream(isInitial = false) {
const audioCtx = new AudioContext();
const osc1 = audioCtx.createOscillator();
const osc2 = audioCtx.createOscillator();
osc1.type = "sawtooth";
osc2.type = "square";
// 두 오실레이터를 겹쳐 쉰 목소리 느낌을 만든다
// 주파수를 빠르게 올렸다 내려서 비명처럼 들리게 한다
}
처음 뽑힐 때는 800Hz, 이후 주기적으로는 500~700Hz 사이에서 랜덤하게 소리를 냈다. 파일 없이 진동만으로 이런 효과를 낼 수 있다는 게 신기했다.
ASCII 아트 맨드레이크
뽑힌 맨드레이크 자체는 두 프레임을 번갈아 표시하는 ASCII 아트로 만들었다.
const asciiFrames = [
` \\|/\n (;_;)\n |||\n /| |\\`,
` \\|/\n (T_T)\n |||\n /| |\\`,
];
300ms 간격으로 프레임을 전환하면 눈물 흘리는 것처럼 보인다. 뽑힌 직후에는 흔들림이 강하고, 시간이 지나면 점점 잦아든다.
4. 결과
꽃을 잡아당기면 → 뿌리가 땅에서 서서히 올라오고 줄기가 곡선으로 늘어나며 장력 표시가 바뀌고 150px를 넘는 순간 → 맨드레이크가 비명을 지르며 튀어나온다
의도한 흐름대로 동작했고, 처음 봤을 때 재미있다는 반응을 들었다.
기술적으로 배운 것
- Canvas는 정적인 원점, Portal은 부모를 벗어난 오버레이 역할을 분담하면 자연스러운 드래그 인터랙션을 만들 수 있다
- SVG의 quadraticCurveTo를 이용하면 직선이 아닌 탄성 있는 줄기를 표현할 수 있다
- Web Audio API의 오실레이터 두 개를 겹치면 파일 없이도 독특한 효과음을 만들 수 있다
5. 지금 다시 해본다면?
처음부터 상태 기반으로 렌더링 위치를 나눠서 설계할 것이다.
이번에는 “꽃을 드래그하면 되겠지”에서 시작해 막힐 때마다 구조를 바꿨다. 처음부터 “원점에 무엇을 그리고, 커서에 무엇을 그리고, body에 무엇을 올릴지”를 스케치했다면 더 빠르게 완성됐을 것이다.
그리고 Web Audio API는 생각보다 간단하다. 음향 효과가 필요한 인터랙션에서 외부 파일 없이 절차적 소리를 만드는 방법을 먼저 떠올려볼 만하다.