본문 바로가기
카테고리 없음

휠 줌 하나 붙이려다 react한테 두 번 혼났습니다. - 마이페이지 레거시 리펙터링 후기

by CWM 2026. 6. 23.

안녕하세요. 

회사에서 마이페이지 회원정보 화면의 레거시 코드를 정리하다, 프로필 사진 업로드 기능을 손보게 되었습니다.

사진을 고르면 그냥 그대로 올라가는 구조라, "동그란 틀에 맞춰 확대/이동해서 잘라올리는" 크롭 기능을 넣기로 했습니다.

ex. 인스타 프로필

 

드래그로 위치를 옮기고, 마우스 휠을 굴리면 확대/축소되게 만들고 싶었는데, 바로 이 '휠'에서 react에게 2번 혼났습니다.

먼저 이 글에 나오는 개념만 가볍게 짚고 갈게요.

 

리렌더: React는 상태가 바뀌면 화면 그리는 함수를 처음부터 다시 실행해요. 이때 함수 안의 변수들도 매번 새로 만들어집니다.

ref(메모지): 리렌더가 일어나도 지워지지 않는 메모지. 화면에 직접 안그려지는 값을 적어둘 때 씁니다.(ex. 확대 비율 xx배) useRef로 만들고 ref.current로 읽고 쓸 수 있습니다.

메모이제이션(useCallback): "이 함수 안 바뀌었으면 새로 만들지 말고 재사용해"라고 표시해 두는 것. 매 리렌더마다 똑같은 함수를 새로 만드는 낭비를 줄이려고 씁니다.

 

휠을 굴리니, 사진은 가만히 있고 뒤 페이지가 스크롤되었어요. 휠은 원래 '스크롤' 동작이니 당연한거긴 해요. 근데, 휠로 스크롤하지 말고 사진을 확대하도록 해야합니다.

React의 onWheel로는 이 스크롤 문제를 막을 수 없었어요.(브라우저가 휠 이벤트를 '수동 모드'로 처리해서 preventDefault가 안 먹음.)

그래서 브라우저에 직접 등록하는 방식을 써야함.

useEffect(() => {
	const el = viewportRef.current;
	const onWheel = (e) => {
		e.preventDefault();   // 이렇게 직접 등록해야 스크롤이 막힌다
		// ... 여기서 사진을 확대 ...
	};
	el.addEventListener("wheel", onWheel, { passive: false });
	return () => el.removeEventListener("wheel", onWheel);
}, []);

문제는 이렇게 한 번 등록한 onWheel은 '등록하던 그 순간의 값'에 갇힙니다. 그래서 나중에 배율이 2배, 3배로 바뀌어도 onWheel은 여전히 옛날 값만 갖게 되는 것이죠.(이 문제를 클로저 stale closure 라고 합니다.)

 

흔한 우회법은 최신 배율을 메모지(ref)에 계속 적어두고, 휠 핸들러는 그 메모지를 읽게 하는 것이에요.

 

줌 계산을 한 군데로 모으려고 함수로 묶고, useCallback을 달아서 재사용가능하게 하였습니다. 그리고 최신 배율은 메모지 zoomRef에서 읽었죠. 아래는 실제 코드입니다.

const zoomRef = useRef(1);

const applyZoom = useCallback((target) => {
	const oldScale = baseScale * zoomRef.current;   // ← 메모지 읽기
	const nz = Math.min(MAX_ZOOM, Math.max(1, target));
	const newScale = baseScale * nz;
	zoomRef.current = nz;                            // ← 메모지 쓰기
	setZoom(nz);
	setOffset((o) => recenter(o, newScale / oldScale));
}, [baseScale, nat, clampAt]);   // ← "이 값들 바뀔 때만 새로 만들어"

될줄 알았는데, 빌드 에러가 뜨더라고요.

react-hooks/preserve-manual-memoization Could not preserve existing memoization

=> 기존 메모이제이션을 보존할 수 없습니다. 라는 이야기입니다.

 

쉽게 얘기하자면, "너는 이 함는 baseScale, nat, clampAt가 바뀔 때만 새로 만든다"고 약속했는데, 함수 안에서 zoomRef 메모지를 들여다보고 있네? 메모지는 언제든 몰래 바뀌는 값이라 약속 목록에 없어. 그러니 최적화 해줄 수 없다.

 

