프로퍼티 상속 파고들기 – Swift

프로퍼티에 대한 이야기를 몇 번 하긴하였는데, 실제로 프로퍼티 상속은 실제로 상당히 오묘하게 헷갈릴 수 있는 여지가 많아서 다시 한 번 정리하는 차원에서 예제와 함께 포스팅한다. 이 글에서는 클래스의 프로퍼티가 서브클래싱할 때 어떤식으로 처리되는지를 살펴볼 것이다. 먼저 저장 프로퍼티와 계산 프로퍼티에 대해서 살펴보자. 다음 예제 코드는 정수형 프로퍼티 4개를 가지고 있는 클래스 Foo를 구현한 예이다.

class Foo {
  var a: Int = 5
  var b: Int = 30 {
    willSet { print("[Foo] property b will be changed.") }
    didSet { print("[Foo] property b has been changed.") }
  }
  var c: Int { return a * b } 
  var d: Int {
    get {
      return a + b
    }
    set {
      a = newValue % 10
      b = newValue - a
    }
  }
}
  • Int 타입의 a는 가장 단순한 형태의 저장 프로퍼티이다. 클래스 Foo는 하나의 Int 타입 값을 위한 스토리지 공간을 할당한다. 그리고 해당 공간에 값을 읽고 쓸 수 있는 getter, setter 메소드를 자동으로 생성한다. 물론, 이 메소드들은 프로그래머에게 노출되지 않으며,  foo.a = 1 과 같은식으로 점 표기(dot notation)를 쓰는 것으로 대신하게 된다.
  • b 는 역시 Int 타입의 저장프로퍼티이다. a 와 동일하게 구성되겠지만, 값이 변하기 직전, 직후에 willSet, didSet에서 정의해준 옵저버 코드 블럭이 실행될 것이다. 여기서는 값이 변하는 순간 전후에 프로퍼티 b가 변경된다는 메시지를 출력하게 했다.
  • c는 가장 일반적인 계산 프로퍼티형태로, a, b 의 값을 곱한 것을 결과로 리턴하는 읽기 전용의 프로퍼티이다.
  • d는 계산 프로퍼티이면서 setter를 지원한다. 이 경우 두 개의 접근자를 get{ ... } set { ... } 형태로 분리해서 적는다. (두 블럭 사이에 컴마와 같은 연결 문법은 필요없다.) d의 동작은 입력된 정수값에서 1의 자리 숫자를 a에, 그외 값을 b로 나누어 저장하며, getter 호출시에는 나누어 저장된 두 값을 더하여 리턴한다.

실제로 Foo 클래스의 인스턴스를 만들어서 확인해보자.

let f = Foo()
foo.a = 7
foo.b = 30 
// [Foo] property b will be changed.
// [Foo] property b has been changed.

print(f.a, f.b, f.c, f.d)
// 7   30   210   37

f.d = 84
// 80 + 4로 분해되어 b = 30, a = 4가 된다.
print(f.a, f.b, f.c, f.d)
// 4   80   820   84

여기까지는 사실 Objective-C의 선언프로퍼티와 거의 같은 개념이기 때문에 (옵저버만 빼고, Objective-C에서는 setter를 직접 구현해야하기 때문에 옵저버가 따로 필요없다) 본격적으로 헷갈리기 시작할 수 있는 부분은 바로 상속에 대한 부분이다. Foo 클래스를 상속받은 Bar 클래스를 다음과 같이 작성하면서 각각의 프로퍼티를 어떻게 상속하는지 살펴보자.

class Bar : Foo { 
  // #1 - a를 computed property로 오버라이드
  override var a: Int {
    get { return super.a * 10 }
    set { super.a = newValue / 10 }
  }

  override var b: Int {
  // #2 - Bar의 옵저버를 추가로 설치
     willSet { print("[Bar] property b will be changed.") }
     didSet { print("[Bar] property b has been changed.") }
  }

  // #3 c에 대한 오버라이드는 없음.

  override var d: Int {
    get {  return super.d } // #4
    set {  // #5
      a = newValue % 100
      b = newValue - a
    }
  }
}

프로퍼티는 값의 저장공간과 접근자 메소드들의 집합이라 볼 수 있고, 프로퍼티를 오버라이드한다는 것은 getter/setter 및 옵저버 메소드들을 오버라이드하는 개념으로 이해할 수 있다.

저장 프로퍼티를 오버라이드하기

Foo의 a는 저장 프로퍼티였는데, Bar에서는 이를 오버라이드하여 별도의 getter, setter를 정의하고 있다. Bar에는 a를 위한 저장공간을 할당하게끔하고 있고 이는 Foo로부터 상속받은 속성이 된다. 대신에 Bar는 저장소를 액세스할 때 임의의 변경을 거치는데 그 내용은 다음과 같다.

  1. Foo에서 저장공간에 액세스하는 메소드는, 저장공간에 있는 값을 그대로 리턴한다. Bar에서는 이를 오버라이드하여 저장공간에 들어있는 값(이는 super.a를 통해서 액세스할 수 있다.)을 10배로 늘려서 리턴한다.
  2. 따라서 처음 초기화했을 때, a를 액세스하면 5대신 50이라는 값을 얻게 된다.
  3. setter에서는 주어진 값을 10으로 나눈 몫을 super.a를 이용해서 저장한다.
  4. 이 setter/getter 관계에 의해서 54라는 값을 대입하려하면 5라는 값이 저장공간에 저장되고, 다시 액세스하면 50이라는 값을 얻게된다. 즉 접근자에 의해서 1자리에 대한 정밀도가 사라지는 효과를 얻게된다.

옵저버에 대한 오버라이드

