본문으로 건너뛰기

네비게이션 악세서리 버튼 UX 패턴

partner.addAccessoryButton은 상단 네비게이션 바에 아이콘+라벨 버튼을 한두 개 끼워 넣는 API입니다. 자체로는 간단하지만 이벤트 구독(tdsEvent.navigationAccessoryEvent)과 클린업(partner.removeAccessoryButton)이 한 세트로 묶여야 하고, 화면 전환 사이에 버튼이 새 거지처럼 남거나, 같은 핸들러가 두 번 등록되거나, 다른 화면의 클릭을 가로채는 버그가 잘 생깁니다. 이 가이드는 그 세 호출의 라이프사이클 계약과 멀티 버튼·상태 토글까지 한 곳에 정리합니다.

한눈에 — 라이프사이클

[화면 mount] [상단바] [tdsEvent]
│ │ │
│ partner.addAccessoryButton │ │
├─────────────────────────────►│ 버튼 렌더 │
│ │ │
│ tdsEvent.addEventListener( │ │
│ 'navigationAccessoryEvent')│ │
├─────────────────────────────────────────────────────────►│
│ │ │
│ │ ◄── 사용자 탭 ─── │ │
│ │ │
│ ◄────────────────────── { id, ... } ──────────────────────┤
│ │ │
│ [같은 id면 핸들러 실행] │ │
│ │ │
[화면 unmount] │ │
│ unsubscribe() │ │
├─────────────────────────────────────────────────────────►│
│ partner.removeAccessoryButton│ │
├─────────────────────────────►│ 버튼 제거 │

핵심 계약:

  • 버튼 추가와 이벤트 구독은 한 쌍addAccessoryButton 만 호출하고 이벤트를 구독하지 않으면 사용자가 눌러도 아무 일도 안 일어납니다. 반대로 구독만 하고 버튼은 추가하지 않으면 영원히 콜백이 안 옵니다.
  • id는 핸들러의 라우팅 키 — 한 화면에 버튼이 여러 개거나, 컨테이너의 기본 버튼이 함께 떠 있을 수 있으므로 콜백 안에서 항상 event.id === BUTTON_ID로 비교.
  • 클린업도 한 쌍removeAccessoryButtonunsubscribe()를 같은 cleanup 함수에서 호출. 한쪽만 하면 stale 버튼 또는 stale 핸들러가 남습니다.

표준 호출 시퀀스

화면(useEffect) 단위 mount/unmount에 묶어 두는 패턴.

import { partner, tdsEvent } from '@apps-in-toss/web-framework';
import { useEffect } from 'react';

const FAVORITE_BUTTON_ID = 'btn-favorite';

function ProductDetailPage({ productId }: { productId: string }) {
useEffect(() => {
partner.addAccessoryButton({
id: FAVORITE_BUTTON_ID,
title: '즐겨찾기',
icon: { name: 'icon-star-mono' },
});

const unsubscribe = tdsEvent.addEventListener(
'navigationAccessoryEvent',
(event) => {
if (event.id !== FAVORITE_BUTTON_ID) return;
toggleFavorite(productId);
},
);

return () => {
partner.removeAccessoryButton();
unsubscribe();
};
}, [productId]);

return <main>{/* 상세 콘텐츠 */}</main>;
}

여기서 addAccessoryButton의 결과 Promise는 일부러 await 하지 않습니다 — cleanup이 이미 등록돼 있으니, 컴포넌트가 빨리 unmount 돼도 다음 라이프사이클에서 결국 짝지어 제거됩니다. 만약 결과를 await 해야 하는 비동기 흐름(예: 추가 직후 다른 SDK 호출)이라면 아래 "비동기 추가와 race condition" 섹션 참고.

id는 단일 책임 키

한 화면이 하나의 버튼만 가진 단순한 케이스라도 상수로 분리해 두는 게 안전합니다.

const FAVORITE_BUTTON_ID = 'btn-favorite';
  • 다른 화면과 충돌 회피 — 화면 전환이 비동기적으로 일어나 두 화면의 cleanup/setup이 한 프레임 안에 섞일 수 있습니다. id가 글로벌하게 같으면 어느 쪽 핸들러로 라우팅되는지가 불명확.
  • 여러 버튼 화면'btn-favorite', 'btn-share' 식으로 분리해 한 콜백 안에서 switch.
  • 로깅·테스트 명료 — analytics에 event.id를 그대로 실으면 분석에서 버튼별 클릭 분리.

서비스 단위로 BUTTON_IDS namespace를 두고 모든 화면이 거기서만 골라 쓰면 충돌이 구조적으로 막힙니다.

export const BUTTON_IDS = {
productFavorite: 'product:favorite',
productShare: 'product:share',
cartCheckout: 'cart:checkout',
} as const;

여러 버튼이 필요한 화면

addAccessoryButton은 호출당 하나의 버튼을 등록합니다. 두 개가 필요하면 두 번 호출하고, cleanup도 짝수만큼 합니다 — 단, removeAccessoryButton은 인자가 없어 마지막에 추가된 버튼을 제거합니다(SDK 동작). 안전한 방법은 unmount 시 컨테이너가 화면 전환과 함께 모든 악세서리 버튼을 자동 정리한다고 가정하지 말고, 각 버튼마다 명시적으로 removeAccessoryButton을 한 번씩 호출합니다.

