| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 네이버 부캠
- Swift
- 개발
- WWDC
- ios #swift #uialertcontroller #메서드 스위즐링
- boostcamp
- 코코아 인터널스
- Cocoa Internals
- Design Pattern
- SwiftUI
- World
- 알고리즘
- 커스텀 뷰
- 부트캠프
- Opensource
- 단위 테스트
- rxswift
- 디자인패턴
- OS
- notion
- development
- IOS
- Algorithm
- Hello
- 부스트캠프
- 후기
- Tistory
- Today
- Total
꿈돌이랜드
LetSwift 2025 - Live Activity 개발기 본문
들어가며
LetSwift 2025에 오거나이저로 참여하며 앱에 Live Activity를 넣기로 했다, 막상 시작하니... 생각보다 훨씬 복잡했다. Push-to-Start 토큰이 뭔지도 몰랐고, FCM으로 Live Activity를 어떻게 시작하는지도 감이 안 잡혔다.
결과적으로는 성공했지만, 그 과정에서 정말 많은 시행착오가 있었다. 날짜 파싱이 안 돼서 머리를 쥐어뜯기도 했고, 토큰 관리 때문에 메모리 누수를 만들기도 했다. 이 글에서는 그런 삽질들을 가감 없이 공유해보려고 한다.
일단 설계부터
Let'Swift는 A트랙이랑 B트랙이 동시에 진행된다. 그래서 처음부터 "트랙별로 독립적인 Live Activity를 띄워야겠다"고 생각했다. 문제는 Live Activity의 구조를 이해하는 게 쉽지 않았다는 거다.
Live Activity는 ActivityAttributes랑 ContentState 두 부분으로 나뉜다. ActivityAttributes는 한번 정하면 못 바꾸는 고정값이고, ContentState는 계속 업데이트할 수 있는 동적인 값이다. 그래서 트랙 정보는 Attributes에 넣고, 세션 제목이나 진행 상태는 ContentState에 넣었다.
struct PresentationAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var presentationId: Int
var title: String
var speakers: [Speaker]
var location: String?
var startTime: String
var endTime: String
var track: String
var currentStatus: Status
enum Status: String, Codable, Hashable {
case upcoming, ongoing, ended, unknown
}
}
var track: String // "A" 또는 "B"
}
이렇게 하니까 A트랙이랑 B트랙 Live Activity를 동시에 띄워도 각각 따로 관리할 수 있었다.
토큰 지옥
솔직히 토큰 관리가 제일 헷갈렸다. FCM Token, Push-to-Start Token, Live Activity Token... 도대체 뭐가 이렇게 많은 건지. 각각 용도가 다르다는 건 알겠는데, 언제 어떤 토큰을 써야 하는지 감이 안 잡혔다.
특히 Live Activity Token은 더 골치 아팠다. 이 토큰은 Live Activity가 시작된 "후에" 발급된다. 그런데 트랙별로 따로 관리해야 하니까, A트랙 Activity 시작하면 A트랙 토큰이 나오고, B트랙 Activity 시작하면 B트랙 토큰이 또 나온다.
처음에는 이걸 그냥 배열로 관리하려다가 완전 꼬였다. 어떤 토큰이 어떤 트랙 거인지 구분이 안 됐다. 결국 딕셔너리로 바꿨다.
actor LiveActivityTokenManager {
static let shared = LiveActivityTokenManager()
private var pushToStartTokenTask: Task<Void, Never>?
private var activityMonitorTask: Task<Void, Never>?
private var activityTokenTasks: [String: Task<Void, Never>] = [:]
func observePushToStartToken() {
pushToStartTokenTask?.cancel()
pushToStartTokenTask = Task {
for await data in Activity<PresentationAttributes>.pushToStartTokenUpdates {
let token = data.map { String(format: "%02x", $0) }.joined()
UserDefaults.standard.set(token, forKey: "pushToStartToken")
await registerTokensWithServer()
}
}
monitorActiveActivities()
}
func observeActivityToken(_ activity: Activity<PresentationAttributes>) {
let track = activity.attributes.track
activityTokenTasks[track]?.cancel()
activityTokenTasks[track] = Task {
for await data in activity.pushTokenUpdates {
let token = data.map { String(format: "%02x", $0) }.joined()
var tokens = getLiveActivityTokens()
tokens[track] = token
saveLiveActivityTokens(tokens)
await registerTokensWithServer()
}
}
}
}
Actor로 만든 건 스레드 안전성 때문이다. 여러 곳에서 동시에 토큰 관리를 해도 문제없게 하려고. 그리고 트랙별로 Task를 관리하는 게 핵심인데, 새 Activity가 시작되면 기존 Task를 취소하고 새로 만든다. 안 그러면 메모리 누수가 생긴다. (실제로 이거 때문에 한번 당했다...)
Push-to-Start 구현
Push-to-Start는 진짜 신기한 기능이다. 앱이 완전히 꺼져 있어도 서버에서 푸시를 보내면 Live Activity가 뜬다. 컨퍼런스 앱에는 완벽한 기능이었다. 세션 시작 5분 전에 자동으로 Live Activity를 띄울 수 있으니까.
iOS에서는 푸시 알림을 받으면 페이로드를 확인해서 Live Activity를 시작한다. 처음에는 이 페이로드 파싱이 계속 실패했다. 알고 보니 JSON 구조를 잘못 이해하고 있었다.
func startLiveActivityIfNeeded(from userInfo: [AnyHashable: Any]) async {
guard let aps = userInfo["aps"] as? [String: Any],
let event = aps["event"] as? String,
event == "start",
let contentStateDict = aps["content-state"] as? [String: Any],
let attributesDict = aps["attributes"] as? [String: Any] else {
return
}
do {
let contentStateData = try JSONSerialization.data(withJSONObject: contentStateDict)
let contentState = try JSONDecoder().decode(
PresentationAttributes.ContentState.self,
from: contentStateData
)
let attributesData = try JSONSerialization.data(withJSONObject: attributesDict)
let attributes = try JSONDecoder().decode(
PresentationAttributes.self,
from: attributesData
)
// 중복 체크
let existingActivities = Activity<PresentationAttributes>.activities
guard !existingActivities.contains(where: { $0.attributes.track == attributes.track }) else {
return
}
let content = ActivityContent(state: contentState, staleDate: nil)
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token
)
await LiveActivityTokenManager.shared.observeActivityToken(activity)
} catch {}
}
중복 체크 부분이 중요한데, 처음에는 이거 안 넣어서 같은 트랙 Live Activity가 두세 개씩 뜨는 참사가 있었다.
서버는 FastAPI로 짰다. Python이 익숙해서 선택했는데, Firebase Admin SDK 쓰는 게 생각보다 간단했다.
async def start_live_activity(
self,
fcm_token: str,
push_to_start_token: str,
content_state: ActivityContentState,
track: str
) -> str:
content_state_dict = content_state.model_dump(mode='json')
message = {
"token": fcm_token,
"apns": {
"live_activity_token": push_to_start_token,
"headers": {
"apns-priority": "10",
"apns-push-type": "liveactivity"
},
"payload": {
"aps": {
"timestamp": int(datetime.now().timestamp()),
"event": "start",
"content-state": content_state_dict,
"attributes-type": "PresentationAttributes",
"attributes": {
"track": track
},
"alert": {
"title": content_state.title,
"body": f"Starts at {content_state.startTime[11:16]}"
}
}
}
}
}
return await self._send_message(message)
apns-push-type을 liveactivity로 안 하면 안 된다. 이것도 몰라서 한참 헤맸다. Apple 문서를 제대로 안 읽은 내 잘못이지만...
Dynamic Island는 진짜 멋있다
Dynamic Island 구현은 정말 재밌었다. Compact, Minimal, Expanded 세 가지 상태를 각각 디자인할 수 있는데, 특히 Expanded 상태는 꽤 넓은 공간을 쓸 수 있어서 좋았다.
final class Volcano {
static func createDynamicIsland(
with context: ActivityViewContext<PresentationAttributes>
) -> DynamicIsland {
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(.logo2025200)
.resizable()
.frame(width: 32, height: 32)
.clipShape(Circle())
}
DynamicIslandExpandedRegion(.trailing) {
Image(systemName: context.state.currentStatus == .upcoming
? "clock.badge" : "clock.fill")
.foregroundColor(context.state.currentStatus == .upcoming
? Color(.upcoming) : Color(.themePrimary))
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 6) {
HStack {
Text(context.state.title)
.font(.system(size: 15, weight: .bold))
Spacer()
}
if context.state.currentStatus == .ongoing,
let startDate = parseDate(from: context.state.startTime),
let endDate = parseDate(from: context.state.endTime) {
ProgressView(
timerInterval: startDate...endDate,
countsDown: false
)
.progressViewStyle(.linear)
.tint(Color(.themePrimary))
}
}
}
} compactLeading: {
Image(.logo2025200)
.resizable()
.frame(width: 18, height: 18)
.clipShape(Circle())
} compactTrailing: {
if context.state.currentStatus == .ongoing {
ProgressView(timerInterval: startDate...endDate)
.progressViewStyle(.circular)
.frame(width: 20, height: 20)
} else {
Image(systemName: "clock.badge")
}
} minimal: {
ZStack {
ProgressView(timerInterval: startDate...endDate)
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
Image(.logo2025200)
.resizable()
.frame(width: 10, height: 10)
}
}
}
}
Dynamic Island 디자인할 때 제일 어려운 건 공간이 진짜 작다는 거다. 특히 Compact랑 Minimal에서는 거의 아이콘 하나 넣으면 끝이다. 그래서 세션 진행 중일 때는 원형 진행률 표시기를, 대기 중일 때는 시계 아이콘을 보여주는 걸로 타협했다.
iOS 18 Smart Stack은 보너스
iOS 18에서는 Live Activity가 홈 화면 위젯으로도 뜬다는 걸 나중에 알았다. 그게 바로 Smart Stack인데, 문제는 공간이 더 작다는 거다.
@available(iOS 18.0, *)
private struct LiveActivityContentView: View {
let context: ActivityViewContext<PresentationAttributes>
@Environment(\.activityFamily) private var activityFamily
private var isSmartStack: Bool {
activityFamily == .small
}
private var iconSize: CGFloat {
isSmartStack ? 28 : 52
}
var body: some View {
LiveActivityBaseView(
context: context,
iconSize: iconSize,
badgeSize: isSmartStack ? 12 : 18,
clockIconSize: isSmartStack ? 8 : 13,
isCompact: isSmartStack
)
}
}
@Environment(\.activityFamily)로 현재 크기를 알 수 있다. Small이면 Smart Stack이니까 모든 크기를 줄인다. 처음에 이거 몰라서 Smart Stack에서 텍스트가 다 짤렸다... 실기기가 없어 테스트를 제대로 못 한 내 잘못이다.
날짜 파싱 지옥
개발하면서 제일 화났던 부분이다. ProgressView(timerInterval:)을 쓰려면 Date 객체가 필요한데, 서버에서 받은 문자열을 파싱하는 게 계속 실패했다.
private func parseDate(from dateString: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [
.withInternetDateTime,
.withDashSeparatorInDate,
.withColonSeparatorInTime
]
// 1. 타임존 포함된 형식 시도
if let date = formatter.date(from: dateString) {
return date
}
// 2. 타임존 없으면 Z 붙여서 재시도
if let date = formatter.date(from: dateString + "Z") {
return date
}
// 3. 안 되면 커스텀 포맷터
let fallbackFormatter = DateFormatter()
fallbackFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
fallbackFormatter.timeZone = TimeZone.current
return fallbackFormatter.date(from: dateString)
}
3단계 폴백 로직을 만들고 나서야 안정화됐다. 그런데 근본적인 해결은 서버를 고치는 거였다. 항상 타임존을 명시하도록 바꿨다.
start_time_str = presentation.start_time.strftime("%Y-%m-%dT%H:%M:%S+09:00")
end_time_str = presentation.end_time.strftime("%Y-%m-%dT%H:%M:%S+09:00")
이렇게 하니까 문제가 싹 사라졌다. 교훈: 날짜는 항상 타임존을 명시하자.
서버 자동화는 필수
처음에는 "내가 눌러서 시작하면 되지"라고 생각했다. 완전 안일한 생각이었다. 컨퍼런스 당일에 세션 시작할 때마다 버튼 누르고 있을 거야? 말이 안 된다.
그래서 서버가 알아서 현재 시간에 맞는 세션을 찾아서 Live Activity를 시작하도록 만들었다. 우선순위는 이렇다.
- 지금 진행 중인 세션 있으면 그거 ongoing으로
- 30분 안에 시작하는 세션 있으면 그거 upcoming으로
- 10분 전에 끝난 세션 있으면 그거 ongoing으로 (늦게 온 사람 배려)
- 2시간 안에 시작하는 세션 있으면 그거 upcoming으로
@router.post("/start-current")
async def start_current_live_activity(
request: StartCurrentActivityRequest,
db: AsyncSession = Depends(get_db),
storage: RedisTokenStorage = Depends(get_storage),
fcm: FCMService = Depends(get_fcm_service)
) -> ActivityResponse:
now = datetime.now()
tracks = ["A", "B"]
for idx, track in enumerate(tracks):
query = select(PresentationDB).where(
PresentationDB.end_time > now,
PresentationDB.track == track
).order_by(PresentationDB.start_time)
presentations = (await db.execute(query)).scalars().all()
selected_presentation = None
selected_status = None
for presentation in presentations:
if presentation.start_time <= now <= presentation.end_time:
selected_presentation = presentation
selected_status = PresentationStatus.ongoing
break
elif presentation.start_time > now:
time_until_start = (presentation.start_time - now).total_seconds() / 60
if time_until_start <= 30:
selected_presentation = presentation
selected_status = PresentationStatus.upcoming
break
# ... 나머지 로직 생략
if idx < len(tracks) - 1:
await asyncio.sleep(1.0) # FCM rate limiting 방지
이 API를 크론으로 5분마다 호출하도록 설정했다. 이제 아무것도 안 해도 알아서 Live Activity가 뜬다.
FCM Rate Limiting에 당하다
여러 명한테 동시에 푸시를 보내니까 FCM이 "야 너무 빠른데?"라면서 거부하기 시작했다. 일부 메시지가 실패하는 거다.
해결책은 간단했다. 좀 천천히 보내면 된다.
async def send_to_multiple_devices(
self,
fcm_tokens: List[str],
live_activity_tokens: List[str],
payload_builder,
*args, **kwargs
) -> tuple[int, int]:
success_count = 0
failure_count = 0
for fcm_token, la_token in zip(fcm_tokens, live_activity_tokens):
try:
await payload_builder(fcm_token, la_token, *args, **kwargs)
success_count += 1
except Exception as e:
failure_count += 1
await asyncio.sleep(0.1) # 0.1초 쉬기
return success_count, failure_count
각 메시지 사이에 0.1초씩 쉬고, 트랙 사이에는 1초 쉬도록 했다. 좀 느려지긴 했지만 안정성이 훨씬 좋아졌다.
Redis로 토큰 저장
디바이스 토큰을 어디에 저장할까 고민하다가 Redis를 선택했다. 이유는 간단하다.
- 빠르다
- TTL 설정하면 알아서 정리된다
- JSON 저장이 편하다
class RedisTokenStorage:
async def update(self, device_id: str, request: DeviceTokenRequest):
device_token = DeviceToken(
deviceId=device_id,
fcmToken=request.fcmToken,
pushToStartToken=request.pushToStartToken,
liveActivityTokens=request.liveActivityTokens,
appVersion=request.appVersion,
platform=request.platform,
registeredAt=datetime.now()
)
device_data = device_token.model_dump_json()
await self.redis.set(
f"device_token:{device_id}",
device_data,
ex=60 * 60 * 24 * 30 # 30일 후 자동 삭제
)
return device_token
30일 지나면 자동으로 삭제되니까 관리가 편했다.
테스트는 Preview로
Live Activity 테스트는 진짜 귀찮다. 실제 디바이스 필요하고, 서버도 켜야 하고... 그래도 Xcode Preview 기능이 있어서 다행이었다.
#Preview("Lock Screen", as: .content, using: PresentationAttributes.preview) {
LetSwift_iOS_WidgetLiveActivity()
} contentStates: {
PresentationAttributes.ContentState.ongoing
PresentationAttributes.ContentState.upcoming
PresentationAttributes.ContentState.upcomingSoon
}
Preview 덕분에 UI는 빠르게 만들 수 있었다. Dynamic Island 여러 상태를 한눈에 보면서 디자인 결정할 수 있었다.
실제 푸시 테스트는 Postman으로 API 직접 호출하는 식으로 했다. 개발 디바이스 등록하고 테스트 메시지 날려보고.
배운 것들
이번 프로젝트하면서 정말 많이 배웠다.
일단 Actor가 이렇게 편한지 몰랐다. 예전 같았으면 DispatchQueue랑 세마포어로 머리 싸맸을 텐데, Actor 쓰니까 훨씬 간단했다.
날짜/시간은 진짜 조심해야 한다. 타임존 안 붙이면 나중에 분명히 문제 생긴다.
서버 자동화는 필수다. 실제 운영 환경에서는 사람이 일일이 관리할 수 없다.
Push-to-Start는 정말 강력한 기능이다. 앱 꺼져 있어도 Live Activity 띄울 수 있다니.
Dynamic Island는 멋있긴 한데... 모든 사용자가 Pro 모델 쓰는 건 아니니까 Lock Screen UI가 더 중요하다.
아쉬운 점
물론 완벽하진 않았다.
에러 처리가 좀 약하다. 지금은 에러 나면 그냥 로그만 남기는데, 재시도 로직이나 사용자 알림이 있으면 좋겠다.
Live Activity 토큰은 Activity 끝나면 무효화되는데, 서버에서 이걸 감지해서 자동으로 정리하는 게 없다. Redis 메모리가 좀 아깝다.
분석 데이터도 수집하면 좋을 것 같다. 얼마나 많은 사람이 Live Activity 쓰는지, 어떤 상태에서 제일 많이 보는지 알면 좋을 듯 싶었다.
마치며
Live Activity 구현하면서 정말 많이 삽질했다. 토큰 관리 때문에 머리 아팠고, 날짜 파싱 때문에 화났고, FCM rate limiting 때문에 당황했다.
그래도 재밌었다. Push-to-Start부터 Dynamic Island, Smart Stack까지 최신 기능을 다 써볼 수 있었고, 실제 운영 환경에서 필요한 것들을 고민해볼 수 있었다.
LetSwift 2025에서 참가자들이 Live Activity로 세션 정보 보면서 "오 이거 편한데?"라고 생각했다면 좋겠다. 그럼 이 모든 삽질이 보람 있을 것 같다.
이 글이 Live Activity 구현하려는 다른 개발자들한테 조금이라도 도움이 됐으면 좋겠다. 더불어 함께 해준 오거나이저분들께 진심으로 감사의 말씀을 전합니다.
'Programming > 개발 후기' 카테고리의 다른 글
| Runway 개발 후기 - 아키텍처 (0) | 2023.07.07 |
|---|---|
| SeeMeet 개발후기 - 2, Rx 도입 (0) | 2023.05.01 |
| SeeMeet 개발후기 - 1, Coordinator 패턴 도입 (1) | 2023.05.01 |