2026/03/13 Blog - 23

김기훈·2026년 3월 13일

TIL

목록 보기
163/191
post-thumbnail

코딩테스트(14626)


오늘 구현 목표

글자수 카운트

  • 작성 중인 문서의 글자 수 카운트 기능

    • 서버와 통신할 필요 없이 사용자 화면에서 즉각적으로 반응해야 하므로 프론트엔드 100%

Toast UI Editor 활용

  • write.htmledit.html에 글자 수를 표시할 작은 UI(HTML)를 추가
    • 에디터에 글자가 입력될 때마다 길이를 계산하는 JavaScript 코드를 추가
  • write.html 본문 내용 영역

<div class="mb-5">
                        <div class="d-flex justify-content-between align-items-end mb-2">
                            <label class="form-label fw-bold text-secondary mb-0">본문 내용</label>
                            <button type="button"
                                    class="btn btn-sm btn-warning fw-bold rounded-pill px-3 shadow-sm text-dark"
                                    data-bs-toggle="modal" data-bs-target="#aiToneModal">
                                <i class="bi bi-magic me-1"></i> AI로 글 다듬기
                            </button>
                        </div>
                        <div id="editor"></div>
                    </div>
                    
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="mb-5">
    <div class="d-flex justify-content-between align-items-end mb-2">
        <label class="form-label fw-bold text-secondary mb-0">본문 내용</label>
        
        <div class="d-flex align-items-center gap-3">
            <span class="text-muted small fw-bold">
                글자 수: <span id="text-count" class="text-success">0</span></span>

            <button type="button" class="btn btn-sm btn-warning fw-bold rounded-pill px-3 shadow-sm text-dark" data-bs-toggle="modal" data-bs-target="#aiToneModal">
                <i class="bi bi-magic me-1"></i> AI로 글 다듬기
            </button>
        </div>
    </div>
    <div id="editor"></div>
</div>
  • write.html 하단 <script> 내부

    • Toast UI Editor가 공식적으로 지원하는 editor.on('change') 이벤트를 사용하면
      • 오직 '사용자가 타이핑을 했을 때만' 연산이 일어나므로 CPU 자원을 아낄 수 있음
// '글자 수 카운트' 최적화 로직
        // HTML에서 글자 숫자가 표시될 요소를 DOM에서 찾아 변수에 저장합니다.
        const textCountElement = document.getElementById('text-count');

        // Toast UI 에디터의 내장 이벤트인 'change' 이벤트를 구독합니다.
        // 키보드를 칠 때마다 매번 실행됩니다.
        editor.on('change', function () {
            // 에디터 안에 입력된 마크다운 텍스트 원본을 가져옵니다.
            let text = editor.getMarkdown();

            // 공백(스페이스바, 엔터)을 글자 수에서 제외하려면 공백을 지워줍니다.
            // 정규식 /\s/g 는 모든 종류의 공백(띄어쓰기, 줄바꿈 등)을 의미합니다.
            // (만약 공백을 포함하고 싶다면 아래 replace 줄을 삭제하시면 됩니다.)
            let textWithoutSpace = text.replace(/\s/g, '');

            // 공백이 제거된 순수 글자의 길이를 구합니다.
            let length = textWithoutSpace.length;

            // 계산된 길이를 화면의 HTML 요소에 텍스트로 삽입하여 업데이트합니다.
            textCountElement.innerText = length;

            // UX 향상을 위해 글자 수가 0일 때는 회색, 1자 이상일 때는 초록색으로 바꿉니다.
            if (length > 0) {
                // 글자가 있으면 초록색 클래스(text-success)를 추가합니다.
                textCountElement.classList.add('text-success');
                // 회색 클래스는 제거합니다.
                textCountElement.classList.remove('text-secondary');
            } else {
                // 글자가 없으면 회색 클래스(text-secondary)를 추가합니다.
                textCountElement.classList.add('text-secondary');
                // 초록색 클래스는 제거합니다.
                textCountElement.classList.remove('text-success');
            }
        });
  • 결과