부모 클래스의 프로퍼티가 저장 프로퍼티이기만 하면 서브 클래스에서 옵저버를 오버라이드할 수 있다. 그런데 이 옵저버는 “현재 서브 클래스에서 setter의 전/후에 호출된다”는 것을 알아두자. 따라서 부모 클래스에서 만들어놓은 옵저버를 무시할 수 없게 된다. Bar의 인스턴스에 대해서 b 값을 변경하려하면 다음과 같은 동작을 수행하게 된다.

  1. Bar의 setter가 호출되기 전에 willSet이 호출된다.
  2. Bar의 setter는 별다른 오버라이드가 없기 때문에 자동으로 super.b = newValue를 호출하는 것으로 기본 구현이 적용된다.
  3. Foo의 setter가 호출되려할 것이고, 호출 전에 Foo willSet이 호출된다.
  4. Foo의 setter에 의해 값이 변경된다.
  5. Foo의 didSet이 호출된다.
  6. 다시 Bar의 didSet이 호출된다.

참고로 옵저버들은 개별 클래스의 해당 컨텍스트에서만 의미를 가지며, 별도의 setter 구현이 없으면 기본적으로 super.prop = newValue가 되므로 부모의 옵저버는 서브 클래스로 상속되지 않지만, setter 체인을 타고 거슬러 올라가서 부모의 옵저버가 호출될 것이다.

부모의 옵저버가 상속되지 않는다는 것은 옵저버를 가진 저장 프로퍼티를 계산 프로퍼티로 상속해보면 알 수 있다.

class Bee:
  var x: Int = 5 {
    didSet { print("x has been changed") }
  }
}

class Pou: Bee {
  var a: Int = 10
  var b: Int = 2
  override var x: Int {
    get { return a + b }
    set { a = newValue % 10
          b = newValue - a }
  }
}

let p = Pou()
p.x = 55

위 예에서 오버라이드된 x는 super(Bee)의 x에 대한 setter를 호출하지 않는다. 따라서 Bee의 프로퍼티 옵저버는 호출되지 않으며, 옵저버에 대한 오버라이드를 제공하지 않았으므로 p.x 값이 바뀔 때에는 어떤 문구도 출력되지 않는다.

계산 프로퍼티에 대한 오버라이드

계산 프로퍼티 작성에 관해서는 두 가지 주의해야 할 규칙이 있다.

  1. setter가 있는 프로퍼티에 대해서는 반드시 getter를 작성해야 한다. 즉 write-only라는 프로퍼티를 만들 수는 없다.
  2. read-write 계산 프로퍼티를 서브 클래스에서 read-only로 오버라이딩할 수 없다. 만약 setter의 동작만 바꾸려는 경우에도 getter는 명시적으로 오버라이딩해야 한다.

프로퍼티의 접근자는 getter와 setter가 내부적으로 구분되어 있지만, 외부에서는 이 둘을 묶어서 하나로 본다. 즉 모두 구현되어 있느냐, getter만 구현되어 있느냐는 것은 해당 프로퍼티가 read-only인지 read-write인지를 구별하는데 사용된다. 따라서 계산 프로퍼티를 오버라이딩할 때에는 1)getter는 무조건 오버라이딩, 2) setter는 부모에서 정의되었다면 무조건 오버라이딩해야 한다.

참고로 부모클래스에서 계산 프로퍼티로 정의된 프로퍼티에 대해서 자식 클래스에서 저장 프로퍼티로 변경할 수 없다. (메모리 구조가 달라지기 때문이다.)

getter, setter를 갖는 계산 프로퍼티 오버라이드에서 super의 프로퍼티 접근자를 호출하는 것은 선택적이다. 위 예에서 Bar의 d에 대한 getter는 super.d를 리턴하는 것으로 되어 있다. (그리고 이 getter는 위의 두 번째 규칙에 의해서 반드시 오버라이드해야 만 한다.)이를 분석해보자.

  1. Bar.d-getter는 return super.d 로 부모클래스의 구현을 그대로 사용한다.(즉 자동으로 상속하는 것은 아니다.)
  2. 부모 클래스의 구현은 return a + b 이다.
  3. 그런데 이때 a, bBar의 컨텍스트에서 실행된다. (어떠한 값도 변경하지 않았다면) a + b는 5 + 30 (Foo의 기본값이자, Foo에서의 접근자로 얻은 값들)이 아니라 50 + 30(bar.a + bar.b) 이 되어 80이 리턴될 것이다.

setter의 경우에는 자신의 다른 프로퍼티 값 변경을 위해서 다른 접근자를 호출한다. d = 524를 실행할 때의 동작을 따라가보자.

  1. Bar.d의 setter가 호출된다. 이 때, 암묵적으로 변경될 새값은 newValue라는 이름을 달고 전달된다.
  2. a = newValue % 100이 실행된다. 이 때의 컨텍스트는 Bar 이므로 Bar.a = 24가 된다.
  3. Bar의 a에 대한 setter는 다시 이 값을 10으로 나눠서 2라는 값을 저장소에 저장한다. (super.a = 24/ 10)
  4. 다시 d 의 setter로 돌아와서, b에는 newValue - a를 대입한다. 이 때 a는 얼마일까? 24? 땡! 저장소에 저장할 때 10을 나눠서 저장했고, 이 값을 다시 10배해서 가져오므로 20이다. 즉 524 – 20 이 되어 504를 세팅하게 된다.
  5. 그런데 b는 willSet 옵저버가 달려있는 프로퍼티이다. 따라서 “[Bar] b will be changed”가 출력된다. 그리고 super.b = 504가 호출될 것이다.
  6. Foo의 b 역시 willSet 옵저버가 달려있다. “[Foo] b will be changed”가 출력된다.
  7. b의 값이 변경된다.
  8. Foo의 didSet옵저버가 호출된다. “[Foo] b has been changed”가 출력된다.
  9. 다시 Bar의 setter가 실행을 완료했다. didSet이 호출되면서 “[Bar] b has been changed”가 출력된다.

이렇게 변경이 끝난 후 a, b, c, d 값을 각각 get해서 출력해보면 다음과 같다.

  1. a의 실제 저장값은 2이며, 20이 리턴된다.
  2. b에는 504가 저장되어 있고, 504가 리턴된다.
  3. c는 두 값을 곱한 값이므로 10080이 리턴된다.
  4. d는 두 값을 더한 값이므로 524가 리턴된다.

