Shopify 테마 학습기

상품 가격 갱신부터 편집 가능한 안내 배너까지 직접 연결한 기록
calendar icon

2026년 10월 15일

조회 0

tag icon

테마 구조를 정리한 글과 기존 섹션을 바꿔본 글을 쓰고 나서도, 설명을 읽는 것과 직접 구현하는 것 사이에는 빈칸이 남아 있었다. 앱을 만들어보고 있다는 이야기를 하다가 테마부터 공부해보라는 조언을 들었다. 이번에는 기능 하나를 빨리 완성하기보다, 작은 코드를 직접 작성하고 그 결과를 설명하는 방식으로 다시 접근하기로 했다.

학습 기준도 정했다. 화면이 나온다는 이유만으로 다음 단계로 넘어가지 않고, 어떤 데이터가 어디에서 읽히고 무엇이 갱신되는지 답할 수 있어야 했다. 조건문부터 시작해 상품 가격, 설정 입력란, 별도 섹션과 블록으로 범위를 넓혔다. 문법을 틀린 부분은 고쳐서 다시 작성하고, 동작은 개발용 미리보기에서 확인했다.

"이 코드가 왜 동작하는지 설명할 수 있을까?"

그래서 이번 글은 테마의 폴더 구조를 다시 나열하지 않기로 했다. 상품 페이지에 붙인 작은 연습 코드가 편집 가능한 기능이 되는 과정을 중심으로 남겼다. 특히 Liquid로 값을 출력하는 일과 브라우저에서 화면을 갱신하는 일이 어디에서 만나는지 확인하고 싶었다.


가격을 읽는 것과 바꾸는 것

배치를 옮긴 뒤에도 옵션을 눌러봤다

첫 수정은 상품 가격을 옵션 선택 영역 바로 위로 옮기는 것이었다. 상품 템플릿의 block_order에서 가격 블록의 순서를 바꿨다. 다른 블록의 상대적인 순서는 유지하고, 개발용 미리보기에서 가격 위치가 달라지는지 확인했다.

배치가 맞는 것만으로 끝내지는 않았다. 같은 상품 안에서 옵션을 바꿨을 때 선택 표시와 기존 가격이 정상적으로 갱신되는지도 봤다. 상품 하나의 색상이나 크기처럼 구체적인 옵션 조합을 나타내는 variant를 직접 눌러보니, 다른 상품으로 이동하는 것과 같은 상품의 옵션을 바꾸는 것을 분리해서 볼 수 있었다.

판매 가능 여부도 같은 구분이 필요했다. product.available은 지금 선택한 variant 하나만의 상태를 뜻하지 않았다. 구매 가능한 variant가 하나라도 있으면 상품 전체의 값은 참이었다. 나는 이 조건으로 구매 가능 안내 문구를 출력한 뒤, 구매할 수 있는 상품과 그렇지 않은 상품에서 각각 다른 문구가 나오는지 확인했다.

같은 가격처럼 보여도 읽는 데이터가 달랐다

상품명 아래에 product.price를 출력하고 money 필터를 붙였다. 통화 형식은 익숙한 가격처럼 보였지만, 더 비싼 옵션을 골라도 새로 추가한 숫자는 그대로였다. 기존 가격은 선택한 옵션을 따라 바뀌고, 내가 붙인 가격은 상품의 최저가를 계속 보여줬다.

읽는 값이번 실습에서의 의미
product.price상품 variant들의 최저가
variant.price반복문에서 현재 다루는 variant의 가격
product.selected_or_first_available_variant.price렌더링 시 선택됐거나 기본으로 사용할 variant의 가격
money 필터금액의 통화 표시 형식

여기서 데이터 선택과 표시 형식은 다른 문제라는 기준이 생겼다. money는 최저가를 선택한 옵션의 가격으로 바꿔주지 않았다. 출력할 값을 잘못 고르면 형식이 정확해도 원하는 가격은 나오지 않았다.

