Vapor로 Push Notification 서버 만들기 - Team-HGD/SniffMEET GitHub Wiki
인증서
사실 이 부분에서 가장 시간을 많이 썼던 것 같다. fly.io로 배포할 계획을 가지고 있었기 때문에 인증서를 secrets에 어떻게 올리면 좋을지 고민했다. fly.io는 파일을 secrets로 관리해주는 기능은 없고 문자열 형태의 값만 등록이 가능하다.
첫번째 시도) .pem 파일은 text로 열어볼 수 있으니 그대로 복사해서 secrets에 올리자.
→ 실패
privateKey를 불러올때 계속 실패했다. 뭔가 내부적으로 복호화할 때나 디코딩할 때 문제가 있었는지… 트래킹이 전혀 안됐다. 복사하는 과정에서 \n
와 같은 문자열이 섞여들어갔을까봐 .replacingOccurrences(of: with:)
로 다 제거하고 넣어줬는데도 실패했다.
두번째 시도) .pem 파일을 base64 인코딩해서 secrets에 올리자.
→ 성공
base64 apns-key.pem > apns_key.text
let certData = Data(base64Encoded: Environment.get("apns_cert")!)
let certBuffer = [UInt8](certData!)
let keyData = Data(base64Encoded: Environment.get("apns_key")!)
let keyBuffer = [UInt8](keyData!)
let apnsConfig = APNSClientConfiguration(
authenticationMethod: .tls(
privateKey: .privateKey(
try .init(bytes: keyBuffer, format: .pem)
),
certificateChain: [.certificate(try .init(bytes: certBuffer, format: .pem))]
),
environment: .development
)
Supabase
서버를 Vapor로 만든 이유 중 하나가 push notification을 전송하고 db 테이블에도 알림을 추가해야 했기 때문이었다.
서버 클라이언트에서는 그냥 Supabase-swift 써도 되지 않을까? 하는 안일한 생각과 귀차니즘으로 지옥의 불구덩이로 떨어지게 되었다.
- Package 버전 문제
deploy시 자꾸 컴파일이 안된다는 억까 아닌 억까가 있었다.
APNs는 문제없이 동작하는 것을 확인한 후였기 때문에 Supabase의 코드를 하나씩 제거하면서 동작하는지를 확인했는데, Supabase 패키지 자체를 들어내니 정상적으로 배포가 되는 것을 확인할 수 있었다.
원인은 패키지 버전 문제였다.
혹시나 해서 패키지 코드들을 살펴보니 버전이 0.3.0으로 맞추어져 있었다. 최신 버전은 2.0.0인데 이 정도면 문제 될 법도 했다. 2.0.0 버전 쓰니까 바로 컴파일 성공했다.
dependencies: [
// 💧 A server-side Swift web framework.
.package(url: "https://github.com/vapor/vapor.git", from: "4.99.3"),
// 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/vapor/apns.git", from: "4.0.0"),
.package(url: "https://github.com/supabase-swift.git", from: "0.3.0")
],
이제는 잘 되겠지 싶었는데…
공식문서 상에서 Configuration
을 하지 않을 경우 해당 생성자를 사용하라고 나와있어서 당연 Vapor도 잘 적용될줄 알고 썼다. (게다가 0.3.0 버전으로 테스트 했을 때는 정상 동작했다.)
var supabase: SupabaseClient {
SupabaseClient(
supabaseURL: URL(
string: Environment.get("supabase_url")!
)!,
supabaseKey: Environment.get("supabase_api_key")!
)
}
도커에서 빌드할 때 생성자를 계속 인식하지 못했다.
왜 그런건가 싶어서 Supabase 소스코드로 이동해보니까 Linux 에서는 인식이 안되는 생성자였다.
#if !os(Linux)
public convenience init(supabaseURL: URL, supabaseKey: String) {
self.init(
supabaseURL: supabaseURL,
supabaseKey: supabaseKey,
options: SupabaseClientOptions()
)
}
#endif
그래서 소스코드를 하나하나 살펴보며 리눅스 환경에서 빌드가 가능한 생성자를 만들어 주기 위해 아래 생성자를 사용하려고 했다.
public init(
storage: any AuthLocalStorage,
redirectToURL: URL? = nil,
storageKey: String? = nil,
flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType,
encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder,
decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder,
autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken,
accessToken: (@Sendable () async throws -> String)? = nil
) {
self.storage = storage
self.redirectToURL = redirectToURL
self.storageKey = storageKey
self.flowType = flowType
self.encoder = encoder
self.decoder = decoder
self.autoRefreshToken = autoRefreshToken
self.accessToken = accessToken
}
}
AuthLocalStorage
는 iOS나 macOS를 사용할 때는 Keychain을 자동으로 적용하는 생성자가 제공된다. 하지만 리눅스에서는 그런 게 없으니 직접 구현체를 만들어줘야 했다.
extension AuthClient.Configuration {
#if !os(Linux) && !os(Windows)
public static let defaultLocalStorage: any AuthLocalStorage = KeychainLocalStorage()
#elseif os(Windows)
public static let defaultLocalStorage: any AuthLocalStorage = WinCredLocalStorage()
#endif
}
push notification 서버에서는 Auth 관련 기능을 사용하지 않을 것이기 때문에 구현부는 전부 비워뒀다.
public protocol AuthLocalStorage: Sendable {
func store(key: String, value: Data) throws
func retrieve(key: String) throws -> Data?
func remove(key: String) throws
}
struct DefaultAuthLocalStorage: AuthLocalStorage {
func store(key: String, value: Data) throws {}
func retrieve(key: String) throws -> Data? { return nil }
func remove(key: String) throws {}
}
다시 SupabaseConfigure
를 설정해줬다.
var supabase: SupabaseClient {
SupabaseClient(
supabaseURL: URL(
string: Environment.get("supabase_url")!
)!,
supabaseKey: Environment.get("supabase_api_key")!,
options: SupabaseClientOptions(auth: DefaultAuthLocalStorage())
)
}
빌드도 잘 되고 배포도 성공적으로 됐다.
아 이젠 되겠지!
어림도 없었다.
빌드 자체에는 문제가 없었는데, 배포 환경에서 machine이 계속 unmount 된다는 둥… 502 에러와 함께 서버에 접속이 안 됐다. 이건 정말 몇시간을 찾아봤는데 도저히 문제를 시간 내에 찾을 수 없어서 결국 Supabase 패키지를 서버에서 제거할 수밖에 없었다.
문자열 파싱을 귀찮아한 죄…
client 메서드를 사용해서 만드는 게 훨씬 빨랐다.
진작에 이렇게 할걸…
Router
이제 드디어… 서버 로직을 제대로 짤 수 있게 되었다.
사실 서버를 만들기 전에 앱 클라이언트의 화면전환 로직을 구현해두었는데, 그때 짰던 데이터 구조가 많고 처리하기 복잡했다.
- 기존 데이터 구조
struct RequestAPS: Decodable {
let aps: APS
let categoryIdentifier: APSCategoryIdentifier
let walkRequest: WalkNotiDTO
}
struct RespondAPS: Decodable {
let aps: APS
let categoryIdentifier: APSCategoryIdentifier
let isAccepted: Bool
}
struct APS: Decodable {
let alert: Alert
}
enum APSCategoryIdentifier: String, Decodable {
case walkRequest
case walkRespond
}
struct Alert: Decodable {
let title: String
let body: String
}
그래서 서버 짤 때 너무 헷갈려서 데이터 구조를 한눈에 보기 위해 플로우 차트에서 교환되는 데이터 구조들을 하나씩 매핑해봤다. 그랬더니… 음? 데이터 구조 하나만으로도 해결할 수 있겠다는 생각이 들었다.
플로우 차트를 기반으로 정리해보니
Request
/ Response
각각으로 나뉘었던 Notification
을 WalkAPSDTO
하나로 통일할 수 있었다.
struct WalkAPSDTO: Decodable {
let aps: APS
let notification: WalkNotiDTO
}
struct APS: Decodable {
let alert: Alert
}
struct Alert: Decodable {
let title: String
let subtitle: String
}
데이터 구조를 변경하니 앱 클라이언트에서도 화면 전환 로직이 더 직관적으로 바뀌었다.
let userInfo = response.notification.request.content.userInfo
guard let walkAPS = convertToWalkAPS.execute(walkAPSUserInfo: userInfo) else { return }
let walkNoti: WalkNoti = walkAPS.notification.toEntity()
switch walkAPS.notification.category {
case .walkRequest:
sceneDelegate.appRouter?.presentWalkRequestView(walkNoti: walkNoti)
case .walkAccepted, .walkDeclined:
sceneDelegate.appRouter?.presentRespondWalkView(walkNoti: walkNoti)
}
라우팅 메서드 작성
특이한게 URL 대신 URI를 받는다. URI가 좀더 큰 범위라고 생각하면 된다. (부분집합 관계)
그래서 URL → URI 변경은 가능한데, URI → URL 변경은 실패할 수 있다.
우리는 URL을 URI로 변경해주는 거니 이 부분은 크게 신경쓰지 않아도 됐다.
앱 클라이언트에서 했던 것처럼 URL과 apiKey만 잘 입력해주면 get, post 요청을 받아서 처리할 수 있다.
let response = try await req.client.get(
.init(string: tableURLString)
) { request in
request.headers.contentType = .json
request.headers.bearerAuthorization = .init(token: Environment.get("supabase_api_key")!)
SupabaseClient.defaultHeader.forEach { key, value in
request.headers.replaceOrAdd(name: key, value: value)
}
}
table URL을 작성할 때 그래도 Supabase SDK처럼 작성해보고 싶어서 익스텐션에 아래 같은 코드를 추가해봤는데, 사실 모양새만 비슷하다. 시간이 더 있다면 쿼리, 속성, 테이블에 따라 각자 타입을 만들어줄 것 같다.
extension URL {
func query(with: URLQueryItem...) -> URL {
self.appending(queryItems: with)
}
func table(_ table: String) -> URL {
self.appending(path: table)
}
func eq(column: String, value: String) -> URL {
self.appending(queryItems: [.init(name: column, value: "eq.\(value)")])
}
func rpc(_ function: String) -> URL {
self.appending(path: "rpc/\(function)")
}
}
그래서 나온 결과물이 아래 코드이다. 산책 요청하는 endpoint 하나가 이렇게 하는 일이 많다… 나눌 수 있는 방법이 더 있을 거 같긴 한데 그 부분은 더 공부가 필요할 것 같다.
추후 단계별로 글을 좀 더 수정해야겠다.
app.post("notification", "walkRequest") { req async throws -> HTTPStatus in
// validate
let bodyData: NotificationDTO = try req.content.decode(NotificationDTO.self)
guard bodyData.category == .walkRequest else {
return .badRequest
}
let tableURLString: String = SupabaseClient.databaseURL
.table("user_info")
.eq(column: "id", value: bodyData.receiver.uuidString)
.absoluteString
let response = try await req.client.get(
.init(string: tableURLString)
) { request in
request.headers.contentType = .json
request.headers.bearerAuthorization = .init(token: Environment.get("supabase_api_key")!)
SupabaseClient.defaultHeader.forEach { key, value in
request.headers.replaceOrAdd(name: key, value: value)
}
}
// 유저의 디바이스 토큰을 찾을 수 없음.
guard let deviceToken = try? response.content.decode([UserInfoDTO].self).first?.deviceToken else {
return .notFound
}
// notification table에 추가
_ = try await req.client.post(
.init(
string: SupabaseClient.databaseURL.table("notification").absoluteString
)
) { request in
request.headers.contentType = .json
request.headers.bearerAuthorization = .init(token: Environment.get("supabase_api_key")!)
SupabaseClient.defaultHeader.forEach { key, value in
request.headers.replaceOrAdd(name: key, value: value)
}
request.body = req.body.data
}
let addNotification: NotificationPostDTO = .init(
id: bodyData.receiver,
notificationID: bodyData.id
)
let addNotificationData = try JSONEncoder().encode(addNotification)
// notification_list 테이블에 추가
_ = try await req.client.post(
.init(
string: SupabaseClient.databaseURL.rpc("add_notification").absoluteString
)
) { request in
request.headers.contentType = .json
request.headers.bearerAuthorization = .init(token: Environment.get("supabase_api_key")!)
SupabaseClient.defaultHeader.forEach { key, value in
request.headers.replaceOrAdd(name: key, value: value)
}
request.body = .init(data: addNotificationData)
}
let notificationPayload: NotificationPayload = .init(notification: bodyData)
// send push notification
try await app.apns.client.sendAlertNotification(
.init(
alert: .init(
title: .raw("\(bodyData.name)님이 " + bodyData.category.alertTitle),
subtitle: .raw(bodyData.category.alertSubTitle)
),
expiration: .immediately,
priority: .immediately,
topic: Environment.get("bundle_id") ?? "",
payload: notificationPayload
),
deviceToken: deviceToken
)
return .ok
}
앞으로의 개선 방향
한 endpoint의 일들을 처리할 때 한번에 한가지의 일만 처리할 수 있다. 각 클라이언트 요청마다 await를 걸어두기 때문이다.
병렬 처리할 수 있는 작업에 대해서는 Task Group으로 묶어서 처리하면 어떨까.
예를 들면 push notification을 보내는 작업과 notification list에 알림을 추가하는 작업은 동시에 이루어져도 된다.