Dictionary!

Dictionary는 파이썬 생태계에서 매우매우 중요한 자료구조입니다.

 

유연하고, 빠르며, 사용하기 쉽습니다.

 

그야말로 만능에 가까운 자료구조입니다.

 

1년 주기로 발표되는 Minor 버전마다 계속해서 성능의 향상이 이루어지고 있을 정도로

 

Python core 개발진과 커뮤니티가 모두 많은 관심을 보입니다.

 

주요 특징은 다음과 같습니다.

  1. 해시 테이블을 활용합니다.
  2. 대부분의 동작에서 평균적으로 O(1)의 시간복잡도를 가집니다.
  3. 불변 객체라면 모두 Key로 넣을 수 있습니다.
  4. 입력 순서를 보장합니다.

Java에도 유사한 역할을 하는 HashMap이라는 자료구조가 있으나,

 

위의 3, 4번 특징은 갖지 못하기에, 유연한 사용은 조금 힘든 편입니다.

 

또한 해시 테이블이 아닌 체이닝 방식으로, 세부적인 구조는 조금 다릅니다.

1. Hash Table 방식

딕셔너리는 해시 테이블 방식으로 구현되어 있습니다.

 

키로 들어온 값을 고유한 해시 값을 생성하여 인덱스로 활용합니다.

 

일반적으로 list, tuple은 list[1]처럼 정수형 인덱스로 값에 접근합니다.

 

name이라는 string 값을 key로 넣는다고 하면,

 

해시 함수를 활용해 5라는 정수 값으로 변환하고 이를 인덱스로 넣는 셈입니다.

 

내부적으로는 다른 시퀀스 자료형에 접근하는 것과 같이 정수형 인덱스로 접근한다는 것이죠.

 

그렇기 때문에 다른 시퀀스 자료형에 인덱스로 접근하는 것과 같이 평균적으로 O(1)이란 시간 복잡도를 가집니다.

 

다만, 해시 충돌이라는 문제를 고려해야 하기 때문에 평균적으로 O(1)이라 표현합니다.

해시 충돌?

두 개의 서로 다른 키 k1, k2가 해시 함수로 변환된 값이 서로 같을 때, 해시 충돌이 일어납니다.

 

충돌이 발생하면 해시 테이블의 시간 복잡도는 O(1)에서 O(n)으로 증가할 수 있습니다.

 

Python의 딕셔너리는 오픈 어드레싱(Open Addressing) 방식을 사용하여 충돌을 해결하며,

 

다음과 같은 방식으로 성능을 유지합니다.

  • 충돌이 발생할 경우, 인접한 빈 슬롯을 찾아 이동(프로빙)하여 데이터를 저장합니다.
  • 이는 충돌이 심하지 않다면 여전히 O(1)에 가까운 성능을 유지하도록 설계되었습니다.
  • 또한 입력되는 값의 수에 따라 동적으로 테이블의 크기를 조정합니다.

따라서, 많은 값이 입력되어도 평균적으로 O(1)에 가까운 성능을 내는 것이죠.

2. 대부분의 동작에서 평균 O(1)의 시간 복잡도

메서드/연산 설명 평균 시간 복잡도 최악 시간 복잡도
d[key] (조회) 키에 해당하는 값을 반환 O(1) O(n)
d[key] = value (삽입) 키-값 쌍을 삽입하거나 값을 업데이트 O(1) O(n)
del d[key] (삭제) 키-값 쌍을 삭제 O(1) O(n)
key in d 키가 딕셔너리에 있는지 확인 O(1) O(n)
len(d) 딕셔너리의 키-값 쌍 개수 반환 O(1) O(1)
d.clear() 모든 키-값 쌍 제거 O(n) O(n)
d.keys() 키 뷰 반환 O(1) O(n)
d.values() 값 뷰 반환 O(1) O(n)
d.items() 키-값 뷰 반환 O(1) O(n)
d.pop(key) 키-값 쌍을 삭제하고 값을 반환 O(1) O(n)
d.popitem() 마지막 키-값 쌍을 삭제하고 반환 (Python 3.7+) O(1) O(1)
d.get(key) 키에 해당하는 값을 반환 (없으면 기본값 반환) O(1) O(n)
d.update([other]) 다른 딕셔너리를 병합하거나 키-값 삽입 O(k) (k는 삽입 개수) O(k)

 

다만, 위에서 말했던 해시 충돌에 대한 대책 덕분에

 

실제로 O(n)이 걸릴 일은 사실상 없다고 봐도 무방합니다.

3. 불변 객체라면 모두 Key로 넣을 수 있습니다.

파이썬은 사실상 모든 객체가 1급 객체 취급이기 때문에 가능한 것이죠.

 

Key로 넣을 수 있는 값은 다음과 같습니다.

  1. int, str, float 등등, 수 많은 기본 자료형
  2. tuple
  3. 함수(메서드)
  4. 클래스에 대한 참조

ListDict 타입은 가변 객체이므로 넣을 수 없습니다!

 

ListDict 모두 1 버전에서 해시하여 dict의 키로 넣었을 때,

 

만약 해당 객체가 변형되어 2 버전이 되어버리면,

 

해당 객체를 해싱했을 때 같은 해시 값이 나오지 않아, 값을 찾을 수 없기 때문입니다.

 

4. 입력 순서를 보장합니다.

입력 순서 보장은, Python 3.6 버전에서 구현이 되었으나,

 

공식적으로 확정된 것은 3.7 버전 부터입니다!

 

애초에 해시 테이블이라 순차적 인덱스로 접근하는 것도 아닌데 이게 왜 필요한가 싶지만,

  1. 순차적 접근이 필요한 때, 별도의 정렬이 필요없게 됩니다.
  2. Json 형태로 파싱될 때, 입력순서가 보장된다면, 보다 가독성 높은 Json 파일로 변환할 수 있습니다.
  3. Print를 통해 값을 출력했을 때 가독성이 보장됩니다.

이러한 발전 덕에 기존에 사용되던 OrderedDict 타입을 굳이 쓸 필요가 없게 되었습니다!

 

정리하자면, 정렬 용이, 데이터 포맷 변환 용이, 디버깅 용이, 코드 간소화 등의 장점이 생긴 것이죠!

요약

Python의 dictionary유연하고 빠르며, 사용하기 쉬운 만능 자료구조로, Python 생태계에서 매우 중요한 역할을 합니다.

  1. 해시 테이블 기반:
    • 키를 해시하여 고유한 인덱스를 생성하고, 이를 통해 빠르게 값에 접근.
    • 평균 O(1)의 시간 복잡도를 가지며, 충돌이 발생할 경우에도 최적화된 방식(Open Addressing)으로 성능 유지.
  2. 대부분의 동작에서 평균 O(1):
    • 조회, 삽입, 삭제 등 대부분의 연산이 평균적으로 O(1)이며, 충돌이 거의 없으므로 O(n)은 사실상 발생하지 않음.
  3. 불변 객체만 키로 사용 가능:
    • int, str, float, tuple, 함수, 클래스 등 해시 가능한 객체를 키로 사용할 수 있음.
    • list, dict와 같은 가변 객체는 키로 사용할 수 없음(변경 시 해시 값 불일치 문제).
  4. 입력 순서 보장:
    • Python 3.7부터 입력 순서를 공식적으로 보장.
    • 정렬, JSON 변환, 디버깅 등에서 데이터 가독성과 사용성을 향상.

