Choose a default theme

You can change your theme from the menu

Python

Python은 간결한 문법과 풍부한 라이브러리를 갖춘 범용 프로그래밍 언어다. 이 문서는 값과 객체의 관계부터 함수·예외·파일 처리·실전 CSV 집계까지, 특징이 실제 코드에서 어떻게 작동하고 어떤 실수를 주의해야 하는지 설명한다.

프로그래밍 언어

언어

Python · Java · JavaScript · TypeScript · Rust

개요: 언어와 실행 환경을 나누어 이해하기

Python은 자동화·데이터 처리·웹 서버·과학 계산 등 다양한 작업에 쓰이는 범용 프로그래밍 언어다. 읽기 쉬운 문법, 표준 라이브러리, 확장 패키지가 널리 활용되지만, “코드가 짧다”는 설명만으로 언어의 성격을 이해할 수는 없다. 이름이 객체를 가리키는 방식, 변경 가능한 자료의 공유, 반복 프로토콜, 예외와 자원 정리를 이해해야 짧은 코드가 실제로 무엇을 하는지 판단할 수 있다.

Python이라는 언어, 이를 실행하는 CPython 같은 구현, 운영체제에 설치된 인터프리터, 프로젝트별 패키지 환경은 구별한다. 같은 .py 파일이라도 서로 다른 인터프리터로 실행하면 지원 문법이나 설치 패키지가 달라질 수 있다. 프로그램이 실행되지 않을 때 소스의 오타부터 찾기 전에 어떤 실행 파일과 환경을 사용하는지 확인하면 원인을 빨리 좁힐 수 있다.

이 문서는 Python 3를 기준으로 설명한다. 코드 블록마다 필요한 import와 자료를 포함했으므로 별도 안내가 없는 예제는 독립적으로 실행할 수 있다. 실행 결과 블록은 코드에 붙여 넣지 않는다. 대화형 문서에서 보이는 >>>는 입력 프롬프트이며 코드가 아니다. 후반의 실전 예제는 외부 패키지나 네트워크 없이 표준 라이브러리로 실행할 수 있게 구성했다.

첫 프로그램: 입력 자료와 계산, 출력을 분리하기

다음 코드는 단가와 수량으로 총액을 계산한다. 이름에 값을 대입한 다음 곱셈을 수행하고, f-string으로 사람이 읽을 문장을 만든다. 결과는 3개 합계: 3,600원이다. total:,에서 콜론 뒤 쉼표는 천 단위 구분자를 넣는 출력 형식이며 실제 정수 값에 쉼표 문자가 저장되는 것은 아니다. 숫자 계산과 표시 문자열을 구분하는 작은 예다.

실제 주문 프로그램에서는 숫자가 아니라 입력 상자의 문자열부터 처리할 수도 있다. 그 경우 숫자로 변환하고 허용 범위를 확인한 뒤 계산해야 한다. 이 예제는 고정된 양의 정수를 사용하므로 입력 검증을 생략했다. 프로그래밍에서 중요한 것은 짧은 예제를 외우는 것보다 무엇을 이미 참이라고 가정하는지 알아내는 것이다. 세금·할인·통화의 최소 단위를 다루면 계산 규칙도 따로 정의해야 한다.

파일 실행과 대화형 실행의 차이도 알아두면 좋다. 대화형 환경은 입력한 표현식의 결과를 보여 주지만 파일에서 total이라는 표현식만 적으면 일반적으로 화면 출력이 생기지 않는다. 화면에 보여 줄 목적이면 print를 호출하고, 다른 코드에서 쓰려면 함수의 반환값으로 전달한다.

예제 코드

unit_price = 1200
quantity = 3
total = unit_price * quantity
print(f"{quantity}개 합계: {total:,}원")

실행 결과

3개 합계: 3,600원

이름, 객체와 동적 타입

Python의 대입은 이름을 객체에 연결한다. 어떤 이름이 정수를 가리키다가 나중에 문자열을 가리킬 수 있지만, 객체의 타입 규칙까지 사라지는 것은 아니다. 다음 예제에서 value의 타입 이름은 처음 int, 재대입 뒤 str이다. 정수 7에 문자열 길이 연산을 적용하거나 문자열에 숫자를 무조건 더할 수 있다는 뜻이 아니다.

동적 타입을 “타입 검사가 없다”로 이해하면 오류의 원인을 놓친다. 실제 객체가 해당 연산을 지원하는지는 실행 중에 중요하다. 외부 입력을 int로 변환하려다가 실패하면 ValueError가 발생할 수 있고, 서로 맞지 않는 타입을 더하면 TypeError가 발생할 수 있다. 어떤 함수가 허용하는 입력과 보장하는 결과를 문서화하고 테스트하는 이유다.

이름에 의미를 유지하는 것도 중요하다. 한 함수에서 data가 원본 문자열, 파싱한 목록, 집계 딕셔너리를 차례로 가리키면 짧게 작성할 수 있지만 현재 형태를 추적하기 어렵다. raw_text, records, totals처럼 처리 단계의 역할을 드러내면 오류 메시지와 변수 상태를 읽기 쉬워진다. 동적 타입의 유연성을 사용하되 같은 이름을 재사용하는 횟수를 줄이는 것만으로도 큰 프로그램의 이해 비용을 낮출 수 있다.

예제 코드

value = 7
print(type(value).__name__)
value = "seven"
print(type(value).__name__)

실행 결과

int
str

변경 가능성: 같은 리스트를 공유한다는 의미

리스트는 원소를 추가하거나 바꿀 수 있는 변경 가능한 객체다. 아래에서 alias = original은 리스트 전체를 복사하지 않고 같은 객체를 다른 이름으로 가리킨다. alias.append(3)을 실행하면 original도 [1, 2, 3]으로 보인다. 둘 사이에 변경을 자동으로 복제한 것이 아니라 애초에 관찰 대상이 같은 리스트였던 것이다.

