Dart 백엔드 서버 구축: Node.js 대비 메모리 50% 절감한 실전 마이그레이션기

최근 Flutter로 프론트엔드를 개발하고 Node.js(Express)로 백엔드를 운영하던 사내 프로젝트에서 흥미로운 병목 현상을 발견했습니다. JSON 직렬화/역직렬화 과정에서 발생하는 오버헤드와, 프론트-백엔드 간 타입 정의(DTO) 중복 관리로 인한 휴먼 에러가 지속적으로 발생했습니다. "어차피 클라이언트가 Dart인데, 서버도 Dart로 통일하면 타입 공유와 런타임 성능 두 마리 토끼를 잡을 수 있지 않을까?"라는 가설을 세웠고, 이를 검증하기 위해 메인 API 서버를 Dart 기반으로 포팅했습니다. 결과적으로 콜드 스타트 속도 3배 향상, 메모리 사용량 50% 절감이라는 유의미한 수치를 얻었습니다. 이 글에서는 단순한 "Hello World"가 아닌, 상용 수준의 트래픽을 처리하기 위해 Dart Server를 어떻게 튜닝했는지 기술적인 디테일을 공유합니다.

Dart 런타임 분석: Event Loop와 Isolate의 오해

많은 개발자들이 Dart를 단순히 "Flutter를 위한 UI 언어"로만 인식하지만, Dart의 서버 사이드 잠재력은 Isolate 모델과 강력한 AOT(Ahead-Of-Time) 컴파일에 있습니다. Node.js가 싱글 스레드 이벤트 루프에 의존하여 CPU 집약적인 작업(이미지 리사이징, 암호화 등)에서 메인 스레드를 블로킹하는 문제가 빈번한 반면, Dart는 손쉽게 별도의 메모리 공간을 가진 스레드(Isolate)를 생성하여 병렬 처리가 가능합니다.

실제 AWS t3.medium 인스턴스에서 테스트를 진행했을 때, Node.js는 CPU 사용량이 100%를 칠 때 GC(Garbage Collection) 스파이크가 튀며 요청 타임아웃이 발생했습니다. 반면 Dart는 Isolate.spawn을 통해 무거운 연산을 분리함으로써 메인 이벤트 루프의 응답성(Latency)을 안정적으로 유지할 수 있었습니다. 하지만 초기 접근 방식에는 실수가 있었습니다.

초기 실패 사례: dart:io의 HttpServer를 직접 구현하여 라우팅을 switch-case 문으로 처리했습니다. 코드가 500줄이 넘어가자 미들웨어 주입이 불가능해졌고, 예외 처리(Exception Handling)가 누락되어 서버가 조용히 죽는 'Zombie Process' 현상이 발생했습니다.

로우 레벨 API인 dart:io를 직접 다루는 것은 바퀴를 재발명하는 것과 같습니다. 특히 HTTP/1.1의 Keep-Alive 처리나 청크(Chunked) 인코딩을 직접 구현하다 보면 엣지 케이스에서 반드시 터집니다. 우리는 이 실패를 딛고, 미들웨어 파이프라인과 의존성 주입이 가능한 프레임워크 도입을 결정했습니다.

Shelf vs Dart Frog: 기술적 선택의 기로

Dart 서버 생태계의 양대 산맥은 Shelf(구글 공식 지원, 미니멀리즘)와 Dart Frog(Very Good Ventures, 생산성 중심)입니다. 우리는 초기에는 가벼운 Shelf를 고려했으나, 핫 리로드(Hot Reload) 지원과 폴더 구조 기반 라우팅(Next.js 스타일)의 생산성을 무시할 수 없어 최종적으로 Dart Frog를 선택했습니다.

솔루션: Dart Frog를 활용한 고가용성 아키텍처

다음은 실제 프로덕션에 적용된 미들웨어 패턴과 의존성 주입 예제입니다. Dart의 강력한 타입 시스템을 활용하여, 컴파일 타임에 DB 연결 오류나 환경 변수 누락을 잡아내는 것이 핵심입니다.

// middleware.dart
// 요청 전처리를 위한 미들웨어 체이닝 예제
import 'package:dart_frog/dart_frog.dart';
import 'package:shelf_cors/shelf_cors.dart';

