Shopify 첫 앱 만들어보기

템플릿 앱을 상품 편집 작업실로 키워본 하루의 기록
calendar icon

2026년 9월 30일

조회 3

tag icon

테마는 한 번 뜯어봤다. section 과 snippet 을 구분하고, {% schema %} 에 설정 하나 넣고, 그 값이 CSS 까지 흘러가는 걸 확인했다. 그런데 테마로는 화면만 바꿀 수 있다.

상품 정보를 고치는 일은 여전히 관리자 화면에서 한다. 목록에서 상품을 누르고, 상세 페이지에서 고치고, 뒤로 나와서 다음 상품을 누른다. 상품이 스무 개면 이걸 스무 번 한다.

"이 왕복을 한 화면에서 끝낼 수는 없나?"

그래서 앱을 하나 만들어보기로 했다. 처음 목표는 상품 목록을 보여주는 관리자 화면 하나였다. 하루가 끝났을 때는 목록 옆에서 상품을 바로 편집하는 작업 화면이 되어 있었다. 테스트 33개가 통과하고, 커밋은 아직 0개다.


왜 앱인가

Shopify 앱은 크게 두 종류다. App Store 에 올리는 공개 앱과, 스토어 한 곳만을 위해 만드는 custom app. 내가 필요한 건 후자였다. 심사도 과금도 필요 없다.

custom app 을 만드는 이유는 대개 이 정도다.

목적예시
테마로 안 되는 기능데이터 저장, 서버 로직
관리 업무 자동화주문 태그, 재고 맞추기
외부 시스템 연동ERP, 물류, 알림
기본 동작 확장할인·배송 규칙, 체크아웃 UI

시작하기 전에 공식 문서부터 확인했다. 예전에는 관리자 화면에서 버튼 몇 번이면 custom app 이 만들어졌는데, 2026년 1월 1일부터는 관리자에서 새로 만들 수 없다. 이제는 Shopify CLI 로 앱을 만들고 Dev Dashboard 에서 관리한다. 블로그 글이나 옛 튜토리얼을 따라갔으면 첫 단계에서 막혔을 것이다.

shopify app init --name sh-first-app --template reactRouter --flavor typescript

템플릿은 React Router 가 기본이다. 만들어진 걸 열어보니 인증, 세션 저장(Prisma + SQLite), 웹훅, 예제 페이지까지 다 들어 있었다. 세션 DB 는 로컬 파일 하나라 개발 중에는 신경 쓸 게 없었다. 배포할 때가 되면 바꿔야 하지만 그건 나중 일이다.


시작하기 전에 치운 것들

코드를 한 줄 쓰기 전에 환경부터 두 번 막혔다.

CLI 가 두 벌 깔려 있었다

shopify version 을 치자 자동 업그레이드가 돌다가 ENOTEMPTY 로 죽었다. 들여다보니 WSL 쪽 설치는 이미 며칠 전 실패한 업그레이드 때문에 깨져 있었다. 실제 패키지는 .cli-XXXX 라는 임시 이름으로 밀려나 있고, 실행 링크도 임시 이름으로 남아 있었다.

그런데 명령은 멀쩡히 돌았다. PATH 뒤쪽에 있던 Windows 쪽 설치본이 조용히 대신 잡히고 있었기 때문이다.

which -a shopify
# /mnt/c/Users/.../AppData/Roaming/npm/shopify   ← 이것만 남아 있었다

깨진 폴더를 지우고 다시 설치한 뒤, Windows 쪽 설치본은 아예 지웠다. 버전이 다른 CLI 두 개가 번갈아 잡히는 구조는 언젠가 또 조용히 사고를 낸다.

폴더는 스토어 단위로 묶었다

shopify/ 폴더는 모든 Shopify 작업의 루트로 쓰고 있었다. 기존 스토어 하나는 이미 스토어/theme, 스토어/pdp 식으로 나뉘어 있었는데, 개발 스토어는 폴더 자체가 테마 저장소였다. 앱을 어디에 둘지 정해야 했다.

