전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

클릭보다 화면이 먼저 움직입니다 — 체감 속도 다듬기

공감을 누르면 서버보다 하트가 먼저 채워져요. 실패하면 그 자리에 재시도가 남고요. 기능 목록에 안 적히는 커밋들 — 낙관적 업데이트, CLS 제거, 잔잔한 다듬기 — 로 체감 속도를 다듬은 기록이에요.

2026. 07. 20. 11:24:318분 읽기프로젝트/arcaC/기술 노트
#회고#낙관적 업데이트#성능
조회 53
손가락이 닿기 전에 이미 채워진 하트 버튼 — 반 박자 먼저 움직이는 UI 일러스트

기능 목록에는 안 적히는 커밋들이 있어요. 새 기능도 버그 수정도 아닌, "조금 더 빨라 보이게, 조금 덜 흔들리게"만을 위한 커밋들. 이번 편은 그 커밋들의 이야기예요.

체감 속도는 실제 속도와 다른 문제예요

먼저 이걸 갈라야 뒤 이야기가 됩니다. 같은 300ms도 어디에 있느냐에 따라 완전히 다르게 느껴져요.

지연이 있는 자리사용자가 느끼는 것고치는 방법
클릭 → 화면 반응앱이 멈춤낙관 반영 — 먼저 그리고 나중에 확인
화면 전환느림미리 가져오기 · 전환 애니메이션
콘텐츠 로딩비어 있음자리를 미리 차지하는 스켈레톤
로딩 끝난 뒤 레이아웃 이동고장 난 느낌높이를 미리 예약

첫 줄이 제일 커요. 사람은 대략 100ms 안에 반응이 없으면 "내가 누른 게 맞나"를 의심하기 시작하거든요. 그 의심이 한 번 생기면 실제로 200ms가 걸렸든 500ms가 걸렸든 똑같이 느립니다.

그래서 이번 편의 커밋들은 대부분 서버를 빠르게 만든 게 아니에요. 기다리는 자리를 옮긴 거예요.

클릭보다 화면이 먼저

공감을 누르면 이제 서버 응답을 기다리지 않고 하트가 먼저 채워져요. 댓글도, 구독도 마찬가지 — 입력은 즉시 화면에 반영되고, 서버 확인은 반 박자 뒤에 따라옵니다. 낙관적 업데이트라고 불러요. 화면이 사용자를 기다리게 하는 대신, 시스템이 화면을 따라잡는 구조예요.

낙관 반영은 말은 쉬운데 상태가 하나 더 늘어나는 일이에요.

// 이렇게 짜면 금방 엉킵니다
setLiked(true);
await api.like(postId);   // 실패하면? 그 사이 또 누르면?

// 그래서 요청을 상태로 다룹니다
type Pending = {
  intent: "like" | "unlike";
  requestId: string;        // 멱등 키
  startedAt: number;
};

// 화면에 보이는 값 = 서버 값 + 대기 중인 의도
const shown = pending ? applyIntent(server, pending) : server;

여기서 requestId가 핵심이에요. 같은 요청을 다시 보내도 두 번 처리되지 않게 하는 열쇠거든요. 이게 없으면 재시도가 위험해지고, 재시도가 위험하면 실패했을 때 할 수 있는 게 롤백밖에 없어요.

멱등을 미리 깔아 둔 게 여기서 배당금으로 돌아왔습니다. 그때는 서버 쪽 안전장치라고만 생각했는데, 실제로는 화면이 과감해질 수 있게 해 주는 장치였어요.

실패하면요? 조용히 원상복구하고 끝내지 않아요. 실패한 그 자리에 인라인 재시도가 남아요 — 쓰던 댓글, 옮기던 글, 어디까지 했는지를 잃지 않게 실패의 의도를 보존합니다. 이게 안전한 건 4편에서 깔아둔 멱등 설계 덕이에요. 같은 요청을 다시 보내도 두 번 처리되지 않으니, 낙관과 재시도가 마음 편히 공존해요.

실패했을 때 뭘 하느냐가 이 편에서 제일 신경 쓴 부분이에요.

실패 처리사용자가 겪는 것
조용히 원상복구내가 뭘 했는지도 모른 채 사라짐 — 최악
토스트로 알리고 원상복구알긴 아는데 쓰던 걸 잃음
그 자리에 재시도를 남김다시 누르면 이어짐
// 실패해도 의도를 버리지 않습니다
if (result.failed) {
  setPending({ ...pending, state: "failed" });
  // 쓰던 댓글 본문, 옮기던 위치가 pending 안에 그대로 남습니다
}

// 화면에는 그 자리에 재시도 버튼이 남고
// 같은 requestId 로 다시 보냅니다

세 번째 줄을 고른 이유는 단순해요. 실패는 대부분 일시적이거든요. 지하철에서 터널을 지나는 중이라든가요. 그 몇 초 때문에 쓰던 댓글을 날리는 건 과한 벌입니다.

