전체 검색

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

가입부터 시키지 않는 캘린더 만들기

일정 하나 안 적어본 사람한테 가입 화면부터 들이미는 게 이상했어요. 그래서 로그인 없이 다 되게 만들었는데, 그때부터 진짜 문제가 시작됐습니다. 저장 위치, 이전 규칙 다섯 개, 그리고 권한을 왜 따로 만들었는지까지.

2026. 07. 29. 10:09:348분 읽기프로젝트/arcaC/아키텍처
#아키텍처
조회 33
카페 창가에서 노트북을 열지 않고 종이 수첩에 일정을 적는 사람 일러스트

일정 앱을 켜면 대개 로그인 화면이 먼저 나옵니다. 아직 일정 하나 안 적어본 사람한테요.

생각해보면 순서가 이상하잖아요. 이 도구가 나한테 맞는지도 모르는데 계정부터 만들라니.

그래서 이 캘린더는 로그인 없이 바로 씁니다. 일정 만들고, 고치고, 지우고, 지도에 핀 찍는 것까지 전부요.

그런데 그 결정 하나가 뒤에 있는 설계를 거의 다 바꿔놨어요. 오늘은 그 연쇄를 순서대로 적을게요.

먼저 상태를 셋으로 정리했습니다

제품이 가질 수 있는 상태를 먼저 그렸어요. 코드를 쓰기 전에요.

local mode
  인증 없음
  이 브라우저 안에만 존재
  공유·다른 기기 불가

account mode
  플랫폼 로그인 필요
  서버에 저장
  여러 브라우저·기기에서 사용

shared calendar
  account mode + 활성 멤버십

여기서 정리 하나를 했어요. 공유는 별도의 저장 방식이 아닙니다. 계정 모드인데 멤버가 둘 이상인 상태일 뿐이에요.

이렇게 정의하니까 구현이 확 줄었습니다. 공유용 저장소를 따로 만들 필요가 없어졌거든요. 멤버십 테이블 하나가 늘었을 뿐이에요.

(개념을 하나 줄이면 구현은 두세 개가 줄어요. 반대로 개념을 늘리면 구현은 다섯 개가 늘고요.)

첫 번째 질문 — 로컬 어디에 저장하나

로그인이 없으면 저장할 곳은 브라우저 안이죠. 후보는 둘이었어요.

간단한 키-값 저장소브라우저 내장 DB
쓰기 난이도쉬움번거로움
조회 방식전부 읽어서 걸러야 함범위로 조회 가능
용량작음 (수 MB)
비동기동기 — 메인 스레드 블록비동기

두 번째 줄이 결정적이었어요. 캘린더는 "이번 달과 겹치는 일정"처럼 범위로 꺼내는 데이터거든요. 통째로 읽어서 거르면 일정이 쌓일수록 느려집니다.

그래서 브라우저 내장 DB를 골랐어요. 그리고 규칙 하나를 설계 문서에 못 박았습니다.

일정 내용을 간단한 키-값 저장소에 저장하지 않는다.

이걸 왜 문서에까지 적었냐면, 급할 때 그리로 새는 걸 제가 알기 때문이에요. "설정 하나만 임시로 여기 저장하자"가 시작이고, 반년 뒤엔 일정 절반이 거기 있습니다.

(미래의 제가 게을러질 지점에 미리 울타리를 치는 일, 혼자 개발할 때 꽤 중요합니다.)

두 번째 질문 — 한계를 말할 것인가

로컬 저장에는 명백한 한계가 있어요. 다른 기기에서 못 보고, 브라우저 데이터를 지우면 사라집니다.

이걸 숨기고 싶은 유혹이 있어요. 말 안 하면 사용자가 그냥 쓰니까요. 그리고 대부분은 문제없이 지나갑니다.

그런데 문제가 생기는 그 한 번이 배신이 돼요. 몇 달 치 일정이 사라진 사람에게 "원래 그런 거였어요"는 변명이 안 됩니다.

그래서 제품이 먼저 말합니다. 이건 백업이 아니라고요. 그리고 말만 하지 않고 경로를 같이 줬어요 — 내보내기와 복구요.

공유 버튼은 숨기지 않았습니다

로그인해야 되는 기능들을 아예 안 보이게 할 수도 있었어요. 깔끔하니까요.

그런데 그러면 사용자는 그런 기능이 있는 줄도 모릅니다. 그래서 보이게 두고, 누르면 "이건 로그인하면 돼요"로 안내해요.

기능을 감추는 것과 조건을 설명하는 건 다릅니다. 앞은 사용자를 작게 만들고, 뒤는 선택지를 줍니다.

진짜 어려운 문제 — 로그인하는 순간

브라우저 안 IndexedDB에서 서버 PostgreSQL로, 사용자가 승인해야 이동한다는 화살표가 있는 그림
로그인은 자동 업로드의 신호가 아니에요. 옮길지는 사용자가 정합니다.

로컬에 일정이 쌓인 사람이 로그인을 합니다. 이제 어떻게 되어야 할까요?

