본문 바로가기

코딩/트러블슈팅

'소금 약간'은 몇 개일까? - 레시피 단위 변환 로직 구현

문제의 시작

혼자 자취를 하다 보면 유튜브에서 간단한 요리 영상을 보고 따라 하게 되는데요, 문제는 한 끼를 위한 재료가 아니다 보니 냉장고에 식재료가 점점 쌓이게 되었습니다. 유통기한은 다가오고, 이걸로 무엇을 해먹을지 매번 검색하는 게 번거로웠습니다.

그러던 중 '냉장고 속 재료를 알아서 관리해 주고, 그 재료로 만들 수 있는 레시피를 추천해 주는 서비스가 있으면 좋겠다'는 생각이 들었고, 청년취업사관학교 마지막 프로젝트로 팀원과 함께 '오늘의 냉장고'라는 웹 사이트를 개발하게 되었습니다.

저는 이 프로젝트에서 레시피 추천 기능과 로그인 기능을 담당했는데, 개발 중 예상치 못한 큰 난관을 만났습니다. 바로 레시피 데이터의 단위와 냉장고 관리 단위가 완전히 달랐던 것입니다.

 

문제 발견

시중에 냉장고 관리 앱은 많지만, 레시피와의 연동은 대부분 URL 링크만 제공하거나 아예 없었습니다. 처음에는 우리가 만드는 웹은 '레시피 기능도 당연히 있어야지!'라고 쉽게 생각했는데, 막상 레시피 데이터를 DB에 넣고 식재료 관리 시스템과 연결하려니 큰 문제가 보이기 시작했습니다.

실제 데이터 비교


레시피 데이터

당근 200g
양파 2개
소금 약간
우유 1컵
간장 1큰술

 

냉장고 재료 데이터

당근 3개
양파 2개
소금 1개
우유 1개
간장 1개

 

냉장고 재료는 모든 재료를 '개' 단위로 통일해도 되는 반면, 레시피는 통일하기도 애매한 g, ml, 컵, 큰 술, 약간 등 다양한 단위를 사용하고 있었습니다.

핵심 난관들

  1. 레시피에서 '당근 200g'을 사용했을 때, 냉장고에서 몇 개를 차감해야 할까?
  2. '소금 약간'은 어떻게 처리해야 할까?
  3. 사용자가 레시피대로 정확히 사용하지 않을 수도 있는데, 이건 어떻게 대응할까?

'아, 그래서 다른 앱들이 레시피 연동을 안 했구나...'라는 걸 그제야 깨달았습니다😇..


🛠️ 해결 과정

1단계: 재료 단위 타입 분류

가장 먼저 한 일은 레시피에 나오는 모든 단위를 분석해서 3가지 타입으로 분류하는 것이었습니다.

const INGREDIENT_UNITS = {
  COUNT: ["개", "알", "송이", "장", "봉지", "공기"],
  WEIGHT_VOLUME: ["g", "kg", "ml", "L", "컵", "큰술", "작은술", "스푼", "숟가락", "T", "t"],
  ABSTRACT: ["줌", "적당량", "약간", "조금"],
} as const;

타입별 처리

재료 단위 단위 설명 레시피 단위 예시 타입 분류 후 사용할 예시
COUNT 개수를 직접 세는 단위 당근 2개, 계란 3알 숫자 그대로 사용
WEIGHT_VOLUME 무게/부피 단위 당근 200g, 우유 1컵 1개로 변환
ABSTRACT 추상적 표현 소금 약간, 참기름 조금 0개로 처리 (차감 안 함)

 

2단계: 정규표현식으로 재료 파싱

 

단위 타입을 정의했으니, 이제 실제 레시피 문자열에서 숫자와 단위를 추출해야 했습니다.

export const normalizeIngredientForDisplay = (ingredient: RecipeIngredient) => {
  const { name, quantity } = ingredient;
  const quantityText = quantity.toString();
  
  // 1. 단위 타입 판별
  const unitType = getUnitType(quantityText);
  
  // 2. 정규표현식으로 숫자 추출
  const numericMatch = quantityText.match(INGREDIENT_UNITS_NUMBER_REGEX);
  const numericValue = numericMatch && numericMatch[1] 
    ? parseFloat(numericMatch[1]) 
    : 0;
  
  let displayQuantity = 0;
  
  // 3. 타입별 수량 변환
  switch (unitType) {
    case "COUNT":
      // 예: 무화과 2.5개 -> 3개
      displayQuantity = Math.max(0, Math.round(numericValue));
      break;
      
    case "WEIGHT_VOLUME":
    case "ABSTRACT":
      // 예: 플레인요거트 200g → 1개, 약간 → 0개
      displayQuantity = numericValue > 0 ? 1 : 0;
      break;
  }
  
  return {
    name,
    displayQuantity,      // 냉장고 재료 기준으로 변환된 수량(데이터 관리용)
    unitType,
    originalQuantity: quantity, // 원본 레시피 단위(화면 표시용)
  };
};

 

3단계: 요리 완료 시 실사용량 조정 모달

단위 변환만으로는 부족했습니다. 사용자가 레시피대로 정확히 사용하지 않을 수 있다는 점을 고려해야 했습니다.

예를 들어

  • 레시피: 당근 200g
  • 실제 사용: 작은 당근 1개 반 정도만 사용

이런 경우를 위해 요리 완료 시 실제 사용량을 조정할 수 있는 모달을 구현했습니다.

