[팀프로젝트]모바일앱 게시판 만들기 - 3편 : 게시판 CRUD 개발하기
이번 글부터 게시판 개발 내용을 진행합니다.
현재까지 추가된 API는 다음과 같습니다.
- [POST] /oauth/kakao : 로그인
- [POST] /user/join : 회원가입
- [GET] /user/profile/{userId} : 사용자 정보 조회
- [GET] /board/list ?page={page} : 게시판 리스트 조회
- [POST] /board/save : 게시글 저장
- [PUT] /board/save/{seq} : 게시글 수정
- [GET] /board/info?seq={seq} : 게시글 내용 조회
- [DELETE] /board/item/{seq} : 게시글 삭제
- [POST] /board/like : 좋아요 등록
- [GET] /board/comment/list?seq={seq} : 게시판 댓글 리스트 조회
- [POST] /board/comment : 댓글 저장
- [PUT] /board/comment/{seq} : 댓글 수정
- [DELETE] /board/comment/{seq} : 댓글 삭제
포스팅 내용이 아래 순서대로 진행되고 있으니 이전 글을 확인해 주세요.
- 테이블 설계하기
- 로그인/회원가입 개발하기
- 게시판 CRUD 개발하기
1. 화면 구성
모바일 게시판은 처음 만들어보는 경험이었습니다. UI를 어떻게 하면 좋을지 고민했는데 당근마켓과 네이버 카페의 게시판 UI를 많이 참고했습니다.
화면 구성은 아래와 같습니다.
- 검색 영역 : 제목/내용을 선택할 수 있는 드롭박스와 입력칸이 위치합니다.
- 게시판 리스트 : 게시글이 10개씩 조회됩니다. 아래로 스크롤을 하며 페이징 됩니다.
- 게시판 정보 : 게시판 제목, 내용(50글자 제한, 레이아웃을 벗어나게 되면 말줄임표), 작성자 정보, 댓글 수, 좋아요 수가 표시됩니다.
- 글쓰기 : 글쓰기 버튼은 화면 하단에 위치합니다.
화면은 총 6개로 정리했습니다. 화면을 구성하는 요소들을 위젯으로 관리하기도 했는데 모든 코드를 공개할 수 없어 일부 코드만 화면과 함께 확인해보겠습니다.
.
└── lib/
├── providers/
│ └── board/
│ └── board_provider.dart
├── screens/
│ └── board/
│ ├── comment/
│ │ ├── comment_list.dart
│ │ ├── comment_sub.dart
│ │ └── comment_write.dart
│ ├── board_info.dart
│ ├── board_list.dart
│ └── board_write.dart
└── services/
└── board/
└── baord_service.dart
2. 게시판 추가
2-1. BoardService 추가
BoardService는 앞서 만든 API를 호출합니다.
- getBoardList : 게시판 리스트 조회 - [GET] /board/list?page={page}
- saveBoard : 게시글 저장 - [POST] /board/save
- saveBoard : 게시글 수정 - [PUT] /board/save/{seq}
- getBoardInfo : 게시글 내용 조회 - [GET] /board/info?seq={seq}
- deleteBoard : 게시글 삭제 - [DELETE] /board/item/{seq}
- updateLike : 좋아요 등록 - [POST] /board/like
- getCommentsList : 게시판 댓글 리스트 조회 - [GET] /board/comment/list?seq={seq}
- saveComment : 댓글 저장 - [POST] /board/comment
- updateComment : 댓글 수정 - [PUT] /board/comment/{seq}
- deleteComment : 댓글 삭제 - [DELETE] /board/comment/{seq}
class BoardService {
final boardApi = '${dotenv.env['NONGSIL_API_URL']}/board';
/// 게시글 리스트 조회
Future<Map<String, dynamic>> getBoardList({
required int page,
String? search,
String? text,
}) async {
Map<String, dynamic> resultMap = {};
try {
final response = await ApiService().callApi(
'GET',
'$boardApi/list?page=$page&search=$search&text=$text',
{},
);
await Future.delayed(const Duration(milliseconds: 800));
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
dynamic data = resultData['result'];
int boardCount = data['boardCount'];
List<dynamic> dataList = data['boardList'];
List<Board> boardList = [];
if (dataList.isNotEmpty) {
dataList.forEach((data) {
boardList.add(Board.fromMap(data));
});
}
resultMap = {
"boardList": boardList,
"boardCount": boardCount,
};
logger.d(':: 게시판 리스트 조회 :: $dataList');
} else {
throw Exception(":: 게시판 리스트 조회 오류 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
return resultMap;
}
/// 게시판 내용 조회
Future<Board> getBoardInfo({required int seq}) async {
late Board boardInfo;
try {
final response = await ApiService().callApi(
'GET',
'$boardApi/info?seq=$seq',
{},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
dynamic data = resultData['result'];
boardInfo = Board.fromMap(data);
logger.d(':: 게시판 내용 조회 :: $data');
} else {
throw Exception(":: 게시판 내용 조회 오류 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
return boardInfo;
}
/// 게시글 저장
/// POST : 신규
/// PUT : 수정
Future<void> saveBoard({
required int boardSeq,
required String title,
required String content,
String? marketCode,
String? companyCode,
required String userId,
}) async {
String methodType = boardSeq == 0 ? 'POST' : 'PUT';
String url = boardSeq == 0 ? '$boardApi/save' : '$boardApi/save/$boardSeq';
try {
final response = await ApiService().callApi(
methodType,
url,
{
"boardTitle": title,
"boardContent": content,
"marketCode": marketCode,
"companyCode": companyCode,
"userId": userId,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 저장 성공 ::');
} else {
throw Exception(":: 게시글 저장 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
/// 게시글 삭제
Future<void> deleteBoard({
required int boardSeq,
required String userId,
}) async {
try {
final response = await ApiService().callApi(
'DELETE',
'$boardApi/item/$boardSeq',
{"userId": userId},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 삭제 성공 ::');
} else {
throw Exception(":: 게시글 삭제 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
/// 댓글 리스트 조회
Future<List<Comments>> getCommentsList(int seq) async {
List<Comments> commentList = [];
try {
final response = await ApiService().callApi(
'GET',
'$boardApi/comment/list?seq=$seq',
{},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
List<dynamic> dataList = resultData['result'];
commentList = dataList.map((e) => Comments.fromMap(e)).toList();
logger.d(':: 게시판 댓글 조회 :: $dataList');
} else {
throw Exception(":: 게시판 댓글 조회 오류 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
return commentList;
}
/// 게시글 좋아요 등록
Future<void> updateLike({
required int boardSeq,
}) async {
try {
final response = await ApiService().callApi(
'POST',
'$boardApi/like',
{
"boardSeq": boardSeq,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 좋아요 저장 성공 ::');
} else {
throw Exception(":: 게시글 좋아요 저장 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
/// 게시글 댓글 저장
Future<void> saveComment({
required int boardSeq,
required int parentSeq,
required String content,
}) async {
try {
final response = await ApiService().callApi(
'POST',
'$boardApi/comment',
{
"boardSeq": boardSeq,
"parentSeq": parentSeq,
"commentContent": content,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 댓글 저장 성공 ::');
} else {
throw Exception(":: 게시글 댓글 저장 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
/// 게시글 댓글 수정
Future<void> updateComment({
required int commentSeq,
required String content,
}) async {
try {
final response = await ApiService().callApi(
'PUT',
'$boardApi/comment/$commentSeq',
{
"commentContent": content,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 댓글 수정 성공 ::');
} else {
throw Exception(":: 게시글 댓글 수정 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
/// 게시글 삭제
Future<void> deleteComment({
required int commentSeq,
required String userId,
}) async {
try {
final response = await ApiService().callApi(
'DELETE',
'$boardApi/comment/$commentSeq',
{
"userId": userId,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 댓글 삭제 성공 ::');
} else {
throw Exception(":: 게시글 댓글 삭제 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
}
2-2. 게시판 리스트 조회
게시판 화면은 infinite_scroll_pagination을 적용해 무한 스크롤 형태로 페이징이 되게 했습니다.
로그인시에 사용자 정보는 Secure storage에 저장됩니다. 여기서 사용자 정보를 가져와 로그인 상태를 체크하고 로그인이 필요한 회원은 레이어 창을 덮었습니다.
getBoardList를 통해 받아온 데이터를 pageController에 등록하여 리스트를 그립니다.
Future<void> _fetchPage(int page) async {
try {
Map<String, dynamic> result = await BoardService()
.getBoardList(page: page, search: _searchType, text: _searchText);
List<Board> boardList = result['boardList'];
final isLastPage = boardList.length != _perPage;
if (isLastPage) {
_pagingController.appendLastPage(boardList);
} else {
final nextPage = page + 1;
_pagingController.appendPage(boardList, nextPage);
}
} catch (e) {
_pagingController.error = e;
}
}
Container(
padding:
EdgeInsets.symmetric(horizontal: screen.width * p15),
child: PagedListView<int, Board>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Board>(
firstPageProgressIndicatorBuilder: (_) =>
MyLoading(color: widgetMainColor),
newPageProgressIndicatorBuilder: (_) =>
MyLoading(color: widgetMainColor),
noItemsFoundIndicatorBuilder: (context) =>
const Center(
child: EmptyBox(
emptyText: '등록된 게시글이 없습니다.',
),
),
itemBuilder: (context, item, index) {
Board board = item;
return BoardItem(board: board, user: user);
}),
),
),
2-3. 게시판 정보 조회
게시판 이동시 게시판 번호(seq)를 전달받습니다.
마찬가지로 사용자 정보를 가져와서 만약 해당 사용자가 게시글 작성자라면 수정이 가능하도록 했습니다. 토큰 만료/인증 오류가 발생한 경우 오류 알림과 함께 사용자 정보를 초기화하도록 했습니다.
전달받은 seq로 게시판 정보를 조회하고 상태 관리를 하는 boardProvider를 생성했습니다.
게시글 수정/등록시 boardProvider의 updateBoard를 통해 내용을 관리할 수 있습니다.
final boardInfo = ref.watch(boardProvider(widget.seq));
return Scaffold(
appBar: BoardHeader(
height: screen.height,
),
backgroundColor: Colors.white,
body: boardInfo.when(data: (data) {
Board board = data;
return Scaffold(
backgroundColor: Colors.white,
final boardProvider = AutoDisposeStateNotifierProviderFamily<BoardNotifier,
AsyncValue<Board>, int>(
(ref, seq) => BoardNotifier(ref: ref, seq: seq),
);
class BoardNotifier extends StateNotifier<AsyncValue<Board>> {
final Ref ref;
final int seq;
BoardNotifier({
required this.ref,
required this.seq,
}) : super(const AsyncValue.loading()) {
getBoardInfo(seq);
}
void updateBoard(Board updateBoard) {
state = AsyncValue.data(updateBoard);
}
Future<void> getBoardInfo(int seq) async {
state = const AsyncValue.loading();
Board boardInfo = Board(
boardSeq: 0,
boardTitle: '',
boardContent: '',
userId: '',
nickName: '',
profileImageUrl: '',
likeCount: 0,
commentCount: 0,
isLiked: false,
createDate: '');
try {
// seq가 0인 경우 신규 작성
if (seq != 0) {
boardInfo = await BoardService().getBoardInfo(seq: seq);
}
state = AsyncValue.data(boardInfo);
} catch (e, s) {
state = AsyncValue.error(e, s);
}
}
}
사용자 정보는 userProvider로 관리되는데 오류 발생시 userProvider를 초기화했습니다.
}, error: (e, s) {
return ErrorBox(
errorText: '오류가 발생했습니다.\n다시 로그인해 주세요.',
buttonText: '돌아가기',
updateStatus: () {
ref.invalidate(userProvider);
context.pushReplacement("/board");
},
);
}, loading: () {
return MyLoading(
color: widgetMainColor,
);
}));
2-4. 좋아요 등록
좋아요 위젯은 초기에 회원의 좋아요 여부(widget.board.isLiked)와 좋아요 개수(widget.board.likeCount)를 전달받아 화면을 구성합니다.
좋아요 클릭(취소) 시 setState를 이용해 기존 좋아요 개수에 +1(-1) 하도록 합니다.
class _LikeEditor extends ConsumerState<LikeEditor> {
int _likeCount = 0;
bool _isLiked = false;
bool _isClicked = false;
@override
void initState() {
super.initState();
_likeCount = widget.board.likeCount;
_isLiked = widget.board.isLiked;
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.of(context).size;
return IgnorePointer(
ignoring: _isClicked ? true : false,
child: GestureDetector(
onTap: () async {
setState(() {
_isClicked = true;
});
await BoardService().updateLike(boardSeq: widget.board.boardSeq);
setState(() {
_isClicked = false;
_isLiked = !_isLiked;
_likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1;
});
},
Future<void> updateLike({
required int boardSeq,
}) async {
try {
final response = await ApiService().callApi(
'POST',
'$boardApi/like',
{
"boardSeq": boardSeq,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 좋아요 저장 성공 ::');
} else {
throw Exception(":: 게시글 좋아요 저장 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
2-5. 게시판 댓글 리스트 조회
게시글 정보에 댓글 영역을 따로 추가했습니다. 댓글 리스트는 commentListProvider로 관리되며 게시판 번호를 전달받고 해당 게시판의 댓글을 조회합니다.
댓글 리스트 화면은 이후에 나올 답글 달기, 댓글 수정 화면에서도 재사용됩니다. 이를 위해 parentSeq를 추가하여 화면을 구분했습니다.
/// ------------------------------------------------------------------- ///
/// 댓글 영역
/// ------------------------------------------------------------------- ///
CommentList(
boardSeq: board.boardSeq,
userId: user.userId,
parentSeq: 0,
),
final commentListProvider = AutoDisposeStateNotifierProviderFamily<
CommentListNotifier, AsyncValue<List<Comments>>, int>(
(ref, seq) => CommentListNotifier(ref: ref, seq: seq),
);
class CommentListNotifier extends StateNotifier<AsyncValue<List<Comments>>> {
final Ref ref;
final int seq;
CommentListNotifier({
required this.ref,
required this.seq,
}) : super(const AsyncValue.loading()) {
getCommentsList(seq);
}
Future<void> getCommentsList(int seq) async {
state = const AsyncValue.loading();
try {
List<Comments> commentsList = await BoardService().getCommentsList(seq);
state = AsyncValue.data(commentsList);
} catch (e, s) {
state = AsyncValue.error(e, s);
}
}
}
답글 달기, 댓글 수정시 조건 처리문입니다.
List<Comments> dataList = data;
List<Widget> commentWidget = [];
/// 답글달기를 통한 댓글리스트 조회
if (widget.parentSeq != 0) {
dataList = dataList
.where((e) => (widget.parentSeq == e.commentSeq ||
widget.parentSeq == e.parentSeq))
.toList();
}
for (Comments comments in dataList) {
commentWidget.add(CommentBody(
comments: comments,
userId: widget.userId,
isReply: widget.parentSeq == 0 ? true : false,
));
}
2-6. 게시글 등록/수정하기
게시글을 등록/수정하는 화면은 한 파일로 관리했습니다.
등록은 게시판 리스트에서 글쓰기 버튼을 통해 접속하며 이 때 seq를 0으로 전달합니다.
PositionButton(
buttonText: '글쓰기',
iconData: LucideIcons.pencil,
updateStatus: () {
ref.read(menuProvider.notifier).state =
"/board/write/0";
context.go("/board/write/0");
},
)
0으로 넘어오는 경우는 초기화된 boardInfo를 전달하고, 0이 아닌 경우 API 조회 후 게시판 내용을 전달하게 됩니다.
Future<void> getBoardInfo(int seq) async {
state = const AsyncValue.loading();
Board boardInfo = Board(
boardSeq: 0,
boardTitle: '',
boardContent: '',
userId: '',
nickName: '',
profileImageUrl: '',
likeCount: 0,
commentCount: 0,
isLiked: false,
createDate: '');
try {
// seq가 0인 경우 신규 작성
if (seq != 0) {
boardInfo = await BoardService().getBoardInfo(seq: seq);
}
state = AsyncValue.data(boardInfo);
} catch (e, s) {
state = AsyncValue.error(e, s);
}
}
글 작성시 입력값을 체크(_isFieldPassed())하고 저장이 완료되면 게시판 리스트로 이동합니다.
if (_isFieldPassed()) {
setState(() {
_isLoading = true;
});
await BoardService().saveBoard(
boardSeq: widget.seq,
title: _titleController.text,
content: _contentController.text,
marketCode:
_isMarketChecked ? user.marketCode : '',
companyCode:
_isCompanyChecked ? user.companyCode : '',
userId: user.userId,
);
_showToastMessage('글이 등록되었습니다.');
context.pushReplacement("/board");
}
2-7. 게시글 삭제하기
게시글 삭제시 회원 아이디를 추가로 넘겨 서버에서는 한번더 사용자를 체크하게 됩니다.
Future<void> deleteBoard({
required int boardSeq,
required String userId,
}) async {
try {
final response = await ApiService().callApi(
'DELETE',
'$boardApi/item/$boardSeq',
{"userId": userId},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 삭제 성공 ::');
} else {
throw Exception(":: 게시글 삭제 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
2-8. 댓글 등록/수정하기
댓글 등록, 수정이 가능한 에디터를 위젯으로 관리했습니다. commentSeq가 0으로 넘어오면 신규 댓글이 등록되며 아닌 경우 기존 댓글이 수정됩니다.
GestureDetector(
onTap: () async {
if (_textController.text == '') {
_showToastMessage('댓글을 입력해 주세요.');
} else {
setState(() {
_isLoading = true;
});
if (widget.commentSeq == 0) {
// 신규 댓글
await BoardService().saveComment(
boardSeq: widget.boardSeq,
parentSeq: widget.parentSeq ?? 0,
content: _textController.text,
);
} else {
// 댓글 수정
await BoardService().updateComment(
commentSeq: widget.commentSeq!,
content: _textController.text,
);
}
// 저장 후 처리
setState(() {
FocusManager.instance.primaryFocus?.unfocus();
_textController.text = '';
_isLoading = false;
ref.invalidate(commentListProvider(widget.boardSeq));
});
}
},
child: Container(
margin: const EdgeInsets.only(left: 5),
height: screen.height * p30,
decoration: BoxDecoration(
color: widgetMainColor,
borderRadius: BorderRadius.circular(15),
),
child: _isLoading
? MyLoading2()
: const Icon(
LucideIcons.checkCircle,
color: Colors.white,
)),
),
수정시에는 FocusNode를 통해 키보드가 자동으로 올라올 수 있게 합니다.
TextField(
focusNode: widget.isFocus ?? widget.isFocus,
controller: _textController,
textInputAction: TextInputAction.newline,
minLines: null,
maxLines: null,
decoration: InputDecoration(
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: screen.width * f14,
fontWeight: FontWeight.w500,
color: grayFontColor),
hintText: '댓글을 입력해 주세요.',
),
style: TextStyle(
color: basicFontColor,
fontSize: screen.width * f14,
fontWeight: FontWeight.w600,
),
),
2-9. 댓글 삭제하기
댓글 삭제 UI도 게시글 삭제와 동일합니다. 마찬가지로 회원 아이디를 추가로 넘겨 서버에서는 한번 더 사용자를 체크하게 됩니다.
Future<void> deleteComment({
required int commentSeq,
required String userId,
}) async {
try {
final response = await ApiService().callApi(
'DELETE',
'$boardApi/comment/$commentSeq',
{
"userId": userId,
},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
logger.d(':: 게시글 댓글 삭제 성공 ::');
} else {
throw Exception(":: 게시글 댓글 삭제 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
}
이렇게 농실농실의 게시판 기능을 추가하게 되었습니다. 이외에도 댓글 등록시 푸쉬 알림 발송, 내 글보기, 공지사항 기능 등 부가적인 기능도 추가되었습니다.
이번 기회를 통해 토큰 기반의 인증과 모바일앱의 게시판 CRUD를 만들어볼 수 있어 좋은 경험이 된 것 같습니다.
이번 포스팅은 일부 코드만 공개하다보니 포스팅을 바탕으로 따라해보기 힘들겠다는 생각이 들었습니다. 하지만 전체적인 기능, 테이블 설계를 통해 저희 앱이 이런 과정으로 기능을 추가했다는 것을 소개하고 싶었습니다.
농실농실은 웹, 모바일앱 플랫폼을 지원하고 있습니다. 아래 링크를 통해 확인해 보세요.
[웹]
[플레이스토어]
[앱스토어]