자동 임시저장

  • 특정 시간이 경과하면 자동으로 저장되는 기능

    • 프론트
      • setInterval이나 setTimeout 같은 타이머 함수를 사용
        • 주기적으로 현재 작성 중인 글의 데이터를 백엔드로 보내는 API 호출 로직 작성
        • 성공 시 "임시저장 되었습니다"라는 UI 피드백을 추가
      • 자바스크립트의 setInterval을 사용해 1~3분마다 현재 작성 중인 글을
        • 백엔드로 몰래(Background) 보냄
        • 이미 write.html에 존재하는 savePost(true) 로직을 재활용
    • 백엔드
      • 프론트엔드에서 보낸 데이터를 받아 DB에
        • '임시저장' 상태로 저장하거나 업데이트(UPSERT)하는 API 엔드포인트를 구축
  • 휴지통 30일 경과 자료 자동 삭제

    • 프론트
      • 작업 필요 없음
      • 휴지통 UI에 "30일 후 자동 삭제됩니다"라는 안내 문구 정도만 추가
      • trash_list.html 안내 문구에 "휴지통에 버린 글은 30일 뒤 자동 삭제됩니다." 추가
    • 백엔드
      • 백엔드 프레임워크(Django)에서 제공하는 스케줄러(Cron Job) 기능을 설정
      • 매일 자정 등에 한 번씩 DB를 조회하여 deleted_at (삭제된 날짜)가
        • 30일이 지난 데이터를 찾아 영구 삭제(Hard Delete)하는 로직을 작성
      • Django에서 제공하는 스케줄러(Celery Beat, APscheduler 등)를 설정하여
        • 매일 자정에 deleted_at이 30일 지난 데이터를 DB에서 hard delete 하는 배치구현

로직 고민

  • 1분마다 서버로 데이터를 보낸다면?

    • 서버 과부하 & DB 낭비
      • 글을 수정하지 않고 가만히 켜두기만 해도 1분마다 서버에 요청이 감
    • 중복 데이터 생성
      • POST 요청만 계속 보내면, 1시간 동안 글을 쓸 때 임시저장 글이 60개나 생성되는 대참사
    • 사용자 경험(UX) 저하
      • 저장이 완료될 때마다 화면에 alert("임시저장 되었습니다")가 뜨면 글쓰기 흐름이 끊김
  • 최선

    • 프론트엔드
      • 이전에 저장한 내용과 비교하여 '변경 사항이 있을 때만' 서버로 보냄
        • 처음에는 생성(POST)을 하지만 한 번 생성된 후에는
          • 해당 글의 ID를 기억해 두었다가 수정(PUT 또는 PATCH) 요청을 보내 덮어쓰기
        • 알림창 대신 화면 구석에 "마지막 저장: 14:32" 같은 작은 텍스트로 몰래 알려줌
    • 백엔드(Django)
      • 프론트엔드가 처음 임시저장(POST)을 했을 때 방금 만들어진 임시글의
        • id (Primary Key)를 응답(Response)으로 프론트엔드에 꼭 돌려줘야 함

프론트 코드

