복사되었습니다.

Python으로 private 변수, getter와 setter를 만드는 방법은 없을까? - Python property decorator

Cover Image for Python으로 private 변수, getter와 setter를 만드는 방법은 없을까? - Python property decorator

Python으로 객체지향 프로그래밍을 하려고 하는가? Java같은 객체지향 언어를 학습하다보면 public, private 등의 접근제어자, 그리고 getter와 setter에 대해 자연스럽게 접하게 된다. Python에도 private같은 접근제어자 기능을 사용할 수 있다는 사실을 알고 있는가? Python에서 getter와 setter를 쉽게 만들 수 있는 built-in 기능에 대해 알아보자.

Getter와 setter 소개

Private 변수, getter와 setter란 무엇일까?

Private 변수, getter와 setter는 객체지향 프로그래밍에서 캡슐화(encapsulation), 그리고 정보 은닉(information hiding) 개념과 함께 종종 소개된다. 아주 간략하게 정리하면 다음과 같다.

  • Private 변수는 "자신만 아는 변수"다. Private 변수가 정의된 클래스의 객체를 생성했을 때, 다른 객체들은 해당 변수가 있는지조차 모른다. 참고로 public 변수는 "남들도 다 아는 변수"다.
  • Getter는 특정 변수의 값을 "얻어"주는 기능을 수행하는 메서드를 의미한다.
  • Setter는 특정 변수의 값을 "세팅"해주는 기능을 수행하는 메서드를 의미한다.

이 세 가지 개념 사이에 어떤 관계가 있길래 함께 다루는 것일까? Getter와 setter는 일종의 "감춰진 변수"들을 대상으로 사용할 때 의미가 크기 때문이다. Public 변수처럼 객체 외부에 공개되는 변수들은 getter와 setter를 사용해도 큰 의미가 없다. 다른 객체가 getter와 setter 없이도 직접 변수의 값을 확인하고 변경해버릴 수 있기 때문이다.

Private 변수, getter와 setter는 왜 필요할까?

만약 클래스의 모든 변수를 public으로 선언하면 어떤 일이 발생할까? 변수의 소유자인 클래스 객체 외에 다른 객체들이 마음대로 변수의 값을 변경할 수 있기 때문에, 데이터가 오염될 위험이 있다. 오염된 데이터는 오류로 이어진다.

예를 들어, 다음과 같이 java 언어로 나이 변수를 가진 사람 클래스를 정의해보자.

public class Human {
  public int age;
  /* ... */
}

이때, 다른 누군가가 human.age = -1과 같이 나이를 음수 값으로 바꿔버리면 어떻게 될까? 태어나기 이전으로 돌아가는 것일까? 이런 동작이 가능한 프로그램이 잘 돌아갈 리가 없다.

Private 변수, getter와 setter는 데이터 오염을 방지하기 위해 변수에 접근하는 권한을 제어하는 역할을 한다고 정리할 수 있다. 실제로, private, public같이 변수 선언부 앞에서 변수의 공개 여부를 결정하는 것을 "접근제어자"라고 부른다. Getter와 setter는 접근제어자라고 부르진 않지만, 변수의 값을 확인하거나 변경하는 동작 중간에 껴서 간섭을 할 수 있다.

getter and setter intercept user requests in front of private variables

Getter와 setter는 어떻게 사용하는지에 따라 다양한 데이터 제어가 가능하다.

  • Getter와 setter 중 하나만 정의해서, 값 조회와 변경 중 하나의 기능만 허용할 수 있다.
  • Getter에서 변수 값을 반환하기 전에, 로직을 추가해서 원하는 동작을 수행할 수 있다.
  • Setter에서 변수 값을 설정하기 전/후에, 로직을 추가해서 원하는 동작을 수행할 수 있다.

Java 코드 예시를 살펴보자.

public class Human {
  private int age;
  private String name;

  public int getAge() {
    return age - 2;
  }

  public void setAge(int age) {
    if (age < 0) {
      this.age = 0;
    } else {
      this.age = age;
    }
  }

