JS의 number 타입이 Dart의 double을 만났을 때
크로스 플랫폼 웹 캔버스를 개발할 당시엔 힘들었지만, 병원에서 꽤 자주 사용하는 것 같아 보람을 느낀다.
이슈가 터질때는 더 힘들고.
이 포스팅은 앱에서 int 타입이던 strokeWidth 가 double 타입으로 바뀌어 발생했던 문제를 웹에서 어떻게 해결했는지 작성한 글이다.
라는 인입이 들어온건 모바일 배포가 끝난 다음주여서, 이번 업데이트때 무언가 변경사항이 있으리라 짐작은 했었다.
담당했던 모바일 개발자분과 원인을 찾다가 이번 업데이트때 펜 스트로크를 프로그레스 바로 바꾸면서, double 타입이 됐다는 얘기를 해줬다.
재현경로는 다음과 같다.
Type Mismatch 에러가 발생해서 원본 이미지만 보이고, 벡터 정보가 적용되지 않는다.원인은 웹과 앱이 데이터를 주고받는 JSON 직렬화(Serialization) 과정에 있다.
앱 코드상으로는 gridSize, currentStrokeWidth 같은 필드들이 double 타입으로 선언되어 있고, 앱끼리 데이터를 주고받을 땐 문제가 없었다.
웹(JS 환경)에서 캔버스를 저장할 때 호출되는 exportJson() 메서드가 문제였다. Flutter Web으로 빌드된 코어 라이브러리는 내부적으로 다음 코드를 실행한다.
// web_bridge.dart 일부
static Future<JSString> _exportToJson() async {
try {
final state = _canvasBloc?.state;
if (state == null) {
throw Exception('Canvas not initialized');
}
// Create clean state with no selection for export
final cleanState = state.copyWith(selectedElementId: null);
// Use built-in toJson method from clean state
final canvasStateJson = cleanState.toJson();
// Add metadata for web export
final canvasData = canvasStateJson;
final jsonString = jsonEncode(canvasData);
return jsonString.toJS;여기서 jsonEncode는 JS의 JSON.stringify와 동일하게 동작한다. 즉, JS 런타임 특성상 10.0 같은 실수를 10(정수)으로 최적화 해버린다.
이 최적화된 정수 10이 서버에 저장되고, Dart Native 에서 이걸 double 변수에 넣으려다 크래시가 나는 것.
원인은 파악했고 앱쪽에서 수정도 어렵지 않게 가능하다고 했다.
- currentStrokeWidth: json['currentStrokeWidth'] as double,
- canvasSize: Size(
- canvasSizeJson['width'] as double,
- canvasSizeJson['height'] as double,
- ),
+ currentStrokeWidth: (json['currentStrokeWidth'] as num).toDouble(),
+ canvasSize: Size(
+ (canvasSizeJson['width'] as num).toDouble(),
+ (canvasSizeJson['height'] as num).toDouble(),
+ ),
...as double 대신 .toDouble()을 사용하면 10이든 10.0이든 안전하게 double로 변환된다.
문제는, 이 수정이 담긴 앱이 배포될 때까지 시간이 필요하다는 것이었다. iOS 앱 스토어 심사는 빠르면 하루, 느리면 일주일이 걸린다.
그 사이에 병원에서 웹으로 캔버스를 수정하면, 저장된 JSON이 10(정수)으로 들어가고, 앱에서 열 때마다 크래시가 난다.
내 목표는 명확했다.
→ 앱 배포가 완료될 때까지, 웹에서 저장하는 JSON이 앱을 터뜨리지 않게 만들기.
JS는 모든 숫자를 64비트 부동 소수점(IEEE 754) 형식으로 처리한다. 내부적으로 정수와 실수를 구분하지 않는 number 타입만 있다.
typeof 10 // 'number'
typeof 10.0 // 'number'
10 === 10.0 // true메모리 상에서도 10 === 10.0 은 완전히 동일하다.
하지만 Dart Native에서는 10은 int이고, 10.0은 double이다. 같은 JSON인데, 읽는 쪽의 언어에 따라 타입이 달라진다. 이게 크로스 플랫폼의 매력이자 함정이다.
JSON 문자열이 된 이후에 문자열 조작으로 정수를 소수로 바꾸면 된다.
즉 JSON.stringify가 만든 결과물에서, Dart가 double로 읽어야 하는 필드의 값에 .0을 강제로 붙이는 것이다.
반신반의 했지만, 이게 정답이었다.
대응해야할 필드는 세개였다.
gridSize, currentStrokeWidth (최상위 키-값)canvasSize 객체 안의 width, height// applyDoubleTypeFix 함수 일부
// 단순 필드
const simpleKeys = ['gridSize', 'currentStrokeWidth'];
simpleKeys.forEach((key) => {
const regex = new RegExp(`("${key}"\\s*:\\s*)(\\d+)(?![.\\d])`, 'g');
finalJson = finalJson.replace(regex, '$1$2.0');
});
// 중첩된 객체 필드
finalJson = finalJson.replace(/("canvasSize"\s*:\s*\{[^}]+\})/g, (match) => {
return match
.replace(/("width"\s*:\s*)(\d+)(?![.\d])/g, '$1$2.0')
.replace(/("height"\s*:\s*)(\d+)(?![.\d])/g, '$1$2.0');
});그리고 이런 짜치는 코드에 try/catch 는 필수다.
try {
// 정규식 변환 로직
} catch (error) {
// 데이터독 전송용
console.error('[CanvasContainer] JSON Serialization Fix Error:', error);
// 변환 실패 시 원본 json 사용
finalJson = typeof json === 'string' ? json : JSON.stringify(json);
}이렇게 하고 모바일 앱에서 디버깅 해봤을때, 실제로 성공적으로 double 타입으로 인식했다!
앱 수정이 배포되면 이 코드는 더 이상 필요 없다. 오히려 불필요한 문자열 조작이 매 저장마다 돌아가는 셈이니, 가능한 빨리 제거해야 한다.
그런데 그걸 제거하려면 또다시 웹을 배포해야 한다. 핫픽스를 치우기 위한 핫픽스라니, 그건 너무 짜친다.
이런 상황에 딱 맞는 도구가 있다. LaunchDarkly 피처 플래그다.
웹 개발은 이게 맞아
const { canvasJsonDoubleFlag } = useFlags();
if (canvasJsonDoubleFlag) {
// 정규식으로 .0 붙이는 로직
finalJson = applyDoubleTypeFix(finalJson);
}
canvasVector({ imageId, jsonString: finalJson });이렇게 해두면 웹 코드를 한 줄도 건드리지 않고, 대시보드 토글 하나로 이 짜치는 로직을 거둘 수 있다!
물론 구버전 앱 사용자가 남아있다면 이런 전략이 어렵지만, 이번 케이스에서는 강업이었기 때문에 가능한 추가 보강이었다.
이런 임시 코드에는 반드시 출구가 있어야 한다.
물론 피처플래그를 다음배포 전까지 지우는건 나의 몫이지만, 프로덕션 레벨에서 불필요한 작업이 계속 돌아가는 일보단 훨씬 나을 것이다.
사실 이번 핫픽스는 정석적인 해결이 아니다. 문자열 정규식으로 JSON 값을 조작하는 건 꽤 무모한…
하지만 이 당시 제약은 명확했다.
"근본 원인을 고칠 수 있지만 지금 당장은 배포할 수 없는 상황" 에서 웹이 시간을 벌어주는 역할을 한 것이다.
정규식으로 JSON을 조작하는 코드가 프로덕션에 올라가는 건 유쾌한 일은 아니지만, 제약 안에서 최선을 찾고, 문제 해결을 해내는 것이야말로 개발자의 진정한 존재 이유 라고 생각한다.