Python vs 쉘 스크립트 — 자동화 도구를 선택하는 기준
퍼블리싱 워크플로우를 자동화하다 보면 필연적으로 이 질문과 마주친다. "이 작업을 쉘 스크립트로 짜야 할까, Python으로 짜야 할까?" 둘 다 잘 동작하는 것처럼 보이지만, 작업이 복잡해질수록 선택의 무게가 달라진다.
이 글은 Figma 퍼블리싱 파이프라인을 Node.js에서 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개월 후에 이 코드를 고칠 수 있는가"로 판단하면 대부분 맞다.