tech.junha.kr

Tailwind v4 도입하다가 monorepo 빌드 시스템을 뜯어고친 썰

Currency 앱에 새로 나온 Tailwind v4를 적용해보고 싶었다. 기존 앱들은 v3를 쓰고 있었는데, "각각 따로 빌드하니까 상관없겠지"라고 생각했던 게 착각이었다. PostCSS 설정이 충돌하면서 빌드가 망가지기 시작했고, 이를 해결하려다 보니 결국 전체 monorepo 구조를 뜯어고치게 됐다.

일단 Tailwind v4 적용했던 것

Tailwind v4의 새로운 CSS 엔진이 궁금해서 Currency 앱에 적용한 코드다. 공식 문서를 보니 설정 방법이 꽤 달라져 있었다.

// apps/currency/vite.config.ts
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(), // v4는 Vite 플러그인으로 모든 걸 처리
  ]
})

기존 v3처럼 별도의 PostCSS 플러그인이 필요 없다는 게 신기했다. 일단 적용해보니 잘 동작했다. "이거 쉽네?"라고 생각했는데 터보 빌드를 돌려보니 문제가 생겼다.

첫 번째 문제: PostCSS 설정 충돌

터보가 전체 앱들을 빌드할 때 PostCSS 설정이 뒤섞이기 시작했다. Currency 앱의 CSS는 제대로 생성되는데, 다른 앱들의 CSS가 이상해졌다. 로그를 보니 Tailwind v3와 v4의 PostCSS 처리 방식이 서로 간섭하고 있었다. Currency 앱에 별도의 PostCSS 설정을 만들어서 간단히 해결할 수 있을 줄 알았다.

// apps/currency/postcss.config.js
export default {
  plugins: [] // Tailwind v4는 Vite에서 처리하니까 비워두자
}

하지만 여전히 문제가 있었다. 터보가 빌드할 때 각 앱의 node_modules가 서로 영향을 주고 있는 것 같았다. LLM에 물어보니 "phantom dependencies" 문제일 가능성이 있다고 했다.

두 번째 문제: 패키지 격리 부족

문제를 더 파보니 npm의 hoisting 때문이었다. 각 앱이 독립적으로 관리되어야 하는데, 의존성들이 섞이면서 PostCSS 플러그인들이 예상치 못한 방식으로 로드되고 있었다. 결국 패키지 격리가 더 엄격한 pnpm으로 갈아타기로 결정했다. 일단 설정부터 만들어봤다.

# pnpm-workspace.yaml
packages:
  - 'apps/*'
# .pnpmrc
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=false  # 이게 핵심이었다
store-dir=.pnpm-store

shamefully-hoist=false가 중요했다. 이 설정으로 각 앱의 의존성이 완전히 격리됐다.

세 번째 문제: Turborepo 설정의 미스터리

pnpm으로 바꾸고 나니 새로운 의문이 생겼다. 공식 문서에 나온 best pracice대로 turbo.json 파일을 작성하다 보니 이상했다.

{
  "tasks": {
    "build": {
      "inputs": [
        "src/**",
        "public/**",
        "*.json",
        "postcss.config.*",
        "vite.config.*"
      ]
    }
  }
}

분명히 내 프로젝트에는 apps/currency, apps/chat, apps/mbti 같은 폴더들이 있는데, 왜 구체적인 경로가 없을까? 처음에는 이렇게 써야 하는 줄 알았다:

"inputs": ["apps/currency/**", "apps/chat/**", "apps/mbti/**"]

터보가 어떻게 내 앱들을 찾는지 궁금해서 여러 실험을 해봤다.

실험 1: 터보가 뭘 보고 있나?

npx turbo ls

이 명령어로 터보가 인식하는 패키지들을 확인해봤다. 신기하게도 apps 폴더 안의 것들만 나타났다. 그럼 터보가 어떻게 이걸 알지?

LLM에 물어보니 "워크스페이스 설정을 자동으로 감지한다"고 했다. 그래서 더 실험해봤다.

실험 2: 워크스페이스 패턴 테스트

packages 폴더를 만들고 그 안에 테스트 패키지를 추가해봤다. npx turbo ls를 다시 실행해도 새 패키지는 목록에 없었다.

