개요
왜 이렇게 동작하는지에 대한 의문에서 시작해, 기능 개선까지 이어지는 저의 사고 과정을 풀어봅니다.
서비스 페이지들을 고객의 입장이 되어 요리조리 탐방하던 중, 특정 페이지들에서 전체 새로고침이라는 불편한 현상을 연이어 마주치게 되었습니다. 당연히 클라이언트 사이드 네비게이션으로 동작할 것이라고 생각했는데, 예상 밖의 동작을 맞닥뜨리니 개선하고 싶은 욕구가 샘솟았습니다.
저 스스로도 너무 불편해 항상 "아 이거 진짜 개선하고 싶은데..."라는 생각을 되뇌며 다른 이슈 처리 핑계로 미뤄온 지 어느덧 반년, 결국 더 이상 이 상황을 외면할 수 없게 되었습니다.
다들 마음속에 나만 아는 문제점 하나쯤은..? 🤖
문제점 파악 및 정의하기
페이지를 이동하다 보면, 당연하다고 생각되었던 클라이언트 사이드 네비게이션이 동작하지 않고 있었습니다. 그래서 저는 "왜?"라는 질문을 하게 되었습니다. 넥스트/리액트와 같은 모던 프레임워크(라이브러리)을 사용하면 일반적으로 앱 내의 이동은 클라이언트 사이드 네비게이션으로 이루어질 텐데 서비스의 경우 서버 사이드 네비게이션으로 동작하고 있던 것이 문제였습니다. 그래서 저는 예상과 다른 동작의 원인을 파악해 보기로 했습니다.
원인을 파악하기 위해서는 먼저 페이지가 구성되는 과정을 살펴봐야 했습니다.
그 과정은 다음과 같습니다:
- 페이지를 CMS(콘텐츠 관리 시스템) 형태로 관리
- 페이지의 HTML 데이터를 문자열로 응답
- 응답받은 HTML 문자열을 DOM 파서로 DOM 트리 형태로 변환하고, 필요한 스크립트를 추가하는 후처리 진행
- 추가 작업(리액트 의존)이 필요한 경우, 레이아웃에 맞는 리액트 컴포넌트로 치환 후 렌더링
- 별도의 작업이 필요 없다면,
dangerouslySetInnerHTML
prop을 사용해 원본 HTML을 주입해 렌더링
이 과정에서 클라이언트 사이드 네비게이션을 제어하는 스크립트가 누락되었고, 결국 페이지 이동이 서버 사이드 네비게이션(전체 페이지 새로고침)으로 강제되는 구조적 문제가 발생했습니다. 또한, 앵커를 사용한 네비게이션이 아닌 버튼 태그를 프로그래머틱 네비게이션을 처리한 레이아웃도 존재했습니다.
개선을 위해 클라이언트 / 서버 사이드 네비게이션에 관해 정리한 포스트를 공유해 봅니다. 🔍
무엇이 문제인지 파악이 끝났습니다. 저는 이를 어떻게 개선할지 방향을 잡아보기로 했습니다.
개선 방향
문제점을 파악한 시점부터, 리액트 컴포넌트화를 통해 개선하는 방법으로 결정했습니다.
치환을 통한 개선
기존 코드베이스에서 추가 작업이 필요한 경우, 해당 레이아웃을 리액트 컴포넌트화하는 것이 자연스러운 선택이었기에 이번에도 동일한 방법으로 문제를 해결하기로 했습니다. 이는 단순히 이벤트 페이지에서 사용되는 레이아웃에서 직접 버튼 태그를 활용해 프로그래밍적 네비게이션을 처리하는 방법에만 대응하면 된다고 판단했기 때문입니다.
그 후 레이아웃을 넥스트에서 제공하는 Link
로 매핑한 새로운 컴포넌트를 만들어 적용하려 했습니다.
치환하고자 하는 레이아웃(101)은 사용자에 의해 자유롭게 구성할 수 있는 백지와 같은 형태로, 인라인 스타일뿐만 아니라 실제 스타일 등 모든 속성이 HTML 데이터로 응답되는 구조였습니다.
101 레이아웃 데이터
<p class="container__text-content fc-content-text" style="color: #121212">
<style type="text/css">
.eventpage {
background-color: #f2f2f2;
padding: 5px 12px 4px;
border-radius: 5px;
text-align: center;
font-size: 15px;
font-weight: 400;
margin-right:8px;
margin-top:12px;
color: #4D4D4D;}
.eventpage:hover {
background-color: #e0e0e0;
color:#4d4d4d;
}
</style>
<button type="button" class="eventpage" style="background-color: #4d4d4d; color:#fff;" onclick="location.href='/event/event-list-main';">추천 이벤트</button>
<button type="button" class="eventpage" onclick="location.href='/event/event-list-exhibitions';">기획전</button>
<button type="button" class="eventpage" onclick="location.href='/event/event-list-user';">브랜드관</button>
<button type="button" class="eventpage" onclick="location.href='/event/event-list-earlybird';">얼리버드</button>
<button type="button" class="eventpage" onclick="location.href='/event/event-list-notice';">종료</button>
</p>
우선 기존 속성들을 그대로 추출해 JSX로 변환하여 컴포넌트를 구성했습니다. 하지만 작업을 진행하면서, "만약 CMS 운영 사용자가 속성 값을 변경한다면 어떻게 대응해야 할까? 외부에서 주입되는 모든 파라미터를 처리할 수 있도록 구성해야 하나?"라는 의문이 들었고 대응하기가 쉽지 않다는 결론을 내렸습니다. 물론 불가능하진 않지만, 좀 더 효율적인 방법이 없을까 고민을 해보았습니다.
그 고민은 다음과 같습니다:
- 스크립트가 적용이 되지 않아, 서버 사이드 네비게이션으로 동작하는 레이아웃이 과연 101 레이아웃뿐인가? 애초에 1:1 대응으로 해결할 수 있다고 속단한 것은 아닐까?
- 기존 방법이 치환이라고 해서 내가 그 방법을 따라야 할까?
- 내가 사용하는 프레임워크를 사용하며 클라이언트 사이드 네비게이션을 사용할 수 있는 방법은
Link
컴포넌트밖에 없는가?
이처럼 고민했음에도 불구하고, 초기부터 컴포넌트로 치환하는 방법을 선택한 이상 그 방향을 바꾸기가 쉽지 않았습니다. 마치 컴포넌트 치환이라는 목적지를 향해 달려가는 차안대를 쓴 말과 같다는 느낌이 들었습니다.
어쩌면, 투자한 시간이 아까워서였을지도 모르겠습니다.. ⏳
1. 1:1 대응 속단
개선의 방향을 잡을 때, 제가 중점을 둔 레이아웃은 이벤트 페이지의 탭 버튼들이었습니다.

해당 레이아웃만 리액트 컴포넌트로 치환하면 된다고 생각하며 개선 작업을 시작했고, 단순히 치환으로 끝날 것이라 여겼습니다. 하지만 앞서 언급했듯, 현실적으로 모든 속성과 HTML 태그에 대응하는 것이 쉽지 않다는 점을 깨달았습니다.
개선을 진행하면서 "다른 레이아웃은 어떻게 동작 할까?"라는 의문이 계속 따라다녔고, 결국 다른 레이아웃들도 살펴보게 되었습니다. 아차차...🫠 예상대로, 다른 레이아웃 역시 네비게이션에 대한 대응이 전혀 이루어지지 않고 있었습니다. 기존 HTML을 그대로 사용한 탓에 앵커 태그로 네비게이션을 수행했고, 이는 클라이언트 사이드 네비게이션 처리가 불가능한 상황이었습니다.
결국, 1:1 대응 방법으로는 모든 상황을 처리하기 어렵다는 것을 깨달았습니다. 이 과정을 거치며 두 가지 문장이 떠올랐습니다:
- 은탄환은 없다.
- 1억 개의 상황이 있다고 생각하고 개발하라.
어쩌면 제가 너무 단순하게 문제를 해결하려 했던 것은 아닐까 싶습니다. 혹은 결말을 미리 정해놓고 개선하려 했던 것은 아닐까 고민하게 되었습니다. 개발이란 무엇보다도 열린 결말을 지향하는 세계인데 말입니다. 이런 생각 끝에 차안대를 벗으며, "그렇다면 나는 이제 어디로 가야 할까?"라는 질문을 하게 되었습니다.
2. 치환을 따라야 하는가?
치환에도 여러 방법이 존재한다는 것을 알게 되었습니다.
-
dangerouslySetInnerHTML
- 해당 방법은 가장 간단한 방법이지만 스크립팅 처리에 적합하지 않다고 판단
- 또한 보안상 취약점이 존재하여 개인적으로 선호하지 않는 방법
-
HTML 파싱 라이브러리 사용
- 문자열로 전달되는 구조를 파싱 라이브러리들로 단순 문자열 HTML 태그 또는 컴포넌트로 치환 가능
- 하나, 해당 라이브러리를 추가를 위해 또 다른 검증이 필요했으며, 의존성이 하나 추가되는 것(팀에서 지양)으로 반려되었을 가능성 존재
- 그리 복잡하지 않은 설정으로 치환할 수 있어 좋았지만, 직접 해결하고 싶어 최후의 보루
-
커스텀 컴포넌트 사용
- 실제로 시도한 방법으로 1:N 대응이 어려움
치환 방법 외에 다른 방법은 무엇이 있을까? 혹시 놓친 점은 없을까? 자연스럽게 사고가 전환되면서, 사용하는 프레임워크가 제공하는 네비게이션 방법에는 어떤 것들이 있는지 살펴보게 되었습니다.
3. 사용하는 프레임워크(넥스트)가 제공하는 네비게이션 방법
-
Link
- 처음 생각한 치환 방법에 맞는 해결책
a
태그를 확장해 프리페칭과 클라이언트 사이드 네비게이션 제공- 해당 컴포넌트를 사용하면 매우 간단하게 네비게이션 개선 가능 → 이게 될 줄 알았는데..!
- 넥스트 공식적으로 특정 요구사항이 필요하지 않은 이상,
Link
컴포넌트를 사용하는 것을 추천
-
useRouter
- 해당 방법은
Link
컴포넌트와 다르게 프로그래밍적으로 네비게이션을 처리할 수 있게 해주는 넥스트의 빌트인 훅 - 즉 프로그래밍적으로 라우트를 변경
- 특정 요구사항 → 풀고자 했던 의문에 답이 되었습니다.
- 브라우저의 히스토리 API를 제어하는 훅
- 해당 방법은
실제 개선 방향 (useRouter를 활용한 네비게이션 가로채기)
위의 상황을 종합했을 때, 특정 요구 사항을 해결해야 했으므로 useRouter
훅을 사용하는 것이 가장 간단하게 개선할 수 있을 것으로 보였습니다.
그렇다면 어떻게 해당 훅을 사용해 네비게이션을 적용할 수 있을까 상황을 생각해 봤습니다.
- HTML 태그가 앵커 태그인 경우
- 앵커 태그가 아닌 태그에 프로그래밍적 네비게이션이 적용된 경우
앵커 태그인 경우
우선 앵커 태그를 찾아줍니다. 그 후 href
속성을 가져와 그 href
를 검증합니다.
- 외부 링크
- https로 시작할 경우, 굳이 클라이언트 사이드 네비게이션이 필요 없으므로 아무런 동작을 취하지 않습니다.
- 내부 링크
- 내부 링크일 경우 앵커 태그의 기본 동작을 방지합니다.
- 그 후 위에서 찾은
href
속성을useRouter
훅으로 생성한router
객체를 사용해 프로그래밍적 네비게이션을 적용합니다.
- href가 오리진 URL을 포함한 링크
- 예를 들어, CMS에서 설정한
href
의 값이href="https://coloso.co.kr/...
인 경우 - 오리진 URL을 포함할 경우 조건 처리를 추가합니다.
- 예를 들어, CMS에서 설정한
앵커 태그가 아닌 태그에 프로그래밍적 네비게이션이 적용된 경우
모든 HTML 태그 케이스를 대응하려 했으나, 결국 모든 경우를 처리하는 것이 어렵다는 판단을 내렸습니다. 이에 CMS 운영 사용자와 논의한 끝에, 페이지 이동 시 의미론적 관점에서 그 역할을 수행하는 태그인 앵커 태그를 사용하는 방향으로 합의하였습니다.
이로 인해 추가적인 코드 없이 앵커 태그만을 대응하여 문제를 해결할 수 있게 되었습니다.
useRouteInterceptor
커스텀 훅 구현
각 HTML의 네비게이션을 가로채는 커스텀 훅을 구현하기로 하였습니다. 해당 훅을 사용하면 다음과 같은 이점이 존재합니다:
- HTML 속성 및 태그 대응 불필요
- 각 레이아웃 별 1:1 대응 불필요
- 하나의 훅에서 CMS에서 구성되는 페이지의 네비게이션 제어 가능
const useRouteInterceptor = () => {
const router = useRouter();
const interceptAnchorClick = (e: MouseEvent<HTMLElement>) => {
const anchor = (e.target as HTMLElement).closest('a');
const href = anchor?.getAttribute('href');
if (shouldBypassRouter(href)) return;
e.preventDefault();
const path = extractPath(href);
router.push(path);
};
return { interceptAnchorClick };
};
export default useRouteInterceptor;
훅에서 반환되는 interceptAnchorClick
함수는 이벤트 위임을 통해 부모 요소에서 이벤트를 제어하도록 구현되었습니다. 해당 함수의 동작 방법은 다음과 같습니다:
- 컴포넌트 클릭 시 이벤트 객체에서 타겟 정보 추출
- 타겟과 가까운 앵커 태그 탐색
- 앵커 태그의
href
정보 추출 href
가 없거나 외부 링크일 경우, 클라이언트 사이드 네비게이션이 필요 없으므로 함수 종료- 내부 페이지 이동이 필요한 경우, 앵커 태그의 기본 동작 방지
- 이후 Next.js의
router
를 사용하여 클라이언트 사이드 네비게이션 구현
20줄도 되지 않는 코드의 훅으로 문제를 효과적으로 해결할 수 있었습니다.
개선 결과
클라이언트 사이드 네비게이션 적용
이전 서버 사이드 네비게이션으로 동작하던 것과 차이점이 보이시나요? 새로고침이 일어나지 않으니 페이지의 전환이 매우 부드러워진 것을 볼 수 있습니다. 또, 서버에 전체 데이터를 요청하지 않고 필요한 데이터만을 가져오게 되어 네트워크 트래픽이 줄어들고, 데이터 로드 속도가 빨라져 사용자 경험이 더욱 향상되었습니다.
개선 전후를 지표를 통해 분석해 보겠습니다.
기본 성능 지표 비교 분석

개선 전 서버 사이드 네비게이션

개선 후 클라이언트 사이드 네비게이션
성능 지표 | 개선 전 (서버 사이드 네비게이션) | 개선 후 (클라이언트 사이드 네비게이션) | 개선율 |
---|---|---|---|
총 실행 시간 | 12,148ms | 6,536ms | 46.2% 감소 |
Loading | 107ms | 27ms | 74.8% 감소 |
Scripting | 4,046ms | 891ms | 78.0% 감소 |
Rendering | 233ms | 65ms | 72.1% 감소 |
Painting | 32ms | 13ms | 59.4% 감소 |
System | 443ms | 142ms | 67.9% 감소 |
Idle | 7,248ms | 5,387ms | 25.7% 감소 |
네트워크 요청 패턴 차이점
개선 전의 경우 각 네비게이션마다 여러 네트워크 요청이 발생하며, DCL(DOMContentLoaded), FP(First Paint), FCP(First Contentful Paint) 등이 반복적으로 발생하는 것을 볼 수 있습니다. 이는 각 페이지 전환마다 전체 페이지 생명주기가 다시 시작됨을 의미합니다. 개선 후에는 필요한 데이터만 요청하는 패턴으로 변경되어 네트워크 부하가 크게 감소했습니다.
클라이언트 사이드 네비게이션 전환 후 개선 결과
- 페이지 전환 속도가 빨라져 사용자의 대기 시간 감소
- 전체 페이지를 다시 로드하지 않기 때문에 화면 깜빡임 없이 부드러운 사용자 경험 제공
- 필요한 데이터만 로드하므로 네트워크 트래픽 감소 → 서버 부하 감소
- 안정적인 힙 메모리 사용량
결론
지표를 통해 성능 개선 결과를 명확하게 확인할 수 있습니다. 서버 사이드 네비게이션에서 클라이언트 사이드 네비게이션으로 전환함으로써 총 로딩 시간은 약 46%, 스크립팅 시간(78.3%)과 렌더링 시간(72.1%)이 대폭 감소되었습니다. 이는 페이지 전환 시 사용자의 경험이 크게 향상되었음을 알 수 있습니다.
개선 과정 회고
스스로 문제를 정의하고 탐구하며 결론을 도출하는 과정에서 많은 것을 배웠습니다. 실제 개선에 필요한 코드는 간단했지만, 문제를 정의하고 해결책을 찾아가는 과정이 매우 의미 있었습니다. 처음에는 단순히 "서버 사이드 네비게이션이 불편하다"는 현상에 집중했으나, 페이지 구성 과정을 분석하며 문제점을 이해할 수 있었습니다.
추후에는 이 페이지 구성 과정을 개선해보고 싶습니다. 🔨
문제 해결 과정에서 하나의 방법에만 갇혀 다른 접근법을 고려하지 못했던 점이 아쉬웠습니다. 초기에 결론을 미리 정해놓고 그 방향으로만 구현하려다 보니 사고가 제한되었고, 이미 투자한 시간과 노력 때문에 쉽게 포기하지 못한 미련도 있었습니다. 그러나 이러한 한계를 깨닫고 틀에서 벗어나, 더 나은 해결책을 발견할 수 있었습니다. 이를 통해 여러 가능성을 열어두고 사고하는 것의 중요성을 알게 되었습니다.
문제를 정확히 정의하고 해결책을 명확히 설정하니, 복잡해 보였던 문제도 간단한 코드로 해결할 수 있었습니다. 또한, "왜?"라는 질문을 끊임없이 던지며 문제에 다가가는 과정의 중요성을 깨달았고, 개선 전후의 성능 지표를 비교하며 데이터 기반으로 결과를 확인한 점도 의미 있는 배움이었습니다.
이번 경험은 단순한 기능 개선을 넘어, 문제를 해결하기 위해 어떤 방법으로 사고해야 하는지 깨닫게 해주었습니다.