copied = original.copy()는 바깥 리스트를 새로 만든다. copied를 만든 뒤 alias로 원본에 원소를 추가해도 copied의 길이는 바뀌지 않는다. 출력은 [1, 2, 3], [1, 2], True다. is 비교는 같은 객체인지를 검사한다. 두 리스트의 내용이 같은지 비교하려면 ==를 사용해야 하며, 작은 정수나 문자열의 구현상 재사용을 근거로 is를 값 비교에 쓰지 않는다.

문자열과 튜플 같은 불변 객체에서는 같은 방식의 원소 변경을 할 수 없다. 그러나 튜플 안에 리스트가 들어 있다면 그 리스트의 내용은 바뀔 수 있다. “바깥 컨테이너가 불변”이라는 것과 “연결된 모든 데이터가 깊이 불변”이라는 것은 다르다. 함수에 자료를 넘길 때도 그 함수가 입력을 수정하는지 새 결과를 반환하는지 API의 약속을 확인해야 한다.

예제 코드

original = [1, 2]
alias = original
copied = original.copy()
alias.append(3)
print(original)
print(copied)
print(original is alias)

실행 결과

[1, 2, 3]
[1, 2]
True

얕은 복사와 깊은 복사: 중첩 구조의 함정

바깥 리스트를 복사해도 내부 객체까지 모두 새로 만들어지는 것은 아니다. 아래 shallow는 바깥 리스트만 독립적이고 안의 리스트는 rows와 공유한다. shallow[0].append(9)를 실행하면 rows와 shallow가 모두 [[1, 9], [2]]가 된다. deepcopy로 만든 deep은 [[1], [2]]를 유지한다.

이 구분은 설정 딕셔너리, 중첩 JSON, 화면 상태처럼 여러 층의 자료에서 자주 문제를 만든다. 복사한 설정만 바꿨다고 생각했는데 다른 요청의 설정도 달라진다면 어느 깊이에서 객체를 공유하는지 살펴야 한다. [some_list] * 3처럼 같은 내부 객체 참조를 반복해서 넣는 코드도 독립적인 세 리스트를 만드는 것과 다르다.

deepcopy를 모든 문제의 해답으로 쓰는 것도 조심해야 한다. 큰 객체 그래프를 복제하면 시간과 메모리가 필요하고, 파일·연결·캐시처럼 복제 의미가 적절하지 않은 자원이 섞일 수 있다. 정말 독립적이어야 하는 부분만 새로 만들거나 변경 책임을 한곳에 모으는 편이 더 명확할 수 있다. 복사는 성능 최적화보다 먼저 데이터 소유와 공유 의도를 정한 뒤 선택한다.

예제 코드

from copy import deepcopy
rows = [[1], [2]]
shallow = rows.copy()
deep = deepcopy(rows)
shallow[0].append(9)
print(rows)
print(shallow)
print(deep)

실행 결과

[[1, 9], [2]]
[[1, 9], [2]]
[[1], [2]]

숫자: 정수, 부동소수점과 십진수 계산

Python 정수는 고정된 32비트나 64비트 범위에만 묶이지 않고 필요한 만큼 커질 수 있다. 그러나 큰 정수 연산에도 시간과 메모리 비용이 있으므로 크기가 무한히 공짜로 늘어나는 것은 아니다. float는 보통 이진 부동소수점으로 동작하며 십진 소수를 모두 정확히 표현하지 못한다.

아래 첫 비교는 False다. 0.1과 0.2를 표현하고 더한 결과가 0.3의 표현과 정확히 같지 않기 때문이다. 출력 자릿수를 줄여 0.3처럼 보여도 내부 값의 표현이 바뀐 것은 아니다. 측정값 등 오차를 허용하는 수치를 비교할 때는 문제에 맞는 허용 오차와 math.isclose 같은 방법을 고려한다. 모든 수치에 하나의 고정 오차를 적용하면 크기가 다른 값에서는 부적절할 수 있다.

Decimal 예제는 문자열에서 십진 값을 만들고 더해 0.3을 출력한다. Decimal(0.1)처럼 이미 이진 float가 된 값을 넣으면 처음 입력의 근사 표현을 가져올 수 있으므로 생성 경로도 중요하다. Decimal 역시 정밀도와 반올림 문맥을 사용한다. 금액 계산은 십진 타입을 선택하는 데서 끝나지 않고 최소 통화 단위, 할인과 세금 계산 순서, 반올림 시점을 정해야 한다. 정수로 최소 단위를 저장하는 설계도 상황에 따라 비교한다.

예제 코드

from decimal import Decimal
print(0.1 + 0.2 == 0.3)
print(Decimal("0.1") + Decimal("0.2"))
print(10 ** 30)

실행 결과

False
0.3
1000000000000000000000000000000

문자열과 바이트: 텍스트를 파일로 옮길 때

str은 텍스트를, bytes는 바이트 열을 표현한다. 파일과 네트워크의 바이트를 텍스트로 바꾸는 과정이 디코딩이고 텍스트를 바이트로 바꾸는 과정이 인코딩이다. UTF-8로 저장한 한글을 다른 규칙으로 읽으면 잘못 표시되거나 변환 오류가 생길 수 있다. 읽기와 쓰기의 인코딩을 명확히 하는 이유다.

가A는 str의 len이 2지만 UTF-8로 인코딩한 bytes의 길이는 4다. 아래 예제의 출력은 2 4와 가A다. 텍스트 길이와 전송 바이트 수는 다른 단위다. Python의 문자열 길이는 화면에서 사용자가 보는 글자 덩어리 수와도 항상 같지 않다. 결합 문자나 여러 코드 포인트로 표현되는 이모지를 처리하는 편집기에서는 추가적인 유니코드 처리 규칙이 필요하다.