다음에는 product.variants를 반복하면서 각 이름과 가격을 나열했다. 같은 상품의 여러 옵션이 한 번에 보였고, 목록의 가격을 기존 옵션 선택 UI의 가격과 비교할 수 있었다. 반복문은 문법을 익히는 용도이면서, 어떤 데이터가 들어 있는지 확인하는 수단이 됐다.

서버 응답과 현재 화면을 따로 읽었다

선택된 variant의 가격을 출력하더라도 옵션을 누르는 순간 자동으로 갱신되는 것은 아니었다. Liquid는 서버에서 HTML을 만들 때 실행되고, 클릭을 감지하는 역할은 브라우저의 JavaScript가 맡았다. 그래서 기존 테마의 옵션 변경 코드를 따라가며 선택값 수집, variant 결정, 서버 요청, 화면 교체를 나눠 읽었다.

개발자 도구의 Network에서 옵션을 바꿀 때 생기는 요청을 찾았다. URL에는 선택한 variant와 다시 렌더링할 section_id가 들어 있었다. 응답은 JSON이 아니라 섹션의 HTML이었고, 그 안의 가격 요소에서 화면에 표시된 것과 같은 금액을 확인했다.

서버가 가격이 들어 있는 HTML을 보냈다는 사실만으로 현재 화면이 바뀌는 것은 아니었다. 응답을 파싱해 만든 문서에서 source를 찾고, 현재 document에서 destination을 찾은 뒤 내부 내용을 복사해야 했다. 섹션 전체를 받는 범위와 실제로 교체하는 범위가 달랐다.

const learningDestination = document.getElementById(
  `learning-price-${this.dataset.section}`
);
const learningSource = html.getElementById(
  `learning-price-${this.dataset.originalSection ? this.dataset.originalSection : this.dataset.section}`
);
if (learningDestination && learningSource) {
  learningDestination.innerHTML = learningSource.innerHTML;
}

위 코드는 실습한 갱신 부분을 읽기 쉽게 정리한 것이다. 새 가격에는 기존 가격과 다른 ID를 붙이고, 응답 쪽에서도 그 ID에 대응하는 요소를 찾았다. 기존 갱신 코드를 참고하면서 처음에는 조건만 새 변수로 바꾸고 대입문에는 옛 변수를 남기기도 했다. 요소를 찾는 것과 찾은 요소를 실제로 사용하는 것을 함께 확인해야 했다.

저장 후 바로 바뀌지 않는 순간도 있었다. 개발자 도구에서 캐시를 끄고 새로고침하자 추가한 가격도 정상적으로 움직였다. 이전 JavaScript가 남아 있었을 가능성은 있었지만, 당시 캐시 상태를 따로 측정한 것은 아니므로 원인을 확정하지는 않았다.


설정을 만든다는 것

코드 조각을 나누면서 필요한 값을 드러냈다

구매 가능 안내, 선택된 가격, 전체 variant 목록을 별도 snippet으로 옮겼다. 상품 정보 섹션에서는 render로 호출하고 상품과 섹션 ID를 전달했다. 이후 설정이 늘어날 때도 같은 호출에 필요한 값을 명시했다.

{% render 'learning-product-info',
  product: product,
  section_id: section.id,
  available_message: section.settings.available_message,
  show_available_message: section.settings.show_available_message,
  available_message_size: section.settings.available_message_size
%}

내가 주목한 부분은 파일이 짧아진 것보다 이름의 경계였다. 호출하는 쪽의 section.id는 snippet 안에서 section_id라는 매개변수로 사용했다. snippet 파일에는 별도의 전용 래퍼가 필요한 것이 아니라 HTML과 Liquid를 그대로 넣었다. 분리 후 문구가 중복되지 않는지, 가격 갱신이 유지되는지를 다시 봤다.

입력란과 화면 출력을 끝까지 연결했다