// [1] 전역 변수 선언: 자동 임시저장을 최적화하기 위한 변수들입니다.
        // 최초 임시저장 시 백엔드(Django)에서 발급받은 게시글의 고유 PK(id)를 저장할 변수입니다. (초기값은 비어있음)
        let currentTempPostId = null;

        // 내용이 변경되었을 때만 서버에 요청을 보내기 위해, 가장 마지막에 DB에 저장된 글의 내용을 기억하는 변수입니다.
        let lastSavedContent = "";

        // [2] 5(300,000밀리초)마다 반복해서 실행되는 자동 임시저장 타이머를 설정합니다.
        // 브라우저 백그라운드에서 비동기(async)로 조용히 실행됩니다.
        setInterval(async () => {
            // 사용자가 Toast UI 에디터에 작성한 마크다운 텍스트 원본을 가져옵니다.
            const currentContent = editor.getMarkdown();

            // 사용자가 제목 입력칸(id="title")에 입력한 값을 가져옵니다.
            const title = document.getElementById('title').value;

            // 만약 제목이나 내용이 아예 없거나,
            // 혹은 방금 전(5분 전)에 저장했던 내용과 현재 내용이 100% 똑같다면 (사용자가 글을 쓰지 않고 켜두기만 했다면)
            if (!title || !currentContent || currentContent === lastSavedContent) {
                // 서버에 쓸데없는 API 요청(트래픽)을 보내지 않고 함수를 즉시 종료(return)합니다.
                // -> 백엔드 부하를 막아주는 프론트엔드 최선의 방어 로직입니다!
                return;
            }

            // 서버로 보낼 데이터를 DRF Serializer가 기대하는 JSON 포맷(객체) 형태로 포장합니다.
            const payload = {
                title: title, // 현재 작성 중인 제목
                content: currentContent, // 현재 작성 중인 본문 내용
                is_temp: true, // 임시저장 여부를 판단하는 boolean 값 (무조건 true)
                visibility: 'PRIVATE' // 임시저장 글은 타인에게 노출되면 안 되므로 기본값을 비공개로 설정합니다.
            };

            try {
                // currentTempPostId가 비어있다면(null) 처음 저장하는 것이므로 POST 엔드포인트를,
                // 이미 PK가 있다면 덮어써야 하므로 PUT 엔드포인트(id 포함)를 삼항 연산자로 동적 할당합니다.
                const url = currentTempPostId ? `/api/v1/post/${currentTempPostId}/` : '/api/v1/post/';

                // HTTP 메서드 또한 PK 유무에 따라 PUT(수정/덮어쓰기) 또는 POST(신규 생성)로 분기합니다.
                const method = currentTempPostId ? 'PUT' : 'POST';

                // 결정된 URL과 메서드를 사용하여 Django 백엔드 서버에 비동기(fetch) 통신을 요청합니다.
                const response = await fetch(url, {
                    method: method, // 동적으로 결정된 HTTP 메서드 (POST or PUT)
                    headers: {
                        'Content-Type': 'application/json', // 전송하는 데이터가 JSON 형식임을 백엔드에 명시합니다.
                        'Authorization': 'Bearer ' + localStorage.getItem('access_token') // JWT 토큰을 헤더에 실어 인증을 통과합니다.
                    },
                    body: JSON.stringify(payload) // 자바스크립트 객체(payload)를 JSON 문자열로 변환하여 본문(body)에 담아 보냅니다.
                });

                // HTTP 상태 코드가 200번대(성공)로 떨어졌다면 실행됩니다.
                if (response.ok) {
                    // 백엔드가 보내준 응답 데이터(JSON)를 자바스크립트 객체로 파싱합니다.
                    const data = await response.json();

                    // 처음 POST 요청으로 임시글을 생성했다면, 백엔드가 돌려준 새로운 게시글의 PK(id)를 전역 변수에 저장합니다.
                    // -> 이제 다음 5분 뒤부터는 이 PK를 사용해 새로운 글을 생성하지 않고 기존 글을 PUT(수정)하게 됩니다!
                    if (!currentTempPostId && data.id) {
                        currentTempPostId = data.id;
                    }

                    // 다음 5분 뒤 비교 연산을 위해, 방금 성공적으로 DB에 저장한 내용을 '마지막 저장 내용' 변수에 덮어씌웁니다.
                    lastSavedContent = currentContent;

                    // 사용자에게 저장이 완료되었음을 시각적으로 알리기 위해 현재 시간을 구합니다.
                    const now = new Date();
                    //(Hours)와 분(Minutes)을 추출하여 두 자리 숫자(: 09:05)로 예쁘게 포맷팅합니다.
                    const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;

                    // 화면 우측 하단이나 상단에 몰래(?) 저장 시간을 텍스트로 업데이트해 줍니다.
                    // (경고창 alert를 띄우면 글을 쓰다가 흐름이 끊기므로 절대 금물입니다!)
                    const timeDisplay = document.getElementById('auto-save-time');
                    if (timeDisplay) {
                        timeDisplay.innerText = `마지막 자동 저장: ${timeString}`;
                    }
                }
            } catch (error) {
                // 사용자가 눈치채지 못하게 백그라운드에서 돌아가는 기능이므로, 에러가 나더라도 경고창 대신 개발자 도구 콘솔에만 기록합니다.
                console.error('자동 임시저장 실패:', error);
            }

        // 타이머의 간격을 설정
        // 1(60000) -> 5(300000)으로 변경했습니다. (만약 10분으로 하시려면 600000을 입력하시면 됩니다.)
        }, 300000);
  • html코드

    • "마지막 자동 저장: 14:32" 나옴

<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
                        <a href="/" class="btn btn-light rounded-pill px-4">취소</a>
                        <button type="button" id="tempSaveBtn" class="btn btn-outline-secondary rounded-pill px-4">
                            임시저장
                        </button>
                        <button type="submit" class="btn btn-primary rounded-pill px-5 shadow">발행하기</button>
                    </div>
                </form>
                
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
                        
                        <span id="auto-save-time" class="text-muted small fw-bold ms-2"></span>

                        <div class="d-flex gap-2">
                            <a href="/" class="btn btn-light rounded-pill px-4">취소</a>
                            <button type="button" id="tempSaveBtn" class="btn btn-outline-secondary rounded-pill px-4">
                                임시저장
                            </button>
                            <button type="submit" class="btn btn-primary rounded-pill px-5 shadow">발행하기</button>
                        </div>
                        
                    </div>
                </form>

