Perplz Logo
Productivity

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

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

발단

우측 상단 본인 프로필에만 두르고 있던 멤버십 표식(보라색 링)을 화면에 노출되는 모든 유저 아바타에 적용해달라는 요구가 들어왔다. "내가 멤버인 게 안 보이니 돈 쓴 보람이 없다"는 합당한 불만이었다.

문제의 본질을 정리하면 셋이었다.

  1. 아바타가 어디에 몇 개 있는지 아무도 모른다 — Avatar 컴포넌트가 따로 있는 게 아니라 페이지마다 인라인 마크업으로 박혀 있다.

  2. 다른 유저의 멤버십 상태를 어떻게 알아낼지 정해진 패턴이 없다 — 기존엔 본인 것만 한 번 조회했다.

  3. 같은 디자인을 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 → 2xl 4px)

  • 색상: 보라 → 라일락 → 매젠타

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쿼리, 그래서 얼마나 비싼가?

부담은 낮다. 이유는 세 가지.

  1. partial unique indexuser_id = ANY($1) 조회를 인덱스 lookup으로 처리한다. 인덱스 자체가 "현재 활성 구독자 수" 크기라 매우 작다.

  2. RSC에서 기존 Promise.all(...) 안에 합치면 wall clock 영향이 거의 없다.

  3. 멤버십 정보는 분 단위 신선도면 충분하므로, 트래픽이 커지면 캐싱으로 거의 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 그 자체였다.