Shopify 앱에 메타필드 붙이기

상품 메타필드의 값과 정의를 앱 안에서 다루게 만든 기록
calendar icon

2026년 10월 22일

조회 0

tag icon

지난 글에서는 앱을 공개할 수 있는 모양으로 다듬었다. 홈 화면과 판매 채널 게시를 붙이고, 이름을 바꾸고, 영어 화면까지 만들었다. 그다음에 손댈 곳으로 고른 것은 상품 메타필드였다.

메타필드는 Shopify가 기본으로 주지 않는 상품 정보를 담는 칸이다. 상품별 안내 문구, 보드 길이, 무게 같은 값이 여기에 들어간다. 그런데 이 앱의 편집 패널에서는 제목·가격·재고·분류는 고칠 수 있어도 메타필드는 보이지조차 않았다. 안내 문구 하나를 고치려면 결국 Shopify 관리자의 상품 화면으로 나가야 했다.

"목록 옆에서 고치려고 만든 앱인데, 이것만 또 관리자로 나가야 하나?"

그래서 메타필드를 편집 패널의 탭 하나로 붙이기로 했다. 처음에는 값 편집만 하려고 했지만, 범위를 따져 보다가 정의 추가·편집, 카테고리 지정, 측정 형식까지 이어졌다. 지난 글 이후 커밋은 4개이고, 15개 파일에서 3,310줄이 늘었다.


저장 성공이 오류로 보였다

메타필드 전에 고쳐야 할 것이 있었다

메타필드 작업을 시작하기 직전에 이상한 일이 있었다. 재고를 -10 조정해서 0으로 만들었더니 화면에는 오류처럼 표시됐다. 그런데 새로고침하면 숫자는 제대로 줄어 있었다. 저장 직후 왼쪽 목록도 이전 숫자 그대로였다.

서버 기록을 보니 저장은 성공해 있었다. 문제는 화면이 응답을 읽는 방식이었다. 지난 글에서 영어 지원을 넣으면서 서버 응답의 문장을 번역용 코드로 바꿨는데, 그 결과 저장 성공 응답에도 code: "saved"가 붙게 됐다. 화면 쪽은 응답에 code가 있으면 오류로 판정하고 있었다.