Handler middleware(Handler handler) {
  // 1. CORS 설정 (Shelf 패키지 호환)
  final corsHandler = corsHeaders(
    headers: {ACCESS_CONTROL_ALLOW_ORIGIN: 'https://admin.myservice.com'},
  );

  // 2. 의존성 주입 (Repository 패턴)
  // use()를 사용하면 하위 라우트에서 context.read<UserRepository>()로 접근 가능
  return handler
      .use(requestLogger())
      .use(provider<UserRepository>((context) => _userRepo))
      .use(fromShelfMiddleware(corsHandler));
}

// routes/api/v1/users/index.dart
Response onRequest(RequestContext context) {
  // 런타임이 아닌 컴파일 타임에 타입 체크 가능
  final userRepo = context.read<UserRepository>();
  
  if (context.request.method == HttpMethod.get) {
    return Response.json(body: userRepo.getAllUsers());
  }
  
  return Response(statusCode: 405);
}

위 코드에서 주목할 점은 provider를 통한 의존성 주입입니다. Node.js의 Express에서 req.customData에 아무 객체나 구겨 넣다가 런타임에 undefined 에러를 만나는 것과 달리, Dart는 제네릭(Generics)을 통해 컨텍스트에서 꺼내오는 객체의 타입을 보장합니다. 이는 리팩토링 안정성을 비약적으로 높여줍니다.

성능 벤치마크 및 AOT 컴파일 효과

Dart VM은 JIT(Just-In-Time) 모드로 개발할 때는 빠른 핫 리로드를 제공하지만, 배포 시에는 AOT(Ahead-Of-Time) 컴파일을 통해 네이티브 바이너리로 변환해야 합니다. 이를 도커(Docker) 멀티 스테이지 빌드와 결합하여 경량화된 이미지를 생성했습니다.

지표 (Metric) Node.js (Express) Dart (JIT Mode) Dart (AOT Binary)
Docker Image Size 180 MB (Alpine) 250 MB 15 MB (Scratch)
Cold Start Time 850ms 600ms 120ms
Idle Memory Usage 65 MB 45 MB 12 MB
Requests/Sec (TPS) 2,800 3,100 4,500

결과는 충격적이었습니다. AOT 컴파일된 Dart 서버는 런타임 VM을 포함할 필요가 없기 때문에 FROM scratch 기반의 극도로 작은 도커 이미지를 만들 수 있습니다. 이는 오토스케일링(Auto-scaling) 시 파드(Pod)가 뜨는 속도를 획기적으로 단축시켜, 트래픽 폭주 상황에서의 대응력을 높여주었습니다. 특히 메모리 사용량이 12MB 수준으로 떨어진 것은, 람다(AWS Lambda)나 클라우드 런(Cloud Run) 같은 서버리스 환경에서 비용 절감 효과가 막대함을 의미합니다.

Dart Server 공식 문서 확인하기

주의사항 및 도입 전 고려사항

Dart 백엔드가 장밋빛 미래만 있는 것은 아닙니다. 도입을 고려한다면 다음의 치명적인 단점(Edge Cases)을 반드시 인지해야 합니다.

생태계의 한계: Node.js의 방대한 NPM 생태계에 비하면 Dart의 서버 사이드 라이브러리는 아직 부족합니다. 예를 들어, Pub.dev에서 AWS SDK나 특정 결제 모듈(Payment Gateway)의 공식 지원 라이브러리가 없거나, 커뮤니티 버전이 유지보수되지 않는 경우가 종종 있습니다.

또한, ORM(Object-Relational Mapping)의 선택지가 좁습니다. TypeScript 진영의 Prisma나 TypeORM처럼 성숙한 도구를 기대한다면 실망할 수 있습니다. 현재로서는 Prisma의 Dart 클라이언트나, Dart 전용 ORM인 Drift 등을 사용해야 하는데, 복잡한 조인 쿼리나 마이그레이션 관리에서 SQL을 직접 작성해야 하는 상황이 발생할 수 있습니다.

결론: Flutter 팀이라면 강력 추천

Dart 서버는 더 이상 실험적인 장난감이 아닙니다. 클라이언트와 서버가 동일한 언어를 사용함으로써 얻는 코드 공유(Code Sharing)의 이점과, AOT 컴파일이 주는 압도적인 퍼포먼스는 소규모 팀이 빠르게 제품을 이터레이션 하는 데 큰 무기가 됩니다. 다만, 프로젝트가 복잡한 서드파티 통합을 필요로 한다면 라이브러리 지원 여부를 먼저 검토하는 PO(Proof of Concept) 과정이 필수적입니다. 지금 운영 중인 단순 API 서버가 있다면, 이번 주말 Dart Frog로 마이그레이션을 시도해 보시길 권장합니다.

Post a Comment