프론트 수정
home
기존
- 최근 작성글 전체를 받았음
- 로그인한 유저가 작성하지 않은것도 받음
이후
- 나의 최근 이야기 로 전환
- 로그인 유저가 작성한 글중 최신순으로 5개를 로딩
조회 페이지네이션 도입

기능수정
페이지네이션 수정
요약
- 기존에 프론트에서 하드코딩 되어있었기 때문에 10개를 제공하던 페이지네이션을 백에서 15로 바꾸면
- 현재는 백에서 데이터를 보내주고 프론트는 버튼만 그림
fetchMyPosts 함수 수정(데이터를 넘겨주는 부분)
- 기존에는 백엔드에서 받은 전체 게시글 개수(
data.count)를 넘겨주었지만
- 이제는 백엔드가 계산해서 보내준 총 페이지 수(
data.total_pages)를 바로 넘겨주도록 변경
// 총 데이터 개수(count)가 존재한다면 페이지네이션을 렌더링합니다.
if (data.count !== undefined) {
// renderPagination의 첫 번째 인자로 전체 게시글 수(data.count)를 넘겼습니다.
renderPagination(data.count, page, document.getElementById('pagination-container'), 'fetchMyPosts');
}
——————————————————————————————————————[비교]—————————————————————————————————————————
// 백엔드가 계산해서 보내준 총 페이지 수(total_pages)가 존재하는지 확인합니다.
if (data.total_pages !== undefined) {
// 프론트엔드에서 계산할 필요 없이, 백엔드가 준 총 페이지 수(data.total_pages)를 바로 넘겨줍니다.
renderPagination(data.total_pages, page, document.getElementById('pagination-container'), 'fetchMyPosts');
}
- 프론트엔드에 하드코딩되어 있던
pageSize = 10 이라는 제약을 완전히 없애고
- 백엔드가 준 페이지 수를 그대로 사용하도록 계산 로직을 제거
function renderPagination(totalCount, currentPage, container, fetchFunctionName) {
// 백엔드가 10개씩 주는지 15개씩 주는지 모르기 때문에, 프론트엔드에 '10'이라고 강제로 박아두었습니다. (하드코딩)
const pageSize = 10;
// 전체 개수를 10으로 나누어 올림 처리하여 총 페이지 수를 프론트엔드가 직접 계산했습니다.
const totalPages = Math.ceil(totalCount / pageSize);
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
// ... (이하 버튼 그리는 로직) ...
——————————————————————————————————————[비교]—————————————————————————————————————————
function renderPagination(totalPages, currentPage, container, fetchFunctionName) {
// 프론트엔드에서 강제로 개수를 정하던 pageSize = 10; 코드를 완전히 삭제했습니다.
// 계산 로직(Math.ceil...)도 삭제했습니다. 인자로 받은 totalPages를 그대로 씁니다.
// 전달받은 총 페이지 수가 1 이하일 경우, 넘길 페이지가 없으므로 버튼 영역을 비웁니다.
if (totalPages <= 1) {
// 컨테이너 내부의 HTML을 지워 화면에서 숨깁니다.
container.innerHTML = '';
// 처리를 마쳤으므로 함수를 즉시 종료합니다.
return;
}
// ... (이하 버튼 그리는 로직은 기존과 100% 동일하게 유지됩니다) ...
입력창 기능 추가
개선방안
- 마크다운 / WYSIWYG 리치 텍스트 에디터 도입
- 대표 이미지(썸네일) 업로드 기능
- 시각적인 태그(Tag) 입력기
- 자동 임시 저장 (Auto-save) & 글자 수 세기
'Toast UI Editor'
- NHN(구 네이버, 한게임 등)에서 개발하여 오픈소스로 무료 제공하는
- 마크다운(Markdown) 기반의
WYSIWYG(What You See Is What You Get) 에디터

글 쓰기 화면
<div class="mb-4">
<label for="content">내용</label>
<textarea class="form-control" id="content" rows="12"></textarea>
</div>
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
<div class="mb-4">
<label for="content">내용</label>
<div id="editor"></div> </div>
const content = document.getElementById('content').value;
——————————————————————————————————————[비교]—————————————————————————————————————————
const editor = new toastui.Editor({
el: document.querySelector('#editor'),
initialEditType: 'markdown',
previewStyle: 'vertical',
height: '500px'
});
const content = editor.getMarkdown();
글 읽기 화면
- 기존에 마크다운 형식의
**를 붙이면 조회에서도 별이 보이지만 이를 변환해서 보여주는 '뷰어(Viewer)' 기능이 추가
<div id="post-content" class="text-dark lh-lg mb-5" style="white-space: pre-wrap;"></div>
<div id="viewer" class="mb-5"></div>
document.getElementById('post-content').innerText = post.content;
——————————————————————————————————————[비교]—————————————————————————————————————————
const viewer = toastui.Editor.factory({
el: document.querySelector('#viewer'),
viewer: true, // "나는 글쓰기 모드가 아니라 읽기 전용 모드야!"
initialValue: post.content // 백엔드에서 가져온 마크다운 데이터
});
수정 로직
<script>
// [수정] 동일한 안전한 ID 추출 로직 사용
const segments = window.location.pathname.split('/');
const currentPostId = segments[segments.indexOf('post') + 1];
const token = localStorage.getItem('access_token');
document.addEventListener("DOMContentLoaded", async () => {
try {
const res = await fetch(`/api/v1/post/${currentPostId}/`);
const data = await res.json();
if (res.ok) {
document.getElementById('title').value = data.title;
document.getElementById('content').value = data.content;
document.getElementById('visibility').value = data.visibility;
}
} catch (e) { console.error(e); }
});
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
title: document.getElementById('title').value,
content: document.getElementById('content').value,
visibility: document.getElementById('visibility').value
};
const res = await fetch(`/api/v1/post/${currentPostId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(payload)
});
if (res.ok) {
alert("성공적으로 수정되었습니다.");
window.location.href = `/api/v1/post/${currentPostId}/page/`;
} else {
alert("수정에 실패했습니다.");
}
});
</script>
——————————————————————————————————————[비교]—————————————————————————————————————————
<script>
const segments = window.location.pathname.split('/');
const currentPostId = segments[segments.indexOf('post') + 1];
const token = localStorage.getItem('access_token');
// ✨ [추가] 전역 변수로 에디터 선언
let editor;
document.addEventListener("DOMContentLoaded", async () => {
// ✨ [추가] 1. 화면이 로드되면 먼저 에디터 UI부터 화면에 그립니다.
editor = new toastui.Editor({
el: document.querySelector('#editor'),
height: '500px',
initialEditType: 'markdown',
previewStyle: 'vertical'
});
try {
// 2. 서버에서 수정할 기존 글의 데이터를 가져옵니다.
const res = await fetch(`/api/v1/post/${currentPostId}/`);
const data = await res.json();
if (res.ok) {
// 3. 제목과 공개설정을 채웁니다.
document.getElementById('title').value = data.title;
document.getElementById('visibility').value = data.visibility;
// ✨ [수정] 4. 가져온 마크다운 본문을 에디터 안에 채워 넣습니다! (setMarkdown 사용)
editor.setMarkdown(data.content);
}
} catch (e) {
console.error(e);
}
});
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
// ✨ [수정] 제출 시 textarea가 아닌 에디터 객체에서 내용을 가져옵니다. (getMarkdown 사용)
const payload = {
title: document.getElementById('title').value,
content: editor.getMarkdown(),
visibility: document.getElementById('visibility').value
};
const res = await fetch(`/api/v1/post/${currentPostId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(payload)
});
if (res.ok) {
alert("성공적으로 수정되었습니다.");
window.location.href = `/api/v1/post/${currentPostId}/page/`;
} else {
alert("수정에 실패했습니다.");
}
});
</script>
백엔드 기능
태그에 따른 필터
- 지금 글에서 작성된 태그들이 모델에 저장되는데 저장된 태그들에 따른 필터링기능 구현
"좋아요"기능 구현