수정(edit.html)

  • 임시저장 상태인 글(is_temp=True)
    • write.html처럼 5분마다 서버에 당당하게 PUT 요청을 보내어 갱신
  • 이미 발행된 글(is_temp=False)
    • 서버(DB)를 건드리지 않고, 브라우저의 로컬 스토리지(Local Storage)에 조용히 백업
    • 만약 브라우저가 강제 종료되더라도 내용이 날아가지 않도록 하는 최선의 프론트엔드 방어 로직
  • HTML

<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
                        <button type="button" onclick="history.back()" class="btn btn-light rounded-pill px-4">취소</button>
                        <button type="submit" class="btn btn-primary rounded-pill px-5 shadow">수정 완료</button>
                    </div>
                </form>
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
                        
                        <span id="auto-save-time" class="text-muted small fw-bold ms-2"></span>

                        <div class="d-flex gap-2">
                            <button type="button" onclick="history.back()" class="btn btn-light rounded-pill px-4">취소</button>
                            <button type="submit" class="btn btn-primary rounded-pill px-5 shadow">수정 완료</button>
                        </div>
                    </div>
                </form>
  • js

//  기존 데이터 불러오는 곳 수정

// 🌟 1. 자동 저장을 제어할 전역 변수 2개를 추가합니다.
    let isOriginalTemp = false; // 이 글이 원래 임시저장 글이었는지 확인하는 변수
    let lastSavedContent = "";  // 변경사항 비교를 위한 변수

    document.addEventListener("DOMContentLoaded", async () => {
        // ... (editor 세팅 코드 생략) ...

        // 2. 기존 게시글 데이터 불러오기 로직 (여기 안쪽을 수정합니다)
        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('visibility').value = data.visibility;
                // ... 생략 ...
                
                editor.setMarkdown(data.content);

                // 🌟 2. 백엔드에서 받아온 데이터로 전역 변수를 세팅합니다.
                isOriginalTemp = data.is_temp === true;
                lastSavedContent = data.content; 
            }
        } catch (e) {
            console.error(e);
        }
