Python 모듈과 패키지: import, pip, 가상환경

이 글의 핵심

파일 하나로 시작한 스크립트가 커지면 모듈로 나누고 패키지로 묶어야 하는데, 이때 ModuleNotFoundError와 순환 import가 가장 먼저 발목을 잡습니다. from x import *를 피해야 하는 이유, 전역 pip 설치가 프로젝트끼리 버전 충돌을 일으키는 과정, 가상환경으로 이를 막는 흐름을 차례로 확인합니다.

들어가며

모듈과 패키지는 코드를 구조화하고 재사용하기 위한 핵심 개념입니다.

처음에는 main.py 하나에 모든 코드를 넣어도 문제가 없습니다. 파일이 수백 줄을 넘어가면 함수를 찾기 어려워지고, 다른 스크립트에서 같은 함수를 쓰려고 복사해 붙여넣기 시작합니다. 이때 파일을 나누는 순간부터 “왜 import가 안 되지?”라는 문제가 시작됩니다. 이 글은 import가 실제로 어떤 순서로 모듈을 찾고 실행하는지를 먼저 설명하고, 그 위에서 패키지, pip, 가상환경을 차례로 다룹니다. 문법 자체보다 import가 동작하는 방식을 이해하면 뒤에 나오는 오류 대부분은 원인을 바로 짐작할 수 있습니다.


모듈 만들고 쓰기, __name__, 검색 경로

모듈의 정의

모듈(Module)은 Python 코드를 담은 .py 파일입니다. 함수, 클래스, 변수를 모듈로 만들어 다른 파일에서 재사용할 수 있습니다. 왜 모듈을 사용할까?

  • 코드 재사용: 같은 코드를 여러 곳에서 사용
  • 네임스페이스 관리: 이름 충돌 방지
  • 유지보수 용이: 기능별로 파일 분리

모듈 만들기

예제: 수학 유틸리티 모듈 math_utils.py처럼 파일 하나를 모듈로 두면, 자주 쓰는 도구를 정리해 둔 공구함과 같습니다. 다른 스크립트에서는 import math_utils로 공구함만 가져와 함수 이름으로 꺼내 쓰면 됩니다.

# math_utils.py (모듈 파일)
"""
수학 관련 유틸리티 함수 모음
"""
def add(a, b):
    """두 수를 더합니다."""
    return a + b
def multiply(a, b):
    """두 수를 곱합니다."""
    return a * b
def power(base, exponent):
    """base의 exponent 제곱을 계산합니다."""
    return base ** exponent
# 모듈 레벨 상수
PI = 3.14159
E = 2.71828
# 모듈이 직접 실행될 때만 실행되는 코드
if __name__ == '__main__':
    print("math_utils 모듈 테스트")
    print(f"2 + 3 = {add(2, 3)}")
    print(f"2 * 3 = {multiply(2, 3)}")

모듈 사용하기

# main.py (모듈을 사용하는 파일)
import math_utils
# 모듈의 함수 호출
result1 = math_utils.add(3, 5)
print(result1)  # 8
result2 = math_utils.multiply(4, 7)
print(result2)  # 28
# 모듈의 상수 사용
print(math_utils.PI)  # 3.14159
# 원의 넓이 계산
radius = 5
area = math_utils.PI * math_utils.power(radius, 2)
print(f"반지름 {radius}인 원의 넓이: {area}")  # 78.53975

import math_utils를 실행하면 Python은 math_utils.py를 처음부터 끝까지 한 번 실행하고, 그 결과로 만들어진 이름들(add, PI 등)을 모듈 객체의 속성으로 묶어 돌려줍니다. 실행된 모듈은 sys.modules에 캐시되므로, 같은 프로그램 안에서 다른 파일이 또 import math_utils를 해도 코드는 다시 실행되지 않고 캐시된 객체를 그대로 받습니다. 모듈 최상단에 print()나 무거운 초기화 코드를 두면 import하는 순간 실행된다는 점, 그리고 두 번째 import부터는 실행되지 않는다는 점이 이 동작에서 나옵니다.

__name__ 변수의 동작

# math_utils.py
print(f"__name__ = {__name__}")
if __name__ == '__main__':
    print("이 모듈이 직접 실행되었습니다")
else:
    print("이 모듈이 import 되었습니다")
# 실행 결과:
# 1. python math_utils.py 실행 시:
#    __name__ = __main__
#    이 모듈이 직접 실행되었습니다
# 2. import math_utils 실행 시:
#    __name__ = math_utils
#    이 모듈이 import 되었습니다

if __name__ == '__main__': 가드가 필요한 이유는 앞에서 본 “import하면 파일 전체가 실행된다”는 동작 때문입니다. 가드 없이 테스트 코드나 서버 실행 코드를 최상단에 두면, 다른 파일이 함수 하나를 쓰려고 import하는 순간 그 코드까지 실행됩니다. 이 문제가 가장 크게 드러나는 곳이 multiprocessing입니다. Windows와 macOS(Python 3.8부터 기본값)는 자식 프로세스를 spawn 방식으로 만들면서 메인 모듈을 다시 import하는데, 가드가 없으면 자식이 또 프로세스를 만들려고 시도하고 RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. 오류가 납니다. Linux에서 잘 돌던 코드가 Windows에서만 이 오류를 내는 경우가 흔하므로, 실행 코드는 처음부터 가드 안에 두는 습관을 들이는 편이 좋습니다.

모듈 검색 경로

Python은 모듈을 다음 순서로 검색합니다:

import sys
# 모듈 검색 경로 확인
for path in sys.path:
    print(path)
# 출력 예시:
# 1. 현재 디렉토리 (스크립트가 있는 위치)
# 2. PYTHONPATH 환경 변수
# 3. 표준 라이브러리 경로
# 4. site-packages (pip로 설치한 패키지)
# 경로 추가 (런타임)
sys.path.append('/custom/path')

