
먼저 프로퍼티 래퍼가 나오게 된 이유에 대해서 설명해보면 프로퍼티 래퍼는 SwiftUI로 넘어오면서 자주 사용하게 되는 개념인데 말 그대로 프로퍼티를 감싸주는 요소라고 생각하면 된다.
프로퍼티 래퍼
우리가 상태 추적을 위해 사용하던 State, Binding 등 프로퍼티 래퍼를 사용해 왔다 이 프로퍼티 래퍼는 왜 사용하게 됐을까
먼저 프로퍼티 래퍼 안쪽을 살펴보면 프로퍼티의 반복적인 계산 속성을 하나의 프로퍼티 래퍼로 만들어 중복 로직을 작성하지 않고 사용할 수 있게 해주는 개념이다!
struct User {
private var _name: String = ""
var name: String {
get { _name.capitalized }
set { _name = newValue }
}
}
struct NPC {
private var _name: String = ""
var name: String {
get { _name.capitalized }
set { _name = newValue }
}
}
예를 들어 User와 NPC에서 계산 속성을 통해 겟셋에서 값을 가져와 이 계산속성을 활용하게 되면 name의 결과값의 첫문자를 대문자로 만드는 로직을 이렇게 구현하게 된다. 그런데 보면 지금 User와 NPC이 두개는 같은 중복된 로직을 반복해서 사용하고 있다.
이러한 내용을 방지하기 위해 프로퍼티 래퍼를 만들어서 그냥 감싸 사용할 수 있도록 할 수 있다.
@propertyWrapper
struct Capitalized {
private var value: String = ""
var wrappedValue: String {
get { value.capitalized } // 항상 첫 글자 대문자
set { value = newValue }
}
var projectedValue: String { value.uppercased() } // ALL 대문자 버전
}
struct User {
@Capitalized var name: String = ""
}
var u = User()
u.name = "allen"
print(u.name) // Allen
print(u.$name) // ALLEN
@propertyWrapper 는 속성의 동작(읽기,쓰기)을 감싸는 재사용 가능한 타입을 선언하고, wrappedValue 를 통해 실제 값에 접근할때 호출 되는 부분의 get set를 작성한다.
그 외 에 $로 접근할 수 있는 추가적인 정보나 부가정보, 상태를 다르게 주기 위해서는 projectedValue 를 만들어 작성해줘야 한다.
이런식으로 구현을 하게 되면 단순하게 앞에 @Capitalized 를 선안하고 프로퍼티를 만들어주면 편리하게 원하는 값을 중복없이 사용할 수 있다.
private var _name = Capitalized(wrappedValue: "")
var name: String {
get { _name.wrappedValue }
set { _name.wrappedValue = newValue }
}
var $name: String { _name.projectedValue }
위에 작성한 프로퍼티 래퍼를 통해 실질적으로 실행이 되면 Swift 컴파일단계에서 이런식으로 자동으로 바꿔 실행을 하게 된다.
여기까지 프로퍼티 래퍼의 개념정리 끝~
@TaskLocal - Task단위의 동적 컨텍스트!
사실 오늘은 이 부분을 설명하기 위해 프로퍼티 래퍼의 개념에 대해 먼저 알아봤다 ㅎㅎ 이 부분을 이해하기 위해 프로퍼티 래퍼의 개념 이해가 먼저 되어야 할 것 같아서..
암튼 이 TaskLocal은 왜 생겼을까! 를 생각해보면 Swift의 비동기 시스템은 여러 Task가 동시에 실행된다. 이때, 각각의 Task가 어떤 맥락(Context)에서 동작 중인지 알아야 할때가 있다.
Task {
await fetchUserData()
}
Task {
await fetchNotification()
}
예를 들어 이런 코드가 있다고 해보면 이 Task들은 각각 작업 단위! 라고 보면 된다. 독립된 Task이기 때문에 실행 순서도 다르고, 스레드도 달라질 수 있다.
그런데 비동기 함수들은 이런 데이터를 자주 공유한다.
예를들어
- 현재 사용자의 권한 (Role)
- 로깅할 때 사용할 요청 ID
- 특정 모드(예: “특별 다운로드 모드”)
- 현재 Locale, Region 등
그런데 이런 정보를 매번 함수로 넘기게 되면 코드가 이런 식으로 구현된다.
func downloadFile(option: DownloadOption) async { ... }
func syncData(option: DownloadOption) async { ... }
func process(option: DownloadOption) async { ... }
await downloadFile(option: .special)
await syncData(option: .special)
await process(option: .special)
이러면 모든 함수가 option 인자를 받아야해서 API가 지저분해지고, 한곳만 빼먹게 되도 컨텍스트가 깨져버리게 된다.
그래서 Swift는 이 함수들이 같은 Task 안에서 실행 중이라면, 그 Task의 컨텍스트를 자동으로 상속받게 하자! 라는 개념에서 만들어진게 @TaskLocal이다 즉, Task 내부에서 공통된 값을 자동으로 공유할 수 있는 컨테이너라는 것
다운로드 옵션으로 예시해서 하니까 조금 이해가 안되는 것 같아서 UserRole을 나눈 예시로 다시 개념 정리를 해보자
@TaskLocal은 비동기 함수 단위로 유지되는 지역적(global처럼 보이는) 컨텍스트 저장소다 쉽게 말해서 전역 변수처럼 어디서든 접근 가능하지만, 실제로는 현재 Task 안에서만 유효한 값을 저장할 수 있게 해주는 장치이다.
즉, 비동기 함수들 사이에서 Context를 안전하게 전파하기 위한 수단!
예를들어 어떤 Task는 관리자 권한으로 실행 중이고, 또 다른 Task는 게스트 권한으로 실행 중이라고 할 때, 각 Task 마다 함수마다 일일이 role를 인자로 전달해야 한다면 코드가
func deletePost(role: UserRole) async { ... }
func editComment(role: UserRole) async { ... }
func viewUserList(role: UserRole) async { ... }
모든 함수에 role 파라미터를 넣어서 넘겨야하는데 실수로 한 곳이라도 빠지면 잘못된 권한으로 실행 될 수 있다.
이 부분을 좀 해결하기 위해 아래와 같이 진행해보면
enum UserRole: String {
case guest = "게스트"
case member = "회원"
case admin = "관리자"
}
먼저 유저 롤을 정의하는 단순 열거형을 만들어주고 TaskLocal로 권한 저장소를 만들어준다.
final class UserSession {
// 기본 권한은 guest
@TaskLocal static var role: UserRole = .guest
}
이 코드는 모든 Task는 자기만의 UserRole 값을 가지고 있다. 아무 설정도 없으면 기본값은 .guest 이다 라는 뜻!
struct PostService {
func deletePost() async {
switch UserSession.role {
case .guest:
print("❌ 게스트는 게시글을 삭제할 수 없습니다.")
case .member:
print("⚠️ 회원은 본인 게시글만 삭제할 수 있습니다.")
case .admin:
print("✅ 관리자는 모든 게시글을 삭제할 수 있습니다.")
}
}
}
그리고 메서드에서 사용할 때 role를 읽으면 현재 Task에 설정된 권한이 자동으로 들어가게 되고, 각각의 컨텍스트별로 다른 값을 설정할 수 있다.
중복 로직이 훨씬 줄어들고 체계적으로 관리가 가능해진다는 말!
let service = PostService()
Task {
// 1️⃣ 기본값: 게스트
await service.deletePost()
// 출력: ❌ 게스트는 게시글을 삭제할 수 없습니다.
// 2️⃣ 이 블록 안에서는 role을 admin으로 바꿈
await UserSession.$role.withValue(.admin) {
await service.deletePost()
// 출력: ✅ 관리자는 모든 게시글을 삭제할 수 있습니다.
// 3️⃣ 자식 Task 생성 (상속받음)
Task {
await service.deletePost()
// 출력: ✅ 관리자는 모든 게시글을 삭제할 수 있습니다.
}
}
// 4️⃣ 블록이 끝나면 다시 기본값으로 복귀
await service.deletePost()
// 출력: ❌ 게스트는 게시글을 삭제할 수 없습니다.
}
각 Task 별로 권한을 다르게 적용할 수 있고 Task 별로 관리가 되기 때문에 해당 블록이 종료되면 기본값으로 복귀 한다
여기서 내부에서 일어나는 withValue의 동작 구조는 다음과 같다.
[기본값: guest]
↓ withValue(.admin)
[스코프 내부: admin]
↓ 블록 끝남
[복귀: guest]
withValue() 는 단순히 값을 바꾸는 함수가 아닌 TaskLocal의 스코프를 만들어주는 일종의 컨텍스트 푸시 함수이다.
여기서 자식 Task로 값이 전파 되는 이유는 Swift Concurrency에서는 자식 Task는 부모 Task의 메타데이터를 상속 받는다.
그 메타데이터 에서는 우선순위, 실행 중인 액터 정보, TaskLocal 저장소를 상속 받게 된다. 그래서 withValue() 블록 안에서 만든 자식 Task도 부모의 TaskLocal 값을 그대로 상속 받게 된다.
여기서 참고적으로 Detached Task는 왜 상속 받지 않을까를 생각해보면 Task.detached {} 는 구조적으로 부모와 완전히 독립된 Task 이다. 그래서 부모의 TaskLocal 값을 전혀 전달되지 않는다.
await UserSession.$role.withValue(.admin) {
Task.detached {
await service.deletePost()
// 출력: ❌ 게스트는 게시글을 삭제할 수 없습니다. (상속 안 됨)
}
}
처음 강의 들었을땐 어떤 말을 하는지 잘 이해하지 못했는데 다시 개념을 정리하니 이제야 좀 이해 되는 것 같다!
'◽️ Programming > iOS' 카테고리의 다른 글
| Sendable protocol과 Actor 개념 재정리 (0) | 2025.10.11 |
|---|---|
| Swift Concurrency에서 작업 취소라는 개념 (0) | 2025.09.22 |
| Swift Concurrency 구조적 동시성을 정리하면서.. (0) | 2025.09.16 |
| Swift Concurrency의 Continuation과 Task.sleep, sleep의 차이! (1) | 2025.09.12 |
| Swift Concurrecy에서 비동기 개념의 확장? (0) | 2025.09.02 |