방식장점단점
루트에 앱 폴더를 나란히옮길 게 없다어느 스토어 앱인지 이름으로만 구분
apps/, themes/ 로 종류별앱을 여러 스토어에 쓸 때 유리기존 규칙과 섞인다
스토어별 묶음기존 규칙과 같다테마 저장소를 옮겨야 한다

스토어별 묶음으로 갔다. 테마 저장소를 sh-dev-store/theme 으로 옮기고, 앱은 그 옆에 앱 이름 그대로 뒀다. 앱마다 app/ 같은 고정 이름을 쓰면 앱이 둘만 돼도 곤란해진다.

shopify/
└── sh-dev-store/
    ├── theme/          ← 테마 git
    └── sh-first-app/   ← 앱 git

옮기고 나니 에디터에 등록해둔 프로젝트 경로가 깨져서 다시 등록해야 했다. 폴더 하나 옮기는 데 따라오는 게 은근히 많다.

돌이켜보면 이 구분이 처음부터 필요했다. 며칠 전 테마 폴더에서 shopify app dev 를 쳤다가 Couldn't find an app toml file 을 본 적이 있다. 테마는 shopify theme dev, 앱은 shopify app dev 다. 둘은 명령도, 프로젝트 모양도, 배포 방식도 다르다.


메뉴가 하나만 보였다

첫 과제는 간단했다. Admin GraphQL 로 상품 20개를 가져와 표로 보여주는 페이지 하나, 그리고 앱 메뉴에 링크 하나.

페이지는 바로 떴다. 문제는 메뉴였다. 링크를 세 개 넣었는데 사이드바에는 항상 하나만 보였다.

원인을 두 번 잘못 짚었다

공식 문서 예제를 보니 첫 링크에 rel="home" 이 붙어 있었다. 템플릿에는 없었다. 이게 원인이라고 생각하고 붙였더니 이번엔 타입 오류가 났다.

Property 'rel' does not exist on type ... <Link>

App Bridge 의 타입 정의에는 rel?: "home" 이 있는데, Polaris 의 s-link 타입에는 없었다. 같은 태그를 두 라이브러리가 서로 다르게 알고 있었던 것이다.

타입을 우회해서 붙였더니 Home 이 사라지고 Products 하나만 보였다. 다음엔 "현재 페이지 항목만 보이는 것" 이라고 추측했는데, Additional page 로 가도 Home 만 보여서 그것도 틀렸다.

"첫 항목만 보이는 거였네. 내 추측이 두 번 다 빗나갔다."

결국 원래 코드로 되돌리고 관리자 화면을 새로고침하니 세 개가 전부 멀쩡히 나왔다. 코드는 처음부터 문제가 없었다. 개발 중에 관리자 쪽 메뉴 상태가 갱신되지 않았던 것으로 보이지만, 원인은 끝내 확정하지 못했다.

그래도 rel="home" 은 필요했다

엉뚱한 곳에서 쓸모가 생겼다. 사이드바의 앱 이름을 누르면 템플릿의 소개·로그인 화면(/)이 떴다. 실제 앱 홈은 /app 이다. 문서를 다시 보니 rel="home" 이 없으면 앱 이름은 / 로 간다.

<s-app-nav>
  {/* App Bridge supports rel="home"; Polaris 1.0.1 omits it from s-link's types. */}
  <s-link {...{ href: "/app", rel: "home" }}>Home</s-link>
  <s-link href="/app/products">Products</s-link>
</s-app-nav>

이번엔 원인을 알고 붙였다. 앱 이름이 홈이 되고, 템플릿의 예제 버튼(누르면 실제로 상품이 생긴다)은 별도 "개발 데모" 페이지로 뺐다.


가격 변경 화면에서 작업실로