고민
- 멱등성(Idempotency)
- 여러 번 같은 요청을 보내도 서버 상태가 안전하게 유지되는 성질)을 보장
- 동시성(Concurrency) 문제
- 데이터 정합성
select_for_update
- 기존 데이터를 읽어서 수정(UPDATE) 할 때 사용
- 예: 게시글 테이블에 like_count라는 정수 필드가 있고, 이를 읽어서 +1을 해야 할 때
get_or_create
동작
- 먼저 get()을 시도, 만약 데이터가 없으면 create()를 시도
[동시성 발생]
- 0.001초 차이로 두 개의 요청(스레드)이 동시에 들어와서
- 둘 다 get()에서 데이터를 못 찾고 create()를 시도한다고 가정
- 한 요청은 성공적으로 INSERT 되지만
- 다른 요청은 DB의 UniqueConstraint(유니크 제약조건)에 막혀서 IntegrityError를 발생
- Django의 get_or_create는 똑똑하게도 이 IntegrityError를 내부적으로 catch한 뒤
- 다시 get()을 호출하여 이미 생성된 객체를 안전하게 가져옴
사용 시점
- 새로운 데이터를 생성(INSERT) 할 때 동시성으로 인한 중복 생성을 방지할 때 사용
- 지금처럼 좋아요 내역(Like row)을 추가하기만 할 때는 DB에 락(Lock)을 걸지 않는
인덱스
UniqueConstraint
- 한 유저가 같은 게시글에 중복으로 좋아요를 누를 수 없도록 데이터베이스 단에서 방어
class Like(models.Model):
post = models.ForeignKey(
"post.Post", on_delete=models.CASCADE, related_name="likes"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="likes"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "likes"
constraints = [
models.UniqueConstraint(fields=["post", "user"], name="uk_likes_post_user")
]
코드
def add_post_like(*, post_id: int, user: User) -> None:
"""게시글 좋아요를 등록하는 서비스 로직입니다."""
post = Post.objects.filter(id=post_id, deleted_at__isnull=True).first()
if not post:
raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)
Like.objects.get_or_create(post=post, user=user)
def remove_post_like(*, post_id: int, user: User) -> None:
"""게시글 좋아요를 삭제(취소)하는 서비스 로직입니다."""
post = Post.objects.filter(id=post_id, deleted_at__isnull=True).first()
if not post:
raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)
Like.objects.filter(post=post, user=user).delete()
class PostLikeAPIView(APIView):
"""게시글 좋아요 등록 및 삭제를 담당하는 View입니다."""
permission_classes = [IsAuthenticated]
@extend_schema(tags=["포스트 좋아요"], summary="게시글 좋아요 등록")
def post(self, request: Request, post_id: int):
"""POST 요청이 오면 좋아요를 생성합니다."""
user = cast(User, request.user)
add_post_like(post_id=post_id, user=user)
return Response(
{"message": "좋아요가 등록되었습니다."},
status=status.HTTP_201_CREATED
)
@extend_schema(tags=["포스트 좋아요"], summary="게시글 좋아요 취소(삭제)")
def delete(self, request: Request, post_id: int):
"""DELETE 요청이 오면 좋아요를 삭제합니다."""
user = cast(User, request.user)
remove_post_like(post_id=post_id, user=user)
return Response(status=status.HTTP_204_NO_CONTENT)
like_count

