DevTI
PERFORMANCE

성능 · 접근성

화면을 그리는 방식과 속도·접근성 디테일까지. 남들은 그냥 넘어가는 부분을 의도적으로 챙긴 장치 모음.

P1 - DATA & RENDER

데이터 성격에 맞춘 렌더 전략
  • 원칙·공개+SEO → RSC 서버 패칭 / 로그인+상호작용 → react-query
  • 쓰기 후·서버액션 revalidatePath 또는 invalidateQueries 로 무효화
  • 사용성·좋아요·댓글은 낙관적 업데이트로 즉시 반영 + 실패 시 롤백
쉽게 말하면

화면마다 데이터를 가져오는 방식을 성격에 따라 다르게 골랐어요. 아무나 보는 화면(카드 목록 같은)은 검색에 잘 잡히고 첫 로딩이 빠르도록 서버에서 미리 그리고, 로그인 후 계속 바뀌는 부분(좋아요·댓글)은 클릭 즉시 반응하도록 다른 방식을 씁니다.

아래 그림은 ‘이 데이터는 어느 쪽이냐’를 가르는 갈림길이고, 그 아래는 좋아요·댓글이 눌리자마자 먼저 반영되고 실패하면 되돌리는 방식을 설명합니다.

실선 = 렌더 경로 / 점선 분기 = 데이터 성격. RSC·react-query 는 레거시 관계가 아니라 역할이 달라 공존한다.

왜 Next 16 최신 기능을 '다' 쓰지 않았나 - 실무 실용성 우선
요즘 Next는 서버액션·useOptimistic 같은 걸로 데이터 변이까지 프레임워크 안에서 처리할 수 있어요. 그런데 이 프로젝트에서는 데이터를 별도 REST API 백엔드(Spring)로 주고받고, 화면 상태는 react-query 로 관리하는 구조를 중심에 뒀습니다. 서버액션은 꼭 필요한 곳 - RSC 캐시 무효화(revalidatePath) - 에만 최소로 쓰고, 낙관적 업데이트도 useOptimistic 대신 react-query 의 onMutate·롤백으로 직접 다뤘어요. 실무에서 가장 흔한 게 ‘REST API + 클라 상태관리’라, 최신 기능을 많이 썼다는 자기만족보다 바로 실무에 쓰이는 구조를 보여주는 게 더 실용적이라 판단했습니다.
낙관적 업데이트 (즉시 반영 · 실패 시 롤백)
좋아요 토글
onMutate 에서 좋아요 상태키와 ['card', id] 캐시를 즉시 뒤집어 클릭 즉시 반영. 실패하면 onError 가 스냅샷으로 되돌리고, onSettled 가 서버 값으로 보정.
댓글 작성·수정·삭제
임시 id(temp)로 먼저 그려 입력이 끊김 없이 보이게 하고, onSettled 에서 목록을 다시 받아 실제 id·닉네임·시각으로 교체. 3개 변이 모두 같은 롤백 규약.
react-query - 스피너 없는 전환
캐시를 즉시 그리고 뒤에서 조용히 최신화(refetchOnMount:"always"). 페이지·필터 전환엔 keepPreviousData 로 이전 페이지를 잠깐 유지해 깜빡임을 막고, 전환 중에만 오버레이로 로딩을 알린다.
쓰기 후 RSC 캐시 무효화 - 서버액션
공유 카드·상세는 RSC 로 서버 렌더하고, 수정·삭제 후 "use server" 액션이 revalidatePath 로 해당 경로만 다시 굽는다. (Next 16 에서 revalidateTag 는 2번째 인자가 필수, updateTag 는 cacheComponents 플래그를 요구해 경로 기반으로 우회.)
의도적 예외 - 카드 목록(/cards)은 공개지만 클라 렌더
원칙대로면 공개 목록은 RSC 서버 패칭이지만, 이 화면만은 (1) 헤더로 새로 진입할 때마다 서버가 목록을 다시 await 해 forward 전환이 느려지고, (2) 검색·필터·스와이프 위치를 URL 쿼리로 다루는데 그걸 읽는 useSearchParams 가 클라 전용이라 어차피 서버에서 못 그린다. 그래서 정적 셸 + react-query 로 두어 전환은 프리패치로 즉시, 데이터는 미리 데운 캐시로 표시한다.

P2 - FRONT PERFORMANCE & A11Y

LCP·이미지·번들·접근성
  • 집중·풀스크린·스와이프·필터가 겹치는 카드 목록(/cards)
  • 측정·라이트하우스 - 시크릿 창(확장 노이즈 제거)에서
  • 원칙·숨기되 상호작용 가능 → inert / 순수 장식 → aria-hidden