상품 목록 다음으로 만든 건 일괄 가격 변경이었다. 옵션을 골라 같은 가격을 넣고, 변경 전후를 비교한 뒤 적용한다. 적용 직전에는 현재 가격을 한 번 더 확인한다.

기능은 돌아갔다. 그런데 막상 써보니 불편한 게 한둘이 아니었다.

  • 전체 몇 페이지 중 몇 번째인지, 상품이 몇 개인지 안 보인다
  • 가격을 입력하려면 목록 끝까지 스크롤해야 한다
  • "이 페이지 전체 선택" 이 한 줄을 통째로 차지한다
  • 아무것도 안 쓰고 검색 버튼을 눌러도 검색이 된다

하나씩 고쳤다. 가격 입력은 오른쪽 패널에 고정했다. 전체 선택은 테이블 헤더의 체크박스 하나로 줄였다. 페이지 전체는 고정하고 테이블 안에서만 스크롤되게 했다. 빈 값이나 공백만 입력한 검색은 버튼과 Enter 모두 막았다.

17개와 26개

이상한 숫자가 하나 있었다. 상품 목록은 17개인데 가격 변경 화면에는 26개가 나왔다.

버그가 아니었다. 상품 목록은 상품 수를 세고, 가격 변경은 옵션 조합(Variant) 수를 센다. 티셔츠 하나에 사이즈가 셋이면 상품은 1개, 가격 항목은 3개다. 옵션이 없는 상품도 내부적으로는 기본 조합 하나를 갖는다. 가격이 조합마다 따로 저장되기 때문이다.

기본 화면과 뭐가 다른가

여기서 멈추고 물었다. Shopify 기본 관리자에도 일괄 편집 기능이 있다. 그럼 이 앱은 왜 필요한가.

답은 가격이 아니었다. 처음 불편했던 건 상세 페이지를 드나드는 왕복이었다. 목록에서 들어가 고치고 나와서 다음 상품으로 가는 반복. 그러니 이 화면이 해야 할 일은 depth 를 줄이는 것이다.

"목록을 떠나지 않고 다음 상품으로 넘어갈 수 있어야 한다."

상세 페이지의 항목을 하나씩 따져봤다. 상품명, 설명, 이미지, 옵션별 가격, 위치별 재고, 분류, 태그까지는 목록 옆에서 다룰 만했다. 반대로 옵션 구조 변경, 마켓별 가격, 채널 공개, 메타필드처럼 반복 작업보다 설정에 가까운 것은 기존 상세로 연결하기로 했다.

우선순위는 콘텐츠 → 가격·재고 → 분류·태그로 정했다. 사실 이 순서를 정하는 1분 사이에 말을 두 번 바꿨다.

작업동작
여러 상품 훑기왼쪽 목록, 검색·페이지·스크롤 유지
한 상품 편집오른쪽 패널, 탭으로 콘텐츠·가격·재고·분류 전환
다음 상품패널을 닫지 않고 목록에서 선택, 이전·다음 버튼
여러 상품 한 번에체크박스 선택, 페이지를 넘어도 선택 유지
미저장 상태에서 이동저장 후 이동 / 버리고 이동 / 계속 편집

목록은 고를 수 있게 보여줘야 한다

패널을 열면 목록이 좁아진다. 그래서 목록에는 어떤 상품을 먼저 고칠지 판단하는 데 필요한 정보를 넣었다. 상품명 아래 공급업체·유형, 가격 아래 옵션 수, 재고 칸에는 0 과 미추적 을 구분해 표시한다. 설명이나 이미지가 비어 있으면 그 상품에만 표시가 붙는다.

카테고리 선택창은 처음엔 직접 짠 검색 목록이었는데, 써보니 기본 관리자의 선택창이 훨씬 나았다. 이름을 누르면 선택, 화살표를 누르면 하위 계층으로 들어가는 방식으로 다시 맞췄다. 익숙한 조작을 굳이 새로 만들 이유가 없었다.

