학습 자료

폴더를 나눈다 — app 을 core 와 features 로


Article

어제 오후에 02_prepare.py 를 쳤습니다. 한 파일 안에서 DB 를 열고, 상세를 읽고, 글을 자르고, 접두어를 붙이고, 표를 만들어 넣었습니다. 240줄 남짓 됩니다.

그 파일을 다시 열어 보면 무엇이 어디 있는지 찾기가 쉽지 않습니다. 자르는 코드와 저장하는 코드가 붙어 있고, 그 사이에 화면에 찍는 코드가 끼어 있습니다. 오늘 오후에 개인정보를 가리는 규칙이 들어오고, 내일은 AI 를 부르는 코드가 들어옵니다. 이대로 두면 금요일에는 한 폴더에 파일 열댓 개가 쌓입니다.

오늘 오전은 정리하는 시간입니다. 새 코드는 한 줄도 안 칩니다. 파일을 옮기고, 옮기느라 어긋난 import 줄만 맞춥니다. 끝나고 나서 어제와 똑같이 돌면 성공입니다.


1. 이번 주가 끝나면 이런 모양이 됩니다

먼저 목적지를 보겠습니다. 오늘 한 번에 다 만들지는 않습니다. 필요할 때 하나씩 생기는데, 어디로 가는지 알고 움직이는 편이 낫습니다.

다 만들고 나면
  • cosmetic-admin/
    • app/웹 서비스가 쓰는 코드
      • core/뼈대 — 설정 · DB 연결 · 로그인. 모두가 쓴다
      • domain/규칙 — 개인정보 가리기 · 안전 필터. 아무것에도 안 기댄다
      • adapters/바깥 — LLM · 벡터 저장소. 갈아끼우는 자리
      • features/조립 — 위 셋을 엮어서 일을 시킨다
      • main.py웹 서버 입구
    • pipeline/데이터를 만드는 코드 (서비스와 따로 돈다)
      • 01_schema.pyCSV -> 표
      • 02_chunk.py상세 -> 조각
      • 03_embed.py글 -> 벡터
      • 04_verify.py제대로 됐나 점검
      • prep/실제 일을 하는 조각들 — chunking · embedding · storage · options
    • tests/규칙이 맞는지 기계가 본다
    • eval/얼마나 잘 찾나 채점한다
    • web/index.html화면 한 장
    • data/CSV 원본 — 여기만 손으로 만든 것이다

큰 줄기는 둘입니다. app/웹 서비스가 도는 동안 쓰는 코드이고, pipeline/데이터를 미리 만들어 두는 코드입니다. 파이프라인은 하루에 한 번 돌든 일주일에 한 번 돌든 상관없고, 서비스는 요청이 올 때마다 돕니다. 도는 시점이 다르면 파일도 갈라 둡니다.

domain/adapters/ 는 오늘 안 만듭니다. 넣을 것이 아직 없기 때문입니다. 빈 폴더를 미리 파 두면 일주일 내내 "여기 뭐가 있어야 하는데 없네" 하는 인상만 남습니다. 폴더는 넣을 것이 생기는 날 만듭니다.


2. 오늘 나오는 말 다섯 개

앞으로 계속 나올 용어입니다. 채용 공고나 코드 리뷰에서 그대로 만나게 되는 말이라 지금 한 번 정리하고 갑니다. 어려운 개념은 아닌데 말이 딱딱해서 겁을 먹기 쉽습니다.

① 관심사의 분리 (Separation of Concerns)

"한 파일은 한 가지 일만 한다"는 뜻입니다.

어제 02_prepare.py 는 자르기도 하고, 저장도 하고, 벡터도 만들었습니다. 관심사가 셋인데 파일은 하나입니다. 그래서 "조각이 이상한데?" 싶을 때 어디를 봐야 할지 모릅니다. 자르는 코드만 따로 있으면 거기만 보면 됩니다.

거창해 보이지만 실제로 하는 일은 파일을 나누고 이름을 잘 붙이는 것입니다.

② 결합도와 응집도 (Coupling / Cohesion)

  • 결합도는 "이 파일이 다른 것에 얼마나 매여 있나"입니다. 낮을수록 좋습니다.
  • 응집도는 "한 파일 안의 코드들이 얼마나 한 가지 일에 모여 있나"입니다. 높을수록 좋습니다.

db.py 를 예로 들면, 여기에는 sqlite3 를 다루는 코드만 있습니다(응집도 높음). 그리고 다른 파일들은 sqlite3 를 직접 안 부르고 db.py 만 부릅니다(결합도 낮음). 그래서 나중에 SQLite 를 다른 데이터베이스로 바꿀 때 고칠 파일이 하나입니다.