여기서 가장 중요한 것은 첫 번째 항목입니다. python app.py로 실행하면 sys.path[0]은 현재 작업 디렉토리가 아니라 app.py가 있는 디렉토리입니다. 그래서 같은 코드도 어느 파일을 진입점으로 실행하느냐에 따라 import 결과가 달라집니다. 또 이 디렉토리가 표준 라이브러리보다 앞에 있기 때문에, 내 파일 이름이 표준 모듈과 같으면 표준 모듈이 가려집니다. 연습용으로 random.py를 만들고 그 안에서 import random을 하면 자기 자신을 import하게 되고, AttributeError: partially initialized module 'random' has no attribute 'randint' (most likely due to a circular import) 같은 오류가 납니다. 제가 초보자 코드를 봐줄 때 가장 자주 만나는 원인이 이것이라서, 이 메시지를 보면 먼저 프로젝트 안에 random.py, json.py, email.py, test.py 같은 파일이 있는지 확인합니다.

sys.path.append()로 경로를 직접 추가하는 방법은 당장은 동작하지만 권하지 않습니다. 실행 위치에 따라 경로가 달라지고, 편집기의 자동 완성이나 타입 검사기는 이 경로를 모르기 때문에 “모듈을 찾을 수 없음” 경고가 계속 남습니다. 프로젝트 루트에서 python -m 패키지.모듈로 실행하거나, 뒤에서 다룰 pip install -e .로 프로젝트를 가상환경에 설치하는 쪽이 오래 가는 해결책입니다.


import 문법 네 가지와 상대·절대 import

import의 4가지 방법

Python에서 모듈을 가져오는 방법은 크게 4가지입니다. 각각의 장단점을 이해하고 상황에 맞게 사용해야 합니다.

방법 1: 모듈 전체 import

import math
print(math.sqrt(16))  # 4.0
print(math.pi)  # 3.141592653589793
print(math.factorial(5))  # 120
# 장점:
# - 어디서 온 함수인지 명확 (math.sqrt → math 모듈의 sqrt)
# - 이름 충돌 방지
# 단점:
# - 매번 모듈명을 붙여야 함 (타이핑 많음)

언제 사용?

  • 여러 함수를 사용할 때
  • 코드의 출처를 명확히 하고 싶을 때

방법 2: 특정 요소만 import

from math import sqrt, pi, factorial
print(sqrt(16))  # 4.0 (math. 없이 사용)
print(pi)  # 3.141592653589793
print(factorial(5))  # 120
# 장점:
# - 간결한 코드 (모듈명 생략)
# - 필요한 것만 가져옴
# 단점:
# - 출처가 불명확 (sqrt가 어디서 왔는지 알기 어려움)
# - 이름 충돌 가능

이름 충돌 예시:

# 문제 상황
from math import sqrt
from numpy import sqrt  # math.sqrt를 덮어씀!
print(sqrt(16))  # numpy.sqrt 실행 (의도와 다를 수 있음)
# 해결 방법: 별칭 사용
from math import sqrt as math_sqrt
from numpy import sqrt as np_sqrt
print(math_sqrt(16))  # 4.0
print(np_sqrt(16))  # 4.0

언제 사용?

  • 특정 함수를 자주 사용할 때
  • 코드가 짧고 출처가 명확할 때

방법 3: 별칭(Alias) 사용

import math as m
import numpy as np
import pandas as pd
print(m.sqrt(16))  # 4.0
print(np.array([1, 2, 3]))  # [1 2 3]
print(pd.DataFrame({'a': [1, 2]}))
# 장점:
# - 긴 모듈명을 짧게 사용
# - 출처 명확 + 타이핑 간결
# - 관습적 별칭 (np, pd) 사용 시 가독성 향상
# 단점:
# - 비표준 별칭 사용 시 혼란 가능

관습적 별칭:

import numpy as np          # ✅ 표준
import pandas as pd         # ✅ 표준
import matplotlib.pyplot as plt  # ✅ 표준
import tensorflow as tf     # ✅ 표준
import numpy as n           # ❌ 비표준 (혼란 유발)
import pandas as p          # ❌ 비표준

언제 사용?

  • 긴 모듈명을 반복 사용할 때
  • 관습적 별칭이 있는 라이브러리 (numpy → np)

방법 4: 모든 것 import (비추천)

from math import *
print(sqrt(16))  # 4.0
print(pi)  # 3.141592653589793
# 장점:
# - 모든 함수를 바로 사용 가능
# 단점:
# - 어디서 온 함수인지 알 수 없음
# - 이름 충돌 위험 높음
# - 코드 리뷰 어려움
# - PEP 8 스타일 가이드 위반

문제 상황:

from math import *
from statistics import *
# mean 함수가 두 모듈에 모두 있음!
# 어느 mean이 사용되는지 알 수 없음
result = mean([1, 2, 3, 4, 5])

언제 사용?

  • 거의 사용하지 않음 (대화형 셸에서만 가끔)
  • 프로덕션 코드에서는 절대 사용 금지

import *의 진짜 문제는 이름 충돌 그 자체보다 충돌이 조용히 일어난다는 점입니다. 위 예에서 뒤에 import한 모듈의 이름이 앞의 것을 덮어써도 경고는 전혀 없고, 나중에 어느 쪽 함수가 호출되는지는 import 순서에 달려 있습니다. 또 코드를 읽는 사람도, 린터도 sqrt가 어디서 왔는지 알 수 없어서 flake8은 F403/F405 경고를 내고, 사용하지 않는 이름을 찾아내는 기능도 제대로 동작하지 않습니다. 모듈이 __all__을 정의하면 *로 가져오는 이름이 그 목록으로 제한되고, 정의하지 않으면 밑줄(_)로 시작하지 않는 모든 최상위 이름을 가져옵니다(그 모듈이 import한 다른 모듈 이름까지 포함됩니다).