정리

지금까지 Swift의 클래스에 프로퍼티를 선언하고, 서브 클래스에서 어떻게 오버라이딩하는지를 살펴보았다. 특히 오버라이딩 시에는 다른 프로퍼티에 대한 의존과 부모 클래스의 접근자를 참조하는 등의 상황이 겹쳐지면서 상당히 복잡한 경우가 생길 수 있는데, 다음과 같은 몇 가지 원리로 정리해서 접근하면 이해에 도움이 될 것이다.

  1. 저장프로퍼티와 계산프로퍼티는 내부적인 저장공간을 따로 마련하는가의 차이만 있을 뿐, 본질적으로 프로퍼티는 getter + setter의 접근자 조합으로 볼 수 있다.
  2. Swift는 write-only 프로퍼티를 작성할 수 없게 강제하여, 당신이 변태가 되는 것을 막아준다.
  3. 프로퍼티의 오버라이드는 결국 각 접근자의 오버라이딩이며, 따라서 저장 프로퍼티가 계산 프로퍼티로 오버라이딩되는 것은 문제가 없다. 단 서브클래스에서 부모 클래스와 같은 프로퍼티 이름에 대해서 추가적인 저장공간을 할당하는 것이 금지되어 있기 때문에 계산 프로퍼티를 저장 프로퍼티로 오버라이딩하는 것은 금지된다.
  4. 옵저버는 항상 현단계의 setter 앞뒤에 붙게 된다. 따라서 옵저버 메소드는 상속되지 않는다고 본다. 또한 커스텀 setter를 작성했다면 옵저버의 기능을 여기서 구현하면 되기 때문에 computed property에는 옵저버를 추가할 수 없다. 대신 이것은 커스텀 setter가 존재하는 클래스에 대한 이야기이기 때문에 계산 프로퍼티라도 상속받은 프로퍼티에 대해서는 옵저버를 추가할 수 있다.
  5. 공식 문서에서는 이니셜라이저 내에서 옵저버가 붙은 프로퍼티 값을 변경하는 것은 옵저버를 호출하지 않는다고 설명되어 있지만 여기에는 몇 가지 단서가 붙어야 한다.
    1. 지정 이니셜라이저에서 현재 클래스에서 도입한 프로퍼티의 초기값을 변경할 때만 옵저버가 실행되지 않는다.
    2. 편의 이니셜라이저 내에서 지정 이니셜라이저를 호출한 후에 변경하는 것은 옵저버 실행을 유발한다.
    3. 부모 클래스의 이니셜라이저를 호출한 후에 상속받은 프로퍼티의 값을 변경하는 것 역시 옵저버 실행을 유발한다.

 

Swift의 프로퍼티에 대한 이해

프로퍼티(property)는 직역하자면 “재산”, “소유물” 등으로 번역되는데, 보통은 속성이라고 번역하여 쓰는 것이 일반적이다. (“속성”이라는 의미의 attribute와 혼동이 있을 수 있지만, 일단 언어의 기능으로 한정했을 때에는 크게 상관은 없을 것 같다.) 프로퍼티는 클래스나 구조체 혹은 열거체(enum 타입)의 객체 인스턴스가 그 내부에 가지고 있는, 객체의 상태에 관한 정보를 말한다. 이렇게 말하면 Swift의 프로퍼티는 마치 C 구조체의 멤버 변수와 다름 없다고 생각될 수 있는데, 조금 차이가 있다. Swift의 프로퍼티 개념은 Objective-C의 선언 프로퍼티의 개념을 이어 받은 것이라 볼 수 있다. 따라서 프로퍼티에 대해 생각할 때에는 다음의 특성에 대해 생각해봐야 한다.

  • 객체의 어떤 속성의 상태를 나타내기 위해서 그에 해당하는 값이 객체 내부에 보관(stored)되어야 할 필요가 있다면, 객체가 차지하는 메모리 공간 내부에 실제로 그 값을 위한 저장 공간이 마련되어야 한다.
  • 그리고 객체는 그 프로퍼티를 객체 외부에서 액세스할 수 있도록 읽거나(getter) 쓸 수 있는(setter) API를 제공해야 한다.
  • 따라서 원칙적으로 프로퍼티의 backing storage는 은닉되며, 접근자를 통해서만 액세스하게 된다.
  • 따라서 객체의 프로퍼티는 내부의 저장공간과 그곳을 액세스할 수 있는 접근자를 묶은 개념이라 할 수 있다.

저장 프로퍼티와 계산 프로퍼티

예를 들어 2차원 평면 상의 한 점을 나타내는 Point 라는 타입이 있다고 생각해보자. 평면위의 한 점의 현재 상태는 곧 그 점의 위치이며, 그 점의 위치는 점의 x 좌표 값과 y 좌표값으로 표현될 수 있다. 이 때 x 좌표값, y 좌표값은 마치 타입 내부에서 종속된 변수처럼 생각할 수 있고, 실제 문법상으로도 변수와 같이 선언한다.

struct Point {
  let x: Double
  let y: Double
}

한 점의 각 축에서의 위치는 점이 정의되는 시점에 결정되어 그 점의 특성을 결정하며, 여러 개의 점이 있을 때 각각의 점은 자신의 고유한 위치값을 가지고 있게 된다. 이 때 하나의 점은 그 자신의 x좌표 값과 y좌표값을 “소유한다”고 볼 수 있다. 또한 동시에 점은 각각의 좌표상의 위치라는 단일 값들을 그 내부에 보관하고 있게 된다. 위 코드에서 각 축에서의 위치 정보는 프로퍼티의 이름과 그 타입으로 정의된다.

