요청 처리와 응답 설계
API 서버의 핵심은 요청을 정확하게 파싱하고, 일관된 응답을 반환하는 것입니다. 이번 챕터에서는 DTO(Data Transfer Object) 패턴으로 요청/응답을 구조화하고, 유효성 검사를 체계화합니다.
모델 정의
비즈니스 로직의 핵심 데이터 구조를 먼저 정의합니다.
// 새 파일: lib/src/models/todo.dart
/// 할 일 모델.
class Todo {
const Todo({
required this.id,
required this.title,
required this.completed,
required this.userId,
required this.createdAt,
this.updatedAt,
});
final int id;
final String title;
final bool completed;
final int userId;
final DateTime createdAt;
final DateTime? updatedAt;
/// 데이터베이스 행 (Map)에서 [Todo]를 생성합니다.
factory Todo.fromMap(Map<String, dynamic> map) {
return Todo(
id: map['id'] as int,
title: map['title'] as String,
completed: (map['completed'] as int) == 1,
userId: map['user_id'] as int,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: map['updated_at'] != null
? DateTime.parse(map['updated_at'] as String)
: null,
);
}
/// [Todo]를 JSON 응답 Map으로 변환합니다.
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'completed': completed,
'userId': userId,
'createdAt': createdAt.toIso8601String(),
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
/// 일부 필드를 변경한 새 [Todo]를 반환합니다.
Todo copyWith({
String? title,
bool? completed,
DateTime? updatedAt,
}) {
return Todo(
id: id,
title: title ?? this.title,
completed: completed ?? this.completed,
userId: userId,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
// 새 파일: lib/src/models/user.dart
class User {
const User({
required this.id,
required this.email,
required this.passwordHash,
required this.createdAt,
});
final int id;
final String email;
final String passwordHash;
final DateTime createdAt;
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as int,
email: map['email'] as String,
passwordHash: map['password_hash'] as String,
createdAt: DateTime.parse(map['created_at'] as String),
);
}
/// 비밀번호 해시는 응답에 포함하지 않습니다.
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'createdAt': createdAt.toIso8601String(),
};
}
}
DTO 패턴 — 요청/응답 분리
Todo 모델은 내부 데이터 구조입니다. 클라이언트가 보내는 요청과 서버가 반환하는 응답은 별도의 DTO로 표현합니다.
// 새 파일: lib/src/dto/todo_dto.dart
import '../exceptions.dart';
import '../models/todo.dart';
/// 할 일 생성 요청 DTO.
class CreateTodoRequest {
const CreateTodoRequest({required this.title});
final String title;
/// JSON Map에서 파싱하고 유효성을 검사합니다.
factory CreateTodoRequest.fromJson(Map<String, dynamic> json) {
final title = json['title'];
if (title == null) {
throw const ValidationException('title 필드가 필요합니다.');
}
if (title is! String) {
throw const ValidationException('title은 문자열이어야 합니다.');
}
if (title.trim().isEmpty) {
throw const ValidationException('title은 비어있을 수 없습니다.');
}
if (title.length > 500) {
throw const ValidationException('title은 500자를 초과할 수 없습니다.');
}
return CreateTodoRequest(title: title.trim());
}
}
/// 할 일 수정 요청 DTO.
class UpdateTodoRequest {
const UpdateTodoRequest({this.title, this.completed});
final String? title;
final bool? completed;
factory UpdateTodoRequest.fromJson(Map<String, dynamic> json) {
final title = json['title'] as String?;
final completed = json['completed'] as bool?;
if (title == null && completed == null) {
throw const ValidationException(
'title 또는 completed 중 하나 이상 포함해야 합니다.',
);
}
if (title != null && title.trim().isEmpty) {
throw const ValidationException('title은 비어있을 수 없습니다.');
}
return UpdateTodoRequest(
title: title?.trim(),
completed: completed,
);
}
/// 기존 [Todo]에 수정 사항을 적용합니다.
Todo applyTo(Todo todo) {
return todo.copyWith(
title: title,
completed: completed,
updatedAt: DateTime.now(),
);
}
}
// 새 파일: lib/src/dto/auth_dto.dart
import '../exceptions.dart';
/// 회원가입/로그인 요청 DTO.
class AuthRequest {
const AuthRequest({required this.email, required this.password});
final String email;
final String password;
factory AuthRequest.fromJson(Map<String, dynamic> json) {
final email = json['email'] as String?;
final password = json['password'] as String?;
if (email == null || email.trim().isEmpty) {
throw const ValidationException('email이 필요합니다.');
}
if (!_isValidEmail(email)) {
throw const ValidationException('올바른 이메일 형식이 아닙니다.');
}
if (password == null || password.isEmpty) {
throw const ValidationException('password가 필요합니다.');
}
if (password.length < 8) {
throw const ValidationException('password는 8자 이상이어야 합니다.');
}
return AuthRequest(email: email.trim().toLowerCase(), password: password);
}
static bool _isValidEmail(String email) {
return RegExp(r'^[\w-.]+@([\w-]+\.)+[\w-]{2,}$').hasMatch(email);
}
}
/// 로그인 성공 응답 DTO.
class AuthResponse {
const AuthResponse({required this.token, required this.user});
final String token;
final Map<String, dynamic> user;
Map<String, dynamic> toJson() => {
'token': token,
'user': user,
};
}
표준 응답 구조
일관된 응답 형식을 위한 헬퍼를 만듭니다.
// 새 파일: lib/src/utils/response_utils.dart
import 'package:dart_frog/dart_frog.dart';
/// 성공 응답을 표준 형식으로 반환합니다.
Response okResponse(dynamic data) {
return Response.json(body: {'data': data, 'success': true});
}
/// 생성 성공 응답 (201).
Response createdResponse(dynamic data) {
return Response.json(
statusCode: 201,
body: {'data': data, 'success': true},
);
}
/// 목록 응답 (페이지네이션 포함).
Response listResponse(
List<dynamic> items, {
int? total,
int? page,
int? limit,
}) {
return Response.json(body: {
'data': items,
'success': true,
'meta': {
if (total != null) 'total': total,
if (page != null) 'page': page,
if (limit != null) 'limit': limit,
},
});
}
/// 204 No Content (삭제 성공 등).
Response noContentResponse() {
return Response(statusCode: 204);
}
라우트 핸들러 개선
DTO와 표준 응답을 적용해 핸들러를 다시 작성합니다.
// 수정: routes/todos/index.dart
import 'package:dart_frog/dart_frog.dart';
import '../../lib/src/dto/todo_dto.dart';
import '../../lib/src/utils/response_utils.dart';
Future<Response> onRequest(RequestContext context) async {
return switch (context.request.method) {
HttpMethod.get => _getTodos(context),
HttpMethod.post => _createTodo(context),
_ => Response(statusCode: 405),
};
}
Future<Response> _getTodos(RequestContext context) async {
final queryParams = context.request.uri.queryParameters;
final completed = queryParams['completed'];
final limit = int.tryParse(queryParams['limit'] ?? '20') ?? 20;
final offset = int.tryParse(queryParams['offset'] ?? '0') ?? 0;
// Ch 05에서 DB 연동
final todos = <Map<String, dynamic>>[
{'id': 1, 'title': '할 일 1', 'completed': false},
{'id': 2, 'title': '할 일 2', 'completed': true},
];
final filtered = completed != null
? todos.where((t) => t['completed'] == (completed == 'true')).toList()
: todos;
return listResponse(
filtered,
total: filtered.length,
page: offset ~/ limit + 1,
limit: limit,
);
}
Future<Response> _createTodo(RequestContext context) async {
final json = await context.request.json() as Map<String, dynamic>;
final request = CreateTodoRequest.fromJson(json);
// Ch 05에서 DB 저장
final newTodo = {
'id': 3,
'title': request.title,
'completed': false,
'createdAt': DateTime.now().toIso8601String(),
};
return createdResponse(newTodo);
}
// 수정: routes/todos/[id].dart
import 'package:dart_frog/dart_frog.dart';
import '../../lib/src/dto/todo_dto.dart';
import '../../lib/src/exceptions.dart';
import '../../lib/src/utils/response_utils.dart';
Future<Response> onRequest(RequestContext context, String id) async {
final todoId = int.tryParse(id);
if (todoId == null) {
throw const ValidationException('유효하지 않은 ID입니다.');
}
return switch (context.request.method) {
HttpMethod.get => _getTodo(context, todoId),
HttpMethod.put => _updateTodo(context, todoId),
HttpMethod.delete => _deleteTodo(context, todoId),
_ => Response(statusCode: 405),
};
}
Future<Response> _getTodo(RequestContext context, int id) async {
// Ch 05에서 DB 조회로 교체
if (id > 2) throw const NotFoundException('할 일을 찾을 수 없습니다.');
final todo = {'id': id, 'title': '할 일 $id', 'completed': false};
return okResponse(todo);
}
Future<Response> _updateTodo(RequestContext context, int id) async {
final json = await context.request.json() as Map<String, dynamic>;
final request = UpdateTodoRequest.fromJson(json);
if (id > 2) throw const NotFoundException('할 일을 찾을 수 없습니다.');
final updated = {
'id': id,
'title': request.title ?? '할 일 $id',
'completed': request.completed ?? false,
'updatedAt': DateTime.now().toIso8601String(),
};
return okResponse(updated);
}
Future<Response> _deleteTodo(RequestContext context, int id) async {
if (id > 2) throw const NotFoundException('할 일을 찾을 수 없습니다.');
return noContentResponse();
}
쿼리 파라미터 처리
페이지네이션과 필터링을 쿼리 파라미터로 처리합니다.
// lib/src/utils/query_params.dart
/// 쿼리 파라미터에서 정수 값을 안전하게 추출합니다.
int parseIntParam(
Map<String, String> params,
String key, {
required int defaultValue,
int? min,
int? max,
}) {
final raw = params[key];
if (raw == null) return defaultValue;
final value = int.tryParse(raw) ?? defaultValue;
if (min != null && value < min) return min;
if (max != null && value > max) return max;
return value;
}
/// 쿼리 파라미터에서 불리언 값을 추출합니다.
bool? parseBoolParam(Map<String, String> params, String key) {
final raw = params[key];
if (raw == null) return null;
return raw == 'true' || raw == '1';
}
실행 및 테스트
dart_frog dev
# 목록 조회 (페이지네이션)curl -s "http://localhost:8080/todos?limit=5&offset=0" | python3 -m json.tool# 필터링curl -s "http://localhost:8080/todos?completed=false" | python3 -m json.tool# 생성curl -s -X POST http://localhost:8080/todos \ -H "Content-Type: application/json" \ -H "Authorization: Bearer dummy-token" \ -d '{"title": "새 할 일"}' | python3 -m json.tool# 유효성 오류 (빈 title)curl -s -X POST http://localhost:8080/todos \ -H "Content-Type: application/json" \ -H "Authorization: Bearer dummy-token" \ -d '{"title": ""}' | python3 -m json.tool# 수정curl -s -X PUT http://localhost:8080/todos/1 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer dummy-token" \ -d '{"completed": true}' | python3 -m json.tool# 삭제curl -s -X DELETE http://localhost:8080/todos/1 \ -H "Authorization: Bearer dummy-token" \ -v 2>&1 | grep "< HTTP"
정리
이번 챕터에서는 API 설계를 체계화했습니다.
Todo,User모델에fromMap과toJson을 정의했습니다.- DTO 클래스가 요청 파싱과 유효성 검사를 담당합니다.
- 표준 응답 구조(
data,success,meta)로 일관성을 유지합니다. AppException계층이 에러 처리 미들웨어와 연동됩니다.
다음 챕터에서는 SQLite를 연동해 실제 데이터를 저장하고 조회합니다.