기술면접 보완 1. 파이썬 참조 카운팅

재혁·2024년 6월 13일

면접 회고

목록 보기
2/7
post-thumbnail

틀린 부분을 댓글로 알려주신다면 감사하겠습니다.

기술 면접에서 부족했던 부분을 블로그 작성을 통해 기록하고 이해하며 깊이 파고들어가려고 합니다. 기술 면접의 순서는 파이썬, 자료구조/알고리즘, 데이터베이스, 네트워크, 운영체제, 아키텍처, 프로젝트/직장 업무, 인성 질문으로 정리하려고 합니다. 막연하게 안다고 생각하지 말고, 정확하게 아는 것을 목표로 합니다. 오늘은 우선 파이썬의 참조 카운팅(reference counting)에 대해 설명해보려고 합니다.

참조 카운팅 (Reference Counting)

객체가 얼마나 많은 참조를 가지고 있는지를 추적하여 메모리를 관리하는 방법입니다. 파이썬에서는 각 객체가 몇 개의 참조를 받고 있는지를 카운트하여, 그 카운트가 0이 되면 객체를 메모리에서 해제합니다.

조금 더 쉽게 요약하자면 객체를 참조하는 변수의 수를 세어, 더 이상 참조하는 변수가 없으면 메모리에서 삭제하는 방식입니다.

이걸 왜 사용하는지?

참조 카운팅을 사용하지 않는다면? 어떤 문제가 발생할지 생각해 봅시다. 메모리에서 객체가 해제되지 않으면 불필요한 데이터가 계속 쌓여 시스템의 메모리가 고갈될 것입니다. 이는 흔히 말하는 메모리 누수 현상(파이썬 내부에서 malloc()와 free()를 많이 사용합니다.)입니다. 또한, 메모리가 부족해지면서 불필요한 객체들이 메모리에 할당될 경우, 메모리 접근 속도가 느려져 성능 저하가 발생할 것입니다. 정리하면, 참조 카운팅을 사용하지 않을 때 발생할 수 있는 문제는 다음과 같습니다.

  1. 메모리 누수 현상
  2. 메모리 부족 현상
  3. 성능 저하 현상

이러한 문제들을 본다면 왜 참조 카운팅을 사용하는지 알 수 있습니다. 키워드로 정리하자면, "메모리를 효율적으로 관리" 하기 위해서입니다.

파이썬에서 참조 카운팅이 작동하는 방식

직접 경험을 위해 파이썬 코드로 작성해봅니다.
확인하기 위해서는sys.getrefcount()가 필요합니다.
sys.getrefcount()는 객체의 참조 카운트를 확인하는 데 사용되며 주어진 객체가 현재 몇 개의 참조를 받고 있는지 반환합니다.

import sys

# 객체 생성
my_list = [1, 2, 3] 
print(sys.getrefcount(my_list))  # 참조 카운트 출력 (2, 기본 참조 포함)

# 참조 추가
another_list = my_list
print(sys.getrefcount(my_list))  # 참조 카운트 출력 (3)

# 참조 삭제
del my_list
print(sys.getrefcount(another_list))  # 참조 카운트 출력 (2)

# 마지막 참조 삭제
del another_list
# 이 시점에서 리스트 객체는 메모리에서 해제됨

위의 코드를 살펴보면, 객체를 생성할 때 참조 카운트가 2로 표시됩니다. 참조를 추가할 때는 1이 증가하여 3이 됩니다. 또한, 참조를 삭제할 경우 다시 2로 줄어듭니다. 참조 카운트가 1이 되지 않고 2로 먼저 표시되는 이유는 sys.getrefcount() 함수 자체가 참조를 하기 때문입니다. 따라서, 실제 참조 카운트에서 1을 더한 값이 표시된다고 생각하시면 됩니다.

이것을 요약한다면 객체가 참조될 때마다 파이썬은 참조 횟수를 증가시키고, 반대로 개체가 더이상 참조되지 않으면 개수가 감소합니다. 여기서 개수가 0으로 떨어진다면 파이썬은 안전하게 메모리 할당을 해제할 수 있다는 것을 알게됩니다.

하지만 참조를 잘못할 경우 순환 참조가 발생할 수 있습니다.

순환참조

순환 참조란 두 개 이상의 객체가 서로를 참조하여 참조 카운트가 0이 되지 않아서 메모리에서 해제되지 않는 경우를 말합니다. 순환 참조를 할 경우에 문제가 될 수 있습니다. 왜냐하면, 이러한 참조들은 파이썬의 기본 참조 카운팅 메커니즘만으로는 해제되지 않기 때문입니다. 결과적으로, 이 객체들은 메모리에 계속 남아 있게 되어 메모리 누수가 발생할 수 있습니다. 메모리 누수는 사용되지 않는 객체들이 메모리를 차지하여, 시스템의 메모리 자원을 불필요하게 낭비하게 되고, 성능 저하를 초래할 수 있습니다.