장점

  • 효율성: 평균 O(1)의 성능.
  • 유연성: 다양한 객체를 키로 사용 가능.
  • 가독성: 입력 순서 유지로 디버깅과 데이터 변환이 용이.
  • 코드 간소화: 추가 데이터 구조 사용 없이 순차 접근 지원.

Python의 dict는 계속해서 최적화되고 있으며, JSON 변환, 데이터 정렬, 디버깅 등 다방면에서 활용도가 매우 높은 자료구조입니다.

Python 3.13

Python의 정식 넘버링 3.13 버전의 LTS가 지난 10월 7일 릴리즈 되었습니다!

 

17개월의 개발 기간을 가진 이번 3.13 버전은 작년과 마찬가지로 2024년 10월 7일에 공개되었습니다.

 

이번 3.13 버전은 매우매우 실험적이고 중대한 기술의 도입이 이루어졌습니다.

 

1. JIT 컴파일러의 실험적 도입

 

2. GIL을 해제한 free-threded 환경 도입

 

3. 대화형 인터프리터 환경(REPL)의 사용성 증대

 

여기서도 가장 중요한 1번2번에 대해 조금 더 깊게 다뤄보도록 하겠습니다.

JIT 컴파일러의 실험적 도입

JIT(Just In Time) 컴파일러란, 인터프리터처럼 동작하지만 자주 사용되는 코드를 기계어로 컴파일하여 캐싱하는 방법을 통해 성능을 향상시킨 방식1)입니다.

 

인터프리터 방식은 파이썬이 다른 언어들에 비해 느린 여러 이유 중, 가장 큰 지분을 차지한다고 할 수 있습니다.

 

컴파일을 거치지 않으므로 프로그램의 수정과 실행 자체는 빠르게 할 수 있으나,

 

모든 동작에서 코드를 실시간으로 읽고 해석하기 때문입니다.

 

Java 또한 실질적으로 런타임 시에는 인터프리터 방식으로 동작하나,

 

자바 컴파일러가 바이트 코드를 만들어주고 이를 JIT 컴파일을 통해 기계어로 번역하여 실행하기 때문에

 

파이썬에 비해 훨씬 빠른 성능을 보여주는 것이죠.

 

이미 오래 전부터 JIT 컴파일을 통한 Python의 성능 향상은 시도된 바가 있었습니다만,

 

실험적으로라도 정식에 편입될 줄은 상상도 못했습니다.

 

이에 대해 가장 정확하고 자세한 정보를 확인하시려면

 

Python의 JIT 컴파일러에 대한 PEP-744 문서를 확인하시면 좋을 것 같습니다!

아직은 어디까지나 실험 단계

정식으로 편입되었다곤 하지만, 아직도 어디까지나 실험적 도입 단계임을 명심해야 합니다.

 

PEP-744 문서에서는, 프로덕션 환경에서는 절대 사용하지 마라고 못을 박아 두었습니다.

 

예기치 못한 문제가 발생할 수 있고, 다음 버전에서 다시 큰 변화가 생길 수 있기 때문입니다.

 

자세한 사용 방법은 저도 체험하고 다시 포스팅을 진행해보고자 합니다!

GIL 해제 실험적 도입

GIL(Global Interpreter Lock)은 멀티 코어(스레드) 환경에서도 하나의 스레드만 실행되도록 제한하는 상호 배제 락2)입니다.

 

파이썬이 느린 이유 2입니다.

 

GIL이 있어도 강제로 멀티 스레드 환경을 실행할 수는 있지만, 이는 병렬처리가 아닌 동시성 제어를 위한 기능입니다.

 

I/O가 발생했을 때, 스레드가 놀지 않고 다른 일을 하도록 만드는 것이죠.

 

즉, CPU 집약적 작업을 멀티 스레드로 처리할 수 없었습니다.

 

따라서 파이썬에서는 멀티 스레드가 아닌 멀티 프로세스 환경으로 병렬처리를 구현해야 합니다.

 

그러나 두 방식은 동작 원리나 구현 방식이 전혀 다릅니다.

 

또한 같은 수의 워커를 만드는 데에 멀티 프로세싱 환경이 더욱 많은 비용이 들기 때문에

 

보다 적은 리소스로 병렬 처리를 진행할 수 있는 멀티 스레딩 환경에 대한 아쉬움이 남아있던 것이죠.

 

Python의 초기 설계 철학으로 GIL이 존재하게 되었고,

 

시간이 지나며 코어와 GIL의 강결합으로 삭제가 매우 어려운 상태였으나,

 

많은 기여자들을 통해(무려 한국의 LINE 개발자 나동희 님에 대한 감사도 공식 문서에 있습니다!) 가능했다고 합니다.

 

앞으로는 GIL을 완전히 제거하고자 노력 중이라 합니다!

 

사용 방법은 똑같다!

 

앞서 말했듯, 원래 멀티 스레딩 자체는 기존 Python에서도 지원을 하고 있었기 때문에,

 

GIL을 해제한 버전의 Python 빌드를 사용하면, 진정한 멀티 스레딩 병렬 처리를 쓸 수 있게 됩니다!

요약하자면

GIL의 실험적 해제를 통한 병렬 처리 확장

 

JIT 컴파일의 실험적 도입을 통한 성능 확장

 

이 메인이 되는 매우 뜻 깊은 업데이트였습니다!

일급 객체?

컴퓨터 프로그래밍 언어 디자인에서, 일급 객체(first-class object)란 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 가리킨다. 보통 함수에 인자로 넘기기, 수정하기, 변수에 대입하기와 같은 연산을 지원할 때 일급 객체라고 한다. (출처 : 위키백과1))

사실 자주 쓰이는 개념은 아니고, 아직도 논의가 이루어지는 개념입니다.

 

앞선 위키백과의 설명문을 로빈 포플스톤이 정의한 세부적인 항목으로 나타내면 다음과 같습니다.

  1. 모든 요소는 함수의 실제 매개변수가 될 수 있다. (함수의 인자로 전달될 수 있다)
  2. 모든 요소는 함수의 반환 값이 될 수 있다.
  3. 모든 요소는 할당 명령문의 대상이 될 수 있다. (변수에 할당될 수 있다)
  4. 모든 요소는 동일 비교의 대상이 될 수 있다. (is)

다만, 아직도 일급 객체가 정확히 어떤 것인지 애매합니다.

 

그 이유는 현대적인 고급 언어에서 “객체”라는 개념 자체가 보편화되었기 때문입니다.

 

예를 들어, Python, JavaScript, Ruby 같은 언어에서는 함수도 하나의 객체로 취급되며, 자연스럽게 일급 객체처럼 사용됩니다.

 

이 때문에 “객체 = 일급 객체”라는 식으로 동일시되기도 합니다.


일반적으로 객체로 불릴 수 있는 것들을 나열해봅시다.

 

값, 기본 자료형, 복합 자료형, 클래스, 함수(메서드)...

 

이 보다 더 많을 수 있지만, 일단 이 다섯을 중점적으로 설명해봅시다.

 

