들어가기 전
아주 사소한 문제일지도 모르나 직면했던 일부 이슈들을 하나씩 정리한 포스트 입니다. 개인의 참고용 및 히스토리로 남기기 위한 목적으로 작성되었습니다.
NextJS 를 사용했기에 프론트엔드와 백엔드 간의 구분이 모호하여 이를 별도로 구분해서 정리할까 고민했으나, 결국 하나의 프레임워크 내에서 발생하는 일이므로 이를 구분하지 않고 정리하는 것이 문서의 흐름에 좋을 듯하여, 구분없이 목차별로 정리하였습니다. 전체적으로 1,2,3 가지의 파트가 있으며, 정리한 수에 비해 비중있는 트러블 슈팅이 많이 없을 수 있습니다.
돌아보면 부끄러운 기록이 되겠지만, 이번 프로젝트는 저에게 있어서는 감회가 남달랐던 경험이었기에 다른 프로젝트에 비해 최대한 상세히 정리하며 넘어가볼까 합니다.
그럼 시작하겠습니다.
렌더링 버그 | 업데이트 후에도 이전 텍스트와 배경의 잔상이 남는 문제
※ 이 문제는 useEffect 의 의존성이 복잡해지는 경우 어떤 문제를 초래하는가에 관한 이슈였습니다. useEffect 는 의존성을 최소한으로 가지는게 좋지만, 어떤 이유에서든 의존성이 복잡해진다면 예기치 문제가 발생할 수 있음을 경험하였던 문제입니다.
잔상이 남는 문제의 해결 방법은 단순하게 Canvas API 에서 제공하는 ClearRect 함수를 사용하여 적절한 위치에 적용 해주는 것 입니다.
이렇듯, 문제 원인은 단순했습니다. 다만, 해당 함수의 호출 위치를 찾는 것이 새로운 문제였습니다. 카드를 꾸미는 페이지의 특성상 다양한 onChange 를 통해 변경되는 상태를 분리하기가 애매했고, 결국 하나의 의존성으로 관리함에 따라 결과적으로 내부 코드의 동작 방식을 바로 파악하기 어려웠습니다. 최대한 로직을 단순화하고, 작업 프로세스를 예측 가능한 방식으로 개선하면서 적절한 위치를 찾을 수 있었고, 결국 해결할 수 있었던 문제였습니다.
문제상황
HTML5 Canvas API 의 fillText() 함수로 생성된 텍스트, 이미지 등이 useEffect를 통해 업데이트 된 후에도 사라지지 않고 잔상이 남는 문제가 발생하였습니다.
참고로 현재 대상 컴포넌트는 HTML5 의 Canvas API 를 사용하여 그래픽 요소가 렌더링 되고 있습니다. 그래서 하나의 useEffect 에 서로 간에 의존성을 가지는 Canvas API의 메소드가 많은 편으로, 해당 상태들은 각기 다른 역할을 수행하는 컴포넌트로 분리한 후 Zustand 를 사용하여 전역적으로 상태 관리를 하고 있는 중입니다. |
해결과정
해결방법 모색
해당 문제는 리액트의 렌더링 사이클 마다 이전 텍스트나 배경이 제거 되지 않아서 생기는 문제 이므로, 이를 제거하기 위해서는 기존 canvas 의 그래픽 요소를 지우고, 마운트 이후 수정된 그래픽 요소가 렌더링 되도록 로직을 수정해야 한다고 판단하였습니다.
여러 방법을 모색 중 모질라 MDN 문서에서 기존 그래픽 요소를 지우는 역할을 수행하는 clearRect() 메서드를 찾을 수 있었고, 이를 활용하기로 하였습니다.
[참고] clearRect( ) 메서드에 대한 부가 설명
clearRect() 함수는 총 4개의 인자를 받을 수 있습니다. 순서대로 x, y, width, height 이며, x 와 y 는 Rect 를 그릴 시작되는 위치 좌표를 의미하고, width와 height 는 시작 좌표(x 와 y) 에서 부터 Rect 를 적용할 최대 넓이와 높이를 지정합니다.
즉, clearRect 는 함수 명칭 그대로 캔버스의 특정 좌표에서 특정 길이 만큼의 영역을 초기화하는 역할을 수행합니다. 쉽게 말해 지우개 라고 할 수 있습니다.
ClearRect() 메소드를 활용한 초기화 함수 구현
기존 캔버스를 초기화하기 위해서 clearCanvas 함수를 구현하였습니다. 구현을 했다고 보기에는 매우 단순한 로직이지만, 해당 함수를 호출하는 경우 이전에 그려진 그래픽 요소를 제거해주기 때문에, 렌더링 결과가 이후 동작에 영향을 미치는 것을 방지하는 역할을 수행 합니다.
const clearCanvas = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
구현한 함수 적용
해당 함수를 구현하는 것은 매우 간단한 일이었으나, 호출하는 위치를 찾는 것은 생각 보다 쉽지는 않았습니다. 결과만 우선 언급하자면, 이미지 요소가 모두 로드되고 나서 fillText 와 drawImage 가 적용되기 직전에 기존 캔버스를 초기화할 수 있도록 추가 하였습니다.
참고로 abc 는 ⓐ 처음 시도, ⓑ 중간 결과, ⓒ 2차 시도 및 성공 를 의미합니다. 글이 너무 길어져서 가독성을 위해 추가하였습니다. |
ⓐ 처음에는 canvas 에 그래픽 요소를 그리는 래퍼 함수인 draw() 함수가 호출되기 직전에 적용하려고 하였으나, 이미지가 로드가 된 이후에 순차적으로 그래픽 요소가 화면에 그려지도록 로직을 구성하였기 때문인지(추측), ⓑ draw 함수가 호출될 때 무수히 많은 리렌더링이 발생하는 경우에는 잔상이 조금씩 쌓이는 문제가 발생하였습니다 → 초기화 함수는 적용되었음에도 잔상이 약간씩 겹치는 문제가 발생
따라서 해당 문제를 개선하기 위해 호출 시점을 변경하기로 하였습니다.
즉, ⓒ이미지 요소에 대한 load 이벤트가 호출된 직후에 즉시 초기화 함수를 실행하게 하여 초기화 함수가 호출되는 시점과 draw 함수가 호출되는 시점을 동기화하였고, 최종적으로 해당 문제를 해결 할 수 있었습니다.
이를 개선한 전체 코드는 다음과 같습니다.
loadImage 함수의 인자 내 타입 지정이 지저분 하여 해당 부분은 향후 리펙토링을 진행 하였습니다. 현재 부분은 해당 문제를 개선할 당시 상황에서의 코드 입니다. |
// Reset | 캔버스 초기화 함수 정의
const clearCanvas = ( ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement ) => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
// === 중략 ===
// 이미지 로드 이벤트 핸들
const loadImage = useCallback((textY: number, ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, split: string[]) => {
if (!imageEl) return
imageEl.alt = '명언 카드 배경 이미지'
imageEl.addEventListener('load', () => {
// Reset | 캔버스 초기화 함수 호출
clearCanvas(ctx, canvas)
bgColorDraw(ctx, width, height)
ctx.fillStyle = `${color}`
ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height)
// 배열 형태로 분리된 텍스트를 조건에 따라서 다르게 렌더링한다.
split.forEach((text: string, i: number) => { // === 중략 === })
})
},[bgColorDraw, color, fontStyle, height, imageEl, lineHeight, width])
// 그리기
const draw = useCallback (
(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => {
// === 중략 ===
// Load Image | 이미지 로드 호출
loadImage(...)
}, [ bgImageSrc, ... ],
)
// 캔버스 생성
const createCanvas = () => { // === 중략 === }
useEffect(() => {
const { canvas, ctx } = createCanvas()
if (canvas && ctx) { draw(ctx, canvas) }
}, [draw])
성과
- 기대한 결과대로 텍스트, 이미지 등의 요소를 변경할 때 마다 발생하는 잔상 문제를 개선할 수 있었습니다.
- 또한, 개선중 리마운트 시 조금씩 지연이 되는 문제가 발생하는 점을 찾을 수 있었습니다. ( 이 부분은 다음 트러블 슈팅으로 정리한 메모리 누수 | 리렌더링 시 작업 중인 화면에 미묘한 버벅임이 발생하는 문제 에 대한 부분에서 개선을 시도해 보았습니다.
메모리 누수 | 리렌더링 시 작업 중인 화면에 미묘한 버벅임이 발생하는 문제
※ 해당 문제의 근본적인 원인은 컴포넌트가 언마운트 될 시 등록된 이벤트리스너를 정상적으로 제거해주지 못하여 발생한 것 입니다. 보통 이벤트 리스너를 제거할 때는 동일한 형태의 이벤트 등록과 해제가 이루어져야 하는데, 이를 잘못된 방식으로 처리하여 이벤트가 리렌더링 시 마다 제거되는게 아니라 계속 쌓이게 되었고, 결국 화면이 버벅이는 문제로 이어지게 되었습니다.
브라우저 개발자 도구의 성능탭과 메모리 탭을 통해 최대한 원인을 찾아보려고 하였는데, 지금 생각해보면 너무 단편적인 지식만을 사용하여 이도저도 아닌 방식으로 문제에 임하지 않았나 하는 아쉬움이 있습니다.
문제상황.
앞서 캔버스 렌더링 버그를 해결하고 나서, 커스텀 명언 카드를 만들고 있는 중에 처음 페이지에 접속했을 때 보다 미묘한 버벅거림이 발생하는 것을 인지하였습니다.
해결과정.
해결방법 모색
버벅거림이 왜 생기는지에 대하여 유사한 사례나 인사이트를 찾아보기 위해 스택오버플로우, MDN 등의 사이트의 커뮤니티를 활용하였고, 종합적으로 해당 문제의 원인이 메모리 누수일 가능성이 있다고 짐작하였습니다.
대략적으로 살펴본 코드에서는 커스텀 명언 카드의 배경으로 쓰이는 이미지를 로드할 시 등록된 이벤트 리스너를 해제 하지 않았기에 이 부분에서 메모리 누수가 발생한 것일 가능성이 높았지만, 추가적인 누수 원인이 존재할지도 모른다고 판단하여 추가적인 성능 측정을 시도해 보기로 하였습니다. 따라서 해당 컴포넌트에서 어떤 부분이 메모리 누수를 발생시키고 있는지 크롬 개발자 도구에 있는 메모리(Memory) 탭 부분을 활용하기로 결정하였습니다.
타임라인별 메모리 사용빈도 및 사용량 추적
첨부된 이미지와 같이 메모리 탭에서 [타임라인의 할당 계측] 을 클릭 후 ◎ 을 클릭하면 해당 페이지에서 발생하는 메모리 사용 및 누수 현황을 시간의 흐름에 따라 막대 그래프의 활성화 여부로 시각적으로 확인할 수 있습니다.
측정해본 결과 시간이 지남에 따라 사용된 메모리는 가비지 컬렉터에 의해 등록이 해제되어 비어지는 경우도 존재하였으나, 처리되지 않고 남은 경우도 존재함을 확인할 수 있었습니다.
부분적으로 메모리가 비어지지 않고 남는 이유를 추측 해보면, Zustand 로 관리되고 있는 상태에 저장된 값이 변동될 때 마다 이미지 로드 이벤트가 useEffect 가 호출될 때 마다 계속해서 등록된 상태로 남아 있어서 그런게 아닐까 싶었습니다. |
성능(performance) 측정
그 다음에는 크롬 개발 도구의 성능 측정을 시도해 보았습니다. 기존 캐시가 미칠 수 있는 예기치 못한 문제를 사전에 방지하기 위해 [강력 캐시 비우기] 를 실행하고,성능 측정의 하위 탭에서 메모리 측정 결과를 표시해주도록 하는 [메모리] 항목을 체크한 후 측정을 시작하고, 약 1분 정도 버튼, 이미지 업로드, 글씨체 변경 등의 동작을 실시하였습니다.
그 결과만 살펴보면 전반적으로 그리 높은 메모리 사용량은 아닌 것으로 보였고, 성능 측정의 마지막에서는 마운트 이후 초기의 메모리 사용량과 큰 차이가 없는 것으로 보아 해당 측정으로는 메모리 누수를 판단하기는 어렵다고 보여졌습니다.
잠깐 정리하자면
앞서 메모리 탭과 성능 탭으로 분석까지 시도해보면서, 추가적인 메모리 누수 현황은 찾아보기 어렵다고 결론 내렸습니다. |
메모리 누수를 발생시키는 로직 분석
최종적으로 문제가 되는 컴포넌트 내의 이미지 로드 관련 코드를 다시 분석하기로 하였습니다.
코드를 보면, loadImage 내부에 있는 이벤트 리스너를 등록하고, useEffect 로 해당 로직 전체가 리렌더링 될 때 이전에 등록된 이벤트 리스너가 해제되지 않고 누적되는 문제가 있을 수 있다는 사실을 다시 한 번 명확히 할 수 있었습니다.
메모리 누수가 의심되는 부분의 로직을 추출 후 클린업 함수로 처리
의심되는 부분이 한 가지로 좁혀졌으니, 해당 로직을 개선하고자 하였습니다. useEffect 에서 클린업 함수로 image load 이벤트에 대한 리스너를 해제 시키기 위해 load 이벤트 부분을 함수로 감싸고, 해당 함수를 draw 함수 내에서 return 으로 반환토록 로직을 수정하였습니다.
그 후 반환된 이벤트리스너를 클린업 함수로 컴포넌트가 디마운트 시 등록 해제하여 메모리 누수를 방지할 수 있도록 처리하였습니다.
아래는 위 로직을 포함하여 연관된 코드를 일부 정리한 것입니다.
// 텍스트 그리기
const draw = useCallback(
(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, imageEl:HTMLImageElement) => {
// === 중략 ===
const handleImageLoad = () => {
clearCanvas(ctx, canvas) // 새 그림을 추가하기 전에 이전 그림들 제거
bgColorDraw(ctx, width, height)
ctx.fillStyle = `${color}`
ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height)
// 배열 형태로 분리된 텍스트를 조건에 따라서 다르게 렌더링한다.
split.forEach((text, i) => {
textY = height / 3 + i * lineHeight
if (fontStyle === 'fill') {
ctx.fillText(text, width / 2, textY)
}
if (fontStyle === 'stroke') ctx.strokeText(text, width / 2, textY)
if (fontStyle === 'hybrid') {
ctx.strokeText(text, width / 2, textY)
ctx.fillText(text, width / 2, textY)
}
})
}
// 이벤트 등록
imageEl.addEventListener('load', handleImageLoad)
imageEl.src = bgImageSrc
return handleImageLoad
}, [color, ...])
// 캔버스 생성
const createCanvas = () => { ...}
useEffect(()=>{
const imageEl = new Image();
setImageEl(imageEl)
},[])
useEffect(() => {
const { canvas, ctx } = createCanvas()
if (!imageEl) return
if (canvas && ctx) {
const handleImageLoad = draw(ctx, canvas, imageEl)
// 이벤트 등록 해제
return () => {
if(!handleImageLoad) return
imageEl.removeEventListener('load', handleImageLoad)
}
}
}, [draw, imageEl])
성과.
- (사용성 개선) 리렌더링이 이루어지더라도 버벅거리는 문제(혹은 약간의 변경 상 지연이 되는 문제)를 눈에 띄게 개선할 수 있었습니다.
- (추가적이 문제점 인식) 이번 개선과정을 통해서, 앞서 메모리 탭에서 발견한 부분적인 메모리 누수 부분이 처음에 비해서는 많이 개선되었으나, 장기간 작업 시 동일한 문제가 발생할 여지가 있어 보였기에 지속적인 모니터링과 개선이 필요함을 알 수 있었습니다.
재유효화 문제 | 북마크 리스트의 갱신이 즉시 이루어지지 않는 문제
※ 해당 문제는 SWR 라이브러리의 캐싱 기능과 뮤테이션에 대한 이해를 기반으로 풀어야 했던 이슈였습니다. 사용자가 북마크를 추가하고, 이를 백엔드에서 업데이트 하였으나, 클라이언트 측에서는 기존의 캐싱된 데이터를 사용함에 따라 변화를 인지하지 못하는 문제였습니다. 따라서 전역적으로 공유되는 트리거 역할을 하는 상태를 Zustand 를 이용해 관리하고 북마크가 업데이트 될 때 전역상태를 변경하여 변경된 상태에 따라 뮤테이션을 호출함으로써 해결했던 문제였습니다.
해당 문제를 해결하고 나서 생각해보면, 굳이 mutate 함수를 트리거하기 위해 전역 상태를 사용하는 것이 최선이었나? 라는 생각이 듭니다. NextJS 에서 제공하는 재유효화 기능과 리프레쉬 기능을 잘 활용 했어도, 충분히 외부 라이브러리 도움 없이 가능하지 않았을까라는 생각이 들었기에 향후 개선 시 검토해봐야 겠습니다. |
문제상황..
(문제상황) 사용자가 원하는 명언을 담으면 북마크 리스트에 즉시 갱신이 되지 않는 문제가 발생하였습니다.
(실패 사례 1) 사용자가 북마크 추가를 시도할 때, 서버에서 데이터 추가 및 조회를 동시에 처리하여 갱신된 데이터를 클라이언트 단으로 보내는 방식으로 즉시 업데이트 되도록 하는 방법도 고민해 봤지만, 북마크 모달이 표시되는 컴포넌트의 위치와 북마크 버튼이 위치하는 컴포넌트 간의 거리가 멀다는 제한점이 존재하여 보류하였습니다.
(실패 사례 2 ) NextJS 에서 제공하는 revaildateTag 나 path 를 활용하여 적용해 보았지만, 이 또한 실패사례 1 과 같은 사유로 인해 재유효화가 힘든 상황이라 보류하였습니다.
(실패 사례 3) 또한 클라이언트 단에서 사용 가능한 useRouter 의 refresh 기능의 경우도 동일한 컴포넌트 내에서나 리렌더링 시 서로 영향을 받는 컴포넌트여야 한다는 제한점으로 이 또한 적용 시 실패하였습니다.
Q. 전역상태로 관리하면 되는거 아닌가?
위 문제를 해결하기 제일 쉬운 방법은 전역 상태로 관리하면 해결할 수 있겠으나, 북마크 리스트 같은 경우 다른 사용자가 자신이 등록한 명언을 삭제하는 경우, 이를 참조하고 있는 해당 북마크 아이템도 같이 삭제되도록 해야 합니다. 사라진 북마크를 사용자가 링크를 타고 이동해도 404 페이지를 볼 수 밖에 없다면, 이 또한 사용자 경험에 좋지 못한 영향을 미칠 수 있으니까요.
다시 말해, 다른 유저가 자신의 명언을 삭제하면, 그것을 참조하고 있는 다른 유저의 북마크 목록에서도 삭제되도록 해야 하는데, 이 때 서버 상태를 직접 관리해야 하므로 전역상태로만 관리하기에는 개인적인 판단에서는 한계가 있다고 보았습니다.
해결과정..
도구 선택
(기대결과) 해당 기능이 정상 동작하는 경우, 북마크 추가 시 사용자는 자신이 원하는 명언 카드의 링크를 실시간으로 저장하고, 확인할 수 있어야 합니다.
(도구 선택) 따라서 실시간 업데이트를 염두에 두면서도, 네트워크 요청에 대한 캐싱 기능을 지원하는 서버 상태 라이브러리를 활용해보기로 결정했습니다. 서버상태 라이브러리로 소개되고 있는 라이브러리 중 대표적인 것으로 TanStack Query/react 와 SWR 이 있었으나, 이 중에서 NextJS와의 호환성을 생각하여 SWR 을 선택하기로 하였습니다.
Q. 왜 SWR를 선택 했나?
SWR을 선택한 이유는 복잡한 캐싱처리가 필요하지 않다고 판단했고, 현재 프로젝트에 있어서는 TanStack Query 보다 SWR이 로직이 단순하면서도 빠르게 활용할 수 있다고 판단했기 때문입니다. ㄸ한, 패키지의 크기에 있어서도 SWR이 더 가볍고, NextJS 팀에서 만들었기 때문에 NextJS 와의 호환성이나 유지보수에 있어서도 유리할 것이라 판단하여 최종적으로 선택하였습니다.
useSWR 을 적용한 새로운 fetch 함수 정의
SWR 사용 이전에는 다음과 같은 코드를 사용하여 북마크 리스트를 GET 요청 하였습니다.
[수정 전 코드]
// 북마크 리스트 조회 요청
export async function getBookmarkListFormDB(token: string) {
try {
const response = await fetch(`http://localhost:3000/api/bookmark`,{
headers:{
"Authorization":`Bearer ${token}`
}
})
const items = response.json()
} catch(error){
console.error(error)
}
}
참고) 클라이언트 컴포넌트에서 요청을 보내고 있기에 아래 처럼 호스트:포트(localhost:3000)와 프로토콜(http://)을 명시할 필요는 없습니다. 이 부분은 현재는 수정 되었습니다.
위 코드에서 SWR 을 적용하는 경우에는 try ~ catch 로 감싸지 않아도, SWR 에서 error 를 자동으로 캐치 해주기 때문에, 이를 반영하여 재작성된 코드는 다음과 같습니다. 이 때 첫 번째 파라미터는 url 이 들어옵니다.
[수정 후 코드]
// 북마크 리스트 조회 요청
export const getBookmarkListFormDB = async (url: string, token: string) => {
if (!(token === '')) {
const response = await fetch(url, {
headers: {
"Authorization": `Bearer ${token}`
}
})
const items = response.json()
return items
}
}
참고) useSWR() 등을 사용할 때 따로 인자로 전달하지 않아도 내부적으로 HTTP 요청 함수의 첫 번째 파라미터로 전달해줍니다.
참고) fetcher 로 사용되는 함수 내부에서 throw new Error 를 던지면 해당 에러를 useSWR 에서 캐치하여 사용자가 직접 에러를 다룰 수 있다고 합니다. ( https://swr.vercel.app/ko/docs/error-handling )
Fetcher 를 호출하는 컴포넌트의 useSWR() 훅 적용
Fetcher 함수를 호출하는 컴포넌트(BookmarkList.tsx) 의 코드 또한 useSWR() 훅을 적용한 형태로 다음과 같이 로직을 작성하였습니다.
// SWR | 북마크 리스트 불러온다.
const { data, isLoading } = useSWR(
[`/api/bookmark?page=${page}&limit=5`, token],
([url, token]) => getBookmarkListFormDB(url, token),
{
refreshInterval:4000,
revalidateOnFocus: false,
revalidateOnReconnect: false,
onErrorRetry: ({ retryCount }) => {
if(retryCount >=5) return
}
},
)
참고) 지정한 옵션 설명
- refreshInterval:4000 : 4초 마다 캐시된 데이터를 새로운 데이터로 업데이트 합니다.
- revalidateOnFocus: false : 브라우저에 초점을 맞추면 새로운 데이터로 업데이트 합니다.
- revalidateOnReconnect: false : 화면보호기 등의 상태에서 해당 브라우저로 재연결 시 새로운 데이터로 업데이트 합니다.
- onErrorRetry: ({ retryCount }) => { if(retryCount >=5) return } : 에러 발생 시 5번의 재요청 이후에 더 이상 요청하지 않도록 합니다.
적용 결과
다음은 SWR 을 적용한 결과 화면입니다.
성과..
- (실시간 데이터 변경 갱신에 의한 사용자 경험 향상)사용성 경험이 개선되었습니다. 기존에 적용 해봤던 방식은 브라우저가 전체 새로고침 되어 사용자로 하여금 즉각적인 피드백을 전달하지 못하였고, 새로고침 동안에 번쩍이는 화면을 사용자에게 제공함으로써 부자연스러운 상호작용이 발생하였습니다. useSWR() 훅을 적용 이후에는 데이터 변경을 감지해주기 때문에, 변동 사항을 사용자에게 즉시 제공해줄 수 있게 되었습니다.
- (개발 편의성 및 코드 가독성 향상) 개발 편의성과 코드 가독성이 향상되었습니다. 기존에 fetch만을 활용했을 때는, 별도의 error 상태 처리와 loading 상태 처리를 실시해야 했지만, 리팩터링 후에는 로직을 직접 구현할 필요성이 많이 줄어들었습니다.
(중요) 현재작성된 트러블 슈팅의 해결책은 아주 큰 문제점이 개선되지 않고 남아 있습니다. 바로 5000ms 마다 북마크 리스트를 GET 요청하는 부분인데, 이 문제는 향후 뮤테이션 이라는 기술을 적용하여 해결 하였습니다. 이에 대한 부분은 [ 서버 리소스 낭비 문제 | bookmark 목록 조회 시 잦은 호출로 인한 서버 리소스 낭비 문제 ] 를 참고 해주시면 감사하겠습니다. |
빌드 문제 | 빌드 후 배포 환경에서 카테고리 목록이 렌더링 되지 않는 문제
※ 서버 구성 요소에서 searchParams 를 사용하면 해당 페이지는 정적 페이지가 아닌 동적으로 렌더링 되는 페이지가 되어야 함을 알 수 있었던 이슈였습니다. NextJS 를 이용해 처음 만든 프로젝트여서 기본 개념이 부족해 경험했던 문제로 기억됩니다.
문제상황...
- 개발 환경에서 /quotes/[category] 경로로 접속 시 정상적으로 렌더링 되었으나, 빌드 이후 배포(production) 환경에서 동일한 경로로 접근 시 목록이 렌더링 되지 않는 문제가 발생하였습니다.
해결과정...
원인 분석
개발 서버에서는 정상 동작 하였고, 빌드 이후에 문제가 발생하였으므로 이와 관련한 빌드 에러가 로그에 남아 있는지 확인해보았습니다.
해당 에러의 원인은 nextUrl.searchParams를 서버 측에서 사용하였기 때문에 해당 api를 호출하는 페이지는 정적으로 생성할 수 없다는 것이었습니다. 따라서 이 문제를 해결하려면 해당 페이지를 동적 생성이 가능한 페이지로 바꿔주어야 한다는 사실을 확인할 수 있었습니다(미리 언급하지만 이 방식은 실패 했습니다).
해당 페이지를 강제로 동적 렌더링되도록 지정 | export const dynamic = ‘force-dynamic’ 적용
위 사실을 확인 후 우선적으로 행한 조치는 해당 fetch 를 호출하는 페이지의 상단에 해당 페이지를 동적 렌더링 대상으로 강제 지정하는 방식이었습니다. 즉, 다음 구문을 추가 하였습니다.
export const dynamic = ‘force-dynamic’
아래는 해당 구문을 추가한 페이지의 일부 코드 입니다.
// 저자별 카테고리를 렌더링하는 페이지
export const dynamic = 'force-dynamic'
import type { Metadata } from 'next'
import CategoryList from '@/components/UI/list/CategoryList'
export const metadata: Metadata = {
title: '저자별 | My wise saying',
description:
'유명 위인별 명언 페이지에 접근하기 전의 카테고리 페이지 입니다.',
}
export default async function CategoryPage() {
return (
<section>
<CategoryList />
</section>
)
}
빌드 다시 실행하기
아쉽게도, npm run build 를 실행하였으나, 해당 문제는 해결되지 않고 동일한 문제가 발생하였습니다.
디렉토리 구조 분석와 오타수정
재빌드가 실패한 이후, 디렉토리 구조의 문제가 아닐지 의심을 던져 보았습니다. 참고로, 앞서 빌드 에러를 보면 에러가 발생한 경로를 확인할 수 있었는데, /api/quotes/category/routs.ts 와 같이 routs.ts 부분에 오타가 난 것을 확인할 수 있었습니다. 그러나 이 부분을 수정하고도 동일한 문제가 계속 발생 하였습니다.
그 후 빌드 결과에 생성된 정적 페이지와 동적 페이지에 대한 부분을 살펴보았습니다. 파란색과 노란색으로 표기된 부분을 보면 서로 다른 경로를 나타내는 것을 볼 수 있는데, 사실 이 부분은 동일한 페이지에서 동작하고 있습니다. 따라서 이 부분이 정적 페이지 생성과 동적 페이지 생성 알고리즘에 혼동을 일으킨 것이 아닌가 나름 추측해 보았습니다.
중복된 디렉토리 구조 병합
앞서 의심이 되는 디렉토리를 하나로 병합하기로 결정하였습니다. 따라서 이를 개선하기 위해 category 경로를 제거하고, [slug] 경로로 동일한 api 라우트를 사용하도록 수정함으로써 Next 에서 혼동을 경험하지 않도록 수정하였습니다.
그 결과 빌드 에러가 뜨지 않고 정상적으로 빌드되는 것을 확인할 수 있었습니다.
참고) 해당 문제를 경험했던 폴더 구조와는 차이가 있습니다. 다만 해당 문제를 개선 한 api 디렉토리 구조는 동일합니다.
빌드 후 배포 환경(npm run start)
빌드 문제가 해결되고 난 뒤 npm run start 를 입력하여 배포 환경에서 프로젝트를 실행해 보았습니다. 결과적으로 문제 없이 카테고리 목록이 렌더링되는 것을 확인할 수 있었습니다.
성과...
- (디렉토리 구조에 대한 안티 패턴 발견 및 개선) NextJS 에서 정적 페이지와 동적 페이지를 생성 시 혼란을 야기하는 잘못된 디렉토리 구성이 무엇인지 확인할 수 있었습니다.
- (빌드 프로세스 단축) 중복된 apiRoute 경로를 추상화함으로써 불필요한 빌드 프로세스를 단축시킬 수 있었습니다.
리소스 낭비 | 명언 카드 편집 시 잦은 onChange(or onInput) 호출로 인한 메모리 낭비 문제
※ 디바운싱을 적용하여 메모리 낭비 문제를 개선했다는 것이 요지입니다. 메모리 낭비였는데, 얼마나 낭비했냐 라고 수치적으로 증명하지않은 것이 아쉬웠던 이슈였습니다.
onChange 이벤트가 짧은 시간 안에 너무 많은 리렌더링을 발생시켰고, 이로 인해 화면이 버벅이는 문제를 경험하여 개선했던 문제입니다. 디바운싱 이라는 기술 자체는 중요한 것이 아니라, 찾아보면 많은 방법이 있을 텐데, 굳이 디바운싱을 사용해야 했는가에 대해 스스로 정리해보는 시간을 가진 이슈였습니다.
디바운싱은 다 좋은데, 해당 기술도 너무 남용이 되면 동일한 성능 저하를 일으킬 수 있다는 생각이 들었습니다. 결국 클로저를 이용한다는 것은 메모리 공간을 반납하고 사라졌어야 할 변수를 억지로 살려놓은 행위이므로 조금 더 성능 이슈가 적을 수 있는 방법을 고려해봐도 좋았지 않을까 생각이 듭니다. |
문제상황....
- <QuotesStyler/> 컴포넌트의 자식 컴포넌트 내부에서는 input change 이벤트를 사용하여 실시간으로 변동되는 값을 Zustand 의 use 훅을 사용하여 <QuotesStylerCanvas/> 컴포넌트에서 상태를 가져와 사용하고 있습니다.
- 이 때, onChange 이벤트가 사용자의 타이핑 마다 호출되고 있고, 모바일 환경에서 적용한 결과 호출이 반복될수록 잠깐의 버벅임이 발생하는 사용자 경험에 좋지 못한 문제가 발생하였습니다.
해결과정....
해결방안 및 도구 선택
이벤트가 호출되는 시점에 동작하는 함수의 호출 시점을 뒤로 미뤄서 마지막 호출 시점에 함수를 실행하는 디바운스 기술을 적용하기로 하였습니다.
디바운스를 구현하는 방법에는 서드 파티 라이브러리를 사용할 수도 있으나, 패키지에 대한 종속성을 최대한 줄이고 싶었고, 빌드 시 파일의 무게를 늘리고 싶지는 않았기에 직접 구현하는 방식을 채택하였습니다.
클로저를 이용한 디바운스 구현
디바운스를 구현하는 방법은 다양하게 있겠으나 저는 범용적인 방식으로 클로저를 사용하여 구현하였습니다.
Q. 왜 클로저를 적용했는지?
클로저의 이점은 내부 함수의 실행이 완료될 때 까지 외부함수 내에 선언된 변수에 접근할 수 있게 된다는 점입니다. 또한 클로저가 매번 생성될 때 마다 각각의 클로저는 서로 독립적으로 존재하기 때문에 향후 타이머 식별자 역할을 수행하는 let timerId 변수는 공유되는 것이 아닌 각 클로저가 독립적으로 소유하게 됩니다.
이러한 특성은 클로저 간에 부수효과를 배제할 수 있다는 이점이 있기에 이를 바탕으로 구현하고자 하였습니다.
디바운스 함수의 랩퍼 함수 생성
디바운스 함수를 구현하기 위해서는 외부함수와 내부함수가 모두 필요 했습니다. 그리고 내부함수에 접근하기 위해서는 반환된 클로저에 접근할 수 있도록 외부함수의 클로저를 반환할 필요가 있었습니다. 즉, 다음과 같이 debounceClozer 변수를 추가 하였습니다.
// 디바운스 함수 랩퍼
const debounceClozer = debounce( )
디바운스 함수 구현
실제 동작하는 디바운스 함수를 구현하였습니다. 매개변수로 전달받을 값은 새롭게 지정할 값(newValue), 해당 값의 타입(targetName), 지연시간(delayTime)으로 지정하고, 클로저별로 참조할 변수로 timerId 변수를 선언하였습니다.
마지막으로 일정 지연 시간 이후에 상태를 변경하는 내부함수를 선언하여, 각각의 클로저 마다 독립된 상태 업데이트를 실시할 수 있도록 코드를 작성하였습니다.
'use client'
export default function QuotesSizeStyler({ selectTapNum }: PropsType) {
// 디바운스 클로저
function debounce(){
let timerId: NodeJS.Timeout;
return function (newValue: number, targetName:string, delayTime:number) {
clearTimeout(timerId)
timerId = setTimeout(()=>{
setState({...state, [targetName]:newValue})
}, delayTime)
}
}
// 디바운스 함수 호출 : debonce 함수의 클로저 반환하고 이를 활용.
const debounceClozer = debounce()
return (
{/* 캔버스 넓이 */}
<input
onChange={(e) => {
const width = Number(e.currentTarget.value)
debounceClozer(width, 'width', 500)
}}
></input>
)
}
적용결과
성과....
- (과도한 리소스 낭비 방지) 500ms 마다 setState 가 호출되어 onChange 이벤트가 계속 실행되더라도 함수는 지연시간 이후에 호출되므로, 불필요한 리소스 낭비를 막을 수 있었습니다.
- (패키지 의존성 감소 및 빌드 시 리소스 크기 최적화) 해당 구현 함수를 위해 별도의 서드파티 라이브러리를 사용하지 않았으므로, 패키지에 대한 의존성을 줄이고, 동시에 빌드 시 리소스 크기를 아주 약간이지만 최적화할 수 있었습니다.
서버 리소스 낭비 문제 | bookmark 목록 조회 시 잦은 호출로 인한 서버 리소스 낭비 문제
※ 해당 문제는 북마크 목록을 조회시 5초 마다 새로운 데이터를 요청하는 것이 서버자원을 불필요하게 낭비하고 있다고 느껴져서 개선할 필요성을 느꼈던 문제입니다. 캐싱과 변이에 대한 이해부족이 문제의 배경이 되었습니다.
해당 문제를 해결 후 곰곰이 생각해보면, 굳이 5초 마다 목록을 조회하도록 코드를 작성 하였는가 잠깐의 의구심이 들었습니다. 알고보니 해당 로직에 accessToken 을 재발급 하도록 등록해두어, 로그인이 풀리는 문제를 임시방편으로 개선하기 위해 작성했던 것이 떠올랐고(임시방편이었고, 간혈적으로 로그인이 풀리는 문제를 막지는 못했습니다), 이후에 문제의 로직을 개선하지 않았다는 점입니다. |
문제상황.....
- 현재 구현된 bookmark 조회 기능은 swr 을 사용하여 5초 마다 새로운 데이터를 서버로 부터 가져오도록 구현되어 있습니다.
- 이렇게 로직을 구성했던 이유는 개발 초기에 사용자가 북마크 추가 아이콘을 클릭하면 새로운 데이터가 바로 즉시 렌더링되지 않는 문제를 개선하기 위해서 입니다.
- 그러나 현재 로직은 너무 잦은 호출이 이루어짐에 따라 사용자의 수가 늘어날 수록 서버가 받는 부하는 커질 수 밖에 없다는 단점이 있습니다.
- 따라서 해당 로직을 개선하여, 사용자가 북마크를 추가 및 삭제 요청을 하는 경우에만 북마크 목록이 갱신되도록 기능을 개선하려고 하는 상황입니다.
해결과정.....
해결 방법 탐색
해당 문제를 인지하면서, 제일 먼저 찾아본 것은 SWR 공식 사이트 입니다. TanStack 쿼리에서도 상태 변경을 촉발하는 시점에 값을 업데이트하는 헬퍼 함수가 존재했기에, 이와 유사한 목적을 수행하는 함수가 해당 라이브러리에도 존재할 것이라 판단했기 때문입니다.
그러던 중 다음 뮤테이션이라는 기능을 발견할 수 있었습니다.
뮤테이션 기술을 사용하는 경우 Key 로 연결된 모든 swr hook 들은 기존에 가지고 있던 데이터를 만료된 것으로 간주하고, 서버로 부터 새로운 데이터를 받아오는 동작을 수행하게 됩니다.
따라서 이 방법을 이용하여 북마크 리스트에 변경이 발생하면 즉시 변경되도록 구현하도록 하기로 결정하였습니다.
북마크 리스트 자동 갱신 비활성화
뮤테이션을 적용하게 되면 더 이상 북마크 리스트를 자동으로 갱신하여 가져올 필요가 없기 때문에, refreshInterval 의 옵션을 비활성화하고, 마운트 시(revalidateOnMount) , 캐시 만료 시(revalidateIfStale), 포커스 시(revalidateOnFocus) 데이터가 갱신되지 않도록 관련 옵션을 모두 false 로 지정하였습니다(기본 값은 모두 true 입니다).
우선 확인하고 넘어가야 하는 사항이 있습니다.
현재 북마크 기능의 경우 추가와 삭제 기능 두 가지를 내포하고 있으며, 삭제 기능의 경우에는 모달 컴포넌트와 동일한 컴포넌트에서 부모와 자식 컴포넌트 간의 상속 구조를 가지고 있습니다. 반면, 추가 기능의 경우에는 서로 다른 컴포넌트에 위치하고 있습니다.
SWR 에서는 동일한 키를 사용하더라도 서로 다른 곳에 위치하는 경우 상태가 공유 되지 않음으로 뮤테이션이 적용되지 않는다고 언급하고 있습니다.
해당 메시지를 확인하고 실제로 추가 기능에 뮤테이션을 적용하였으나, 동일한 키를 업데이트함에도 리스트가 갱신되지 않는 문제를 추가로 확인하였습니다. 따라서 이 부분도 삭제 기능의 리팩터링 이후에 해결 과정을 언급할 예정입니다.
북마크 삭제 기능에 적용할 뮤테이션 방법 선택
우선 삭제 기능에 뮤테이션을 적용하기로 하였습니다. SWR 에서 제공하는 뮤테이션 함수는 mutate 로 불리는데, 해당 함수를 가져오는 방법은 크게 3가지가 있었습니다.
// 전역적으로 가져와서 사용하는 방법 ①
import { useSWRConfig } from "swr"
function App() {
const { mutate } = useSWRConfig()
mutate(key, data, options)
}
// 전역적으로 가져와서 사용하는 방법 ②
import { mutate } from "swr"
function App() {
mutate(key, data, options)
}
// useSWR 훅의 경계 내에서 사용하는 방법 ③
import useSWR from 'swr'
function Profile () {
const { data, mutate } = useSWR('/api/user', fetcher)
저는 이 중에서 ③ 를 적용하기로 하였습니다. 원래 전역적으로 사용하는 경우에는 useSWR 에서 사용하는 동일한 Key 를 첫 번째 인자로 전달해주어야 합니다. 하지만 세번째 방법의 경우에는 Key 를 입력하지 않아도 사용할 수 있습니다.
현재 제가 구현한 컴포넌트 내에서 삭제 기능의 경우 useSWR 과 동일한 위치하고, 있으며 mutate 가 동작하기 위해서는 동일한 위치에서 리렌더링이 발생하여야 하므로 세 번째 방식을 사용하였습니다.
삭제 기능에 뮤테이션 적용
현재 북마크를 조회하는 SWR 훅과 삭제 기능을 수행하는 함수는 동일한 컴포넌트 내에 위치하므로 다음과 같이 적용하였습니다.
삭제 기능의 정상 동작 테스트
해당 기능을 적용하고 나서 정상 동작하는지 테스트 해보았습니다. 다행히 정상적으로 삭제 결과가 바로 갱신되는 것을 확인 하였습니다.
이번에는 북마크 추가 기능에 대해 뮤테이션을 적용하기 위해 시도하였습니다. 일단 해당 과정에서 주요하게 살펴보아야 할 점은 삭제 기능과 다르게 추가 기능은 북마크 리스트와 삭제 기능을 감싸고 있는 컴포넌트와는 완전히 동떨어진 위치에 있다는 점입니다.
앞서 언급 했듯이 SWR 에서는 동일한 Key 를 가지고 있더라도, 루트 디렉토리에서 시작하여 뻗어 나가는 컴포넌트의 뿌리가 다르다면, 뮤테이션이 적용되지 않습니다.
따라서 해당 문제를 해결하기 위해서 다양한 방법을 찾아보던 중 전역 상태를 이용한 방식을 고르기로 하였습니다.
북마크 추가 기능과 useSWR 연결하기 위한 도구 선택 → Zustand
북마크를 추가하는 기능과 북마크 조회를 처리하는 useSWR 를 동기화하기 위해서 전역 상태를 관리하는 라이브러리인 Zustand 를 활용하였습니다. Redux 와 마찬가지로 전역 상태를 관리하지만 보다 단순하고 직관적인 특성으로 인해 현재 제가 진행하고 있는 프로젝트에서 적용하기에 효과적일 것이라 판단하여 이를 활용하였습니다.
사용 방법도 아래 예시를 보면, 매우 간단하며, 직관적인 것을 볼 수 있습니다.
[zustand 상태관리예시]
전역 상태 정의하기
앞서 예시를 참고하여 북마크 추가 시 리스트를 갱신하도록 트리거하는 전역 상태를 만들어야 합니다. 저는 /store/store.ts 파일 내부에 아래와 같이 설정하였습니다.
※ 참고가 필요한 일부 로직을 들고 온 것이므로 실제 타입과 상태는 파일이 구분되어 있습니다. |
import { create } from 'zustand'
export interface BookmarkUpdateState {
isUpdate: boolean
setIsUpdate: (isUpdate : boolean) => void
}
/**
* * Zutand | 북마크 리스트 목록 갱신 트리거 상태 저장
*/
export const useBookmarkUpdate = create<BookmarkUpdateState>((set) => ({
isUpdate: false,
setIsUpdate: (isUpdate) => set(() => ({ isUpdate }))
}))
setIsUpdate 는 상태를 업데이트하는 setter 이고, isUpdate 는 관리하고자 하는 전역 상태입니다. 해당 값을 boolean 으로 관리하여 향후 북마크 추가 버튼을 클릭하는 순간 이 상태를 true 로 대체하는 방식으로 적용할 예정입니다.
북마크 추가 함수 내부에서 setIsUpdate 호출하여 전역 상태를 업데이트
우선적으로 북마크 추가 기능을 수행하는 함수가 위치한 넘포넌트에서 앞서 정의한 setter 메서드를 import 합니다.
import { useBookmarkUpdate } from '@/store/store'
그 후 북마크 추가 함수 내부에 setIsUpdate 를 호출하기 위한 로직을 작성합니다. 아래 함수는 사용자가 북마크 추가 버튼을 클릭하면 addBookmarkItem 함수가 호출되어 명언 식별자(id)를 토대로 해당 사용자의 리스트에 아이템을 추가하도록 서버에 요청 합니다.
그 후 서버로 부터 처리가 성공한 경우 true 를 반환받고, true 인 경우 && 단축 평가를 통해 setIsUpdate(true) 를 호출하게 하였습니다.
const onClickBookmarkAdd = async () => {
if (!item && !hasToken) return
const { id } = item
const isSuccess =await addBookmarkItem(id)
isSuccess && setIsUpdate(true)
}
위 과정을 요약하면 다음과 같습니다.
북마크 리스트를 조회하는 모달 컴포넌트에서 isUpdate 적용 및 뮤테이션 호출
앞서 북마크 버튼을 클릭하고 나면 isUpdate 는 false → true 로 상태가 대체 되므로 해당 상태를 북마크 리스트를 조회하는 모달 컴포넌트(useSWR 훅을 사용하는 컴포넌트) 내에서 import 하여 해당 isUpdate 가 true 가 되는 순간에만 mutate 를 호출하도록 로직을 작성하였습니다.
그리고 해당 상태가 변경되어 mutate 가 호출되고 나면, Promise 를 반환하는 특성을 활용하여, 그 직후 then 체이닝을 통해 업데이트 상태를 false 로 변경하여 불필요한 리렌더링을 방지할 수 있도록 처리하였습니다.
결과 시연
결과적으로 다행히 큰 문제없이 추가와 삭제 모두즉시 갱신이 이루어지도록 개선되었습니다.
성과.....
- (지연시간 없는 즉시 갱신으로 인해 사용성이 크게 향상) 짧은 간격으로 새로운 데이터를 가져오더라도 결국 일정 부분 지연시간(기본적으로 5000ms 정도 지연이 발생 하였습니다) 이 발생하는 것은 여간 불편한 점이 아닙니다. 이번 기능의 개선을 통해서 변경 사항이 즉시 반영되므로 사용자 입장에서 더 이상 지연된 시간 이후에 북마크 리스트가 갱신되는 것을 보지 않아도 됩니다.
- (트래픽 과부하 요소 차단 및 네트워크 리소스 낭비 개선) 기존에는 짧은 주기 마다 가지고 있는 데이터를 만료된 것으로 처리(stale) 하고 새 데이터를 가져왔으므로, 불필요한 HTTP 요청으로 네트워크나 백엔드 자체의 리소스 낭비가 심했습니다. 결과적으로 뮤테이션을 적용한 이후로는 잦은 요청을 막을 수 있었고, 보다 한정된 자원을 효율적으로 사용할 수 있는 환경을 만들 수 있었습니다.
보안 문제와 리팩터링 | 로그인 관련 로직 개선과 기존 accessToken의 보안 문제에 따른 refresh 토큰 도입
해당 내용은 여러 번의 시도에 이어지면서 포스트 길이가 길어짐에 따라 별도 포스트로 정리해두었습니다.
트러블 슈팅 1 포스트 작성 후기
전체적으로 트러블 슈팅으로 정리된 이슈들은 기술적으로는 특별한 것은 없었던 것 같습니다. 최대한 프로젝트를 하면서 직면한 문제들을 나열하고, 정리해두자는 마음으로 하나씩 추가해 나갔으나, 아쉬운 정리가 아니었나 나름 반성해보는 시간을 가져봅니다.
그럼에도 해당 문제들이 특색이 없다고 해서, 프로젝트에서 직면했을 때 결코 가벼운 문제는 아니었습니다. 이후 트러블 슈팅 2와 3에 이어서 작성해 나가고 있지만 트러블이 되는 문제들을 하나씩 정리하면서 느꼈던 점은 모든 문제는 "결국 개발자의 이해부족과 실수에서 발생하는 것이 대부분이다" 그리고 "그 문제의 해결에 대한 어려움도 이에 기인한다" 라는 당연한 사실이었습니다.
따라서, 트러블 슈팅 자체가 단순하든 단순하지 않든 중요하지 않다고 생각합니다. 제일 중요한 것은 해당 문제를 경험한 것에서 무엇을 놓쳤고, 어떤 부분이 미흡했나를 돌아보는 자기 성찰적인 의미에서 가치가 있다고 생각합니다.
그럼 이번 트러블 슈팅 1 포스트를 마치도록 하겠습니다. 보잘 것 없는 포스트 참고해주셔서 감사합니다.
이후 트러블 슈팅
회고
'프로젝트 > 나만의명언집' 카테고리의 다른 글
[나만의 명언집 프로젝트] 테스트 코드 적용 정리본(일부) (4) | 2024.03.05 |
---|---|
[나만의 명언집 프로젝트] 기능 구현 정리본② (0) | 2024.03.04 |
[나만의 명언집 만들기 프로젝트] 기능 구현 모음집 ① (0) | 2024.03.04 |
[나만의 명언집 프로젝트] 트러블 슈팅 모음집 ② | 7 ~ 13 (0) | 2024.03.02 |
[나만의 명언집 프로젝트] NextJS(^14.1) 에서 적용한 accessToken 을 이용한 토큰 인증에서 refresh + access 방식으로 수정 (1) | 2024.02.08 |