import 방법 비교표

방법문법사용 예시장점단점추천 상황
모듈 전체import mathmath.sqrt(16)출처 명확타이핑 많음여러 함수 사용
특정 요소from math import sqrtsqrt(16)간결출처 불명확특정 함수만 사용
별칭import math as mm.sqrt(16)출처 명확 + 간결별칭 기억 필요긴 모듈명
전체 importfrom math import *sqrt(16)매우 간결충돌 위험 높음사용 금지

상대 import vs 절대 import

절대 import: 프로젝트 루트부터 전체 경로 지정

# 프로젝트 구조:
# myproject/
# ├── main.py
# └── mypackage/
#     ├── __init__.py
#     ├── utils.py
#     └── core/
#         └── engine.py
# mypackage/core/engine.py에서
from mypackage.utils import helper  # 절대 import

상대 import: 현재 위치 기준으로 경로 지정

# mypackage/core/engine.py에서
from ..utils import helper  # 상대 import (상위 디렉토리)
from . import config        # 상대 import (같은 디렉토리)
# . : 현재 디렉토리
# ...: 상위 디렉토리
# ... : 상위의 상위 디렉토리

비교:

# 절대 import
# ✅ 장점: 명확, 어디서든 동일
# ❌ 단점: 패키지명 변경 시 모든 import 수정 필요
# 상대 import
# ✅ 장점: 패키지 이동/이름 변경 시 유리
# ❌ 단점: 스크립트를 직접 실행할 수 없음 (python engine.py → 에러)

권장 사항: 절대 import를 기본으로 사용하며, 패키지 내부에서만 상대 import 사용

상대 import의 .은 파일 시스템 경로가 아니라 패키지 이름 기준이라는 점을 기억해야 합니다. from ..utils import helper는 “이 모듈이 속한 패키지의 상위 패키지”를 뜻하고, Python은 이것을 모듈의 __package__ 값으로 계산합니다. 그래서 같은 파일이라도 python mypackage/core/engine.py로 직접 실행하면 __package__가 비어 있어 상대 import가 실패하고, 프로젝트 루트에서 python -m mypackage.core.engine으로 실행하면 성공합니다. 이 차이는 아래 import 문제 해결 절의 “상대 import 에러”에서 오류 메시지와 함께 다시 봅니다.


패키지와 __init__.py

패키지란?

패키지(Package)는 모듈들을 담은 디렉토리입니다. __init__.py 파일이 있으면 Python이 해당 디렉토리를 패키지로 인식합니다. 왜 패키지를 사용할까?

  • 계층적 구조: 관련 모듈을 그룹화
  • 네임스페이스: 모듈 이름 충돌 방지
  • 배포 용이: 패키지 단위로 배포

패키지 구조 예시

myproject/
├── main.py                 # 실행 파일
├── requirements.txt        # 의존성
└── mypackage/              # 패키지 디렉토리
    ├── __init__.py         # 패키지 초기화 (필수*)
    ├── module1.py          # 모듈 1
    ├── module2.py          # 모듈 2
    └── subpackage/         # 하위 패키지
        ├── __init__.py
        └── module3.py
# *Python 3.3+에서는 __init__.py가 선택사항이지만,
# 명시적으로 만드는 것이 권장됩니다.

패키지 만들기

1단계: 디렉토리와 파일 생성

# mypackage/math_ops.py
def add(a, b):
    """덧셈"""
    return a + b
def subtract(a, b):
    """뺄셈"""
    return a - b
# mypackage/string_ops.py
def reverse(text):
    """문자열 뒤집기"""
    return text[::-1]
def capitalize_words(text):
    """각 단어의 첫 글자 대문자"""
    return ' '.join(word.capitalize() for word in text.split())

2단계: __init__.py 작성

# mypackage/__init__.py
"""
mypackage: 유틸리티 함수 모음
"""
# 하위 모듈에서 함수 가져오기
from .math_ops import add, subtract
from .string_ops import reverse, capitalize_words
# 패키지 버전
__version__ = '1.0.0'
# __all__: from mypackage import *에서 가져올 항목
__all__ = ['add', 'subtract', 'reverse', 'capitalize_words']
# 패키지 초기화 코드 (선택)
print(f"mypackage v{__version__} 로드됨")

3단계: 패키지 사용

# main.py
import mypackage
# __init__.py에서 가져온 함수 사용
print(mypackage.add(3, 5))  # 8
print(mypackage.reverse("Hello"))  # olleH
# 또는 직접 모듈 import
from mypackage.math_ops import add
from mypackage.string_ops import reverse
print(add(10, 20))  # 30
print(reverse("Python"))  # nohtyP
# 또는 __init__.py에서 정의한 함수 직접 import
from mypackage import add, reverse
print(add(1, 2))  # 3

__init__.py의 역할

# 1. 빈 __init__.py: 디렉토리를 패키지로 표시만
# mypackage/__init__.py
# (내용 없음)
# 2. 함수 재export: 편리한 import 경로 제공
# mypackage/__init__.py
from .math_ops import add, subtract
# 이제 from mypackage import add 가능
# from mypackage.math_ops import add 대신
# 3. 패키지 초기화: 설정, 로깅 등
# mypackage/__init__.py
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("패키지 초기화")
# 4. __all__ 정의: 공개 API 명시
# mypackage/__init__.py
__all__ = ['add', 'subtract']  # 공개 함수만