// 이전: 성공 응답에도 code가 붙으면서 성공을 오류로 읽었다
if (!response.ok || !data || data.error || data.code) {

// 이후: HTTP 실패이거나 error가 있을 때만 오류로 본다
// A 200 body may carry a code too: a save result reports "saved" that way.
if (!response.ok || !data || data.error) {

성공으로 처리되지 않으니 저장 뒤 목록을 다시 불러오는 단계도 건너뛰고 있었다. 목록 숫자가 그대로였던 것도 같은 원인이었다. 이 판정은 일괄 수정, 상품 편집 저장, 작업 기록의 되돌리기가 함께 쓰고 있어서 번역을 넣은 뒤로 세 곳 모두 같은 증상을 내고 있었다. 서버의 오류 응답은 모두 HTTP 400으로 나가는 것을 확인했기 때문에 code 조건을 빼도 진짜 오류를 놓치지는 않는다.

한 줄짜리 수정이었지만, 번역 작업 때 응답 형식을 바꾸면서 그 응답을 읽는 쪽 조건까지는 다시 보지 않은 것이 원인이었다. 이번에 메타필드 저장도 같은 응답 형식을 쓰게 되니, 먼저 고치고 넘어간 것이 다행이었다.


정의까지 앱에서 만들기로 했다

정의 추가를 뺄 이유는 생각보다 약했다

처음 세운 범위는 좁았다. 관리자에서 정의해 둔 메타필드의 값만 편집하고, 새 정의는 관리자에서 만들게 할 생각이었다. 이유는 셋이었다. 정의는 스토어 전체에 영향을 주고, 형식(type)과 key는 만든 뒤 바꿀 수 없으며, 관리자에 이미 완성된 화면이 있다는 것이었다.

다시 따져 보니 앞의 두 이유는 관리자에서도 똑같았다. 관리자에서 만들어도 정의는 스토어 전체에 걸리고, 형식과 key는 관리자에서도 지우고 다시 만들어야 한다. 앱이라서 생기는 위험이 아니었다. 남는 이유는 검증 규칙 같은 설정을 얼마나 구현해야 하느냐, 즉 작업량 하나뿐이었다.

그래서 Shopify 문서와 실제 스키마를 보고 다시 판단했다. metafieldDefinitionCreate는 앱에 이미 있는 write_products 권한으로 쓸 수 있었다. 검증 규칙도 형식마다 직접 만들 필요가 없었다. metafieldDefinitionTypes 조회가 형식 118개와 각 형식이 받는 검증 항목을 함께 돌려줬다. 검증 칸은 형식별로 코드를 쓰지 않고 이 목록을 보고 그리게 했다.

관리자에서 만든 정의와 같아야 했다

정의를 만드는 API는 관리자 화면보다 기본값이 적다. 그대로 쓰면 앱에서 만든 정의만 성격이 달라진다. 그래서 관리자에서 만든 정의 하나와 템플릿 예시가 만든 정의를 조회해 비교했다.

항목관리자에서 만든 정의API 기본값앱에서 정한 값
namespacecustom비우면 앱 전용 공간custom 고정
상품 화면 고정(pin)켜짐꺼짐켜짐
Storefront API 공개켜짐NONEPUBLIC_READ

가장 중요한 것은 namespace였다. 비워 두면 정의가 app--숫자 같은 앱 전용 공간에 만들어진다. 템플릿 예시가 만든 정의가 실제로 그 공간에 있었고, 판매자가 직접 만든 정의와는 권한과 고정 여부가 달랐다. 앱이 만든 정의를 판매자가 관리자에서 계속 쓰려면 custom에 만들어야 했다.

형식마다 따로 챙길 것도 있었다. 평점(rating)은 척도 최솟값·최댓값 검증이 필수라 두 칸을 필수 입력으로 받았다. 분석 옵션은 쓸 수 있는지가 정의가 만들어진 뒤에야 조회돼서, 추가 화면에서는 빼고 편집 화면에서만 보이게 했다. 형식과 key는 수정 API의 입력 항목에 아예 없어서 편집 화면에서 읽기 전용으로 두고, 그 옆에 관리자의 정의 화면으로 가는 링크를 달았다.

메타필드 하나가 하나로 보여야 했다

첫 화면은 기능은 있었지만 메타필드끼리 경계가 보이지 않았다. 이름, 키, 입력칸이 줄줄이 이어져서 어느 칸이 어느 메타필드인지 구분이 안 됐다. 관리자의 메타필드 카드처럼 메타필드 하나를 한 줄로 묶었다. 왼쪽에는 이름과 형식 배지, 회색 키를, 오른쪽에는 값 입력칸을 두고 줄 사이에 구분선을 넣었다.

정의 편집도 처음에는 별도 링크로 열었는데, 이름을 누르면 바로 열리게 바꿨다. 추가·편집 화면은 목록 위에 끼어드는 대신 탭 전체를 차지하고, 취소하면 원래 목록으로 돌아간다. 패널 아래쪽 저장 바도 그동안은 "정의 저장/취소"로 바뀌게 해서, 상품 저장 버튼과 정의 저장 버튼이 한 화면에 두 벌 보이지 않게 했다.

추가 화면을 열면 스크롤이 갑자기 늘어나는 문제도 있었다. 이름·키 칸이 먼저 그려지고 형식 목록이 나중에 도착하면서 검증 칸이 뒤늦게 붙었기 때문이다. 형식 목록을 받는 동안에는 로딩 막대만 보이다가 완성된 화면이 한 번에 나타나게 했고, 목록은 한 번만 받아 두고 재사용했다.

effect가 Promise를 돌려주고 있었다

편집 화면을 닫을 때 TypeError: destroy is not a function이 났다. 스택은 React가 effect의 정리 함수를 호출하는 지점이었다. 편집 화면을 열 때 맨 위로 스크롤하려고 쓴 코드가 원인이었다.

// 이전: 중괄호 없는 화살표라 scrollIntoView의 반환값이 그대로 반환된다
useEffect(() => ref.current?.scrollIntoView({ block: "start" }), []);

// 원칙: effect 본문은 중괄호로 감싸 아무것도 돌려주지 않는다
useEffect(() => {
  ref.current?.scrollIntoView({ block: "start" });
}, []);

예전에는 scrollIntoView가 아무것도 돌려주지 않아서 이 코드가 문제없이 동작했다. 그런데 Chrome 150 브라우저에서 확인해 보니 scrollIntoView(...) instanceof Promise가 true였다. React는 effect가 돌려준 값을 정리 함수로 알고 호출하는데, 그 값이 Promise라 터진 것이다.

"브라우저가 바뀌었다고 effect 정리 단계가 깨진다고?"

게다가 scrollIntoView는 편집 패널만이 아니라 앱 화면 전체까지 스크롤해서 상품명과 탭을 화면 밖으로 밀어내고 있었다. 결국 scrollIntoView를 빼고 편집 패널 안쪽 스크롤 영역만 맨 위로 올리게 바꿨다. 같은 형태로 쓰던 다른 effect도 값을 돌려주지 않게 고쳤다.


카테고리는 저장된 값을 보고 맞췄다

문서에는 하위 카테고리 이야기가 없었다

정의에는 상품 카테고리를 지정할 수 있다. 지정하면 그 카테고리의 상품에서만 그 메타필드가 보인다. 관리자 화면은 상위 카테고리를 체크하면 하위가 모두 체크되는 트리였다. 문제는 저장 방식이었다. 상위 하나만 저장하면 Shopify가 하위까지 적용해 주는지, 하위를 모두 나열해 저장해야 하는지가 문서에 없었다.

이건 구현을 완전히 바꾸는 차이였다. 상위 카테고리 몇 개의 하위 개수를 세어 보니 차이가 컸다.

상위 카테고리하위 개수
Animals & Pet Supplies417
Apparel & Accessories662
Electronics1,175

추측으로 정하지 않고 관리자에서 직접 저장해 보기로 했다. 기존 정의에 Animals & Pet Supplies 하나만 지정해 저장한 뒤 API로 읽었다. 저장된 값은 419개였다. 상위 1개와 하위 417개, 그리고 고르지 않은 Uncategorized 1개였다. 관리자는 상위 하나가 아니라 하위를 전부 나열해 저장하고 있었다.

Uncategorized는 관리자를 따라 넣었다

Uncategorized(na)는 카테고리가 없는 상품을 뜻한다. 화면에서 고르지 않았는데 저장돼 있었다. 처음에는 이 정의의 값을 가진 두 상품이 모두 Uncategorized라서, 그 값을 지키려고 관리자가 넣은 것이라고 추정했다. 그 추정이 맞다면 값이 있는 상품의 카테고리를 찾아 넣어야 하니 구현이 복잡해진다.

공식 도움말에서는 이 동작에 대한 설명을 찾지 못했다. 값이 없는 시험용 정의로 원인을 가려 볼 수도 있었지만, 관리자는 카테고리를 지정할 때 Uncategorized를 늘 함께 넣는다고 보고 그 결과를 그대로 따르기로 했다. 원인을 확정하는 것보다 관리자에서 만든 정의와 앱에서 만든 정의가 같은 상품에 보이는 것이 더 중요했다. 카테고리를 하나라도 지정하면 서버가 Uncategorized를 함께 저장하고, 하나도 지정하지 않으면 넣지 않는다.

export async function expandCategories(admin: Admin, ids: string[]) {
  const all = new Set(ids.length ? [...ids, UNCATEGORIZED] : []);
  for (let i = 0; i < ids.length; i += 4)
    for (const below of await Promise.all(
      ids.slice(i, i + 4).map((id) => categoryDescendants(admin, id)),
    ))
      for (const id of below) all.add(id);
  return [...all];
}

화면에서는 관리자처럼 자동으로 들어간 Uncategorized를 고른 항목으로 보여 주지 않는다. "하위 포함 N개" 개수에서도 뺐다. Animals & Pet Supplies만 고르면 418개, Uncategorized를 직접 함께 고르면 419개로 나온다.

하위를 미리 받지 않는 선택 방식으로 바꿨다

첫 구현은 상위를 체크할 때마다 하위 전체를 받아 와서 선택 목록에 넣었다. Electronics를 체크하면 하위 1,175개를 받느라 Shopify를 다섯 번 호출했다. 트리도 25개씩 나눠 받아서 처음 여는 것부터 느렸다.

선택을 저장하는 방식을 바꿨다. 선택 목록에 상위가 들어 있으면 그 아래는 모두 선택된 것으로 본다. 카테고리 ID는 ap, ap-2, ap-2-1처럼 접두사로 이어져 있어서, 부모를 따라 올라가며 목록에 있는지만 보면 된다.

// A selection keeps a category in place of its whole subtree.
const covered = (value: Set<string>, id: string) => {
  for (let p: string | null = id; p; p = parentOf(p))
    if (value.has(p)) return true;
  return false;
};

이제 체크는 기다리지 않고 바로 반영된다. 하위 전체 목록은 정의를 저장할 때 서버가 한꺼번에 채운다. 선택된 상위 안에서 하나를 해제하면 그 상위를 나머지 하위들로 나눠 선택 상태를 유지한다. 트리 목록은 한 번에 받아 서버와 화면에 저장해 두게 했다. 개발 서버에서 재 보니 첫 열기는 1.15초였고, 그 뒤에는 0.005초였다.

이 과정에서 한 번 잘못 판단했다. 하위를 미리 받지 않게 되면서 "하위 포함 N개"를 바로 알 수 없게 되자 그 문구를 지웠다. 그런데 몇 개가 지정되는지는 관리자 화면에도 있는 정보였다. 개수는 서버에서 따로 세어 오게 하고 세는 동안에는 "하위 카테고리 세는 중…"을 보여 주는 것으로 되살렸다.

탭도 카테고리를 따라야 했다

카테고리 지정을 넣으면 메타필드 탭도 바뀌어야 했다. 지정한 카테고리가 아닌 상품에서는 그 정의가 보이면 안 되기 때문이다. 어떤 정의가 적용되는지는 직접 계산하지 않고 Shopify의 constraintSubtype 조회에 맡겼다. 카테고리가 없는 상품은 Uncategorized 기준으로 묻는다. 적용되지 않는 정의에 값이 남아 있으면 탭 아래 "이 상품에 적용되지 않는 메타필드"에 읽기 전용으로 모았다.

정의 편집 화면의 카테고리 칸은 처음에 트리를 그대로 펼쳐 두었다. 양식이 길어져서 한 줄 요약으로 바꾸고, 누르면 창에서 트리를 고르게 했다. 요약은 "Animals & Pet Supplies 외 1개 · 하위 포함 N개"처럼 보인다. 창에서 "적용"을 눌러야 양식에 반영되고, 취소하면 원래 선택 그대로다.


측정 형식과 단위

단위 목록은 API에 없었다

무게, 치수, 부피 같은 측정 형식은 값이 {"value": 1.5, "unit": "..."} 모양이다. 형식 목록 조회는 측정 형식이 있다는 것까지만 알려 주고 단위 목록은 주지 않았다. 스키마에 있는 단위 목록도 무게·길이 몇 종뿐이었다. 그래서 Shopify 문서의 형식별 단위를 앱에 표로 넣었다. 문서에 단위가 없는 광속(luminous_flux)은 읽기 전용으로 남겼고, 나머지 31종과 그 목록형을 편집할 수 있게 했다.

표기도 문서와 실제가 달랐다. 스토어에 있던 값은 CENTIMETERS처럼 대문자였고 문서는 centimeters처럼 소문자였다. 읽을 때는 대소문자를 가리지 않고, 저장할 때는 문서대로 소문자로 보냈다.

function readMeasure(raw: unknown, units: string[]): Measure {
  const m = (raw ?? {}) as { value?: unknown; unit?: unknown };
  const unit = String(m.unit ?? "").toLowerCase();
  return {
    value: m.value === undefined || m.value === null ? "" : String(m.value),
    unit: units.includes(unit) ? unit : units[0],
  };
}

실제로 무게 메타필드에 1.5 kg을 저장하고 API로 읽었더니 {"value":1.5,"unit":"KILOGRAMS"}가 돌아왔다. 소문자로 보낸 값을 Shopify가 대문자로 바꿔 저장한 것이다. 다시 읽을 때 대소문자를 가리지 않으니 화면에도 그대로 kg이 선택돼 나왔다.

단위 이름은 기호로 줄였다

처음에는 단위를 API 값에서 밑줄만 뺀 그대로 보여 줬다. 무게 칸 옆에 kilograms가 나오니 칸이 넓어지고 읽기도 불편했다. 단위 133개를 모두 kg, cm, °C, mL 같은 기호로 바꿨다. 질량 유량 24개는 표에 따로 적지 않고 질량 기호와 시간 기호를 붙여 kg/h처럼 만들었다. 저장하는 값은 그대로 두고 보이는 이름만 바꾼 것이다.

밀리리터를 mL로 할지 ml로 할지는 잠깐 고민했다. 국내 쇼핑몰에서는 500ml가 더 익숙하다. 하지만 소문자 l은 글꼴에 따라 숫자 1과 구분이 어렵고, 리터 기호로는 대문자 L을 권장하는 곳이 많다. 그래서 mL, cL, L로 정했다. 미국식과 영국식 부피는 US gal, imp gal처럼 앞에 붙여 구분했다.


결과

항목이전이후
메타필드 값앱에서 보이지 않음편집 패널 탭에서 편집, 저장·되돌리기 흐름 공유
메타필드 정의관리자에서만 추가·편집앱에서 추가·편집, 기본값은 관리자와 같게
편집 가능한 형식없음텍스트·숫자·참/거짓·날짜·URL·색상·평점·측정 31종과 그 목록형
카테고리 지정없음트리 창에서 지정, 하위와 Uncategorized까지 저장
저장 성공 표시번역 작업 뒤 성공도 오류로 표시성공으로 표시하고 목록을 다시 불러옴

실행한 검증은 이렇다. 타입 검사와 린트, 포맷 검사를 통과했고, 테스트는 67개에서 75개로 늘어 모두 통과했다. 새 테스트는 바뀐 값만 저장 명령이 되는지, 저장 직전 값이 바뀌었으면 쓰지 않는지, 카테고리를 지정하면 Uncategorized가 붙는지 등을 확인한다. 새로 쓴 GraphQL 조회와 변경은 모두 실제 스토어의 스키마로 검사했다. 관리자 미리보기에서 무게 값 저장과 kg 표시를 확인했고, 앱에서 지정한 카테고리 정의도 관리자에서 확인한 뒤 시험용 정의를 지웠다.

확인하지 못한 것도 있다. 측정 형식 31종 가운데 실제로 저장해 본 것은 무게 하나다. 정의를 수정할 때 검증 목록을 지우면 정말 지워지는지는 문서에 없고 직접 돌려 보지도 않았다. 관리자가 Uncategorized를 왜 넣는지는 끝내 확인하지 못했고, 결과를 맞추는 데서 멈췄다. 정의를 만들거나 고친 기록은 작업 기록에 남지 않는다. 작업 기록이 상품 단위로만 저장되는 구조라서다. 참조형과 서식 있는 텍스트 같은 형식은 여전히 읽기 전용이다.


느낀점

  • 위험하다는 이유는 비교 대상과 함께 따져야 했다. 정의 추가를 빼려던 이유 대부분은 관리자에서도 똑같이 성립했다. 앱이라서 생기는 위험인지를 먼저 묻자 남는 것은 작업량뿐이었다.
  • 문서에 없는 동작은 실제로 저장된 값이 답이었다. 하위 카테고리 처리는 문서로는 알 수 없었지만, 관리자에서 한 번 저장하고 읽은 419개가 구현 방향을 정해 줬다.
  • 기본값이 다르면 같은 기능도 다른 결과를 낸다. namespace 하나를 비워 두면 앱이 만든 정의만 판매자가 다루기 어려운 공간에 생긴다. API의 기본값과 관리자의 기본값을 나란히 놓고 보는 것이 필요했다.
  • 빠르게 만들려고 정보를 빼면 다시 넣게 된다. 하위 개수 문구를 지웠다가 되살린 것처럼, 성능 때문에 보이는 정보를 줄이는 결정은 그 정보가 왜 있었는지 먼저 봐야 했다.

메타필드는 처음에 값 편집 하나로 생각했던 것보다 넓었다. 정의, 카테고리, 형식별 입력이 서로 얽혀 있어서 하나를 붙이면 다음 것이 따라왔다. 그래도 단계를 나눠 텍스트 형식부터 끝내고 측정 형식과 카테고리를 이어 붙인 덕분에, 각 단계마다 실제 스토어에서 동작을 확인하며 넘어갈 수 있었다.

다음에는 남겨 둔 참조형 메타필드와 일괄 수정에서 메타필드를 한꺼번에 바꾸는 기능을 볼 차례다. 여기까지 읽어주신 분들께 감사드리며 이번 글을 마친다.

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다.

로그인

연관된 글