[배경]
댓글도 django 에서 url 엔드포인트 잡고 작업했더니,
댓글을 CRUD 가 일어날 때마다 전체 화면 렌더링이 다시되었다.
팀원들 모두 그 때 당시에는 이게 불편하다고 느꼈는데, 아무도 해결할 수 있는 사람이 없어보여서, 내가 뚝딱 해버리자는 마인드로 시작했다. 왜냐하면 django 로 템플릿 렌더링까지 다 해야하는 상황에서, 프론트팀 3명은 웹 디자인만 해주고, 백엔트팀(선배 한명과 나)이 모든 기능을 개강전까지 다 마무리를 해야하는 시기였다. 백엔드 쪽에서는 웬만한 기능은 다 구현이 되었고, 자잘한 버그와 프론트쪽에서의 폼 유효성 검사 등을 추가해야했었다. 진짜 심적으로 엄청 급하게 했던 기억이 있다.
첫날에 검색하면서 좀 알아보다가 비동기 , Restful 등의 개념을 알게 되었고,
댓글을 비동기로 구현하기 위해서는 javascript 기반 프레임워크인 Vue.js React.js Angular.js 등을 사용해야 함을 알았다. "이 셋 중에 뭘 선택하지?, 아 빨리 골라야되는데 어어ㅠㅠ" 하면서 이런저런 추천 영상들이나 비교 글들을 보다가 Vue.js 를 선택했다. 그 때 왜 vue.js 를 선택했는지는 잘 기억 안나는데, 이용자가 늘어나는 추세이기도 하고, 문법 자체가 나에게 잘 이해되는 방식이라 선택했던 것 같다. 그래서 Vue.js(cli) + Webstorm 으로 댓글 CRUD를 첫날 구현했다.
둘째날은 대댓글 CRUD 기능까지 완료했다. 부모와 자식 컴포넌트 간의 데이터 흐름과 이벤트 처리 방식이 인상 깊었다. 각 컴포넌트들이 '상태'를 가질 수 있다는 점도 신기했다. 기존에는 웹페이지를 사진 한 장 보듯이 보고 있었는데, 이제는 마을(vilege) 를 보고 있는 듯한 흥미진진함이 느껴진다. (옹기종기 컴포넌트들이 모여서 데이터를 주고 받고 이벤트 처리하는게 마치 스머프 마을 같이 귀여운 느낌이다.)
[구현 가능한 방법]
일단 기존의 장고에 vue.js 를 붙이는 방법은 크게 두가지가 있다.
- cdn 방식
: cdn 방식은 vue.js 를 해당 페이지에서 임포트해서 script 태그 안에 해당 컴포넌트를 적어서 사용하는 것이다. 브라우저 렌더링 시에 알아서 생성되기 때문에, 되게 간단하고 쉽다. 반면 단점은 컴포넌트 단위의 재사용이 힘들다는 것이다. 댓글같은 경우 여러 페이지에서 빈번하게 사용되는데, 그 때마다 동일한 코드를 script 태그 안에 복붙해야한다는 것이다. 또 수정이 일어날 경우에는 모든 코드를 수정해야하는 참사가 일어날 수 있다. - cli 방식
: npm 모듈을 이용해서 vue.js 를 로컬에 다운받은 후, vue 프로젝트를 하나 생성 => vue 컴포넌트 제작 => 빌드(빌드하면 js 파일로 압축된 결과물이 나옴.) => 빌드된 결과물을 django 템플릿 렌더링 시에 임포트해서 붙이기
: 이런 방식을 진행되서 부가적으로 세팅하고 관리해야하는 품이 더 들겠지만, vue 컴포넌트를 재사용하고 하나의 클래서처럼 사용할 수 있다는 게 장점이다.
그래서 결국은 (2)번 방식인 cli 방식으로 프로젝트를 생성 => 빌드 => 결과물을 장고에서 임포트하여 렌더링! 이렇게 진행했다.
vue 개발자분들이 보면 정말 형편없는 코드겠지만,,
일단 돌아가는 코드 먼저 기한 내에 만들어야겠다는 압박감에..
자바스크립트 코드도 제대로 모르는데 마구 만들어버렸다.... 허허... ㅠ
지금와서 보면 props 를 더 잘 활용했으면 어땠을까하는 생각도 들고,,?
[구현 코드]
댓글 Vue.js 구현 부분
<template>
<div class="clear" id="comment-list">
<template v-if="comment_list != null && comment_list.length > 0">
<!-- 게시글과 댓글을 구분짓는 구분선 -->
<div class="dlab-divider bg-gray-dark"></div>
</template>
<div class="comments-area" id="comments">
<div class="clearfix">
<ol class="comment-list">
<li v-for="(comment, i) in comment_list" :key="comment.comment_id" class="comment">
<comment @deleteComment="deleteComment(comment.comment_id, i)" @updateComment="updateComment"
@addRecomment="addComment"
:send-comment="comment" :send-logined-user="logined_user" :send-index="i"
:send-comment-set="comment_set_list[i]"></comment>
</li>
</ol>
<comment-input @addComment="addComment"></comment-input>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue';
import axios from "axios";
import Comment from "./components/Comment";
import CommentInput from "./components/CommentInput";
import {alert_msg_for_client} from "./assets/response.js"
axios.defaults.xsrfCookieName = 'csrftoken';
axios.defaults.xsrfHeaderName = 'X-CSRFToken';
axios.defaults.baseURL = 'https://inhabas.com';
export default {
data: () => {
return {
logined_user: null,
comment_list: null,
comment_set_list: null,
board_type: null,
board_no: null,
};
},
components: {
'comment-input': CommentInput,
'comment': Comment
},
mounted() { // DOM 객체 생성 후 drf server 에서 데이터를 가져와 CommentList 렌더링
let pathname = location.pathname.split('/')
this.board_no = pathname[pathname.length - 1]
if (/board\/contest\/detail\/\d+/.test(location.pathname)) {
this.board_type = 'contest'
} else if (/board\/detail\/\d+/.test(location.pathname)) {
this.board_type = 'board'
this.board_no = pathname[3]
} else if (/lect\/room\/\d+\/detail\/\d+/.test(location.pathname)) {
this.board_type = 'lect'
} else if (/staff\/member\/delete\/detail\/\d+/.test(location.pathname)) {
this.board_type = 'staff'
} else if (/activity\/\d+\/detail/.test(location.pathname)) {
this.board_type = 'board'
this.board_no = pathname[2]
}
this.fetch_all_comment()
},
methods: {
fetch_all_comment: function () {
axios({
method: 'get',
url: "comment/" + this.board_type + "/view/" + this.board_no
})
.then(response => {
this.comment_list = response.data.comment_list;
this.comment_set_list = response.data.comment_set_list;
this.logined_user = response.data.logined_user;
})
.catch(response => {
console.log("Failed to get commentList", response);
});
},
addComment: function (comment_cont, comment_cont_ref, index) {
if (comment_cont.trim() === "") {
alert('댓글을 입력하세요!')
} else {
var postData = {comment_cont: comment_cont, comment_cont_ref: comment_cont_ref}
axios({
method: 'post',
url: "comment/" + this.board_type + "/register/" + this.board_no,
data: postData
})
.then(response => {
// 통신 성공하면, 해당 댓글 정보 받아와서, 새로 붙이기.
if (comment_cont_ref === undefined) { // 댓글
this.comment_list.push(response.data.comment); // 댓글 붙이기
this.comment_set_list.push([]); // 해당 댓글의 대댓글 배열 생성
} else { // 대댓글
this.comment_set_list[index].push(response.data.comment); // 대댓글 붙이기
}
})
.catch(response => {
console.log("Failed to add the comment", response);
alert_msg_for_client(response)
alert("덧글 등록에 실패했습니다. 새로고침한 후 지속되면 웹팀에 문의하세요!!")
})
}
},
deleteComment: function (comment_id, index) {
if (confirm('댓글을 삭제하시겠습니까?')) {
axios.delete("comment/delete/" + comment_id)
.then(() => {
Vue.delete(this.comment_list, index); // 해당 댓글 삭제
Vue.delete(this.comment_set_list, index); // 대댓글 컴포넌트를 생성하게 만드는 대댓글 데이터 삭제
})
.catch(response => {
console.log("Failed to remove the comment", response);
alert_msg_for_client(response)
alert("덧글 삭제에 실패했습니다. 새로고침한 후 지속되면 웹팀에 문의하세요!!")
});
}
},
updateComment: function (comment_id, comment_cont, index) {
if (comment_cont.trim() === "") {
alert('댓글을 입력하세요!')
} else {
axios.put("comment/update/" + comment_id, {comment_cont: comment_cont})
.then(response => {
console.log('반응이 왔엉', response)
this.comment_list[index] = response.data.comment;
})
.catch(response => {
console.log("Failed to update the comment", response);
alert_msg_for_client(response)
alert("덧글 수정에 실패했습니다. 새로고침한 후 지속되면 웹팀에 문의하세요!!")
});
}
}
}
};
</script>
<template>
<div class="comment-body">
<div class="comment-author vcard">
<cite class="fn">
<img
:src="comment.comment_writer.user_pic"
width="35"
height="35" class="comment-profile-size"
alt="현재 브라우저에서 지원하지 않는 형식입니다."> {{ comment.comment_writer.user_name }}
</cite>
</div>
<div class="dlab-post-meta m-l10">
<ul class="d-flex">
<li class="post-author">
<a href="javascript:void(0);"> {{ comment.comment_writer.user_major }}
{{ comment.comment_writer.user_stu | subStr(2,4) }}학번</a>
</li>
<li class="post-comment"><i
class="ti ti-alarm-clock"></i>
<a href="javascript:void(0);">
{{ comment.comment_created | timeFormat }}
</a>
</li>
</ul>
</div>
<input type="text" :disabled="isDisabled === true"
style="width: 706px"
name="comment_cont"
class="comments-area"
:id="'comment_cont_' + comment.comment_id"
v-model="comment.comment_cont"
autofocus>
<div class="reply-btn-div">
<button class="btnAdd comment-btn m-r20" @click="Recomment()">
<i class="fa fa-commenting m-r5"></i>답글쓰기
</button>
<template v-if="logined_user.user_stu === comment.comment_writer.user_stu">
<button @click="updateComment()" class="comment-btn m-r10">
<i class="fa fa-pencil m-r5"></i>수정
</button>
</template>
<template v-if="logined_user.user_stu === comment.comment_writer.user_stu || logined_user.user_role < 3">
<button @click="deleteComment()" class="comment-btn m-r10">
<i class="fa fa-trash m-r5"></i>삭제
</button>
</template>
<template v-if="recomment_mode === true">
<!-- 대댓글 입력 창 -->
<recomment_input @cancelInput="Recomment" @addRecomment="addRecomment"></recomment_input>
</template>
<template v-if="comment_set_list != null">
<div v-for="(recomment, j) in comment_set_list" :key="recomment.comment_id">
<recomment @deleteRecomment="deleteRecomment(recomment.comment_id, j)" @updateRecomment="updateRecomment"
:send-recomment="recomment" :send-logined-user="logined_user" :send-index="j"
style="margin-left: 20px;"></recomment>
</div>
</template>
</div>
</div>
</template>
<script>
import ReCommentInput from "./ReCommentInput";
import ReComment from "./ReComment";
import axios from "axios";
import {alert_msg_for_client} from "../assets/response.js"
export default {
props: ['sendComment', 'sendLoginedUser', 'sendIndex', 'sendCommentSet'],
name: "Comment.vue",
data: () => {
return {
index: null,
comment: null,
isDisabled: true,
logined_user: null,
comment_set_list: null,
recomment_mode: false,
};
},
watch: {
sendIndex: function (newVal) {
this.index = newVal;
}
},
components: {
'recomment_input': ReCommentInput,
'recomment': ReComment
},
created() {
this.comment = this.sendComment;
this.logined_user = this.sendLoginedUser;
this.index = this.sendIndex;
this.comment_set_list = this.sendCommentSet;
},
methods: { // CRUD 로직이 들어갈 부분
// 댓글 수정하도록 상위 컴포넌트(commentList)에 이벤트 발생
updateComment: function () {
if (this.isDisabled === false) {
this.$emit("updateComment", this.comment.comment_id, this.comment.comment_cont, this.index)
}
this.isDisabled = !this.isDisabled;
},
// 댓글 삭제하도록 상위 컴포넌트(commentList)에 이벤트 발생
deleteComment: function () {
this.$emit('deleteComment')
this.$destroy()
},
// 대댓글 입력 창 열고 닫기
Recomment: function () {
this.recomment_mode = !this.recomment_mode;
},
// 대댓글 등록하기
addRecomment: function (recomment_cont) {
this.$emit('addRecomment', recomment_cont, this.comment.comment_id, this.index)
},
// 대댓글 수정하도록 상위 컴포넌트(commentList)에 이벤트 발생
updateRecomment: function (comment_id, comment_cont, index) {
if (comment_cont.trim() === "") {
alert('댓글을 입력하세요!')
} else {
axios({
method: 'put',
url: "comment/update/" + comment_id,
data: {comment_cont: comment_cont}
})
.then(response => {
this.comment_set_list[index] = response.data.comment;
})
.catch(response => {
console.log("Failed to update the comment", response);
alert_msg_for_client(response)
})
}
},
// 대댓글 삭제하기
deleteRecomment: function (comment_id, index) {
if (confirm('댓글을 삭제하시겠습니까?')) {
axios.delete("comment/delete/" + comment_id)
.then(() => {
this.comment_set_list.splice(index, 1)
})
.catch(response => {
console.log("Failed to remove the comment", response);
alert_msg_for_client(response)
});
}
},
},
filters: {
truncate: function (text, length) {
return String(text).slice(0, length)
},
subStr: function (text, start, end) {
return String(text).substring(start, end);
},
timeFormat: function (date) {
date = new Date(date)
let month = date.getMonth() + 1;
let day = date.getDate();
let hour = date.getHours();
let minute = date.getMinutes();
month = month >= 10 ? month : '0' + month;
day = day >= 10 ? day : '0' + day;
hour = hour >= 10 ? hour : '0' + hour;
minute = minute >= 10 ? minute : '0' + minute;
return date.getFullYear() + '-' + month + '-' + day + ' ' + hour + ':' + minute;
}
}
}
</script>
<template>
<div>
<div class="dlab-divider bg-gray-dark"></div>
<div class="comment-form" id="respond">
<!-- 댓글입력창 -->
<p class="comment-form-comment">
<label for="comment">Comments</label>
<textarea rows="8" name="comment_cont" v-model="comment_cont"
placeholder="댓글을 남겨보세요!" required="required"
id="comment"></textarea>
</p>
<!-- 댓글작성 버튼 -->
<p class="form-submit" style="text-align: right">
<input type="submit" v-on:click="addComment()" value="댓글등록"
class="submit site-button"/>
</p>
</div>
</div>
</template>
<script>
export default {
name: "Comment-input.vue",
data: () => {
return {
comment_cont: null
}
},
methods: {
addComment: function () {
this.$emit("addComment", this.comment_cont);
this.comment_cont = ""
}
},
}
</script>
<template>
<div>
<div class="dlab-divider bg-gray-dark"></div>
<div class="comment-author vcard">
<cite class="fn">
<img
:src="comment.comment_writer.user_pic"
width="35"
height="35" class="comment-profile-size"
alt="현재 브라우저에서 지원하지 않는 형식입니다."> {{ comment.comment_writer.user_name }}
</cite>
</div>
<div class="dlab-post-meta m-l10">
<ul class="d-flex">
<li class="post-author">
<a href="javascript:void(0);"> {{ comment.comment_writer.user_major }}
{{ comment.comment_writer.user_stu | subStr(2,4) }}학번</a>
</li>
<li class="post-comment"><i
class="ti ti-alarm-clock"></i>
<a href="javascript:void(0);">
{{ comment.comment_created | timeFormat }}
</a>
</li>
</ul>
</div>
<input type="text" :disabled="isDisabled === true"
style="width: 706px"
name="comment_cont"
class="comments-area"
:id="'comment_cont_' + comment.comment_id"
v-model="comment.comment_cont"
autofocus>
<div class="reply-btn-div">
<template v-if="logined_user.user_stu === comment.comment_writer.user_stu">
<button @click="updateRecomment()" class="comment-btn m-r10">
<i class="fa fa-pencil m-r5"></i>수정
</button>
</template>
<template v-if="logined_user.user_stu === comment.comment_writer.user_stu || logined_user.user_role < 3">
<button @click="deleteRecomment()" class="comment-btn m-r10">
<i class="fa fa-trash m-r5"></i>삭제
</button>
</template>
</div>
</div>
</template>
<script>
export default {
name: "ReComment",
props: ['sendRecomment', 'sendLoginedUser', 'sendIndex'],
data: () => {
return {
index: null,
comment: null,
isDisabled: true,
logined_user: null
};
},
watch: {
sendIndex: function (newVal) {
this.index = newVal;
}
},
created() {
this.index = this.sendIndex;
this.comment = this.sendRecomment;
this.logined_user = this.sendLoginedUser;
},
methods: {
// 대댓글 수정하도록 상위 컴포넌트(Comment)에 이벤트 발생
updateRecomment: function () {
if (this.isDisabled === false) {
this.$emit('updateRecomment', this.comment.comment_id, this.comment.comment_cont, this.index)
}
this.isDisabled = !this.isDisabled;
},
// 대댓글 삭제하도록 상위 컴포넌트(Comment)에 이벤트 발생
deleteRecomment: function () {
this.$emit('deleteRecomment');
this.$destroy()
}
},
filters: {
truncate: function (text, length) {
return String(text).slice(0, length)
},
subStr: function (text, start, end) {
return String(text).substring(start, end);
},
timeFormat: function (date) {
date = new Date(date)
let month = date.getMonth() + 1;
let day = date.getDate();
let hour = date.getHours();
let minute = date.getMinutes();
month = month >= 10 ? month : '0' + month;
day = day >= 10 ? day : '0' + day;
hour = hour >= 10 ? hour : '0' + hour;
minute = minute >= 10 ? minute : '0' + minute;
return date.getFullYear() + '-' + month + '-' + day + ' ' + hour + ':' + minute;
}
}
}
</script>
<template>
<div>
<!-- 댓글란과 대댓글란 구분선-->
<div class="dlab-divider bg-gray-dark"></div>
<div class="comment-form" >
<p class="comment-form-comment">
<textarea placeholder="답글을 입력하세요!" row="8" name="comment_cont" v-model="comment_cont"></textarea>
</p>
<div class="extra-cell text-right">
<button id="comment-delete" type="button" class="site-button radius-xl m-l10 red" @click="cancelInput()">작성취소</button>
<button id="re-comment-submit" type="submit" class="site-button radius-xl m-l10 m-r15" @click="addRecomment()">답글작성</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "recomment_input",
data: () => {
return {
comment_cont: null
}
},
methods: {
cancelInput: function () {
this.$emit("cancelInput");
},
addRecomment: function () {
this.$emit("addRecomment", this.comment_cont);
this.comment_cont = "";
this.cancelInput();
}
}
}
</script>
Django 구현 부분
from DB.models import Comment, User, MajorInfo
from rest_framework import serializers
class MajorSerializer(serializers.ModelSerializer):
class Meta:
model = MajorInfo
fields = ('major_name', )
class UserSerializer(serializers.ModelSerializer):
user_major = serializers.ReadOnlyField(source='user_major.major_name')
class Meta:
model = User
fields = ('user_stu', 'user_name', 'user_pic', 'user_major')
depth = 1
class CommentSerializer(serializers.ModelSerializer):
comment_writer = UserSerializer()
class Meta:
model = Comment
fields = ('comment_id', 'comment_writer', 'comment_cont', 'comment_created')
depth = 1
@auth_check()
def comment_register(request, type, board_ref):
comment_type = get_object_or_404(CommentType, pk=type_no[type])
if request.method == "POST":
data = json.loads(request.body)
comment = Comment(
comment_type=comment_type,
comment_writer=get_logined_user(request),
comment_cont=data['comment_cont'].strip(),
comment_board_ref=int(board_ref),
comment_cont_ref_id=data.get('comment_cont_ref', None)
)
content = comment.comment_cont
if not 0 < len(content) < 5001:
raise ValidationError(
code=400,
message='댓글 내용 길이 제한을 확인하세요'
)
else:
comment.save()
create_comment_alarm(comment)
serializer = CommentSerializer(comment)
return JsonResponse({'comment': serializer.data}, safe=False)
return JsonResponse(data={}, status=400)
@writer_only(superuser=True)
def comment_delete(request, comment_id):
if request.method == "DELETE":
comment = get_object_or_404(Comment, pk=comment_id)
comment.delete()
return JsonResponse(data={}, status=204)
else:
return JsonResponse(data={}, status=400)
@writer_only()
def comment_update(request, comment_id):
if request.method == "PUT":
comment = get_object_or_404(Comment, pk=comment_id)
comment.comment_cont = json.loads(request.body)['comment_cont']
comment.save()
serializer = CommentSerializer(comment)
return JsonResponse({'comment': serializer.data}, safe=False)
else:
return JsonResponse(data={}, status=400)
@ensure_csrf_cookie
def comment_view(request, type, board_ref):
if cur_user := User.objects.filter(pk=request.session.get("user_stu")).first():
comment_list = Comment.objects.filter(
Q(comment_type_id=type_no[type]) & Q(comment_board_ref=board_ref) & Q(
comment_cont_ref__isnull=True)).prefetch_related("re_comments").order_by("comment_created")
commentset_serializer = [CommentSerializer(comment.re_comments.all(), many=True).data for comment in comment_list]
comment_serializer = CommentSerializer(comment_list, many=True)
logined_user = {
'user_role': cur_user.user_role_id,
'user_stu': cur_user.user_stu
}
return JsonResponse({
'comment_list': comment_serializer.data,
'comment_set_list': commentset_serializer,
'logined_user': logined_user}, safe=False, status=200)
else:
return JsonResponse({}, status=400)
HTML 구현부
// 생략
<!--==================== 댓글부분 시작 ====================-->
<div id="CommentList"></div>
<!--==================== 댓글부분 끝 ====================-->
// 생략
<!-- django 렌더링 끝나고 나서 vue.js 렌더링 -->
<script type="text/javascript" src="{% static 'src/vue/dist/js/chunk-vendors.js' %}"></script>
<script type="text/javascript" src="{% static 'src/vue/dist/js/app.js' %}"></script>
<!-- django 렌더링 끝나고 나서 vue.js 렌더링 -->
- vue 를 빌드하고 나면, app.js 와 chunk-vecdors.js 파일이 생성된다. 두개의 js 파일이 임포트 하고, 컴포넌트를 삽입하고 싶은 dev 에 id로 컴포넌트 이름을 지정해주면 된다.
'웹 프로젝트 (IBAS) > Django 레거시' 카테고리의 다른 글
[Django 웹 프로젝트] 6. 유지 보수를 위한 새로운 아키텍처 고민 (2021-10-21) (0) | 2022.03.01 |
---|---|
[Django 웹 프로젝트] 5. static file name hashing 하기 (2021-09-09) (0) | 2022.02.28 |
[Django 웹 프로젝트] 3. 파일 관리 시스템 개선 (2021-04-30) (0) | 2021.07.09 |
[Django 웹 프로젝트] 2. 장고 폼(forms) 도입 => 코드 간결화 (2021-04-28) (0) | 2021.07.09 |
[Django 웹프로젝트] 1. 어쩌다 생애 첫 프로젝트 (2021-04-04) (0) | 2021.04.04 |