__init__.py에 무엇을 넣을지는 트레이드오프가 있습니다. 하위 모듈의 함수를 __init__.py에서 다시 import해 두면 사용자는 from mypackage import add처럼 짧게 쓸 수 있지만, import mypackage만 해도 그 하위 모듈이 전부 실행되므로 패키지가 커질수록 import 시간이 늘고 순환 import가 생기기 쉬워집니다. 라이브러리처럼 외부에 공개할 API가 분명하면 대표 이름 몇 개만 올려두고, 애플리케이션 내부 패키지라면 __init__.py를 비워 두고 from mypackage.math_ops import add처럼 전체 경로로 쓰는 편이 문제를 덜 만듭니다.

참고로 Python 3.3부터는 __init__.py가 없는 디렉토리도 네임스페이스 패키지로 import됩니다(PEP 420). 그래서 “없어도 되던데요?”라는 질문이 자주 나오는데, 네임스페이스 패키지는 여러 경로에 흩어진 같은 이름의 디렉토리를 하나로 합치기 위한 기능이라 일반 패키지와 동작이 다르고, pytest나 setuptools의 패키지 자동 탐색(find_packages())이 이를 패키지로 인식하지 못하는 경우도 있습니다. 여러 배포판이 하나의 이름 공간을 나눠 쓰는 특별한 경우가 아니라면 빈 파일이라도 두는 쪽을 권합니다.

하위 패키지 (Subpackage)

복잡한 프로젝트는 패키지 안에 패키지를 만들 수 있습니다.

myproject/
├── main.py
└── mypackage/
    ├── __init__.py
    ├── math_ops.py
    ├── string_ops.py
    └── advanced/           # 하위 패키지
        ├── __init__.py
        ├── statistics.py
        └── algorithms.py

하위 패키지 사용:

# mypackage/advanced/statistics.py
def mean(numbers):
    """평균 계산"""
    return sum(numbers) / len(numbers)
def median(numbers):
    """중앙값 계산"""
    sorted_nums = sorted(numbers)
    n = len(sorted_nums)
    mid = n // 2
    return sorted_nums[mid] if n % 2 == 1 else (sorted_nums[mid-1] + sorted_nums[mid]) / 2
# main.py에서 사용
from mypackage.advanced.statistics import mean, median
data = [1, 2, 3, 4, 5]
print(f"평균: {mean(data)}")  # 3.0
print(f"중앙값: {median(data)}")  # 3

자주 쓰는 표준 라이브러리

표준 라이브러리란?

Python 설치 시 기본 제공되는 모듈 모음입니다. 별도 설치 없이 import만으로 사용 가능합니다.

자주 사용하는 표준 라이브러리

os - 운영체제 기능

import os
# 현재 작업 디렉토리
print(os.getcwd())  # C:\Users\JB\workspace\pkglog.com
# 디렉토리 변경
os.chdir('/path/to/directory')
# 디렉토리 생성
os.mkdir("new_folder")  # 단일 디렉토리
os.makedirs("parent/child/grandchild")  # 중첩 디렉토리
# 파일/디렉토리 존재 확인
print(os.path.exists('file.txt'))  # True/False
print(os.path.isfile('file.txt'))  # 파일인지
print(os.path.isdir('folder'))  # 디렉토리인지
# 경로 조합 (OS 독립적)
path = os.path.join('folder', 'subfolder', 'file.txt')
print(path)  # Windows: folder\subfolder\file.txt
             # Linux/Mac: folder/subfolder/file.txt
# 환경 변수
print(os.environ.get('PATH'))  # PATH 환경 변수
os.environ['MY_VAR'] = 'value'  # 환경 변수 설정
# 파일 목록
files = os.listdir('.')  # 현재 디렉토리의 모든 파일/폴더
print(files)

datetime - 날짜와 시간

from datetime import datetime, timedelta, date, time
# 현재 날짜/시간
now = datetime.now()
print(now)  # 2026-03-29 14:30:45.123456
# 포맷팅
formatted = now.strftime("%Y-%m-%d %H:%M:%S")
print(formatted)  # 2026-03-29 14:30:45
# 날짜 계산
tomorrow = now + timedelta(days=1)
next_week = now + timedelta(weeks=1)
two_hours_ago = now - timedelta(hours=2)
print(f"내일: {tomorrow.strftime('%Y-%m-%d')}")
print(f"다음 주: {next_week.strftime('%Y-%m-%d')}")
# 문자열 → datetime
date_str = "2026-12-31 23:59:59"
parsed = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
print(parsed)  # 2026-12-31 23:59:59
# 날짜만
today = date.today()
print(today)  # 2026-03-29
# 시간만
current_time = time(14, 30, 0)  # 14:30:00
print(current_time)

random - 난수 생성

import random
# 정수 난수
print(random.randint(1, 10))  # 1~10 (양 끝 포함)
print(random.randrange(0, 10, 2))  # 0, 2, 4, 6, 8 중 하나
# 실수 난수
print(random.random())  # 0.0 ~ 1.0
print(random.uniform(1.0, 10.0))  # 1.0 ~ 10.0
# 리스트에서 선택
colors = ['빨강', '파랑', '초록', '노랑']
print(random.choice(colors))  # 하나 선택
print(random.choices(colors, k=3))  # 중복 허용 3개
print(random.sample(colors, k=2))  # 중복 없이 2개
# 리스트 섞기 (in-place)
numbers = [1, 2, 3, 4, 5]
random.shuffle(numbers)
print(numbers)  # [3, 1, 5, 2, 4] (무작위 순서)
# 시드 설정 (재현 가능한 난수)
random.seed(42)
print(random.randint(1, 100))  # 항상 같은 값

json - JSON 처리