그런데 pnpm-workspace.yaml에 packages/*를 추가하고 다시 실행하니 새 패키지가 나타났다! 이제 이해됐다. 터보는 워크스페이스 설정을 읽고 그에 따라 패키지들을 찾는다.

즉, turbo.json의 postcss.config.* 패턴은 각 앱 디렉토리를 기준으로 상대적으로 적용된다는 뜻이었다. apps/currency/postcss.config.js, apps/chat/postcss.config.js 이런 식으로.

문제 해결의 전체적인 그림

모든 퍼즐이 맞춰지니 Currency 앱의 Tailwind v4 적용이 완벽하게 작동했다.

  1. pnpm의 엄격한 격리: shamefully-hoist=false로 각 앱의 PostCSS 관련 패키지들이 완전히 분리됨
  2. 독립적인 PostCSS 설정: Currency 앱은 빈 PostCSS 설정, 다른 앱들은 기존 v3 설정 유지
  3. 터보의 개별 빌드: 각 앱이 자신의 설정에 따라 독립적으로 빌드됨

최종 빌드 플로우는 이렇게 됐다:

{
  "scripts": {
    "build-apps": "turbo run build",
    "build": "pnpm run build-apps && next build",
    "clean": "rm -rf public/static && rm -rf apps/*/node_modules && rm -rf apps/*/.next",
    "dev": "pnpm run build-apps && next dev --turbopack"
  }
}

먼저 터보가 각 앱을 개별적으로 빌드하고, 그 결과물을 메인 Next.js 프로젝트가 사용하는 하이브리드 구조가 됐다.

예상치 못한 부가 효과들

Currency 앱의 Tailwind v4 적용을 위해 시작한 작업이었는데, 결과적으로 전체 monorepo가 훨씬 견고해졌다.

빌드 캐시 개선: 각 앱의 설정 파일 변경이 해당 앱에만 영향을 주게 됐다. Currency 앱의 Vite 설정을 바꿔도 다른 앱들은 캐시를 그대로 사용한다.

의존성 충돌 해결: phantom dependencies 문제가 완전히 사라졌다. 각 앱이 정말로 독립적인 환경에서 실행된다.

개발 경험 향상: 새로운 앱을 추가할 때 별도 설정이 필요 없어졌다. apps 폴더에 디렉토리 만들고 package.json 추가하면 터보가 자동으로 인식한다.

삽질하면서 배운 것들

이번 경험에서 가장 큰 교훈은 "자동화된 도구의 동작 원리를 이해하는 것"의 중요성이었다.

터보가 "워크스페이스를 자동으로 감지한다"는 문서만 봤을 때는 그냥 '오 편하네' 라고만 생각했다. 하지만 실제로 pnpm-workspace.yaml 파일을 읽고, 글로브 패턴을 확장하고, 각 디렉토리에서 package.json을 찾는 구체적인 과정이 있었다.

이런 과정을 이해하고 나니 Currency 앱의 특별한 PostCSS 설정이 어떻게 다른 앱들과 격리되는지도 명확해졌다. 각 앱의 빌드 환경이 진짜로 독립적이라는 확신이 생겼다.

마치며

Tailwind v4 하나 적용하려다가 monorepo 전체를 뜯어고치게 됐지만, 결과적으로는 훨씬 좋은 구조를 얻었다. Currency 앱은 v4의 새로운 기능들을 자유롭게 사용할 수 있게 됐고, 다른 앱들도 안정적으로 v3를 계속 쓸 수 있다.

무엇보다 이제는 새로운 기술을 실험할 때 다른 앱들에게 영향을 줄 걱정이 없어졌다. 각 앱이 완전히 독립적인 환경을 가지고 있으니까.

처음에는 단순한 CSS 충돌 문제였는데, 해결 과정에서 패키지 관리와 빌드 시스템까지 제대로 이해하게 됐다. 이런 식으로 하나의 문제를 깊이 파다 보면 관련된 시스템 전체를 배우게 되는 것 같다. 시간은 좀 걸렸지만 값진 경험이었다.

https://junha.kr/currency

currency