제일 친절해 보이는 답은 자동 업로드예요. 알아서 서버로 옮겨주는 거죠. 그런데 이건 두 가지 문제가 있습니다.

하나는 프라이버시예요. 내 브라우저에만 있던 데이터가 내가 모르는 사이에 서버로 갑니다. 또 하나는 충돌이고요 — 계정 쪽에 이미 일정이 있으면 뒤섞입니다.

그래서 규칙 다섯 개를 세웠어요.

규칙막는 사고
로그인만으로는 안 올린다모르는 사이에 데이터가 서버로 감
미리 보여주고 승인받는다뭐가 올라가는지 모르고 누름
여러 번 눌러도 결과가 같다중복 일정이 쌓임
옮긴 뒤 로컬 사본을 편집 가능 상태로 남기지 않는다두 벌이 각자 수정돼 갈라짐
서버 쪽이 확인되기 전엔 로컬 원본을 안 지운다옮기다 실패하면 다 날아감

세 번째 규칙이 제일 까다로웠어요

"여러 번 눌러도 결과가 같다"를 만들려면, 로컬 일정마다 안정적인 식별자가 있어야 합니다.

없으면 이런 일이 생겨요. 옮기다가 네트워크가 끊겨서 다시 눌렀는데, 서버는 그게 같은 일정인지 새 일정인지 모릅니다. 그래서 두 벌이 생기죠.

그래서 로컬에서 일정을 만들 때부터 고유 id를 부여합니다. 서버로 옮길 때 그 id를 같이 보내고, 서버는 "이 id는 이미 받았다"를 판단할 수 있어요.

네 번째와 다섯 번째는 순서 문제예요

옮기기가 중간에 실패할 수 있다는 걸 전제로 순서를 짰습니다.

1. 로컬 데이터를 읽어 미리보기 제공
2. 사용자 승인
3. 서버에 전송 (같은 id로, 여러 번 보내도 안전)
4. 서버 저장 확인
5. 그제서야 로컬 사본을 편집 불가로 전환
   ← 지우는 건 항상 맨 마지막

5번을 3번 뒤에 두고 싶은 유혹이 있어요. 코드가 짧아지거든요. 그런데 그러면 4번에서 실패했을 때 데이터가 양쪽 다 없습니다.

지우는 연산은 항상 마지막에, 확인된 뒤에. 이건 데이터를 옮기는 모든 작업의 기본이더라고요.

중간에 만난 함정 — "하루 종일"

설계가 순조롭다고 생각한 시점에 시간 문제가 왔어요. 일정에는 성격이 다른 두 종류가 있습니다.

오후 2시부터 3시까지 하는 회의와, "8월 3일 휴가"요. 둘 다 일정인데 시간의 의미가 완전히 달라요.

시각 있는 일정하루 종일 일정
기준절대 시각달력의 날짜
시간대 이동 시시각이 따라 변환됨변하면 안 됨
"8월 3일"의 의미한 시점그 지역의 8월 3일 전체

두 번째 줄이 함정이에요. 휴가를 8월 3일로 잡아뒀는데 해외에 가면 8월 2일로 보이면 안 되잖아요. 하루 종일 일정은 시간대와 무관해야 합니다.

그런데 둘을 같은 필드에 넣으면 이 구분이 사라져요. 시작·종료를 그냥 타임스탬프로 저장하면, 나중에 어느 쪽 의미였는지 알 방법이 없습니다.

그래서 타입 자체를 갈랐어요

export type CalendarEventTime =
  | {
      readonly kind: "timed";
      readonly startAt: string;      // 절대 시각 (ISO 8601)
      readonly endAt: string;
      readonly timeZone: string;     // 어느 시간대에서 만들었는지
    }
  | {
      readonly kind: "all-day";
      readonly startDate: string;          // 날짜만 (YYYY-MM-DD)
      readonly endDateExclusive: string;   // 끝나는 날 "다음" 날
    };

둘이 아예 다른 모양이에요. 그래서 하루 종일 일정에는 시각을 넣을 수가 없습니다. 타입이 막아요.

검증도 종류별로 다릅니다. 시각 있는 일정은 ISO 8601로 파싱되는지 보고, 하루 종일 일정은 날짜 형식인지 봐요.

const datePattern = /^\d{4}-\d{2}-\d{2}$/;

function instantValue(value: string): number {
  const parsed = Date.parse(value);
  if (!Number.isFinite(parsed)) {
    throw new CalendarValidationError(
      "invalid-event-time",
      "일정 시각이 올바른 ISO 8601 값이 아닙니다",
    );
  }
  return parsed;
}

끝나는 날을 "다음 날"로 저장한 이유

작은 결정 하나가 더 있어요. 하루 종일 일정의 끝을 배타적으로 뒀습니다. 8월 3일 하루짜리 휴가는 이렇게 저장돼요.

{ kind: "all-day", startDate: "2026-08-03", endDateExclusive: "2026-08-04" }