문자열은 불변이므로 replace나 strip 같은 메서드는 보통 새 결과를 돌려준다. text.strip()을 호출한 뒤 결과를 버리면 text에 새 문자열이 대입되는 것은 아니다. 큰 문자열을 반복해서 이어 붙이는 작업은 처리량과 메모리를 측정해 보고 join이나 스트리밍 출력을 고려한다. 사용자 입력을 코드로 해석하는 eval을 텍스트 파싱의 편의 기능처럼 사용하지 않고, 데이터 형식에 맞는 파서를 사용한다.

예제 코드

text = "가A"
payload = text.encode("utf-8")
print(len(text), len(payload))
print(payload.decode("utf-8"))

실행 결과

2 4
가A

리스트·튜플·딕셔너리·집합을 고르는 기준

리스트는 순서 있는 항목을 보관하고 수정할 때, 튜플은 고정된 묶음의 의미를 표현할 때 자주 사용한다. 딕셔너리는 키로 값을 찾고 집합은 중복을 제거하거나 포함 여부를 판단하는 데 적합하다. 같은 자료를 담을 수 있다고 해서 연산 비용과 의미까지 같은 것은 아니다. 예를 들어 수많은 항목에서 회원 ID의 존재를 반복 확인하는 작업은 단순 리스트 순회와 집합 조회의 비용 구조가 다르다.

아래 예제는 제품 이름의 등장 횟수를 센다. get(name, 0)은 아직 키가 없을 때 0을 주고, 여기에 1을 더해 저장한다. pen은 2, book은 1이며 set으로 만들고 정렬한 이름 목록은 book, pen이다. 집합의 출력 순서에 의존하지 않고 sorted를 사용했으므로 문서의 결과가 안정적이다. 실제 파일 출력이나 테스트에서도 순서가 필요한지 명시해야 한다.

딕셔너리의 키와 집합 원소에는 해시 가능한 값이 필요하다. 변경 가능한 리스트를 그대로 키로 사용하는 방식은 허용되지 않는다. 또한 dict.get이 None을 돌려줬다는 것만으로 키가 없다고 단정하면, 실제 저장값이 None인 경우를 구별하지 못할 수 있다. 키 존재 여부가 의미가 있으면 in을 사용하거나 별도의 센티널 값을 두는 방법을 고려한다. 자료형 선택은 문법 길이보다 수행할 연산과 데이터의 의미에서 출발한다.

예제 코드

names = ["pen", "book", "pen"]
counts = {}
for name in names:
    counts[name] = counts.get(name, 0) + 1
print(counts["pen"], counts["book"])
print(sorted(set(names)))

실행 결과

2 1
['book', 'pen']

분기와 반복: 경계값을 먼저 정하기

if는 조건에 따라 실행 경로를 선택하고 for는 반복 가능한 객체의 항목을 순회한다. 들여쓰기는 블록을 구분하는 문법이므로 보기 좋게 만드는 장식이 아니다. 복사한 코드가 실행되지 않으면 공백과 탭 혼합, 콜론 뒤 블록, 들여쓰기 깊이를 확인한다. 편집기의 자동 서식을 사용하면 이런 오류를 줄일 수 있다.

다음 예제는 60점 이상을 통과로 분류한다. enumerate의 start=1은 사용자에게 보여 줄 번호를 1부터 시작하게 한다. 실제 리스트 인덱스 자체가 1부터 시작하도록 바뀌는 것은 아니다. 출력에서 59는 재시도, 60과 61은 통과다. 이처럼 경계 바로 아래·같은 값·바로 위를 확인하면 >와 >=를 혼동한 오류를 잡기 쉽다.

반복 도중 순회 중인 리스트에서 원소를 삭제하면 다음 항목의 위치가 바뀌어 의도와 다른 결과를 만들 수 있다. 별도 결과 리스트를 만들거나 명확한 인덱스 전략을 사용한다. range의 끝값은 포함하지 않으므로 range(3)은 0, 1, 2를 제공한다. 반복 횟수와 종료 조건을 설명할 때 입력 크기 0과 1에서도 원하는 결과가 나오는지 살펴본다.

예제 코드

scores = [59, 60, 61]
for number, score in enumerate(scores, start=1):
    if score >= 60:
        result = "통과"
    else:
        result = "재시도"
    print(number, result)

실행 결과

1 재시도
2 통과
3 통과

컴프리헨션: 변환과 필터의 흐름 읽기

리스트 컴프리헨션은 원소를 순회하며 조건에 맞는 값을 변환하여 새 리스트로 만드는 표현이다. 아래 passed는 scores에서 60 이상인 값만 고른 결과다. 원본 리스트에서 낮은 점수를 삭제한 것이 아니다. 출력은 [67, 91, 60], 3, 218이며 선택된 점수의 인원과 합계가 각각 len과 sum으로 계산된다.

읽는 순서는 for score in scores로 각 값을 얻고, if score >= 60으로 통과 여부를 정한 다음, 앞쪽 score를 결과 원소로 넣는 흐름이다. 변환이 필요하다면 앞의 표현식이 score * 2처럼 바뀔 수 있다. 조건과 변환의 역할을 구별하면 한 줄이라는 겉모양에 덜 헷갈린다.

컴프리헨션은 간단한 변환에 적합하지만 여러 겹의 반복, 복잡한 조건, 외부 상태 변경을 모두 넣으면 읽기 어려워진다. 로그를 남기거나 오류를 처리해야 하는 단계는 일반 반복문과 함수로 분리하는 편이 명확할 수 있다. 리스트를 실제로 만든다는 메모리 비용도 있으므로 단순 합계만 필요하면 제너레이터 표현식으로 중간 리스트 생성을 피할 수 있다. 이 선택은 후속 단계에서 결과를 여러 번 사용할지에 따라 달라진다.

예제 코드

scores = [42, 67, 91, 60]
passed = [score for score in scores if score >= 60]
print(passed)
print(len(passed))
print(sum(passed))

실행 결과

[67, 91, 60]
3
218

함수: 출력과 반환, 입력 계약을 구분하기

