"오늘은 뭐 먹지?"
가족과 매일 나누는 얘기 중 하나다. 그리고 그중에서 가장 정하기 어려운 얘기이기도 하다. 매번 반복되고, 뒤로 미뤄 놓아도 저녁이 되면 결국 누군가는 정해야 한다. 미룬다고 사라지는 문제가 아니었다.
그래서 이 문제를 머릿속에서 꺼내 어딘가에 적어 두기로 했다. 다른 일상 얘기에 더 집중하면서도 이 질문만큼은 놓치지 않으려면, 기록해 두는 게 낫겠다고 생각했다. 그렇게 시작한 것이 「식단서랍」이다. 레시피를 모아 두고, 한 달 식단을 달력에 미리 채워 두고, 그 식단에서 장보기 목록을 뽑는다. 서랍처럼 열면 오늘 먹을 것이 나와 있게 하는 게 목표였다.

질문을 세 갈래로 쪼개기
"오늘 뭐 먹지"는 한 문장이지만 들여다보면 질문이 세 개 들어 있다. 우리 집에서 해 먹을 수 있는 요리가 뭐가 있지? 그중에 오늘은 뭘 하지? 그걸 하려면 뭘 사 와야 하지?
식단서랍의 화면도 이 세 질문을 그대로 따라간다. 첫째는 레시피다. 재료와 분량, 조리 단계, 사진을 적어 두는 곳이다. 둘째는 월간 식단 달력이다. 한 달 한 장짜리 달력에 날짜별로 아침·점심·저녁 끼니를 나눠 레시피를 올려 둔다. 셋째는 장보기 목록이다. 달력에 올려 둔 식단을 보고 필요한 재료를 모아 준다.
순서가 중요했다. 레시피가 있어야 식단을 짤 수 있고, 식단이 있어야 장보기가 나온다. 매일 저녁 "뭐 먹지"를 처음부터 고민하는 대신, 한 번 적어 둔 레시피가 달력으로 흘러가고, 달력이 장바구니로 흘러가게 만들고 싶었다.
이 흐름이 코드에서는 엔티티 다섯 개로 나타난다. Recipe, Ingredient, CookingStep, MonthlyMenu, GroceryList. 여기에 레시피 분류용 RecipeCategoryOption 이 하나 붙는다.
레시피는 서랍장, 재료와 단계는 칸
Recipe 는 제목, 사진 경로, 조리 시간, 인분, 난이도, 팁, 태그, 유튜브 영상 주소 같은 것을 갖는다. 분류는 네 갈래로 나눴다. 종류, 상황, 재료, 방법. 국인지 반찬인지, 손님상인지 도시락인지, 돼지고기인지 해물인지, 볶는지 끓이는지. 드롭다운에 들어갈 선택지는 RecipeCategoryOption 테이블에 차원(Type)과 이름(Name) 쌍으로 넣어 두고, 이 쌍에는 유니크 인덱스를 걸었다.
재료와 조리 단계는 레시피에 딸린 자식이다. Ingredient 는 이름, 양, 단위, 정렬 순서를 갖고, CookingStep 은 정렬 순서와 설명, 단계 사진을 갖는다. 둘 다 RecipeId 로 레시피에 매달린다.
여기서 고민했던 건 재료의 "양"을 어떤 타입으로 둘지였다. 숫자로 두면 합산이 쉽다. 하지만 실제 레시피에는 "1/2", "1+1/2", "약간", "적당량" 같은 말이 아무렇지 않게 나온다. 이걸 숫자 칸에 억지로 넣으면 입력하는 순간부터 불편해진다. 그래서 Amount 는 최대 50자 문자열, Unit 은 20자 문자열로 두었다. 적을 때는 사람 말 그대로 적고, 계산이 필요한 순간에만 해석한다. 이 결정이 나중에 장보기 쪽에서 다시 등장한다.
달력 한 칸은 행 하나
식단 달력의 한 칸은 MonthlyMenu 라는 테이블의 행 하나다.
public class MonthlyMenu
{
public int Id { get; set; }
public string UserId { get; set; } = default!;
[Required] public int Year { get; set; }
[Required, Range(1, 12)] public int Month { get; set; }
[Required, Range(1, 31)] public int Day { get; set; }
public int RecipeId { get; set; }
[StringLength(10)]
public string? MealType { get; set; } // 아침 / 점심 / 저녁
}
날짜를 DateTime 하나로 두지 않고 연·월·일 정수 세 개로 나눈 건 화면이 읽는 단위 때문이다. 달력은 언제나 "이 사람의 이번 달"을 통째로 읽는다. 그래서 (UserId, Year, Month) 복합 인덱스를 하나 걸어 두면 달력 한 장을 채우는 조회가 그 인덱스 하나로 끝난다. 날짜 범위 계산 없이 Year == 2026 && Month == 10 이면 된다.
한 행은 "어느 날, 어느 끼니에, 어떤 레시피"다. 같은 날 같은 끼니에 유니크 제약을 걸지 않았다. 저녁에 찌개와 반찬을 같이 올리는 게 자연스럽기 때문이다. 그래서 끼니 하나에 레시피 여러 개가 들어갈 수 있다. 끼니 구분은 문자열 그대로 저장하되, MealType 이라는 정적 클래스에 "아침", "점심", "저녁" 상수와 정렬 순서를 모아 두어 뷰나 서비스에 같은 문자열이 흩어지지 않게 했다.

