1. 스프링
1. 요구사항
# 스프링 CRUD (기초)
## 게시글 목록
- 서버에서 id, title, content, createdAt 응답할 수 있도록 수정
## 게시글 쓰기
- 응답 DTO 에 id, title, content, createdAt 응답할 수 있도록 수정
## 게시글 상세보기
- api 삭제
## 게시글 삭제하기
## 게시글 수정하기
- api 삭제
2. 게시글 목록
1. 서버에서 id, title, content, createdAt 응답할 수 있도록 수정
- Service 확인
public List<PostResponse.DTO> 게시글목록보기(){
List<Post> posts = postRepository.mFindAll();
System.out.println(posts.size());
return posts.stream().map(PostResponse.DTO::new).toList();
}
- PostResponse.DTO 확인
- Java 14 이상에서 도입된
record
는 불변(immutable) 데이터를 표현하기 위한 문법입니다. DTO
는id
와title
이라는 두 필드를 갖습니다.- 컴파일러가 자동으로
getter
,equals()
,hashCode()
,toString()
등을 생성합니다. - 예:
id()
와title()
이라는 getter가 자동 생성됨. - 이 생성자는
Post
객체를 받아서DTO
를 생성합니다. - 내부적으로
record
의 주 생성자(헤더에 정의된 생성자)를 호출하는 형식입니다. this(post.getId(), post.getTitle())
는record DTO(Integer id, String title)
생성자를 의미합니다.- 주로 Entity 객체 (
Post
)를 DTO로 변환할 때 사용됩니다. - 컨트롤러나 서비스 레이어에서
Post
와 같은 복잡한 Entity를 직접 반환하는 대신, 필요한 데이터만 추출한 DTO를 반환할 때 사용합니다. - 이렇게 하면 보안, 응답 최적화, 의도된 구조 전달 등의 이점이 있습니다.
public class PostResponse {
public record DTO(Integer id, String title) {
public DTO(Post post) {
this(
post.getId(),
post.getTitle()
);
}
}
}
🔸 1.
record DTO(...)
🔸 2. 생성자:
public DTO(Post post)
🔸 3. 용도
- PostResponse.DTO 수정
- content, createdAt 추가
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()
);
}
}
3. 게시글 쓰기
1. 응답 DTO 에 id, title, content, createdAt 응답할 수 있도록 수정
- Service 확인
@Transactional
public PostResponse.DTO 게시글쓰기(PostRequest.SaveDTO requestDTO){
Post postPS = postRepository.save(requestDTO.toEntity());
return new PostResponse.DTO(postPS);
}
- PostResponse.DTO 확인
- 이전 게시글 목록에서 수정함
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 삭제
- 게시글상세보기
@GetMapping("/api/post/{id}")
public ResponseEntity<?> findById(@PathVariable Integer id) {
return ResponseEntity.ok(new ApiUtil<>(postService.게시글상세보기(id)));
}
- 삭제
5. 게시글 삭제하기
6. 게시글 수정하기
1. api 삭제
- 게시글수정하기
@PutMapping("/api/post/{id}")
public ResponseEntity<?> update(@PathVariable Integer id, @RequestBody PostRequest.UpdateDTO requestDTO) {
return ResponseEntity.ok(new ApiUtil<>(postService.게시글수정하기(id, requestDTO)));
}
- 삭제
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 만 출력
- PostListBody 에서 출력 부분 확인
- “1” 자리에 id 출력
- “제목입니다” 자리에 title 출력
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),
);
}
}
2. post_list_vm 만들기
- PostRepository 에서 getList() 메서드 만들기

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;
}
}
- PostRepositoryTest 요청 결과 확인
- body 가 컬렉션임 → List 타입으로 받아야 함
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();
}

- post_list_vm 만들기
- Post.fromMap() 이 필요함

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 추가
- updateAt 은 받지 않음 → null 허용

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"];
}
- post_list_vm 완성하기
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}';
}
}
- 내 앱 ProviderScope 로 감싸기
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(),
},
);
}
}
- 뷰에 데이터 바인딩
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),
);
}
}
}

3. 게시글 쓰기
1. TextEditingController사용

- PostWriteForm 확인
- TextEditingController 로 제어중
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("글쓰기")
),
],
),
),
);
}
}
2. post_list_vm 에서 글쓰기 메서드 추가
- Repository 에서 write 메서드 추가
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;
}
}
- RepositoryTest 에서 요청 결과 확인
Future<void> main() async {
Map<String, dynamic> body = await PostRepository().write("title-test","content-test");
}

- post_list_vm 에 write 메서드 추가
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 {
...
}
- 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"]}")),
);
}
- 뷰에서 함수 호출
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("글쓰기")),
],
),
),
);
}
}

4. 게시글 상세보기
1. PostList 화면에서 Post 객체 전달해서 출력하기
- PostListBody 에서 post 전달
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),
);
}
}
}
- PostDetailPage 에서 post 전달
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),
);
}
}
- PostDetailBody 에서 전달 받은 post 출력
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 에 삭제하기 요청
- PostRepository delete 메서드 추가
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 화면 상태 변경
- post_list_vm 에서 delete 메서드 추가
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 {
...
}
- 뷰에서 함수 호출
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}"),
],
),
);
}
}


6. 게시글 수정하기
- 없음
Share article