다음 목표는 구매 가능 안내를 편집기에서 수정하는 것이었다. schema에 text 설정을 추가하고, section.settings에서 값을 읽어 snippet으로 넘겼다. 고정 문구를 escape 필터를 적용한 값 출력으로 바꾼 뒤, 편집기에서 내용을 수정하고 저장했다. 새로고침 후에도 문구가 유지되는지까지 확인했다.

문구를 입력할 수 있게 하니 빈 값과 표시 여부도 결정해야 했다. 문구가 blank이면 <p>를 만들지 않는 조건을 넣고, 별도의 체크박스로 표시를 끌 수 있게 했다. 문구를 지워서 숨기는 것과, 내용을 보존한 채 숨기는 것은 운영자가 쓰는 입장에서 다른 동작이었다.

설정 상태구매 가능한 상품의 안내
표시 켬, 문구 있음문구 출력
표시 끔, 문구 있음숨김
표시 켬, 문구 비어 있음숨김

입력란을 정의하는 JSON에서도 자주 멈췄다. 속성 이름의 큰따옴표, 마지막 항목 뒤의 쉼표, 배열 안에 객체를 넣는 구조가 손에 익지 않았다. Liquid의 출력 태그를 고정 클래스 이름에 붙이거나, CSS 속성을 JSON처럼 쉼표로 구분하기도 했다. 한 파일 안에 여러 문법이 섞이니 지금 작성하는 구간이 무엇인지 먼저 구분해야 했다.

동적이라는 말을 출력 시점으로 풀었다

글자 크기는 range 설정으로 받고 CSS 변수에 연결했다. 처음에는 <p>의 style에 크기와 px를 넣고, CSS 파일에서 var()로 읽었다. 편집기에서 값을 바꾸면 문구 크기가 달라졌지만, 이것을 설명하는 과정에서 동적이라는 말이 오히려 혼란을 만들었다.

"설정을 바꾼다는 것과 코드를 다시 쓴다는 것을 같은 말로 보고 있지 않을까?"

정리하고 보니 설정값을 연결하는 코드는 한 번 작성하는 것이었다. 이후 편집기에서 숫자를 바꾸면, 변경된 설정을 반영한 렌더링 결과가 화면에 적용됐다. Liquid가 브라우저에서 설정값을 계속 감시하는 것도 아니고, CSS 변수가 관리자 화면을 직접 읽는 것도 아니었다.

특히 font-size: 20px처럼 렌더링된 결과만 보면 하드코딩과 구분이 되지 않았다. 직접 숫자를 적은 원본 코드와, Liquid가 설정값을 읽어 숫자를 출력한 결과를 나눠 봐야 했다. 인라인 font-size, Liquid로 만든 CSS 규칙, CSS 변수 모두 설정과 연결할 수 있다는 뜻이지, 고정 숫자를 써두고 편집기에서 바꿀 수 있다는 뜻은 아니었다.

설정이 많아지면 CSS 변수 선언을 어디에 둘지도 고민했다. 한곳에 모으는 방식이 항상 낫다고 결론내리려 했지만, 한 요소에 값을 붙이는 방식도 용도가 있었다. 적용 범위와 기존 테마의 구성을 먼저 보고, 그 안에서 관리하기 편한 방식을 선택하기로 했다.

배치 방식관리할 때 보는 지점
요소의 인라인 CSS 변수값과 사용 대상이 가까이 있음
섹션 ID를 대상으로 한 style 블록여러 선언을 모으고 해당 섹션으로 범위를 제한함
변수 선언 전용 snippet반복되는 선언과 변환을 재사용함

지금 작성한 상품 안내는 인라인 변수를 유지했다. 뒤에 만든 배너는 섹션의 style 블록에서 열 수를 선언했다. 방식의 우열을 일반화하는 대신 두 구조가 실제로 어떻게 적용되는지 직접 비교할 수 있었다.


추가할 수 있는 배너로 넓혔다

섹션 파일과 배치 정보를 구분했다

상품 정보 안에서만 연습하던 코드를 벗어나 새 안내 배너 섹션을 만들었다. 제목과 문구를 출력하고, schema에 두 입력란을 넣었다. presets를 정의한 뒤 편집기의 섹션 추가 목록에서 배너를 선택했다.