하나의 Point 타입 객체에 대해서 ‘원점으로부터의 거리’라는 속성을 가진다고 생각해보자. 원점으로부터의 거리는 x, y 좌표 값으로부터 계산해 낼 수 있다. 물론 객체가 생성되는 시점에 주어지는 x, y 거리 값으로부터 계산해서 내부의 별도 저장 공간에 저장해두는 방법이 있겠지만, 필요한 시점에 그것을 계산해서 사용하는 방법도 있다. (Objective-C라면 back storage 변수 없이 getter 메소드만 있는 경우가 된다.) 즉 어떤 프로퍼티들은 다른 프로퍼티 값에 의존하며, 그 값을 위한 저장 공간을 따로 구비하지 않아도 되는 경우가 있다. 이러한 프로퍼티들은 특별히 계산 프로퍼티(계산된 프로퍼티, computed property)라 부르며, 이와 대비해서 내부 저장 공간을 따로 마련하는 프로퍼티는 저장 프로퍼티(저장된 프로퍼티, stored property)라 구분한다.

Objective-C에서 계산 프로퍼티는 getter 메소드만 구현하는 것으로 정의하였는데, 비슷하게 Swift에서는 선언은 변수처럼 하되, getter 접근자를 위한 코드 블럭의 형태로 정의한다.

struct Point {
  let x: Double
  let y: Double
  var distance: Double{
    return hypot(x, y)
  }
}

계산 프로퍼티를 정의하는 방법은 다음과 같다.

  1. 선언자로 var 를 사용한다. 이는 객체의 초기화 시점에 해당 프로퍼티의 값이 정해지지 않으며, 다른 조건에 의해 get 할 때마다 다른 값이 나올 수 있기 때문이다.
  2. 저장 프로퍼티와 마찬가지로 이름과 타입을 명시한다.
  3. { … return value } 의 형태로 getter 메소드의 형태로 본체를 구현한다.

컨텍스트 상으로는 func getDistance() -> Double { return hypot(x, y) } 와 같은 식으로 정의되는 것과 다름없다. Objective-C에서는 aPoint.distance 라 표기가 [aPoint distance]라는 getter 메소드 호출로 자동으로 변환되는데 반해서 Swift에서는 메소드의 호출과 프로퍼티의 getter 호출은 문법적으로 구분되며 aPoint.distance()의 형태로 호환되지 않음에 주의하자.

계산 프로퍼티의 setter

어떤 프로퍼티에 대해서 setter가 있다고 해서, 그 프로퍼티가 반드시 저장 프로퍼티여야 한다는 법은 없다. 위의 계산 프로퍼티 작성 방법은 기본적으로 getter만 있는, readonly 속성임을 가정하고 쓰는 것이다. 만약 읽고 쓸 수 있는 계산 프로퍼티를 만든다면 getter와 setter를 모두 블럭 내에 작성할 수 있다.

Water라는 클래스에 대해서 tempC, tempF라는 두 개의 프로퍼티가 있다고 가정해보자. 이 프로퍼티들은 각각 물의 섭씨온도와 화씨온도를 나타내는데, 두 온도는 특정한 관계식에 의해서 묶여 있는 관계이다. 따라서 섭씨나 화씨 중 하나의 온도만 저장하고 다른 한쪽은 계산 프로퍼티로 만들 수 있다. 블럭안에 get{}, set{} 블럭을 각각 정의해주면, 계산 프로퍼티에 대해서도 setter를 정의할 수 있다.

class Water {
  var tempC: Double = 0
  var tempF: Double {
    get { return tempC * 1.8 + 32 }
    set { tempC = (newValue - 32) / 1.8 }
    // set(newF) 와 같은식으로 새로 받은 값의 이름을 바꿀 수 있다.
  }
}

프로퍼티의 초기화

Objective-C에서는 모든 인스턴스 변수에 대해서 C 원시 타입의 값은 0으로, 모든 객체 참조는 nil로 컴파일러에 의해서 초기값이 주어진다. 따라서 별도의 초기화과정이 기본적으로 필요하지 않았다. 하지만 Swift는 객체(기본적으로 ‘객체’라고 하면 인스턴스를 말한다.)를 사용하기에 앞서서 해당 객체가 점유하는 모든 메모리 공간내에 적절한 초기값이 할당되어 있는 것을 보장해야 한다. 이는 객체의 초기화와 관련된 토픽에서 따로 다뤄야 할 정도로 중요한 내용인데, 기본적으로 구조체나 열거체는 문법적으로 모든 프로퍼티의 값을 고정하거나 외부에서 제시하면서 생객체를 생성하는 것을 원칙으로 한다. 클래스의 경우에는 상속이라는 골치아픈 부분이 존재하기 때문에, 다음의 원칙을 따른다. 1) 해당 클래스에서 처음 도입되는 프로퍼티는 반드시 가장 먼저 초기값을 할당한다. 2) 부모클래스가 존재한다면 반드시 부모 클래스의 지정 이니셜라이저를 통해서 상속받은 프로퍼티를 초기화해야 한다.

예외적으로 초기화 단계에서 고려할 필요가 없는 경우는 두 가지인데, 첫째로 디폴트 값이 코드에서 주어진 프로퍼티는 그 값으로 초기화되는 것으로 간주한다. 이때 옵셔널 타입으로 선언된 프로퍼티는 nil을 초기값으로 갖는 것으로 가정한다. 두 번째는 느긋한 프로퍼티인데, 이는 다음에서 설명하겠다.

프로퍼티 자체는 상수가 되거나 변수가 될 수 있는데, 상수로 정의한 프로퍼티는 변경할 수 없고, 특히 클래스에서 상수 프로퍼티에 디폴트값을 제시한다면 이는 서브클래스에서 오버라이드가 불가능한 점을 참고하자.

느긋한 프로퍼티

느긋한 프로퍼티는 초기화 시점에 초기값을 할당하지 않고, 해당 프로퍼티가 처음 get 되는 시점으로 초기화를 미루는 프로퍼티이다. 특히 프로퍼티를 초기화하는데 많은 연산 혹은 메모리를 필요로하거나, 네트워크 접속과 같이 시간이 오래걸리는 작업을 포함해야 한다면 해당 프로퍼티를 쓸 지 안 쓸지 모르는 상황에서 무조건 생성시에 초기화하는 것은 좋지 않은 전략이다. 대신에 초기화를 특정한 클로저를 통해서 수행하는 것으로 타입과 계약하고 타입은 해당 계약을 신뢰한다.

