휴대폰에서 달력을 왼쪽으로 쓱 밀었다. 화면은 꼼짝도 하지 않았다. 손을 떼자 그제야 페이지가 깜빡이며 다음 달이 떴다. 틀린 데는 없었다. 밀었고, 넘어갔다. 그런데 손이 한 일과 화면이 한 일 사이에 아무 연결이 없었다.
「식단서랍」에서 가장 오래 보는 화면이 월간 식단 달력이다. 날짜 칸마다 아침·점심·저녁 칩이 붙고, 그 칩을 보며 "이번 주엔 뭘 먹었지", "다음 주엔 뭘 먹지"를 정한다.

손을 뗄 때만 듣는 달력
처음 넣은 스와이프는 단순했다. Alpine.js 컴포넌트 하나에 touchstart 와 touchend 두 개만 달았다. 시작 좌표와 시각을 기억해 두고, 손을 뗄 때 가로로 얼마나, 얼마나 빨리 움직였는지 보고 조건을 넘으면 이전 달이나 다음 달 URL 로 이동했다.
조건은 세 가지였다. 가로로 60px 넘게, 세로 이동의 1.5배 넘게, 0.6초 안에. 세로 조건은 스크롤하다 손가락이 옆으로 빗겨 나간 것을 스와이프로 잡지 않기 위해서다. 컨테이너에 Tailwind 의 touch-pan-y 를 줘서 세로 스크롤은 브라우저가 처리하게 두고, 가로 움직임만 내가 판단했다.
판정은 정확했다. 문제는 판정 전까지 화면이 아무것도 보여 주지 않는다는 점이었다. 손가락이 반쯤 가로지르는 동안 달력은 제자리였고, 조건을 못 넘으면 정말로 아무 일도 없었다.
손가락에 달력을 붙이다
그래서 touchmove 를 받기로 했다. 손가락이 움직이는 동안 그 거리만큼 달력을 translateX 로 옮기고, 멀리 갈수록 조금씩 흐리게 했다. 이동은 Alpine 의 :style 바인딩 하나로 묶었다. 상태 값이 바뀌면 스타일이 따라 바뀐다.
get styleObj() {
var transition = (this.dragging || !this.animating)
? 'none'
: 'transform 220ms cubic-bezier(.2,.7,.3,1), opacity 220ms';
if (!this.dragging && !this.animating) {
return { transform: '', opacity: '', transition: '' };
}
var w = window.innerWidth || 360;
var opacity = Math.max(0, 1 - Math.abs(this.dragX) / (w * 1.1));
return {
transform: 'translateX(' + this.dragX + 'px)',
opacity: opacity.toFixed(3),
transition: transition
};
},
핵심은 transition 을 언제 끄고 켜느냐다. 손가락이 붙어 있는 동안에는 전환 효과가 없어야 한다. 220ms 짜리 전환이 걸려 있으면 달력이 손가락을 0.2초씩 늦게 쫓아온다. 그래서 드래그 중에는 none, 손을 뗀 뒤 제자리로 돌아가거나 화면 밖으로 날아갈 때만 전환을 켰다. 평소에는 빈 문자열로 인라인 스타일 자체를 지운다.
컨테이너에는 will-change-transform 을 붙였다. 날짜 칸과 칩이 잔뜩 든 큰 덩어리를 미리 별도 레이어로 올려 두라는 힌트다.
하나 더 있었다. touchmove 를 받기 시작하자 세로 스크롤과 다시 부딪쳤다. 위로 스크롤하는데 손가락이 조금만 옆으로 흔들려도 달력이 좌우로 떨었다. 그래서 처음 5px 움직임으로 방향을 잠갔다. locked 가 아직 비어 있을 때 가로·세로 둘 다 5px 미만이면 판단을 미루고, 넘는 순간 더 큰 쪽으로 방향을 정한다. 세로로 잠기면 dragging 을 꺼서 그 터치가 끝날 때까지 스크롤은 브라우저 몫으로 남긴다. 사람은 스크롤을 시작한 뒤에 방향을 바꾸지 않는다. 처음 몇 픽셀이 의도를 거의 다 말해 준다.
넘어가거나, 돌아오거나
손을 뗐을 때의 판정은 처음 버전의 세 조건을 그대로 썼다. 달라진 건 그 뒤다.
var threshold = Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && dt < 600;
if (threshold) {
var url = dx < 0 ? this.nextUrl : this.prevUrl;
if (url) { this.flyOutAndGo(dx < 0 ? -1 : 1, url); return; }
}
this.springBack();
조건을 넘으면 flyOutAndGo 가 dragX 를 화면 폭의 1.05배로 보낸다. 손가락이 가던 방향으로 달력이 마저 미끄러져 화면 밖으로 사라지고, 200ms 뒤에 다음 달 페이지로 이동한다. 전환(220ms)이 끝나기 직전에 페이지를 바꿔서 빈 화면을 오래 보지 않게 했다.
조건을 못 넘으면 springBack 이 dragX 를 0 으로 돌린다. 이때 쓰는 곡선이 cubic-bezier(.2,.7,.3,1) 이다. 빠르게 돌아오다 끝에서 부드럽게 멈춘다. 이 되돌아옴이 있어서 사용자는 "조금 덜 밀었구나"를 말없이 알게 된다.
전화가 오거나 알림이 화면을 덮으면 touchend 대신 touchcancel 이 온다. 이것도 똑같이 되돌아오게 했다. 안 그러면 반쯤 밀린 채로 굳는다. 애니메이션 중에는 onStart 가 새 터치를 받지 않는다.
서버 렌더링 페이지라 실제 달 이동은 여전히 페이지 이동이다. 옆 달을 미리 받아 두는 캐러셀은 만들지 않았다. 따라오고, 넘어가고, 돌아오는 것만으로 손에 붙는 느낌은 충분했다.
칸 전체가 버튼이 되자
같은 무렵 날짜 칸도 손봤다. 처음엔 칸 오른쪽 위의 작은 + 만 메뉴 추가 버튼이었다. 휴대폰에서 칸 하나는 손가락보다 조금 큰 정도라, 그 안의 + 를 정확히 누르기 어려웠다. 그래서 칸 전체를 role="button" 으로 만들고 클릭과 Enter·Space 키 모두 추가 창을 열게 했다. 칸 안의 끼니 칩은 @click.stop 으로 전파를 끊어서, 칩을 누르면 추가 창이 아니라 그 끼니의 상세 창이 열린다.
추가 창은 나중에 단계형으로 바꿨다. ① 끼니 → ② 종류 → ③ 레시피. 처음엔 한 화면에 모든 게 있었고 끼니는 아침이 기본이라, 저녁 메뉴가 아침에 들어가곤 했다. 기본값을 없애고, 끼니 없이 다음 단계로 가려 하면 ①로 돌려보내며 알리게 했다. 종류를 오가며 여러 개를 고른 뒤 한 번에 등록하고, 고른 레시피는 상태에 들고 있다가 hidden 필드로 보내서 목록에서 사라져도 선택이 남는다.
이 추가 창에서 오래 걸린 함정이 하나 있었다. 필터 패널을 펼치자 창이 화면 아래로 뚫고 나가 레시피 목록이 화면 밖에 그려졌다. 창에는 분명 max-h-[90vh] 와 flex flex-col 이 걸려 있었고, 가운데 영역에는 overflow-y-auto 가 있었다.
<div class="... max-h-[90vh] overflow-hidden flex flex-col">
<form class="flex flex-col min-h-0 flex-1">
<div class="... shrink-0">머리</div>
<div class="flex-1 min-h-0 ... overflow-y-auto">목록</div>
<div class="... shrink-0">버튼</div>
</form>
</div>
flex 항목의 min-height 기본값은 auto 다. 내용물보다 작아지지 않는다는 뜻이다. 그래서 가운데 영역은 스크롤을 만드는 대신 내용만큼 자라고, 부모의 max-h 를 밀어낸다. 가운데에 flex-1 min-h-0 을 줘야 남은 높이까지만 차지하고 나머지를 스크롤로 넘긴다.
그걸 넣고도 고쳐지지 않았다. Razor 의 Html.BeginForm 이 머리·본문·버튼 전체를 <form> 하나로 감싸고 있었기 때문이다. 창의 flex 자식은 세 영역이 아니라 <form> 하나였고, flex 구조가 거기서 끊겼다. <form> 자체를 flex flex-col min-h-0 flex-1 로 만들어 창의 정상적인 flex 자식으로 세우고, 머리와 버튼에 shrink-0 을 줘서 줄어드는 건 가운데뿐이게 했다. 보이지 않는 태그 하나가 사슬을 끊고 있었다.
링크 하나로 보여 주기
달력이 손에 붙자 남에게 보여 주고 싶어졌다. 첫 공유는 토큰 URL 이었다. 사용자 행에 MenuShareToken 을 두고, 처음 공유할 때 Guid.NewGuid().ToString("N") 으로 만든다. 링크를 아는 사람은 로그인 없이 그 달력을 읽기 전용으로 본다. 공유 페이지에는 X-Robots-Tag: noindex, nofollow 를 붙여 검색엔진에 잡히지 않게 했다.
나중에 링크가 얼마나 열렸는지 궁금해서 조회수, 마지막 조회 시각, 만료일을 붙였다. 만료일은 N일 뒤로 설정하고, 0 이면 무기한, 음수는 거절, 너무 먼 값은 2년으로 자른다. 자기 달력을 새로고침해도 조회수가 오르지 않게 주인이 아닌 조회만 셌다.
조회수를 올리는 코드에서 한 번 더 생각할 곳이 있었다. 처음엔 토큰으로 주인을 찾고 만료를 확인한 뒤, 주인 Id 로 카운트를 올렸다. 그런데 확인과 갱신 사이에 주인이 링크를 재발급하거나 만료를 바꾸면, 이미 죽은 링크의 조회가 새 링크의 숫자에 더해질 수 있다.
await _db.Users
.Where(u => u.Id == owner.Id
&& u.MenuShareToken == token
&& (u.MenuShareTokenExpiresAt == null || u.MenuShareTokenExpiresAt > nowUtc))
.ExecuteUpdateAsync(s => s
.SetProperty(u => u.MenuShareTokenViewCount, u => u.MenuShareTokenViewCount + 1)
.SetProperty(u => u.MenuShareTokenLastViewedAt, nowUtc), ct);
EF Core 의 ExecuteUpdateAsync 는 엔티티를 불러오지 않고 UPDATE ... SET count = count + 1 WHERE ... 한 문장을 보낸다. 동시에 열려도 숫자를 잃지 않는다. 그리고 토큰이 그대로이고 아직 살아 있다는 조건을 WHERE 에 다시 넣었다. 확인과 갱신이 한 문장 안에 있으니 그 사이에 끼어들 틈이 없다.
함께 쓰는 달력, 겹쳐 보는 달력
링크는 보여 주기만 한다. 실제로 필요했던 건 같은 달력을 여러 사람이 함께 채우는 일이었다. 그래서 초대와 수락으로 공동 편집을 만들었다. MenuCalendarShare 에 주인, 참여자, 편집 가능 여부, 상태(대기·수락·거절)를 둔다. 주인이 사용자를 찾아 "함께 편집"이나 "보기 전용"으로 초대하고, 상대가 수락해야 접근이 생긴다. 거절된 관계는 다시 초대하면 대기로 돌아간다.
여기서 지킨 원칙은 하나다. 식단 행(monthly_menus)의 주인은 언제나 달력 주인이다. 참여자가 넣은 끼니도 주인의 행으로 저장된다. 공유가 끊겨도 데이터가 누구 것인지 흔들리지 않는다. 남의 달력은 /Menu?owner=... 로 여는데, 권한은 요청마다 서버가 다시 푼다. 수락된 공유가 없으면 404, 보기 전용인데 추가나 삭제를 시도하면 403 이다. 보기 전용 화면에서는 +, 칸 클릭, 추가 창, 삭제 버튼을 아예 그리지 않는다.
마지막으로 붙인 게 겹쳐 보기다. 내 달력 위에 다른 사람의 끼니를 사람별 색 배지로 함께 띄운다. 후보는 두 부류다. 나를 초대해 수락한 공유 달력의 주인, 그리고 내가 구독한 작성자 중 식단 공유 링크를 켜 둔 사람. 후보를 고르는 쿼리가 곧 권한 규칙이다. 구독 작성자는 토큰이 있고 만료되지 않았을 때만 보인다.
켜 둔 상태는 MenuOverlaySource 에 (나, 상대) 한 행으로 저장한다. 다만 이 행은 권한을 주지 않는다. 화면을 그릴 때마다 후보 목록을 다시 계산하고, 그 안에서 지금 볼 수 있는 상대만 골라 그 달의 monthly_menus 를 ids.Contains(m.UserId) 한 번으로 가져온다. 상대가 링크를 끄면 칩은 남지만 끼니는 사라진다. 남의 끼니를 내 달력에 담을 때는 그 날짜·끼니에 그 레시피가 실제로 있는지 다시 본다. 비공개 레시피라면 내 서랍으로 복사한 뒤에 담는다. 아무 레시피 번호나 끼워 넣는 길은 없다.
겹쳐 보기를 켜 두면 한 칸에 내 끼니와 다른 색 배지가 나란히 선다. 손가락으로 달을 넘기면 그 배지들도 함께 미끄러진다.
달력이 채워지면 다음 질문은 장보기다. 달력에 올린 끼니가 실제 장바구니가 되기까지, 다음 편은 그 이야기다.