새 파일을 만들었다고 페이지에 자동으로 들어가는 것은 아니었다. 편집기에서 어느 페이지에 배치할지 선택하는 단계가 별도로 있었다. 상품 페이지에 배너를 추가하더라도 기존 상품 정보 섹션 파일 안에 배너 코드가 삽입되는 구조는 아니었다.

예를 들어 상품 템플릿은 상품 정보 섹션과 안내 배너 섹션을 나란히 구성할 수 있었다. 섹션 파일은 구현을 가지고, 템플릿 JSON은 섹션 인스턴스의 구성과 순서를 가진다. 편집기에서 저장한 원격 구성과 로컬 파일이 동기화됐는지도 별도로 봐야 했다. 이번 학습에서는 새 배너를 추가하고 문구가 반영되는 것부터 확인했다.

정의와 초기값은 같은 blocks여도 달랐다

배너 안에 배송 안내와 교환 안내를 추가하려고 블록을 만들었다. section.blocks를 반복해 각 제목과 문구를 출력하고, 블록 래퍼에는 block.shopify_attributes를 유지했다. 편집기에서는 항목을 각각 수정하고, 순서를 바꾸고, 삭제하는 동작을 확인했다.

그다음에는 배너를 추가할 때 두 항목이 기본으로 생기게 하고 싶었다. schema 최상위의 blocks는 허용할 블록 종류와 입력 설정을 정의했다. 반면 presets 안의 blocks는 새 섹션에 넣을 블록과 초기값을 지정했다. 이름이 같아도 선언 위치에 따라 역할이 달랐다.

{
  "type": "notice",
  "settings": {
    "heading": "배송 안내",
    "message": "배송 정보를 확인하세요."
  }
}

처음에는 초기값을 "settings.heading"이라는 속성으로 적었다. JSON에서는 그 점이 중첩 구조를 만들어주지 않으므로, settings 객체 안에 heading과 message를 넣는 형태로 고쳤다. 배열과 객체의 닫는 괄호, 마지막 쉼표도 다시 확인했다. 완성 후 기존 배너가 아니라 새 배너를 추가하자 두 안내 항목이 기본으로 들어갔다.

반응형 배치에도 편집 범위를 정했다

블록 목록에는 Grid를 적용했다. 기본은 한 열이고, 화면 너비가 750px 이상이면 두 열이 되도록 시작했다. CSS 파일을 만든 뒤 asset_url과 stylesheet_tag로 섹션에서 불러와야 실제 화면에 적용됐다.

여기에 데스크톱 열 수를 2부터 4까지 바꾸는 슬라이더를 붙였다. 섹션 ID를 가진 요소에 숫자 CSS 변수를 선언하고, 미디어 쿼리 안의 repeat()가 그 값을 읽게 했다. 아래는 설정값을 CSS로 전달한 부분이다.

{% style %}
  #LearningBanner-{{ section.id }} {
    --learning-banner-columns: {{ section.settings.desktop_columns }};
  }
{% endstyle %}

폭이 줄면 자동으로 4열, 3열, 2열을 거쳐 내려오는 방식은 아니었다. 750px 이상에서는 편집기에서 정한 열 수를 유지하고, 그보다 좁아지면 한 열이 됐다. 데스크톱 설정과 모바일 규칙의 적용 범위를 나눴기 때문이다.

처음에는 계속 한 열로만 보였다. schema 속성 이름에 들어간 " default"의 앞 공백을 제거한 뒤 정상적으로 동작했다. 당시 오류 때문에 섹션 업로드가 실패했는지까지 로그로 확정한 것은 아니었다. 확인한 결과는 오타를 고친 뒤 설정 열 수와 모바일 전환이 의도대로 적용됐다는 데까지다.


결과

