거래소 앱의 신분증 자동촬영 기능에서 실시간 프레임 분석과 60fps를 동시에 달성한 이야기
안녕하세요, INEX에서 Flutter로 앱을 개발하고 있는 Hue입니다.
암호화폐 거래소 앱의 KYC(본인인증) 절차에는 신분증 촬영 → OCR 추출 → 진위확인으로 이어지는 신원확인 단계가 있습니다. 저희는 여기에 사용자가 카메라에 신분증을 비추기만 하면 자동으로 촬영되는 기능을 추가했는데, 구현 과정에서 예상치 못한 벽을 만났습니다.
카메라가 보내주는 매 프레임마다 다음과 같은 작업이 필요했습니다:
이미지가 흐릿한지 판단하는 블러 감지
조명이 적절한지 판단하는 밝기 측정
텍스트의 존재 여부와 신분증 여부를 판단하는 OCR 분석
문제는 이 연산들이 생각보다 무거웠다는 것입니다. 720x1280 해상도 기준으로 약 92만 개의 픽셀을 하나하나 순회하며 계산해야 했고, 이 작업을 UI를 그리는 메인 스레드에서 수행하자 카메라 프리뷰가 눈에 띄게 버벅이기 시작했습니다.
이 글에서는 Flutter의 Isolate를 활용해 프레임 드롭 문제를 해결한 과정과, 그 과정에서 마주친 삽질들을 공유하려 합니다.
1. 문제: 메인 스레드 하나로는 부족했습니다
Flutter는 기본적으로 싱글 스레드로 동작합니다. UI 렌더링, 사용자 입력 처리, 비즈니스 로직이 모두 하나의 메인 스레드(Main Isolate)에서 돌아갑니다.
평소에는 이것이 문제가 되지 않습니다. 네트워크 요청 같은 I/O 작업은 async/await로 처리하면 메인 스레드를 블로킹하지 않으니까요.
하지만 CPU 집약적인 연산은 이야기가 달랐습니다. async/await는 I/O 대기 시간을 양보하는 것이지, CPU 연산 자체를 다른 스레드로 보내주는 것이 아닙니다. 92만 픽셀에 대해 라플라시안 분산을 계산하는 이중 for문은 async로 감싸든 말든 메인 스레드의 CPU 시간을 점유합니다.

메인 스레드 블로킹 흐름도
실제로 얼마나 차이가 날까?
아래는 Isolate 적용 전후를 비교한 영상입니다. Isolate 없이 메인 스레드에서 직접 프레임 분석을 실행했을 때와, Isolate로 분리한 뒤의 차이를 확인할 수 있습니다.


Before 영상에서는 카메라 프리뷰가 뚝뚝 끊기는 반면, After 영상에서는 부드럽게 실시간 분석이 이루어지는 것을 볼 수 있습니다.
2. 해결 도구: Isolate
Isolate란?
Isolate는 Dart의 병렬 실행 단위입니다. 다른 언어의 Thread와 비슷한 역할을 하지만, 결정적인 차이가 있습니다.

Thread vs Isolate 메모리 구조 비교
이름 그대로 Isolate는 격리되어 있습니다. 메인 Isolate의 변수에 직접 접근할 수 없고, 오직 메시지를 주고받는 것만 가능합니다. 이 제약이 오히려 장점인데, Thread 프로그래밍의 고질적인 문제인 Race condition이 구조적으로 불가능해지기 때문입니다.
Isolate 사용법 3단계
Level 1: Isolate.run() — 가장 간단한 방식
final result = await Isolate.run(() {
// 무거운 연산
return heavyComputation(data);
});Dart 2.19 이후 추가된 가장 간단한 API입니다. 다만 매번 Isolate를 생성하고 파괴하므로, 반복 호출 시 오버헤드가 발생합니다.
Level 2: compute() — Flutter 기본 제공 헬퍼
// Top-level 함수여야 합니다 (클로저 불가)
ResultType myFunction(InputType input) {
return heavyComputation(input);
}
final result = await compute(myFunction, inputData);Flutter의 foundation 패키지에서 제공하는 헬퍼입니다. 내부적으로 Isolate를 생성하고 결과를 받아오는 과정을 감싸줍니다. 저희 앱에서 실제로 사용하고 있는 방식이기도 합니다.
Level 3: Isolate + SendPort — 고급 패턴
// Isolate를 한 번만 생성하고 계속 재사용
final receivePort = ReceivePort();
await Isolate.spawn(workerEntryPoint, receivePort.sendPort);
// 작업 요청
workerSendPort.send(data);
// 결과 수신
receivePort.listen((result) {
// 처리
});Isolate를 앱 시작 시 띄워놓고 계속 재사용하는 패턴입니다. 생성/파괴 오버헤드가 없어 초당 수십 회 호출이 필요한 실시간 처리에 적합합니다.
3. 실전 적용: 신분증 OCR 자동촬영
전체 파이프라인
저희 자동촬영 기능의 전체 흐름은 다음과 같습니다.