느긋한 프로퍼티를 정의하는 방법은 다음과 같다.

  1. lazy var 를 통해서 선언한다. 이 때 lazy let 은 허용되지 않는다. 어쨌거나 해당 프로퍼티의 값은 초기화 이후에 결정되는 것이므로 “변하는” 값으로 간주되어야 한다.
  2. lazy var name:Type = { () -> Type in ... } () 의 형태로 구현한다. 계산 프로퍼티와 같이 메소드 형태로 접근자를 제공하는 것이 아니라, 저장 프로퍼티이면서 최초 액세스 시점에 우변이 평가되는 것이다.
  3. 프로퍼티의 초기값을 생성하는 클로저는 객체 외부의 것이며, 따라서 self는 명시적으로 캡쳐해야 한다. 따라서 [unowned self] 를 반드시 명시해서 혹시 모를 메모리 누수를 막도록 한다.

타입 프로퍼티

static (클래스의 경우에는 class) 변경자를 맨 앞에 붙여서 타입 프로퍼티를 정의할 수 있다. 타입 프로퍼티는 해당 타입의 인스턴스가 공유하는 개념이 아니라, 타입 자체가 가지고 있는 프로퍼티를 말한다. 따라서 특정한 객체 인스턴스는 self를 통해서 액세스할 수 없고 반드시 해당 객체의 타입명을 통해서 액세스해야 한다.

프로퍼티 옵저버

프로퍼티 옵저버는 키밸류 옵저빙과는 다르다. 프로퍼티 옵저버는 특정 저장 프로퍼티의 값이 변경될 때, 1)변경되기 직전, 2)변경된 직후에 실행되는 코드블럭을 의미한다. 따라서 내부적으로 setter가 실제로 동작하기 직전과 직후에 일종의 훅을 걸어둔다고 보면 된다. 프로퍼티 옵저버는 접근자 블럭 내에 didSet, willSet 으로 설정한다.

참고로 계산 프로퍼티의 setter는 커스텀한 동작을 수행할 수 있으므로 별도의 옵저버를 따로 설치할 수 없다. 아래의 코드는 bar 라는 프로퍼티에 옵저버를 설치하는 예를 보여준다. 옵저버는 setter가 호출되고 실제로 수행되기 직전/직후에 실행되기 때문에 newValue, oldValue 라는 암묵적인 값을 참조할 수 있다.  아래 예에서 bar 값이 변경되면, 기존 값은 moo로 옮겨지고 changeCount 가 1 올라가도록 연계된다.

class Foo {
  private var changeCount: Int = 0
  var moo: Int = 0
  var bar: Int = 0 {
    didSet {
      var moo = oldValue
      changeCount += 1
    }
  }
}

참고로 이니셜라이저 내에서 특정 프로퍼티의 초기값을 변경하게 되면, 이 때에는 옵저버 메소드를 생략하고 값만 변경된다.1 대신에 프로퍼티 옵저버 내에서 변경하는 다른 프로퍼티는 모두 외부에서 변경되는 것과 동일하게 취급된다. 예를 들어 다음과 같은 상황을 보자.

class Bar {
  var x : Int = 0 { 
    didSet {  y = x + 1 }
  var y : Int = 1 {
    didSet {  x = y - 1 }
  }
}

x, y 의 두 프로퍼티가 1만큼의 차이를 유지하려고 프로퍼티 옵저버를 만들어서 두 값의 차이를 유지하고자 한다. 하지만 이 코드는 심각한 문제가 있다. 두 개의 프로퍼티 옵저버가 계속해서 서로를 호출하는 동작을 하고 있기 때문에 프로그램이 터질 것이다. 이런 경우라면 위의 계산 프로퍼티의 setter를 설정하는 편이 올바른 패턴이라 하겠다.

프로퍼티 상속

클래스의 상속에서 가장 어려운 것 중 하나는 바로 “프로퍼티를 상속받는다”는 개념이다. 프로퍼티를 단순히 멤버 변수 수준으로 이해하고 있다면 이걸 상속받는다는 표현을 이해하기 어렵다. 하지만 프로퍼티는 서두에서 말했듯이 은닉된 저장공간과, 스토리지를 액세스하는 접근자들을 묶은 개념이다.  따라서 프로퍼티를 상속받는다는 것은 접근자를 상속받는다는 것을 의미한다. 프로퍼티 상속과 관련해서는 다음과 같이 정리 가능하다.

  1. 기본적으로 getter, setter 접근자가 상속되며, 이들은 오버라이드 가능하다.
  2. 스토리지 공간은 상속의 개념과 무관하다.
  3. 옵저버는 오버라이드 될 수 없다. 매 단계에서 옵저버들은 현재 클래스에서의 setter에 후킹된다.

그러면 다음과 같이 동작한다고 볼 수 있다.

  1. 저장 프로퍼티를 오버라이드한다는 것은 getter, setter를 오버라이드한다는 의미이다. 따라서 저장 프로퍼티를 계산 프로퍼티로 오버라이드하게 된다. 반대로 그 역의 케이스는 허용되지 않는다.
  2. 오버라이드된 getter, setter에서는 super.prop 의 형태로 부모의 getter, setter를 호출하게 될 것이다.
  3. 현재 클래스의 setter를 호출하면, 현재 클래스의 옵저버가 호출된다. setter 내에서 부모의 setter를 호출하면 그 때 부모의 옵저버가 호출된다.
    1. 현재 클래스에서 willSet 이 호출된다.
    2. 현재 클래스의 setter가 호출된다. 여기서 부모의 setter를 호출한다.
    3. 부모 클래스의 willSet이 호출된다.
    4. 부모의 setter가 스토리지 값을 변경한다.
    5. 부모 클래스의 didSet이 호출된다.
    6. 현재 클래스의 setter 호출이 완료되고, 현재 클래스의 didSet이 호출된다.

다음의 예는 무척 괴상하지만, 프로퍼티 상속의 개념을 이해하는데 도움이 될 것이다.

class Foo {
  var a: Int = 10 {
    willSet {
      print("[Foo] a will change")
    }
    didSet {
      print("[Foo] a did change")
    }
  }
}

class Bar : Foo {
  override var a: Int { // 오버라이드할 때 디폴트값을 바꾸는 것은 허용되지 않는다. 
    get { return super.a * 10 }
    set { super.a = newValue / 10 }
    willSet {
      print("[BAR] a will change")
    }
    didSet {
      print("[BAR] a did change")
    }
}

let b = Bar()
print(b.a)
b.a = 121
print(b.a) // "120"

동작을 설명하면 다음과 같다.