만약, 어떤 언어에서 어떤 요소가 함수의 인자로 사용되고, 때론 함수의 반환값이 되며,



변수에 할당할 수 있고, 동일 비교의 대상이 된다면, 해당 요소는 일급 객체의 요건을 만족합니다.

Python 코드로 알아보기

모든 것은 객체로 이루어진 파이썬에서는, 사실 정말 거의 모든 것이 일급 객체입니다.

 

10이라는 숫자조차도 원시 타입이 아닌 int라는 클래스로 지정된 객체가 됩니다.

 

아주 한정적으로 원시 타입을 쓸 수 있기도 하지만, 사실상 파이썬의 모든 객체는 일급객체 입니다.

기본, 복합 자료형의 경우

기본 자료형은 매우 간단합니다.

 

굳이 설명이 필요하지 않을 것 같지만, 코드로 나타낸다면 다음과 같습니다.

# 할당 명령문의 대상이 됨
x1 = 10 # Python에서는 10이란 정수 값도 참조 클래스 기반 객체입니다.
l1 = [1, 2] # 복합 자료형 List

def function(x):
    if x == 10 or x == [1, 2]: # 동일 비교의 대상이 됨
        return x # 함수의 반환 값이 됨

x2 = function(x1) # 함수의 매개 변수가 됨
l2 = function(l1)

 

위의 코드는 매우 정상적으로 실행됩니다.

 

정수 자료형 x1과 복합 자료형 l1 모두 function 함수의 인자로 들어갈 수 있으며,

 

동일 비교(equals)의 대상이 되고,

 

함수의 반환 값으로 쓰일 수 있습니다.

함수(메서드)의 경우

Python에서 함수를 일급 객체 취급을 함으로써 가능한 것이 대표적으로 두 가지 있습니다.

  1. 데코레이터를 만들 때, 함수의 인자로 또다른 함수를 넣곤 합니다.
  2. 람다 표현식 자체가 함수를 일급 객체로 취급하기에 가능한 것이기도 합니다.
def deco(func):  # 함수의 매개 변수가 됨
    def wrapper():
        print("목표 함수 실행 이전")
        func()
        print("목표 함수 실행 이후")
    return wrapper # 함수의 반환 값이 됨

@deco
def hello():
    f = lambda x : print(f"{x} - hello world") # 변수에 람다 표현식(함수)를 할당 함
    f("cat") # 람다 함수에는 반드시 인자가 필요하기에 임시로 넣은 값입니다.

h = hello  # 데코레이터가 적용된 함수 자체를 새로운 변수에 할당
h()

print(h == hello)  # 함수 간에도 equals가 가능함

# 목표 함수 실행 이전
# cat - hello world
# 목표 함수 실행 이후
# True

 

함수 또한 다른 함수의 인자로 들어가거나, 반환값이 될 수 있고

 

함수 자체를 변수에 할당하거나 대소 비교가 가능합니다.

클래스의 경우

당연히 클래스도 가능합니다.

 

클래스 자체가 일반적으로 객체를 이르는 말이긴 합니다만,

 

클래스에 대한 참조도 객체로써 활용될 수 있습니다.

class Cat:
    def meow(self):
        print("Nyang")

Dog = Cat() # Cat의 인스턴스를 생성해 meow에 할당합니다.
Dog = Cat   # Cat 클래스에 대한 참조 자체를 할당합니다.

cat = Dog() # 이는 매우 정상적으로, Cat 클래스 인스턴스를 생성해 cat에 할당합니다.

cat.meow()  # Nyang

from typing import Type

def factory(Cls : Type[Cat] ): # Cat 클래스에 대한 참조를 넣습니다
    x = Cls()
    return x

cat2 = factory(Cat)

cat2.meow() # Nyang

 

del?

x = 10

del x

print(x) # NameError 발생

Python에서 직접적으로 변수의 네임스페이스 참조를 제거하고,
참조 카운트를 감소시키는 명령어
입니다.

 

어디에 쓰지?

엄밀히 말하면, 현대의 Python에서는 del 명령어를 굳이 쓸 이유가 별로 없습니다.

 

Python을 오래 써오신 분들도 del이란 명령어의 존재 자체는 알지만, 실제 개발에서 잘 사용하진 않습니다.

 

가비지 컬렉터(GC)가 알아서 다 해주기 때문입니다.

 

GC를 튜닝하거나 아예 끄지 않는 이상, 크게 필요가 없는 것이죠.

 

(GC를 끄고 개발할 수 있는 사람들은 그냥 C/C++을 쓰겠죠?)

 

복합 자료형에서 특정 객체를 삭제하는 데에 쓸 수도 있지만, 이미 여러 메서드가 이 역할도 수행하고 있습니다.

먼저 그나마 유용한 방법에 대해서

  1. 복합 자료형에서 특정 객체를 삭제
  2. (사실상) 모든 것을 삭제

여기서 1번은 코딩 테스트에서 꽤나 유용하게 쓰이나, 2번은 잘못 쓰면 매우 위험한 존재입니다.

복합 자료형에서 특정 객체 삭제

리스트

arr = [10, 20, 30]

del arr[0]

print(arr) # [20, 30]

 

이렇게, 해당 객체에 직접 접근해서 네임 스페이스를 해제하여, 객체 자체를 삭제할 수 있습니다.

 

pop처럼 값을 반환 받을 필요가 없고, 명시적으로 삭제를 행하고 싶다면 선택할법 합니다.

딕셔너리

딕셔너리의 경우, 특정 key로 접근한 객체의 value만 삭제하는 것이 아닌,

 

key-value모두 삭제합니다.

 

만약 키는 남겨두고 싶다면, dcit[key] = None 을 씁시다.

d = {10:3, 20:6, 30:9}

del d[10]

print(d) # {20:6, 30:9}

복합 자료형 자체를 삭제

arr = [10, 20, 30]

del arr

print(arr) # NameError 발생

 

이렇게 객체 자체를 삭제하는 것이 가능합니다.

 

그리고 클래스, 변수, 함수 모든 것들이 1급 객체로 관리되는 파이썬에서는...

(사실상) 모든 것을 삭제

예... 클래스와 함수, 변수 모두 삭제할 수 있습니다.

 

삭제할 수 있는 항목은 다음과 같습니다.

  1. 클래스 자체
  2. 클래스 속성, 인스턴스 속성
  3. 클래스 메서드
  4. 전역 함수
  5. 기본 모듈 및 함수 (sum, print 등등...)
  6. 모든 변수
# 클래스 삭제
class Spam:
    pass

del Spam  # 이제 Spam은 존재하지 않습니다.

# 전역 함수 삭제
def hello():
    print("안녕하세요!")

del hello  # hello 함수가 삭제되었습니다.

# 기본 함수 삭제
del print  # 이제 print를 사용할 수 없습니다.

 

그러나 이러한 반컴퓨터적 파괴행위가 딱히 개발 인생에 쓸모는 없을 것 같습니다...

 

이제 조금 심화된 메모리와 관련된 내용으로 넘어가도록 합시다!

 

GC와 메모리 관리에 관한 내용입니다.

짧은 Python의 GC 설명