useEffect(() => {
partner.addAccessoryButton({ id: 'btn-share', title: '공유', icon: { name: 'icon-share-mono' } });
partner.addAccessoryButton({ id: 'btn-favorite', title: '즐겨찾기', icon: { name: 'icon-star-mono' } });

const unsubscribe = tdsEvent.addEventListener('navigationAccessoryEvent', (event) => {
switch (event.id) {
case 'btn-share': onShare(); return;
case 'btn-favorite': onFavorite(); return;
}
});

return () => {
partner.removeAccessoryButton();
partner.removeAccessoryButton();
unsubscribe();
};
}, []);
removeAccessoryButton에 인자가 없습니다

removeAccessoryButton(id) 형태를 지원하지 않습니다. 두 번 추가했으면 두 번 제거. 한 번만 제거하면 stale 버튼이 다음 화면까지 따라갑니다.

상태에 따라 버튼이 바뀔 때

"즐겨찾기 ↔ 즐겨찾기 해제"처럼 같은 슬롯의 라벨/아이콘이 토글되는 경우. 두 가지 접근:

1. 같은 id로 다시 추가

addAccessoryButton을 같은 id로 다시 호출해도 SDK는 새 설정으로 덮어쓰지 않을 수 있습니다(컨테이너 구현 의존). 보장되는 동작은 **removeAccessoryButton 후 다시 addAccessoryButton**입니다.

function toggleFavorite() {
const next = !isFavorite;
setIsFavorite(next);
partner.removeAccessoryButton();
partner.addAccessoryButton({
id: FAVORITE_BUTTON_ID,
title: next ? '즐겨찾기 해제' : '즐겨찾기',
icon: { name: next ? 'icon-star-fill' : 'icon-star-mono' },
});
}

2. 상태 의존 effect

useEffect 자체를 토글 상태에 묶고, 자연스러운 cleanup → 재등록으로 처리. 단순하지만 매 토글마다 remove → add 한 사이클이 추가됩니다.

useEffect(() => {
partner.addAccessoryButton({
id: FAVORITE_BUTTON_ID,
title: isFavorite ? '즐겨찾기 해제' : '즐겨찾기',
icon: { name: isFavorite ? 'icon-star-fill' : 'icon-star-mono' },
});
return () => partner.removeAccessoryButton();
}, [isFavorite]);

// 이벤트 구독은 별도 effect에서 한 번만.
useEffect(() => {
return tdsEvent.addEventListener('navigationAccessoryEvent', handler);
}, []);

이벤트 구독은 토글과 무관하므로 별도 effect로 분리해 매 토글마다 재구독되지 않게 합니다.

비동기 추가와 race condition

addAccessoryButtonPromise<void>를 반환합니다. cleanup이 promise resolve 전에 실행되면 — 즉 mount → unmount가 매우 빠르게 일어나면 — "추가" 효과가 cleanup 후에 적용돼 stale 버튼이 다음 화면까지 살아남습니다. React 18 strict mode의 더블 마운트가 그 트리거.

방어 패턴: cancelled flag.

useEffect(() => {
let cancelled = false;

(async () => {
await partner.addAccessoryButton({ id, title, icon });
if (cancelled) {
partner.removeAccessoryButton(); // 이미 떠난 화면의 버튼을 즉시 회수
}
})();

const unsubscribe = tdsEvent.addEventListener('navigationAccessoryEvent', handler);

return () => {
cancelled = true;
partner.removeAccessoryButton();
unsubscribe();
};
}, []);

cancelled 플래그가 켜진 뒤 add가 resolve되면 즉시 remove 한 번을 더 던져 cleanup의 remove와 짝을 맞춥니다.

컨테이너 기본 버튼과의 충돌

상단 네비게이션 바에는 컨테이너가 기본으로 띄우는 버튼(공유, 메뉴 등)이 함께 떠 있을 수 있습니다. 클릭 이벤트가 navigationAccessoryEvent로 같이 흘러들어오지는 않지만(컨테이너 버튼은 별도 도메인), 시각적 공간이 제한돼 있어 악세서리 버튼은 1–2개 이내로 유지하는 게 안전합니다. 셋 이상은 잘 보이지 않고 컨테이너 버전 업그레이드 시 잘려나갈 위험.

흔한 실수 체크리스트

  • 버튼은 추가했는데 이벤트 구독을 안 했나요? 사용자가 눌러도 콜백이 안 옵니다 — analytics에 클릭이 안 잡힙니다.
  • 콜백 안에서 event.id를 확인하지 않고 바로 핸들러를 실행하나요? 다른 버튼의 이벤트까지 처리됩니다.
  • 화면 unmount 시 removeAccessoryButton을 호출하지 않나요? 다음 화면 상단바에 stale 버튼이 남습니다.
  • 두 번 추가한 화면이 한 번만 제거하나요? 한 개가 다음 화면까지 살아남습니다.
  • 토글마다 addAccessoryButton을 같은 id로 다시 호출만 하나요? 컨테이너에 따라 첫 등록이 그대로 유지될 수 있어 라벨이 stale.
  • id를 글로벌 단어('btn', 'fav')로 두진 않았나요? 다른 화면과 충돌합니다 — 서비스 단위 namespace를 권장.

환경별 차이

  • 실 토스 앱: 상단 네비게이션 바에 즉시 렌더. 컨테이너 버전에 따라 아이콘 토큰 이름·렌더 위치가 약간 다를 수 있습니다.
  • devtools mock: @ait-co/devtools 패널이 악세서리 버튼을 가상 상단바로 렌더. 클릭은 패널에서 직접 시뮬레이션.
  • 외부 브라우저: 컨테이너가 없어 호출이 throw. mini-app 환경 밖에서는 사용하지 않습니다.

관련 API

관련 가이드

외부 참조