버벅임은 레이어 하나로 줄었다

편집 패널을 스크롤하면 미묘하게 끊겼다. 탭과 상관없이 비슷했다. 패널 하나에만 이걸 넣었다.

.editorBody {
  overflow-anchor: none;      /* 결과가 추가돼도 스크롤 위치를 유지한다 */
  contain: layout paint;
  will-change: transform;     /* 이 패널만 별도 레이어로 합성한다 */
  overscroll-behavior: contain;
}

will-change: transform 은 브라우저에게 이 영역을 따로 합성해 달라는 힌트다. 스크롤할 때 주변 UI 까지 다시 그리지 않는다. 대신 레이어 메모리를 더 쓰기 때문에 행이나 이미지마다 붙이지 않고 패널 한 곳에만 뒀다.


저장은 한 번에 끝나지 않는다

편집 화면에서 제일 오래 고민한 건 버튼 모양이 아니라 저장이었다.

상품 하나를 저장하는 것처럼 보여도 실제로는 요청이 여러 개다. 상품 정보는 productUpdate, 옵션 가격은 productVariantsBulkUpdate, 재고는 inventoryAdjustQuantities, 이미지 대체 텍스트는 fileUpdate. 이 중 하나만 실패할 수 있다.

원자적인 척하지 않기로 했다

그래서 저장 결과를 성공/실패 둘로 나누지 않았다. 항목마다 성공·실패·결과 미확정을 따로 보여주고, 이미 성공한 요청은 다시 보내지 않는다. 여러 요청을 하나의 트랜잭션처럼 표시하면 사용자는 실패한 부분을 모른 채 넘어간다.

재고는 두 번 더해지면 안 된다

가격은 같은 값을 두 번 저장해도 결과가 같다. 재고는 다르다. "+5" 요청의 응답이 끊겼을 때 다시 보내면 +10 이 될 수 있다.

await mutate(admin, Q.ADJUST, {
  key: operationId,                       // @idempotent 키
  input: {
    name: "available",
    changes: c.changes.map((r) => ({
      inventoryItemId: r.inventoryItemId,
      locationId: r.locationId,
      delta: r.delta,
      changeFromQuantity: r.before,       // 이 값일 때만 반영
    })),
  },
}, "inventoryAdjustQuantities");

두 겹으로 막았다. @idempotent(key) 는 같은 키의 요청을 한 번만 처리하게 하고, changeFromQuantity 는 현재 수량이 내가 본 값과 다르면 거부한다. 작업 식별자와 처리 결과는 Prisma 에 ProductOperation 테이블을 추가해서 남겼다. 템플릿이 세션용으로 깔아둔 DB 가 여기서 처음 제 몫을 했다.

조용히 지우지 않는다

같은 원칙을 여기저기 적용했다.

  • 기존 설명에 편집기가 모르는 HTML(표, 인라인 스타일)이 있으면 원문 편집 모드로 연다. 서식 편집기에 넣었다가 저장하면 모르는 태그가 소리 없이 사라진다.
  • 이미지 "제거" 는 상품과의 연결만 끊는다. 다른 상품이 같은 파일을 쓰고 있을 수 있다.
  • 업로드 완료와 상품 연결 완료를 구분한다. 파일이 올라갔다고 저장된 게 아니다.
  • 카테고리를 바꿀 때 기존 속성과 충돌하면 자동으로 지우지 않고, 상세 페이지로 안내한다.

"저장 버튼이 성공이라고 말하면, 정말로 전부 성공한 거여야 한다."


보여주려니 막혔다

어느 정도 모양이 나오자 다른 사람에게 보여주고 싶어졌다. 그런데 주소만 보내서는 안 된다. 이 앱은 Shopify 관리자 안에서 로그인한 채로 도는 앱이고, 개발 서버는 내 컴퓨터에서 돈다.

