본문 바로가기
문법관련/Swift

프로퍼티 (꼼꼼한 재은 씨의 Swift : 문법편)

by print_soo 2022. 3. 31.

프로퍼티를 우리말로 번역하면 값을 저장하기 위한 목적으로 클래스와 구조체 내에서 정의된 변수나 상수라고 되어있지만 이는 프로퍼티가 하는 역할의 일부만 설명한 것이다.

 

프로퍼티의 역할은 클래스나 구조체에서 프로퍼티가 하는 정확한 역할은 값을 제공하는 것이다. 

 

값을 제공하는 것과 저장하는 것은 어떤 차이가 있을까?

값을 저장하는 것이 아니라 제공하는것에 목적이 있다는 것은 프로퍼티중 일부는 값을 저장하지는 않지만 제공하는 특성을 가지고 있다는 것이다. 

 

위에서 설명한거 처럼 프로퍼티에는 값을 저장x + 제공하는 것과 저장 + 제공하는 것이 있는데 이때 저장 + 제공하는 것을 저장 프로퍼티, 저장x + 제공하는 것을 연산 프로퍼티라고 한다. 

 

[저장 프로퍼티 vs 연산 프로퍼티]

저장 프로퍼티
1. 입력된 값을 저장하거나 저장된 값을 제공하는 역할
2. 상수 및 변수를 사용해서 정의 가능
3. 클래스와 구조체에서는 사용이 가능하지만, 열거형에서는 사용할 수 없음
연산 프로퍼티
1. 특정 연산을 통해서 값을 만들어 제공하는 역할
2. 변수만 사용해서 정의 가능
3. 클래스, 구조체, 열거형 모두 사용가능

 

저장 프로퍼티나 연산 프로퍼티는 개별 인스턴스에 소속되어 값을 저장하거나 연산 처리하는 역할을 한다. 따라서 프로퍼티를 사용하려면 인스턴스가 필요하다. 그래서 인스턴스를 생성한 다음 이 인스턴스를 통해서 프로퍼티를 참조하거나 값을 할당해야 한다.

 

이렇게 인스턴스에 소속되는 프로퍼티를 인스턴스 프로퍼티라고 한다. 

예외적으로 일부 프로퍼티는 클래스와 구조체 자체에 소속되어 값을 가지기도 하는데 이런 프로퍼티를 타입 프로퍼티라고 한다. 

 

프로퍼티는 클래스의 정의 구문내부에 작성되어야 하기 때무에 작성 위치가 중요하다. (매소드 내부에 작성 x)

메소드 내에서 작성될 수 있지만 이것은 프로퍼티가 아니라 지역변수에 불과하다. 따라서 프로퍼티는 클래스 내부에, 메소드 외부에 정의 해야한다.

 

부가적으로 프로퍼티 값을 모니터하기 위해 프로퍼티 옵저버(Property Observer)를 정의하여 사용자가 정의한  특정 액션을 반응하도록 처리할 수 있다. 


[저장 프로퍼티]

저장 프로퍼티는 클래스 내에서 선언된 변수나 상수를 부르는 이름이다. 

 

저장 프로퍼티도 다른 변수나 상수처럼 선언할 때 초기값을 할당해줄 수 있다. 하지만 반드시 선언하는 시점에서 초기값을 할당해야 하는 것은 아니다. 초기화 구문에서 초기값을 설정해도 된다.  

 

하지만 클래스에서 프로퍼티를 선언할 때 초기값을 함께 할당해주지 않으면 신경 써야할 것들이 있어서 주의가 필요하다. 

 

우선 프로퍼티 선언시 초기값이 할당되지 않았을 경우 반드시 옵셔널 타입을 선언해주어야 한다. (이유: 스위프트에서는 클래스의 프로퍼티에 값이 비어 있다면 인스턴슬르 생성할 때 무조건 nil값으로 초기화 되기 때문이다.) 

 

옵셔널 타입으로 프로퍼티를 선언할 때 일반적인 옵셔널 타입과 묵시적 옵셔널 해제 타입 중에서 선택해서 정의할 수 있다. 

