Python vs 쉘 스크립트 — 자동화 도구를 선택하는 기준
퍼블리싱 워크플로우를 자동화하다 보면 필연적으로 이 질문과 마주친다. "이 작업을 쉘 스크립트로 짜야 할까, Python으로 짜야 할까?" 둘 다 잘 동작하는 것처럼 보이지만, 작업이 복잡해질수록 선택의 무게가 달라진다.
Figma 퍼블리싱 파이프라인을 Node.js에서 Python으로 포팅하면서 직접 겪은 일을 정리한다. 이론보다는 실제로 어떤 상황에서 어떤 선택이 맞았는지를 중심으로 본다.
쉘 스크립트와 Python이 각각 뭔지부터 짚었다
프론트엔드 개발자라면 터미널을 매일 쓴다. npm run dev, git commit, pnpm install — 이 명령어들을 입력하는 그 검은 화면이 **쉘(Shell)**이다.
쉘 스크립트
터미널에서 하나씩 치는 명령어를 .sh 파일에 모아놓은 것이다. package.json의 scripts 필드와 비슷하다고 생각하면 된다.
// package.json — 이것도 일종의 쉘 명령어 모음
{
"scripts": {
"dev": "next dev",
"build": "next build",
"clean": "rm -rf .next && rm -rf node_modules"
}
}npm run clean을 실행하면 rm -rf .next && rm -rf node_modules가 터미널에서 실행된다. 이 "clean" 스크립트를 별도 파일로 분리하면 그게 쉘 스크립트다.
#!/bin/bash
# clean.sh — package.json scripts에서 분리한 것과 같다
rm -rf .next
rm -rf node_modules
echo "정리 완료"장점: 터미널 명령어를 그대로 쓰면 된다. 새로운 문법을 배울 필요가 거의 없다. 한계: 파일 삭제, 프로그램 실행, 텍스트 출력 같은 "운영체제 조작"에 특화되어 있고, 데이터 가공이나 복잡한 로직에는 약하다.
Python
범용 프로그래밍 언어다. JavaScript처럼 변수, 함수, 클래스, 반복문이 있고, JSON 파싱, HTTP 요청, 파일 읽기/쓰기 등을 코드로 표현할 수 있다.
프론트엔드 개발자에게 가장 가까운 비유: Node.js 스크립트다. 브라우저 밖에서 JavaScript를 실행하듯, Python도 터미널에서 .py 파일을 실행한다.
# fetch_data.py — Node.js 스크립트와 역할이 같다
import json
import urllib.request
response = urllib.request.urlopen('https://api.example.com/data')
data = json.loads(response.read())
for item in data['items']:
print(item['name'])// 같은 작업을 Node.js로 하면
const data = await fetch('https://api.example.com/data').then(r => r.json())
data.items.forEach(item => console.log(item.name))장점: 데이터 처리, API 호출, 복잡한 로직을 깔끔하게 표현할 수 있다. 한계: 쉘 명령어를 실행하려면 별도 코드가 필요하다. 단순 파일 조작에는 오버킬.
핵심 차이를 한 줄로
쉘 스크립트는 "터미널 명령어의 자동화", Python은 **"로직의 자동화"**다.
npm run build && npm run deploy처럼 명령어를 순서대로 실행하는 건 쉘이 자연스럽고, API 응답을 파싱해서 필요한 데이터만 뽑아 파일로 저장하는 건 Python이 자연스럽다.
쉘 스크립트가 빛나는 순간
쉘 스크립트는 특히 아래 상황에서 탁월하다.
명령어 조합이 전부인 작업. 파일을 삭제하고, 경로를 확인하고, 다른 스크립트를 호출하는 수준이면 쉘이 더 직관적이다. package.json의 scripts로 충분한 수준의 작업이라면, 굳이 Python 파일을 만들 필요가 없다.
# 빌드 산출물 정리 + 캐시 삭제 + 재빌드
rm -rf .next && rm -rf /tmp/figma-*.md && echo "정리 완료"Python으로 같은 작업을 하면:
import os, glob, shutil
shutil.rmtree('.next', ignore_errors=True)
for f in glob.glob('/tmp/figma-*.md'):
os.remove(f)
print("정리 완료")3줄 → 4줄이지만, 체감 복잡도는 쉘이 훨씬 낮다.
환경 독립성이 중요한 경우. 쉘(/bin/sh)은 macOS, Linux 어디에나 설치되어 있다. Python은 버전 문제(python vs python3), 가상환경, 패키지 설치 같은 걸 신경 써야 할 때가 있다. CI/CD 파이프라인이나 Docker 환경에서는 이 차이가 의미 있다.
파이프(|)로 명령어를 연결하는 경우. 쉘의 진짜 강점이다. 하나의 명령어 출력을 다음 명령어의 입력으로 넘기는 파이프 구조는 쉘이 압도적으로 우아하다.
# git 커밋에서 "feat"이 포함된 것만 세기
git log --oneline | grep "feat" | wc -l이건 "git 로그 출력 → feat 필터링 → 줄 수 세기"를 한 줄로 표현한 것이다. JavaScript의 메서드 체이닝(.filter().map().length)과 비슷한 개념인데, 쉘에서는 서로 다른 프로그램끼리도 이렇게 연결된다.
쉘 스크립트가 무너지는 순간
문제는 작업이 조금씩 복잡해질 때 찾아온다. 세 가지 상황을 경험했다.
1. 문자열 처리가 복잡해질 때
Figma URL에서 node-id 파라미터를 추출하는 작업이 있었다.
JavaScript라면 이렇게 쓴다:
const url = new URL(figmaUrl)
const nodeId = url.searchParams.get('node-id')?.replace('-', ':')쉘에서는 URL 객체 같은 게 없다. 문자열을 직접 잘라야 한다:
# 처음엔 이랬는데
node_id=$(echo "$url" | sed -E 's/.*node-id=([^&]+).*/\1/')
# URL 인코딩 처리, 하이픈→콜론 변환, 검증까지 추가하니 이렇게 됐다
node_id=$(echo "$url" \
| sed -E 's/.*[?&]node-id=([^&]+).*/\1/' \
| sed 's/%3A/:/g' \
| sed 's/-/:/g')
sed는 문자열 치환 도구다. JavaScript의.replace()와 비슷한데, 정규식 문법이 미묘하게 다르고 체이닝하면 가독성이 급격히 떨어진다.
3개월 후에 이 코드를 열었을 때, "왜 sed가 세 번 파이프되어 있지?" 싶을 게 뻔했다.
2. JSON을 다뤄야 할 때
Figma API는 깊게 중첩된 JSON을 반환한다. JavaScript에서는 response.data.children[0].fills[0].color처럼 자연스럽게 접근하는데, 쉘에서 JSON을 파싱하려면 jq라는 별도 도구가 필요하다.
# jq — 쉘에서 JSON을 다루는 도구. 일종의 "쉘용 JSON 파서"
cat response.json | jq '.document.children[0].fills[0].color.r'간단한 건 되지만, 중첩 객체를 순회하면서 조건부로 값을 추출하는 건 jq의 자체 문법을 새로 배워야 한다. JavaScript도 아니고, Python도 아닌, 또 다른 문법이다.
3. 병렬 처리가 필요할 때
Figma 이미지를 1x 해상도와 2x 해상도로 동시에 요청하는 상황이 있었다. JavaScript에서는 Promise.all로 자연스럽게 표현한다:
const [urls1x, urls2x] = await Promise.all([
exportImages(fileKey, nodeIds, 1),
exportImages(fileKey, nodeIds, 2),
])쉘에서 비슷한 걸 하려면 백그라운드 프로세스(&)와 wait 명령어, PID(프로세스 ID) 관리가 필요하고, 에러가 나면 어디서 실패했는지 추적하기가 까다롭다.
Python이 우위를 가져가는 기준
실제로 이번 Figma 파이프라인을 작성하면서 Python을 선택한 이유는 다음 세 가지였다.
1. 구조화된 데이터 처리
Figma API 응답은 수백 줄의 중첩 JSON이다. 노드 트리를 순회하고, 토큰 역색인을 빌드하고, 재귀적으로 자식 노드를 탐색해야 한다.
Python의 딕셔너리 조작은 JavaScript의 객체 조작과 거의 같다:
# Python
def build_token_index(token_data, prefix=''):
"""디자인 토큰 JSON을 평탄화해서 { "color.primary.500": "#3B82F6" } 형태로 만든다"""
index = {}
for key, val in token_data.items(): # Object.entries()와 같다
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(val, dict): # typeof val === 'object'와 같다
if '$value' in val: # '$value' in val과 같다
index[full_key] = val['$value']
else:
index.update(build_token_index(val, full_key)) # 재귀 호출
return index// 같은 로직을 JavaScript로 쓰면 이렇다
function buildTokenIndex(tokenData, prefix = '') {
let index = {}
for (const [key, val] of Object.entries(tokenData)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof val === 'object' && val !== null) {
if ('$value' in val) {
index[fullKey] = val.$value
} else {
Object.assign(index, buildTokenIndex(val, fullKey))
}
}
}
return index
}거의 1:1 대응이 된다. 이 수준의 데이터 처리를 쉘 스크립트로 구현하는 건 현실적이지 않다.
2. 병렬 처리가 코드에서 읽힌다
Python의 ThreadPoolExecutor는 JavaScript의 Promise.all과 비슷한 패턴이다 (여러 작업을 제출하고 전부 끝날 때까지 대기). 내부 동작은 다르지만 — ThreadPoolExecutor는 OS 스레드 기반, Promise.all은 이벤트 루프 기반 — HTTP 요청 같은 I/O 작업에서는 체감 결과가 비슷하다:
# Python — Promise.all과 같은 패턴
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as executor:
future_1x = executor.submit(export_images, file_key, node_ids, 1)
future_2x = executor.submit(export_images, file_key, node_ids, 2)
urls_1x = future_1x.result() # await와 같다
urls_2x = future_2x.result()// JavaScript — 같은 패턴
const [urls1x, urls2x] = await Promise.all([
exportImages(fileKey, nodeIds, 1),
exportImages(fileKey, nodeIds, 2),
])두 요청을 동시에 보내고, 둘 다 끝나면 다음으로 넘어간다. 의도가 코드에서 그대로 읽힌다.
3. 외부 패키지 설치 없이 충분하다
이게 결정적이었다. 사용한 모듈 전부가 Python에 기본 내장되어 있었다:
| Python 표준 라이브러리 | 하는 일 | JavaScript 대응 |
|---|---|---|
urllib.request | HTTP 요청 | fetch |
json | JSON 파싱 | JSON.parse |
re | 정규식 | RegExp |
argparse | CLI 인자 파싱 | process.argv 또는 yargs |
concurrent.futures | 병렬 처리 | Promise.all (패턴 유사, 내부 모델은 다름) |
pip install 없이 어느 환경에서든 동일하게 동작한다. Node.js 프로젝트에서 node-fetch나 axios 없이 기본 fetch만 쓰는 것과 같은 맥락이다.
Python이 무조건 낫다는 얘기는 아니다
쉘이 여전히 낫거나 같은 경우:
package.json의scripts로 충분한 10줄 이내의 단순 작업- 환경에 Python이 보장되지 않는 경우 (최소 Docker 이미지 등)
- 다른 팀원이 쉘에 더 익숙한 경우
- 파이프라인 구성(
|,>,&&)이 핵심인 경우
Python이 확실히 나은 경우:
- JSON/XML 등 구조화된 데이터를 파싱하고 변환하는 경우
- 병렬 처리가 필요한 경우 (
Promise.all같은 패턴) try/catch로 에러를 세밀하게 분기해야 하는 경우- 6개월 후에도 이해할 수 있는 코드가 필요한 경우
- 테스트 코드를 작성할 가능성이 있는 경우
실제로 쓴 판단 기준
이번 프로젝트에서 내가 적용한 기준은 단순했다.
"이 스크립트를 3개월 후에 고쳐야 한다면?"
Figma API 응답 구조가 바뀌거나, 토큰 매핑 로직에 예외 케이스가 생기거나, 새 앱이 추가될 때 — 그때 이 코드를 처음 보는 사람이 이해하고 수정할 수 있어야 한다. 그 기준에서 Python이 명확하게 앞섰다.
반대로 스킬 파일 안에서 /tmp 파일을 정리하거나 인자를 파싱하는 5줄짜리 작업은 여전히 bash로 한다. 도구를 바꾸는 게 항상 개선은 아니다.
프론트엔드에서도 마찬가지다. 단순한 유틸 함수를 위해 lodash를 설치하진 않는다. 반대로, 날짜 처리가 복잡해지면 직접 구현하지 않고 date-fns를 쓴다. 작업의 복잡도에 맞는 도구를 고르는 것이 핵심이다.
두 도구를 한 줄로 비교하면
| 쉘 스크립트 | Python | 프론트엔드 비유 | |
|---|---|---|---|
| 환경 의존성 | 없음 (sh 어디나 있음) | python3 필요 | CDN 스크립트 vs npm 패키지 |
| 구조화 데이터 | jq 의존 또는 복잡 | 자연스러움 | — |
| 병렬 처리 | 가능하지만 복잡 | ThreadPoolExecutor | & + wait vs Promise.all |
| 가독성 (단순 작업) | 더 나음 | 오버킬 | inline script vs 별도 파일 |
| 가독성 (복잡 로직) | 빠르게 악화됨 | 유지됨 | — |
| 테스트 가능성 | 어려움 | 쉬움 | — |
| 외부 의존성 없이 HTTP | curl 필요 | urllib (내장) | axios vs 내장 fetch |
명령어 조합과 파이프라인은 쉘, 데이터 처리/병렬 처리/복잡한 로직은 Python. 경계가 모호하면 "3개월 후에 이 코드를 고칠 수 있는가"로 판단하면 대부분 맞다.