방법필요한 것한계
샘플 데이터 데모 배포호스팅 계정실제 상품이 아니다
내 개발 스토어에 초대스토어 접근 권한저장하면 실제 데이터가 바뀐다
상대 스토어로 미리보기양쪽 모두의 스토어 권한한 번에 스토어 하나

초대는 생각보다 복잡했다. 조직에 속한 스토어라 사용자 목록이 조직 전체로 나왔고, 스토어 접근 권한 체크박스는 내 계정으로는 비활성화돼 있었다. 스토어를 관리하는 권한과 남에게 권한을 주는 권한은 별개였다.

결국 가장 단순한 건 미리보기 대상 스토어를 바꾸는 것이었다. 별도 폴더에 프로젝트를 복사하고 서버를 두 개 띄우는 방법까지 따져봤는데, 한쪽으로 전환만 할 거라면 필요 없었다.

shopify app dev --store 상대방스토어.myshopify.com

한 줄이면 끝나는 일을 멀리 돌아갔다.


결과

아침에 만든 건 상품 20개를 보여주는 표 하나였다. 저녁에는 이렇게 되어 있었다.

영역할 수 있는 것
콘텐츠상품명, 서식 설명·HTML 원문·미리보기, 이미지 추가·연결 해제·순서·대체 텍스트
가격·재고옵션 조합별 판매·비교 가격, SKU·바코드, 위치별 재고 증감
분류상태, 공급업체, 유형, 표준 카테고리, 태그, 수동 컬렉션
일괄 작업페이지 간 선택 유지, 옵션 제외, 변경 전후 미리보기
목록 → 상세 진입 → 수정 → 저장 → 뒤로 → 다음 상품

이 왕복이 목록 옆 패널 하나로 줄었다.

회귀 테스트 33개는 Shopify 와 DB 를 모킹해서 돌린다. 실제 관리자에서는 목록과 상세 조회까지 확인했고, 실제 상품과 재고를 저장하는 검증은 아직 하지 않았다. 테스트 상품을 하나 정해서 해봐야 한다. 그리고 커밋도 해야 한다.

느낀점

  • 공식 문서를 먼저 봐야 하는 이유가 첫날부터 나왔다. 관리자에서 custom app 을 만드는 방법은 올해부터 막혔다. 옛 튜토리얼을 따라갔다면 시작도 못 했을 것이다.
  • 증상이 사라졌다고 원인을 안 건 아니다. 메뉴 문제는 새로고침으로 풀렸지만 이유는 모른다. 그 사이 내가 세운 가설은 두 번 다 틀렸다. 틀린 가설로 코드를 고쳐놓고 해결했다고 믿을 뻔했다.
  • 기능보다 목적을 먼저 정해야 차별점이 생긴다. 가격 변경 기능만 보면 기본 관리자와 다를 게 없었다. "왕복을 줄인다" 로 목적을 다시 잡고 나서야 무엇을 넣고 무엇을 상세로 넘길지가 정해졌다.
  • 저장은 UI 가 아니라 약속이다. 여러 요청을 하나처럼 보이게 하는 순간 거짓말이 된다. 부분 실패를 그대로 보여주고, 두 번 더해지면 안 되는 값은 두 겹으로 막았다.
  • 익숙한 조작을 이기기는 어렵다. 직접 만든 카테고리 선택창보다 기본 관리자의 선택창이 나았다. 새로 만드는 것보다 맞추는 게 빠를 때가 있다.

테마를 만질 때는 화면을 바꾸는 법을 배웠다면, 이번에는 스토어의 데이터를 다루는 법을 처음 배운 셈이다. 하루치 작업이라 거친 데가 많지만, 적어도 매일 하던 왕복 하나는 줄였다.

여기까지 읽어주신 분들께 감사 인사 올리고 여기서 마치겠다(넙죽).

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

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

로그인

연관된 글