import json
# Python 객체 → JSON 문자열
data = {"name": "철수", "age": 25, "hobbies": ["독서", "영화"]}
json_str = json.dumps(data, ensure_ascii=False, indent=2)
print(json_str)
# {
#   "name": "철수",
#   "age": 25,
#   "hobbies": ["독서", "영화"]
# }
# JSON 문자열 → Python 객체
parsed = json.loads(json_str)
print(parsed['name'])  # 철수
print(type(parsed))  # <class 'dict'>
# 파일로 저장
with open('data.json', 'w', encoding='utf-8') as f:
    json.dump(data, f, ensure_ascii=False, indent=2)
# 파일에서 읽기
with open('data.json', 'r', encoding='utf-8') as f:
    loaded = json.load(f)
    print(loaded)

sys - 시스템 관련

import sys
# Python 버전
print(sys.version)  # 3.11.0 (main, Oct 24 2022, ...)
# 명령줄 인자
# python script.py arg1 arg2
print(sys.argv)  # ['script.py', 'arg1', 'arg2']
# 모듈 검색 경로
print(sys.path)
# 프로그램 종료
# sys.exit(0)  # 정상 종료
# sys.exit(1)  # 에러 종료

pathlib - 경로 처리 (현대적 방법)

from pathlib import Path
# 현재 디렉토리
current = Path.cwd()
print(current)  # C:\Users\JB\workspace\pkglog.com
# 경로 조합
file_path = Path('folder') / 'subfolder' / 'file.txt'
print(file_path)  # folder\subfolder\file.txt
# 파일 존재 확인
if file_path.exists():
    print("파일 존재")
# 파일 읽기/쓰기
file_path.write_text("Hello, World!", encoding='utf-8')
content = file_path.read_text(encoding='utf-8')
print(content)  # Hello, World!
# 파일 정보
print(file_path.name)  # file.txt
print(file_path.stem)  # file
print(file_path.suffix)  # .txt
print(file_path.parent)  # folder\subfolder
# 디렉토리 내 파일 목록
for file in Path('.').glob('*.py'):
    print(file)

pip와 requirements.txt

pip란?

pip는 Python 패키지를 설치하고 관리하는 패키지 관리자입니다. PyPI(Python Package Index)에서 패키지를 다운로드합니다.

pip 기본 명령어

# 패키지 설치
pip install requests
# 설치 과정:
# 1. PyPI에서 requests 패키지 검색
# 2. 최신 버전 다운로드
# 3. 의존성 패키지도 자동 설치
# 4. site-packages에 설치
# 특정 버전 설치
pip install requests==2.28.0  # 정확한 버전
pip install "requests>=2.28.0"  # 2.28.0 이상
pip install "requests>=2.28.0,<3.0.0"  # 범위 지정
# 패키지 업그레이드
pip install --upgrade requests
pip install -U requests  # 단축 명령
# 패키지 정보 확인
pip show requests
# Name: requests
# Version: 2.28.0
# Summary: Python HTTP for Humans.
# Location: C:\Python311\Lib\site-packages
# Requires: charset-normalizer, idna, urllib3, certifi
# 패키지 삭제
pip uninstall requests
pip uninstall -y requests  # 확인 없이 삭제
# 설치된 패키지 목록
pip list
# Package    Version
# ---------- -------
# pip        23.0.1
# requests   2.28.0
# setuptools 65.5.0
# 오래된 패키지 확인
pip list --outdated
# Package  Version  Latest  Type
# -------- -------- ------- -----
# requests 2.28.0   2.31.0  wheel
# requirements.txt 생성
pip freeze > requirements.txt
# requirements.txt로 설치
pip install -r requirements.txt

여기서 흔히 놓치는 점이 두 가지 있습니다. 첫째, pip 명령이 어느 Python에 연결돼 있는지입니다. 시스템에 Python이 여러 개 있으면 pip install requests는 A에 설치하고 python app.py는 B로 실행해서 ModuleNotFoundError가 나는 일이 흔합니다. pip 대신 python -m pip install requests로 쓰면 지금 python이 가리키는 인터프리터에 정확히 설치되므로, 저는 문서나 스크립트에서는 항상 이 형태를 씁니다. 둘째, pip freeze는 직접 설치한 패키지뿐 아니라 그 패키지가 끌고 온 간접 의존성까지 전부 버전을 고정해서 출력합니다. 재현성은 좋아지지만 어떤 패키지가 정말 필요한 것인지 알기 어려워지므로, 직접 쓰는 패키지만 적은 목록과 pip freeze 결과(잠금 파일)를 나눠 관리하는 방식이 많이 쓰입니다. pip-tools, Poetry, uv 같은 도구가 이 두 파일을 자동으로 관리해 줍니다.

requirements.txt 작성법

# requirements.txt
# 1. 정확한 버전 (프로덕션 권장)
requests==2.28.0
flask==2.3.0
pandas==2.0.0
# 2. 최소 버전
numpy>=1.24.0
# 3. 범위 지정
django>=4.0.0,<5.0.0
# 4. 주석
# 웹 프레임워크
flask==2.3.0
# 데이터 처리
pandas==2.0.0
numpy==1.24.0
# 5. 개발 의존성 분리 (선택)
# requirements-dev.txt
pytest==7.3.0
black==23.3.0
flake8==6.0.0

pip 고급 사용법

# 1. 로컬 패키지 설치 (개발 모드)
pip install -e .
# -e: editable mode (소스 코드 수정 시 재설치 불필요)
# 2. GitHub에서 직접 설치
pip install git+https://github.com/user/repo.git
# 3. 특정 브랜치/태그 설치
pip install git+https://github.com/user/repo.git@branch_name
# 4. 로컬 .whl 파일 설치
pip install package-1.0.0-py3-none-any.whl
# 5. 여러 패키지 동시 설치
pip install requests flask pandas
# 6. 캐시 무시하고 설치
pip install --no-cache-dir requests
# 7. 사용자 디렉토리에 설치 (권한 없을 때)
pip install --user requests