쉽게 말하면

눈에 잘 안 띄지만 사용성을 좌우하는 성능·접근성 디테일들을, 그냥 넘기지 않고 하나씩 챙긴 목록이에요. 첫 화면 이미지 우선 로딩, 이미지 용량 최적화, 뒤로 가도 스크롤·검색 상태 복원, 저사양에서도 부드러운 애니메이션 같은 것들입니다.

아래 카드들이 각 장치가 무엇을·왜 하는지 하나씩 설명합니다.

LCP - 첫 화면만 priority
above-the-fold 4장

stagger 로 처음 등장하는 카드에만 priority 를 줘 fetchpriority=high + preload. 나머지는 lazy. 전부에 주면 화면 밖까지 받아 오히려 LCP 악화.

이미지 - dedup · 반응형 · AVIF
next/image

2레이어 펄스가 같은 src 라 요청은 1장으로 합쳐짐(dedup). sizes 로 폭에 맞는 작은 본을 받고, AVIF→WebP 변환에 quality 90 을 쓰려 qualities 등록(Next 16 필수).

상태 복원 - URL + 라우터 캐시
staleTimes

검색·필터·정렬·스와이프 위치까지 URL 쿼리로 직렬화(router.replace). staleTimes 로 상세→목록 복귀 시 RSC 를 잠깐 재사용해 서버 재실행 없이 즉시 복원.

애니메이션 - GPU 합성 우선
transform · opacity

transform/opacity 변화만 GPU 합성(repaint 없음). 해저드 흐름·카드 진입·CTA glint 를 translate 로 짜 저사양에서도 부드럽게. stroke-dashoffset 류 비합성은 별도 표시.

번들 - 레거시 폴리필 제거
browserslist

모던 타깃(Chrome/Edge/FF 111+, Safari 16+)을 지정해 Array.at·flat·Object.fromEntries 등 ~14KiB 폴리필을 번들에서 제외.

표면 축소 - 보안 헤더
next.config

poweredByHeader:false 로 X-Powered-By 차단. CSP frame-ancestors 'none'(클릭재킹), nosniff, Referrer-Policy, Permissions-Policy 공통 적용.

왜 애니메이션에 transform만 쓰나 - 버벅임의 원리

저사양 기기에서도 부드럽게 돌리고 싶었어요. 화면이 버벅이는 건 대개 브라우저가 배치를 다시 계산(reflow)하거나 픽셀을 다시 칠(repaint)하기 때문이라, 그 둘을 건드리지 않는 속성만 골라 썼습니다.

비싼 것 - 피함
width·height·top·left·margin 을 바꾸면 주변 배치를 전부 다시 계산합니다(reflow). 매 프레임 이러면 저사양에서 뚝뚝 끊겨요.
값싼 것 - 씀
transform(이동·확대)·opacity만 바꾸면 브라우저가 GPU 레이어에서 ‘합성’만 합니다. 배치·페인트를 안 건드려 60fps 를 유지해요.
JS 개입도 최소화
진입·흐름·반짝임 모션을 JS로 매 프레임 그리지 않고 CSS keyframe·transition 에 맡겼어요. 그러면 브라우저가 알아서 GPU로 돌려 메인 스레드가 자유롭습니다. (card-rise · hazard-flow · btn-glint 모두 translate 기반) 단, stroke 애니처럼 합성이 안 되는 건 일부러 표시해 남발하지 않았고요.
접근성(a11y) 규약
inert - 닫힌 패널은 포커스에서 제외
검색/필터 패널이 닫히면 inert 로 내부 input·button 을 탭 순서·스크린리더에서 완전히 뺀다. aria-hidden 만 쓰면 포커스 가능한 자식이 탭에 잡혀 접근성 경고가 났다 - inert 가 정답.
유효한 ul > li · 장식은 aria-hidden
Swiper 는 wrapperTag="ul" 로 div.swiper > ul > li 시맨틱을 지키고, 취소선 바·해저드 스트라이프·화살표 글리프 등 순수 장식은 aria-hidden 으로 스크린리더에서 제외.
Next 16 에서 새로 챙겨야 했던 디테일
이미지 quality 는 images.qualities 에 명시해야 적용되고, 라우터 캐시 재사용은 experimental.staleTimes 로 직접 켠다. 쓰기 후 무효화도 revalidateTag 시그니처 변경(2번째 인자 필수)으로 경로 기반 revalidatePath 를 택했다 - 버전이 올라가며 기본값이 바뀐 지점들이라 그냥 두면 조용히 빠진다.