묵시적 옵셔널 타입 해제 구문은 값을 사용할 시점에서는 절대 nil이 되지 않지만, 선언할 때는 초기값을 할당해줄 수 없어서 옵셔널로 선언해야 하는 경우 사용된다. 묵시적 옵셔널  타입으로 지정해두면 이 값을 사용할 때 옵셔널을 해제 처리할 필요 없이 사용할 수 있어서 편리하다.

 

초기값을 주지 않고 문제를 일으키지 않게 하는 마지막 방법은 초기화 구문에서 프로퍼티의 값을 초기화 시켜주는 것이다.

어차피 클래스의 프로퍼티는 인스턴스를 생성할 때 초기화되기 때문에 프로퍼티의 초기값은 인스턴스를 생성하기 전까지만 할당해 줄 수 있으면 문제가 되지 않는다. 따라서 초기화 구문 내에서 프로퍼티의 값을 할당해줄 수 있으면 이 프로퍼티의 타입은 옵셔널로 선언하지 않아도 된다. 

 

한번 예제를 들어보자. 

class User {
    var name: String
}

아래의 클래스를 작성하고 실행을 하면 "Class 'User' has no initializers" 라는 오류가 뜬다. 즉, 초기화가 되지 않았다는 것이다. 

 

이를 해결하기 위해서는 아래의 세 가지 방법을 사용해야한다.

 

<1. 초기화 구문을 작성하고 그 안에서 초기값을 할당한다.>

 

class User {
    var name: String

    init() { //초기화 구문
        self.name = ""
    }
    
}

init()은 초기화 구문인데, 형태가 메소드와 같아서 초기화 메소드라고 불린다. 하지만 일반적인 메소드와는 다르게 직접 호출되기보다는 인스턴스가 생성될 때 간접적으로 호출된다. 따라서 init 메소드 내부에 작성된 구문은 인스턴스가 생성될 때 실행된다.

 

<2. 프로퍼티를 옵셔널 타입으로 바꿔준다.>

class User {
    var name: String? // 옵셔널 타입
}

//또는

class User {
    var name: String! // 묵시적 옵셔널 해제 타입 -> 프로퍼티 값이 nil되지 않을 자신이 있을 때 사용
}

 

<3. 프로퍼티에 초기값을 할당해준다. >

class User {
    var name: String = ""
}

저장 프로퍼티도 변수와 상수 모두 선언할 수 있다. 해당 저장 프로퍼티가 있는 구조체 또는 클래스의 인스턴스도 상수나 변수 모두에게 할당할 수 있다. 그러면 어떤 경우에 프로퍼티 값이 변경될까?

구조체 프로퍼티의 값 변경 가능 여부 저장 프로퍼티
변수 상수
인스턴스 변수 변경 O 변경 X
상수 변경 X 변경 X

 

클래스 프로퍼티의 값 변경 가능 여부 저장 프로퍼티
변수 상수
인스턴스 변수 변경 O 변경 X
상수 변경 O 변경 X

왜 구조체와 클래스 프로퍼티의 값 변경 가능 여부가 다를까?

그 이유는 구조체와 클래스의 값 전달 방식이 다르기 때문이다. 구조체는 값에 의한 전달 방식으로 인스턴스가 변수나 상수에 할당되고, 클래스는 참조에 의한 전달 방식으로 인스턴스의 레퍼런스가 변수나 상수에 할당 되기 때문이다. 

 

따라서 구조체는 저장 프로퍼티의 값이 바뀌면 상수에 할당된 인스턴스 전체가 변경되고, 클래스는 저장 프로퍼티의 값이 바꾸더라고 할당된 인스턴스의 레퍼런스에는 반영되지 않는다. 


[지연 저장 프로퍼티]

일반 저장 프로퍼티는 클래스 인스턴스가 처음 생성될 때 함께 초기화되지만, 저장 프로퍼티 정의 앞에 lazy라는 키워드를 붙이면 달라진다.

 

이 키워드를 붙이면 저장 프로퍼티의 초기화가 지연되는데 클래스의 인스턴스가 생성되어 모든 저장 프로퍼티가 만들어 지더라도 lazy 키워드가 붇은 프로퍼티는 선언만 될 뿐 초기화 되지 않고 계속 대기하고 있다가 프로퍼티가 호출되는 순간에 초기화가 된다.

 