  public getName() {
    return name;
  }
  /* ... */
}

위 코드에서 정의된 Human 클래스의 객체 human은 다음과 같이 동작한다.

  • 다른 객체는 human.age, human.name 등 변수에 직접 접근할 수 없다.
  • human에게 나이를 물어보면, 원래 나이보다 2살 적게 대답한다.
  • human의 나이를 음수 값으로 변경할 경우, 무조건 0으로 설정된다.
  • human의 이름을 얻을 수는 있지만, 이름을 변경할 수는 없다.

확실히, 데이터 참조와 변경에 대한 통제권을 객체 외부가 아닌 객체 자신이 갖게 되어서 안전해보인다. 그런데, 이게 진짜 좋은 것일까? 앞으로는 코드를 짤 때마다 모든 변수에 getter와 setter를 정의해야 하는 것일까? 변수가 많으면 어떡할까? 아직 여러 의문이 남는다.

알고 싶은 것은 무엇인가?

이 글에서 알아볼 것은 다음과 같다.

  • Python으로 private 변수를 정의하는 방법 및 주의사항
  • Python으로 getter와 setter를 정의하는 방법 및 주의사항
  • Private 변수, getter와 setter를 쓰는 것이 과연 좋을까?

Python에서는 어떻게 구현해야 할까?

Python에는 private 접근제어자가 없다?

Python 문법에는 public이나 private같은 예약어가 없다. 만약, python의 클래스 안에 변수를 선언하면 default로 public 변수가 된다. 즉, python의 객체는 기본적으로 자신의 데이터를 만천하에 공개한다는 얘기다. 따라서, 생각없이 사용하면 데이터 오염에 시달리게 된다.

그렇다면 다른 객체가 변수에 직접 접근하는 것을 막을 방법은 없을까? 결론부터 말하자면, python에도 java의 private 변수처럼 외부 직접 접근을 막을 수 있는 방법이 있긴 하다. 변수명 앞에 언더스코어 두 개를 붙여주는 것이다.

class Human:
  def __init__(self, name: str, age: int) -> None:
    self.name = name
    self._age = age  # 외부 접근 가능
    self.__age = age  # 외부 접근 불가능
>>> human = Human(name='Steve', age=20)
>>> print(human._age)
20
>>> print(human.__age)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Human' object has no attribute '__age'

위 코드를 사용하면, 객체 human의 이름(human.name)은 알 수 있지만, 나이(human.__age)는 알 수 없게 된다. 주의할 점은 언더스코어를 꼭 두 개 붙여야 한다는 것이다. 하나만 붙이면 public 변수처럼 접근할 수 있게 된다.

단, 이에 대해서는 논란이 있다. 언더스코어를 하나만 붙이더라도 개발자가 non-public을 의도한 것이기 때문에 충분하다는 의견이 많다. 기능적으로는 접근이 막혀있지 않지만, 접근하려는 사람의 자유도 존중해주자는 주장이다. 이 논쟁에 대해서는 다른 글에서 자세히 다루도록 하겠다.

두 가지 방식의 차이를 간단히 요약하면 다음과 같다.

  • 언더스코어 한 개: 외부 직접 접근을 막아주진 않지만 "non-public"을 나타내는 의도로 사용된다. Python 개발자들은 대부분 이 컨벤션을 사용한다.
  • 언더스코어 두 개: 외부 직접 접근을 막아준다. 네임 맹글링(name mangling)이라고 불리는 특수한 문법이다. Private 접근제어자로 의도하여 만들어진 것은 아니다.

우선 python에서도 기술적으로 private 접근제어자처럼 동작하도록 만드는 것이 가능하다는 정도만 알고 넘어가자. 되도록이면 컨벤션에 따라 언더스코어를 하나만 쓰도록 하자. 누군가 언더스코어 하나짜리 멤버 변수에 직접 접근하려고 한다면 코드 리뷰를 통해 혼내주자!

Property 데코레이터를 활용한 getter와 setter 구현

