Flutter 앱의 강력한 동반자, Dart를 이제 서버에서 만나보세요. 이 가이드에서는 Dart와 경량 웹 프레임워크 Shelf를 사용하여, 확장 가능하고 성능이 뛰어난 REST API 서버를 구축하는 모든 과정을 단계별로 상세하게 안내합니다.
Dart는 구글이 개발한 클라이언트 최적화 언어로, Flutter를 통해 모바일, 웹, 데스크톱 앱 개발에서 엄청난 인기를 얻고 있습니다. 하지만 Dart의 진정한 잠재력은 프론트엔드에만 국한되지 않습니다. Dart는 서버 사이드 개발에서도 강력한 성능, 타입 안전성, 그리고 뛰어난 개발 경험을 제공합니다. 특히 Flutter 앱과 동일한 언어로 백엔드를 구축할 수 있다는 점은 풀스택 개발의 생산성을 극대화하는 매력적인 요소입니다.
이 글에서는 수많은 Dart 서버 프레임워크 중에서도 구글이 공식적으로 지원하고 미들웨어 기반의 유연한 구조를 자랑하는 Shelf를 중심으로 REST API 서버를 구축하는 방법을 처음부터 끝까지 다룹니다. 초보자도 쉽게 따라 할 수 있도록 프로젝트 설정부터 라우팅, JSON 처리, 미들웨어 활용, 그리고 실제 배포를 위한 컨테이너화까지 모든 것을 담았습니다.
왜 서버 사이드 개발에 Dart를 선택해야 할까요?
Node.js, Python, Go 등 쟁쟁한 경쟁자들이 있는 서버 시장에서 Dart가 가지는 차별점은 무엇일까요? Dart 서버 개발의 핵심 장점은 다음과 같습니다.
- 하나의 언어, 풀스택 개발: Flutter 개발자라면 새로운 언어를 배울 필요 없이 기존의 Dart 지식만으로 백엔드를 구축할 수 있습니다. 이는 코드 재사용성을 높이고 프론트엔드와 백엔드 간의 모델 클래스 등을 공유하여 개발 효율을 비약적으로 향상시킵니다.
- 압도적인 성능: Dart는 개발 시에는 빠른 컴파일 속도를 자랑하는 JIT(Just-In-Time) 컴파일러를, 프로덕션 환경에서는 네이티브 코드로 컴파일하여 놀라운 실행 속도를 보장하는 AOT(Ahead-Of-Time) 컴파일러를 모두 지원합니다. AOT 컴파일된 Dart 서버 애플리케이션은 Go나 Rust에 버금가는 고성능을 보여줍니다.
- 타입 안전성(Type Safety): Dart의 정적 타입 시스템과 사운드 널 안정성(Sound Null Safety)은 컴파일 시점에 잠재적인 오류를 대부분 잡아내어 런타임 에러 발생 가능성을 크게 줄여줍니다. 이는 안정적이고 유지보수가 용이한 서버를 만드는 데 결정적인 역할을 합니다.
- 비동기 프로그래밍 지원:
Future
와Stream
을 기반으로 한 Dart의 비동기 처리 모델은 동시 다발적인 요청을 효율적으로 처리해야 하는 서버 환경에 매우 적합합니다.async/await
문법은 복잡한 비동기 로직을 동기 코드처럼 간결하게 작성할 수 있도록 돕습니다.
Shelf 프레임워크 소개: Dart 서버 개발의 표준
Shelf는 Dart 팀이 직접 만들고 관리하는 미들웨어 기반의 웹 서버 프레임워크입니다. '미들웨어'란 요청(Request)과 응답(Response) 사이에서 특정 기능을 수행하는 작은 함수들의 연쇄라고 생각하면 쉽습니다. 이러한 구조 덕분에 Shelf는 매우 가볍고 유연하며, 필요한 기능만 선택적으로 추가하여 서버를 구성할 수 있습니다.
Node.js의 Express.js나 Koa.js에 익숙하다면 Shelf의 개념을 쉽게 이해할 수 있습니다. Shelf의 핵심 구성 요소는 다음과 같습니다.
- Handler:
Request
객체를 받아Response
객체를 반환하는 함수입니다. 모든 요청 처리의 기본 단위입니다. - Middleware:
Handler
를 감싸는 함수로, 요청이 실제 핸들러에 도달하기 전이나 핸들러가 응답을 반환한 후에 추가적인 로직(로깅, 인증, 데이터 변환 등)을 수행합니다. - Pipeline: 여러 개의 미들웨어를 순차적으로 연결하여 하나의
Handler
처럼 만들어주는 역할을 합니다. - Adapter: Shelf 애플리케이션을 실제 HTTP 서버(
dart:io
)에 연결해주는 역할을 합니다.shelf_io
패키지가 이 역할을 수행합니다.
1단계: 프로젝트 생성 및 설정
이제 본격적으로 Dart 서버 프로젝트를 만들어 보겠습니다. 먼저 Dart SDK가 설치되어 있어야 합니다.
터미널을 열고 다음 명령어를 실행하여 Shelf 기반의 서버 프로젝트 템플릿을 생성합니다.
dart create -t server-shelf my_rest_api
cd my_rest_api
이 명령은 my_rest_api
라는 이름의 디렉토리를 생성하고, 기본적인 Shelf 서버 구조를 자동으로 만들어줍니다. 주요 파일과 디렉토리는 다음과 같습니다.
bin/server.dart
: 애플리케이션의 진입점(entry point)입니다. 실제 HTTP 서버를 실행하는 코드가 담겨 있습니다.lib/
: 애플리케이션의 핵심 로직(라우터, 핸들러 등)이 위치할 디렉토리입니다.pubspec.yaml
: 프로젝트의 의존성 및 메타데이터를 관리하는 파일입니다.
REST API를 만들기 위해 라우팅 기능이 필요합니다. pubspec.yaml
파일을 열고 dependencies
섹션에 shelf_router
를 추가합니다.
name: my_rest_api
description: An new Dart Frog project.
version: 1.0.0
publish_to: 'none'
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
args: ^2.4.0
shelf: ^1.4.0
shelf_router: ^1.1.4 # 이 줄을 추가하세요
dev_dependencies:
http: ^1.0.0
lints: ^2.0.0
test: ^1.24.0
파일을 저장한 후, 터미널에서 다음 명령어를 실행하여 새로운 의존성을 설치합니다.
dart pub get
2단계: 기본 라우터 설정 및 서버 실행
이제 bin/server.dart
파일을 수정하여 라우터를 적용해 보겠습니다. 초기 코드를 모두 지우고 아래 코드로 대체합니다.
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';
// API의 엔드포인트를 정의할 라우터 생성
final _router = Router()
..get('/', _rootHandler)
..get('/hello', _helloHandler);
// GET / 요청을 처리할 핸들러
Response _rootHandler(Request req) {
return Response.ok('Welcome to Dart REST API! 🚀');
}
// GET /hello 요청을 처리할 핸들러
Response _helloHandler(Request req) {
return Response.ok('Hello, World!');
}
void main(List<String> args) async {
// 환경 변수에서 포트를 가져오거나 기본값 8080 사용
final port = int.parse(Platform.environment['PORT'] ?? '8080');
// 라우터와 기본 로그 미들웨어를 파이프라인으로 연결
final handler = const Pipeline()
.addMiddleware(logRequests())
.addHandler(_router);
// 서버 실행
final server = await io.serve(handler, '0.0.0.0', port);
print('✅ Server listening on port ${server.port}');
}
위 코드는 두 개의 간단한 GET 엔드포인트(/
와 /hello
)를 정의합니다. shelf_router
의 Router
클래스를 사용하여 HTTP 메소드(get
, post
등)와 경로에 따라 다른 핸들러 함수를 연결합니다. logRequests()
는 모든 수신 요청을 콘솔에 기록하는 편리한 기본 미들웨어입니다.
이제 서버를 실행해 봅시다.
dart run bin/server.dart
서버가 성공적으로 실행되면 "✅ Server listening on port 8080" 메시지가 표시됩니다. 이제 웹 브라우저나 curl
같은 도구를 사용하여 API를 테스트할 수 있습니다.
# 루트 경로 테스트
curl http://localhost:8080/
# 출력: Welcome to Dart REST API! 🚀
# /hello 경로 테스트
curl http://localhost:8080/hello
# 출력: Hello, World!
3단계: JSON 데이터 처리 및 CRUD 구현
실제 REST API는 대부분 JSON 형식으로 데이터를 주고받습니다. 간단한 '메시지'를 관리하는 CRUD(Create, Read, Update, Delete) API를 구현해 보겠습니다.
먼저 메모리에 메시지를 저장할 간단한 데이터 저장소를 만듭니다.
// bin/server.dart 상단에 추가
import 'dart:convert';
// 메모리 내 데이터 저장소 (실제 앱에서는 데이터베이스 사용)
final List<Map<String, String>> _messages = [
{'id': '1', 'message': 'Hello from Dart!'},
{'id': '2', 'message': 'Shelf is awesome!'},
];
int _nextId = 3;
이제 CRUD 엔드포인트를 라우터에 추가합니다.
// _router 정의 부분을 아래와 같이 수정
final _router = Router()
..get('/', _rootHandler)
..get('/messages', _getMessagesHandler) // 모든 메시지 조회 (Read)
..get('/messages/<id>', _getMessageByIdHandler) // 특정 메시지 조회 (Read)
..post('/messages', _createMessageHandler) // 새 메시지 생성 (Create)
..put('/messages/<id>', _updateMessageHandler) // 메시지 수정 (Update)
..delete('/messages/<id>', _deleteMessageHandler); // 메시지 삭제 (Delete)
<id>
구문은 경로 매개변수(path parameter)를 나타냅니다. 이제 각 핸들러 함수를 구현합니다. 모든 핸들러는 JSON 응답을 반환해야 하므로, Content-Type
헤더를 application/json
으로 설정하는 것이 중요합니다.
모든 메시지 조회 (GET /messages)
Response _getMessagesHandler(Request req) {
return Response.ok(
jsonEncode(_messages),
headers: {'Content-Type': 'application/json'},
);
}
특정 메시지 조회 (GET /messages/<id>)
Response _getMessageByIdHandler(Request req, String id) {
final message = _messages.firstWhere((msg) => msg['id'] == id, orElse: () => {});
if (message.isEmpty) {
return Response.notFound(jsonEncode({'error': 'Message not found'}),
headers: {'Content-Type': 'application/json'});
}
return Response.ok(
jsonEncode(message),
headers: {'Content-Type': 'application/json'},
);
}
새 메시지 생성 (POST /messages)
POST 요청에서는 요청 본문(request body)에서 JSON 데이터를 읽어와야 합니다. Request
객체의 readAsString()
메소드를 사용합니다.
Future<Response> _createMessageHandler(Request req) async {
try {
final body = await req.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
final messageText = data['message'] as String?;
if (messageText == null) {
return Response.badRequest(
body: jsonEncode({'error': '`message` field is required'}),
headers: {'Content-Type': 'application/json'});
}
final newMessage = {
'id': (_nextId++).toString(),
'message': messageText,
};
_messages.add(newMessage);
return Response(201, // 201 Created
body: jsonEncode(newMessage),
headers: {'Content-Type': 'application/json'});
} catch (e) {
return Response.internalServerError(body: 'Error creating message: $e');
}
}
메시지 수정 (PUT /messages/<id>) 및 삭제 (DELETE /messages/<id>)
수정과 삭제 로직도 비슷하게 구현할 수 있습니다. 해당 ID의 메시지를 찾고, 데이터를 수정하거나 리스트에서 제거합니다.
// PUT 핸들러
Future<Response> _updateMessageHandler(Request req, String id) async {
final index = _messages.indexWhere((msg) => msg['id'] == id);
if (index == -1) {
return Response.notFound(jsonEncode({'error': 'Message not found'}));
}
final body = await req.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
final messageText = data['message'] as String;
_messages[index]['message'] = messageText;
return Response.ok(jsonEncode(_messages[index]),
headers: {'Content-Type': 'application/json'});
}
// DELETE 핸들러
Response _deleteMessageHandler(Request req, String id) {
final originalLength = _messages.length;
_messages.removeWhere((msg) => msg['id'] == id);
if (_messages.length == originalLength) {
return Response.notFound(jsonEncode({'error': 'Message not found'}));
}
return Response.ok(jsonEncode({'success': 'Message deleted'})); // 또는 Response(204)
}
이제 서버를 재시작하고 curl
을 사용하여 모든 CRUD 기능을 테스트할 수 있습니다.
# 새 메시지 생성
curl -X POST -H "Content-Type: application/json" -d '{"message": "This is a new message"}' http://localhost:8080/messages
# 모든 메시지 조회
curl http://localhost:8080/messages
4단계: 배포를 위한 준비 - AOT 컴파일과 Docker
개발이 완료된 Dart 서버는 프로덕션 환경에 배포해야 합니다. Dart의 AOT 컴파일 기능을 사용하면 단일 실행 파일을 생성하여 의존성 없이 매우 빠른 속도로 실행할 수 있습니다.
dart compile exe bin/server.dart -o build/my_rest_api
이 명령은 build/
디렉토리에 my_rest_api
라는 이름의 네이티브 실행 파일을 생성합니다. 이 파일만 서버에 복사하여 실행하면 됩니다.
현대적인 배포 방식인 Docker 컨테이너를 사용하는 것이 더욱 권장됩니다. 프로젝트 루트에 Dockerfile
을 생성하고 다음 내용을 작성합니다.
# 1단계: Dart SDK를 사용하여 애플리케이션 빌드
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
# AOT 컴파일을 통해 네이티브 실행 파일 생성
RUN dart compile exe bin/server.dart -o /app/server
# 2단계: 작은 런타임 이미지에 빌드된 실행 파일만 복사
FROM scratch
WORKDIR /app
# 빌드 단계에서 생성된 실행 파일 복사
COPY --from=build /app/server /app/server
# SSL 인증서 등을 위해 ca-certificates 복사
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# 서버가 사용할 포트 노출
EXPOSE 8080
# 컨테이너 실행 시 서버 실행
# 환경 변수 PORT를 통해 포트 설정 가능
CMD ["/app/server"]
이 Dockerfile은 멀티-스테이지 빌드를 사용하여 최종 이미지 크기를 최소화합니다. 이제 다음 명령어로 Docker 이미지를 빌드하고 실행할 수 있습니다.
# Docker 이미지 빌드
docker build -t my-dart-api .
# Docker 컨테이너 실행
docker run -p 8080:8080 my-dart-api
이제 여러분의 Dart REST API는 Docker가 지원되는 모든 환경(클라우드, 온프레미스 서버 등)에 쉽게 배포할 수 있습니다.
결론: Dart, 서버 개발의 새로운 강자
이 가이드를 통해 우리는 Dart와 Shelf 프레임워크를 사용하여 간단하지만 완벽하게 동작하는 REST API 서버를 구축하는 전 과정을 살펴보았습니다. Dart는 더 이상 Flutter만을 위한 언어가 아닙니다. 뛰어난 성능, 타입 안전성, 그리고 풀스택 개발의 시너지를 통해 Dart는 서버 사이드 개발에서 매우 강력하고 매력적인 선택지가 되었습니다.
여기서 다룬 내용은 시작에 불과합니다. 데이터베이스 연동(PostgreSQL, MongoDB 등), 웹소켓(WebSocket) 통신, 인증/인가 미들웨어 구현 등 더 깊이 있는 주제들을 탐구하며 Dart 서버 개발의 세계를 더욱 넓혀가시길 바랍니다. 지금 바로 Dart로 여러분의 다음 백엔드 프로젝트를 시작해보세요!