Python의 GC는 Java와는 조금 다르게 작동합니다.

  1. 객체의 참조 카운트를 확인하고, 0이 될 경우 메모리를 해제시킵니다. 순환 참조 탐지도 이루어집니다.
  2. 3개의 세대로 나누어, 각각의 주기에 따라 객체를 체크하고 메모리를 수집합니다. (Java와 유사합니다.)

del 문은 엄밀히 말하면 메모리를 해제하는 것이 아니라 앞서 말씀드렸듯

 

네임 스페이스를 제거하고, 참조 카운트를 0으로 만듭니다.

 

다만 참조 카운트가 0이 될 경우, GC가 꺼져 있어도 메모리가 해제되긴 합니다.

 

그러나 순환 참조 탐지가 이루어지지 않아 메모리 누수 위험이 있습니다.

GC를 튜닝하거나, 아예 끄는 경우

Python의 GC는 분명 많은 최적화가 이루어졌지만, 특정 상황에선 튜닝하는 게 훨씬 좋은 성능을 내곤 합니다.

 

이 때, del은 의도적으로 특정 변수를 삭제하여 매우 세밀한 메모리 조정에 사용할 수 있습니다.

대용량 파일을 여는 경우

일반적으로 대용량 csv 파일이나 이미지 파일, 문서 파일을 여는 경우 with 블럭을 사용합니다.

with open('대용량.csv', 'r') as f:
    df = f.read()

이는 매우 강력히 권장되는 방법입니다.

with블럭을 쓰지 않을 경우, f라는 파일 자체에 대한 변수에 반드시 .close()를 해주어야 합니다.
그렇지 않을 경우, 원본 파일의 데이터가 소실, 변형되는 치명적 문제를 야기할 수도 있습니다.

 

그렇다면, fread()하여 값을 담은 df라는 변수는 with 블럭 밖에서 어떻게 될까요?

 

당연하지만 with 블럭 밖에서 여전히 값을 가지고 쓸 수 있는 변수입니다.

 

f라는 변수에 할당된 파일의 원본 데이터만이 with블럭을 통해 제어되는 것입니다.

 

그런데 만약, df에서 값 몇 개만 가져오기만 할 거라면?

 

100mb가 넘는 csv 파일이라면?

del df

 

데이터를 모두 가져와서 변수에 할당했다면, df를 통해 메모리 상에서 데이터를 명시적으로 삭제할 수 있습니다.

 

사실 알아서 GC가 메모리를 해제해주긴 합니다...

 

어디까지나 명시적인 코드를 위한 방안인 것이죠.

컴프리헨션(Comprehension)?

Python이 강력히 권고하는 복합 자료형(list, dict, set)의 생성 방식입니다.

Python Docs에서는 함수형 프로그래밍 언어 Haskell에서 빌린 표기법이라고 합니다. (빌린..?)

 

간단하게, 0부터 1억-1까지의 값을 순차적으로 넣은 리스트를 생성한다고 가정해봅시다.

lst = []

for i in range(100_000_000): # 천의 단위마다 끊어서 보기 좋게 표현합니다.
    lst.append(i)

 

이러한 방식을 쓰는 것에도 딱히 문제는 없지만, 생각보다 비효율적입니다.

초기 배열을 append로 생성하는 것은 느리다.

Python의 List는 기본적으로 동적 배열입니다.

 

초기에는 일정한 메모리를 할당하고 값의 추가로 인해 해당 메모리의 한계를 초과하면,

 

보다 큰 새로운 메모리 공간을 할당하여 기존 값을 복사해 넣습니다.

 

즉, 0의 길이로 시작한 리스트에 값이 하나씩 추가되면

 

할당된 메모리의 한계에 도달할 때마다, 새로운 메모리 공간을 할당하여 기존 값을 복사하는 과정이 일어납니다.

 

이 한계의 확장과 메모리 이동은 당연히 오버헤드를 발생시킵니다.

 

또한, 컴프리헨션은 하나의 표현식이므로, 바이트코드로 변환될 때 보다 최적화되어 빠른 성능을 보여줍니다.

 

처음부터 배열의 최대 크기를 알기 때문에 불필요한 새 리스트 생성 / 복사 과정이 일어나지 않는 것이죠.

 

문자열에서 특정 문자를 찾을 때 find 메서드를 사용하는 것과

 

for루프equals를 통해 구현하는 것이 상당한 성능차이가 나는 것도 이 부분이 어느 정도 관여합니다.

컴프리헨션을 쓴다면?

lst = [i for i in range(100_000_000)]

 

결과적으로 같은 lst를 만들지만, 코드도 간단하고 훨씬 직관적입니다.

 

또한, 리스트를 초기화하는 과정에서 몇 개의 요소를 넣을지 미리 알 수 있기 때문에

 

처음부터 해당 요소를 모두 넣을 수 있는 메모리를 할당합니다.

 

즉, 빈번한 메모리 확장(재할당) 및 복사가 이루어지지 않습니다.

import time

# append 방식

lst = []
start = time.time()

for i in range(100_000_000):
    lst.append(i)

end = time.time()
print(round(end-start,4)) # 2.860

# 컴프리헨서

start2 = time.time()

lst2 = [i for i in range(100_000_000)]

end2 = time.time()

print(round(end2-start2, 4)) # 1.282

 

보시다시비 시간이 약 45퍼센트 수준으로 단축된 걸 확인할 수 있습니다.

 

그러나 실제 백엔드 서버에서 이러한 코드를 짤 일은 별로 없죠...

 

혹시라도 있다 해도 그 때는 Numpy/Pandas가 압도적인 효율을 내줍니다.

 

기본 Python만 쓸 수 있는 코딩 테스트로 넘어가서,

 

0으로 초기화된 2차원 배열을 만들어 봅시다!

 

2차원 배열

start3 = time.time()
lst3 = []

for _ in range(10_000):
    tmp_lst = []
    for _ in range(10_000):
        tmp_lst.append(0)
    lst3.append(tmp_lst)

end3 = time.time()

print(round(end3-start3, 4)) # 3.047


start4 = time.time()

lst4 = [[0 for _ in range(10_000)] for _ in range(10_000)]

end4 = time.time()

print(round(end4-start4, 4)) # 1.555

 

역시 두 배 가까이 차이가 나는 것을 알 수 있습니다.

 

온몸 비틀기라도 필요한 시점이라면, 꽤나 유용하게 쓸 수 있을 겁니다!

 

숏코딩에도 당연히 필요합니다!

조건식이 달려도 여전합니다.

lst = []
start = time.time()

for i in range(100_000_000):
    if i//2 == 0:
        lst.append(i)

end = time.time()

print(round(end-start,4)) # 3.508


start2 = time.time()

