2026/02/22 Blog - 8

김기훈·2026년 2월 22일

TIL

목록 보기
148/194
post-thumbnail

프론트 수정

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');
                }

  • renderPagination 함수 수정 (버튼을 그리는 부분)

    • 프론트엔드에 하드코딩되어 있던 pageSize = 10 이라는 제약을 완전히 없애고
    • 백엔드가 준 페이지 수를 그대로 사용하도록 계산 로직을 제거
# 기존: 전체 개수(totalCount)를 받아서 프론트엔드가 직접 페이지 수를 계산했습니다.

    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;
        }
        // ... (이하 버튼 그리는 로직) ...
——————————————————————————————————————[비교]—————————————————————————————————————————
# 수정: 백엔드가 계산해준 총 페이지 수(totalPages)를 바로 받아서 사용합니다.

    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>
# ❌ 기존 코드 (Before) 
# 단순히 textarea DOM 요소에 접근해서 텍스트 값을 그대로 가져왔습니다.
const content = document.getElementById('content').value;

——————————————————————————————————————[비교]—————————————————————————————————————————
# ✅ 현재 코드 (After)
# Toast UI 라이브러리를 사용해 화면에 에디터를 그리고, 
const editor = new toastui.Editor({
    el: document.querySelector('#editor'),
    initialEditType: 'markdown',
    previewStyle: 'vertical',
    height: '500px'
});

# 에디터 객체에 내장된 getMarkdown() 메서드를 호출하여, 
# 굵기, 색상, 이미지 링크 등이 포함된 마크다운 포맷의 텍스트를 통째로 가져옵니다!
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>
# ❌ 기존 코드 (Before) 
# 서버에서 받아온 텍스트(post.content)를 div의 innerText로 단순 삽입했습니다.
# 줄바꿈 정도만 적용되는 밋밋한 텍스트였습니다.
document.getElementById('post-content').innerText = post.content;

——————————————————————————————————————[비교]—————————————————————————————————————————
# ✅ 현재 코드 (After)
# 서버에서 받아온 마크다운 텍스트(post.content)를 Toast UI의 Viewer에게 넘겨줍니다.
# Viewer가 알아서 마크다운 문법을 분석하고, 예쁜 HTML 디자인으로 변환하여 화면(#viewer)에 그려줍니다!
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")
        ]

코드

# service
def add_post_like(*, post_id: int, user: User) -> None:
    """게시글 좋아요를 등록하는 서비스 로직입니다."""

    # 1. 대상 게시글이 존재하는지, 삭제되지 않았는지 확인
    post = Post.objects.filter(id=post_id, deleted_at__isnull=True).first()

    if not post:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    # 2. Like 객체를 가져오거나 생성
    Like.objects.get_or_create(post=post, user=user)


def remove_post_like(*, post_id: int, user: User) -> None:
    """게시글 좋아요를 삭제(취소)하는 서비스 로직입니다."""

    # 1. 삭제 시에도 대상 게시글이 유효한지 검증
    post = Post.objects.filter(id=post_id, deleted_at__isnull=True).first()

    if not post:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    # 2. 좋아요 객체를 삭제
    Like.objects.filter(post=post, user=user).delete()

# view
class PostLikeAPIView(APIView):
    """게시글 좋아요 등록 및 삭제를 담당하는 View입니다."""
    permission_classes = [IsAuthenticated]

    @extend_schema(tags=["포스트 좋아요"], summary="게시글 좋아요 등록")
    def post(self, request: Request, post_id: int):
        """POST 요청이 오면 좋아요를 생성합니다."""
        # 1. User 타입 지정
        user = cast(User, request.user)

        # 2. 서비스 레이어 호출
        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 요청이 오면 좋아요를 삭제합니다."""

        # 1. User 타입 지정
        user = cast(User, request.user)

        # 2. 서비스 레이어 호출
        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() 사용 예정
# service
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")  # type: ignore
        .filter(id=post_id, deleted_at__isnull=True)
        .annotate(likes_count=Count('likes', distinct=True)) 
        .first()
    )

# serializer
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)

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/로컬)

    • Django가 사진을 S3(또는 로컬 폴더)에 예쁘게 저장하고
    • 접속 가능한 URL(https://s3.../my-image.jpg)을 프론트엔드에 응답으로 돌려줌
  • 에디터 렌더링

    • 프론트엔드가 받은 URL을 에디터에 ![이미지](URL) 형태로 넣어주면, 마침내 화면에 사진이 뜸

profile
안녕하세요.

0개의 댓글