예제로 살펴보자.

class OnCreate {
    init() {
        print("OnCreate!")
    }
}

class LazyTest {
    
    var base = 0
    lazy var late = OnCreate()
    
    init() {
        print("Lazy Test")
    }
    
}

//LazyTest 클래스를 인스턴스로
let lz = LazyTest()
-----------------------------------
// 실행결과

// Lazy Test

우선 클래스를 인스턴스로 만들었더니 LazyTest클래스의 초기화 구문만 실행되고 late 프로퍼티의 OnCreate는 작동하지 않았다. 

 

이제 late프로퍼티를 호출해서 결과를 보자.

class OnCreate {
    init() {
        print("OnCreate!")
    }
}

class LazyTest {
    
    var base = 0
    lazy var late = OnCreate()
    
    init() {
        print("Lazy Test")
    }
    
}

//LazyTest 클래스를 인스턴스로
let lz = LazyTest()

//late 프로퍼티 호출
lz.late
-------------------------
// 실행결과

//Lazy Test
//OnCreate!

이렇게 late를 호출하니  OnCreate 인스턴스가 초기화 된 것을 확인할 수 있다. 

 

[클로저를 이용한 저장 프로퍼티 초기화]

저장 프로퍼티 중 일부는 연산이나 로직 처리를 통해 얻어진 값을 이용하여 초기화해야 하는 경우가 있다. 이때 스위프트에서는 클로저를 사용하여 필요한 로직을 실행한 후 반환되는 값을 이용하여 저장 프로퍼티를 초기화 할 수 있도록 지원한다. 

// 구문 형식
let/var 프로퍼티명: 타입 = {
	정의 내용
    return 반환값
}()

이렇게 정의된 클로저 구문은 클래스나 구조체의 인스턴스가 생성될 때 함께 실행되어 초기값을 반환하고, 이후로는 해당 인스턴스 내에서 재실행되지 않는다.

 

예제를 살펴보자.

class PropertyInit {
    
    var value01: String! = { //변수
        print("1번 실행")
        return "value01"
    }()
    
    let value02: String! = { //상수
        print("2번 실행")
        return "value02"
    }()
    
    lazy var value03: String! = { //지연
        print("3번 실행")
        return "value03"
    }()
    
}

이제 인스턴스를 생성해서 실행결과를 살펴보자. 

class PropertyInit {
    
    var value01: String! = {
        print("1번 실행")
        return "value01"
    }()
    
    let value02: String! = {
        print("2번 실행")
        return "value02"
    }()
    
    lazy var value03: String! = {
        print("3번 실행")
        return "value03"
    }()
    
}

let s = PropertyInit()
----------------------------------
// 실행결과

//1번 실행
//2번 실행

위의 코드처럼 인스턴스만 생성했을 뿐인데 두개의 클로저가 작동되어 두개의 메세지가 출력되었다.

 

현재 2번 실행만 나왔는데 3번 실행은 왜 출력되지 않는걸까? 

그 이유는 lazy를 사용했기 때문이다. lazy를 사용하면 해당 프로퍼티를 호출해야 초기화가 되기 때문에 3번 실행은 출력되지 않은것이다. 

class PropertyInit {
    
    var value01: String! = {
        print("1번 실행")
        return "value01"
    }()
    
    let value02: String! = {
        print("2번 실행")
        return "value02"
    }()
    
    lazy var value03: String! = {
        print("3번 실행")
        return "value03"
    }()
    
}

let s = PropertyInit()
s.value03
------------------------
// 실행결과

//1번 실행
//2번 실행
//3번 실행

이렇게 프로퍼티를 호출하게 되면 3번 실행이 출력되게 된다. 

 


[연산 프로퍼티]

연산 프로퍼티는 필요한 값을 제공한다는 점에서 저장 프로퍼티와 같지만, 실제 값을 저장했다가 반환하지는 않고 대신 다른 프로퍼티의 값을 연산처리하여 간접적으로 값을 제공한다.

이때, 프로퍼티의 값을 참조하기 위해서 내부적으로 사용하는 구문이 get 구문이다. 함수와 비슷해서 내부적으로 return 키워드를 사용하여 값을 반환하는데, 여기서 반환하는 값이 프로퍼티가 제공하는 값이다.

 