Getter와 setter를 구현하는 방법은 이미 위에서 살펴보았다. 특정 변수의 값을 반환하거나 변경해주는 get_age, set_age같은 메서드를 구현하면 된다. 그런데, python에는 built-in으로 getter와 setter를 구현하는 방법을 제공하고 있기에 소개하고자 한다. Getter와 setter를 적용하고자 하는 변수 이름으로 메서드를 만든 뒤, property 데코레이터를 활용하면 된다. 예제 코드를 살펴보자.

class Human:
  def __init__(self, age: int) -> None:
    self._age = age
  
  @property
  def age(self) -> int:
    """Getter."""
    return self._age - 2
  
  @age.setter
  def age(self, age: int) -> None:
    """Setter."""
    if age < 0:
      self._age = 0
    else:
      self._age = age

먼저, 생성자에서 나이 변수에 언더스코어를 붙여서 non-public이라는 표시를 해준다. 그리고 이 변수와 동일한 이름의 getter와 setter 메서드를 정의한다. Getter 메서드 쪽에는 @property라는 데코레이터를 붙여주기만 하면 된다. Setter 메서드 쪽에는 @변수명.setter라는 데코레이터를 붙이면 된다. 이렇게 정의된 getter와 setter 메서드는 마치 객체의 변수에 직접 접근하는 것처럼 사용할 수 있다.

>>> human = Human(100)
>>> human.age
98
>>> human.age = -100
>>> human.age
-2

굳이 이렇게 구현하는 것의 장점은 무엇일까? 변수 값을 반환하거나 변경할 때 로직을 추가할 수 있는 getter와 setter 메서드의 특징은 유지하면서, 변수에 직접 접근하는 것처럼 사용할 수 있다는 것이 장점이다. 다시 말해서, 다른 사용처의 코드 수정을 최소화하면서, getter와 setter의 동작을 구현할 수 있는 것이다. 이것저것 얽히고설킨 legacy 코드를 수정할 때 유용하다.

단, 오해를 불러일으킬 수 있다는 것이 단점이 된다. 예를 들어, 위 코드 예제에서 객체를 생성할 때 분명히 나이를 100으로 설정했는데 출력을 해보면 98이 나온다거나, -100으로 나이를 변경했는데 -2가 나오는 동작은 사용자의 의도와 다른 결과를 돌려준다고 할 수 있다. 다른 개발자들에게 미리 설명을 해주지 않으면, "나는 잘못한 것이 없는데 왜 안되지?"의 늪에 빠뜨리기 쉽다. 일반적으로 메서드를 호출하는 것이라면 "내가 모르는 내부 동작이 있겠구나"라며 인지할 수 있지만, 직접 변수명에 접근하는 것은 별도의 내부 동작이 없을 것이라고 생각하기 때문에 혼란을 유발한다. 따라서, 무분별하게 property를 사용하면 안된다.

실험을 통해 알아보는 잡지식

Q1. Property로 정의한 변수를 생성자에서 접근할 수 있을까?

class A:
  def __init__(self, name: str) -> None:
    # print(self.name)  # 이건 되다가 만다.
    # AttributeError: 'A' object has no attribute '_A__name'
    self.__name = name
    print(self.name)  # 이건 잘 된다.

  @property
  def name(self) -> str:
    return self.__name

A1. 생성자에서도 property에 접근할 수 있다. 첫 번째 print(self.name)name 메서드 안쪽까지 접근은 되지만, 아직 self.__name이 정의되기 이전이기 때문에 에러가 발생하는 것이다.

Q2. 더블스코어 두 개를 붙여서 name mangling이 적용된 변수에 값을 무작정 넣으면 어떻게 될까? 값이 제대로 변경될까?

A2. 변경되지 않는다. Q1의 코드를 가지고 실험을 해보자.