pip 트러블슈팅

# 문제 1: pip 버전이 오래됨
pip install --upgrade pip
# 문제 2: SSL 인증서 오류
pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org requests
# 문제 3: 권한 오류 (Windows)
# 관리자 권한으로 실행 또는
pip install --user requests
# 문제 4: 패키지 충돌
pip install --force-reinstall requests
# 문제 5: 의존성 확인
pip check  # 의존성 문제 확인

위 목록 중 두 가지는 부작용을 알고 써야 합니다. --trusted-host는 해당 호스트의 인증서 검증을 끄는 옵션이라, 회사 프록시가 TLS를 가로채는 환경에서 임시로 쓰는 것 외에는 권하지 않습니다. 근본적인 해결은 프록시의 루트 인증서를 pip config set global.cert 인증서경로로 지정하는 것입니다. --user는 가상환경 밖에서 쓰는 옵션이며, 가상환경 안에서는 의미가 없고 오히려 설치 위치를 헷갈리게 만듭니다.

최근 Linux 배포판(Debian 12, Ubuntu 23.04 이후)과 Homebrew Python에서는 가상환경 없이 pip install을 하면 error: externally-managed-environment 오류가 나고 설치가 거부됩니다(PEP 668). 시스템 패키지 관리자가 관리하는 Python을 pip가 덮어써서 OS 도구가 망가지는 일을 막기 위한 것입니다. 메시지에 나오는 --break-system-packages로 우회할 수는 있지만, 올바른 대응은 다음 장의 가상환경을 만들어 그 안에 설치하는 것입니다.


venv로 가상환경 만들기

venv 사용

# 가상환경 생성
python -m venv myenv
# 가상환경 활성화
# Windows
myenv\Scripts\activate
# Mac/Linux
source myenv/bin/activate
# 패키지 설치 (가상환경 내)
pip install requests
# 가상환경 비활성화
deactivate

가상환경은 특별한 격리 기술이 아니라, 별도의 site-packages 디렉토리와 그곳을 가리키는 Python 실행 파일 묶음입니다. activate는 단지 PATH 앞쪽에 myenv/bin(Windows는 myenv\Scripts)을 추가해서 python, pip 명령이 가상환경 쪽을 가리키게 할 뿐입니다. 그래서 활성화하지 않아도 myenv/bin/python app.py처럼 가상환경의 Python을 직접 실행하면 똑같이 동작하고, cron이나 systemd에서 실행할 때는 이 방식을 씁니다. 반대로 가상환경 디렉토리를 다른 경로로 옮기거나 다른 PC로 복사하면 내부의 절대 경로가 맞지 않아 동작하지 않으므로, 가상환경은 복사하지 말고 requirements.txt로 다시 만드는 대상으로 봐야 합니다.

Windows PowerShell에서 activate를 실행하면 이 시스템에서 스크립트를 실행할 수 없으므로 ...Activate.ps1 파일을 로드할 수 없습니다 오류가 나는 경우가 있습니다. 실행 정책 때문이며, Set-ExecutionPolicy -Scope CurrentUser RemoteSigned로 현재 사용자에 한해 허용하면 해결됩니다.


가상환경 실전 워크플로우

venv 상세 설명

# 가상환경 생성
python -m venv myenv
# myenv: 가상환경 이름 (관례: venv, .venv, env)
# 디렉토리 구조:
# myenv/
# ├── Scripts/       # Windows (실행 파일)
# ├── bin/           # Mac/Linux (실행 파일)
# ├── Lib/           # 설치된 패키지
# └── pyvenv.cfg     # 설정 파일
# 가상환경 활성화
# Windows (PowerShell)
myenv\Scripts\Activate.ps1
# Windows (CMD)
myenv\Scripts\activate.bat
# Mac/Linux
source myenv/bin/activate
# 활성화 확인:
# (myenv) PS C:\Users\JB\workspace\pkglog.com>
# 프롬프트 앞에 (myenv) 표시
# Python 경로 확인
which python  # Mac/Linux
where python  # Windows
# 출력: myenv/bin/python (가상환경의 Python)
# 패키지 설치 (가상환경 내)
pip install requests
pip install flask pandas
# 설치된 패키지 확인
pip list
# requirements.txt 생성
pip freeze > requirements.txt
# 가상환경 비활성화
deactivate
# 프롬프트에서 (myenv) 사라짐

가상환경 실전 워크플로우

# 새 프로젝트 시작
mkdir myproject
cd myproject
# 가상환경 생성 및 활성화
python -m venv venv
venv\Scripts\activate  # Windows
# 패키지 설치
pip install flask sqlalchemy pytest
# 개발 작업...
# requirements.txt 생성 (배포용)
pip freeze > requirements.txt
# Git에 커밋 (venv는 제외)
echo "venv/" >> .gitignore
git add .
git commit -m "Initial commit"
# 다른 환경에서 복원
git clone <repo>
cd myproject
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt

가상환경 vs 전역 환경

구분전역 환경가상환경
설치 위치Python 설치 디렉토리프로젝트 디렉토리
패키지 격리❌ 모든 프로젝트 공유✅ 프로젝트별 독립
버전 충돌⚠️ 발생 가능✅ 없음
권장 사용시스템 도구만모든 프로젝트

프로젝트 구조와 웹 스크래퍼 예제

프로젝트 전체 구조