  1. Bar의 getter는 부모 클래스의 저장공간에 대해서 10배 뻥튀기한 값을 리턴한다.
  2. Bar의 setter를 쓰면 부모 클래스의 setter를 이용해서 10분의 1로 줄어든 값을 저장한다.
  3. 이 때 [BAR] a will change -> [FOO] a will Change -> Foo.a 의 값이 12가 됨 -> [FOO] a did change -> [BAR] a did change 순으로 흐름이 진행된다.
  4. 최종적으로 b.a를 출력하면 부모의 a(12가 저장되어 있는)를 가져와서 120으로 뻥튀기해서 출력할 것이다.

클래스의 상속과 프로퍼티의 오버라이딩은 제법 복잡한 이슈인데, 몇 가지 원칙만 소개하자면 다음과 같다.

  1. 프로퍼티는 저장/계산 속성과 상관없이 표면적으로는 getter / setter를 하나의 묶음으로 취급하는 접근자 번들이다.
  2. 여러분이 변태가 되는 것을 막기위해 write-only 프로퍼티 (setter만 존재하는)는 작성이 불가능하다.
  3. 프로퍼티의 오버라이딩은 접근자를 오버라이딩한다는 의미이다.
  4. 위 규칙에 의해 저장 프로퍼티를 계산 프로퍼티로 오버라이드하는 것은 가능하다. 하지만 그 반대의 경우는 허용하지 않는다.
  5. read-write 프로퍼티를 readonly 프로퍼티로 오버라이드할 수 없다.
  6. 프로퍼티 옵저버는 현단계의 접근자와 짝지어지며 상속되지 않는다. (현단계의 setter가 부모클래스의 setter를 호출하면 부모의 옵저버가 실행되는 원리)
  7. 계산 프로퍼티에서 setter에서 변경 전/후의 조작을 수행할 수 있으므로 계산 프로퍼티에는 옵저버를 설치할 수 없다.
  8. 하지만 상속받은 계산 프로퍼티에 대해서는 옵저버 설치가 가능하다.

 

 