삭제처럼 무거운 동작에 낙관 반영을 붙이려면 조건이 하나 붙어요. 되돌릴 수 있어야 합니다.

// 목록에서 즉시 사라지되, 되돌릴 창을 엽니다
function deletePost(id) {
  const removed = list.find(p => p.id === id);
  setList(list.filter(p => p.id !== id));      // 즉시 사라짐

  const timer = setTimeout(() => commitDelete(id), UNDO_WINDOW);

  showUndo({
    label: "글을 삭제했어요",
    onUndo: () => {
      clearTimeout(timer);
      setList(list);                            // 원래 목록으로
    },
  });
}

서버 요청을 되돌리기 창이 닫힌 뒤에 보내는 게 포인트예요. 보냈다가 취소하는 게 아니라, 아직 안 보낸 겁니다. 그래서 되돌리기가 실패할 수가 없어요.

방식되돌리기 성공률서버 부담
즉시 삭제 후 복구 API복구가 실패할 수 있음삭제 + 복구 두 번
확인 대화상자되돌릴 일이 없음한 번 — 대신 매번 멈춤
지연 실행 + 되돌리기 창항상 성공한 번

세 번째가 좋은 이유는 사용자를 안 멈춰 세우면서도 안전하다는 거예요. 확인 대화상자는 안전한데, 열 번 중 아홉 번은 그냥 누르게 되니까 결국 안전장치 노릇을 못 합니다.

시리즈 순서 바꾸기도 같은 모양이에요. 드래그를 놓는 순간 화면에서는 바뀌고, 서버 저장은 조금 뒤에 한 번만 갑니다. 드래그하는 동안 매번 저장하면 요청이 수십 개 나가거든요.

낙관 반영은 반응에서 멈추지 않았어요. 글 삭제는 노션처럼 확인 즉시 목록에서 사라지고, 시리즈 순서는 행을 드래그하면 그 자리에서 바뀌어요. 뒤에서 서버가 따라오고요.

(낙관 UX가 늘어나니 인터랙션 코드도 정책/상태/명령 계층으로 갈라 레이어 검사를 붙였어요. 코어에서 하던 버릇 그대로예요.)

어디까지 낙관적으로 할까

낙관 반영을 아무 데나 붙이면 안 돼요. 되돌리기 어려운 일에 붙이면 거짓말이 됩니다.

동작낙관 반영이유
공감 · 구독실패해도 손해가 없고 되돌리기 쉬움
댓글 작성내용이 보존되면 재시도가 안전함
시리즈 순서 바꾸기순서는 언제든 다시 바꿀 수 있음
글 삭제함 (되돌리기 제공)목록에서 즉시 사라지되 취소할 수 있게
발행안 함남에게 보이기 시작하는 일이라 확인이 먼저

마지막 줄이 기준이에요. 결과가 나 혼자만의 것이면 먼저 그려도 되고, 남에게 나가는 일이면 확인부터 합니다.

그리고 낙관 반영이 늘어나면서 인터랙션 코드를 정책·상태·명령 계층으로 갈랐어요. 안 그러면 컴포넌트 하나가 "언제 낙관할지"와 "어떻게 그릴지"를 같이 알게 되거든요.

낙관 반영이 늘어나면서 컴포넌트가 너무 많은 걸 알게 됐어요. 그래서 셋으로 갈랐습니다.

// 정책  "이 동작은 낙관적으로 해도 되는가"
const policy = {
  like:    { optimistic: true,  undo: false },
  comment: { optimistic: true,  undo: false },
  delete:  { optimistic: true,  undo: true  },
  publish: { optimistic: false, undo: false },
};

// 상태  대기 중인 의도를 들고 있음
// 명령  실제 요청을 보내고 결과를 반영

// 컴포넌트가 아는 
const { shown, run } = useAction("like", subject);
<HeartButton filled={shown} onClick={run} />
갈라 두지 않으면갈라 두면
컴포넌트가 낙관 여부를 스스로 판단정책 표 한 곳에서 결정
같은 동작이 화면마다 다르게 동작어디서 부르든 같음
새 동작마다 낙관 로직을 다시 씀표에 한 줄 추가
정책 변경 시 컴포넌트를 다 찾아야 함표만 고침

네 번째 줄이 실제로 겪은 일이에요. "발행도 낙관적으로 해 볼까"를 검토하다가, 그러려면 어디를 고쳐야 하는지 찾을 수가 없었거든요. 그때 갈랐습니다.

코어에서 하던 버릇 그대로예요. 판단하는 곳과 그리는 곳을 갈라 두면, 판단이 바뀔 때 그리는 곳은 안 건드립니다.

흔들리지 않는 화면