Isolate 적용 후 자동촬영 파이프라인
여기서 핵심은 CPU 집약적인 블러/밝기 연산만 Isolate로 분리하고, 나머지는 메인 스레드에서 처리했다는 점입니다. 모든 연산을 Isolate로 보내는 것이 아니라, 병목이 되는 부분만 정확히 분리하는 것이 포인트였습니다.
블러 감지: 라플라시안 분산
흐릿한 사진인지 판단하기 위해 고전적인 기법인 라플라시안 분산을 사용했습니다.
// 라플라시안 커널을 각 픽셀에 적용
for (int y = cropTop + 1; y < cropBottom - 1; y++) {
for (int x = cropLeft + 1; x < cropRight - 1; x++) {
final lap = (gray[(y - 1) * w + x] + // 위
gray[(y + 1) * w + x] + // 아래
gray[y * w + (x - 1)] + // 왼쪽
gray[y * w + (x + 1)] - // 오른쪽
4 * gray[y * w + x]) // 중심 × -4
.toDouble();
lapSum += lap;
lapSumSq += lap * lap;
lapCount++;
}
}
final lapMean = lapSum / lapCount;
final blurScore = (lapSumSq / lapCount) - (lapMean * lapMean);원리는 비교적 간단합니다. 각 픽셀과 상하좌우 이웃 픽셀의 차이를 계산하는 것인데, 선명한 이미지는 경계(edge)가 뚜렷해서 이 값의 분산이 크고, 흐릿한 이미지는 경계가 뭉개져 분산이 작습니다.
저희는 이 blurScore가 60.0을 넘으면 선명하다고 판정하고 있습니다.
Isolate로 데이터를 넘길 때의 제약
Isolate는 메모리가 격리되어 있으므로, 데이터를 보낼 때 직렬화 가능한 타입만 사용할 수 있습니다.
class _PixelData {
final Uint8List yBytes; // 원시 바이트 배열
final int width;
final int height;
final int rowStride;
final bool isAndroid;
}Uint8List, int, bool 같은 원시 타입만 사용했습니다. 커스텀 클래스를 보내는 것도 가능하지만, 내부 필드가 모두 직렬화 가능해야 합니다. BuildContext나 Widget 같은 프레임워크 객체는 전달할 수 없습니다.
compute()는 Top-level 함수만 받습니다
// Top-level 함수 — Isolate에서 실행 가능
_PixelResult _analyzePixelsIsolate(_PixelData data) {
// 블러 + 밝기 계산
}
// 클래스 메서드나 클로저 — Isolate로 전달 불가
class MyController {
void analyze() {
compute((data) { // 컴파일은 되지만 런타임 에러
return processData(data);
}, inputData);
}
}compute()에 넘기는 함수는 반드시 Top-level 함수이거나 static 메서드여야 합니다. 클로저(람다)는 외부 변수를 캡처하는데, 해당 변수가 다른 Isolate의 메모리 영역에 있으므로 접근이 불가능하기 때문입니다.
4. 삽질 로그
Isolate를 도입하면서 겪었던 실제 함정들을 정리했습니다. 공식 문서에는 잘 나오지 않는 이야기들입니다.
삽질 1: Isolate에서 싱글톤에 접근할 수 없었습니다
// 메인 Isolate에서 초기화한 싱글톤
class AppConfig {
static final AppConfig instance = AppConfig._();
// ...
}
// Isolate에서 접근하면?
compute((data) {
AppConfig.instance.apiUrl; // 초기화 안 된 새 인스턴스!
}, data);Isolate는 메모리가 완전히 격리되어 있습니다. 메인 Isolate에서 초기화한 싱글톤이라 하더라도, 새 Isolate에서 접근하면 초기화되지 않은 빈 인스턴스가 반환됩니다. 실제로 저희 코드에는 이런 주석이 남아있습니다:
/// 이 화면은 아이솔레이트 따로 싸서 메인 아이솔레이트에서 만든 싱글톤 객체 접근이 안됨
await RunningEnvironment.init(isMain: false);필요한 설정값은 파라미터로 직접 넘기거나, Isolate 안에서 별도로 초기화하는 방식으로 해결했습니다.
삽질 2: Android와 iOS의 카메라 포맷이 달랐습니다
if (data.isAndroid) {
// YUV420 포맷 — Y 평면만 추출
for (int y = 0; y < data.height; y++) {
for (int x = 0; x < data.width; x++) {
gray[y * data.width + x] = data.yBytes[y * data.rowStride + x];
}
}
} else {
// iOS BGRA8888 포맷 — RGB → 그레이스케일 변환 필요
for (int y = 0; y < data.height; y++) {
for (int x = 0; x < data.width; x++) {
final offset = y * data.rowStride + x * 4;
final b = data.yBytes[offset];
final g = data.yBytes[offset + 1];
final r = data.yBytes[offset + 2];
gray[y * data.width + x] =
(0.299 * r + 0.587 * g + 0.114 * b).round().clamp(0, 255);
}
}
}Android는 YUV420 포맷으로 프레임을 전달하고, iOS는 BGRA8888 포맷으로 전달합니다. 같은 "카메라 프레임"이지만 내부 데이터 구조가 완전히 달라, Isolate 함수 안에서 플랫폼별 분기 처리가 필요했습니다. iOS의 경우 RGB를 그레이스케일로 변환하는 추가 연산까지 들어갑니다.
삽질 3: CameraX가 콜백 리턴 시점에 ImageProxy를 닫아버렸습니다
Future<bool> _analyzeFrame(CameraImage image) async {
// CameraX는 _onFrame 콜백이 리턴되는 순간 ImageProxy를 닫음
// 따라서 await 이전에 image.planes 데이터를 모두 Dart 힙으로 복사해야 함
final pixelData = _PixelData(
yBytes: Uint8List.fromList(image.planes[0].bytes), // 복사!
// ...
);
// 이 시점 이후 image.planes 접근 → 크래시
final pixelResult = await compute(_analyzePixelsIsolate, pixelData);
}await 이전에 이미지 데이터를 반드시 복사해두어야 합니다. compute()는 비동기이기 때문에 await하는 동안 카메라 프레임워크가 원본 버퍼를 해제해버립니다. Uint8List.fromList()로 Dart 힙에 복사하지 않으면, 이후 접근 시 크래시가 발생합니다.
이 부분은 디버깅이 특히 까다로웠는데, 간헐적으로만 발생하는 크래시였기 때문입니다.
삽질 4: RootIsolateToken 없이는 플러그인 호출이 불가능했습니다
Isolate 안에서 Flutter 플러그인(SharedPreferences, PathProvider 등)을 호출해야 하는 경우가 있다면, RootIsolateToken을 넘겨주어야 합니다.
final token = RootIsolateToken.instance!;
await compute((data) {
BackgroundIsolateBinaryMessenger.ensureInitialized(data.token);
// 이제 플러그인 호출 가능
}, DataWithToken(payload: myData, token: token));저희는 블러/밝기 계산에 순수 Dart 연산만 사용했기 때문에 이 문제를 비껴갈 수 있었지만, Isolate 안에서 API 호출이나 파일 저장이 필요한 경우라면 이 설정이 필수적입니다.
5. compute() vs Isolate, 어떤 것을 선택해야 할까
기준 | compute() | Isolate |
|---|---|---|
구현 난이도 | 한 줄 | SendPort/ReceivePort 관리 필요 |
Isolate 생성 비용 | 매번 발생 (~2-5ms) | 최초 1회 |
적합한 상황 | 간헐적 호출 (0.5초+ 간격) | 초당 수십 회 호출 |
메모리 | 사용 후 자동 해제 | 명시적 해제 필요 |
저희 앱에서는 프레임 분석 주기를 900ms(약 1초에 한 번)로 설정했기 때문에 compute()로 충분했습니다. 만약 초당 30프레임 전부를 분석해야 하는 상황이었다면 Isolate를 도입했을 것입니다.
6. 설계 원칙: 병목만 정확히 분리합니다
Isolate가 효과적이라고 해서 모든 연산을 Isolate로 보내면 오히려 성능이 나빠질 수 있습니다. 데이터를 직렬화하고, Isolate를 생성하고, 결과를 다시 받아오는 통신 오버헤드가 존재하기 때문입니다.
저희 파이프라인에서 Isolate로 분리한 것은 블러 + 밝기 계산 단 하나입니다:
[Isolate로 분리] 블러 감지 (92만 픽셀 라플라시안) + 밝기 측정 |
|---|
"병목을 정확히 찾고, 그 부분만 Isolate로 분리한다" — 이것이 저희가 내린 결론이었습니다.
DevTools의 Performance 탭에서 타임라인을 확인하면, 어떤 함수가 메인 스레드를 얼마나 점유하는지 정확히 파악할 수 있습니다. 감이 아닌 측정 기반의 판단이 중요합니다.
마치며
이번 작업을 통해 얻은 교훈을 정리하면 다음과 같습니다.
async/await는 멀티스레드가 아닙니다. CPU 집약적인 연산은 async로 감싸더라도 메인 스레드를 블로킹합니다.
Isolate로 무거운 연산을 분리하면 UI 스레드가 본연의 역할에 집중할 수 있습니다.
모든 것을 Isolate로 보내는 것이 아니라, 병목이 되는 부분만 정확히 분리하는 것이 중요합니다.
Isolate 간 메모리는 완전히 격리되어 있습니다. 싱글톤, BuildContext 등에는 접근할 수 없습니다.
compute()는 간편하지만 매번 Isolate 생성 비용이 발생합니다. 호출 빈도에 따라 Isolate도 고려해볼 만합니다.
Flutter에서 카메라, 이미지 처리, 실시간 데이터 분석 같은 CPU 집약적인 작업을 다루고 계시다면, Isolate는 선택이 아닌 필수입니다. 사용자는 0.1초의 끊김도 느끼니까요.
이 글에서 다룬 코드는 INEX 거래소 앱의 KYC 신분증 자동촬영 기능에서 실제로 사용되고 있습니다.
