Search

iOS 사용자 세션 관리 구현

What I Learned

왜 Singleton 패턴을 사용했는가?

UserSession은 앱 전체에서 단 하나의 사용자 정보만 관리해야 한다. 여러 인스턴스가 생기면 사용자 번호, 닉네임, 연결 상태가 서로 다른 값을 가질 수 있어 데이터 불일치가 발생한다.
@Observable final class UserSession { static let shared = UserSession() private(set) var userNumber: Int var nickname: String = "" private init() { self.userNumber = Int.random(in: 1...100) } }
Swift
복사
왜 final class인가? 상속을 허용하면 자식 클래스에서 또 다른 인스턴스를 만들 수 있다. final로 막아야 진정한 Singleton이 보장된다.
왜 private init()인가? 외부에서 UserSession()을 호출하면 새 인스턴스가 생긴다. 이를 막아야 shared만 사용하게 강제할 수 있다.
왜 private(set)인가? userNumber는 외부에서 읽을 수는 있어야 하지만, 변경은 UserSession 내부에서만 해야 한다. 외부에서 마음대로 바꾸면 P2P 통신에서 다른 기기와 번호가 맞지 않게 된다.

왜 didSet으로 자동 저장을 구현했는가?

설정 값을 변경할 때마다 save() 함수를 수동으로 호출하면 개발자가 실수로 빼먹을 수 있다. 앱을 재시작했을 때 설정이 저장되지 않은 버그가 발생한다.
var isPublicNotificationEnabled: Bool = true { didSet { save(.publicNotification, value: isPublicNotificationEnabled) } } private enum Keys: String { case nickname = "user_nickname" case publicNotification = "public_notification_enabled" } private func save(_ key: Keys, value: Any) { UserDefaults.standard.set(value, forKey: key.rawValue) }
Swift
복사
왜 Key를 enum으로 관리하는가? 문자열 키를 직접 사용하면 오타가 나도 컴파일러가 잡지 못한다. “user_nicknam” 같은 오타는 런타임에 데이터가 안 불러와지는 버그로 이어진다. enum을 사용하면 컴파일 타임에 오타를 잡을 수 있다.

왜 Timer를 RunLoop.common에 추가했는가?

private func startEvolutionTimer() { evolutionTimer?.invalidate() let timer = Timer(timeInterval: 10.0, repeats: true) { [weak self] _ in self?.checkEvolution() } RunLoop.main.add(timer, forMode: .common) evolutionTimer = timer }
Swift
복사
왜 Timer.scheduledTimer를 사용하지 않았는가? Timer.scheduledTimer.default 모드에서만 동작한다. 사용자가 채팅 목록을 스크롤하면 RunLoop이 .tracking 모드로 전화되어 타이머가 멈춘다. 스크롤하는 동안 성장(evolution)이 멈추면 안 되므로 .common 모드를 사용했다.
RunLoop 모드 정리
.default: 일반 상태
.tracking: 스크롤, 드래그 중
.common: .default + .tracking 모두 포함

왜 메인 스레드 체크 패턴을 사용했는가?

@Observable의 프로퍼티가 변경되면 SwiftUI가 자동으로 View를 업데이트한다. 이 업데이트는 반드시 메인 스레드에서 일어나야 한다. 백그라운드 스레드에서 상태를 변경하면 앱이 크래시되거나 UI가 이상하게 동작한다.
func start() { guard Thread.isMainThread else { DispatchQueue.main.async { self.start() } return } isConnected = true // ... }
Swift
복사
왜 이 패턴이 좋은가? 호출하는 쪽에서 스레드를 신경 쓸 필요가 없다. 어느 스레드에서 start()를 호출해도 내부에서 알아서 메인 스레드로 보내준다. API 사용자의 실수를 방지하는 방어적 프로그래밍이다.

왜 같은 파일 내에서 Extension으로 분리했는가?

처음에는 파일을 분리하려 했으나 private 프로퍼티 접근 문제로 실패한다. 같은 파일 내 Extension은 private 멤버에 접근할 수 있으면서도 논리적 분리가 가능하다.
// MARK: - P2P Integration extension UserSession { private func startP2P() { ... } } // MARK: - Evolution extension UserSession { private func startEvolutionTimer() { ... } private func checkEvolution() { ... } } // MARK: - Persistence extension UserSession { private func loadSettings() { ... } private func save(_ key: Keys, value: Any) { ... } }
Swift
복사
왜 MARK 주석을 함께 사용하는가? Xcode의 미니맵과 점프 바에서 섹션별로 빠르게 이동할 수 있다. 200줄 짜리 파일에서 원하는 메서드를 찾는 시간이 크게 줄어든다.

Key Takeaways

1.
Singleton의 완전한 보장: final + private init() 조합 필수
2.
실수 방지 자동화: didSet으로 저장 누락 원천 차단
3.
스크롤 중 Timer: RunLoop.common 모드 사용
4.
방어적 프로그래밍: 메인 스레드 체크를 내부에서 처리
5.
논리적 분리: 같은 파일 Extension + MARK 주석

References