🐝매일 한입
개발일지📖 13분 읽기

DaySeed — 이니셜뿐이던 얼굴에 프로필 사진을, 같은 그룹 멤버에게만 보이게

프로필 사진을 비공개 Storage 와 서명 URL 로 붙이고, 사진을 이니셜 원 위에 겹쳐 호출부를 고치지 않았습니다. 같은 날 거꾸로 서 있던 설정 탭 위계를 바로 세우고 튀던 모달 움직임을 줄인 기록입니다.

📖 13분 읽기
#DaySeed#프로필 사진#Supabase Storage#RLS#React Native#Expo OTA#1인개발

글자 한 개로 사람을 구분하던 화면

DaySeed 에서 사람을 나타내는 표시는 지금까지 닉네임 첫 글자와, 사용자 id 를 해시해 고른 색 원 하나뿐이었습니다. 혼자 쓸 때는 상관이 없는데, 함께 쓰는 그룹은 최대 4명입니다. 공유 멤버 목록, 히스토리, 일정 참여자, 위시의 "함께 고르기" 에서 누가 누구인지를 글자 하나로 구분해야 했습니다. 닉네임이 같은 글자로 시작하거나, 아예 비어 있어서 "나"·"함께 쓰는 사람" 으로 나오면 원은 사실상 같아 보입니다. profiles 테이블은 v1.3 때부터 "future fields (avatar…)" 로 아바타 자리를 예고만 해 두고 칸을 만들지 않은 상태였습니다.

10월 22일에 정한 제품 결정은 세 가지입니다. 사진은 같은 그룹 멤버만 본다. 출처는 앨범만, 카메라는 넣지 않는다. 표시 위치는 지금 이니셜 원이 있는 곳 전부. 위젯·Live Activity·푸시 이미지, 캘린더 막대 위 사진, 다른 멤버가 사진을 바꿨을 때의 실시간 반영은 범위에서 뺐습니다.

공개 범위를 Storage 정책으로 묶기

사진 파일은 비공개 버킷 avatars 에 {user_id}/{uuid}.jpg 로 두고, profiles 에는 경로만 적습니다. 누가 읽을 수 있는지는 기존 프로필 행과 같은 기준인 shares_group_with — 나와 같은 그룹에 있는지 확인하는 함수 — 로 Storage 정책에 걸었습니다.

insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', false, 524288, array['image/jpeg'])
on conflict (id) do update
  set public = false, file_size_limit = 524288, allowed_mime_types = array['image/jpeg'];

create policy avatars_select on storage.objects for select to authenticated
  using (
    bucket_id = 'avatars'
    and (public.avatar_owner_from_path(name) = auth.uid()
         or public.shares_group_with(public.avatar_owner_from_path(name)))
  );

create policy avatars_insert on storage.objects for insert to authenticated
  with check (bucket_id = 'avatars' and public.avatar_owner_from_path(name) = auth.uid());

정책은 파일 경로의 첫 조각을 소유자로 읽습니다. profiles.avatar_path 에도 같은 모양의 check 제약을 걸어서, 내 프로필이 남의 폴더를 가리킬 수 없게 했습니다. spec 의 정책 초안은 owner = auth.uid() 로 적혀 있는데 마이그레이션에서는 경로 기준으로 바뀌었고, 바꾼 이유는 기록에 남아 있지 않습니다. update 정책은 일부러 만들지 않았습니다. 사진을 바꿀 때마다 새 uuid 로 새 파일을 올리니 덮어쓸 일이 없고, 경로가 바뀌면 앱의 이미지 캐시 키도 자연히 바뀝니다.

마이그레이션은 컬럼을 더하기만 해서, 2.1.1 이하 앱에는 이니셜만 계속 보입니다. 업로드 전에는 긴 변 512px, JPEG 로 다시 인코딩해 촬영 위치 같은 메타데이터를 지우고, 앨범은 시스템 선택기로 열어 사진 보관함 권한을 묻지 않습니다. 위시 사진 때와 같은 이유입니다.

바꾸기에서 신경 쓴 것은 순서입니다. 새 파일을 먼저 올리고, 프로필 경로를 바꾸고, 그다음에 옛 파일을 지웁니다. 중간에 실패하면 새로 올린 파일만 지우고 옛 사진은 그대로 둡니다.

      const { error } = await supabase.storage.from(BUCKET).upload(path, body, { contentType: 'image/jpeg', upsert: false });
      if (error) throw error;
      uploaded = path;
      if (!(await writeAvatarPath(userId, email, path))) throw new Error('profile row not updated');
      uploaded = null;
      // Show the re-encoded local file right away; the next read signs the path.
      setCached(userId, path, out.uri);
      if (previous && previous !== path) await removeFiles([previous]);
      return true;
    } catch (e) {
      fireSyncError('avatar:set', e);
      if (uploaded) await removeFiles([uploaded]);
      return false;
    }

옛 파일 삭제가 실패하면 보이지 않는 파일이 남을 뿐이라 로그만 남깁니다. 닉네임을 저장하는 기존 업서트에는 avatar_path 를 넣지 않았습니다. 넣으면 닉네임을 고칠 때 사진이 지워질 수 있어서입니다. 계정 삭제 함수 delete-account 는 사용자 삭제 전에 avatars/{uid}/ 아래를 통째로 지우도록 늘렸습니다. 사진 바꾸기가 중간에 실패해 남은 파일도 이때 같이 쓸려 나갑니다.