함수는 이름을 붙인 처리 단위로, 매개변수로 입력을 받고 return으로 결과를 돌려줄 수 있다. print는 화면에 텍스트를 쓰는 동작이고 return은 호출자가 다시 사용할 값을 전달한다. 화면에 숫자가 보인다는 이유만으로 함수가 그 숫자를 반환했다고 생각하면 후속 계산에서 None을 사용하게 될 수 있다. return을 실행하지 않고 끝난 함수의 반환값은 None이다.

아래 line_total은 단가와 수량이 음수가 아니어야 한다는 조건을 검사한다. 할인이나 세금을 다루지 않는 최소 예제다. 유효한 입력 1200과 3에서는 3600을 반환하고, 호출자가 그 값을 출력한다. quantity를 키워드로 지정하면 호출 지점에서 인자의 의미를 읽기 쉽다. 복잡한 API에서는 키워드 전용 인자를 사용해 의미가 다른 숫자의 순서를 뒤바꾸는 실수를 줄일 수도 있다.

조건 검사는 입력 계약의 일부다. 이 함수가 문자열을 받아 자동 변환할지, 정수만 받을지, bool을 정수처럼 허용할지까지 실제 서비스에서는 결정해야 한다. 예제의 비교와 곱셈이 가능하다고 업무적으로 올바른 단가와 수량인 것은 아니다. 함수가 책임지는 범위를 작게 정하고, 입력 변환과 업무 계산을 분리하면 테스트하기 쉬워진다.

예제 코드

def line_total(unit_price, quantity):
    if unit_price < 0 or quantity < 0:
        raise ValueError("price and quantity must be non-negative")
    return unit_price * quantity

result = line_total(1200, quantity=3)
print(result)

실행 결과

3600

기본 인자: 리스트가 호출 사이에 남는 실수

함수의 기본 인자 표현식은 함수를 정의할 때 평가된다. 따라서 items=[]를 기본값으로 두고 그 리스트를 수정하면 호출할 때마다 새 리스트를 만드는 것이 아니라 같은 기본 리스트를 다시 사용할 수 있다. 아래 bad_add를 두 번 호출하면 첫 결과는 [a], 다음 결과는 [a, b]다. 함수가 전역 변수 없이 작성되었어도 호출 사이의 상태가 공유된 사례다.

이를 피하는 흔한 방법은 기본값으로 None을 두고, 함수 안에서 필요할 때 리스트를 만드는 것이다. good_add의 독립적인 두 호출은 각각 [a]와 [b]를 반환한다. 호출자가 직접 리스트를 전달하면 그 리스트를 수정한다는 점은 여전히 남아 있다. 입력을 절대 수정하지 않는 API가 필요하다면 전달된 리스트를 복사하거나 새 결과를 조립하는 설계가 필요하다.

이 문제는 리스트뿐 아니라 딕셔너리나 변경 가능한 사용자 객체에도 적용된다. 기본 인자가 계산되는 시점을 이해하면 날짜·난수·환경 설정을 기본 인자 표현식으로 계산했을 때 값이 갱신되지 않는 문제도 설명할 수 있다. 함수의 호출마다 새로 필요한 값은 호출 과정에서 계산하고, 고정된 기본값은 정의 시점의 값으로 두는 식으로 의도를 구분한다.

예제 코드

def bad_add(value, items=[]):
    items.append(value)
    return items

print(bad_add("a"))
print(bad_add("b"))

def good_add(value, items=None):
    if items is None:
        items = []
    items.append(value)
    return items

print(good_add("a"))
print(good_add("b"))

실행 결과

['a']
['a', 'b']
['a']
['b']

이터레이터와 제너레이터: 필요한 만큼 계산하기

리스트는 항목을 보관하고 다시 순회할 수 있지만 이터레이터는 현재 진행 위치를 가진 순회 객체다. next로 값을 하나씩 요청하며 소진된 이터레이터가 자동으로 처음으로 돌아가지는 않는다. 제너레이터는 yield를 통해 값을 내보내고 다음 요청까지 실행 상태를 유지하는 방식으로 이터레이터를 만드는 데 쓰인다.

아래 squares는 0부터 limit 미만의 제곱수를 제공한다. 처음 next는 0을 꺼내고, 이어 list는 남은 1, 4, 9를 소비한다. 그 뒤 다시 list를 만들면 빈 리스트다. 처음 결과가 사라졌다는 오류가 아니라 한 번 진행된 순회 상태를 다시 사용한 것이다. 재사용이 필요하면 함수로 새 제너레이터를 만들거나 결과를 저장해야 한다.

한 번에 큰 리스트를 만드는 대신 한 항목씩 처리하면 메모리 사용을 줄일 수 있지만 모든 작업이 스트리밍에 맞는 것은 아니다. 전체 순위를 정하는 정렬이나 두 번의 반복이 필요한 처리는 자료를 보관하거나 다른 알고리즘을 써야 할 수 있다. 또한 파일을 열어 둔 제너레이터가 일찍 소비를 멈추면 자원 수명을 어떻게 정리할지도 고려한다. 지연 계산은 비용을 없애기보다 계산과 자원 사용 시점을 바꾸는 방식이다.

예제 코드

def squares(limit):
    for number in range(limit):
        yield number * number

values = squares(4)
print(next(values))
print(list(values))
print(list(values))

실행 결과

0
[1, 4, 9]
[]

예외 처리: 예상한 실패만 복구하기

문법 오류와 실행 중 예외를 구별한다. 문법 오류는 코드를 해석할 수 없는 상태이고, 예외는 문법이 맞아도 특정 입력이나 자원 상태 때문에 연산을 완료하지 못한 경우다. Traceback에는 호출 경로와 발생 위치가 나오므로 마지막 예외 이름뿐 아니라 어떤 데이터가 그 위치로 전달되었는지 살펴야 한다.

아래 parse_count는 문자열을 정수로 바꾸지 못하면 ValueError를 잡고 새로운 설명을 붙인다. from exc는 원래 오류와의 연결을 보존한다. 변환 후 음수는 형식은 정수여도 업무 조건에 맞지 않아 별도로 거부한다. 실행 결과는 3, count must be an integer, count must not be negative다. 호출자는 성공 값과 오류를 구분하여 사용자에게 다시 입력받는 등의 정책을 적용할 수 있다.