myproject/
├── venv/                   # 가상환경 (Git 제외)
├── src/                    # 소스 코드
│   ├── __init__.py
│   ├── main.py             # 진입점
│   ├── config.py           # 설정
│   ├── utils/              # 유틸리티 패키지
│   │   ├── __init__.py
│   │   ├── file_utils.py   # 파일 처리
│   │   └── string_utils.py # 문자열 처리
│   └── models/             # 데이터 모델
│       ├── __init__.py
│       └── user.py
├── tests/                  # 테스트
│   ├── __init__.py
│   ├── test_utils.py
│   └── test_models.py
├── docs/                   # 문서
│   └── README.md
├── .gitignore              # Git 제외 파일
├── requirements.txt        # 의존성
├── requirements-dev.txt    # 개발 의존성
├── setup.py                # 패키지 설정 (배포용)
└── README.md               # 프로젝트 설명

간단한 웹 스크래퍼 프로젝트

프로젝트 구조:

web_scraper/
├── venv/
├── scraper/
│   ├── __init__.py
│   ├── main.py
│   ├── parser.py
│   └── storage.py
├── requirements.txt
└── README.md

코드 구현:

# scraper/parser.py
import requests
from bs4 import BeautifulSoup
def fetch_page(url):
    """웹 페이지 가져오기"""
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.text
def parse_titles(html):
    """HTML에서 제목 추출"""
    soup = BeautifulSoup(html, 'html.parser')
    titles = [h2.text.strip() for h2 in soup.find_all('h2')]
    return titles