③ 순수 함수 (Pure Function)

같은 값을 넣으면 언제나 같은 값이 나오고, 바깥을 건드리지 않는 함수입니다.

DB 를 읽지도 않고, 파일을 쓰지도 않고, 인터넷에 나가지도 않습니다. 글자를 받아서 글자를 돌려주기만 합니다. 오늘 오후에 만들 개인정보 가리기 규칙이 그렇습니다.

순수하면 좋은 점이 분명합니다. 시험하기가 쉽습니다. DB 를 준비할 필요도, 인터넷이 연결돼 있을 필요도 없습니다. 오늘 9교시에 이 성질을 써서 시험을 만듭니다.

④ 포트와 어댑터 (Port and Adapter)

"무엇을 할 수 있어야 한다"만 적어 두고, 실제로 어떻게 하는지는 따로 두는 것입니다.

콘센트를 생각하면 쉽습니다. 벽에 있는 구멍(포트)은 모양만 정해져 있고, 거기 꽂는 것이 선풍기든 노트북이든 상관없습니다. 벽은 뭐가 꽂힐지 몰라도 됩니다.

우리는 5일차에 벡터 저장소를 이렇게 만듭니다. "가까운 것 k 개를 찾아 준다"는 약속만 적어 두고, 지금은 SQLite 판을 꽂고 나중에 Supabase 판으로 바꿔 끼웁니다. 앱 코드는 어느 쪽이 꽂혀 있는지 모릅니다.

⑤ 의존성 주입 (Dependency Injection)

필요한 것을 함수가 직접 만들지 않고, 밖에서 받는 것입니다.

말이 어렵지 실제로는 이 차이입니다.

예시 코드
def save(sections):
    con = sqlite3.connect("cosmetic.db")   # 함수가 직접 만든다
    ...
예시 코드
def save(con, sections):                   # 밖에서 받는다
    ...

아래쪽이 낫습니다. 시험할 때 메모리 DB 를 넣어 줄 수도 있고, 파일 이름이 바뀌어도 이 함수는 안 바뀝니다. 오늘 오후와 내일 이 모양을 계속 보게 됩니다.


3. 그래서 무슨 기준으로 나누나

파일이 한 폴더에 쌓이면 세 가지가 불편해집니다.

  • 어디를 봐야 할지 모릅니다. "값이 이상한데?" 싶을 때 열어 볼 파일이 정해지지 않습니다.
  • 고칠 곳이 흩어집니다. 데이터베이스를 바꾸려는데 sqlite3 가 여기저기 박혀 있으면 전부 뒤져야 합니다.
  • 시험하기 어렵습니다. 글자만 다루는 규칙을 확인하려는데 DB 부터 켜야 합니다.

파일 이름으로 나누면 안 됩니다. utils/helpers/ 같은 폴더는 결국 아무거나 들어가는 서랍이 됩니다. 우리가 쓸 기준은 하나입니다.

이 파일이 무엇 없이는 못 도나?

이 질문으로 네 칸이 나옵니다.

네 칸
폴더무엇에 기대나언제 만드나
core/아무것도. 다들 이걸 쓴다설정 · DB 연결오늘
domain/아무것도. 순수한 규칙개인정보 가리기 · 안전 필터오늘 오후
adapters/바깥 세상 — 인터넷 · 모델 · DBLLM · 벡터 저장소내일 · 5일차
features/위 셋 전부. 조립해서 일을 시킨다추천 · 검색 · 마스킹오늘

coredomain 이 헷갈릴 수 있습니다. 둘 다 아무것에도 안 기대는데, core모두가 쓰는 뼈대(설정, DB 연결)이고 domain우리 업무 규칙(전화번호를 어떻게 가릴 것인가)입니다. 다른 회사에 갖다 써도 되는 것이 core, 우리 것이 domain 이라고 생각하면 대체로 맞습니다.


4. 지금 모양과 오늘 모양

지금까지 만든 것만 놓고 나란히 봅니다. 오른쪽이 이 시간이 끝났을 때의 모양입니다.

cosmetic-admin/

지금 (2일차까지)