except Exception으로 모든 오류를 잡아 0을 돌려주면 입력 오류뿐 아니라 프로그래머의 버그까지 정상 결과처럼 숨길 수 있다. 복구할 수 있는 범위에서 필요한 예외를 잡고, 복구할 수 없으면 더 높은 계층에 전파한다. finally는 성공·실패와 관계없이 정리가 필요한 경우에 쓰이지만, finally 안에서 return을 해 원래 예외를 가리는 실수도 피해야 한다. 파일 같은 자원은 가능한 경우 with로 수명을 표현하는 편이 명확하다.

예제 코드

def parse_count(text):
    try:
        count = int(text)
    except ValueError as exc:
        raise ValueError("count must be an integer") from exc
    if count < 0:
        raise ValueError("count must not be negative")
    return count

for text in ["3", "many", "-1"]:
    try:
        print(parse_count(text))
    except ValueError as exc:
        print(exc)

실행 결과

3
count must be an integer
count must not be negative

파일과 컨텍스트 관리자: 닫는 책임을 코드에 담기

파일은 데이터를 읽거나 쓴 뒤 닫아야 하는 자원이다. with는 블록을 벗어날 때 정리 동작을 호출하도록 수명을 표현한다. 예외가 발생해도 정리 경로가 실행되게 하는 데 유용하지만, 외부 시스템의 모든 실패를 없애는 것은 아니다. 쓰기 도중 디스크가 가득 차거나 권한이 없으면 별도의 오류 처리가 필요하다.

다음 예제는 TemporaryDirectory로 임시 폴더를 만들고 UTF-8 텍스트 파일을 썼다가 읽는다. with 블록 안에서는 파일에 접근할 수 있고 블록을 빠져나오면 임시 폴더가 정리된다. 출력은 정리된 두 줄의 문자열을 담은 리스트다. 실습이 기존 파일을 덮어쓰지 않도록 임시 위치를 사용했다. 실제 서비스는 저장 위치와 덮어쓰기 정책을 명시해야 한다.

read_text는 작은 파일을 편하게 읽지만 내용을 통째로 메모리에 올린다. 큰 파일은 with open(..., encoding=...)으로 열고 줄이나 청크 단위로 순회하는 방식을 고려한다. 경로는 현재 작업 디렉터리의 영향을 받을 수 있으므로 실행 위치가 달라졌을 때도 의도한 파일을 가리키는지 확인한다. 사용자에게 받은 경로를 사용할 때는 접근 가능한 범위와 심볼릭 링크 등 운영체제 수준 조건도 함께 검토한다.

예제 코드

from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
    path = Path(directory) / "notes.txt"
    path.write_text("첫 줄\n둘째 줄\n", encoding="utf-8")
    lines = path.read_text(encoding="utf-8").splitlines()
    print(lines)

실행 결과

['첫 줄', '둘째 줄']

JSON과 CSV: 자료 형식은 파서로 처리하기

JSON은 객체·배열·문자열·숫자 등을 교환하는 형식이고 CSV는 행과 열 형태의 자료를 교환하는 데 쓰인다. 둘 다 단순해 보여도 문자열의 따옴표와 이스케이프, 줄바꿈과 구분자 규칙이 있다. CSV를 언제나 split(",")로 자르면 따옴표 안에 쉼표가 들어 있는 필드를 잘못 나눌 수 있다. 형식에 맞는 표준 라이브러리를 쓰는 이유다.

아래 JSON 예제는 문자열을 dict로 해석한 뒤 name을 읽고 tags의 수를 출력한다. 파싱 성공은 문법이 JSON이라는 뜻이지 필요한 필드와 자료형이 맞다는 보장은 아니다. tags가 배열 대신 문자열이면 len도 동작하지만 의미가 달라질 수 있다. 외부 입력은 파싱 후 구조와 값의 조건까지 검사한다. 큰 입력이나 지나치게 깊은 구조가 자원을 소모할 수 있다는 점도 고려한다.

CSV는 다음 실전 예제에서 DictReader를 사용한다. 열 이름으로 값을 다루면 인덱스 순서를 외우는 부담이 줄지만, 헤더가 누락되거나 중복되거나 예상과 다를 수 있으므로 스키마 검증은 여전히 필요하다. 스프레드시트로 다시 내보낼 경우에는 데이터를 수식으로 해석하는 소비자의 규칙까지 별도 고려해야 한다. 파서를 쓴다는 것은 문법 처리를 맡기는 것이지 모든 입력 검증을 끝내는 것이 아니다.

예제 코드

import json
payload = '{"name": "memo", "tags": ["work", "draft"]}'
record = json.loads(payload)
print(record["name"], len(record["tags"]))
print(json.dumps({"ready": True}, sort_keys=True))

실행 결과

memo 2
{"ready": true}

모듈과 import: 파일이 실행되는 시점

모듈은 코드를 나누고 재사용하는 단위다. import는 이름만 가져오는 추상적인 표시가 아니라 모듈을 찾아 초기화하는 과정이다. 모듈의 최상위에 파일 삭제나 네트워크 요청 같은 작업을 넣으면 다른 코드가 import할 때도 실행될 수 있다. 재사용할 함수 정의와 프로그램을 직접 실행할 때의 동작을 구분해야 한다.

if __name__ == "__main__": 아래에서 main을 호출하는 관용구는 파일을 직접 실행할 때만 해당 경로를 실행하도록 구성하는 데 쓰인다. 다른 모듈에서 import하여 함수를 테스트할 때 불필요한 작업이 시작되는 것을 줄일 수 있다. 그러나 조건문 밖의 최상위 코드는 여전히 import 과정에서 실행되므로 이 관용구만 넣었다고 모든 부수 효과가 사라지는 것은 아니다.