———————————————————————————————————————————————————————————————————————————————
// 타이머 로직 맨 밑에 추가

        setInterval(async () => {
            const currentContent = editor.getMarkdown();
            const title = document.getElementById('title').value;

            // 내용이 비어있거나 변경사항이 없으면 즉시 종료
            if (!title || !currentContent || currentContent === lastSavedContent) return;

            const timeDisplay = document.getElementById('auto-save-time');
            const now = new Date();
            const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;

            // 💡 케이스 A: 이미 발행된 일반 글인 경우 (서버 전송 X, 로컬 백업 O)
            if (!isOriginalTemp) {
                // 브라우저 로컬 스토리지에 글 번호와 함께 안전하게 백업해둡니다.
                localStorage.setItem(`backup_post_${currentPostId}`, currentContent);
                
                if(timeDisplay) {
                    timeDisplay.innerHTML = `<i class="bi bi-shield-check text-success"></i> 브라우저 안전 백업: ${timeString}`;
                }
                lastSavedContent = currentContent;
                return; // 여기서 함수를 끝내어 서버로 요청이 가지 않게 막습니다!
            }

            // 💡 케이스 B: 아직 발행되지 않은 임시저장 글인 경우 (서버 전송 O)
            const payload = {
                title: title,
                content: currentContent,
                is_temp: true, 
                visibility: document.getElementById('visibility').value,
                tags: document.getElementById('tags').value.split(',').map(tag => tag.trim()).filter(tag => tag !== ""),
            };

            try {
                // 이미 존재하는 임시글이므로 무조건 PUT으로 덮어씁니다.
                const response = await fetch(`/api/v1/post/${currentPostId}/`, {
                    method: 'PUT',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': 'Bearer ' + token
                    },
                    body: JSON.stringify(payload)
                });

                if (response.ok) {
                    lastSavedContent = currentContent;
                    if(timeDisplay) {
                        timeDisplay.innerText = `마지막 자동 저장: ${timeString}`;
                    }
                }
            } catch (error) {
                console.error('자동 임시저장(수정) 실패:', error);
            }
        }, 300000); // 5(300000ms) 주기

    }); // 🚨 DOMContentLoaded 닫는 괄호 (이 위쪽에 붙여넣으세요!)

    // 기존의 '수정 완료' submit 이벤트 코드는 그대로 유지합니다.
    document.getElementById('editForm').addEventListener('submit', async (e) => { ...

수정 로직 추가

  • "이전에 쓰다 만 백업본이 있습니다. 불러오시겠습니까?" 하고 물어보는 기능

// 🌟 [추가된 최적화 코드] 로컬 백업 복구 로직
                // ========================================================
                // 브라우저 로컬 스토리지에 이 글의 백업본이 남아있는지 확인합니다.
                const backupContent = localStorage.getItem(`backup_post_${currentPostId}`);
                
                // 만약 백업본이 존재하고, 그 백업본이 방금 서버에서 불러온 원본 내용과 다를 경우에만 물어봅니다.
                // (같으면 굳이 물어볼 필요가 없으니까요!)
                if (backupContent && backupContent !== data.content) {
                    // confirm 창을 띄워 사용자의 의사를 물어봅니다. (확인=true, 취소=false 반환)
                    const wantsToRestore = confirm("이전에 수정하다 만 백업본이 있습니다. 불러오시겠습니까?\n(취소 시 기존 백업본은 영구 삭제됩니다.)");
                    
                    if (wantsToRestore) {
                        // [확인]을 누르면, 에디터의 내용을 로컬 백업본으로 싹 덮어씌웁니다.
                        editor.setMarkdown(backupContent);
                        // 다음 5분 뒤 비교를 위해, 현재 내용도 백업본으로 갱신해 줍니다.
                        lastSavedContent = backupContent;
                    } else {
                        // [취소]를 누르면, 백업본이 필요 없다는 뜻이므로 로컬 스토리지에서 깨끗하게 지워줍니다.
                        localStorage.removeItem(`backup_post_${currentPostId}`);
                    }
                }
  • 수정 완료 시 백업본 지우기

if (res.ok) {
                alert("성공적으로 수정되었습니다.");
                window.location.href = `/api/v1/post/${currentPostId}/page/`;
            } else {
                alert("수정에 실패했습니다.");
            }
——————————————————————————————————————[비교]—————————————————————————————————————————
if (res.ok) {
                // 서버에 수정 완료(발행)가 무사히 끝났으므로, 더 이상 쓸모없는 로컬 백업을 깔끔하게 지워줍니다.
                localStorage.removeItem(`backup_post_${currentPostId}`);

                alert("성공적으로 수정되었습니다.");
                window.location.href = `/api/v1/post/${currentPostId}/page/`;
            } else {
                alert("수정에 실패했습니다.");
            }

결과

  • 작성 임시저장

  • 수정 임시저장


마이페이지 구현

  • 등급 / 작성글 개수 / 작성 글 목록(공개한 글만) / 프로필 이미지 포함
    • 프로필 이미지
      • user/models/user.py의 User 모델에 profile_img 필드가 존재
    • 작성 글 목록 (공개글)
      • post/models/post.py의 Post 모델에
        • user 외래키와 visibility 필드(PUBLIC, PRIVATE)가 존재, 이를 필터링
    • 작성글 개수
      • Post 모델을 참조하여 해당 유저가 작성한 전체 글(혹은 공개 글)의 개수를
      • DB 쿼리(Count)로 계산
    • 등급 (Tier/Level)
      • 현재 User 모델에 등급과 관련된 필드가 없음, 이를 구현하기 위한 추가 작업

고민

  • 등급관련 기능을 전부 프론트에서 처리해도 괜찮은가?

      1. 보안 및 조작 가능성 (가장 중요)
      • 프론트엔드(JavaScript, HTML) 코드는 사용자의 브라우저에서 실행
      • 즉, 사용자가 브라우저 개발자 도구(F12)를 열어서 포스트 개수 변수나
        • 등급 텍스트를 마음대로 조작 가능
      • 단순 장식일 때
        • 유저가 자기 화면에서만 '마스터'라고 조작해서 보는 거라 큰 타격은 없음
      • 기능이 연결될 때
        • 만약 "특정 등급 이상만 비밀 게시판 접근 가능" 같은 기능이 추가된다면
          • 프론트엔드 로직만으로는 절대 막을 수 없는 심각한 보안 취약점이 됨
      1. 로직의 파편화 (유지보수 어려움)
      • 지금은 웹 브라우저 화면(마이페이지) 하나만 있지만, 나중에 기능이 확장되면 문제
  • 해결방법

    • user 모델에 tier 컬럼을 추가?
      • 오히려 DB낭비가 될 수 있음
    • 백엔드 코드(View나 Model)에서 포스트 개수를 기반으로
      • 등급 글자("새싹", "마스터" 등)만 계산해서 프론트엔드로 딱 넘겨주는 방식 선택

--

ai

  • 코드에 주석을 달아주는 ai기능

profile
안녕하세요.

0개의 댓글