2025. 4. 21. 09:39ㆍProgramming/Flutter
문제 배경
Flutter를 사용해서 HLS로 영상 스트리밍 기능을 구현해야 하는데, 스트리밍 서버가 동작하는 IoT 장치에서는 외부에서 접속할 수 있는 공개망에 연결되어 있다는 보장이 없었습니다. 보안상의 이유로 내부망에 연결되어서 사용되는 경우가 대부분이고, 공장에서 생성되는 시점에 HTTPS를 지원해야하다보니 Self-signed Certificate를 사용하고 있었습니다.
처음에는 Flutter에서 제공하는 video_player
패키지를 사용하려고 했으나, 애석하게도 Self-signed Certificate를 처리할 수 있는 방법이 없었습니다. 결국 네이티브에서 제공하는 플레이어를 사용하기로 결정했는데, Android는 전체적인 SSL 연결을 커스터마이징할 수 있는 기능을 제공하기 때문에 어렵지 않게 ExoPlayer로 스트리밍이 가능했지만, iOS의 AVPlayer는 이런 여지를 제공하지 않습니다. 결국 다른 방법을 찾다가 별다른 방법이 없어, AVPlayer에서 GCDWebServer를 사용해서 SSL 요청을 프록시 패스하는 기능을 구현하기로 했습니다.
로컬 프록시 서버 사용
GCDWebServer를 사용하여 로컬 프록시 서버를 구현해서, AVPlayer는 로컬 프록시 서버로 HLS 스트리밍을 요청합니다. 로컬 프록시 서버는 AVPlayer가 요청한 내용을 원본 서버로 전달하여 m3u8 플레이리스트와 세그먼트 파일을 요청하게 됩니다. 이를 정리하면 다음과 같습니다.
- iOS 앱 내부에서 로컬 HTTP 서버(GCDWebServer)를 실행합니다.
- AVPlayer는 HTTPS 원본 서버가 아닌 로컬 HTTP 서버(localhost)로 요청합니다.
- 로컬 프록시 서버는 원본 HTTPS 서버로 요청을 보내고, 이 과정에서 Self-signed Certificate를 허용하는 커스텀 URLSessionDelegate를 사용합니다.
- 프록시 서버는 원본 서버로부터 받은 응답을 AVPlayer에게 전달합니다.
이 방식의 핵심은 AVPlayer → GCDWebServer(HTTP) → 원본 서버(HTTPS with Self-signed Certificate) 구조를 통해 AVPlayer의 인증서 검증을 우회하는 것입니다.
SSL 인증 우회 방법
SSL 인증을 우회하기 위해 커스텀 URLSessionDelegate를 구현합니다.
class CustomURLSessionDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Self-signed Certificate 검증 우회
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
if let serverTrust = challenge.protectionSpace.serverTrust {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
return
}
}
// 기본 처리
completionHandler(.performDefaultHandling, nil)
}
}
이 델리게이트는 HTTPS 연결 시 인증서 검증 과정에서 Self-signed Certificate를 무조건 허용하도록 합니다. 상용 서비스를 구현할때 모든 Self-signed Certificate를 허용하는 것은 보안상 문제가 발생할 수 있으므로, 인증서 pinning 등을 추가로 구현해줍니다. 여기서 만든 URLSessionDelegate는 GCDWebServer에서 원본 서버로 요청할 때 사용됩니다.
GCDWebServer를 사용한 로컬 프록시 서버 구현
1. 기본 설정
import AVFoundation
import GCDWebServer
class CustomVideoPlayerView: UIView {
private var avPlayer: AVPlayer?
private var avPlayerLayer: AVPlayerLayer?
private var proxyServer: GCDWebServer?
private var proxyPath: String?
// 서버 관리를 위한 직렬 큐
private let serverQueue = DispatchQueue(label: "com.example.proxyserver")
}
2. 프록시 서버 설정
private func setupProxyServer() -> Bool {
var serverStarted = false
serverQueue.sync { [weak self] in
guard let self = self else { return }
// 기존 서버 정리
if let existingServer = self.proxyServer, existingServer.isRunning {
existingServer.stop()
self.proxyServer = nil
}
// 새 서버 인스턴스 생성
let proxyServer = GCDWebServer()
self.proxyServer = proxyServer
// M3U8 핸들러 설정
proxyServer.addHandler(forMethod: "GET",
path: "/",
request: GCDWebServerRequest.self) { [weak self] request in
return self?.handleM3U8Request(request)
}
// TS 세그먼트 핸들러 설정
proxyServer.addHandler(forMethod: "GET",
pathRegex: "^/.+\\.ts$",
request: GCDWebServerRequest.self) { [weak self] request in
return self?.handleTSRequest(request)
}
// 서버 시작
do {
try proxyServer.start(options: [
GCDWebServerOption_Port: 8080,
GCDWebServerOption_BindToLocalhost: true
])
self.proxyPath = "http://localhost:8080/"
serverStarted = true
} catch {
print("서버 시작 실패: \(error)")
}
}
return serverStarted
}
3. M3U8 요청 처리
private func handleM3U8Request(_ request: GCDWebServerRequest) -> GCDWebServerResponse? {
guard let originalUrl = self.originalStreamUrl,
let token = self.authToken else {
return GCDWebServerErrorResponse(statusCode: 500)
}
// Self-signed Certificate를 허용하는 URLSession 설정
let session = URLSession(configuration: .default,
delegate: CustomURLSessionDelegate(),
delegateQueue: nil)
var urlRequest = URLRequest(url: originalUrl)
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
// 동기 처리를 위한 세마포어
let semaphore = DispatchSemaphore(value: 0)
var responseResult: GCDWebServerResponse?
let task = session.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
guard let data = data,
let content = String(data: data, encoding: .utf8) else {
responseResult = GCDWebServerErrorResponse(statusCode: 500)
return
}
// M3U8 컨텐츠 수정 (경로 변경 등)
let modifiedContent = self.modifyM3U8Content(content)
let response = GCDWebServerDataResponse(
data: modifiedContent.data(using: .utf8)!,
contentType: "application/vnd.apple.mpegurl"
)
responseResult = response
}
task.resume()
_ = semaphore.wait(timeout: .now() + 15)
return responseResult
}
4. M3U8 컨텐츠(세그먼트) 수정 (경로 변경)
private func modifyM3U8Content(_ content: String) -> String {
var lines = content.components(separatedBy: .newlines)
var modifiedLines = [String]()
for line in lines {
// TS 파일 경로를 로컬 프록시 서버 경로로 변경
if line.hasSuffix(".ts") {
// 원본 경로에서 파일명만 추출
if let lastPathComponent = line.components(separatedBy: "/").last {
modifiedLines.append(lastPathComponent)
} else {
modifiedLines.append(line)
}
} else {
modifiedLines.append(line)
}
}
return modifiedLines.joined(separator: "\n")
}
5. TS 세그먼트 요청 처리
private func handleTSRequest(_ request: GCDWebServerRequest) -> GCDWebServerResponse? {
guard let originalUrl = self.originalStreamUrl,
let token = self.authToken else {
return GCDWebServerErrorResponse(statusCode: 500)
}
let tsPath = request.path
// Self-signed Certificate를 허용하는 URLSession 설정
let session = URLSession(configuration: .default,
delegate: CustomURLSessionDelegate(),
delegateQueue: nil)
// TS 파일 URL 구성
let tsUrl = originalUrl.deletingLastPathComponent()
.appendingPathComponent(tsPath)
var urlRequest = URLRequest(url: tsUrl)
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let semaphore = DispatchSemaphore(value: 0)
var responseResult: GCDWebServerResponse?
let task = session.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
guard let data = data else {
responseResult = GCDWebServerErrorResponse(statusCode: 500)
return
}
let response = GCDWebServerDataResponse(
data: data,
contentType: "video/mp2t"
)
responseResult = response
}
task.resume()
_ = semaphore.wait(timeout: .now() + 15)
return responseResult
}
6. AVPlayer 설정
private func setupAVPlayer() {
guard let proxyPath = self.proxyPath else { return }
// 로컬 프록시 URL로 AVPlayer 설정
let proxyUrl = URL(string: proxyPath)!
let asset = AVURLAsset(url: proxyUrl)
let playerItem = AVPlayerItem(asset: asset)
avPlayer = AVPlayer(playerItem: playerItem)
avPlayerLayer = AVPlayerLayer(player: avPlayer)
avPlayerLayer?.frame = bounds
avPlayerLayer?.videoGravity = .resizeAspectFill
layer.addSublayer(avPlayerLayer!)
avPlayer?.play()
}
7. Flutter 플랫폼 채널 설정
Flutter에서는 플랫폼 채널을 통해 네이티브 코드와 통신합니다:
// Flutter 코드에서 네이티브 플랫폼 채널 설정
const MethodChannel _channel = MethodChannel('custom-video-player');
// HLS 스트림 URL 설정 메서드
Future<void> playHlsStream(String url, String token) async {
try {
await _channel.invokeMethod('setHlsUrl', {
'url': url,
'token': token,
});
} catch (e) {
print('Error setting HLS URL: $e');
}
}
주의사항
- 보안: 이 방식은 Self-signed Certificate의 검증을 우회하므로, 개발 환경이나 사내 네트워크에서만 사용해야 합니다. 어쩔 수 없이 상용 서비스에 Self-signed Certificate 검증을 우회해야한다면, 인증서 pinning이나 유효성을 체크하기 위한 다른 방법이 추가되어야 합니다.
- 메모리 관리: 프록시 서버는 앱의 라이프사이클에 맞게 적절히 시작/종료되어야 합니다. 메모리 관리가 잘못되는 경우 비정상 종료 등의 문제가 발생할 수 있습니다.
- 에러 처리: 네트워크 오류, 타임아웃 등에 대한 적절한 처리가 필요합니다.
- 성능: 프록시를 통한 방식은 직접 연결보다 약간의 오버헤드가 있을 수 있습니다.
결론
로컬 프록시 서버를 사용하면 Self-Signed Certificate를 사용하는 HLS 스트리밍을 iOS에서 구현할 수 있습니다. 이 방식은 특히 IoT 장치나 내부 개발 환경과 같이 공식 SSL 인증서를 사용하기 어려운 상황에서 유용합니다. AVPlayer는 HTTP 프로토콜을 통해 로컬 프록시 서버에 요청하고, 프록시 서버는 커스텀 URLSessionDelegate를 사용하여 Self-signed Certificate를 허용하면서 HTTPS로 원본 서버에 요청하는 방식으로 동작합니다.
프로덕션 환경에서는 가능한 한 공식 SSL 인증서를 사용하는 것이 보안 측면에서 권장되지만, 불가피한 상황에서는 이러한 방식으로 문제를 해결할 수 있습니다.
참고 사항
- GCDWebServer: https://github.com/swisspol/GCDWebServer
- AVPlayer Documentation: https://developer.apple.com/documentation/avfoundation/avplayer
- HLS Specification: https://developer.apple.com/streaming/
'Programming > Flutter' 카테고리의 다른 글
Dart에서의 안전한 JSON 파싱 (0) | 2025.06.05 |
---|---|
[Flutter] GoRouter의 RouterDelegate와 RouteInformationProvider를 사용한 현재 라우트 추적 (0) | 2025.04.23 |
[Flutter] Isolate를 활용한 HTTP 클라이언트 리팩토링과 iOS에서 Isolate 초기화 문제 (0) | 2025.04.11 |
Sign in with Apple: iOS에서는 실패하는데 Android/Web에서는 성공하는 경우? (0) | 2025.04.09 |
[Flutter] 때때로 생성자에서 비동기 요청을 하게되면, 비동기 요청이 실행되기 전에 dispose()가 호출될 수도 있다. (0) | 2025.03.18 |