멤버십 보라 링 두르기 — 50곳을 하나로 묶어낸 이야기

발단
우측 상단 본인 프로필에만 두르고 있던 멤버십 표식(보라색 링)을 화면에 노출되는 모든 유저 아바타에 적용해달라는 요구가 들어왔다. "내가 멤버인 게 안 보이니 돈 쓴 보람이 없다"는 합당한 불만이었다.
문제의 본질을 정리하면 셋이었다.
아바타가 어디에 몇 개 있는지 아무도 모른다 —
Avatar컴포넌트가 따로 있는 게 아니라 페이지마다 인라인 마크업으로 박혀 있다.다른 유저의 멤버십 상태를 어떻게 알아낼지 정해진 패턴이 없다 — 기존엔 본인 것만 한 번 조회했다.
같은 디자인을 50곳에 손으로 박는 건 어려운 일이다 — 한 번 손대고 두 번 다시 만지고 싶지 않다.
탐색 — 30곳이라고 계획했는데, 실제로는 50곳이었다
Explore 에이전트로 코드베이스를 훑으니 아바타 렌더링이 약 50개 파일에 흩어져 있었고, 패턴도 제각각이었다.
사이즈: w-5 / w-7 / w-8 / w-9 / w-10 / w-11 / w-12 / w-14 / w-[52px] / w-24
이미지: next/image (width/height 또는 fill) / 일반 <img> 태그 혼재
폴백: User 아이콘 / 기본 SVG 이미지 / 닉네임 첫 글자 (gradient or muted bg)
래퍼: <Link>로 감싼 것 / 일반 <div>
같은 디자인을 의도한 곳도 클래스가 미묘하게 달랐다. bg-primary/20이 어떤 곳은 bg-primary/15로 적혀 있는 식이다. 이 변형을 하나하나 보존하면서 멤버십 링만 추가하면 50번의 손길이 든다.
결론: 공통 컴포넌트 <PremiumAvatar>로 흡수하고 사용처를 일괄 교체한다.
설계 결정 세 가지
1. 구독 여부 데이터는 어디서?
subscriptions 테이블은 이미 있었다.
create table subscriptions (
user_id uuid not null,
status varchar(20) not null,
period_end timestamptz not null,
...
);
create unique index idx_unique_active_subscription
on subscriptions(user_id)
where status in ('active', 'past_due');
핵심은 저 partial unique index. active/past_due 상태인 row만 색인하므로 인덱스 크기는 "현재 활성 구독자 수"에 비례한다. 만료된 과거 구독은 인덱스에 없다.
대안 3개를 저울질했다.
방안장점단점users.is_subscribed 컬럼 denormalize + 트리거기존 users 쿼리에 자동 포함, 추가 쿼리 0DB 마이그레이션, 트리거 관리, 만료 시점 처리 까다로움모든 RPC 반환값에 is_subscribed 추가RSC 패턴은 그대로get_posts, get_feeds, get_messages_after_joined 등 10개+ RPC 수정페이지 RSC에서 batch 조회DB/RPC 변경 0, 페이지당 +1쿼리만페이지마다 user_id Set 모으는 보일러 필요
세 번째를 택했다. 페이지당 한 번 WHERE user_id = ANY($1) AND status='active' AND period_end > now() 쿼리만 추가하면 끝이고, partial index를 그대로 탄다.
export async function getSubscribedUserIds(
supabase: SupabaseClient,
userIds: (string | null | undefined)[]
): Promise<Set<string>> {
const uniq = Array.from(new Set(userIds.filter((id): id is string => !!id)));
if (uniq.length === 0) return new Set();
const { data } = await supabase
.from("subscriptions")
.select("user_id")
.in("user_id", uniq)
.eq("status", "active")
.gt("period_end", new Date().toISOString());
return new Set((data ?? []).map((r) => r.user_id));
}
2. prop drilling을 어떻게 피할까?
아바타가 깊이 박혀 있어서 매번 isSubscribed prop을 5단계 내려주는 건 끔찍하다. Context로 페이지 단위 구독자 Set을 공유한다.
export function SubscribersProvider({
initial, children,
}: { initial: string[]; children: React.ReactNode }) {
const [set, setSet] = useState<Set<string>>(() => new Set(initial));
const addSubscribers = useCallback((ids: string[]) => {
setSet((prev) => {
const next = new Set(prev);
ids.forEach((id) => id && next.add(id));
return next;
});
}, []);
...
}
export function useIsSubscribed(userId: string | null | undefined) {
return useContext(SubscribersContext).isSubscribed(userId);
}
addSubscribers는 무한 스크롤이나 새 댓글처럼 클라이언트가 추가 fetch한 유저를 Context에 흘려보내기 위한 출구다. 패턴은 다음과 같다.
// 페이지 RSC
const visibleUserIds = [feedAuthorId, ...commentAuthorIds, ...participantIds];
const subscribedSet = await getSubscribedUserIds(supabase, visibleUserIds);
return (
<SubscribersProvider initial={Array.from(subscribedSet)}>
<FeedDetailClient ... />
</SubscribersProvider>
);
// 클라이언트 — 새 댓글 fetch 후
const refreshed = await getCommentsAction(feedId);
setComments(refreshed.data);
const newIds = refreshed.data.map((c) => c.user_id);
const sub = await getSubscribedUserIdsAction(newIds);
if (sub.length > 0) addSubscribers(sub);
3. PremiumAvatar는 변형을 어디까지 흡수하나?
size는 6단계로 표준화하되, 어차피 페이지마다 미세하게 다른 픽셀(w-7, w-11, w-14)을 위해 className 오버라이드를 허용했다. cn()이 tailwind-merge를 거치므로 w-X가 충돌하면 뒤에 오는 className이 이긴다.
<PremiumAvatar
size="sm"
className="w-7 h-7" // 28px로 강제 (size="sm"의 32px를 덮음)
userId={feed.userId}
imageUrl={feed.profileImageUrl}
nickName={feed.nickname}
/>
isSubscribed는 prop으로 명시할 수도 있고(GNB 본인처럼), userId만 주면 Context에서 자동 조회된다(타인 아바타).
디자인 진화 — 단색 ring은 "돈 쓴 느낌"이 안 난다
첫 구현은 ring였다. 빌드 끝내고 화면을 보니 작은 사이즈(w-8)에선 거의 안 보였고, 큰 사이즈에서도 그냥 "어, 보라색 테두리네" 정도였다. Discord, X Premium이 왜 그라데이션을 쓰는지 체감했다.
리팩토링 방향:
단색
ring-2→ 그라데이션 padding wrapper두께 사이즈별 차등 (xs
2px→ 2xl4px)색상: 보라 → 라일락 → 매젠타
CSS ring은 단색만 지원하므로 그라데이션 링을 만들려면 wrapper에 gradient bg + padding을 주고, inner div에 실제 아바타를 넣어 격리하는 트릭을 쓴다.
const wrapperClass = cn(
"rounded-full shrink-0 inline-flex items-center justify-center",
s.box,
subscribed
? cn(s.pad, "bg-gradient-to-tr from-[#6332EB] via-[#A855F7] to-[#EC4899]")
: cn("bg-muted ring-2 ring-transparent", interactive && "hover:ring-primary/50"),
className,
);
return (
<div className={wrapperClass}>
<div className="w-full h-full rounded-full overflow-hidden bg-muted">
{imageOrFallback}
</div>
</div>
);
비멤버십은 ring-2 ring-transparent를 유지해서 멤버십 ON/OFF로 레이아웃이 흔들리지 않게 했다. 이게 없으면 본인이 결제하는 순간 화면이 한 픽셀씩 들썩인다.
성능 — 페이지당 +1쿼리, 그래서 얼마나 비싼가?
부담은 낮다. 이유는 세 가지.
partial unique index가
user_id = ANY($1)조회를 인덱스 lookup으로 처리한다. 인덱스 자체가 "현재 활성 구독자 수" 크기라 매우 작다.RSC에서 기존
Promise.all(...)안에 합치면 wall clock 영향이 거의 없다.멤버십 정보는 분 단위 신선도면 충분하므로, 트래픽이 커지면 캐싱으로 거의 0에 수렴시킬 수 있다.
스케일이 커질 때 점진적으로 올릴 수 있는 단계:
임계점최적화같은 요청 내 중복 호출이 보이기 시작React cache()로 getSubscribedUserIds 래핑 — 요청 스코프 중복 제거트래픽이 충분히 커서 1쿼리도 아까울 때unstable_cache + 1–5분 TTLsubscriptions가 수십만 row + 페이지당 user_id 수백 개users.subscription_active_until denormalize + 트리거
지금은 그대로 두고, 필요할 때 한 단계씩 올리면 된다는 결론.
회고
디자인 한 줄짜리 요청이 50개 파일이 되는 이유: 공통 컴포넌트가 없었기 때문. 이번 작업 이후엔 "유저 표식 추가"가 PremiumAvatar 한 곳 수정으로 끝난다. 다음에 온라인 배지/검증 체크가 들어와도 동일하다.
단색 ring → 그라데이션 ring 변경이 PremiumAvatar 파일 하나만 수정해서 50곳에 자동 반영된 순간이 이번 작업의 보상이었다. 통합의 가치는 첫 구현 때가 아니라 두 번째 변경 때 체감된다.
데이터 소스 결정이 가장 중요했다. DB 컬럼 추가/RPC 수정으로 갔다면 마이그레이션·트리거·롤백 시나리오까지 늘어졌을 텐데, 페이지 RSC + Context 조합으로 DB 스키마는 손도 안 댔다.
ring-transparent유지처럼 사소해 보이는 디테일 — 레이아웃 흔들림 방지, 사이즈별 padding 차등,tailwind-merge로 픽셀 미세 오버라이드 허용 — 이런 게 "느낌은 다르지만 코드는 한 곳"을 가능하게 한다.
한 줄 요약: 50곳에 흩어진 패턴을 하나로 모으면, 디자인 변경 1번이 PR 1개로 끝난다. 이번 작업의 진짜 산출물은 보라 링이 아니라 PremiumAvatar 그 자체였다.