지난 글에서는 스크린샷 및 화면 녹화 방지 트릭을 다뤘는데, 이번 편에서는 iOS에서 앱이 백그라운드로 전환될 때(앱 스위처 화면) 민감 정보가 그대로 노출되지 않도록 가리는 방법을 다뤄보겠다.
다른 금융앱이나, 주식앱 및 공공앱들을 보면 화면이 가려져 있기 때문이다. (쿠팡은 와이?)

여튼 앱 스취어에서 고객정보나 비밀번호 또는 중요 정보들이 노출되는 것 만으로도 큰 사고가 된다.
따라서 목표는 스크린샷/녹화 방지는 물론, 앱을 백그라운드로 전환했을 때 iOS의 앱 스위처 화면(멀티태스킹 뷰)에 Flutter 화면 대신 흰색 배경에 로고가 박힌 썸네일을 표시하는 것!
앞서 말했듯이 Android는 단 하나의 코드로 모든 문제가 해결된다.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
하지만 iOS는 단순하지 않다.
이쯤 생각나는 첫~맛남은 너무 어려워~!ㅎ
iOS에서는 다음과 같은 기능이 분리되어 있다

일반 iOS 앱에서는 SceneDelegate를 사용해서 이렇게 해결하라고 한다.
이는 iOS의 라이프 사이클과 관련이 있는데

핵심 역할: 개별 UI “장면(Scene)” 관리, 멀티 윈도우 대응
iOS 13 이상에서 등장, 멀티 윈도우(multi-window)를 지원하기 위해 만들어졋으며, 앱 내 각각의 Scene(화면, 창) 단위로 생명주기를 관리한다.
주요 이벤트:
Scene이 화면에 나타날 때:sceneDidBecomeActive
Scene이 백그라운드로 갈 때:sceneDidEnterBackground
Scene 연결/해제할 때:scene(_:willConnectTo:options:), sceneDidDisconnect

https://developer.apple.com/documentation/uikit/uiscenedelegate
func sceneWillResignActive(_ scene: UIScene) {
window?.addSubview(UIView(frame: window!.bounds))
}

핵심 역할: 앱 전체 생명주기 관리, 시스템 이벤트 대응
iOS 앱에서 최초 진입점 역할을 수행하며 앱이 실행, 종료, 백그라운드 진입, 포그라운드 진입 등과 같은 전체 생명주기 이벤트를 받을 때 호출된다. 단일 앱에서 하나만 존재하고 앱 전체를 아우른다.

https://developer.apple.com/documentation/uikit/uiapplicationdelegate
단순하다. 우리가 사용하는 플러터 버전(3.29.3)에서는 지원하지 않는다.
하지만 OS 26이 나오면서, 호환성 문제가 대두되었고, 추가적으로 개발중인것 같다.