  1. 대신에 서브클래스에서 init 할 때 super.init()을 호출한 이후에 변경하려하면 옵저버가 실행된다! 

Objective-C의 선언 프로퍼티 (Declared Property)에 대해

Objective-C의 객체 인스턴스에 어떠한 변수 값을 포함하고자 한다면 클래스 내에 인스턴스 변수를 선언하고, 여기에 값을 저장할 수 있다. (흔히 애플 문서등에서는 이런 인스턴스 변수를 ivar라 한다.)

기본적으로 객체의 내부에서 선언되는 인스턴스 변수는 private하며 객체의 외부에서는 내부의 인스턴스 변수값에 액세스하는 것이 차단된다. 따라서 객체의 외부에서 인스턴스 변수의 값을 읽거나 쓰기 위해서는 클래스가 해당 인스턴스를 읽게하거나, 쓰게 해주는 API를 제공해야 한다. 이렇게 객체가 자신의 내부 속성값에 대해 읽거나 쓰게 하기 위해 제공하는 메소드를 접근자(accessor) 메소드라고 한다.

Objective-C의 선언 프로퍼티 (Declared Property)에 대해 더보기

atomic 프로퍼티의 접근자 메서드

많은 iOS책들에서 프로퍼티는 대부분 nonatomic 속성으로 지정한다. 이 때 nonatomic은 항상 명시하는데, 이는 Objective-C에서 프로퍼티의 디폴트는 atomic이기 때문이다. 그런데 이게 정말, nonatomic이어야 하기 때문에 이렇게 하는 것인지, 맞는 것인지는 좀 아리송한데…

atomic은 예전에도 한 번 설명한 적이 있는데, 접근자를 호출한 스레드에서 그 프로퍼티를 읽거나 쓰는 중간에는 다른 스레드나 큐에서 액세스 하지 못하도록 한다는 의미이다. 따라서 멀티 스레드나 백그라운드 작업을 염두에 둔다면 atomic 속성에 대해서도 고려를 해야 한다는 의미이다.

어떤 스레드에서 객체의 프로퍼티에 접근할 때 다른 스레드에서의 접근을 막는 것을 공교롭게도 “동기화”라고 표현하더라. 정말 와닿지 않는 표현이다. 서로 다른 스레드에서 접근할 때 같은 값을 돌려주는 것을 보장하는 것도 아닌데 말이다. 어쨌든, atomic으로 설정한 프로퍼티의 setter나 getter는 다음과 같은 모양이 될 것으로 추측된다.

-(NSNumber*)myIntegerValue
{
    @synchronized(self) {
        return _myIntegerValue;
    }
}
-(void)setMyIntegerValue:(NSInteger*)newIntegerValue;
{
    @synchronized(self) {
        _myIntegerValue = newIntegerValue;
    }
}

저렇게 synchronized(self) 라고 하면 저 블럭 사이의 코드가 실행될 때는 self 전체가 이 접근자를 호출한 스레드에 대해 atomic해진다. 다른 스레드에서 접근할 때에, 이 스레드는 요 블럭 사이의 코드가 모두 실행을 완료할 때 까지 기다리게 된다.

일반적으로 atomic하게 선언된 프로퍼티에 대해 접근자 메소드를 커스터마이징하려고 하면 오류가 난다. 그래서 왠만하면 nonatomic으로 사용하는게 역시나 정신 건강에 좋을 것 같다. 물론 atomic한 접근이 필요한 케이스도 분명 있지만, 내가 저런 경우를 만날만한 앱을 만들일은 없을 것 같다.

키밸류 코딩이란

NSObject는 Objective-C의 표준 라이브러리라 할 수 있는 Foundation에서 가장 기본이 되는 최상위 클래스에 해당한다. 커스텀 클래스를 만들 때 아무 생각없이 상속받는 이 클래스는 Objective-C에서 클래스라는 것이 마땅히 갖추어야 하는 여러 가지 기능들을 미리 구현해둔 것이 아주 많이 있다. 그 중에서도 키밸류 코딩이라는 기술을 위한 기본적인 기능이 NSKeyValueCoding이라는 비정규 프로토콜에 정의되어 있고, NSObjects는 이를 따르고 있다. 따라서 몇가지 간단한 규칙을 지키면서 프로퍼티를 정의하기만 하면, 우리가 작성하는 모든 클래스의 프로퍼티들이 키밸류 코딩 호환이 될 수 있다. 그렇다면 키밸류 코딩은 무엇이고, 또 어떻게 활용되는 것인지에 대해서 살펴보자.

프로퍼티

키밸류 코딩은 어떠한 객체의 프로퍼티 값에 대해서 미리 정해진 접근자가 아닌 해당 프로퍼티의 이름 키를 사용해서 특정한 객체의 프로퍼티를 액세스하는 것을 말한다. 예를 들어서 어떤 클래스 Foo 에서 bar 라는 프로퍼티를 가지고 있다고 가정하고, 클래스 Foo를 작성하는 과정을 살펴보자. 먼저 Objective-C에서 어떤 클래스가 임의의 값을 저장하고 있으려면 그 값을 저장할 스토리지 변수가 필요하다. Objective-C의 클래스는 본질적으로 그 내부를 알 수 없는 불투명 구조체의 포인터이며, 구조체 내부의 멤버 변수는 인터페이스 선언부 최상단에 블럭을 사용해서 선언한다. 그리고 이렇게 선언된 멤버 변수는 외부와 완전히 격리되면서 외부에서는 액세스할 수 없고, 어떤 멤버 변수를 가지고 있는지 조차 알 수 없다. (이렇게 선언된 멤버 변수는 인스턴스 변수라 하고 흔히 ivar 라 지칭한다.) 따라서 이 변수에 값을 세팅하거나, 변수 값을 알아낼 수 있는 두 개의 메소드가 필요하다.

@interface Foo: NSObject
{
  NSString* _bar;
}
- (NSString*)bar;
- (void)setBar:(NSString*)newValue;
@end

Foo의 외부에서 해당 프로퍼티를 bar라는 이름으로 액세스하고, bar를 세팅하는 메소드를 -setBar:라고 이름붙였다. 멤버변수의 이름을 사실상 무엇이 되더라도 무관한데, 관습적으로는 getter의 이름과 똑같이 하거나 그 앞에 언더스코어를 붙인다. (언더스코어를 붙이는 이름이 멤소드 이름과 혼동을 줄이기 때문에 조금 더 권장된다.)

만약 이 bar라는 프로퍼티가 copy 시멘틱을 따른다고 하면, 두 메소드의 구현은 다음과 같이 작성될 것이다.

@implementation Foo
/// 초기화 시에 ivar를 초기화한다.
- (instancetype)init {
  self = [super init];
  _bar = nil;
}

- (NSString*)bar { return _bar; }
- (void)setBar:(NSString*)newValue]
{
  NSString* newBar = [[newValue copy] retain];
  [_bar release];
  _bar = newBar;
}
...
@end

즉 어떤 오브젝트가 그 내부에 어떤 값을 저장할 수 있고, 객체 외부에서 그 값을 액세스하려고 한다면 이 클래스는 다음의 세 가지 조건을 갖추어야 한다.

  1. 값을 저장할 수 있는 스토리지 변수
  2. 스토리지 변수를 액세스할 수 있는 getter 접근자
  3. 스토리지 변수를 업데이트할 수 있는 setter 접근자

만약 getter/setter 접근자가 모두 없는 경우라면, 해당 ivar는 클래스 내부에서만 참조할 수 있고, 외부에서는 액세스할 수 없는 값이 된다. 또 getter 메소드만 제공되는 경우라면, 객체 외부에서는 그 값을 getter 메소드를 통해서 읽을 수는 있지만 업데이트를 할 수 없는 읽기 전용의 값이 될 것이다. 이것이 Objective-C의 선언 프로퍼티의 핵심 내용이다.

따라서 어떤 클래스가 bar 라는 프로퍼티를 가지고 있다는 것은 그 프로퍼티가 -bar 혹은 -setBar: 라는 접근자 메소드를 가지고 있음을 의미한다. 그리고 그 객체에서 해당 프로퍼티를 액세스하는 것은 해당 접근자 메소드를 호출해야 하는 일이고, 따라서 객체로부터 어떤 값을 얻어와서 사용한다는 것은 “하드 코딩된 코드에서 미리 정해진 접근자 메소드를” 사용해야 한다는 것이다.

키밸류 코딩 – 문자열 기반 이름으로 동적인 프로퍼티 액세스

그런데, 임의의 객체 인스턴스 zoo 가 있다고 하자. 이 객체로부터 어떤 프로퍼티를 액세스해서 그 값을 얻으려고 한다. 그런데 어떤 프로퍼티를 가져올 것인지 혹은 갱신할 것인지가 컴파일 타임에 결정되지 않는다면 어떻게 해야 할까? 즉 “어떤 접근자 메소드를 호출할 것인지”를 코드를 작성하는 시점에 알 수 없는 것이다. “pee”라는 이름의 프로퍼티일 수도 있고, “tee”라는 이름의 프로퍼티 일수도 있는 것이다. 물론 객체 zoo가 이러한 접근자 메소드를 갖고 있는지 아닌지 여부조차 알 수 없을 수도 있다.

이처럼 컴파일 타임에 정의되지 않은 접근자 이름을 사용해서 런타임에 특정한 이름의 프로퍼티에 접근할 수 있는 기술이 키밸류 코딩이다. 키 밸류 코딩은 간단히 다음의 네 개의 메소드에 의존한다.