import sys

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

# 순환 참조 생성
node1 = Node(1)
node2 = Node(2)

node1.next = node2
node2.next = node1

# 여기서 node1과 node2는 서로를 참조하고 있으므로 참조 카운트가 0이 되지 않음
print(sys.getrefcount(node1))  # 3 (node1과 node2가 서로를 참조)
print(sys.getrefcount(node2))  # 3 (node1과 node2가 서로를 참조)

이 문제를 해결하기 위해 파이썬에서는 가비지 컬렉터를 사용합니다. 가비지 컬렉터는 메모리 누수를 방지하고 메모리 사용을 최적화하는 데 유용하지만, 모든 문제를 완벽하게 해결할 수 있는 은탄환은 아닙니다. 따라서, 순환 참조 문제를 해결하기 위해 아래와 같은 해결사항을 알 수 있습니다.

순환참조 해결방법

순환 참조 피하기 - 약한 참조 사용

약한 참조는 객체의 참조 카운트를 증가시키지 않기 때문에 순환 참조를 피할 수 있습니다. 하지만 약한 참조도 객체가 더 이상 강한 참조되지 않을 경우 해제될 수 있습니다. 따라서, 약한 참조를 사용하는 코드에서 예상치 못한 시점에 객체에 접근하려 할 때, 객체가 이미 해제된 상태일 수 있으니 주의가 필요합니다.

https://docs.python.org/3/library/weakref.html#:~:text=A%20weak%20reference%20to,a%20cache%20or%20mapping.

import weakref

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

# 순환 참조 생성
node1 = Node(1)
node2 = Node(2)

# 약한 참조를 사용하여 순환 참조 방지
node1.next = weakref.ref(node2)
node2.next = weakref.ref(node1)

# 약한 참조 객체에 접근하기 위해 () 사용
print(node1.next().value)  # 2
print(node2.next().value)  # 1

명시적 메모리 관리 전략 - 순환 참조 수동 해제

순환 참조를 명시적으로 해제하는 방법 중 하나로 객체를 None으로 설정하여 참조를 끊는 것입니다. 그 후 가비지 컬렉터를 강제로 실행하여 사용되지 않는 객체를 메모리에서 해제합니다. 가비지 컬렉터는 사용되지 않는 객체를 식별한 후 메모리에서 해제하며, 만약 객체에 del 메서드가 정의되어 있다면 해당 메서드가 호출됩니다.

import ctypes # 참조 카운트 확인 모듈
import gc

# 참조 카운트를 확인하는 유틸리티 함수
def ref_count(address: int) -> int:
    return ctypes.c_long.from_address(address).value

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

    def __del__(self):
        print(f'값이 {self.value}인 노드가 삭제되고 있습니다.')

# 순환 참조 생성
node1 = Node(1)
node2 = Node(2)

node1.next = node2
node2.next = node1

# 메모리 주소 확인
address_node1 = id(node1)
address_node2 = id(node2)

print(f'순환 참조 해제 전 node1의 참조 카운트: {ref_count(address_node1)}')
print(f'순환 참조 해제 전 node2의 참조 카운트: {ref_count(address_node2)}')

# 순환 참조 수동 해제
node1.next = None
node2.next = None

print(f'순환 참조 해제 후 node1의 참조 카운트: {ref_count(address_node1)}')
print(f'순환 참조 해제 후 node2의 참조 카운트: {ref_count(address_node2)}')

# 가비지 컬렉터 강제 실행 및 수집된 객체의 수 확인
collected = gc.collect()
print(f'가비지 컬렉터에 의해 수집된 도달할 수 없는 객체 수: {collected}')

# 응답 결과
순환 참조 해제 전 node1의 참조 카운트: 2
순환 참조 해제 전 node2의 참조 카운트: 2
순환 참조 해제 후 node1의 참조 카운트: 1
순환 참조 해제 후 node2의 참조 카운트: 1
가비지 컬렉터에 의해 수집된 도달할 수 없는 객체 수: 0
값이 1인 노드가 삭제되고 있습니다.
값이 2인 노드가 삭제되고 있습니다.

이렇게 참조 카운팅에 대해서 알아봤습니다.

https://docs.python.org/ko/3/extending/extending.html#reference-counts
https://peps.python.org/pep-0683/
https://devguide.python.org/internals/
https://docs.python.org/3/c-api/refcounting.html
https://realpython.com/python-memory-management/

profile
저는 기술적 호기심은 좋지만 좋은 제품을 만드는 것을 우선시하는 개발자입니다.

0개의 댓글