파일 이름도 중요하다. 프로젝트에 json.py나 pathlib.py처럼 표준 라이브러리와 같은 이름을 붙이면 의도하지 않은 모듈이 먼저 선택되어 import 오류가 발생할 수 있다. 패키지 설치에 성공했는데 import가 안 되면 설치한 인터프리터와 실행 중인 인터프리터가 같은지 확인한다. sys.executable로 실제 실행 파일을 확인하고, 그 실행 파일의 -m pip를 사용하는 방식으로 설치 대상을 명확히 할 수 있다.

가상 환경과 의존성: 실행 가능성을 재현하기

프로젝트마다 필요한 라이브러리 버전이 다르면 하나의 전역 설치 공간에 모두 맞추기 어렵다. venv는 프로젝트별로 패키지 설치 영역을 나누는 데 쓰인다. 가상 환경은 운영체제 전체를 격리하는 가상 머신이나 컨테이너가 아니며, 어떤 기반 인터프리터로 만들었는지도 중요하다. 환경 폴더를 다른 컴퓨터로 그대로 복사하면 항상 재현된다고 가정하지 않는다.

실습 폴더에서 python3 -m venv .venv로 환경을 만든 뒤 해당 환경의 Python을 사용한다. Unix 계열에서는 .venv/bin/python, Windows에서는 .venv의 Scripts 폴더 안 실행 파일을 사용할 수 있다. 셸 활성화는 실행 경로 선택을 편하게 해 주는 방식이며, 활성화하지 않아도 환경의 실행 파일을 직접 지정할 수 있다. 명령 이름은 설치 방식에 따라 다르므로 존재하는 실행 파일을 확인한다.

의존성 파일에는 프로젝트가 필요로 하는 패키지와 버전 조건을 기록한다. 환경에 우연히 설치된 모든 것을 나열한 결과와 의도적으로 관리한 직접 의존성은 목적이 다르다. 재현성이 중요하면 해결된 버전, Python 버전, 운영체제와 외부 라이브러리 조건도 함께 관리한다. 패키지 설치는 코드를 가져오는 과정이므로 이름이 비슷한 다른 패키지를 잘못 설치하지 않게 출처와 프로젝트 문서를 확인한다.

클래스와 dataclass: 상태를 어디에 보관할까

클래스는 상태와 그 상태에 관련된 동작을 함께 표현한다. 모든 함수를 클래스 안에 넣어야 하는 것은 아니다. 단순 계산은 함수로 충분하고, 반복되는 데이터 구조에 필드 이름과 관련 동작을 붙이고 싶을 때 클래스를 고려할 수 있다. self는 호출 대상 인스턴스를 받는 관례적인 매개변수이며 각 인스턴스의 상태를 구분하는 데 쓰인다.

아래 Cart는 items 리스트를 가진 dataclass다. field(default_factory=list)는 새 인스턴스를 만들 때마다 새 리스트를 제공한다. 두 카트 중 첫 카트에 pen을 추가한 뒤 출력하면 첫 카트만 항목을 가지고 둘째는 빈 리스트다. 기본값으로 하나의 리스트를 여러 인스턴스가 공유하는 실수를 피하는 의도가 코드에 드러난다.

dataclass는 초기화와 표현 등 반복 코드를 줄여 주지만 모든 업무 제약을 검사하지는 않는다. 필드에 list[str] 같은 타입 힌트를 붙여도 일반적인 실행 시점에 모든 원소가 문자열인지 자동 검사되는 것은 아니다. 항목 중복을 허용할지, 개수 제한이 있는지 등은 메서드나 별도 검증 로직에서 결정해야 한다. 상속을 깊게 쌓기보다 구성 요소를 조합하는 편이 단순한 경우도 많다.

예제 코드

from dataclasses import dataclass, field
@dataclass
class Cart:
    items: list = field(default_factory=list)

first = Cart()
second = Cart()
first.items.append("pen")
print(first.items)
print(second.items)

실행 결과

['pen']
[]

타입 힌트: 문서와 도구를 위한 계약

타입 힌트는 매개변수와 반환값의 의도를 표현하고 편집기나 정적 검사 도구가 오류를 찾도록 돕는다. Python 런타임이 일반적으로 타입 힌트를 자동 강제하는 것은 아니다. 아래 repeat의 count: int는 정수를 의도한다는 표시이지만 count에 2.5를 전달하는 일을 호출 전에 막아 주지는 않는다. 실제 문자열 곱셈에서 TypeError가 발생한다.

정상 호출은 hahaha를 출력하고 잘못된 호출은 TypeError라는 오류 이름을 출력한다. 이 예제의 try는 타입 힌트가 직접 검증한다는 오해를 확인하기 위한 장치다. 실제 API가 외부 입력을 받으면 파싱과 검증을 별도로 하고, 내부 코드는 타입 검사 도구로 계약 위반을 조기에 발견하도록 구성할 수 있다. 두 검사는 다루는 경계와 시점이 다르다.

힌트를 많이 붙이는 것 자체보다 프로젝트에서 실제 검사하는 규칙을 유지하는 것이 중요하다. Any로 모든 불편함을 감추면 검사 효과가 줄어들고, 지나치게 복잡한 제네릭은 읽기 비용을 높일 수 있다. 자료형이 여러 단계에서 달라진다면 단계별 타입을 구분하고 반환값의 의미를 문서화한다. 타입 힌트 문법과 라이브러리 기능도 Python 버전에 따라 지원 범위가 달라질 수 있다.

예제 코드

def repeat(text: str, count: int) -> str:
    return text * count

print(repeat("ha", 3))
try:
    repeat("ha", 2.5)
except TypeError as exc:
    print(type(exc).__name__)

실행 결과

hahaha
TypeError

정렬과 그룹별 집계: 순서 기준을 명시하기