app 안에 셋이 평평하게

  • app/config.py
  • app/db.py
  • app/retrieve.py
  • pipeline/01_schema.py
  • pipeline/02_prepare.py
  • data/*.csv · cosmetic.db

오늘 1교시가 끝나면

app 이 두 칸으로 갈린다

  • app/core/config.py ← 옮김 · 한 줄 고침
  • app/core/db.py ← 옮김 · 한 줄 고침
  • app/core/__init__.py ← 새 빈 파일
  • app/features/retrieve.py ← 옮김 · 한 줄 고침
  • app/features/__init__.py ← 새 빈 파일
  • pipeline/ 은 오늘 안 건드린다 ― 바로 다음 교시에 가른다
  • data/ · cosmetic.db 는 그대로

옮기는 파일은 셋뿐이고, 파일마다 고치는 줄은 하나입니다.


5. 먼저 옮깁니다

  1. 01

    app/core/ 폴더를 만들고 두 개를 옮긴다

    config.py · db.py

  2. 02

    app/features/ 폴더를 만들고 하나를 옮긴다

    retrieve.py

  3. 03

    ③ 두 폴더에 빈 __init__.py 를 하나씩 넣는다

    파이썬이 그 폴더를 「패키지」로 보게 하는 표시입니다

옮기는 파일 셋
어디서어디로
app/config.pyapp/core/config.py
app/db.pyapp/core/db.py
app/retrieve.pyapp/features/retrieve.py

탐색기나 편집기에서 폴더를 만들고 끌어다 놓아도 됩니다. 터미널이 편하면 아래와 같습니다.

mkdir app\core app\features

move app\config.py    app\core\config.py
move app\db.py        app\core\db.py
move app\retrieve.py  app\features\retrieve.py

type nul > app\core\__init__.py
type nul > app\features\__init__.py

__init__.py 는 터미널에서 만드는 편이 안전합니다. 탐색기로 만들면 이름이 __init__.py.txt 가 되기 쉽습니다.

이제 옮긴 파일을 하나씩 엽니다. 파일마다 고칠 줄은 하나이고, 아래 코드를 통째로 복사해 덮어써도 됩니다.


6. app/core/config.py

app/core/config.py
"""설정 ― 프로그램 전체가 함께 쓰는 값을 여기 모은다.

지금은 경로와 2일차에 더한 모델 이름뿐이다. 수업이 진행되면서 여기가 늘어난다 ―
    3일차  벡터 차원 · 소수점 자리
    4일차  LLM 주소와 모델 이름
    6일차  로컬/상용 스위치

다른 파일은 전부 이렇게 쓴다 ─
    from app.core.config import DATA_DIR, DB_PATH
"""

from pathlib import Path

# 🔴 오늘 고친 줄 ― 이 파일이 app/core/ 로 한 칸 더 들어갔다.
#    .parent 는 한 번에 한 칸씩 올라간다. 그래서 뿌리까지 세 단계다
#        .parent         app/core
#        .parent.parent  app
#        세 번째          cosmetic-admin  ← 여기가 뿌리
ROOT = Path(__file__).resolve().parent.parent.parent

DATA_DIR = ROOT / "data"
DB_PATH = str(ROOT / "cosmetic.db")

# ─────────────────────────────────────────────────────────────
# 2일차 ― 글을 자를 때 쓰는 값
# ─────────────────────────────────────────────────────────────

EMBED_TOKENIZER = "intfloat/multilingual-e5-small"
EMBED_MAX_TOKENS = 512
EMBED_MODEL = "intfloat/multilingual-e5-small"

# 🔴 오늘 더한 줄 ― sqlite3 는 파일이 없으면 조용히 새로 만든다.
#    그래서 경로가 틀려도 오류가 안 난다. 죽이지는 않고 경로만 눈에 보이게 한다
if not Path(DB_PATH).exists():
    print(f"알림: DB 가 아직 없다 -> {DB_PATH}")

이 파일에서 기억할 것

  • 경로는 "지금 폴더" 가 아니라 __file__ 기준으로 잡습니다. 그래야 어느 위치에서 python 을 실행하든 같은 곳을 가리킵니다.
  • 파일을 옮기면 .parent 개수도 같이 바뀝니다. 옮기는 것과 고치는 것이 한 짝입니다.
  • 설정값은 여기 한 곳에만 적습니다. 모델 이름을 바꿀 일이 생겼을 때 고칠 자리가 하나여야 합니다.

7. app/core/db.py

app/core/db.py
"""데이터베이스에 닿는 자리를 여기 하나로 모은다.

다른 파일은 전부 이렇게 쓴다 ─
    from app.core.db import query, one, dicts
"""

import sqlite3

# 🔴 오늘 바뀐 줄 ― 어제까지는  from app.config import DB_PATH  였다
from app.core.config import DB_PATH

con = sqlite3.connect(DB_PATH)


def query(sql, params=()):
    """여러 줄을 꺼낸다. 튜플의 목록이 온다."""
    return con.execute(sql, params).fetchall()


def one(sql, params=()):
    """한 줄만 꺼낸다. 없으면 None 이 온다."""
    return con.execute(sql, params).fetchone()


def dicts(sql, params=()):
    """컬럼 이름이 붙은 딕셔너리 목록으로 꺼낸다."""
    cur = con.execute(sql, params)
    columns = [c[0] for c in cur.description]
    return [dict(zip(columns, row)) for row in cur.fetchall()]


if __name__ == "__main__":
    print("고객 수:", one("SELECT COUNT(*) FROM customers")[0])
    print("상품 수:", one("SELECT COUNT(*) FROM products")[0])
    for row in dicts("SELECT name, age FROM customers LIMIT 3"):
        print(f"  {row['name']} ({row['age']}세)")

이 파일에서 기억할 것

  • 함수는 1일차에 만든 셋 그대로입니다. 하나도 안 늘었습니다. 바뀐 것은 import 한 줄뿐입니다.
  • sqlite3 를 아는 파일은 이것 하나입니다. 다른 파일은 query() · one() · dicts() 만 부릅니다. 그래서 나중에 데이터베이스를 갈아끼울 때 고칠 파일이 하나로 좁혀집니다 — 앞에서 말한 결합도가 낮다는 것의 실제 모습입니다.
  • config.pycore/ 안으로 들어갔으니 부르는 이름도 따라 들어갑니다.

8. app/features/retrieve.py

app/features/retrieve.py
"""꺼내오는 자리 ― 데이터베이스에서 필요한 것을 조립해서 돌려준다.

DB 없이는 한 줄도 못 돈다. 여럿을 엮어야 일이 되는 파일이라 features/ 에 둔다.
"""

# 🔴 오늘 바뀐 줄 ― 어제까지는  from app.db import dicts, one  였다
from app.core.db import dicts, one


def customer_list(limit=None):
    """고객 목록 + 각자 구매 건수."""
    rows = dicts("""
        SELECT customers.customer_id, customers.name, customers.age, customers.gender,
               customers.skin_type, customers.city,
               COUNT(purchases.purchase_id) AS n_purchases
        FROM customers
        LEFT JOIN purchases ON purchases.customer_id = customers.customer_id
                           AND purchases.is_holdout = 0
        GROUP BY customers.customer_id
        ORDER BY customers.customer_id
    """)
    return rows[:limit] if limit else rows


def dashboard(customer_id):
    """한 고객에 대해 아는 것을 전부 모아서 돌려준다. 없는 고객이면 None."""
    profile = dicts("""
        SELECT customer_id, name, age, gender, skin_type, city
        FROM customers WHERE customer_id = ?
    """, (customer_id,))

    if not profile:
        return None

    purchases = dicts("""
        SELECT products.product_id, products.name, products.category, products.price,
               purchases.purchased_at, purchases.rating, purchases.review
        FROM purchases
        JOIN products ON purchases.product_id = products.product_id
        WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
        ORDER BY purchases.purchased_at DESC
    """, (customer_id,))

    by_category = {}
    for row in purchases:
        by_category[row["category"]] = by_category.get(row["category"], 0) + 1

    ratings = [row["rating"] for row in purchases if row["rating"] is not None]

    return {
        "customer": {**profile[0], "n_purchases": len(purchases)},
        "avg_rating": round(sum(ratings) / len(ratings), 2) if ratings else None,
        "total_spent": sum(row["price"] for row in purchases),
        "by_category": by_category,
        "purchases": purchases,
    }


if __name__ == "__main__":
    rows = customer_list()
    print(f"고객 {len(rows)}명")

    board = dashboard(rows[0]["customer_id"])
    customer = board["customer"]
    print(f"  {customer['name']} · {customer['skin_type']} · 구매 {customer['n_purchases']}건"
          f" · 누적 {board['total_spent']:,}원")

이 파일에서 기억할 것

  • 여기도 바뀐 것은 import 한 줄입니다. 함수는 1일차에 만든 customer_list() · dashboard() 그대로입니다.
  • 이 파일이 features/ 로 간 이유는 앞의 기준을 대 보면 나옵니다. 혼자서는 아무것도 못 하고 DB 를 엮어야 일이 됩니다. 그게 조립하는 자리입니다.
  • 검색이나 추천 같은 것도 앞으로 여기로 들어옵니다. 오늘은 자리만 잡습니다.

9. pipeline/ 의 두 파일 — 한 줄씩

pipeline/ 의 파일 둘도 app. 으로 시작하는 import 를 갖고 있습니다. 파일이 길어서 통째로 싣지 않고 고칠 줄만 적습니다. 편집기에서 Ctrl+Ffrom app. 을 찾으면 바로 나옵니다.

고침 · pipeline/01_schema.py
- from app.config import DATA_DIR, DB_PATH
+ from app.core.config import DATA_DIR, DB_PATH
고칠 줄
파일어제까지오늘부터
pipeline/01_schema.pyfrom app.config import DATA_DIR, DB_PATHfrom app.core.config import DATA_DIR, DB_PATH
pipeline/02_prepare.pyfrom app.config import DB_PATH, ...from app.core.config import DB_PATH, ...

app.configapp.core.config 로 바꾸는 것이 전부입니다. 02_prepare.py 는 이 시간 뒤쪽에서 파일 자체가 02_chunk.py 로 나뉘므로 거기서 새 경로로 다시 칩니다. 지금 고쳐야 하는 것은 위의 한 줄입니다.


10. 돌려서 확인합니다

새 기능을 하나도 안 넣었으니 어제와 똑같이 돌아야 정상입니다. 작은 것부터 봅니다.

python -m app.core.db
터미널
  • 고객 수: 300
  • 상품 수: 200
  • 박은수 (53세)
  • 권수빈 (37세)
  • 윤가은 (26세)
python -m app.features.retrieve
터미널
  • 고객 300명
  • 박은수 · 건성 · 구매 1건 · 누적 32,700원
python pipeline/02_prepare.py
터미널 — 어제와 같은 화면이 나오면 성공
  • 상품 상세 200건을 읽었다
  • ...
  • sections 1,560행
  • chunks 1,560행 · 접두어까지 넣은 토큰 최소 35 · 중앙 110 · 최대 338

셋 다 어제와 같은 숫자면 됐습니다.

no such table 이 뜨면 config.pyROOT 가 두 단계로 남아 있는 것입니다. app/cosmetic.db 라는 0 바이트 파일이 생겼는지 보고, 있으면 지운 뒤 다시 돌립니다.

dir app\*.db
del app\cosmetic.db

이 시간에 한 것

1교시 요점
주제핵심
언제 나누나파일이 늘어나기 직전. 다섯 개를 나누는 건 싸고 열댓 개를 나누는 건 비쌉니다
무슨 기준으로이름이 아니라 「무엇 없이는 못 도나」. 뼈대는 core/, 엮어서 일 시키는 것은 features/
빈 폴더안 만듭니다. 넣을 것이 생기는 날 만듭니다
오늘 친 새 코드없습니다. 파일 셋을 옮기고 import 를 한 줄씩 고쳤습니다
__file__ 기준 경로파일을 옮기면 .parent 개수가 바뀝니다. 옮기는 것과 고치는 것이 한 짝입니다
sqlite3 의 관대함없는 파일을 오류 없이 만듭니다. 편한 것과 안전한 것은 다릅니다
합격선「빨라졌다」가 아니라 「어제와 똑같다」

다음 시간에 할 것

app/ 은 나눴습니다. 그런데 pipeline/ 은 아직 그 기준 밖에 있습니다.

02_prepare.py 를 다시 열어 보십시오. 240줄쯤 되고, 그 안에서 DB 를 열고, 토큰을 세고, 글을 자르고, 접두어를 붙이고, 표를 만들어 넣는 일이 차례로 일어납니다.

오늘 배운 질문을 던져 보면 — "이 파일이 무엇 없이는 못 도나" 에 답이 셋입니다. 스플리터도 있어야 하고, 토크나이저도 올라와 있어야 하고, DB 도 열려 있어야 합니다. 셋 중 하나만 없어도 이 파일은 한 줄도 못 돕니다.

다음 시간에 오늘과 똑같은 기준으로 이 파일을 넷으로 가릅니다.

2교시에 이렇게 갈린다
파일하는 일
pipeline/prep/options.py조절값만 (chunk_size 같은 것)
pipeline/prep/chunking.py자르는 일. DB 를 모른다 — 그래서 DB 없이 시험할 수 있다
pipeline/prep/storage.py표에 넣는 일
pipeline/02_chunk.py위 셋을 부르는 순서만. 이름도 바뀐다
Share
  • 파이썬
  • 프로젝트 구조
  • 리팩터링
  • SQLite
폴더를 나눈다 — app 을 core 와 features 로 — 디코드랩(DCODELAB)