inblog logo
|
jjack1
    Flutter

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

    최재원's avatar
    최재원
    Jul 30, 2025
    [Flutter] 플러터 블로그 CRUD 과제
    Contents
    1. 스프링1. 요구사항2. 게시글 목록3. 게시글 쓰기4. 게시글 상세보기5. 게시글 삭제하기6. 게시글 수정하기2. 플러터1. 요구사항2. 게시글 목록3. 게시글 쓰기4. 게시글 상세보기5. 게시글 삭제하기6. 게시글 수정하기

    1. 스프링

    https://github.com/flutter-lec/spring_blog_curd_basic.git

    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) 데이터를 표현하기 위한 문법입니다.
        • DTO는 id와 title이라는 두 필드를 갖습니다.
        • 컴파일러가 자동으로 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
    Contents
    1. 스프링1. 요구사항2. 게시글 목록3. 게시글 쓰기4. 게시글 상세보기5. 게시글 삭제하기6. 게시글 수정하기2. 플러터1. 요구사항2. 게시글 목록3. 게시글 쓰기4. 게시글 상세보기5. 게시글 삭제하기6. 게시글 수정하기

    jjack1

    RSS·Powered by Inblog