정렬은 단순히 보기 좋게 바꾸는 동작이 아니라 비교 기준을 정하는 작업이다. 아래 자료는 이름과 점수의 튜플이다. key에서 음수 점수를 사용해 높은 점수를 먼저 놓고, 점수가 같으면 이름을 오름차순으로 비교한다. 결과는 anna 90, mina 90, jun 70 순서다. 동점 처리까지 명시했으므로 실행마다 우연히 얻은 입력 순서에 의존하지 않는다.

sorted는 새 리스트를 반환하고 원본을 그대로 둔다. list.sort는 리스트 자체를 정렬하고 None을 반환한다. result = records.sort()처럼 작성하고 result를 정렬된 목록이라고 생각하면 이후 코드에서 오류가 생긴다. 두 API는 이름이 비슷해도 상태 변경과 반환 계약이 다르다.

집계가 필요할 때는 collections.Counter나 defaultdict도 유용하다. 그러나 사용자가 보게 될 출력은 키 순서를 정할지, 동점 처리와 빈 입력을 어떻게 표시할지 결정해야 한다. 전체 데이터를 정렬하면 메모리와 시간이 필요하므로 상위 몇 개만 필요할 때는 다른 알고리즘을 고려할 수 있다. 데이터가 몇 개 없는 예제의 실행 속도를 대용량 처리 성능으로 일반화하지 않는다.

예제 코드

records = [("mina", 90), ("jun", 70), ("anna", 90)]
ranked = sorted(records, key=lambda row: (-row[1], row[0]))
print(ranked)
print(records[0])

실행 결과

[('anna', 90), ('mina', 90), ('jun', 70)]
('mina', 90)

동시성과 병렬성: 스레드, 프로세스와 async

여러 요청의 대기 시간을 겹쳐 처리하는 동시성과 여러 계산을 동시에 수행하는 병렬성을 구별한다. 파일·네트워크 입출력 대기가 많은 작업은 스레드나 비동기 방식이 도움이 될 수 있지만, 순수한 CPU 계산이 같은 방식으로 빨라진다고 보장되지는 않는다. 먼저 시간을 어디에 쓰는지 측정해야 한다.

CPython의 전통적인 GIL 사용 빌드는 Python 바이트코드를 실행하는 스레드에 제약을 둔다. 그러나 외부 확장 코드의 GIL 해제 여부, 입출력 대기, GIL을 사용하지 않는 빌드 등 환경에 따라 동작 조건이 다르므로 “Python은 절대 병렬 실행을 못 한다”는 설명은 부정확하다. 프로세스로 나누면 주소 공간을 분리할 수 있지만 자료 직렬화와 전달, 프로세스 시작 비용을 고려해야 한다.

async def는 코루틴을 정의하고 await는 대기 가능한 작업을 기다리는 지점을 표현한다. asyncio를 쓴다고 동기식 파일 읽기나 긴 계산이 자동으로 별도 스레드로 이동하지 않는다. 이벤트 루프를 막는 코드가 있으면 다른 작업의 응답성도 떨어질 수 있다. 취소, 시간 제한, 작업 수 제한, 실패가 다른 작업에 미치는 영향까지 설계해야 한다. 동시성 도구를 도입하기 전에 독립적인 함수와 입출력 경계를 정리하면 대안을 비교하기 쉽다.

실전 예제: CSV 재고를 검증하고 품목별 수량 합산

다음 코드는 product와 quantity 두 열을 가진 CSV를 받아 품목별 수량을 합산한다. 함수는 파일 경로나 출력 장치 대신 텍스트를 받는다. 덕분에 실습에서는 StringIO로 동작하고, 실제 프로그램에서는 파일에서 읽은 텍스트를 전달할 수 있다. 입력 경로와 계산을 분리했으므로 테스트도 외부 파일 없이 실행할 수 있다.

첫 단계는 헤더가 정확한지 확인하는 것이다. 다음으로 DictReader가 만든 각 행을 확인하여 누락되거나 추가된 필드를 거부한다. 제품명은 앞뒤 공백을 제거하고 빈 이름을 거부한다. 수량은 정수로 변환하고 음수를 거부한다. 이 예제는 int가 허용하는 정수 문자열 형식을 받아들이며, 숫자 자릿수나 전체 입력 크기의 상한은 두지 않았다. 운영에서는 데이터 크기와 업무 범위를 추가로 제한해야 한다.

실제 집계는 totals.get(product, 0) + quantity다. pen이 두 번 등장하면 2와 3을 합쳐 5가 되고 book은 1이다. 출력 전에 sorted로 제품명을 정렬하여 결과를 재현 가능하게 만들었다. 잘못된 행을 0으로 간주하고 계속 진행하는 대신 함수 전체를 실패시키므로 호출자는 불완전한 합계를 정상 보고서로 오인하지 않는다. 일부 행을 건너뛰는 요구라면 누락 목록과 처리 건수를 함께 반환하는 별도 계약을 정해야 한다.

for의 line_number는 설명을 위한 레코드 번호에 가까우며, 따옴표 안에 줄바꿈이 포함된 CSV의 실제 물리적 줄 번호와 항상 같지는 않다. 사용자에게 정밀한 위치를 보고하려면 csv 파서의 line_num 등의 의미를 검토해야 한다. 파서와 오류 메시지 사이의 이런 작은 차이도 운영 도구의 사용성에 영향을 준다.

예제 코드

import csv
from io import StringIO

def total_inventory(text):
    reader = csv.DictReader(StringIO(text))
    if reader.fieldnames != ["product", "quantity"]:
        raise ValueError("expected product,quantity header")
    totals = {}
    for line_number, row in enumerate(reader, start=2):
        if None in row or any(value is None for value in row.values()):
            raise ValueError(f"record {line_number}: wrong field count")
        product = row["product"].strip()
        if not product:
            raise ValueError(f"record {line_number}: empty product")
        try:
            quantity = int(row["quantity"])
        except ValueError as exc:
            raise ValueError(f"record {line_number}: invalid quantity") from exc
        if quantity < 0:
            raise ValueError(f"record {line_number}: negative quantity")
        totals[product] = totals.get(product, 0) + quantity
    return totals