이번에는 상품 화면의 작은 변경에서 시작해, 운영자가 직접 구성할 수 있는 배너까지 만들었다. 핵심은 추가한 기능의 개수보다 데이터와 화면 사이의 연결을 설명할 수 있게 됐다는 점이었다. 어떤 파일을 바꿔야 하는지뿐 아니라, 바꾼 값이 어떤 경로를 거쳐 화면에 도착하는지 확인했다.

처음 확인한 문제실습 후 연결한 동작
가격 위치를 옮기고 싶었다블록 순서를 바꾸고 옵션 동작까지 확인했다
추가한 가격이 옵션을 따라가지 않았다서버 응답에서 새 요소를 찾아 현재 DOM을 갱신했다
문구를 코드에서만 바꿀 수 있었다편집기 입력과 표시 조건을 snippet에 전달했다
배너에 안내 항목을 늘리고 싶었다블록과 preset으로 편집과 초기 구성을 나눴다
열 수가 고정되어 있었다섹션 설정과 CSS 변수를 연결했다

개발용 미리보기에서 가격 위치, 옵션 선택과 가격 갱신, 문구 변경과 저장 유지, 체크박스와 빈 문구 처리를 확인했다. 배너에서는 제목·문구 수정, 블록 순서 변경과 삭제, preset의 기본 블록 생성, 화면 너비에 따른 열 전환을 직접 확인했다. 이 결과는 학습하면서 수행한 수동 확인이며, 자동 브라우저 테스트를 실행한 결과는 아니다.

커밋 전에는 JavaScript 구문과 변경한 schema·JSON의 파싱을 확인했다. 학습 변경만 기존 커밋에 적용한 사본을 Theme Check로 비교했을 때 새 지적은 없었다. 다만 기존 상태에 오류 51개와 경고 14개가 남아 있었으므로, 테마 전체가 검사를 통과했다고 볼 수는 없었다.

모든 상품 유형이나 구매 흐름 전체를 검증한 것은 아니다. 실제 결제, 다양한 통화와 언어, 여러 브라우저에서의 차이까지 확인하지 않았고, 운영 테마에 배포한 결과로도 보지 않았다. 블록별 링크를 설정하는 다음 실습 역시 아직 구현하지 않았다. 이번 결과는 개발용 테마에서 학습한 범위로 한정했다.


느낀점

  • 출력할 값과 갱신할 영역을 따로 봐야 했다. 올바른 variant 가격을 읽어도 그 요소를 교체하지 않으면 화면에는 이전 값이 남았다. 데이터가 맞는지와 DOM이 바뀌는지를 나눠 확인했다.
  • 설정은 입력란을 만드는 데서 끝나지 않았다. 값을 전달하고 출력하며, 빈 값과 표시 여부까지 처리해야 운영자가 사용할 수 있는 기능이 됐다.
  • 작성한 코드와 생성된 결과를 구분해야 했다. 같은 20px라도 직접 적은 숫자와 설정값으로 만들어진 숫자는 다르다. 동적이라는 말보다 언제 어디에서 값이 결정되는지 설명하는 편이 정확했다.
  • 기본값은 기존 콘텐츠를 덮어쓰는 명령이 아니었다. preset을 바꾼 뒤 새 배너를 추가해봐야 의도한 초기 구성을 확인할 수 있었다.

문법을 틀린 부분도 많았지만, 그 자리에서 다시 작성하는 방식이 맞았다. 한 번에 긴 코드를 완성하려 했다면 어느 문법과 어느 실행 단계에서 막혔는지 구분하기 어려웠을 것이다. 작은 수정과 실제 화면 확인을 반복하니 다음에 무엇을 확인해야 할지가 조금씩 분명해졌다.

다음에는 안내 블록에 링크 설정을 붙여 실제 페이지 이동까지 연결할 예정이다. 오늘 만든 구조 위에 기능을 하나씩 더하면서, 설정을 저장하는 것과 사용자가 경험하는 동작을 계속 함께 확인하려고 한다. 여기까지 읽어주신 분들께 감사드리며 이번 글을 마친다.

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

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

로그인

연관된 글