https://docs.flutter.dev/release/breaking-changes/uiscenedelegate
즉, 우리는 AppDelegate만으로 앱 스위처를 제어해야 했다...
앞서 설명한 앱 스위처 화면 가림 구현에서 핵심 포인트는 Flutter나 WebView가 그린 화면까지 완전히 가려야하고, 캡쳐 및 녹화시에도 화면을 가려야 한다는 점 이다.
단순히 UIView를 올리거나 isHidden을 바꾸는 수준으로는 모든 기능을 충족하기가 어려웠다.
Flutter는 GPU 기반 렌더링을 사용하고, WebView는 별도의 프로세스로 화면을 그리기 때문에,
iOS 기본 뷰 계층만으로는 화면 캡처나 멀티태스킹 썸네일에서 민감 정보가 노출될 수 있다.
그래서 우리는 CALayer를 활용한 ‘보안 레이어’를 만들었다.
CALayer는 모든 UIView의 렌더링 최하위 계층이자 GPU 단에서 처리되는 레이어이므로, Flutter·WebView 등 하위 렌더링 엔진에서 그려진 화면까지 덮어 씌울 수 있는 유일한 방법이다.
- UIWindow 또는 UITextField 레이어를 직접 건드리면서 뷰 계층 구조(View Hierarchy)를 강제로 바꿈
- overlay가 제대로 추가되지 않으면 일부 화면이 가려지지 않거나, 터치 이벤트가 overlay에 막혀 Flutter 화면이 반응하지 않음
- 공식 API가 아닌 ‘hack’ 수준의 처리이므로, 미래 iOS 버전에서 앱 스위처/스크린샷 방지가 제대로 작동한다는 보장이 없음
- 특히 멀티 Scene, 외부 라이브러리 UI가 많은 앱에서는 예상치 못한 버그가 발생할 가능성 높음
1️⃣ 앱이 백그라운드로 전환될 때(applicationWillResignActive)
→ CALayer 기반 overlay를 생성해 화면 전체를 덮고 로고를 표시
2️⃣ 앱이 포그라운드로 돌아올 때(applicationDidBecomeActive)
→ overlay 제거 후, 원래 화면 표시.
3️⃣ 동시에 스크린샷/녹화 감지 알림을 AppDelegate에서 처리
→ 민감 정보가 노출되면 즉시 사용자에게 알림.
1. 보안 Layer 생성
let field = UITextField(frame: window.bounds)
field.isSecureTextEntry = true
window.addSubview(field)
UITextField를 전체 화면 크기로 생성하고 isSecureTextEntry = true 설정
iOS 시스템에 “이 Layer는 민감 정보이므로 캡처 금지”를 알림
2. 로고 Layer 삽입
let logoLayer = CALayer()
logoLayer.backgroundColor = UIColor.white.cgColor
let imageLayer = CALayer()
imageLayer.contents = UIImage(named: "LaunchImage")?.cgImage
imageLayer.contentsGravity = .resizeAspect
logoLayer.addSublayer(imageLayer)
field.layer.insertSublayer(logoLayer, at: 0)
CALayer로 흰색 배경과 앱 로고를 생성
Flutter 화면 위에 강제로 덮어, 앱 사용 중 스크린샷/녹화 시 민감 정보 보호
3. Layer 강제 조작
self.layer.superlayer?.addSublayer(field.layer)
field.layer.sublayers?.last!.addSublayer(self.layer)
UIWindow Root Layer를 UITextField Layer 안으로 강제로 이동
1. 백그라운드 진입 직전 (applicationWillResignActive)
window.makeUnsecure() // Secure Layer 제거
window.addSubview(createOverlay(frame: window.bounds)) // 로고 UIView 추가
Secure Layer 제거 후 로고 UIView를 최상위에 추가
앱 스위처 썸네일에는 깔끔하게 로고만 표시
2. 포그라운드 복귀 (applicationDidBecomeActive)
overlayView?.removeFromSuperview()
window.makeSecure(overlayGenerator: createOverlay)
overlay 제거 후 Flutter 화면 복구
Secure Layer 재활성화 → 앱 사용 중 보호 유지
@main
@objc class AppDelegate: FlutterAppDelegate {
private var overlayView: UIView?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
self.window.makeSecure(overlayGenerator: { _ in UIView() }) //스크린샷 방지
// 스크린샷 감지 Alert 셋팅
NotificationCenter.default.addObserver(
self,
selector: #selector(alertCapture),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(alertRecoding),
name: UIScreen.capturedDidChangeNotification,
object: nil
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// 스크린샷/녹화 알림
@objc private func alertCapture() {
showCaptureAlert("캡쳐가 감지되어 화면을 차단합니다.")
}
@objc private func alertRecoding() {
guard UIScreen.main.isCaptured else { return }
showCaptureAlert("녹화가 감지되어 화면을 차단합니다.")
}
private func showCaptureAlert(_ title: String) {
let alert = UIAlertController(
title: title,
message: "",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "확인", style: .default, handler: nil))
if var topController = self.window?.rootViewController {
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
DispatchQueue.main.async {
topController.present(alert, animated: false, completion: nil)
}
}
}
// 앱이 비활성 상태가 될 때 (백그라운드 진입 직전) 호출
override func applicationWillResignActive(_ application: UIApplication) {
self.window?.makeUnsecure()
guard let window = self.window else { return }
if overlayView == nil {
let overlay = createOverlay(frame: window.bounds)
window.addSubview(overlay)
overlayView = overlay
}else {
window.bringSubviewToFront(overlayView!)
}
}
override func applicationDidBecomeActive(_ application: UIApplication) {
overlayView?.removeFromSuperview()
overlayView = nil
self.window?.makeSecure(overlayGenerator: createOverlay)
}
// 로고 썸네일 생성
private func createOverlay(frame: CGRect) -> UIView {
let overlay = UIView(frame: frame)
overlay.backgroundColor = .white
let imageView = UIImageView(image: UIImage(named: "LaunchImage"))
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
overlay.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
imageView.widthAnchor.constraint(lessThanOrEqualTo: overlay.widthAnchor, multiplier: 0.5),
imageView.heightAnchor.constraint(lessThanOrEqualTo: overlay.heightAnchor, multiplier: 0.5)
])
return overlay
}
}
extension UIWindow {
private static let secureFieldTag = 1000
// 로고 CALayer를 UITextField의 Layer 내부에 삽입
func makeSecure(overlayGenerator: @escaping (CGRect) -> UIView) {
DispatchQueue.main.async {
// 이미 추가되어 있다면 중복 방지
guard self.viewWithTag(UIWindow.secureFieldTag) == nil else { return }
// UITextField를 윈도우 크기로 초기화합니다.
let field = UITextField(frame: self.bounds)
field.isSecureTextEntry = true
field.tag = UIWindow.secureFieldTag
self.addSubview(field)
let logoLayer = CALayer()
logoLayer.frame = field.bounds // UITextField 전체 크기에 맞춤
logoLayer.backgroundColor = UIColor.white.cgColor
// 로고 이미지 CALayer를 생성
if let launchImage = UIImage(named: "LaunchImage")?.cgImage {
let imageLayer = CALayer()
imageLayer.contents = launchImage
imageLayer.contentsGravity = .resizeAspect
let logoScale: CGFloat = 0.25
let imageSize = CGSize(
width: field.bounds.width * logoScale,
height: field.bounds.height * logoScale
)
imageLayer.frame = CGRect(
x: (field.bounds.width - imageSize.width) / 2,
y: (field.bounds.height - imageSize.height) / 2,
width: imageSize.width,
height: imageSize.height
)
logoLayer.addSublayer(imageLayer)
}
// 로고 레이어를 UITextField 레이어의 가장 아래에 삽입
field.layer.insertSublayer(logoLayer, at: 0)
// 캡처 방지 핵심: 레이어 강제 조작 코드 유지
self.layer.superlayer?.addSublayer(field.layer)
field.layer.sublayers?.last!.addSublayer(self.layer)
}
}
func makeUnsecure() {
DispatchQueue.main.async {
if let secureField = self.viewWithTag(UIWindow.secureFieldTag) as? UITextField {
// 강제로 변경되었던 레이어 구조를 복원시도
if let secureFieldSuperlayer = secureField.layer.superlayer {
secureFieldSuperlayer.addSublayer(self.layer)
}
// UITextField 제거 (내부의 logoLayer 포함하여 모두 제거됨)
secureField.removeFromSuperview()
secureField.layer.removeFromSuperlayer()
}
}
}
}
처음에는 스크린샷과 화면 녹화 방지는 단순한 문제일 거라고 생각했다.
하지만 iOS와 Flutter, WebView가 각기 다른 렌더링 구조를 가지고 있어 상황은 훨씬 복잡했다.
게다가 앱 스위처 화면에서도 민감 정보가 노출될 수 있다는 점이 문제는 처음 알게 되었다.
(iOS 와 Android는 다르게 작동하는것도 알게됨)
iOS 개발자가 아니어서 접근 자체가 쉽지 않았다.
수많은 빌드와 테스트를 반복하며 구현해야 했고, AppDelegate에서 화면 전환이 유연하게 처리되지 않아 캡처 시에도 썸네일이 나타나도록 꼼수를 부릴 수밖에 없었다.
과정 내내 쉽지 않았지만, 그 덕분에 안정적인 보안 처리가 가능했다.
재미있게도 기획자님들은 오히려 결과물을 더 마음에 들어하며 좋아했다.
덕분에 수많은 시행착오와 수많은 빌드가 충분히 보람 있었던것 같다.
이제 다음 글에서는 UI 보안 적용보다는, iOS 앱 빌드 시 freeRASP와 충돌하는 문제를 중심으로 해결 방법을 정리할 예정이다.
결론 보안 처리는 너무 어렵다.....