sample = "product,quantity\npen,2\nbook,1\npen,3\n"
for product, quantity in sorted(total_inventory(sample).items()):
    print(product, quantity)

실행 결과

book 1
pen 5

테스트: 올바른 값과 거부해야 할 값을 함께 확인

앞의 재고 함수는 정상 중복 품목, 헤더만 있는 빈 자료, 음수, 숫자가 아닌 수량, 필드 수 불일치, 따옴표 안의 쉼표를 각각 확인해야 한다. 출력이 한 번 맞았다는 사실만으로 모든 입력 경로가 검증되지는 않는다. 특히 파싱 함수는 오류가 적절하게 발생하는지도 중요한 동작이다.

아래 테스트는 앞의 total_inventory 함수와 같은 파일에 추가하거나 그 함수를 import하는 테스트 파일에서 실행할 수 있다. assertEqual은 실제 자료 구조와 기대값을 비교하고 assertRaises는 특정 오류가 발생해야 한다는 계약을 검사한다. 따옴표로 감싼 blue, pen을 하나의 제품명으로 처리하는 테스트는 단순 쉼표 분리가 왜 잘못되는지도 보여 준다.

unittest.main은 파일을 테스트 실행 진입점으로 사용할 때 호출할 수 있고, 프로젝트에서는 python -m unittest 같은 명령으로 테스트를 찾고 실행할 수도 있다. 테스트에서 고정 입력만 성공하도록 구현을 베끼기보다 경계 조건과 오류 계약을 골라야 한다. 예를 들어 음수를 거부하는 규칙을 변경하면 정상 예제만이 아니라 해당 정책을 표현한 테스트도 검토한다. 테스트는 오류가 없다는 절대 증명이 아니라 바뀌면 안 되는 동작을 구체적으로 기록한 장치다.

예제 코드

import unittest

class InventoryTests(unittest.TestCase):
    def test_duplicates(self):
        self.assertEqual(total_inventory("product,quantity\npen,2\npen,3\n"), {"pen": 5})
    def test_empty_rows(self):
        self.assertEqual(total_inventory("product,quantity\n"), {})
    def test_negative(self):
        with self.assertRaises(ValueError):
            total_inventory("product,quantity\npen,-1\n")
    def test_invalid_number(self):
        with self.assertRaises(ValueError):
            total_inventory("product,quantity\npen,many\n")
    def test_extra_field(self):
        with self.assertRaises(ValueError):
            total_inventory("product,quantity\npen,1,extra\n")
    def test_quoted_comma(self):
        self.assertEqual(total_inventory('product,quantity\n"blue, pen",2\n'), {"blue, pen": 2})

성능과 메모리: 짧은 코드가 항상 가벼운 것은 아니다

성능을 개선할 때는 먼저 입력 크기와 자주 수행하는 연산을 확인한다. 작은 리스트를 순회하는 코드가 충분한 상황에서 복잡한 캐시와 병렬 처리를 도입하면 유지보수 비용만 늘 수 있다. 반대로 큰 목록에서 membership 검사를 반복하는 구조는 자료 구조를 바꾸는 것만으로 비용 차이가 커질 수 있다. 측정 대상과 대표 입력을 정하는 일이 문법을 짧게 만드는 일보다 먼저다.

CPU 시간이 긴지, 파일·네트워크를 기다리는지, 메모리가 부족한지에 따라 해결책이 다르다. 큰 중간 리스트를 여러 번 만들고 있다면 제너레이터나 단계 결합을 검토하고, 외부 라이브러리의 배열 연산이 핵심이면 해당 라이브러리의 연산 방식과 자료 복사를 확인한다. Python 코드 한 줄이 내부적으로 많은 계산을 실행할 수 있으므로 줄 수만으로 복잡도를 판단하지 않는다.

객체가 더 이상 필요 없다고 생각해도 다른 자료 구조가 참조를 유지하면 메모리 사용이 계속될 수 있다. 전역 캐시, 콜백, 순환 참조, 장기간 살아 있는 작업에 큰 데이터가 연결되어 있지 않은지 살펴야 한다. Python 구현마다 메모리 관리 세부 사항이 다를 수 있으므로 특정 구현의 참조 횟수 감소 시점을 파일이나 네트워크 자원 정리의 유일한 보장으로 의존하지 않는다. 자원 수명은 with 등으로 명시한다.

활용과 한계, 학습 순서

Python은 작은 자동화에서 시작해 라이브러리와 연결된 큰 응용 프로그램으로 확장하기 쉽다. 문자열과 파일을 처리하는 표준 라이브러리만으로 해결할 수 있는 작업도 많고, 외부 생태계가 중요한 분야도 있다. 그러나 설치한 패키지가 모든 운영체제나 인터프리터 버전에 같은 방식으로 제공되는 것은 아니며, 네이티브 확장과 시스템 라이브러리 조건이 배포 난이도에 영향을 줄 수 있다.

엄격한 지연 시간·메모리 제한이 있는 부분은 알고리즘을 바꾸거나 다른 구현 언어의 라이브러리를 호출하는 선택지를 비교한다. 반대로 개발 속도와 유지보수가 중요한 작은 업무 도구는 익숙한 Python 코드가 실용적일 수 있다. 언어의 우열을 선언하기보다 작업의 요구와 팀의 운영 경험, 재현성, 데이터 규모를 기준으로 판단한다.

학습은 값과 이름, 자료형, 분기·반복, 함수, 예외, 파일 처리 순서로 작은 프로그램을 만들며 진행할 수 있다. 다음 단계에서 모듈과 가상 환경, 테스트, 타입 힌트를 추가하면 코드가 커져도 구조를 유지하기 쉽다. 클래스와 비동기는 실제 필요를 설명할 수 있을 때 적용한다. 문법 목록을 외우는 것보다 입력이 잘못되었을 때, 자료가 비었을 때, 같은 객체가 공유될 때 어떤 일이 일어나는지 예측하고 직접 확인하는 연습이 중요하다.

참고 자료