이 구조 덕분에 달력에서 하는 일은 대부분 행 하나를 만들거나, 고치거나, 지우는 일로 단순해진다. 메뉴를 다른 날로 옮기면 Day 가 바뀌고, 복사하면 행이 하나 더 생긴다.
장보기 목록은 레시피를 모른다
GroceryList 를 처음 보면 조금 이상하게 느껴질 수 있다. 재료에서 만들어지는데, 정작 Ingredient 나 Recipe 를 가리키는 외래 키가 없다.
public class GroceryList
{
public string UserId { get; set; } = default!;
public int Year { get; set; }
public int Month { get; set; }
public int Day { get; set; }
public string Name { get; set; } = default!;
public string? Amount { get; set; }
public string? Unit { get; set; }
public bool IsChecked { get; set; }
public DateTime? CarriedOverFrom { get; set; }
public bool IsGenerated { get; set; }
}
일부러 그렇게 했다. 장보기 목록은 레시피의 "보기"가 아니라 그 시점에 베껴 낸 메모다. 마트에서 하나씩 체크하고, 직접 "우유"를 추가하고, 다 못 산 걸 다음 날로 넘긴다. 이런 일들은 레시피와 상관없이 일어난다. 레시피 쪽 재료를 나중에 고쳤다고 이미 장 봐 온 목록까지 바뀌면 오히려 곤란하다. 그래서 장보기 항목은 이름, 양, 단위를 자기 칸에 직접 들고 있다.
생성 과정은 이렇다. 하루, 한 주, 한 달 중 범위를 고르면 그 기간의 MonthlyMenu 를 읽고, 거기 걸린 레시피들의 재료를 모은다. 메뉴 하나가 레시피 재료 한 벌이라서, 같은 레시피가 한 주에 두 번 나오면 재료도 두 번 들어간다. 그다음 이름과 단위가 같은 것끼리 묶는다.
var aggregated = occurrences
.GroupBy(i => new { i.Name, Unit = i.Unit ?? "" })
.Select(g =>
{
double sum = 0;
int unparsedCount = 0;
foreach (var ing in g)
{
var parsed = AmountAggregator.TryParse(ing.Amount);
if (parsed.HasValue) sum += parsed.Value;
else unparsedCount++;
}
// 숫자만 있으면 "3", 섞이면 "2 + 약간 ×1", 숫자가 없으면 "약간"
...
});
앞에서 양을 문자열로 둔 결정이 여기서 값을 한다. AmountAggregator.TryParse 는 "1", "0.5", "1/2", "1+1/2" 같은 표현을 숫자로 읽고, "약간"이나 "적당량"처럼 읽을 수 없는 건 null 을 돌려준다. 읽히는 것끼리는 더하고, 안 읽히는 건 버리지 않고 개수를 센다. 양파 1개와 양파 1/2개는 "1.5 개"가 되고, 소금 약간이 세 번 나오면 "약간 ×3"이 된다. 사람이 적은 말을 망가뜨리지 않으면서 합칠 수 있는 만큼만 합친다.
GroceryList 의 마지막 두 필드는 나중에 붙었다. IsGenerated 는 식단에서 자동으로 만든 항목인지 표시한다. 식단을 바꾸고 목록을 다시 만들 때, 자동으로 만들었고 아직 안 산 항목만 갈아 끼우고 직접 넣은 항목과 이미 산 항목은 그대로 둔다. CarriedOverFrom 은 다른 날에서 넘어온 항목이면 원래 날짜를 기억한다. 넘어온 항목도 재생성 때 건드리지 않는다. 목록이 레시피와 끊어져 있기 때문에 이런 규칙을 장보기 테이블 안에서만 정할 수 있었다.
지워도 되는 것과 지우면 안 되는 것
관계를 설정하면서 가장 오래 따져 본 건 삭제였다. 무엇이 지워질 때 무엇이 같이 사라져야 하는가.
// Recipe → Ingredient, CookingStep : 레시피가 지워지면 같이 지운다
modelBuilder.Entity<Ingredient>()
.HasOne(i => i.Recipe).WithMany(r => r.Ingredients)
.HasForeignKey(i => i.RecipeId)
.OnDelete(DeleteBehavior.Cascade);
// MonthlyMenu → Recipe : 식단에 올라간 레시피는 함부로 지워지지 않게
modelBuilder.Entity<MonthlyMenu>()
.HasOne(m => m.Recipe).WithMany()
.HasForeignKey(m => m.RecipeId)
.OnDelete(DeleteBehavior.Restrict);
재료와 조리 단계는 레시피 없이는 의미가 없다. 그래서 Cascade 다. 레시피를 지우면 칸도 같이 빠진다. 반대로 달력은 다르다. 이번 달 식단에 올라가 있는 레시피가 조용히 사라지면 달력에 구멍이 난다. 그래서 MonthlyMenu 에서 Recipe 로 가는 관계는 Restrict 로 묶어, 식단이 붙잡고 있는 레시피는 DB 가 몰래 지우지 못하게 했다. 사용자와의 관계도 Restrict 로 두었다. 레시피, 식단, 장보기 모두 사용자 행 하나가 지워진다고 연쇄로 날아가지 않는다.
다만 DB 가 막아 주기만 하면 사용자에게는 오류 화면이 보일 뿐이다. 실제로 식단에 올라간 레시피를 지우려 하면 그랬다. 그래서 지우는 순서를 앱이 직접 쥐게 했다. 레시피를 지울 때는 확인 창에 "이 레시피가 들어간 식단 몇 칸도 함께 빠진다"를 먼저 보여 주고, 그 칸들을 빼낸 다음 레시피를 지운다. 회원 탈퇴도 같다. 식단, 장보기, 공유, 레시피를 앱이 정해진 순서대로 지운 뒤에 계정을 지운다. DB 의 Restrict 는 그대로 두었다. 앱이 순서를 빠뜨리면 조용히 사라지는 대신 시끄럽게 실패하도록 남겨 둔 안전장치다.
이 뼈대 위에 나중에 많은 것이 올라갔다. 즐겨찾기와 별점, 작성자 구독, 달력 공유와 다른 사람 달력 겹쳐 보기, 공용 레시피, 푸시 알림. 테이블은 꽤 늘었지만 "레시피가 달력으로, 달력이 장보기로"라는 흐름은 처음 그대로다.
무엇으로 만들었나
서버는 ASP.NET Core 9 MVC, 데이터는 EF Core 9 에 Pomelo 드라이버로 MySQL 을 쓴다. 로그인은 ASP.NET Core Identity, 화면은 Tailwind CSS v4 와 Alpine.js 로 가볍게 만들었고, 휴대폰 홈 화면에 앱처럼 설치할 수 있게 PWA 로 묶었다. 사실 식단서랍은 원래 ASP.NET MVC 5 로 만든 앱이었다. 지금의 모습은 그 앱을 .NET 9 로 새로 옮기면서 갖추게 된 것이다.
다음 편은 그 이사 이야기다. 오래된 MVC 5 앱의 엔티티와 화면을 .NET 9 로 옮기면서 무엇을 그대로 두고 무엇을 바꿨는지부터 시작해, 손가락을 따라 메뉴가 움직이는 달력, 식단에서 장보기를 뽑아내는 규칙, 오늘의 메뉴를 고르는 추천 점수, 휴대폰으로 찍은 큰 사진을 다루는 법, 운영하던 서비스를 다시 뜯어본 보안 점검, PWA 캐시가 만든 골칫거리, 그리고 Play 스토어에 올리기까지를 차례로 풀어 보려 한다. 서비스는 recipe.addsoft.co.kr 에서 열려 있고, Android 앱은 Play 스토어에서 받을 수 있다.