# scraper/storage.py
import json
from pathlib import Path
def save_to_json(data, filename):
    """데이터를 JSON 파일로 저장"""
    with open(filename, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
def load_from_json(filename):
    """JSON 파일에서 데이터 로드"""
    if not Path(filename).exists():
        return None
    with open(filename, 'r', encoding='utf-8') as f:
        return json.load(f)
# scraper/__init__.py
"""
웹 스크래퍼 패키지
"""
from .parser import fetch_page, parse_titles
from .storage import save_to_json, load_from_json
__version__ = '1.0.0'
__all__ = ['fetch_page', 'parse_titles', 'save_to_json', 'load_from_json']
# scraper/main.py
from scraper import fetch_page, parse_titles, save_to_json
def main():
    url = 'https://example.com'
    
    print(f"페이지 가져오는 중: {url}")
    html = fetch_page(url)
    
    print("제목 파싱 중...")
    titles = parse_titles(html)
    
    save_to_json({'titles': titles}, 'output.json')
    
    print(f"✅ {len(titles)}개 제목 저장 완료")
if __name__ == '__main__':
    main()

requirements.txt:

requests==2.31.0
beautifulsoup4==4.12.0

실행:

# 가상환경 설정
python -m venv venv
venv\Scripts\activate
# 의존성 설치
pip install -r requirements.txt
# 실행
python -m scraper.main

ModuleNotFoundError, 순환 import, 상대 import 에러

ModuleNotFoundError

# 에러: ModuleNotFoundError: No module named 'mymodule'
# 원인 1: 모듈이 설치되지 않음
# 해결: pip install mymodule
# 원인 2: 모듈이 sys.path에 없음
import sys
print(sys.path)  # 모듈 검색 경로 확인
# 해결: 경로 추가
sys.path.append('/path/to/module')
# 원인 3: 가상환경이 활성화되지 않음
# 해결: 가상환경 활성화 확인
import sys
print(sys.prefix)  # 가상환경 경로 확인

순환 import (Circular Import)

# module_a.py
from module_b import func_b
def func_a():
    return func_b()
# module_b.py
from module_a import func_a  # 순환 import!
def func_b():
    return func_a()
# 실행 시: ImportError: cannot import name 'func_b'
# 해결 1: import를 함수 내부로 이동 (지연 import)
# module_a.py
def func_a():
    from module_b import func_b  # 함수 호출 시에만 import
    return func_b()
# 해결 2: 공통 모듈로 분리
# common.py
def shared_func():
    pass
# module_a.py
from common import shared_func
# module_b.py
from common import shared_func
# 해결 3: 구조 재설계 (권장)
# 순환 의존성이 생기지 않도록 모듈 구조 개선

Python 3.8 이후 실제 메시지는 ImportError: cannot import name 'func_b' from partially initialized module 'module_b' (most likely due to a circular import)입니다. 동작을 순서대로 따라가면 원인이 보입니다. module_a를 import하면 module_a가 실행되기 시작하고, 첫 줄에서 module_b를 import합니다. module_b는 첫 줄에서 module_a의 func_a를 달라고 하는데, module_a는 sys.modules에 등록은 됐지만 아직 첫 줄에서 멈춰 있어서 func_a가 정의되지 않은 상태입니다. 이것이 메시지의 “partially initialized”가 뜻하는 바입니다.

import module_b처럼 모듈 자체를 import하고 함수 안에서 module_b.func_b()로 쓰면 이름을 꺼내는 시점이 호출 시점으로 늦춰져 오류가 사라지는 경우가 많습니다. 다만 함수 안 import나 이 방법은 증상을 숨기는 것에 가깝습니다. 두 모듈이 서로를 필요로 한다는 것은 대개 공통 부분을 별도 모듈로 떼어낼 때가 됐다는 신호이므로, 가능하면 해결 2나 3으로 구조를 정리하는 편이 좋습니다. 타입 힌트 때문에만 서로를 import하는 경우라면 from typing import TYPE_CHECKING으로 감싸 실행 시에는 import하지 않게 만들 수도 있습니다.

상대 import 에러

# 에러: ImportError: attempted relative import with no known parent package
# 원인: 스크립트를 직접 실행
# python mypackage/module.py  # ❌
# 해결: 패키지를 모듈로 실행
# python -m mypackage.module  # ✅
# 또는 절대 import 사용
# from mypackage.utils import helper  # ✅

ImportError와 ModuleNotFoundError의 차이

# ModuleNotFoundError: 모듈 자체를 찾을 수 없음
# import nonexistent_module
# ImportError: 모듈은 있지만 특정 항목을 찾을 수 없음
# from math import nonexistent_function
# 해결: 모듈 내용 확인
import math
print(dir(math))  # 사용 가능한 모든 항목 출력

import 순서와 선택적 의존성

한 파일 안에서 import는 표준 라이브러리 → 서드파티 → 내 프로젝트 순으로 두면, 의존 관계가 한눈에 들어옵니다(PEP 8 권장). NumPy처럼 있으면 쓰고 없으면 다른 경로로 가는 코드를 쓸 때는 try/except ImportError로 선택적 import를 나눕니다.

# 1. 표준 라이브러리
import os
import sys
from datetime import datetime
# 빈 줄
# 2. 서드파티 라이브러리
import requests
import numpy as np
from flask import Flask
# 빈 줄
# 3. 로컬 모듈
from mypackage import utils
from mypackage.models import User
# 각 그룹 내에서는 알파벳 순으로 정렬

조건부 import

# 선택적 의존성 (패키지가 없어도 동작)
try:
    import numpy as np
    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False
    print("NumPy가 설치되지 않았습니다. 기본 기능만 사용합니다.")
def process_data(data):
    if HAS_NUMPY:
        return np.array(data).mean()
    else:
        return sum(data) / len(data)
# 사용
result = process_data([1, 2, 3, 4, 5])
print(result)  # NumPy 있으면 np.mean(), 없으면 순수 Python

지연 import (Lazy Import)

# 무거운 모듈은 필요할 때만 import
def process_image(image_path):
    # PIL은 함수 호출 시에만 import (프로그램 시작 시간 단축)
    from PIL import Image
    
    img = Image.open(image_path)
    return img.resize((100, 100))
# 장점: 프로그램 시작 시간 단축
# 단점: 첫 호출 시 약간 느림

__all__ 사용

# mymodule.py
def public_func():
    """공개 함수"""
    pass
def _private_func():
    """비공개 함수 (관례: _ 접두사)"""
    pass
def __internal_func():
    """모듈 수준에서는 __ 접두사도 _와 똑같이 취급됩니다 (이름 맹글링은 클래스 안에서만 적용)"""
    pass
# 공개 API 명시
__all__ = ['public_func']
# 다른 파일에서
from mymodule import *
# public_func만 import됨 (_private_func, __internal_func는 제외)
# 명시적 import는 여전히 가능
from mymodule import _private_func  # 가능하지만 권장하지 않음

모듈 리로드 (개발 중)

# 모듈 수정 후 재import가 필요할 때
import importlib
import mymodule
# 모듈 리로드
importlib.reload(mymodule)
# 주의: 프로덕션 코드에서는 사용하지 말 것
# 개발/디버깅 용도로만 사용

연습 문제: 유틸 패키지, requirements.txt, 표준 라이브러리

문제 1: 간단한 유틸리티 패키지 만들기

다음 구조의 패키지를 만들고 사용하세요:

utils/
├── __init__.py
├── math_utils.py  # add, multiply 함수
└── text_utils.py  # reverse, count_words 함수

문제 2: requirements.txt 작성

다음 패키지를 포함하는 requirements.txt를 작성하세요:

  • requests (2.31.0)
  • flask (2.3.0 이상, 3.0.0 미만)
  • pandas (최신 버전)

문제 3: 표준 라이브러리 활용

datetime과 random을 사용하여 다음 기능을 구현하세요:

  • 오늘부터 7일 후 날짜 출력
  • 1~100 중 랜덤 숫자 10개 생성 (중복 없이)
정답 보기

문제 1:

# utils/math_utils.py
def add(a, b):
    return a + b
def multiply(a, b):
    return a * b
# utils/text_utils.py
def reverse(text):
    return text[::-1]
def count_words(text):
    return len(text.split())
# utils/__init__.py
from .math_utils import add, multiply
from .text_utils import reverse, count_words
__all__ = ['add', 'multiply', 'reverse', 'count_words']
# main.py
from utils import add, reverse
print(add(3, 5))  # 8
print(reverse("Hello"))  # olleH

문제 2:

requests==2.31.0
flask>=2.3.0,<3.0.0
pandas

문제 3:

# 필요한 모듈 import
from datetime import datetime, timedelta
import random
# 7일 후
today = datetime.now()
after_7_days = today + timedelta(days=7)
print(after_7_days.strftime("%Y-%m-%d"))
# 랜덤 숫자 10개 (중복 없이)
random_numbers = random.sample(range(1, 101), k=10)
print(random_numbers)

모듈과 패키지 요약

  1. 모듈: .py 파일, import로 재사용
  2. 패키지: 모듈들의 디렉토리, __init__.py로 초기화
  3. import: 4가지 방법 (전체, 특정, 별칭, *)
  4. 표준 라이브러리: os, datetime, random, json, sys, pathlib 등
  5. pip: PyPI에서 패키지 설치/관리
  6. 가상환경: 프로젝트별 독립 환경 (venv)
  7. requirements.txt: 의존성 관리 import 오류를 만났을 때는 “어느 Python이 실행 중인가(sys.executable)”, “그 Python의 검색 경로에 무엇이 있는가(sys.path)”, “내 파일 이름이 다른 모듈을 가리고 있지 않은가”를 이 순서로 확인하면 대부분 원인이 드러납니다.

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. attempted relative import with no known parent package 오류는 왜 생기나요?

A. 패키지 안의 모듈을 python mypackage/module.py처럼 스크립트로 직접 실행하면, Python은 그 파일이 어느 패키지에 속하는지 모르기 때문에 from . import ... 같은 상대 import를 해석하지 못합니다. 프로젝트 루트에서 python -m mypackage.module처럼 모듈로 실행하면 패키지 정보가 잡혀 상대 import가 동작합니다. 또는 from mypackage.utils import helper처럼 절대 import로 바꾸는 방법도 있습니다.