  • - (id)valueForKey:(NSString*)key / -(id)valueForKeyPath:(NSString*)keyPath
  • - (void)setValue:(id)obj forKey:(NSString*)key / - (void)setValue:(id)obj forKeyPath:(NSString*)keyPath

이 메소드들은 NSObject에 의해서 이미 구현되어 있다. 이 메소드들을 호출하여 성공적으로 특정한 프로퍼티에 액세스하기 위해서는 처음에 프로퍼티를 정의할 때, ivar와 접근자 메소드들의 이름이 중요하다.

  • valueForKey: 에서 키이름이 getter 메소드와 같거나
  • 키 이름과 동일한 ivar 혹은 앞에 언더스코어가 붙은 키 이름의 ivar가 있다.
  • setValue:forKey:는 키 이름을 첫글자를 대문자로 바꾸고 그 앞에 set-을 붙인 setter 메소드가 있다.

이러한 가정을 두고 있는 것이다. 만약 [zoo getValueForKey:@"bar"] 라고 했을 때,   zoo 가 Foo의 인스턴스라면, 이 메시지는 Objective-C 런타임 내부에서 [zoo bar] 로 번역될 것이다. 그리고 [zoo setValue:@"hello" forKey:@"bar"]라는 메시지를 받는다면 이는 다시 [zoo setBar:@"hello"];로 변경되어 호출될 것이다.1

키밸류 코딩을 따르는 방법

키밸류 코딩 호환 클래스를 작성하는 방법은 간단하다. 키밸류 코딩은 결국 키 이름을 기반으로 그에 매칭되는 접근자 메소드 및 인스턴스 변수를 런타임이 동적으로 찾아서 액세스해주는 기술이기 때문에 어떤식으로 프로퍼티 이름을 짓느냐는 것만, 관습을 따르면 되며, 그 관습이란 앞서 소개한 Foo의 bar와 같다.

  • 기본적으로 getter 이름이 프로퍼티 이름이며, 이것이 곧 키 이다.
  • setter 이름은 setKeyName: 과 같은 식으로 작성한다. getter이름의 첫글자를 대문자로 바꾸고 앞에 set을 붙인다.
  • ivar 이름은 getter이름과 똑같거나, 앞에 언더스코어를 붙인다.

그리고 이 관습은 @property 문법을 쓰면 자동으로 지켜진다.

@interface Foo: NSObject
@property (copy, nonatomic) NSString* bar;
@end

이상의 코드만으로 키밸류 코딩에서 요구하는 인스턴스변수, getter 메소드, setter 메소드를 모두 작성한 것과 다름없는 결과를 얻을 수 있다. 이것은 언어의 기능이라기보다는 컴파일러가 소스코드를 처리하기 직전에 자동으로 관련 코드를 만들어서 삽입해준다고 보면 된다. (이전에는 @synthesize bar; 같은 구문을 구현부에 써야했는데, LLVM 컴파일러는 이런 처리도 모두 자동으로 해주기 때문에 굳이 쓸 필요없다.)

키밸류 코딩은 왜 중요한가

그렇다면 키밸류 코딩은 왜 중요한가? 이것은 특정한 프로퍼티가 변경될 때, 자동으로 옵저버들에게 통지가 가는 키밸류 옵저빙을 비롯하여, 이 기술을 기반으로 하고 있는 코코아 바인딩등에서 기본 가정으로 “모든 참여 객체가 KVC/KVO 호환이다”라는 것을 가정하기 때문이다.

  • 키밸류 코딩 이름 규칙을 지원하면 valueForKey:, setValueForKey:는 따로 구현하지 않더라도 자동으로 지원된다.
  • 키밸류 코딩 규칙을 따르더라도 _bar = @"hello";와 같이 인스턴스 변수를 직접 변경해버리면 이는 KVO와 호환되지 않는다.
  • KVO에서는 반드시 [foo setValue:@"hello" forKey:@"bar"]를 쓰지 않아도 된다. [foo setBar:@hello]라고만 써도, 런타임에서 자동으로 통지를 보낼 수 있다. self.bar = @"hello"; 역시 setter 메소드 호출과 1:1로 치환되므로 KVO 호환이 된다. 이는 KVC 호환인 메소드는 필요한 경우 런타임에 의해 자동으로 다른 내부 메소드로 치환되기 때문에 적용가능하다. 물론 메소드 이름이 정해진 규칙을 벗어나면 이러한 기능은 지원되지 않는다.

기본적인 키밸류 코딩은 특정한 단일 값 프로퍼티의 변경을 런타임에서 동적으로 관리하는 수준에서 적용된다. 하지만 Foundation에서는 배열이나 Set과 같은 집합형식 자료 구조에 대해서도 KVC/KVO를 지원한다. 이는 단순히 이름 규칙만으로는 지원될 수 없으며, 별도의 메소드들을 추가로 작성해주어야 하는데 (대부분 NSMutableArray, NSMutableSet의 메소드들 간단힌 래핑하는 수준의 구현이다.) 이를 지원하도록 하는 것은 다음 기회에 추가로 소개하도록 하겠다.

참고자료

 

 

 

 

 


  1.   물론 키밸류 코딩은 이렇게 간단한 일차원적 변환 이상의 것이다. 실질적으로 @property 문법이 확립되어 적용되기 이전부터 존재해온 기술이기 때문에 탐색 패턴은 좀 더 많은 경우를 순차적으로 따르게 된다.