직관적으로는 종료일도 8월 3일이어야 할 것 같죠. 그런데 포함 방식으로 두면 기간 계산마다 하루를 더해야 하고, 그 하루를 빼먹는 실수가 반드시 나옵니다.

끝을 배타적으로 두면 기간 길이가 그냥 뺄셈이 돼요. "겹치는 일정 찾기"도 부등호 두 개로 끝나고요.

경계값 실수를 줄이는 제일 좋은 방법은 조심하는 게 아니라, 실수할 수 없는 표현을 고르는 거예요.

시간대는 저장하되 강요하지 않습니다

시각 있는 일정에는 만든 시간대를 같이 저장해요. 절대 시각만 있으면 충분할 것 같은데 아니거든요.

"매주 화요일 오후 2시" 같은 반복 일정을 나중에 붙이려면, 그게 어느 시간대의 오후 2시였는지 알아야 합니다. 서머타임이 있는 지역이면 절대 시각이 계절마다 달라지고요.

지금은 반복 일정이 범위 밖이지만, 그때 가서 과거 데이터를 복구할 수는 없으니 미리 남겨뒀어요.

(나중에 필요한 정보 중에 "지금 안 남기면 영영 못 얻는 것"이 있어요. 그건 미리 남겨두는 게 맞습니다.)

네 번째 질문 — 공유 권한을 어디서 가져오나

공유 캘린더를 만들려니 권한 문제가 걸렸어요. 플랫폼에는 이미 사이트 멤버십이 있었거든요.

재활용하면 하루면 끝났을 겁니다. 그런데 그러면 "가족 일정 하나 같이 보자"고 초대한 사람에게 이런 게 딸려 가요.

주려던 것같이 딸려 가는 것
이 캘린더 보기사이트 설정 변경
일정 추가하기빌더로 화면 편집
다른 콘텐츠 발행

그래서 캘린더 멤버십을 따로 만들었습니다. owner·editor·viewer 세 단계로, 딱 그 캘린더에 대해서만요.

그리고 선을 하나 더 그었어요. 캘린더 멤버라는 사실만으로는 플랫폼 명령을 하나도 부를 수 없습니다. 권한이 한 방향으로만 흐르게요.

(권한 모델을 재활용하고 싶어지면, 그 권한이 원래 무엇을 열어주는지부터 세어봐야 해요. 대개 생각보다 많습니다.)

다섯 번째 질문 — 지도는 어떻게 붙이나

일정에 장소를 붙이려면 지도가 필요하죠. 지도 SDK를 캘린더 화면에서 그냥 가져다 쓰면 제일 빠릅니다.

안 했어요. 위치 기능을 따로 떼서 패키지로 만들고, 캘린더는 그 경계 너머만 봅니다. 이유가 셋이에요.

1. 업체를 갈아탈 때

지도 업체는 정책이 바뀌고 가격이 바뀝니다. SDK를 화면에 직접 박아두면 갈아탈 때 화면을 다시 만들어야 해요.

실제로 어댑터를 두 개 만들어 뒀습니다. 하나가 막히면 다른 걸로 갈아탈 수 있게요.

2. 업체에 우리 데이터를 안 넘기려고

이게 제일 중요했어요. 장소 검색은 장소만 물어봅니다.

업체에 보내는 것:  검색어 (예: "강남역")
업체에 안 보내는 것:
  일정 제목
  일정 설명
  캘린더 이름
  누가 멤버인지

SDK를 화면에 직접 넣으면 이 경계를 지키기 어려워요. 화면에는 일정 정보가 다 있으니까, 실수로 같이 넘어갈 여지가 생깁니다.

3. 장소가 사라지지 않게

검색한 장소는 이름·주소·좌표를 그 시점에 복사해 둡니다. 업체 참조는 부가 정보로만 남기고요.

그래서 나중에 업체가 장애가 나거나 우리가 갈아타도, 지난 일정의 장소는 그대로 보여요. 지도만 안 뜨고 "어디였는지"는 남습니다.

계정은 관문이 아니라 전환점

이 설계 전체를 한 문장으로 요약하면 이거예요.

계정은 기능을 잠그는 관문이 아니라, 동기화와 공유를 켜는 스위치입니다.

로컬 사용을 체험판으로 취급하지 않았어요. 로그인 안 한 사람도 이 캘린더를 온전히 씁니다. 다만 그 데이터가 어디 있고 어떤 한계가 있는지 알 뿐이죠.

그리고 이 결정 덕에 얻은 게 하나 더 있어요. 지금 캘린더 주소를 열면 아무 준비 없이 일정을 적을 수 있습니다. 설명이 필요 없는 제품이 된 거예요.

가족에게 "여기 들어가서 가입하고 나서…"라고 말할 필요가 없다는 것. 이게 이 프로젝트의 원래 목적이었거든요.


다음 편은 이 캘린더를 다른 제품에서 꺼내 쓰는 이야기예요. 블로그 글 안에 일정표를 꽂았는데, 블로그는 캘린더가 뭔지 모릅니다.

시리즈 이어읽기

캘린더를 짓다

1 / 2

이야기 나누기

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

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

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

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