[Flutter] 플러터 블로그 CRUD 과제

최재원's avatar
Jul 30, 2025
[Flutter] 플러터 블로그 CRUD 과제

1. 스프링

1. 요구사항

💡
# 스프링 CRUD (기초)
## 게시글 목록
- 서버에서 id, title, content, createdAt 응답할 수 있도록 수정
## 게시글 쓰기
- 응답 DTO 에 id, title, content, createdAt 응답할 수 있도록 수정
## 게시글 상세보기
- api 삭제
## 게시글 삭제하기
## 게시글 수정하기
- api 삭제

2. 게시글 목록

1. 서버에서 id, title, content, createdAt 응답할 수 있도록 수정

  1. Service 확인
    1. public List<PostResponse.DTO> 게시글목록보기(){ List<Post> posts = postRepository.mFindAll(); System.out.println(posts.size()); return posts.stream().map(PostResponse.DTO::new).toList(); }
  1. PostResponse.DTO 확인
    1. public class PostResponse { public record DTO(Integer id, String title) { public DTO(Post post) { this( post.getId(), post.getTitle() ); } } }
      🔸 1. record DTO(...)
      • Java 14 이상에서 도입된 record는 불변(immutable) 데이터를 표현하기 위한 문법입니다.
      • DTOidtitle이라는 두 필드를 갖습니다.
      • 컴파일러가 자동으로 getter, equals(), hashCode(), toString() 등을 생성합니다.
        • 예: id()title()이라는 getter가 자동 생성됨.
      🔸 2. 생성자: public DTO(Post post)
      • 이 생성자는 Post 객체를 받아서 DTO를 생성합니다.
      • 내부적으로 record의 주 생성자(헤더에 정의된 생성자)를 호출하는 형식입니다.
        • this(post.getId(), post.getTitle())record DTO(Integer id, String title) 생성자를 의미합니다.
      • 주로 Entity 객체 (Post)를 DTO로 변환할 때 사용됩니다.
      🔸 3. 용도
      • 컨트롤러나 서비스 레이어에서 Post와 같은 복잡한 Entity를 직접 반환하는 대신, 필요한 데이터만 추출한 DTO를 반환할 때 사용합니다.
      • 이렇게 하면 보안, 응답 최적화, 의도된 구조 전달 등의 이점이 있습니다.
  1. PostResponse.DTO 수정
    1. public class PostResponse { public record DTO(Integer id, String title, String content, LocalDateTime createdAt) { public DTO(Post post) { this( post.getId(), post.getTitle(), post.getContent(), post.getCreatedAt() ); } }
      • content, createdAt 추가

3. 게시글 쓰기

1. 응답 DTO 에 id, title, content, createdAt 응답할 수 있도록 수정

  1. Service 확인
    1. @Transactional public PostResponse.DTO 게시글쓰기(PostRequest.SaveDTO requestDTO){ Post postPS = postRepository.save(requestDTO.toEntity()); return new PostResponse.DTO(postPS); }
  1. PostResponse.DTO 확인
    1. public class PostResponse { public record DTO(Integer id, String title, String content, LocalDateTime createdAt) { public DTO(Post post) { this( post.getId(), post.getTitle(), post.getContent(), post.getCreatedAt() ); } } }
      • 이전 게시글 목록에서 수정함

4. 게시글 상세보기

1. api 삭제

  1. 게시글상세보기
    1. @GetMapping("/api/post/{id}") public ResponseEntity<?> findById(@PathVariable Integer id) { return ResponseEntity.ok(new ApiUtil<>(postService.게시글상세보기(id))); }
  1. 삭제

5. 게시글 삭제하기

6. 게시글 수정하기

1. api 삭제

  1. 게시글수정하기
    1. @PutMapping("/api/post/{id}") public ResponseEntity<?> update(@PathVariable Integer id, @RequestBody PostRequest.UpdateDTO requestDTO) { return ResponseEntity.ok(new ApiUtil<>(postService.게시글수정하기(id, requestDTO))); }
  1. 삭제
 

2. 플러터

1. 요구사항

💡
# 플러터 CRUD (기초)
## 게시글 목록
- 화면에 id, title 만 출력
- post list vm 만들기
## 게시글 쓰기
- TextEditingController사용
- post list vm 에서 글쓰기
## 게시글 상세보기
- PostList 화면에서 Post 객체 전달해서 출력하기
- post detail vm 없음
## 게시글 삭제하기
- post list vm 에 삭제하기 요청
- PostList 화면에 상태 변경
## 게시글 수정하기

2. 게시글 목록

1. 화면에 id, title 만 출력

  1. PostListBody 에서 출력 부분 확인
    1. import 'package:blog/ui/detail/post_detail_page.dart'; import 'package:flutter/material.dart'; class PostListBody extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ListView.separated( itemBuilder: (context, index) { return ListTile( leading: Text("1"), title: Text("제목입니다"), trailing: IconButton( icon: Icon(Icons.arrow_forward_ios), onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (context) => PostDetailPage(),)); }, ), ); }, separatorBuilder: (context, index) => Divider(), itemCount: 20), ); } }
      • “1” 자리에 id 출력
      • “제목입니다” 자리에 title 출력