- "좋아요" 수가 너무 많을경우 나중에
like_count = models.IntegerField(default=0)를 만들어서
- 역정규화(캐싱) 예정, 이때에는
get_or_create 가 아닌 select_for_update / F() 사용 예정
def get_global_posts() -> QuerySet[Post]:
"""
모든 사용자의 공개된 포스트 목록을 가져옵니다. (전체 피드용)
"""
return (
Post.objects.filter(
is_temp=False,
visibility=Post.Visibility.PUBLIC,
deleted_at__isnull=True,
)
.select_related("user")
.annotate(likes_count=Count('likes', distinct=True))
.order_by("-created_at")
)
def get_post_detail(post_id: int) -> Post:
"""
특정 ID의 게시글을 상세 조회합니다. (삭제되지 않은 글만)
"""
return (
Post.objects.select_related("user")
.filter(id=post_id, deleted_at__isnull=True)
.annotate(likes_count=Count('likes', distinct=True))
.first()
)
class PostListSerializer(serializers.ModelSerializer):
"""목록 조회를 위한 시리얼라이저"""
author_nickname = serializers.CharField(source="user.nickname", read_only=True)
likes_count = serializers.IntegerField(read_only=True)
class Meta:
model = Post
fields = [
"id",
"title",
"thumbnail",
"author_nickname",
"created_at",
"visibility",
"likes_count",
]
이미지 처리
S3
- "용량이 무한대로 늘어나는 인터넷 상의 외장 하드디스크"
- 사진, 동영상, 텍스트 문서 등 어떤 파일(Object)이든 마음껏 저장하고
- 고유한 인터넷 주소(URL)를 통해 언제 어디서나 꺼내볼 수 있는 AWS의 대표적인 클라우드 스토리지 서비스
S3를 사용하는 이유
서버의 휘발성 (데이터 증발 방지)
- 문제
- 요즘 서버들은 업데이트를 하거나 트래픽이 몰리면 기존 서버를 껐다가 새로운 서버를 켬
- 만약 서버 내부 폴더에 사용자가 올린 이미지를 저장해 뒀다면,
- 서버가 재시작되는 순간 사진이 전부 날아가는 대참사 발생 가능
- S3의 해결
- 서버(Django)와 저장소(S3)를 완전히 분리
- 서버가 100번 꺼졌다 켜져도, 사진은 S3라는 튼튼한 금고에 안전하게 보관
서버 확장성 (다중 서버 문제 해결)
- 문제
- 블로그가 유명해져서 Django 서버를 3대(A, B, C)로 늘렸다고 가정
- 유저가 'A 서버'에 접속해서 사진을 올리면 A 서버에만 사진이 저장됨
- 다음날 유저가 'B 서버'로 접속하게 되면 엑스박스(사진 깨짐) 뜸 (B 서버에는 사진이 없어서)
- S3의 해결
- A, B, C 서버 모두 유저가 사진을 올리면 무조건 중앙의 S3로 보냄
- 유저가 어느 서버로 접속하든 똑같은 S3 URL에서 사진을 불러오기 때문에 엑스박스가 뜨지 않음
비용과 성능의 최적화
- 서버 컴퓨터(EC2 등)의 하드디스크 용량을 늘리는 것은 꽤 비쌈
- 게다가 이미지를 서빙(전송)하는 데 서버의 CPU와 네트워크를 낭비 가능
- S3는 쓴 만큼만 돈을 내며(GB당 아주 저렴함)
- 서버를 거치지 않고 사용자의 브라우저와 S3가 직접 사진 데이터를 주고받게 할 수 있어
S3의 3가지 핵심 용어
- 버킷 (Bucket)
- 파일을 담는 '최상위 폴더'이자 '프로젝트 단위' (예: my-django-blog-bucket)
- 전 세계에서 유일한 이름을 가져야 함
- 객체 (Object)
- 버킷 안에 저장되는 파일 그 자체 (예: profile_img.png, post_thumbnail.jpg)
- S3는 폴더라는 개념이 없고, 모든 것이 객체
- 엔드포인트 (Endpoint URL)
- 파일에 접근할 수 있는 고유한 인터넷 주소
- 프론트엔드 HTML의
<img src="...">에 바로 이 주소가 들어가게 됨
presigned_url
- S3를 훨씬 더 똑똑하고 안전하게 쓰기 위해 AWS가 제공하는 '핵심 기능(기술)'
기존 이미지 업로드 과정
- 프론트엔드: 무거운 고양이 사진(5MB)을 백엔드(Django)로 전송합니다.
- 백엔드: 그 무거운 사진을 낑낑대며 받아서 메모리에 올린 뒤, 다시 S3로 전송합니다.
- 문제점
- 트래픽이 몰리면 백엔드 서버가 이미지 파일들을 옮기느라 뻗어버립니다.
- 백엔드는 데이터베이스 통신이나 로직 처리를 해야 하는데
- 단순 '택배 배달부' 역할을 하느라 자원을 다 낭비
Presigned URL 방식 (우아하고 효율적인 방식)
- 프론트엔드
- "백엔드야, 나 '고양이.jpg' S3에 올릴 건데 딱 한 번만 쓸 수 있는 주소(출입증) 좀 만들어줘."
- 백엔드(Django)
- 자기가 가진 마스터 권한으로 S3에게 물어봅니다.
- "S3야, 얘한테 딱 5분 동안만 업로드할 수 있는 일회용 주소 하나만 발급해 줘."
- 이때 S3가 만들어주는 임시 주소가 바로 presigned_url
- 프론트엔드
- 백엔드에게 그 presigned_url을 받으면
- 무거운 고양이 사진을 백엔드가 아닌 S3 주소로 직접 전송해 버립니다.
장점
- 서버 비용 절감 & 성능 향상
- 백엔드(Django)는 아주 가벼운 '문자열(URL)'만 하나 만들어주고 끝납니다.
- 무거운 파일 트래픽은 모두 AWS S3가 감당하므로 백엔드 서버가 쾌적해집니다.
- 완벽한 보안
- 프론트엔드(클라이언트) 코드에 AWS 해킹의 주범인 '비밀키(Secret Key)'를 숨겨둘 필요가 없음
- 백엔드만 비밀키를 가지고 임시 출입증만 발급해 주면 됨
Toast UI Editor와 이미지 동작
프론트엔드 (글쓰기)
가로채기 (Hook)
- Toast UI Editor가 사진을 화면에 띄우기 전에 만든 함수(addImageBlobHook)가 사진을 가로챔
API 통신
- 프론트엔드가 백엔드(Django)의 이미지 업로드 API로 사진 파일을 보냄
백엔드 (S3/로컬)
에디터 렌더링
- 프론트엔드가 받은 URL을 에디터에
 형태로 넣어주면, 마침내 화면에 사진이 뜸