export function CookingCompleteModal({
  recipeIngredients,
  userIngredientList,
  // ...
}: CookingCompleteModalProps) {
  // 각 재료별 사용량을 State로 관리
  const [ingredientQuantity, setIngredientQuantity] = useState<{
    [key: string]: number;
  }>(() => {
    const quantity: { [key: string]: number } = {};
    
    normalizedRecipeIngredients.forEach((ingredient) => {
      // 사용자가 보유한 재료 수량
      const userQuantity = getUserQuantityForIngredient(ingredient.name);
      // 레시피에서 필요한 수량
      const recipeQuantity = ingredient.displayQuantity;
      
      // 둘 중 작은 값을 초기값으로 설정
      quantity[ingredient.name] = Math.min(userQuantity, recipeQuantity);
    });
    
    return quantity;
  });
  
  // + - 버튼으로 수량 조절
  const handleAdjustQuantity = (ingredientName: string, change: number) => {
    setIngredientQuantity((prev) => {
      const currentQuantity = prev[ingredientName] || 0;
      const newQuantity = Math.max(0, currentQuantity + change);
      return {
        ...prev,
        [ingredientName]: newQuantity,
      };
    });
  };
  
  // ... 렌더링
}

 

4단계: 냉장고 재료 일괄 차감

모달에서 사용자가 수량을 조정하고 확인 버튼을 누르면, 여러 재료를 한 번에 차감하는 API를 호출합니다.

export const processIngredientUpdates = (
  userIngredientList: IngredientForRecipe[],
  usedIngredients: { name: string; quantity: number }[]
): Array<{ id: number; quantity: number }> => {
  // 사용한 재료를 Map으로 빠르게 조회
  const usedIngredientsMap = new Map(
    usedIngredients.map((item) => [item.name.toLowerCase(), item.quantity])
  );
  
  const updates: Array<{ id: number; quantity: number }> = [];
  
  userIngredientList.forEach((fridgeItem) => {
    const usedQuantity = usedIngredientsMap.get(fridgeItem.name.toLowerCase());
    
    if (usedQuantity !== undefined && usedQuantity > 0) {
      // 기존 수량 - 사용량 (음수 방지)
      const newQuantity = Math.max(0, fridgeItem.quantity - usedQuantity);
      
      updates.push({
        id: fridgeItem.id,
        quantity: newQuantity,
      });
    }
  });
  
  return updates;
};

 

실제 동작 흐름

1. 사용자가 '무화과요거트' 요리 완료 버튼 클릭
2. 모달 열림
   - 플레인요거트 100g → 1개
   - 무화과 1~2개 → 1개
   - 견과류 1스푼 → 1개
   - 바나나 1/2개 → 1개
   - 블루베리쨈 1스푼 → 1개
3. 사용자가 실제 사용량 조정
   - 무화과 3개 사용 → 모달에서 3개로 수정
4. 확인 버튼 클릭
   → 냉장고 재료 일괄 차감

 

✨ 성과

1. 단위 불일치 문제 완벽 해결

레시피의 다양한 단위 (g, ml, 컵, 개, 약간)를 '개' 단위로 통일하여, 냉장고 재료 관리와 완벽하게 연동했습니다.

2. 사용자 중심 설계

레시피대로 정확히 사용하지 않은 경우에도 직접 수량을 조절할 수 있어, 실제 사용 패턴을 반영한 정확한 재고 관리가 가능합니다.

3. 정확한 재고 관리

요리 후 실제 사용량만큼만 차감되어 냉장고 재료가 항상 정확하게 유지됩니다. 심지어 '소금 약간' 같은 추상적 표현도 시스템에서 처리됩니다.

4. 타입 안전성 확보

TypeScript로 단위 타입(COUNT / WEIGHT_VOLUME / ABSTRACT)을 엄격하게 관리했습니다.

5. API 호출 최적화

여러 재료를 하나씩 업데이트하는 게 아니라 일괄 처리하여 네트워크 비용을 줄이고 성능을 개선했습니다.

 

💡 배운 점

1. 데이터 정규화의 중요성

레시피 데이터는 '당근 200g', '소금 약간'처럼 단위가 통일되어 있지 않았습니다. '약간', '적당량' 같은 추상적 표현까지 있어서 타입별 분류가 필수였습니다.

2. 사용자 경험 우선 사고

기술적으로는 자동 차감이 훨씬 쉽지만, 사용자가 실제로는 레시피대로 사용하지 않을 수 있다는 점을 고려해 '조정 가능한 모달'을 설계했습니다. 이게 오히려 더 정확한 시스템을 만들었습니다.

3. 예외 처리의 중요성

'소금 약간', '물 적당량' 같은 추상적 표현도 시스템에서 처리할 수 있도록 `ABSTRACT` 타입을 추가했습니다.

4. 일괄 처리의 효율성

여러 재료를 하나씩 업데이트하는 대신 한 번에 처리하여 효율성을 높였습니다.

  • API 호출 횟수 감소
  • 네트워크 비용 절감
  • 사용자 경험 개선 (로딩 시간 단축)

5. 정규표현식의 활용

복잡한 문자열 파싱을 정규표현식으로 간결하게 해결할 수 있었습니다. 처음에는 `split()`과 `indexOf()`로 하려다가 정규표현식으로 변경하니 코드가 훨씬 깔끔해졌습니다.

 

마치며

시중에 냉장고 관리 앱이 많지만 레시피 연동이 제대로 된 서비스가 없었던 이유를 직접 겪으면서 알게 되었습니다. 단순해 보이는 기능도 실제로 구현하려면 생각보다 훨씬 복잡한 문제들이 숨어있었습니다.

하지만 이 문제를 해결하면서 데이터 정규화, 사용자 중심 설계, 타입 안전성 같은 중요한 개념들을 체득할 수 있었습니다.