2. post_list_vm 만들기

  1. PostRepository 에서 getList() 메서드 만들기
    1. notion image
      class PostRepository { /// post list 요청 메서드 Future<Map<String, dynamic>> getList() async { Response response = await dio.get("/api/post"); Map<String, dynamic> responseBody = response.data; print(responseBody); return responseBody; } }
  1. PostRepositoryTest 요청 결과 확인
    1. import 'package:blog/data/post_repository.dart'; import 'package:flutter_test/flutter_test.dart'; Future<void> main() async { Map<String, dynamic> body = await PostRepository().getList(); }
      notion image
      • body 가 컬렉션임 → List 타입으로 받아야 함
  1. post_list_vm 만들기
    1. notion image
      import 'package:blog/core/utils.dart'; import 'package:blog/data/post.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final postListProvider = AutoDisposeNotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends AutoDisposeNotifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { return null; } } /// 3. 창고 데이터 타입 class PostListModel { List<Post> posts; PostListModel(this.posts); PostListModel.fromMap(List<dynamic> data) : posts = data.map((p) => Post.fromMap(p)).toList(); PostListModel copyWith({ List<Post>? posts, }) { return PostListModel( posts ?? this.posts, ); } @override String toString() { return 'PostListModel{posts: $posts}'; } }
      • Post.fromMap() 이 필요함
  1. Post 수정하기
    1. notion image
      class Post { int id; String title; String content; String createdAt; String? updateAt; Post(this.id, this.title, this.content, this.createdAt, this.updateAt); Post.fromMap(Map<String, dynamic> data) : id = data["id"], title = data["title"], content = data["content"], createdAt = data["createdAt"], updateAt = data["updatedAt"]; }
      • fromMap 추가
      • updateAt 은 받지 않음 → null 허용
  1. post_list_vm 완성하기
    1. import 'package:blog/core/utils.dart'; import 'package:blog/data/post.dart'; import 'package:blog/data/post_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final postListProvider = AutoDisposeNotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends AutoDisposeNotifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } Future<void> init() async { Map<String, dynamic> body = await PostRepository().getList(); if (body["status"] != 200) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("게시글 목록보기 실패 : ${body["errorMessage"]}")), ); return; } state = PostListModel.fromMap(body["body"]); } } /// 3. 창고 데이터 타입 class PostListModel { List<Post> posts; PostListModel(this.posts); PostListModel.fromMap(List<dynamic> data) : posts = data.map((p) => Post.fromMap(p)).toList(); PostListModel copyWith({ List<Post>? posts, }) { return PostListModel( posts ?? this.posts, ); } @override String toString() { return 'PostListModel{posts: $posts}'; } }
  1. 내 앱 ProviderScope 로 감싸기
    1. import 'package:blog/core/theme.dart'; import 'package:blog/ui/list/post_list_page.dart'; import 'package:blog/ui/write/post_write_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/utils.dart'; void main() { runApp(const ProviderScope(child: MyApp())); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, theme: theme(), initialRoute: "/", routes: { "/": (context) => PostListPage(), "/write": (context) => PostWritePage(), }, ); } }
  1. 뷰에 데이터 바인딩
    1. import 'package:blog/ui/detail/post_detail_page.dart'; import 'package:blog/ui/list/post_list_vm.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); if (model == null) { return Center(child: CircularProgressIndicator()); } else { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ListView.separated( itemBuilder: (context, index) { return ListTile( leading: Text("${model.posts[index].id}"), title: Text("${model.posts[index].content}"), trailing: IconButton( icon: Icon(Icons.arrow_forward_ios), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PostDetailPage(), )); }, ), ); }, separatorBuilder: (context, index) => Divider(), itemCount: 20), ); } } }
      notion image