get구문은 연산 프로퍼티에서 필수 요소이다. 만약 get 구문이 생략된다면 연산 프로퍼티가 값을 반환하는 기능 자체를 갖지 못하기 때문이다.

 

또한, 연산프로퍼티는 선택적으로 set 구문을 추가할 수도 있다. set 구문은 연산 프로퍼티에 값을 할당하거나 변경하고자 할 때 실행되는 구문이다. get구문은 연산 프로퍼티에서 필수 요소지만 set 구문은 선택적이어서 생략가능하다. 하지만 생략할 경우 외부에서 연산 프로퍼티에 값을 할당할 수 없으며, 내부적인 연산 처리를 통해서 값을 제공받는 읽기 전용 프로퍼티가 된다. 

 

연산 프로퍼티는 아래와 같이 정의할 수 있다.

class/struct/enum 객체명 {
    
    var 프로퍼티명 : 타입 {
        get { 
            필요한 연산 과정
            return 반환값
        }
        set(매개변수명) {
            필요한 연산구문
        }
    }
}

연산 프로퍼티는 다른 프로퍼티에 의존적이거나, 특정 연산을 통해서 얻을 수 있는 값을 정의할 때 사용된다. 

대표적으로 개인정보중 나이가 있다. 나이는 출생연도에 의존적이며, 현재 연도를 기준으로 계산해야 하므로 매년 그 값이 달라진다. 

 

따라서 예제로 나이 계산을 해보자.

struct UserInfo {
    
    var birth: Int! //저장 프로퍼티 : 태어난 연도
    
    var thisYear : Int! { // 연산 프로퍼티 : 올해가 몇년도 인지 계산
        get {
            let df = DateFormatter()
            df.dateFormat = "yyyy"
            return Int(df.string(from: Date()))
        }
        
    }
    
    var age: Int { //연산 프로퍼티 : 올해 - 태어난 연도 + 1
        get {
            return (self.thisYear - self.birth) + 1
        }
        
    }
    
}

let info = UserInfo(birth: 2001)
print("사용자의 나이는 \(info.age)세 입니다.")
----------------------------------------
//실행 결과

//사용자의 나이는 22세 입니다.

 

이제는 좀 더 복잡한 예제를 다루어보자. 특정 사각형에 대한 정보를 저장하는 구조체에 연산 프로퍼티를 사용해서 사각형의 중심 좌표를 구하는 예제를 다루어보자. 

struct Rect {
    var originX: Double = 0.0, originY: Double = 0.0
    
    var sizeWidth: Double = 0.0, sizeHeight: Double = 0.0
    
    var centerX: Double { //연산 프로퍼티 - x 중심
        get {
            return self.originX + (sizeWidth / 2)
        }
        set(newCenterX) { //값 할당 또는 변경
            originX = newCenterX - (sizeWidth / 2)
        }
    }
    
    var centerY: Double { //연산 프로퍼티 - y줌심
        get {
            return self.originY + (sizeHeight / 2)
        }
        set(newCenterY) { //값 할당 또는 변경
            originY = newCenterY - (sizeHeight / 2)
        }
    }
    
}

var square = Rect(originX: 0.0, originY: 0.0, sizeWidth: 10.0, sizeHeight: 10.0)
print("\(square.centerX), \(square.centerY)")
------------------------------------------------------------------------------------
//실행 결과

//5.0, 5.0

중심 좌표를 연산 프로퍼티로 설정한 이유는 사각형의 가로, 세로 길이에 따라서 좌표가 변경되기 때문이다. 

 

set 구문은 위에서 선택적이라고 했다. 그렇다면 어떤 상황에서는 사용하면 안될까? 대표적으로 배열의 크기를 알려주는 Count가 있다. 

count 프로퍼티는 배열애 들어간 아이템의 개수와 같아야 한다.

 

하지만 만약 임의로 count 값을 늘리면 실제 배열 아이템의 수와 count의 값이 다르기 때문에 문제가 발생하게 된다. 따라서 우리는 배열 count 프로퍼티는 사용자가 임의로 수정할 수 없도록 제약을 줘야한다. 

 