>>> a = A('old_name')
old_name
>>> a.__name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute '__name'. Did you mean: '_A__name'?
>>> a.__name = 'new_name'
>>> a.__name
'new_name'
>>> a.name
'old_name'
>>> a.name = 'new_name2'
>>> a.__name
'new_name'
>>> a.name
'new_name2'
  • 처음부터 언더스코어를 붙여서 접근하면 AttributeError가 발생한다.
  • 변수에 값('new_name')을 강제 지정하는 것은 가능하다.
  • 변수에 값을 설정하고 난 뒤부터는 직접 접근(a.__name)이 가능하다.
  • 언더스코어를 빼고 getter(a.name)로 접근해보면, 사용자가 강제 지정한 값으로 바뀌지 않은 상태다.
  • Setter(a.name)를 통해 값을 변경하면, 사용자가 강제 지정한 private 변수의 값은 변경되지 않는다.

정리하자면, 객체 외부에서 강제로 언더스코어 두 개가 붙은 변수에 지정하는 값이랑은 독립적으로 getter와 setter가 동작한다. 마치 외부에서 name mangling이 적용된 변수에 값을 설정할 수 있는 것 처럼 보이지만, 실제로는 제대로 동작하지 않는다. 혼란을 피하기 위해 언더스코어가 붙은 변수들에는 직접 접근하지 않도록 주의하자.

요약 및 코멘트

요약 정리

  • Python에서는 변수명 앞에 언더스코어 두 개를 붙이면 private 변수처럼 동작한다.
  • 언더스코어 한 개를 붙이면 외부 접근은 가능하지만, non-public이라는 의도를 전달한다.
  • 되도록이면 언더스코어 한 개만 붙이는 것으로 non-public을 표현하자.
  • 변수명과 똑같은 이름의 메서드에 @property를 붙여주면 getter가 된다.
  • 변수명과 똑같은 이름의 메서드에 @변수명.setter를 붙여주면 setter가 된다.
  • Property를 사용하면 사용처의 코드 수정을 최소화할 수 있다.
  • Property를 사용하면 사용자에게 오해를 불러일으킬 수 있다.

Getter와 setter는 되도록이면 쓰지 말아야 한다?

이 글에서 열심히 소개를 했지만, 사실 개인적으로 getter와 setter 자체를 사용하는 것이 좋지 않다고 생각한다. 어쩔 수 없이 필요한 경우도 많지만, 그렇지 않은 경우에는 되도록 지양해야 한다. 조금 과장해서 property는 아예 쓰지 말아야 한다고 생각하는 편이다.

객체지향 프로그래밍은 쉽게 말하면 "위임"을 중요시하는 정신이라고 할 수 있다. 하나의 객체가 자신이 맡은 일을 알아서 하도록 하고, 그 안에서 어떤 동작을 하는지는 외부에서 알 필요가 없다. 객체를 사용하는 쪽에서는 단지 대상 객체가 스스로의 일을 하도록 시키기만 하면 된다. 일을 시킨다는 것은 곧 동작을 요청하는 것, 즉, 메서드를 호출하는 것이다. 따라서, 객체지향 프로그래밍에서는 객체들이 서로 상호작용할 때, 데이터는 숨기고 서로의 메서드를 호출하는 방식으로만 소통하도록 권장한다.

다른 객체의 변수 값을 확인하거나 변경할 수 있다는 것은 무엇을 의미할까? 해당 객체가 할 일을 외부에서 대신해준다는 의미가 된다. 객체지향 정신에 위배된다. 따라서, private 변수의 사용은 항상 옳다. 글 초반부에 언급했듯이, 정보를 숨기는 것이기 때문에 객체지향 정신에 부합한다고 할 수 있다.

Getter와 setter는 결국 메서드인데, 뭐가 문제냐는 의문이 생기지 않는가? 변수를 숨기고 메서드로만 통신한다는 점은 아슬아슬하게 지켰다. 하지만, 변수를 그대로 노출하는 것과 다를바가 없는 동작을 수행한다는 점이 문제다. 예를 들어, 다음 두 가지 코드는 사실 완벽히 동치다.