빠른 것만큼 중요한 게 안 흔들리는 거예요. 글 본문이 로딩되는 동안 스켈레톤이 실제 콘텐츠와 같은 높이를 미리 차지해서, 로딩이 끝나도 화면이 털썩 내려앉지 않아요. 페이지 전환도 CSR 로딩 흐름으로 정리했고, 로그아웃 확인의 어색한 지연도 걷어냈어요.

흔들림을 잡는 건 대부분 높이 예약이에요.

/* 스켈레톤이 실제 콘텐츠와 같은 높이를 미리 차지합니다 */
.post-body-skeleton { min-height: var(--estimated-body-height); }

/* 이미지는 비율을 미리 알려 둡니다 */
.thumbnail { aspect-ratio: 16 / 9; }

/* 폰트가 늦게 와도 줄 높이가 안 바뀌게 */
.post-body { line-height: 1.75; }

두 번째 줄이 제일 효과가 컸어요. 이미지 크기를 안 적어 두면 이미지가 도착하는 순간 아래 있던 글이 통째로 밀려 내려갑니다. 읽던 문장이 사라지는 거죠.

그리고 이건 "예쁘게 보이기"가 아니라 "읽던 자리를 지키기"의 문제예요. 화면이 한 번 튀면 사용자는 방금 어디를 읽고 있었는지 다시 찾아야 하거든요.

흔들림을 어떻게 재나

"안 흔들린다"도 눈으로 판정하면 안 돼요. 개발 기계에서는 로딩이 빨라서 흔들림이 안 보이거든요.

// 레이아웃 이동을 직접 관찰합니다
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;   // 사용자 조작에 의한 이동은 제외
    console.warn("레이아웃 이동", entry.value, entry.sources);
  }
});
observer.observe({ type: "layout-shift", buffered: true });

`sources`에 어떤 요소가 밀렸는지가 들어 있어요. 그래서 "느낌상 튄다"가 아니라 "이 요소가 이만큼 밀었다"로 이야기할 수 있습니다.

원인증상처방
이미지 크기 미지정이미지 도착 시 아래가 밀림비율을 미리 선언
스켈레톤 높이가 실제와 다름로딩 끝나면 털썩예상 높이를 데이터로 계산
늦게 오는 폰트줄 높이가 바뀜줄 높이 고정 · 대체 폰트 지표 맞춤
조건부 배너뒤늦게 나타나 전체를 밈자리를 미리 비워 두거나 겹쳐 띄움

두 번째 줄은 스켈레톤을 대충 만들면 오히려 더 나빠지는 경우예요. 아무것도 없는 것보다, 잘못된 높이를 차지하다가 바뀌는 게 더 튑니다.

잔잔한 것들

다듬은 것
시각 표기를 24시간제로 고정서버와 브라우저가 다르게 표기하면 hydration이 어긋나요
댓글 조회 실패 안내를 유형별로"실패했습니다"보다 "지금 다시 눌러보세요"가 낫죠
링크 편집 경험·로딩 레이아웃 정리자주 쓰는 길일수록 턱이 없어야 해요
상세 화면 정보 밀도 조정읽는 밀도는 높게, 시각 소음은 낮게

(그리고 지금 레포의 최신 커밋은 "대표 이미지를 썸네일 스키마로 통합"이에요. 다음 배포부터 이 글의 썸네일도 그 스키마로 옮겨 갑니다.)

체감 최적화의 함정

정직하게 하나 적어 둘게요. 이 작업들이 문제를 가리는 효과도 있었습니다.

클릭이 빨라 보이니까 서버가 여전히 느리다는 걸 한동안 못 느꼈어요. 프리패치가 걸리지 않는 첫 진입에서는 원래 속도가 그대로 나오는데, 그 경로를 제가 자주 안 밟았거든요.

최적화한 경로자주 밟는 사람실제로 느린 경로
링크 클릭 후 전환나 (계속 돌아다님)첫 진입
공감·댓글 반응
목록에서 상세로검색 유입 → 상세 직행

오른쪽 열이 실제 독자의 경로예요. 검색으로 들어온 사람은 첫 화면이 곧 마지막 화면일 수도 있는데, 그 경로를 만든 사람이 제일 안 밟습니다.

나중에 첫 응답 시간을 재보고서야 알았어요. 그래서 다음 편에서 서버 쪽을 손봅니다.

체감 속도는 기다림을 견디게 해주고, 실제 속도는 기다림을 없애요. 둘 다 필요한데, 순서를 착각하면 진짜 문제를 늦게 발견하게 됩니다.


체감 속도로 할 수 있는 건 여기까지예요. 이건 전부 "이미 도착한 다음"을 다듬은 커밋들이거든요. 도착하기까지의 시간을 줄인 이야기는 다음 편에 이어집니다.

시리즈 이어읽기

빠르게 느껴지게

2 / 4

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.