이때 우리는 set 구문을 제거함으로 위의 문제를 해결할 수 있다. set 구문을 삭제하면 프로퍼티를  통해 값을 읽기만 할 뿐 할당은 할 수 없기 때문에 우리는 이 프로퍼티를 읽기 전용 프로퍼티(get - only 프로퍼티)라고 한다. 

 

또한 읽기 전용 프로퍼티는 get 구문도 생략할 수 있다. 

 

[프로퍼티 옵저버(Property Observer)]

프로퍼티 옵저버는 특정 프로퍼티를 계속 관찰하고 있다가 프로퍼티의 값이 변경되면 이를 알아차리고 반응한다.

 

 

 

willSet 옵저버가 실행될 때 프로퍼티에 대입되는 값이 옵저버의 실행 블로에 매개 상수 형식으로 함께 전달된다. (프로퍼티 옵저버(택배기사)라는 사람이 값(택배)을 들고 프로퍼티(고객)에게 같이 전달.) 

 

단, 전달된 값은 참조할 수 있지만, 수정할 수는 없다.  또한 매개 상수에 이름을 부여하는 것은 선택사항이다. 따라서 매개 상수의 이름을 부여하지 않을 때는 매개 상수의 이름과 괄호를 모두 생략해주면 된다. 

 

물론 이름을 생략한다고 값이 전달되지 않는 것은 아니다. 값은 여전히 매개상수 형태로 전달 되지만 시스템에서 사용하는 기본 상수명이 newValue라는  이름으로 전달되어 필요한 작업에서 처리할 수 있다.

 

willSet 옵저버의 정의 구문은 아래와 같다. 

var <프로퍼티명> : <타입> [ = <초기값> ]{
	willSet [ (<인자명>) ]{
		<프로퍼티 값이 변경되지 전에 실행할 내용>
	}
}

대괄호 []에 둘러싸여서 표시되는 부분은 생략 가능한 부분이다. 따라서 실제로 옵저버 구문을 작성할 때는 대괄호를 표시하지 않는다. 


didSet 옵저버도 willSet과 비슷한 특성을 지닌다. 하지만 이 옵저버는 새로 할당된 값이 아닌 기존에 저장되어 있던 값이 매개상수 형태로 전달된다. 또한 매개상수에 이름을 부여할 수 있지만 부여하지 않아도 된다. 만약 부여하지 않았다면 oldValue라는 이름으로 자동 전달된다. 

 

그렇다면 didSet구문에서 새로 할당된 값이 필요할 때는 어떻게 해야할까?

이럴 때는 프로퍼티 자체를 그냥 참조하면 된다. 왜냐하면 이미 새로운 값이 프로퍼티에 저장되어 있는 상태이기 때문이다. 

 

didSet 옵저버의 정의 구문은 아래와 같다.

var <프로퍼티명> : <타입> [ = <초기값> ]{
	didSet [ (<인자명>) ]{
		<프로퍼티 값이 변경되지 전에 실행할 내용>
	}
}

 

프로퍼티 옵저버 \ 값 예전 값 새로운 값
wilSet 프로퍼티를 참조 newValue를 참조
didSet oldValue를 참조 프로퍼티를 참조

 

이제 예제를 보자. 아래의 예제에서는 willSet과 didSet을 함께 사용하지만 무조건 함께 사용할 필요는 없다. 

struct Job {
    var income: Int = 1500000 {
        willSet(newIncome) { //새로운 매개상수 이름 설정
            print("이번달 월급은 \(newIncome)원 입니다.")
        }
        
        didSet {
            if income > oldValue { //매개 상수 이름을 설정하지 않아서 oldValue로 자동 설정
                print("월급이 \(income - oldValue)원 증가하셨습니다.")
            } else {
                print("월급이 \(oldValue - income)원 감소하셨습니다.")
            }
        }
        
    }
}

var s = Job()
s.income = 1000000
------------------------------------------------------------------------------
// 실행 결과

// 이번달 월급은 1000000원 입니다.
// 월급이 500000원 감소하셨습니다.