표시 쪽은 Avatar 컴포넌트 하나만 고쳤습니다. 사진 URL 이 있으면 이니셜 원 위에 같은 크기의 이미지를 겹칩니다. 사진을 받는 동안이나 받지 못했을 때는 밑에 있던 이니셜이 그대로 보여서 깜빡이지 않고, props 가 그대로라 이 컴포넌트를 쓰는 화면들은 손대지 않았습니다. 목록에서 행마다 따로 요청이 나가지 않도록, 같은 틱에 들어온 요청은 모아서 프로필 조회 한 번과 서명 URL 발급 한 번으로 처리합니다. 경로는 10분, 서명 URL 은 50분 동안 재사용합니다. 다른 멤버가 사진을 바꾸면 10분 안에 화면을 다시 열거나 앱을 다시 시작해야 보이는데, 이건 Realtime 을 범위에서 뺀 값입니다. 배경에서 읽다 실패해도 오류 배너는 띄우지 않습니다. 이니셜로 대신되니 사용자가 할 일이 없습니다.

사진 넣을 자리를 찾다가 설정 탭 전체를 다시 세웠다

프로필 사진 행은 처음에 설정 › 계정 맨 위에 두었습니다. 그런데 시뮬레이터에서 확인하다 보니 그 "계정" 묶음이 설정 탭 맨 아래에 있었습니다. 나를 나타내는 프로필이 바닥에 있고, 맨 위는 알림 토글 6개였습니다. 함께 쓰기와 캘린더 설정은 캘린더 헤더의 톱니로만 들어갈 수 있어서, "설정" 탭에서 찾으면 못 찾습니다. 같은 날 별도 intent 로 위계를 다시 잡았습니다.

DaySeed 캘린더 월 화면, 8월 달력에 일정과 기념일이 날짜마다 색 라벨로 표시되고 위쪽에 검색·설정 아이콘이 있다 캘린더 월 화면(스토어 스크린샷)입니다. 함께 쓰기·캘린더 설정은 그동안 이 화면 위쪽 설정 아이콘으로만 들어갈 수 있었고, 이번에 설정 탭에도 진입점을 더했습니다.

새 순서는 프로필 카드(사진 64pt·닉네임·이메일) → 함께 쓰기·캘린더 → 광고 제거 → 알림 → 화면(테마·언어) → 데이터·개인정보 → 정보 → 로그아웃·계정 삭제입니다. 사진은 프로필 카드의 아바타 버튼으로 옮겼고, 함께 쓰기 행에는 그룹 이름과 멤버 얼굴을 겹쳐 보여 줍니다. 캘린더 헤더 톱니는 그대로 두고 경로만 하나 더한 것입니다. 회색 띠 위에 끝까지 닿는 흰 행이던 묶음은 함께 쓰기 화면과 같은 카드형(여백 16, radius 12)으로 통일했습니다.

시뮬레이터 확인 중에 나온 피드백이 하나 더 있었습니다. 모달 등장이 과하다는 것입니다. 모달 카드는 스프링을 타고 통통 튀며 올라왔고, 액션 메뉴는 화면 아래 끝에서부터 스프링으로 올라왔습니다. 둘 다 오버슈트 없는 페이드 + 짧은 상승으로 바꿨습니다.

export function popInEntering(rise = 8) {
  return FadeInDown.duration(durations.modal)
    .easing(Easing.bezier(0.16, 1, 0.3, 1))
    .withInitialValues({ opacity: 0, transform: [{ translateY: rise }] });
}

0.22초 동안 8px(액션 메뉴는 16px) 떠오르며 나타납니다. 이 함수를 쓰는 시간·날짜·월 피커, 알림 시점 칩 같은 모달 카드 전부가 같이 바뀌었습니다. 액션 메뉴는 Reduce Motion 에서 전과 같이 페이드만 합니다.

확인한 것과 못 한 것

plan 의 출시 기록에 따르면 10월 22일, 로컬 Supabase 전체 스택과 테스트 계정 3개로 시뮬레이터 인수 확인을 한 뒤 운영 DB 에 마이그레이션 20261022000000 을 적용하고 버킷·컬럼·정책 3개를 다시 읽어 확인했습니다. 이어서 delete-account 함수를 v2 로 재배포하고, OTA 그룹 a62b408b(runtime 2.1.2)로 두 변경을 함께 내보냈습니다. 순서는 plan 에 미리 정해 두었습니다. 마이그레이션보다 OTA 가 먼저 나가면 컬럼이 없어 사진 저장이 실패하기 때문입니다(크래시는 아니고 실패 안내만 뜹니다). 모달 움직임은 plan T3 에 "녹화 프레임으로 오버슈트 없음 확인" 이라고 남아 있습니다.

같은 기록에 확인하지 못한 것도 적혀 있습니다.

  • 계정 삭제 때 avatars/{uid}/ 가 실제로 비는지 — 로컬에 엣지 함수 런타임을 띄우지 않았습니다
  • 다크 모드와 큰 글씨에서의 설정 탭 — settings-hierarchy intent 의 판정 기준에 들어 있던 항목입니다

두 번째는 intent 의 완료 조건이었으니, 출시는 했어도 판정은 아직 덜 끝났습니다.

DaySeed 는 매일의 습관을 한 번의 탭으로 기록하고 캘린더를 가장 가까운 사람과 함께 쓰는 iOS 앱입니다. App Store 에서 받기

📚 관련 글

💬 댓글