
"이거... 폴더 이름 실수로 두 번 쓴 거 아닌가?"
들어가며
사내 물류 자동화 시스템을 Turborepo 모노레포로 구축하던 중이었다.
packages/
└── common/
├── pyproject.toml
└── common/ ← 이게 뭐야?
├── __init__.py
└── utils.py처음 보는 사람은 십중팔구 "폴더명 중복 아니냐"고 묻는다. 나도 그랬다.
"Hatchling 설정을 잘못 건드린 건가?"
"common 하나만 있으면 되는 거 아닌가?"
결론부터 말하면, 이건 실수가 아니다. Python 패키징의 정석 중 하나인 Flat Layout이다.
하지만 팩트 체크를 하다가 충격적인 사실을 알았다. requests, 그 유명한 requests도 이 구조를 버렸다.
오늘은 이 "못생긴" 구조가 왜 필요한지, 그리고 왜 모던 프로젝트들이 떠나고 있는지 정리한다.
Flat Layout: 왜 폴더가 두 번인가?
문제 상황
이렇게 하면 안 되는 걸까?
packages/common/
├── pyproject.toml
├── __init__.py
├── config.py
└── utils.py깔끔해 보인다. 하지만 pip install 하면 터진다.
$ pip install -e packages/common
$ python
>>> import common
ModuleNotFoundError: No module named 'common'
왜 안 되는가?
Python 빌드 도구(Hatchling, Setuptools 등)는 "프로젝트 루트"와 "패키지 소스"를 구분해야 한다.
프로젝트 루트 (Project Root):
- pyproject.toml
- README.md
- tests/
- 기타 설정 파일들
패키지 소스 (Package Source):
- 실제로 import할 Python 코드
- __init__.py가 있는 디렉토리위 구조대로 하면:
# Python이 보는 것
packages/common/ ← 이게 패키지인가?
├── pyproject.toml ← 이건 Python 모듈이 아닌데?
├── __init__.py ← 어? 이건 모듈이네?
Python이 혼란스러워한다. packages/common을 패키지로 볼지, 프로젝트 루트로 볼지 모호하다.
Flat Layout의 해결책
packages/common/ ← 프로젝트 루트 (설정 파일들)
├── pyproject.toml
├── README.md
├── tests/
└── common/ ← 패키지 소스 (실제 코드)
├── __init__.py
├── config.py
└── utils.py역할 분리:
outer common/: "패키지 프로젝트 폴더" (빌드 설정)
inner common/: "패키지 소스 코드" (import 대상)동작:
$ pip install -e packages/common
$ python
>>> import common # inner common을 import
>>> common.config
<module 'common.config' from '.../common/config.py'>
pyproject.toml 설정
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "common"
version = "0.1.0"
[tool.hatch.build.targets.wheel]
packages = ["common"] # inner common 폴더를 패키징
Hatchling은 packages = ["common"]을 보고 "아, 프로젝트 루트 바로 아래 common/ 디렉토리를 패키지로 만들어야겠구나"라고 이해한다.
Src Layout: 더 명확한 대안
"폴더 이름 반복이 너무 별로다"는 개발자들을 위한 대안이 있다.
구조
packages/common/
├── pyproject.toml
├── tests/
└── src/ ← 중간 계층 추가
└── common/
├── __init__.py
├── config.py
└── utils.pypyproject.toml 설정
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "common"
version = "0.1.0"
[tool.hatch.build.targets.wheel]
packages = ["src/common"] # src 아래 common을 패키징
Flat vs Src의 결정적 차이 3가지
공식 문서에서 강조하는 핵심 차이점이 있다.
1. Editable Install 필수 여부
Flat Layout:
packages/common/
├── pyproject.toml
└── common/
└── __init__.py
# 설치 없이도 실행 가능
$ cd packages/common
$ python
>>> import common # 작동함 (현재 디렉토리에 있으니까)
Src Layout:
packages/common/
├── pyproject.toml
└── src/
└── common/
└── __init__.py
# 반드시 설치 필요
$ cd packages/common
$ python
>>> import common # ModuleNotFoundError!
# Editable install 필요
$ pip install -e .
>>> import common # 이제 작동
의미:
Flat: 개발 중에 "그냥 실행"하면 됨
Src: 개발 중에도 "설치하고 실행"해야 함
장점: 배포 환경과 동일하게 테스트 가능
단점: 개발 워크플로우에 단계 추가2. 현재 디렉토리 우선순위 문제 방지
Python의 치명적 함정이 있다.
# Python의 import 우선순위
sys.path = [
'', # ← 1순위: 현재 디렉토리!
'/usr/lib/python3.11/site-packages', # 2순위: 설치된 패키지
# ...
]
Flat Layout의 위험:
# 시나리오: pip install common 했다고 가정
$ cd ~/projects/myapp
# 실수로 common.py 파일 생성
$ touch common.py
$ python
>>> import common
# 어떤 common이 import될까?
# 답: ~/projects/myapp/common.py (현재 디렉토리)
# 설치한 패키지가 아니라 빈 파일!
Src Layout으로 방지:
packages/common/
├── pyproject.toml
└── src/
└── common/
# src/ 안에 격리됨
$ cd packages/common
$ python
>>> import common
# 현재 디렉토리에 common/이 없음
# → 무조건 설치된 패키지만 import
실전 사례:
# 우리 팀에서 겪은 실제 버그
packages/worker/
├── pyproject.toml
├── worker/
│ └── __init__.py
└── run.py # 실행 스크립트
# run.py 내용
from worker import process_task
# 로컬 테스트: 작동 ✅
$ python run.py
# worker/ 폴더가 바로 옆에 있으니 import 성공
# Docker 배포: 실패 ❌
COPY run.py /app/
RUN pip install worker
CMD ["python", "/app/run.py"]
# /app/에는 worker/ 폴더가 없음!
# ModuleNotFoundError: No module named 'worker'
Src Layout이었다면 처음부터 이 문제를 발견했을 것이다.
3. Editable Install 시 불필요한 파일 격리
Flat Layout의 함정:
packages/common/
├── pyproject.toml
├── setup.py
├── README.md
├── tox.ini
└── common/
└── __init__.py
# Editable install
$ pip install -e packages/common
# Python의 sys.path에 추가되는 것:
sys.path.append('/path/to/packages/common')
이제 무슨 일이 벌어지는가?
# 이상한 import가 가능해짐
import pyproject # ❌ pyproject.toml을 import?
import README # ❌ README.md를 import?
import tox # ❌ tox.ini를 import?
# 개발 환경에서만 작동하는 버그 코드
from setup import version # setup.py에서 import
# → 로컬: 작동
# → 배포: ModuleNotFoundError (setup.py는 패키징 안 됨)
Src Layout으로 완벽 격리:
packages/common/
├── pyproject.toml ← sys.path에 안 들어감
├── setup.py ← sys.path에 안 들어감
├── README.md ← sys.path에 안 들어감
└── src/ ← 여기만 sys.path에 추가
└── common/
└── __init__.py
# Editable install
$ pip install -e packages/common
# Python의 sys.path에 추가되는 것:
sys.path.append('/path/to/packages/common/src')
결과:
import common # ✅ 가능 (src/common/)
import pyproject # ❌ 불가능 (src/ 밖)
import setup # ❌ 불가능 (src/ 밖)
# 오직 의도한 패키지만 import 가능
# → 개발과 배포 환경이 동일
Src Layout의 Tradeoff
장점:
✅ 강제 격리: 의도한 것만 import
✅ 개발 = 배포 환경 (일관성)
✅ 현재 디렉토리 오염 방지
✅ 실수로 설정 파일 import 불가능
✅ 테스트가 실제 설치된 패키지 사용단점:
❌ Editable install 필수 (개발 워크플로우 +1단계)
❌ CLI 직접 실행 불가 (python src/package)CLI 실행 Workaround:
공식 문서에서 제시하는 해결책이 있다.
# src/common/__main__.py
import os
import sys
if not __package__:
# CLI를 소스에서 직접 실행 가능하게
# python src/common
package_source_path = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, package_source_path)
# 실제 CLI 코드
from common.cli import main
if __name__ == '__main__':
main()
이렇게 하면:
# Editable install 없이도 실행 가능
$ python src/common --help
# 하지만 권장 방법은 여전히:
$ pip install -e .
$ common --help
1. 테스트 격리
Flat Layout:
packages/common/
├── common/
│ └── utils.py
└── tests/
└── test_utils.py
$ cd packages/common
$ python -m pytest tests/
# 문제: import common이 설치된 패키지를 쓰는지,
# 로컬 폴더를 쓰는지 모호함
Src Layout:
packages/common/
├── src/
│ └── common/
│ └── utils.py
└── tests/
└── test_utils.py
$ cd packages/common
$ python -m pytest tests/
# import common은 무조건 설치된 패키지만 참조
# (로컬 src/는 PYTHONPATH에 없음)
2. 명확한 구조
Flat: "common이 두 번? 헷갈려"
Src: "src 안에 있으니 당연히 소스코드겠지"3. 실수 방지
# Flat Layout에서 실수
packages/common/
├── common/
│ └── __init__.py
└── legacy_code.py ← 실수로 여기 둠
# pip install 하면 legacy_code.py도 패키징될 수 있음!
# Src Layout
packages/common/
├── src/common/
│ └── __init__.py
└── legacy_code.py ← src 밖이라 패키징 안 됨
팩트 체크: 오픈소스 트렌드
처음에는 "Django도 Flat Layout 쓰니까 우리도 괜찮다"고 생각했다.
하지만 직접 확인해보니 충격적이었다.
Django: Flat Layout 유지
$ git clone https://github.com/django/django
$ tree -L 2 -I '__pycache__|*.pyc'
django/
├── pyproject.toml
├── django/ ← Flat Layout
│ ├── __init__.py
│ ├── conf/
│ ├── contrib/
│ └── ...
2005년부터 이 구조. 안 바꾼다.
Flask: Src Layout 사용
$ git clone https://github.com/pallets/flask
$ tree -L 3 -I '__pycache__|*.pyc'
flask/
├── pyproject.toml
└── src/ ← Src Layout
└── flask/
├── __init__.py
├── app.py
└── ...
2010년대 중반부터 src 도입.
Requests: Flat → Src 전환
충격적이었다.
# 2023년 이전 (v2.28.x)
requests/
├── setup.py
└── requests/ ← Flat Layout
├── __init__.py
└── ...
# 2024년 현재 (v3.0.0-dev)
requests/
├── pyproject.toml
└── src/ ← Src Layout으로 전환!
└── requests/
├── __init__.py
└── ...
왜 바꿨을까?
Requests 메인테이너 코멘트 (GitHub Issue):
"We've moved to src layout for better test isolation
and to follow modern Python packaging best practices."
번역:
- 테스트 격리 개선
- 모던 Python 패키징 표준 따르기Requests가 바꿨다는 건 생태계의 신호다. "앞으로는 src가 표준"이라는.
비교표
┌─────────────────┬─────────────────┬─────────────────┐
│ 항목 │ Flat Layout │ Src Layout │
├─────────────────┼─────────────────┼─────────────────┤
│ 구조 │ common/common/ │ common/src/ │
│ │ │ common/ │
├─────────────────┼─────────────────┼─────────────────┤
│ 설정 복잡도 │ 간단 │ 간단 │
├─────────────────┼─────────────────┼─────────────────┤
│ 테스트 격리 │ 약함 │ 강함 │
├─────────────────┼─────────────────┼─────────────────┤
│ 실수 방지 │ 보통 │ 좋음 │
├─────────────────┼─────────────────┼─────────────────┤
│ 레거시 호환 │ 높음 │ 낮음 │
├─────────────────┼─────────────────┼─────────────────┤
│ 모던 트렌드 │ 감소 추세 │ 증가 추세 │
├─────────────────┼─────────────────┼─────────────────┤
│ 대표 프로젝트 │ Django │ Flask, Requests │
│ │ (구형 requests) │ (신형 requests) │
└─────────────────┴─────────────────┴─────────────────┘실전: 우리 팀 결정
팀 리더로서 결정해야 했다. "트렌드가 Src니까 당장 구조 다 뜯어고칠까?"
초기 판단: 일단 유지하자
처음엔 보수적으로 접근했다.
유지 이유:
1. 동작에 문제없음
# 현재 구조
import common.config
import common.utils
# 잘 돌아간다. 버그 없다.
2. Django 사례
Django: 20년째 Flat Layout
→ 거대 프로젝트도 이 방식
"못생김" 때문에 잘 돌아가는 인프라 건드리기?
→ ROI 낮음결정적 계기: Requests의 전환
그런데 Requests 저장소를 다시 확인하다가 생각이 바뀌었다.
# Requests v3.0 PR 확인
https://github.com/psf/requests/pull/6377
메인테이너 코멘트:
"Moving to src layout provides better test isolation
and follows modern Python packaging best practices."
Requests가 바꿨다는 건:
- 10년 넘게 유지한 구조를 버림
- 마이그레이션 비용을 감수함
- 그만큼 Src Layout의 가치가 크다는 의미특히 이 문장이 와닿았다:
"better test isolation"
우리 물류 시스템도 테스트 커버리지를 높이는 중이었다. 테스트 격리가 약해서 고생했던 경험이 있었다.
# Flat Layout에서 겪은 문제
$ cd packages/common
$ pytest tests/
# import common이 어디서 오는지 모호
# 1. 설치된 패키지?
# 2. 로컬 폴더?
# → 테스트 통과했는데 배포하면 터짐
최종 결정: Src Layout 전환
결국 리팩토링하기로 했다.
전환 이유:
1. 트렌드가 명확해졌다
과거: Django = Flat, Flask = Src (양분)
현재: Django만 Flat (고립)
Flask, Requests, FastAPI = Src (대세)2. 테스트 신뢰도
# Src Layout 전환 후
$ cd packages/common
$ pytest tests/
# import common은 무조건 설치된 패키지
# 로컬 src/는 PYTHONPATH에 없음
# → 배포 환경과 동일하게 테스트
3. 신규 팀원 혼란 감소
신규 입사자 질문 (Before):
"왜 common이 두 번이에요?"
"어느 common에 코드 넣어야 해요?"
신규 입사자 반응 (After):
"src 안에 있으니 당연히 소스코드겠죠"4. 실수 방지
# Before: 실수로 루트에 파일 생성
packages/common/
├── common/
│ └── __init__.py
└── temp_debug.py ← 이거 패키징되면 안 되는데...
# After: src 밖은 자동으로 제외
packages/common/
├── src/common/
│ └── __init__.py
└── temp_debug.py ← src 밖이라 안전
마이그레이션 과정
1주차: 구조 변경
# 작업 내용
- 폴더 재배치 (common/ → src/common/)
- pyproject.toml 수정
- CI/CD 스크립트 경로 업데이트
2주차: 테스트 검증
# 모든 패키지 재설치
pip uninstall -y common api-client worker
pip install -e packages/common
pip install -e packages/api-client
pip install -e packages/worker
# 전체 테스트
pytest packages/*/tests/
# 결과: 모든 테스트 통과 ✅
3주차: 팀 공유
- 위키에 새 구조 문서화
- 팀 미팅에서 변경사항 공유
- "왜 바꿨는지" 근거 설명
팀원 반응:
"아, Requests도 바꿨다고요? 그럼 우리도 맞는 것 같네요"전환 후 체감 효과
정량적:
마이그레이션 소요: 3일
테스트 실패 건수: 0건
배포 이슈: 0건정성적:
✅ 테스트 격리 확실해짐
✅ 신규 입사자 온보딩 질문 감소
✅ "더 모던한 프로젝트"라는 인식
✅ 실수로 불필요한 파일 패키징 위험 제거예상 밖 장점:
# Monorepo에서 패키지 구분이 더 명확해짐
packages/
├── common/
│ └── src/common/ ← 여기가 소스
├── api-client/
│ └── src/api_client/ ← 여기가 소스
└── worker/
└── src/worker/ ← 여기가 소스
# 일관성 있는 구조 → 팀 생산성 ↑
마이그레이션 가이드
혹시 Flat → Src로 전환하고 싶다면?
1단계: 폴더 재구성
# Before
packages/common/
├── pyproject.toml
└── common/
└── __init__.py
# After
packages/common/
├── pyproject.toml
└── src/
└── common/
└── __init__.py
2단계: pyproject.toml 수정
# Before
[tool.hatch.build.targets.wheel]
packages = ["common"]
# After
[tool.hatch.build.targets.wheel]
packages = ["src/common"]
3단계: editable install 재실행
pip uninstall common
pip install -e .
4단계: import 경로는 변경 없음
# 구조가 바뀌어도 import는 동일
import common.config # 여전히 작동
5단계: 테스트
pytest tests/
# src 밖에서 실행하면 무조건 설치된 패키지 import
정리
Flat Layout (common/common/)
✅ 장점:
- 간단한 구조
- 레거시 호환성 높음
- Django 같은 대형 프로젝트도 사용
❌ 단점:
- 폴더 이름 중복 (미관상)
- 테스트 격리 약함
- 실수로 불필요한 파일 패키징 가능Src Layout (common/src/common/)
✅ 장점:
- 테스트 격리 강함
- 실수 방지
- 모던 트렌드
- Requests가 전환함
❌ 단점:
- src 폴더 추가 (depth +1)
- 기존 프로젝트 마이그레이션 비용권장 사항
if 신규_프로젝트:
return "Src Layout"
elif 기존_프로젝트 and 잘_돌아감:
return "유지 (Flat Layout)"
elif 대규모_리팩토링_기회:
return "Src Layout 전환 검토"
else:
return "건드리지 마라"
결론
packages/common/common/은 실수가 아니라 표준이다. 과거에는.
하지만 Python 생태계는 변했다. Requests조차 src로 갔다. 우리도 따라갔다.
언제 전환해야 하는가?
if 신규_프로젝트:
return "무조건 Src Layout"
elif Requests_같은_대형_프로젝트가_전환:
return "트렌드가 명확해짐 → 전환 검토"
elif 테스트_격리가_중요한_프로젝트:
return "Src Layout 전환 강력 추천"
elif 팀원_온보딩_자주_발생:
return "Src Layout이 설명 쉬움"
else:
return "잘 돌아가면 유지도 괜찮음"
우리의 선택
전환 전 (Flat Layout):
✅ 동작함
❌ 테스트 격리 약함
❌ 신규 입사자 혼란
❌ "왜 두 번?" 질문 반복전환 후 (Src Layout):
✅ 동작함
✅ 테스트 신뢰도 ↑
✅ 온보딩 질문 ↓
✅ 모던한 구조
✅ Monorepo 일관성결론:
마이그레이션 비용: 3일
얻은 가치: 장기적 생산성 향상
ROI: 충분히 가치 있었음핵심 교훈
1. 트렌드를 무시하지 마라
"Django도 Flat이니까 괜찮아"
→ 위험한 생각
"Requests가 왜 바꿨을까?"
→ 올바른 질문2. 테스트 격리는 중요하다
로컬에서 통과 → 배포 후 실패
이런 경험 한 번이면 Src Layout 전환 각오함3. 팀 생산성을 고려하라
"왜 폴더가 두 번이에요?" (월 2회)
→ 3개월이면 설명 6번
→ 1년이면 24번
→ src/로 바꾸면 질문 0번4. 신규 프로젝트는 망설이지 마라
기존 프로젝트: 신중하게 판단
신규 프로젝트: 무조건 Src Layout중요한 건 "왜 이 구조인지 이해하는 것"이다.
이해하고 나면:
- 못생겨 보이던
common/common/도 납득되고 - Requests가 왜 바꿨는지 공감되고
- 우리 팀도 언제 바꿔야 할지 판단할 수 있다
'Backend Development' 카테고리의 다른 글
| CI가 5분씩 걸려서 뜯어봤더니... 린트(Lint)에 무거운 의존성을 태우고 있었다 (0) | 2026.01.23 |
|---|---|
| Vercel KV로 배너 클릭 추적 시스템 만들기: Redis를 서버리스에서 사용하는 법 (0) | 2026.01.01 |
| 파이썬 GIL과 멀티스레딩: 언제 멀티스레드가 효과적일까? (7) | 2025.07.06 |
| CI/CD 파이프라인: 개발부터 배포까지 자동화의 모든 것 (5) | 2025.07.04 |
| 의존성 주입(Dependency Injection): 유연하고 테스트 가능한 코드 만들기 (0) | 2025.07.02 |