class BankAccount:
  def __init__(self, balance: int) -> None:
    self._balance = balance
  
  def get_balance(self) -> int:
    return self._balance
  
  def set_balance(self, balance: int) -> None:
    self._balance = balance
class BankAccount:
  def __init__(self, balance: int) -> None:
    self.balance = balance

백번 양보해서 getter와 setter가 필요하기 때문에 정의했다고 가정해보자. 잔고를 확인해야만 자신의 일을 할 수 있는 객체들이 존재할 수도 있기 때문에, getter는 필요하다고 판단할 수 있다. 값을 반환하기 전에 본인 확인을 요청하는 등 객체 자신만의 특화된 로직을 수행한다면 더욱 당위성이 생긴다.

그렇다면 setter는 어떨까? 잔고를 수정한다는 것은 BankAccount의 객체 자신이 해야 할 일 아닌가? 외부 객체들이 이 메서드를 호출할 필요가 있을까? 차라리 "현금 인출", "송금" 등의 메서드를 정의해서 외부에 노출시키고, set_balance는 내부적으로 자신이 처리하는 것은 어떨까? 객체들간의 역할 분담도 명확해지고, 사용자에게 메서드의 의도를 직관적으로 전달할 수 있지 않을까?

정리하자면, 사용자의 사용 시나리오 기반으로 메서드를 작성해야 좋다는 것이다. 단순히 값을 변경해주는 메서드는 다른 객체에게 일을 대신해 달라는 것과 같은 의미다. 따라서, 무분별한 setter의 사용을 지양하는 것이 좋다는 의견이다. 메서드인지조차 모르도록 혼란을 주는 property는 더더욱 지양하는 것이 좋다고 생각한다. "내 값은 내가 관리한다!"라고 기억해두자.

Private 변수, getter와 setter 적용 시 주의사항

사실 실제로 개발을 하다보면 다양한 프로젝트를 만나게 되고, 모든 프로젝트가 객체지향 정신을 이상적으로 따르고 있을 확률은 없다고 보면 된다. 수많은 getter와 setter를 만나게 될 수도 있다. 그럴 때마다 객체지향 정신을 외치며 모든 코드를 뜯어고쳐야 할까? 열정을 불태우는 것은 좋지만, 현실적으로 힘들다. Property의 장점으로 "legacy 코드를 수정할 때 사용처의 코드 변경이 최소화된다"는 점을 언급한 것에는 다 이유가 있다.

이 글에서는 python에서도 언더스코어를 붙여서 private 접근제어자처럼 쓸 수 있다는 것 하나 정도는 얻어갔으면 좋겠다. 향후 개발을 하면서 새로 짜는 코드에는 적어도 private 변수를 사용하는 습관을 들여보자. 언더스코어 두 개까지는 아니더라도, 언더스코어 하나짜리는 용기있게 마구마구 써보자. 수많은 프로젝트들과 온라인에 떠돌아다니는 python 예제 코드들에 언더스코어가 많아지는 그날까지!

Comments

    More Posts

    Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

    Kubectl 명령어를 통해 kubernetes 환경에 다양한 구성요소들을 배포할 수 있지만, python으로 템플릿 코드를 짜두면 개발자들도 편리하게 배포를 자동화할 수 있다. Jinja2와 kubernetes 패키지를 통해 사용자 요청에 따라 kubernetes 환경에 pod들을 띄우는 기능을 만들어보자.

    Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

    Domain entity를 정의할 때 declarative 방식의 ORM을 사용하면 domain model에 DB 관련 정보가 노출되는 문제가 있다. Python sqlalchemy를 활용하여 imperative mapping style로 domain entity로부터 ORM을 분리해보자.

    도메인 entity와 ORM을 동시에 추구하면 안 되는 걸까? - Domain entity with declarative ORM

    ORM을 사용하는 환경에서 DDD를 따르는 코드를 작성하다보면, domain entity를 어떤식으로 정의해야 하는지 혼란스러울 때가 있다. Python sqlchemy를 통해 declarative mapping 방식으로 domain entity를 어떻게 구현하면 좋은지 알아보자.

    Font Size