iBetter Books
수정

요청 처리와 응답 설계

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 모델에 fromMaptoJson을 정의했습니다.
  • DTO 클래스가 요청 파싱과 유효성 검사를 담당합니다.
  • 표준 응답 구조(data, success, meta)로 일관성을 유지합니다.
  • AppException 계층이 에러 처리 미들웨어와 연동됩니다.

다음 챕터에서는 SQLite를 연동해 실제 데이터를 저장하고 조회합니다.