lst2 = [i for i in range(100_000_000) if i//2 == 0]

end2 = time.time()

print(round(end2-start2, 4)) #2.645

 

물론, 이 경우에는 컴프리헨션도 최종 리스트의 길이를 알지 못하기 때문에, 메모리를 동적으로 늘려나가야 합니다.

 

다만 최대 길이는 알고 있으므로 보다 최적화된 동적 메모리 할당이 가능합니다.

 

그리고 표현식 자체가 여전히 최적화된 바이트코드로 변환 가능하므로, 빠릅니다.

 

가능하고, 의도에 맞다면 컴프리헨션을 적극적으로 사용하는 걸 추천드립니다.

 

다른 복합 자료형들도 가능합니다.

# dict
d = {i : 1 for i in range(100)}

# set
d = {s for s in range(100)}

 

혹시라도 소괄호()로 감싸면 튜플 컴프리헨션 아니냐? 하실 수 있겠으나, 이건

제네레이터 표현식

입니다!

 

리스트 컴프리헨션과 상당히 다른 표현식입니다.

 

모든 객체를 생성해서 메모리에 로드하는 것이 아니라,

 

객체에 접근하는 시점에 해당 인덱스의 표현식에 따라 값을 계산하는 지연 평가(lazy evaluation)을 사용합니다.

 

즉, 제네레이터 자체는 메모리를 적게 사용하나, 값을 읽는 속도는 비교적 느립니다.

 

코테 용도라면, 대부분의 경우 메모리보다 속도가 중요하므로(그리고 애초에 리스트에 표현식을 넣을 일이 없으므로)

 

리스트 컴프리헨션을 써야 합니다.

 

간단히 정리하자면 다음과 같습니다.

 

리스트 컴프리헨션 제너레이터 표현식
[]로 감싸서 작성됨 ()로 감싸서 작성됨
모든 요소를 한 번에 메모리에 로드 요소를 필요할 때마다 하나씩 생성
더 빠른 접근과 반복 작업 가능 메모리 사용이 적고 대용량 데이터 처리에 적합

 

Session.close()

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r)

연결 풀(QueuePool)의 크기 제한(5개)을 초과하여 오버플로(10개)가 발생했습니다. 연결이 제한 시간을 초과하여 타임아웃되었습니다. (제한 시간: 30초)

session.close()는 sqlalchemy에서 매우 중요한 명령어입니다.

기본적으로 Python에서 .close()는 매우 중요합니다.

Python에서 close()는 매우 중요한 명령어입니다.

 

open() 함수는 시스템 호출(system call)을 통해 파일을 엽니다. 따라서 여러 파일을 동시에 열어 사용할 때, close()를 통해 파일 점유를 해제하는 과정은 필수적입니다.

 

with 블록이나 .close() 메서드를 사용하여 자동적(컨텍스트 매니저를 통한) 또는 명시적으로 파일이 점유한 메모리를 반드시 해제해야 합니다.

 

Python의 가비지 컬렉터는 참조 카운트(reference count)가 0이 되면 메모리를 자동으로 해제하지만, 파일 처리의 경우 다음과 같은 문제가 발생할 수 있습니다:

 

  1. 파일의 버퍼에 담긴 데이터가 close() 호출 시점에 디스크에 저장되므로, close()가 명시적으로 이루어지지 않으면 데이터 유실이나 원치 않는 변경이 발생할 가능성이 있습니다.
  2. 열린 파일 핸들이 제대로 해제되지 않으면 리소스 누수가 생길 수 있습니다.

SQLAlchemy의 Session도 마찬가지입니다.

engine = create_engine(...)

Session = sessionmaker(bind=engine)

with Session() as db:
    # OR

db = Session()
db.add(...)
db.commit()
db.close()

 

세션 팩토리를 펑션 콜 하는 순간 Sessionopen()되고, with 블럭을 탈출하는 순간 session.close()가 이루어집니다.

 

또는 후자의 방식대로 명시적인 close()를 해줘야 합니다.

 

session(db)의 사용을 마친 뒤, 이를 닫지 않으면 문제가 발생하게 되는데요.

 

커넥션 풀링 방식을 쓰는 SQLAlchemy는 DB와의 커넥션을 한번 쓰고 삭제하지 않습니다.

 

커넥션은 유지하되, 요청이 들어올 때마다 session은 커넥션 풀에 존재하는 미점유 상태의 커넥션을 가져와 사용합니다.

 

원활한 쿼리를 위해서는 session사용이 끝난 뒤, 커넥션에 대한 점유를 해제하여 커넥션 풀로 빠르게 돌려보내야 하는 것이죠.

 

그렇지 않는다면 session은 요청이 끝난 뒤에도 계속 커넥션을 붙잡고 있기 때문에

 

새로운 요청이 들어왔을 때 사실상 놀고 있는(무의미하게 점유당한) 커넥션을 사용하지 못합니다.

 

그렇기 때문에,

Session을 닫지 않으면 커넥션을 계속 생성합니다.

커넥션에도 당연히 최대 개수가 존재하고, 이는 SQLAlchemy나 MySQL같은 DBMS에도 존재하죠.

 

MySQL의 경우, 151개로 상대적으로 넉넉한 편이지만,

 

별 다른 설정이 없는 경우 SQLAlchemy는 다음과 같은 설정을 가집니다.

pool_size=5,       # 기본 커넥션 수
max_overflow=10,   # 추가로 생성할 수 있는 임시 커넥션 수
pool_timeout=30    # 사용할 수 있는 커넥션이 없을 때, 기다리는 시간(초)
# 세션을 닫지 않은 채로 계속해서 DB에 쿼리를 보내는 요청을 보내고 그 결과를 프린팅하고 있습니다.
Pool size: 5  Connections in pool: 3 Current Overflow: 10 Current Checked out connections: 12
INFO:     127.0.0.1:50423 - "GET / HTTP/1.1" 200 OK
Pool size: 5  Connections in pool: 2 Current Overflow: 10 Current Checked out connections: 13
INFO:     127.0.0.1:50423 - "GET / HTTP/1.1" 200 OK
Pool size: 5  Connections in pool: 1 Current Overflow: 10 Current Checked out connections: 14
INFO:     127.0.0.1:50423 - "GET / HTTP/1.1" 200 OK
Pool size: 5  Connections in pool: 0 Current Overflow: 10 Current Checked out connections: 15

 

5개의 요청이 빠르게 들어오면 서버 쪽에서는 기본 커넥션 풀의 크기를 채워버립니다.

 

또한 추가로 생성하는 임시 커넥션도 10개 밖에 안 되므로, 매우 빠르게 임시 풀도 차버립니다.

 

총 15개의 세션이 모두 차버린 채로 새 요청을 받게 되면, 30초간 세션(커넥션 풀에서 놀고 있는 커넥션)을 기다립니다.

 

그리고 30초의 시간이 지날 때까지 세션을 가져오지 못할 경우

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r)

 

에러가 발생하는 것이죠.

 

당연하지만 커넥션을 닫지 않았기 때문에 연결된 DB에도 커넥션이 무의미하게 존재하고 있습니다.

반드시 with, finally를 통해서 close()를 시켜줍시다.

방법은 여러가지가 있지만, FastAPI가 공식 Docs에서 권장하는 방법이 매우 좋습니다.

 

FastAPI는 다음과 같은 연결 방식을 권장합니다.

from sqlalchemy import create_engine, Integer
from sqlalchemy.orm import sessionmaker, DeclarativeBase

DB_URL = f'mysql+pymysql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}'
engine = create_engine(url=DB_URL)
session_maker = sessionmaker(bind=engine, autoflush=False)

async def get_db():
    db = session_maker()
    try:
        yield db
    finally:
        db.close()

app = FastAPI()

class Base(DeclarativeBase):
    pass

class Item(Base):
    __tablename__ == "items"
    item_id = Column(Integer, primary_key = True)