willSet 은 프로퍼티에 할당될 새로운 값을 우리가 선언한 매개상수인 newIncome으로 전달받는다. 이와는 달리 didSet 구문에서는 매개상수 선언을 생략했다. 이 경우 oldValue라는 기본 이름으로 사용하면 바뀌기 전의 income프로퍼티 값을 읽어 올 수 있다. 


[타입 프로퍼티]

앞서 배웠더 저장 프로퍼티나 연산 프로퍼티는 클래스 또는 구조체 인스턴스를 생성한 후 이 인스턴스를 통해서만 참조할 수 있는 프로퍼티였다. 따라서 인스턴스 프로퍼티라고 부른다. 

 

하지만 경우에 따라서 객체자체(클래스, 구조체, 열거형)에 관련된 값을 다루어야할 때가 있는데 이때는 인스턴스를 생성하지 않고 클래스나 구조체 자체에 값을 저장하게 되며 이를 타입 프로퍼티라고 부른다. 

 

타입 프로퍼티는 클래스나 구조체 자체에 속하는 값이므로 인스턴스를 생성하지 않고 클래스나 구조체 자체에 저장하게 되며, 저장된 값은 모든 인스턴스가 공통으로 사용할 수 있다. 인스턴스 프로퍼티는 개별 인스턴스마다 다른 값을 저장할 수 있어서 하나의 인스턴스에서 변경한 프로퍼티의 값은 그 인스턴스 내에서만 유지될 뿐 나머지 인스턴스에는 영향을 미치지 않지만, 타입 프로퍼티는 인스턴스가 아무 많더라도 모든 인스턴스가 하나의 값을 공용으로 사용한다. 

 

쉽게 말하자면, 인스턴스 프로퍼티는 나무의 가지들이기 때문에 한 가지의 상태가 변해도 다른 가지에 다른 영향을 주지 않지만 타입 프로퍼티는 나무의 뿌리라서 뿌리가 변하게 되면 나무의 가지인 인스턴스 모두에게 영향을 끼친다.

 

타입 프로퍼티를 선언할 때는 두가지 방법이 있다.

1. 사용할 타입 프로퍼티 앞에 static 키워드만 추가해주면 된다. (저장 프로퍼티연산 프로퍼티 모두 가능) 

 

static let/var 프로퍼티명 = 초기값

 

2. 사용할 타입 프로퍼티 앞에 class 키워드만 추가해주면 된다. (연산 프로퍼티만 가능) 

class let/var 프로퍼티명 : 타입 {
	get {
    	return 반환값
    }
    set {
    }
}

위에서 설명했듯이 타입 프로퍼티는 인스턴스와 상관없기 때문에 인스턴스 생성 과정에서 초기값을 할당할 수 없다. 따라서 타입 프로퍼티를 선언할 때는 반드시 초기값을 할당해야한다. 

 

struct Foo {
    
    static var sFoo = "구조체 타입 프로퍼티 값" // 타입 저장 프로퍼티

    static var cFoo: Int { // 타입 연산 프로퍼티
        return 1 // 읽기 전용이므로 get 구문 생략
    }
}

class Boo {
    static var sFoo = "클래스 타입 프로퍼티 값" // 타입 저장 프로퍼티
    
    static var cFoo: Int { // 타입 연산 프로퍼티
        return 10
    }
    
    class var oFoo: Int { // 재정의가 가능한 타입 연산 프로퍼티
        return 100
    }
}

Foo 구조체와 Boo 클래스 모두 타입 저장 프로퍼티와, 연산 프로퍼티가 있다. 그 중에서 Boo 클래스에 있는 oFoo는 class 키워드를 사용했는데 이 프로퍼티는 Boo의 클래스를 상속받는 하위 클래스에서 재정의할 수 있다는 타입 프로퍼티이다. 이 말이 무슨 말인지는 아래의 예제를 보자.

class BooBoo: Boo {
    override static var oFoo: Int { // 아무 에러가 뜨지 않음.
        return 100
    }
    
    override static var cFoo: Int { // "Cannot override static property"라는 에러가 발생
        return 10
    }
}

위의 예제 처럼 class 키워드를 사용하지 않은 프로퍼티를 재정의할 경우 에러가 발생하게 된다.