3. 게시글 쓰기

1. TextEditingController사용

notion image
  1. PostWriteForm 확인
    1. import 'package:flutter/material.dart'; class PostWriteForm extends StatelessWidget { final _title = TextEditingController(); final _content = TextEditingController(); @override Widget build(BuildContext context) { return Form( child: Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ TextFormField( controller: _title, ), TextFormField( controller: _content, ), TextButton( onPressed: () { String title = _title.text.trim(); String content = _content.text.trim(); // vm 에게 전달해야 함. }, child: Text("글쓰기") ), ], ), ), ); } }
      • TextEditingController 로 제어중

2. post_list_vm 에서 글쓰기 메서드 추가

  1. Repository 에서 write 메서드 추가
    1. import 'package:blog/core/utils.dart'; import 'package:dio/dio.dart'; class PostRepository { ... /// 글쓰기 요청 Future<Map<String, dynamic>> write(String title, String content) async { Response response = await dio.post( "/api/post", data: { "title": title, "content": content, }, ); Map<String, dynamic> responseBody = response.data; print(responseBody); return responseBody; } }
  1. RepositoryTest 에서 요청 결과 확인
    1. Future<void> main() async { Map<String, dynamic> body = await PostRepository().write("title-test","content-test"); }
      notion image
  1. post_list_vm 에 write 메서드 추가
    1. import 'package:blog/core/utils.dart'; import 'package:blog/data/post.dart'; import 'package:blog/data/post_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final postListProvider = AutoDisposeNotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends AutoDisposeNotifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } ... Future<void> write(String title, String content) async { Map<String, dynamic> body = await PostRepository().write(title, content); if (body["status"] != 200) { handleHttpError(body: body, context: mContext, msg: "게시글 쓰기"); return; } Post post = Post.fromMap(body["body"]); state = state!.copyWith(posts: [post, ...state!.posts]); Navigator.pop(mContext); } } /// 3. 창고 데이터 타입 class PostListModel { ... }
  1. handleHttpError 중복 사용에 의해 분리 함
      • utils 에 분리함
      /// msg : 페이지 이름 넣으면 됨 /// /// 예) "게시글 목록보기" void handleHttpError({ required Map<String, dynamic> body, required BuildContext context, String msg = "", }) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("${msg} 실패 : ${body["errorMessage"]}")), ); }
  1. 뷰에서 함수 호출
    1. import 'package:blog/ui/list/post_list_vm.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostWriteForm extends ConsumerWidget { final _title = TextEditingController(); final _content = TextEditingController(); @override Widget build(BuildContext context, WidgetRef ref) { PostListVM vm = ref.read(postListProvider.notifier); return Form( child: Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ TextFormField( controller: _title, ), TextFormField( controller: _content, ), TextButton( onPressed: () { String title = _title.text.trim(); String content = _content.text.trim(); // vm 에게 전달해야 함. vm.write(title, content); }, child: Text("글쓰기")), ], ), ), ); } }
      notion image

4. 게시글 상세보기

1. PostList 화면에서 Post 객체 전달해서 출력하기

  1. PostListBody 에서 post 전달
    1. import 'package:blog/ui/detail/post_detail_page.dart'; import 'package:blog/ui/list/post_list_vm.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); if (model == null) { return Center(child: CircularProgressIndicator()); } else { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ListView.separated( itemBuilder: (context, index) { return ListTile( leading: Text("${model.posts[index].id}"), title: Text("${model.posts[index].content}"), trailing: IconButton( icon: Icon(Icons.arrow_forward_ios), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PostDetailPage(model.posts[index]), )); }, ), ); }, separatorBuilder: (context, index) => Divider(), itemCount: 20), ); } } }
  1. PostDetailPage 에서 post 전달
    1. import 'package:blog/data/post.dart'; import 'package:blog/ui/detail/components/post_detail_body.dart'; import 'package:flutter/material.dart'; import '../components/custom_appbar.dart'; class PostDetailPage extends StatelessWidget { Post post; PostDetailPage(this.post); @override Widget build(BuildContext context) { return Scaffold( appBar: CustomAppBar(title: "Post Detail Page"), body: PostDetailBody(post), ); } }
  1. PostDetailBody 에서 전달 받은 post 출력
    1. import 'package:blog/data/post.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class PostDetailBody extends StatelessWidget { Post post; PostDetailBody(this.post); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ Align( alignment: Alignment.centerRight, child: ElevatedButton( child: Icon(CupertinoIcons.trash_fill), onPressed: () {}, ), ), SizedBox(height: 10), Text("id : ${post.id}", style: TextStyle(fontSize: 20)), Text("title : ${post.title}"), Text("content : ${post.content}"), Text("createdAt : ${post.createdAt}"), ], ), ); } }

2. post_detail_vm 없음

5. 게시글 삭제하기

1. post_list_vm 에 삭제하기 요청

  1. PostRepository delete 메서드 추가
    1. import 'package:blog/core/utils.dart'; import 'package:dio/dio.dart'; class PostRepository { ... /// 글삭제 요청 Future<Map<String, dynamic>> deleteById(int id) async { Response response = await dio.delete("/api/post/${id}"); Map<String, dynamic> responseBody = response.data; return responseBody; } }

2. PostList 화면 상태 변경

  1. post_list_vm 에서 delete 메서드 추가
    1. import 'package:blog/core/utils.dart'; import 'package:blog/data/post.dart'; import 'package:blog/data/post_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final postListProvider = AutoDisposeNotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends AutoDisposeNotifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } ... Future<void> deleteById(int postId) async { Map<String, dynamic> body = await PostRepository().deleteById(postId); if (body["status"] != 200) { handleHttpError(body: body, context: mContext, msg: "게시글 삭제"); return; } state!.posts = state!.posts.where((p) => p.id != postId).toList(); state = state!.copyWith(posts: state!.posts); Navigator.pop(mContext); } } /// 3. 창고 데이터 타입 class PostListModel { ... }
  1. 뷰에서 함수 호출
    1. import 'package:blog/data/post.dart'; import 'package:blog/ui/list/post_list_vm.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostDetailBody extends ConsumerWidget { Post post; PostDetailBody(this.post); @override Widget build(BuildContext context, WidgetRef ref) { PostListVM vm = ref.read(postListProvider.notifier); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ Align( alignment: Alignment.centerRight, child: ElevatedButton( child: Icon(CupertinoIcons.trash_fill), onPressed: () { vm.deleteById(post.id); }, ), ), SizedBox(height: 10), Text("id : ${post.id}", style: TextStyle(fontSize: 20)), Text("title : ${post.title}"), Text("content : ${post.content}"), Text("createdAt : ${post.createdAt}"), ], ), ); } }
      notion image
      notion image

6. 게시글 수정하기

  • 없음
Share article

jjack1