@app.get('/item')
async def get_item(n, db = Depends(get_db)):

    item = db.query(Item).get(n)

    return item

 

세션을 요청마다 주입받아 사용하고, 요청이 끝날 경우 강제로 close()를 호출하는 것이죠.

SQLAlchemy + MySQL 10054 Error & MySQL 2006 Error

Lost connection to MySQL system error: 10054 An existing connection was forcibly closed by the remote host

Error Code: 2006 - MySQL server has gone away

 

SQLAlchemy와 FastAPI 또는 Flask를 활용하여 백엔드 서버를 구축할 때, 어쩌면 한 번쯤은 만났을 수도 있는 에러입니다.

 

문제는 어쩌다가 딱 한 번 발생하고, 한 번 발생한 후에는 문제없이 서버가 동작한다는 것입니다.

 

에러 자체는 여러 환경에서 발생할 수 있지만, SQLAlchemy를 사용한다면 하나의 대표적인 원인을 꼽을 수 있습니다.

 

DB에서는 폐기한 Connection을 SQLAlchemy(server 단)에서 사용하려 했기 때문입니다.

Connection Pooling

SQLAlchemy는 DB와의 통신을 위해 Connection Pooling 방식을 사용합니다.

 

Connection은 DB와 Server가 각각 연결한 대상에 대한 정보입니다.

 

이러한 Connection은 생성 시에 연결, 인증, 권한확인 등, 여러 절차를 거쳐야 하기에 오버헤드가 큰 작업입니다.

 

모든 query마다 Connection의 생성/삭제를 거칠 경우 안 그래도 심한 병목현상이 심해질 것입니다.

 

이를 위해, 변경 사항이 없을 경우 일종의 캐시처럼 한번 연결된 Connection을 재사용합니다.

 

그리고 SQLAlchemy 또한 이러한 Connection을 생성한 뒤 Pool에 넣어두고,

 

요청마다 Pool에 존재하는 Connection을 가져와 사용하는 것입니다.

 

요청이 정상적으로 수행되고 session을 close()하면 Connection은 다시 Pool로 반환됩니다.

 

마치 프로세스가 CPU를 점유하는 것처럼, session은 Connection을 점유하는 것입니다.

 

여기서, 두 가지 대표적인 문제가 발생합니다.

 

1. close()를 수행하지 않아, session이 connection을 반환하지 못하고 계속 점유하는 경우

 

2. sqlalchemy와 DB의 connection 유지 기간이 달라 통신 에러가 발생하는 경우.

 

이번 포스팅에서는 2번 문제를 다루도록 하겠습니다.

Connection의 유지 기간

먼저, SQLAlchemy의 Connection의 특징을 짚고 넘어가겠습니다.

  1. 풀에 사용할 수 있는 커넥션이 없을 경우, db에 커넥션 생성 요청을 보내고 정상적으로 생성이 된 경우 이 커넥션 정보를 풀에 넣는다.
  2. 사용할 수 있는(비어있는, 점유 가능한) 커넥션이 있을 경우, 이를 활용해 db에 접근한다.
  3. 커넥션은 별도의 설정이 없는 경우 영구히 유지된다.
  4. SQLAlchemy(Python 프로그램)과 DB는 서로의 상태를 모른다.

3번과 4번 때문에 문제가 발생합니다.

 

MySQL(MariaDB)의 Connection 타임 아웃은 8시간(28800초)입니다.

 

다만, 타임 아웃 전에 한 번이라도 사용될 경우 타임 아웃이 초기화됩니다.

 

그런데 SQLAlchemy의 경우, 별도의 설정이 없으면 Connection은 영구히 유지됩니다.

 

즉, DB와 연결된 서버를 8시간 이상 열어놓지만 요청이 없어, DB측 커넥션이 타임아웃 된 경우에 문제가 발생합니다.

 

DB는 이미 폐기한 Connection을 SQLAlchemy가 사용했기 때문에

Lost connection to MySQL system error: 10054 An existing connection was forcibly closed by the remote host

MySQL 연결이 끊어졌습니다: 시스템 오류 10054 - 기존 연결이 원격 호스트에 의해 강제로 종료되었습니다.

 

이렇게 원격 연결을 DB측에서 닫아버린 것이죠.

 

10054에러를 반환 받았으니 SQLAlchemy도 Connection을 Pool에서 제거합니다.

 

그러나 이러한 문제가 발생했음에도 SQLAlchemy는 다시 요청을 보내지 않습니다.

 

다음 요청에서는 커넥션 풀을 새로 생성해서 통신하기 때문에 문제가 발생하지 않는 것처럼 보입니다.

 

이제 문제를 파악했으니 해결해봅시다.

해결 방법

해결 방법은 여러 가지가 있습니다.

 

먼저 SQLAlchemy 단에서 해결하는 방법입니다.

1. pool_pre_ping

engine = create_engine("mysql+pymysql://user:pw@host/db", pool_pre_ping=True)

 

모든 요청마다 먼저 "Select 1"과 같은 쿼리를 던져 Connection 상태를 확인

 

그야말로 돌다리도 두들겨보고 건넌다는 느낌입니다.

 

아무리 가벼운 쿼리여도 쿼리를 보내는 수 자체가 많아지면 오버헤드가 될 수 있다는 단점이 있습니다.

2. pool_recycle

engine = create_engine("mysql+pymysql://user:pw@host/db", pool_recycle=3600)

 

Connection에 타임 아웃을 설정해 자동으로 새 Connection을 생성

 

커넥션이 생성된 뒤, 요청 없이 3600초(1시간)이 지나면 타임아웃시키고 새 커넥션을 생성합니다.

 

이를 통해 커넥션이 오랜 기간 대기하며 발생하는 문제를 줄일 수 있습니다.

 

이 방법이 가장 좋은 방법이라 생각합니다.

3. 크론잡/ 배치를 활용한 강제 갱신

DB의 Connection timeout이 일어나기 전, 크론잡 등을 활용해 자동으로 쿼리를 던져 커넥션을 갱신하는 방식입니다.

 

1번 방법과 2번 방법을 적절히 섞은 방법이긴 한데, 굳이?인 방법입니다.

 

개인적으로는 2번 방법을 선호하는 편입니다.

4. wait_timeout 설정

SHOW VARIABLES LIKE 'wait_timeout';

 

MySQL(MariaDB)에서는 위의 명령어를 통해 타임아웃을 확인할 수 있습니다.

 

당연히 재설정도 가능하니, 이 시간을 보다 길게 잡아 커넥션의 유지 시간 자체를 늘리는 것도 방법입니다만,

 

그다지 좋은 방법은 아닌 것 같습니다...

Python은 접근 제어자가 없다.

애석하게도, 파이썬으로 작성한 코드는 어디서든 접근할 수 있습니다.

 

숨기는 것과 "이 요소에는 접근하지 마시오" 라고 명시하는 것은 가능하지만, 여전히 접근이 쉽습니다.

 

Java에서 public, private 등의 접근 제어자를 활용해 요소 마다 접근 가능 범위를 수 있는 것과는 상당히 대비됩니다.

 

(물론 Reflection을 적극 활용할 경우, 얼마든지 접근이야 가능합니다.)

 

