Next.js 앱이 둘 돌고 있다. 공개 사이트(client)와 관리자(admin)다. 둘이 무엇을 나눠 갖고 무엇을 따로 가질 것인가.
안 A 컴포넌트를 공유한다. Button 하나를 두 앱이 같이 쓴다
안 B 각자 만든다. 같은 이름이라도 따로 둔다
처음 물음은 “컴포넌트를 공유할까” 였다. 그런데 재보고 나서 질문이 바뀌었다.
먼저: 재보기 전에는 질문이 틀려 있었다
이건 원칙이 아니라 이 글이 서 있는 자리다. 안을 세우기 전에 두 앱을 실제로 셌고, 그 숫자가 질문을 바꿨다.
컴포넌트 client 15개 · admin 14개 이름이 겹치는 것 0 개
토큰 6개 파일 121줄 바이트까지 100% 같다
겹치는 컴포넌트가 하나도 없었다. 공유를 고민할 대상이 애초에 없었던 것이다. 반대로 토큰은 이미 공유하고 있는데 복사본 두 벌로 하고 있었다.
| client | admin | |
|---|---|---|
| 컴포넌트 | Header · ReviewCard · MobileCtaBar · SocialRail |
AccountForm · CaregiverForm · ReviewForm · Sidebar |
| 하는 일 | 방문자에게 보여준다 | 운영자가 입력한다 |
같은 「후기」 도메인인데도 client는 ReviewCard(보여주기), admin은 ReviewForm(입력하기)다.
그래서 이 글의 질문은 「컴포넌트를 공유할까」가 아니라 「무엇이 공유 대상인가」가 됐다.
먼저: Docker 빌드 컨텍스트가 앱마다 갈려 있다
이 제약이 뒤의 안 두 개를 미리 지웠다.
build:
context: ./frontend/client
build:
context: ./frontend/admin
빌드 컨텍스트가 각 앱 폴더라, 그 밖의 파일은 Docker가 아예 못 본다. frontend/shared/ 같은 폴더를 만들어도 이미지 안으로 안 들어간다.
컨텍스트를 ./frontend로 올리면 볼 수 있다. 그 대가를 쟀다.
node_modules client 427MB · admin 427MB → 854MB 가 컨텍스트 후보가 된다
.dockerignore 각 앱에 하나씩 있다 → 컨텍스트 루트에 하나만 먹으므로 둘 다 무효가 된다
토큰 121줄을 합치려고 빌드 구조를 갈아엎는 셈이다. 이 제약을 먼저 재지 않았으면 공유 폴더 안을 정성껏 설계하고 마지막에 배포에서 터졌을 것이다.
원칙 1. 보이는 값은 공유하고, 만드는 것은 공유하지 않는다
무엇을 두 앱이 나눠 갖는가.
안 A — 컴포넌트를 공유한다. 공통 UI 패키지를 만들고 두 앱이 거기서 가져다 쓴다. 얻는 것은 버튼 모양을 한 번만 고치면 두 곳이 같이 바뀌는 것이다.
버린 이유는 공유할 것이 실제로 없다는 것이다. 15개와 14개가 이름조차 안 겹친다. 억지로 합칠 대상을 만들면 한쪽 요구에 다른 쪽이 끌려간다 — 방문자에게 보여주는 카드와 운영자가 입력하는 폼은 필요한 상태도 접근성 요구도 다르다.
골랐다 — 안 B, 토큰만 공유하고 컴포넌트는 각자 만든다.
토큰 (색 · 간격 · 모서리 · 글꼴 · 그림자) 공유한다 한 벌로 만든다
컴포넌트 공유하지 않는다 각자 만든다
안 A가 못 지켜주는 것은 두 앱이 서로의 사정에 안 끌려다니는 것이다. 토큰은 값이라 성격이 없다 — --brand-pink-40은 보여주는 화면에서도 입력하는 화면에서도 같은 분홍이어야 한다. 컴포넌트는 성격이 있고, 그 성격이 두 앱에서 다르다.
대가는 비슷한 것을 두 번 만드는 일이 생긴다는 것이다. 지금은 0개지만 앞으로 겹칠 수 있다. 그때도 합치지 않는다 — 겹쳐 보이는 둘이 실제로 같은 것인지는 세 번째가 나와야 알 수 있고, 두 번째에서 합치면 세 번째가 안 맞을 때 되돌리기 어렵다.
원칙 2. 끊는 값은 공유하지 않는다 — 그건 브랜드가 아니라 레이아웃이다
원칙 1이 「보이는 값은 공유한다」로 끝났는데, 그 안에서 한 겹이 더 갈린다.
지금 --breakpoint-mobile · tablet · desktop · wide 넷이 radius.css(모서리 둥글기 파일)에 얹혀 있다. 그리고 아무도 안 쓴다 — 미디어쿼리에서 참조한 곳이 0건이고, common.css 에는 토큰을 부르는 대신 주석에 숫자를 적어놨다.
/* arrows from the same desktop breakpoint the rest of the site uses (768px). */
@media (min-width: 768px) { ... }
안 A — 끊는 값도 다른 토큰처럼 공유한다. 한 곳에서 관리한다는 목적에 맞다. 버린 이유는 두 앱의 화면 구조가 달라서 같은 값이 한쪽을 망가뜨리는 것이다.
admin 의 화면 client 의 화면
┌──────┬────────┐ ┌───────────────┐
│사이드바│ 본문 │ │ 본문 │ 사이드바가 없다
│240px │ │ │ │
└──────┴────────┘ └───────────────┘
admin 은 900px 에서 사이드바를 가로 바로 눕힌다
client 은 그런 사이드바가 없어 900 이라는 지점 자체가 의미가 없다
admin 의 900을 client 에 맞춰 768로 내리면 768~900 구간에서 사이드바 240px 가 그대로 남는다. 폴드8 가로(816px)에서 본문이 816 - 240 = 576px 밖에 안 남아 표가 안 들어간다.
골랐다 — 안 B, 끊는 값은 앱마다 자기 파일에 둔다.
frontend/tokens/ colors · spacing · radius · fonts · typography · elevation 공유
<앱>/src/styles/breakpoints.css 그 앱의 끊는 값 각자
안 A가 못 지켜주는 것은 각 앱이 자기 화면 구조에 맞게 끊는 것이다. 색은 어디서든 같은 분홍이어야 하지만, 끊는 지점은 그 앱이 어떻게 생겼는지가 정한다.
| 성격 | 공유 | |
|---|---|---|
| 색 · 간격 · 모서리 · 글꼴 · 그림자 | 브랜드 — 어디서든 같아야 한다 | 한다 |
| breakpoint | 레이아웃 결정 — 화면 구조와 대상 기기가 정한다 | 안 한다 |
대상 기기가 다른 것도 같은 이유다. client 는 모든 사용자가 오지만 admin 은 일반 폰 · 폴드8 · PC 셋으로 한정된다. admin 의 900은 그 셋을 정확히 가른다 — 앞의 둘은 900 미만이라 가로 바, PC 만 900 이상이라 사이드바다.
대가는 두 앱이 다른 지점에서 갈린다는 것이다. 「모바일」이라는 말이 client 에서는 768 미만, admin 에서는 900 미만을 가리킨다. 그래서 각 앱의 breakpoints.css 에 그 값이 무엇을 가르는지 한 줄씩 적어둔다 — 값만 있으면 다음에 보는 사람이 왜 900인지 모른다.
원칙 3. 토큰은 원본 하나에서 각 앱으로 뽑는다
토큰을 어떻게 한 벌로 만드는가. Docker 제약이 안 둘을 이미 지웠으므로, 남은 것은 「어디에 두고 어떻게 나르는가」다.
안 A — 공유 폴더를 두고 두 앱이 그 경로를 읽는다. 가장 자연스럽다. 버린 이유는 로컬에서는 되고 Docker 에서만 깨진다는 것이다. next dev는 프로젝트 밖 CSS를 읽어주지만 이미지 빌드에는 그 폴더가 없다.
이건 이 프로젝트가 이미 한 번 피한 실패 모양이다. .gitattributes에 그 경위가 적혀 있다 — “윈도우에서는 통과하고 리눅스 러너에서만 죽는, 로컬은 초록인데 CI만 빨간 가장 알아보기 어려운 실패”.
안 B — npm 워크스페이스로 패키지를 만든다. 프론트엔드 생태계의 정석이다. 버린 이유는 안 A와 같은 Docker 문제에 설치 단계 복잡도가 더 얹히기 때문이다. 지금 두 앱은 각자 package.json 하나로 독립해 있고, 워크스페이스로 묶으면 그 독립이 사라진다.
골랐다 — 안 C, 원본 하나에서 각 앱으로 뽑는다.
frontend/tokens/*.css ← 손으로 고치는 유일한 곳
│ 생성 스크립트
├─→ frontend/client/nextjs/src/styles/tokens/*.css 생성물
└─→ frontend/admin/nextjs/src/styles/tokens/*.css 생성물
안 A와 안 B가 못 지켜주는 것은 Docker 설정을 하나도 안 건드리는 것이다. 각 앱 안에 파일이 실제로 존재하므로 빌드는 지금 그대로 돈다.
대가는 토큰 파일이 생성물이 되는 것이다. 앱 안에서 직접 고치면 다음 생성 때 날아간다. 그래서 파일 첫 줄에 그렇게 적어둔다.
원칙 4. 생성은 개발 명령에 딸려 돌고, 낡음은 검사가 잡는다
생성 스크립트를 언제 돌리는가. 손으로 돌리기로 하면 까먹는 자리가 생긴다.
골랐다 — npm 의 pre 훅에 얹는다.
"scripts": {
"predev": "node ../../tokens/build.js",
"dev": "next dev",
"prebuild": "node ../../tokens/build.js",
"build": "next build"
}
npm run dev를 치면 predev가 자동으로 먼저 돈다. 원본을 고치고 개발 서버를 켜는 것만으로 최신이 된다 — 따로 기억할 명령이 없다.
Docker 안에서는 안 돌린다. 원본이 컨텍스트 밖이라 못 돈다. 커밋된 생성물을 그대로 쓴다.
로컬 npm run dev/build → pre 훅이 뽑는다 → 늘 최신
Docker 커밋된 파일 그대로 → 커밋 시점의 것
CI 「최신인가」 검사 → 낡았으면 빌드를 멈춘다
셋을 잇는 것이 CI 검사다. 원본만 고치고 생성물을 안 담은 채 커밋하면 거기서 걸린다. 이 저장소가 「항상 적용」 세 벌과 .agents 스킬에서 쓰는 것과 같은 구조다 — 원본 하나 + 생성물 + 「최신인가」 검사.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 컴포넌트를 공유할까 | 안 한다 | 겹치는 것이 0개다. 성격이 달라 합치면 한쪽이 끌려간다 |
| 그럼 무엇을 공유하나 | 토큰 | 값이라 성격이 없다. 두 앱에서 같아야 한다 |
| 공유 폴더를 만들까 | 안 만든다 | Docker 컨텍스트가 앱마다 갈려 못 본다 |
| 워크스페이스로 묶을까 | 안 묶는다 | 같은 Docker 문제 + 설치 복잡도 |
| 끊는 값(breakpoint)도 공유하나 | 안 한다 | 브랜드가 아니라 레이아웃이다. 앱마다 화면 구조가 다르다 |
| 특정 기기용 값을 더할까 | 안 더한다 | 기기마다 더하면 값이 다시 흩어진다. 폴드8 은 admin 의 900 에 이미 덮인다 |
| 어떻게 한 벌로 두나 | 원본에서 뽑는다 | frontend/tokens/ 가 원본, 앱 안은 생성물 |
| 언제 뽑나 | predev · prebuild |
개발 명령에 딸려 돌아 까먹을 자리가 없다 |
| 낡으면 누가 아나 | CI 검사 | 로컬은 자동, Docker 는 커밋된 것, 그 둘을 검사가 잇는다 |
| 나중에 컴포넌트가 겹치면 | 그래도 안 합친다 | 세 번째가 나와야 같은 것인지 안다 |
이 표준을 정하기까지
질문이 틀린 채로 시작했다. 처음 물음은 “두 앱이 컴포넌트를 같이 쓸까”였고, 같이 쓴다면 서로 다른 프로젝트에 있는 소스를 어떻게 관리할지가 걱정이었다. 그런데 세어 보니 겹치는 컴포넌트가 0개였다. 15개와 14개가 이름조차 안 겹쳤다. 고민하던 문제가 존재하지 않았다.
대신 아무도 안 보던 곳에서 진짜 중복이 나왔다. styles/tokens/의 여섯 파일이 양쪽에 있었고 바이트까지 같았다. 지금은 우연히 같지만 한쪽에서 브랜드 색을 고치면 다른 쪽은 그대로 남고, 그걸 알려주는 장치가 없다. 이 저장소가 반복해서 당한 모양 그대로다 — 「항상 적용」 세 벌이 14/13/12줄로 갈라진 것, 스킬 넷이 이름이 어긋난 것, 그리고 이슈 상태가 두 곳에 산 것.
해법을 세우기 전에 Docker 를 먼저 열어봤다. 이 저장소는 안을 정성껏 세워놓고 답이 이미 프로젝트에 있어서 헛돈 적이 있어서다. 열어보니 빌드 컨텍스트가 앱 폴더로 갈려 있었고, 그 한 줄이 공유 폴더 안과 워크스페이스 안을 동시에 지웠다. 컨텍스트를 올리는 길도 있었지만 재보니 node_modules 854MB와 .dockerignore 두 개가 걸렸다. 먼저 안 쟀으면 공유 폴더를 설계하고 마지막에 배포에서 터졌을 것이다.
「로컬에서도 도는가」라는 물음이 안 하나를 더 확인시켰다. 생성 방식이 로컬에서 번거로우면 안 쓰이게 된다. 그런데 npm 의 pre 훅에 얹으면 개발 서버를 켜는 것만으로 최신이 된다. 오히려 공유 폴더 안이 로컬에서만 돌고 Docker 에서 깨지는 쪽이었다 — 「로컬은 초록인데 배포만 빨간」 실패이고, 이 프로젝트의 .gitattributes에 같은 실패를 피한 기록이 이미 적혀 있었다.
「토큰은 공유한다」가 한 겹 더 갈렸다. 끊는 값을 세어 보니 client 에 8종류가 흩어져 있었고(768 이 36회, 600 은 1회), 토큰이 정한 값과 반대였다 — 토큰의 tablet:600px 은 한 번 쓰이고 wide:1440px 은 한 번도 안 쓰였다. 미디어쿼리에서 토큰을 참조한 곳은 0건이고, 대신 주석에 숫자를 적어놨다. 그래서 토큰을 실제에 맞추려 했는데, admin 을 열어보니 미디어쿼리가 딱 하나(900px)였다.
그 하나가 판단을 뒤집었다. admin 의 900 은 세로 사이드바 240px 를 가로 바로 눕히는 지점이고, 대상 기기 셋(일반 폰 · 폴드8 · PC)을 정확히 가른다. client 에는 그런 사이드바가 아예 없어 900 이라는 지점이 의미가 없다. 억지로 통일하면 폴드8 가로(816px)에서 본문이 576px 만 남는다. 색은 어디서든 같은 분홍이어야 하지만 끊는 지점은 그 앱이 어떻게 생겼는지가 정한다 — 같은 「토큰」으로 묶어둔 것 안에 성격이 둘 있었다.
폴드8 을 넣어달라는 요청이 그 확인의 계기였다. 처음 받은 값 2448 × 1848 은 물리 해상도였고, DPR 이 3이라 CSS 폭은 816 × 616 이다. 그대로 미디어쿼리에 넣었으면 어느 기기에서도 안 걸렸다. 그리고 재보니 폴드8 은 admin 의 900 에 이미 덮여 있었다 — 세로 616 도 가로 816 도 900 미만이라 가로 바가 된다. 더할 값이 없었다. 기기 이름으로 값을 더하는 안은 그래서 버렸다. 폴드 하나만 해도 접힘·펼침 × 가로·세로로 폭이 넷이라, 기기마다 더하면 방금 모으려던 8종류로 되돌아간다.
마지막으로 이 표준이 들어갈 자리가 없다는 걸 알았다. 아키텍처가 도메인 드리븐과 3계층 둘뿐이고 둘 다 자바 백엔드 계층 구조다. 거기 붙이면 거짓말이 된다. 그래서 프론트엔드 아키텍처를 새로 열되 status: open 으로만 열었다. 지금 정한 것은 토큰 하나뿐이고, app · components · lib · styles 가 계층인지 그냥 폴더인지는 정한 적이 없다. 그려두면 정한 것으로 착각한다.
그 빈 자리가 실제로 어떻게 새고 있는지도 같이 봤다. Header 는 components/ 루트에 있고 Footer 는 components/sections/ 안에 있다 — 같은 레이아웃 조각인데 사는 곳이 다르다. 그리고 app/layout.js 가 페이지 CSS 11개를 전부 한꺼번에 불러서, about 페이지 하나를 봐도 home · care · fee 의 CSS 가 딸려온다. 둘 다 이번 표준의 범위 밖이라 장부에 열어뒀다.
정리
- 컴포넌트는 공유하지 않는다. 겹치는 것이 0개이고, 보여주기와 입력하기는 성격이 다르다.
- 토큰은 공유한다. 값이라 성격이 없고, 두 앱에서 같은 색이어야 한다.
- 공유 폴더도 워크스페이스도 아니다. Docker 빌드 컨텍스트가 앱마다 갈려 있어 밖의 파일을 못 본다.
- 끊는 값은 공유하지 않는다. 색은 브랜드라 같아야 하지만 breakpoint 는 그 앱 화면이 어떻게 생겼는지가 정한다.
- 특정 기기 이름으로 끊는 값을 더하지 않는다. 기기마다 더하면 값이 다시 흩어진다.
frontend/tokens/가 원본이고 앱 안의 토큰은 생성물이다. 앱 안에서 고치면 다음 생성 때 날아간다.- 생성은
predev·prebuild에 얹는다. 개발 명령에 딸려 돌아 까먹을 자리가 없다. - 낡음은 CI 검사가 잡는다. 로컬은 자동, Docker 는 커밋된 것, 그 둘을 검사가 잇는다.
- 나중에 컴포넌트가 겹쳐 보여도 두 번째에서 합치지 않는다. 세 번째가 나와야 같은 것인지 안다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code · Codex — .claude/skills/frontend-token-sharing-standard/SKILL.md · .agents/skills/frontend-token-sharing-standard/SKILL.md
두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.
---
name: frontend-token-sharing-standard
description: 프론트엔드 앱이 둘 이상일 때 무엇을 공유하고 무엇을 각자 만드는지 정한다. 디자인 토큰의 원본 위치와 생성 방식, 컴포넌트를 공유하지 않는 이유, 공유 폴더와 워크스페이스를 안 쓰는 이유, 생성 시점과 검사를 담는다. 앱을 하나 더 만들 때, 토큰을 고칠 때, 두 앱에 비슷한 컴포넌트가 생겼을 때 적용한다.
---
# 프론트엔드 토큰 공유 표준
앱이 둘 이상이면 **보이는 값은 공유하고, 만드는 것은 공유하지 않는다.**
## 무엇을 공유하나
| | 공유 | 왜 |
|---|---|---|
| 디자인 토큰 (색·간격·모서리·글꼴·그림자) | **한다** | 값이라 성격이 없다. 어느 앱에서든 같은 색이어야 한다 |
| 컴포넌트 | **안 한다** | 성격이 있고 그 성격이 앱마다 다르다 |
| 끊는 값 (breakpoint) | **안 한다** | 브랜드가 아니라 레이아웃이다. 그 앱 화면 구조와 대상 기기가 정한다 |
- **컴포넌트가 겹쳐 보여도 두 번째에서 합치지 않는다.** 세 번째가 나와야 같은 것인지 안다.
- 합치면 **한쪽 요구에 다른 쪽이 끌려간다** — 보여주는 화면과 입력하는 화면은 필요한 상태도 접근성 요구도 다르다.
## 끊는 값은 앱마다 따로 둔다
```
<앱>/src/styles/breakpoints.css 그 앱의 끊는 값
```
- **공유 토큰에 넣지 않는다.** 색은 어디서든 같아야 하지만 끊는 지점은 그 앱이 어떻게 생겼는지가 정한다.
- **각 값이 무엇을 가르는지 한 줄로 적는다.** 값만 있으면 다음에 보는 사람이 왜 그 숫자인지 모른다.
- **특정 기기 이름으로 값을 더하지 않는다.** 기기 하나가 접힘·펼침 × 가로·세로로 폭을 넷 만든다. 기기마다 더하면 값이 다시 흩어진다.
- **물리 해상도를 그대로 쓰지 않는다.** 미디어쿼리는 CSS 픽셀을 본다 — 물리 해상도를 devicePixelRatio 로 나눈 값이다. 물리 값을 넣으면 어느 기기에서도 안 걸린다.
- 미디어쿼리 조건에서는 CSS 변수를 못 쓴다. 값을 한 곳에 모으려면 그 파일에서만 숫자를 쓰고 나머지는 그 파일을 본다.
## 토큰 원본
```
frontend/tokens/*.css ← 손으로 고치는 유일한 곳
│ 생성 스크립트
├─→ frontend/<앱>/nextjs/src/styles/tokens/*.css 생성물
└─→ ...
```
- **앱 안의 토큰 파일을 직접 고치지 않는다.** 다음 생성 때 날아간다. 첫 줄에 생성물이라고 적어둔다.
- **공유 폴더를 만들어 앱이 그 경로를 읽게 하지 않는다.** Docker 빌드 컨텍스트가 앱 폴더라 그 밖의 파일은 이미지에 안 들어간다. `next dev` 는 읽어주므로 **로컬은 초록인데 배포만 빨간** 실패가 된다.
- **npm 워크스페이스로 묶지 않는다.** 같은 Docker 문제에 설치 복잡도가 더 얹힌다.
- 빌드 컨텍스트를 올리는 것도 하지 않는다. 다른 앱의 `node_modules` 까지 컨텍스트 후보가 되고, `.dockerignore` 는 컨텍스트 루트에 하나만 먹어 앱별 파일이 무효가 된다.
## 생성 시점
```json
"scripts": {
"predev": "node ../../tokens/build.js",
"dev": "next dev",
"prebuild": "node ../../tokens/build.js",
"build": "next build"
}
```
- **`pre` 훅에 얹는다.** 개발 명령에 딸려 돌아 따로 기억할 것이 없다.
- **Docker 안에서는 생성하지 않는다.** 원본이 컨텍스트 밖이라 못 돈다. 커밋된 생성물을 그대로 쓴다.
- **CI 가 「최신인가」를 검사한다.** 원본만 고치고 생성물을 안 담은 채 커밋하면 거기서 멈춘다.
## 앱을 하나 더 만들 때
1. `frontend/tokens/` 를 원본으로 그대로 쓴다. 새 토큰 파일을 만들지 않는다.
2. 생성 스크립트의 대상에 새 앱을 더한다.
3. `predev` · `prebuild` 를 새 앱의 `package.json` 에 넣는다.
4. **기존 앱의 컴포넌트를 가져오지 않는다.** 새로 만든다.
## 하지 않는 것
- **앱 안의 `styles/tokens/*.css` 를 직접 고치지 않는다.**
- **끊는 값을 공유 토큰에 넣지 않는다.** 특정 기기 이름으로 값을 더하지도 않는다.
- **물리 해상도를 미디어쿼리에 쓰지 않는다.**
- **토큰 값을 컴포넌트나 페이지 CSS 에 직접 적지 않는다.** `#9C4A5C` 가 아니라 `var(--brand-pink-40)` 로 쓴다.
- **앱끼리 컴포넌트를 가져다 쓰지 않는다.** 공통 UI 패키지를 만들지 않는다.
- **공유 폴더·워크스페이스로 토큰을 잇지 않는다.**
- **Docker 빌드 안에서 토큰을 생성하지 않는다.**
GitHub Copilot — .github/instructions/frontend-token-sharing-standard.instructions.md
---
description: 프론트엔드 앱이 둘 이상일 때 무엇을 공유하고 무엇을 각자 만드는가
applyTo: "**"
---
# 프론트엔드 토큰 공유
- 앱이 둘 이상이면 **보이는 값은 공유하고 만드는 것은 공유하지 않는다.** 디자인 토큰(색·간격·모서리·글꼴·그림자)은 한 벌로 두고, 컴포넌트는 앱마다 각자 만든다.
- **컴포넌트가 겹쳐 보여도 두 번째에서 합치지 않는다.** 세 번째가 나와야 같은 것인지 안다. 합치면 한쪽 요구에 다른 쪽이 끌려간다 — 보여주는 화면과 입력하는 화면은 필요한 상태도 접근성 요구도 다르다.
- **끊는 값(breakpoint)은 공유하지 않는다.** 색은 브랜드라 어디서든 같아야 하지만 끊는 지점은 **그 앱 화면 구조와 대상 기기가 정한다** — 사이드바가 있는 앱과 없는 앱은 갈리는 폭이 다르고, 억지로 통일하면 한쪽에서 본문 폭이 무너진다. 앱마다 `src/styles/breakpoints.css` 에 자기 값을 두고 **각 값이 무엇을 가르는지 한 줄씩 적는다.**
- **특정 기기 이름으로 끊는 값을 더하지 않는다.** 기기 하나가 접힘·펼침 × 가로·세로로 폭을 넷 만든다. 기기마다 더하면 값이 다시 흩어진다.
- **물리 해상도를 미디어쿼리에 쓰지 않는다.** 미디어쿼리는 CSS 픽셀을 본다 — 물리 해상도를 devicePixelRatio 로 나눈 값이다(예: 2448px / DPR 3 = 816px). 물리 값을 넣으면 어느 기기에서도 안 걸린다.
- **토큰 원본은 `frontend/tokens/` 하나이고, 앱 안의 `styles/tokens/*.css` 는 생성물이다.** 앱 안에서 직접 고치지 않는다 — 다음 생성 때 날아간다. 생성물 첫 줄에 그렇게 적어둔다.
- **공유 폴더를 만들어 앱이 그 경로를 읽게 하지 않는다.** Docker 빌드 컨텍스트가 앱 폴더라 그 밖의 파일은 이미지에 안 들어간다. `next dev` 는 읽어주므로 **로컬은 초록인데 배포만 빨간** 실패가 된다.
- **npm 워크스페이스로 묶지 않고, 빌드 컨텍스트를 올리지도 않는다.** 컨텍스트를 올리면 다른 앱의 `node_modules` 까지 후보가 되고 `.dockerignore` 는 컨텍스트 루트에 하나만 먹어 앱별 파일이 무효가 된다.
- **생성은 `predev` · `prebuild` 에 얹는다.** 개발 명령에 딸려 돌아 따로 기억할 것이 없다. **Docker 안에서는 생성하지 않고** 커밋된 생성물을 그대로 쓴다.
- **CI 가 「생성물이 최신인가」를 검사한다.** 원본만 고치고 생성물을 안 담은 채 커밋하면 거기서 멈춘다. 로컬은 자동, Docker 는 커밋된 것, 그 둘을 검사가 잇는다.
- **토큰 값을 컴포넌트나 페이지 CSS 에 직접 적지 않는다.** `#9C4A5C` 가 아니라 `var(--brand-pink-40)` 로 쓴다.
- 앱을 하나 더 만들 때는 `frontend/tokens/` 를 그대로 원본으로 쓰고, 생성 대상에 새 앱을 더하고, `pre` 훅을 새 `package.json` 에 넣는다. **기존 앱의 컴포넌트를 가져오지 않는다.**
자신만의 철학을 만들어가는 중입니다.
댓글남기기