핵심은 재사용하라고 만든 useCallback 함수 안에서 메모지를 읽거나 쓰지 말라는 겁니다.

 

그럼 useCallback을 떼고, 함수 자체를 메모지에 담아 매번 최신으로 갈아 끼우면 되지 않나?라는 생각을 하게 됩니다. (latest ref)

const applyZoom = (target) => { /* ... */ };

const applyZoomRef = useRef(applyZoom);
useEffect(() => {
	applyZoomRef.current = applyZoom;   // ← 매번 최신 함수로 갈아끼우기
});

될 줄 알았는데, 또 에러가 나더군요.

react-hooks/immutability applyZoomRef cannot be modified.

=> 함수를 메모지에 담아두고 갈아 끼우는 패턴도 하지마.

예를 들어 숫자 담아둔 메모지(zoomRef.current=1.6)는 괜찮은데, 함수를 갈아 끼우는 건 막더군요. - 왜지?

 

정리하자면,

사용자가 행동하는 순간(휠 굴리기, 클릭, 드래그)에 메모지를 읽고 쓰는 건 괜찮은데, useCallback 안에서는 메모지 안됨. 함수를 메모지에 담는 것도 안됨.

 

그럼 이제 문제는 메모지를 어디서 읽고쓰고 해야하는가?

 

하나, 억지로 useCallback을 떼어냄.

둘, 순수하게 계산만 하는 부분은 컴포넌트 바깥의 부품 함수로 빼냄.

// 컴포넌트 바깥 — 그냥 계산만 하는 순수 함수
function coverScale(w, h) {
	return Math.max(VIEWPORT / w, VIEWPORT / h);
}

function clampOffset(x, y, w, h) {
	return {
		x: Math.min(0, Math.max(VIEWPORT - w, x)),
		y: Math.min(0, Math.max(VIEWPORT - h, y)),
	};
}

셋, 최신 배율 메모지는 오직 '휠을 굴리는 순간(이벤트 핸들러)'에서만 읽고 쓰게 옮김.

useEffect(() => {

	const el = viewportRef.current;
	if (!el || !nat) return;
	const C = VIEWPORT / 2;
	const onWheel = (e) => {
	  e.preventDefault();                  // 페이지 스크롤 막기
	  const prev = zoomRef.current;        // 최신 배율 읽기
	  const nz = Math.min(MAX_ZOOM, Math.max(1, prev - e.deltaY * 0.0015));
	  const ratio = nz / prev;
	  const s = coverScale(nat.w, nat.h) * nz;
	  zoomRef.current = nz;                // 이벤트 핸들러 안이라 OK
	  setZoom(nz);
	  setOffset((o) =>
	    clampOffset(C - (C - o.x) * ratio, C - (C - o.y) * ratio, nat.w * s, nat.h * s));
	};

	el.addEventListener("wheel", onWheel, { passive: false });
	return () => el.removeEventListener("wheel", onWheel);
}, [isOpen, nat]);   // 이미지가 바뀌면 그때 다시 등록

 

이렇게 하면, '오래된 값에 갇히는' 문제는 두 갈래로 풀립니다.

자주 안 바뀌는 값(nat, 이미지 원본 크기)은 의존성 목록 [isOpen, nat]에 넣어서 바뀔 때 리스너를 다시 등록하고

자주 바뀌는 값(배율)은 메모지(zoomRef)로 읽는다.

 

그래서 메모지는 사용자가 뭔가 하는 순간에만 사용하고 계산을 캐싱하는 자리나 항상 똑같아야 하는 자리에서는 쓰면 안됩니다.

또한 억지로 최적화하려고 코드를 꼬지 마세요. 요즘 react에서 알아서 잘해주기 때문에 단순하게 쓰는 것이 정답일 때가 많습니다.

 

레거시 코드를 만지다 보면, "요즘은 이렇게 안 쓰는구나"하고 배우는 순간이 종종 옵니다. 저한테는 이번 휠 줌이 그랬고요.ㅎㅎ 

여러분들도 빨간 에러 없이 초록초록한 터미널 세상이 되길 응원합니다🥳

댓글