그럼에도, 숨기기 정도와 접근하지 마라고 일러주는 것까지는 가능합니다.

언더 스코어를 통한 접근 제어 표기

Python에서 클래스 내부 요소를 숨기기 위해서 보통 언더 스코어(언더바, _)를 활용합니다.

 

이를 활용한 네이밍 컨벤션은 클래스 내부 값 뿐만 아닌 함수와 내부 클래스에도 적용이 가능합니다.

 

변수, 함수, 클래스 모두 1급 객체이므로 취급이 같기 때문이죠.

_ 한 개로 시작하는 요소

클래스 내부와 해당 클래스를 상속받은 클래스의 내부 요소로만 사용하기를 권고

 

어떠한 강제도 없고, 코드 상 제약도 없는 그저 권고의 의미일 뿐입니다.

 

Java와 비교하자면, protected 접근 제어와 유사한 범위를 의미한다고 볼 수 있습니다.

 

__ 두 개로 시작하는 요소 (mangling)

클래스 내부에서만 사용하기를 어느정도 강제

 

Java의 private 접근 제어자와 동일한 범위를 뜻합니다.

 

이 경우, 런타임 단계에서 name mangling이 되어 직접 접근을 막아줍니다.

 

이를 통해 상속 관계에서 이름의 충돌을 막아주는 역할도 합니다.

class Spam:

    def __init__(self):
        self.x = 1
        self._y = 2
        self.__z = 3

spam = Spam()

spam.x     # 1
spam._y    # 2 / 강제성이 없다.
spam.__z   # AttributeError: type object 'Spam' has no attribute '__z'

 

이렇듯, _y에는 문제없이 접근 가능하지만, __z는 직접적인 접근 자체가 막힙니다.

 

다만, IDE 기능으로 내부 코드를 뜯어 요소를 확인할 수 있고, 이를 알면 우회적으로 접근할 수 있습니다.

class Spam:

    def __init__(self):
        self.x = 1
        self._y = 2
        self.__z = 3

spam = Spam()

spam._Spam__z  # 에러가 발생하지 않는다.

 

{ClassName}._{ClassName}__{attribute} 와 같은 식으로 만들면 외부에서도 접근할 수는 있습니다.

 

물론, 해당 코드 설계자의 의도에 완전히 반하는 행동인만큼, 쓰지 맙시다!

그럼 repr 이건 뭐예용?

매직 메서드입니다.

 

__repr__은 객체의 "개발자용 표현"을 정의하는 매직 메서드로, 디버깅이나 로깅 시 객체를 어떻게 보여줄지 결정합니다.

 

일반적이지 않은 특수한 동작을 원할 경우 이를 오버라이드하여 원하는 방식으로 동작하게 만들 수 있습니다.

 

예를 들어, 객체의 대표 이름을 출력하고 싶은 경우, 클래스 내부에 __repr__ 메서드를 오버라이드하여 마음대로 바꿀 수 있죠.

 

class Spam:

    def __repr__(self):
        return "언제부터 내가 스팸일 거라고 생각했지?"

spam = Spam()
print(spam)  # 언제부터 내가 스팸일 거라고 생각했지?

 

따라서, 앞뒤로 언더스코어 2개가 오는 경우는 접근 제어의 의미는 아닙니다.

 

물론 둘 다 멋대로 가져다 쓰다가 낭패보기 쉽다는 건 같으니, 잘 알아보고 써야합니다!

Python의 class의 속성과 객체의 속성은 별개입니다.

선요약

class의 __init__ 즉, 생성자에서 할당하는 것이 아닌,

 

class 자체에 선언된 속성은 instance.attribute가 아니라 instance.__class__.attribute에 존재합니다.

 

다만 instance.attribute에 무언가를 할당한 상태가 아니라면, instance.attribute를 통해 접근이 가능하긴 합니다.

 

class로 생성할 객체마다 변하기 쉬운 기본 속성을 주고 싶다면, 클래스 속성이 아닌 인스턴스 생성자에 기본값을 설정합시다.

 

class 하나를 만들어봅시다.

class Cat:
    race = 'Domestic Shot hair'

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def change_race(self, race):
        self.race = race

 

클래스의 기본 속성으로 도메스틱 숏 헤어라는 종 분류가 들어갑니다.

 

생성자에서 이름과 나이를 설정할 수 있으며,

 

메서드를 통해 race변경 할 수 있는 것처럼 만들어놨습니다.

 

위와 같은 코드는 만들면 안 됩니다.

 

change_race 메서드는 과연 어떻게 동작할까?

oia_cat = Cat('oai', 2)
huh_cat = Cat('huh', 3)

oia_ca.change_race('american shot hair')

 

과연 위의 코드는 어떤 동작을 의미할까요?

  1. Cat 클래스의 클래스 속성인 race 값이 변하여, huh_cat의 race도 바뀌었다.
  2. oia_cat 객체(인스턴스)의 race 값만 변한다. huh_cat은 그대로다.
  3. 둘 다 아니다.

정답은 3번입니다.

 

겉으로 드러나는 부분만 보면 2번이 될 수도 있지만, 파고 들면 아예 다릅니다.

 

class 속성은 모든 인스턴스가 공유한다.

class의 속성은 class의 인스턴스 전체가 공유하는 변수입니다.

# 위의 코드에서 이어진다.

print(oia_ca.race)
print(huh_cat.race)

# ======
american shot hair
domestic shot hair

 

보다시피 Cat 클래스의 두 객체의 race는 다릅니다.

 

앞서 말씀드렸다시피, 한 클래스의 모든 객체가 공유해야 하는 값이므로 서로 달라서는 안 됩니다.

 

단순히 값이 같은 수준(==)이 아닌, 하나의 메모리에 존재하는 똑같은 객체 (is)가 되어야 합니다.

 

어째서 같은 클래스인데 클래스 속성이 다른가?

chage_race 메서드는 클래스 속성인 race의 값을 바꾼 것이 아닌, 인스턴스 속성 스페이스에 race 값을 생성했기 때문입니다.

 

클래스 속성과 객체 속성은 다른 스페이스에 선언되어 있습니다.

 

클래스 속성에 보다 명시적으로 접근하기 위해서는,

 

{class_instance}.__class__.{attribute}로 접근 할 수 있습니다.

 

(당연하지만, getter를 만드는 게 최고입니다.)

 

같은 변수명으로 객체 속성을 선언하지 않는 이상, {class_instance}.attribute

 

{class_instance}.__class__.attribute를 불러옵니다.

 

코드로 설명하면 다음과 같습니다.

oia_cat = Cat('oia', 2)
huh_cat = Cat('huh', 3)

print(oia_cat.race is oia_cat.__class__.race) # True
print(oia_cat.race is huh_cat.race) # True
print(oia_cat.__class__.race is huh_cat.__class__.race) # True

oia_cat.change_race('american shot hair')

print(oia_cat.race is oia_cat.__class__.race) # False
print(oia_cat.race) # american shot hair
print(oia_cat.__class__.race) # domestic shot hair
print(huh_cat.race) # domestic shot hair

print(oia_cat.__class__.race is huh_cat.__class__.race) # True

 

oia catchange_race 메서드로 클래스 속성과 이름을 공유하는 객체 속성을 생성한 뒤에는,

 

객체 속성을 먼저 호출하게 바뀝니다.

 

이 때문에 클래스 속성과 객체 속성을 혼동하는 경우가 잦은 것 같습니다.

 

클래스 속성은 모든 인스턴스가 공유해야만 하는 값, 하나가 바뀌면 모든 값이 바뀌는 값, 또는 상수가 들어가야 합니다.

 

Cat 클래스를 예로 든다면, 모든 고양이 객체가 변함 없이 공유하는 평균 수명, 학명 정도를 넣을 수 있겠죠.

 

(물론 세부 품종마다 다를 수는 있으니, 단순 예시입니다!)

 

위의 클래스를 조금 더 올바르게 수정한다면 다음과 같이 다듬을 수 있을 것 같습니다.

 

class Cat:
    avg_life_span = 15
    scientific_name = 'Felis catus'

    def __init__(self, name, age, race = 'Domestic Shot hair'):
        self.name = name
        self.age = age
        self.race = race

    def change_race(self, race):
        self.race = race

 

클래스의 속성은 객체 전체가 공유하는 값으로 바꾸고,

 

race(품종)의 경우, 기본값은 Domestic shot hair로 하되, 클래스 생성 시 값이 들어올 경우 해당 값으로 할당합니다.

 

그렇다면 클래스 속성은 어떻게 바꾸지?

@classmethod 데코레이터를 활용하여 클래스 속성을 다룰 수 있는 메서드를 만들어야 합니다.

class Cat:
    avg_life_span = 15
    scientific_name = 'Felis catus'

    def __init__(self, name, age, race = 'Domestic Shot hair'):
        self.name = name
        self.age = age
        self.race = race

    @classmethod
    def change_avg_life_span(cls, new_value):
        cls.avg_life_span = new_value

 

@classmethod로 선언된 메서드의 경우, 인스턴스를 나타내는 self 대신 클래스 참조를 나타내는 cls가 첫 번째 인자로 들어갑니다.

 

이 방법으로는 class.__class__ 내에 존재하는 클래스 속성을 바꿀 수 있습니다.

 

근데 그럼 이것도 되는 거 아니냐?

oia_cat.__class__.avg_life_span = 50

 

봉크!

얌전히 setter 만들어 씁시다.

SQLAlchemy란

Java의 JPA와 같이, Python의 ORM 라이브러리입니다.

 

Django를 사용할 경우 전용 ORM 모듈이 있으나,

 

FastAPI, Flask를 사용하거나 단순한 Python 실행 파일에서 DB와 연결이 필요할 경우,

 

거의 무조건 쓰게 되는 라이브러리가 됩니다.

 

애석하게도 Python backend는 java spring에 비하면 비주류에 속하고,

 

그 작은 파이 안에서도 절대적 다수는 아직까지 Django입니다.

 

그렇기 때문에 SQLAlchemy에 대한 정보는 찾기가 쉽지 않은 편이죠.

 

공식 문서도 있으나, 당연히 영어 버전밖에 없습니다.(그와중에 공식문서 테마가 심히 y2k스럽습니다.)

 

SQLAlchemy

The Database Toolkit for Python

www.sqlalchemy.org

 

FastAPI를 깊게 파면서, 당연히 SQLAlchemy도 깊게 팔 수밖에 없었고, 이를 통해 알게 된 정보들을 포스팅하고자 합니다.

 

Session Maker를 활용한 다양한 연결 방식

1. Try-Yield-Finally 블럭활용

FastAPI는 다음과 같은 연결 방식을 권장합니다.

from sqlalchemy import create_engine, Integer
from sqlalchemy.orm import sessionmaker, DeclarativeBase

DB_URL = f'mysql+pymysql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}'
engine = create_engine(url=DB_URL)
session_maker = sessionmaker(bind=engine, autoflush=False)

# ==== 핵심입니다. =======
async def get_db():
    db = session_maker()
    try:
        yield db
    finally:
        db.close()

app = FastAPI()
# =====================

class Base(DeclarativeBase):
    pass

class Item(Base):
    __tablename__ == "items"
    item_id = Column(Integer, primary_key = True)

@app.get('/item')
async def get_item(n, db = Depends(get_db)):

    item = db.query(Item).get(n)

    return item

 

DB연결 정보를 담은 engine을 바탕으로 session_maker객체를 생성합니다.

 

session_maker 객체는 펑션 콜이 발생할 시, 새로운 세션(db)를 반환합니다.

 

FastAPI의 DI용 함수인 Depends를 통해 get_db 함수 자체를 객체로써 파라미터에 넣으면

 

해당 요청 내에서 db와 연결된 세션을 얻는 것이죠.

 

이 방식은 Try-Finally 블럭을 통해 자동으로 세션의 커넥션 풀 반환을 시행하므로, 커넥션 누수 문제가 발생하지 않습니다.

 

Depends(get_db)같은 의존성 주입 방식이 아닌 다른 방식을 쓸 수도 있습니다.

 

함수 내에서 session_maker의 펑션 콜을 직접 시행해도 괜찮습니다.

2. with 블럭을 통한 생명주기 자동 관리

마치 file open을 with를 통해 안전하게 실행하는 것처럼,

 

session도 다음과 같이 with블럭을 통해 만들 수 있습니다.

@app.get("/item")
async def get_item(n):
    with session_maker() as db:
        item = db.query(Item).get(n)
    # with 블럭 밖에서도 db 객체 사용이 가능하지만, 하면 안 됩니다!

    return item

 

 

with블럭에서 생성한 session 객체는 with블럭 밖에서도 사용이 가능하지만, 하면 안 됩니다!

 

사실상 강제로 새 세션을 생성한 것이므로, with 블럭의 자동 생명 주기 관리 범위를 벗어나게 되어 문제가 발생합니다.

3. 그냥 만들기

대단히 복잡해져 로직 상 문제가 생길 여지가 많아지고,

 

쓸 데 없는 반복으로 코드가 길어질 것이며,

 

한 번의 실수가 매우 치명적으로 다가오는 방법입니다.

@app.get("/item")
async def get_item(n):
    db = session_maker()
    item = db.query(Item).get(n)
    db.close()

 

이 방법 자체를 쓰지 않았으면 하지만, 만약 쓴다면 세션 사용이 끝난 후에 반드시 .close()로 세션을 마쳐줘야 합니다.

 

그러지 않을 경우, session 객체가 DB와 Sqlalchemy의 소통 창구인 커넥션을 계속 점유하기 때문에 문제가 발생합니다.

 

웹서버가 아니라 필요한 때 한 번 세션을 열어줘야 하는 그런 프로그램이 아니라면, 권장하지 않습니다.

 

이 문제는 다음 번에 다뤄보도록 하겠습니다!

요약

개인적으로는 session_maker를 쓸 경우 1번 방법을 가장 권하는 편입니다.

 

session_maker 방식이 아닌, engine.connect()를 통한 직접 연결 방식도 있으나,

 

웹 서버를 개발하고자 한다면 session_maker를 쓰는 게 훨씬 효율적이고 객체지향적입니다.

 

처음에는 3번을 사용하다가, 2번을 써보고, 1번으로 정착하게 되면서 확실히 느꼈습니다.

 

1번을 씁시다!

+ Recent posts