2026/03/11 Blog - 21

김기훈·2026년 3월 11일

TIL

목록 보기
161/194
post-thumbnail

코딩테스트(2292)


오늘 할 일

ai 본문 수정

토큰 상향 조정

  • 토큰낭비를 방지하기 위해서 max_output_tokens=1000 세팅해놓았는데 생각보다 너무 짧음
    • 고민

        1. 토큰제한을 좀 널럴하게 하여서 ai실행횟수를 줄이는게 좋을까
        1. 제한을 유지하고 여러번 ai를 여러번 반복하는게 좋을까
    • 1번으로 선택

      • 문맥과 문체의 일관성 유지
        • 글을 여러 번 쪼개서 변환을 요청하면
          • 앞서 생성된 글의 흐름이나 감정선, 문체가 도중에 끊기거나 달라질 위험이 큼
          • 한 번의 호출로 끝내는 것이 글의 자연스러움을 유지하는 데 훨씬 유리함
      • 사용자 경험(UX) 및 속도
        • API를 여러 번 호출하면 네트워크 지연 시간(Latency)이 누적되어
          • 사용자가 변환된 글을 받아보기까지 대기하는 시간이 너무 길어짐
      • 입력 토큰 비용 절감
        • 여러 번 호출하게 되면 모델에게
          • 시스템 프롬프트(예: "당신은 10년 차 전문 IT 블로거입니다...")와
          • 앞부분의 문맥을 매번 다시 전달해야 함
        • 이로 인해 오히려 입력 토큰 비용이 중복으로 발생하여 전체 비용이 증가함
    • 결론

      • gemini-flash-latest 모델은 최대 8192개의 출력 토큰을 지원하므로
        • max_output_tokens 를 2000~4000으로 수정
        response = model.generate_content(
            text,  # 유저가 작성한 텍스트
            # 결과물 생성을 위한 세부 옵션을 설정합니다.
            generation_config=genai.types.GenerationConfig(
                temperature=0.7,
                # 결과물이 너무 길어져서 토큰(비용)을 과다하게 쓰는 것을 방지
                max_output_tokens=1000,
            ),
        )
——————————————————————————————————————[비교]—————————————————————————————————————————
        response = model.generate_content(
            text,
            generation_config=genai.types.GenerationConfig(
                temperature=0.7,
                # 상향 조정
                max_output_tokens=2500,
            ),
        )

변환 대기시간 고려

  • 토큰 상향조정으로 인한 대기시간 증가

    • 기존에도 좀 길다 싶었는데 토큰 상향으로 인해서 좀더 길어짐
  • 고민

    • 비동기처리
      • 사용자가 결과를 바로 봐야하는 블로그 특성상
        • Celery 같은 백그라운드 비동기 작업 큐를 쓰면 '작업 완료 후 알림'을 받아야 하므로
        • 오히려 더 복잡해질 수 있음
    • 스트리밍 응답
      • 글자가 한 글자씩 타닥타닥 쳐지는 것
      • 완성된 전체 텍스트를 기다렸다가 한 번에 받는 대신
        • 생성되는 즉시 조각(Chunk) 단위로 클라이언트(프론트엔드)에 쏴주는 방식

스트리밍 응답

  • service

    • Gemini API 호출 시 stream=True 옵션을 주고
    • 결과를 yield로 반환하는 제너레이터(Generator) 함수를 만듬
		# 모델에게 실제 변환할 사용자의 텍스트를 전달하고 결과(응답)를 생성하도록 요청
        response = model.generate_content(
            text,  # 유저가 작성한 텍스트
            # 결과물 생성을 위한 세부 옵션을 설정
            generation_config=genai.types.GenerationConfig(
                # 0.7은 너무 뻔하지도, 너무 엉뚱하지도 않은 적절하고 자연스러운 문장을 만듬
                temperature=0.7,
                # 토큰(비용) 상향 조정
                max_output_tokens=2500,
            ),
        )
        
        # Gemini의 응답 객체에서 생성된 텍스트 문자열만 뽑아서 반환
        return response.text
        
——————————————————————————————————————[비교]—————————————————————————————————————————
		# 모델에게 텍스트 생성을 요청하되, 스트리밍 모드(stream=True)를 활성화
        response = model.generate_content(
            # 변환할 원본 텍스트를 첫 번째 인자로 전달
            text,
            # 스트리밍 옵션을 켜서, 생성 즉시 응답을 받도록 설정
            stream=True,
            # 결과물 생성을 위한 세부 설정값을 전달
            generation_config=genai.types.GenerationConfig(
                # 자연스럽고 적절한 변환을 위해 창의성 정도(온도)를 0.7로 설정
                temperature=0.7,
                # 블로그 글이 잘리지 않도록 넉넉하게 토큰 제한을 2500으로 제한 
                max_output_tokens=2500,
            ),
        )
        
        # 스트리밍 응답 객체(response)에서 생성되는 텍스트 조각(chunk)을 순회
        for chunk in response:
            # 조각 안에 텍스트 데이터가 정상적으로 존재하는지 확인
            if chunk.text:
                # 텍스트 조각을 반환(yield)하여, 모이지 않고 즉시 밖으로 내보냄
                yield chunk.text
  • views

    • 장고(Django)에서 스트리밍 데이터를 클라이언트에 전달하려면
      • StreamingHttpResponse를 사용해야 함.
      • from django.http import StreamingHttpResponse
        try:
            # Gemini 서비스 함수를 호출하여 결과를 받아옴
            converted_text = convert_text_tone(text=text, tone=tone)

            # 에러 없이 무사히 변환되었다면, 200 성공 코드와 함께 변환된 텍스트를 돌려줌
            return Response(
                {"converted_text": converted_text}, status=status.HTTP_200_OK
            )
            
——————————————————————————————————————[비교]—————————————————————————————————————————
        try:
            # Gemini 서비스 함수를 호출하여 결과를 받아옴
            converted_text = convert_text_tone(text=text, tone=tone)

            # 생성된 제너레이터를 StreamingHttpResponse에 담아 클라이언트에 반환함
            # content_type을 'text/plain' 혹은 'text/event-stream'으로 주어 텍스트 조각임을 알림
            return StreamingHttpResponse(
                # 첫 번째 인자로 텍스트 조각들을 지속적으로 뿜어내는 제너레이터를 넣음
                converted_text,
                # 클라이언트가 데이터를 일반 텍스트 스트림으로 인식하도록 타입을 지정
                content_type='text/plain'
            )
  • 프론트 구현

    • 현재
      • templates/post/write.html
        • const data = await response.json(); 방식을 사용중
          • 이 방식은 백엔드에서 모든 데이터(전체 텍스트)가 완성되어
            • 한 번에 JSON 형태로 넘어올 때까지 꼼짝 않고 기다리는 방식
          • 백엔드에서 조각(Chunk) 단위로 쪼개서 스트리밍(text/plain)으로 보내주더라도
            • 현재 프론트엔드 코드는 이를 이해하지 못하고 에러를 내거나 끝날 때까지 기다리게 됨
        • 자바스크립트의 표준 스트림 처리 API인 ReadableStreamgetReader()를 사용하여
          • 넘어오는 조각을 실시간으로 화면에 찍어주도록 변경해야함
        try {
            // ai 앱의 API 엔드포인트로 POST 요청을 보냄
            const response = await fetch('/api/ai/tone-convert/', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    // AI 변환은 로그인한 유저만 쓸 수 있도록 토큰을 헤더에 담아줌
                    'Authorization': 'Bearer ' + localStorage.getItem('access_token')
                },
                // 사용자의 글과 선택한 문체를 JSON 형태로 포장하여 보냅니다.
                body: JSON.stringify({text: text, tone: tone})
            });

            const data = await response.json();
            
——————————————————————————————————————[비교]—————————————————————————————————————————
            // HTTP 응답 본문(body)에서 데이터를 조각 단위로 읽어올 수 있는 Reader(리더) 객체를 가져옵니다.
            const reader = response.body.getReader();
            // 서버에서 넘어오는 데이터는 바이트(Byte) 단위이므로, 이를 사람이 읽을 수 있는 텍스트(UTF-8)로 변환해줄 디코더를 생성합니다.
            const decoder = new TextDecoder('utf-8');

            // 서버와 연결이 성공했고 첫 데이터가 넘어오기 시작할 준비가 되었으므로 로딩 스피너를 화면에서 숨깁니다.
            document.getElementById('ai-loading').classList.add('d-none');

            // 데이터가 모두 넘어올 때까지 무한 반복하며 스트림을 읽습니다.
            while (true) {
                // Reader를 통해 버퍼에 도착한 데이터 조각(chunk)을 읽어옵니다. (done은 완료 여부, value는 바이트 배열)
                const {done, value} = await reader.read();

                // 만약 done이 true라면(서버가 모든 스트리밍 전송을 마쳤다면)
                if (done) {
                    // 무한 반복문을 탈출하여 읽기를 종료합니다.
                    break;
                }

                // 읽어온 바이트 데이터(value)를 디코더를 사용해 문자열로 변환합니다. (stream: true 옵션으로 조각이 깨지지 않게 보정)
                const chunkText = decoder.decode(value, {stream: true});

                // 디코딩된 문자열(한 글자 혹은 단어)을 결과창 텍스트에 계속해서 이어 붙여줍니다. (타자 치는 효과 발생!)
                resultTextArea.value += chunkText;

                // 글이 길어져서 텍스트 영역을 넘어갈 경우, 최신 글자가 보이도록 스크롤을 항상 맨 아래로 내려줍니다.
                resultTextArea.scrollTop = resultTextArea.scrollHeight;
            }

            // 스트리밍이 무사히 끝났으므로 결과창의 글을 [에디터에 삽입] 할 수 있도록 버튼을 활성화시킵니다.
            document.getElementById('btn-ai-apply').disabled = false;

문제

  • 원래 시작하면 바로 첫글자가 찍히면서 보여야하는데 7초 기다린후에 찍힘

    • 백엔드에서 조각(Chunk)을 만들긴 했지만
      • 장고(Django) 서버나 브라우저가 "어? 조각이 너무 작네?
      • 다 모일 때까지 기다렸다가 한 번에 줘야지" 하고 버퍼링(Buffering, 모아두기)을 걸어버린 것
    • 버퍼링을 강제로 해제하기 필요
  • view 수정 (버퍼링 방지 헤더 추가)

    • 장고 미들웨어나 Nginx 같은 웹 서버
    • 그리고 브라우저가 데이터를 모아두지 못하도록 강력한 헤더(Header)를 추가
        try:
            # Gemini 서비스 함수를 호출하여 결과를 받아옴
            converted_text = convert_text_tone(text=text, tone=tone)

            # 생성된 제너레이터를 StreamingHttpResponse에 담아 클라이언트에 반환함
            # content_type을 'text/plain' 혹은 'text/event-stream'으로 주어 텍스트 조각임을 알림
            return StreamingHttpResponse(
                # 첫 번째 인자로 텍스트 조각들을 지속적으로 뿜어내는 제너레이터를 넣음
                converted_text,
                # 클라이언트가 데이터를 일반 텍스트 스트림으로 인식하도록 타입을 지정
                content_type='text/plain'
            )

——————————————————————————————————————[비교]—————————————————————————————————————————
        try:
            # Gemini 서비스 함수를 호출하여 결과를 받아옴
            converted_text = convert_text_tone(text=text, tone=tone)

            # 생성된 제너레이터를 StreamingHttpResponse에 담아 클라이언트에 반환함
            # content_type을 'text/plain' 혹은 'text/event-stream'으로 주어 텍스트 조각임을 알림
            response = StreamingHttpResponse(
                # 첫 번째 인자로 텍스트 조각들을 지속적으로 뿜어내는 제너레이터를 넣음
                converted_text,
                # content_type을 'text/event-stream'으로 변경하여 브라우저의 버퍼링을 원천 차단
                content_type='text/event-stream'
            )

            # 브라우저나 중간 프록시 서버가 이 응답을 캐싱(저장)하지 못하게 막음
            response['Cache-Control'] = 'no-cache'
            # Nginx 같은 웹 서버를 사용할 경우, 버퍼링을 하지 말고 즉시 클라이언트로 쏘도록 지시
            response['X-Accel-Buffering'] = 'no'

            return response
  • service 수정 (Byte 단위 강제 전송)

    • 파이썬 내부나 장고 WSGI 서버에서 텍스트(String)를 인코딩하느라 모아두는 현상을 방지
    • 애초에 텍스트를 바이트(Byte)로 변환
		# 스트리밍 응답 객체(response)에서 생성되는 텍스트 조각(chunk)을 순회
        for chunk in response:
            # 조각 안에 텍스트 데이터가 정상적으로 존재하는지 확인
            if chunk.text:
                # 텍스트 조각을 반환(yield)하여, 모이지 않고 즉시 밖으로 내보냄
                yield chunk.text
                
——————————————————————————————————————[비교]—————————————————————————————————————————
        # 스트리밍 응답 객체(response)에서 생성되는 텍스트 조각(chunk)을 순회
        for chunk in response:
            # 조각 안에 텍스트 데이터가 정상적으로 존재하는지 확인
            if chunk.text:
                # 일반 문자열(String)이 아닌 UTF-8 바이트(Bytes)로 인코딩하여 반환
                # 장고가 내부적으로 문자를 처리하며 대기하는 시간을 없애줌
                yield chunk.text.encode('utf-8')

결과

  • 변경 전

    • 변환하기를 누르면 9~10초정도의 대기시간이 지나고 전체 변환 내용이 출력됨
  • 변경 후

    • 변환하기를 누르면 7초정도 대기하고 스트리밍이 진행됨
    • 원래 누르자마자 바로 스트리밍이 진행되어야 하지만
    • 제미나이(Gemini) AI가 사용자의 글을 읽고 문맥을 파악하여
      • '첫 번째 글자'를 생각해 내는 순수한 연산 시간(TTFT)이 7초인것 같다.
    • 그래도 최대 토큰수를 1000 -> 2500으로 향상 했음에도 오히려 처리시간이 줄어들었다.

이미지 처리

  • 썸네일 뿐만 아니라 내용에도 이미지가 들어가도록 변경
    • Toast UI Editor에 있는 기능을 이용해서 이미지를 업로드하면
      • 아래같은 주소가 엄청 길게나옴 100줄은 그냥 넘는거 같음
![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAt4AAAIqCAYAAAATl01QAAAMTGlDQ1BJ
Q0MgUHJvZmlsZQAASImVVwdYU8kWnltSSQgQiICU0JsgIiWAlBBaAOlFEJWQBAglxoSgYkeXXcG1iwh
WdBVEsQMiNuwri2J3LYsFFWVdXBe78iYE0GVf+d7JN/f++efcf845d+69MwAwOg
							... 

본문 이미지 S3 Presigned URL 전환

  • 이유

    • 백엔드 서버 부하 감소 (가장 중요한 이유)
      • Toast UI Editor의 기본 기능(또는 일반적인 폼 업로드)을 사용하면
        • 이미지가 브라우저 -> 백엔드 서버 -> S3의 경로를 거치게 됨
      • 블로그 특성상 고화질 이미지가 많이 첨부될 수 있는데
        • 이 경우 백엔드 서버의 네트워크 대역폭과 메모리를 크게 소모
      • Presigned URL을 사용하면 클라이언트(브라우저)에서 S3로 이미지를 직접 쏘기 때문에
        • 백엔드는 가벼운 URL 발급 역할만 하여 서버 자원을 획기적으로 아낄 수 있음
    • 아키텍처의 일관성 유지
      • 썸네일은 Presigned URL로 직행하고 본문 이미지는 백엔드를 거친다면
        • 두 개의 서로 다른 이미지 업로드 로직(S3 직접 업로드 vs Multipart File 처리 로직)
        • 을 백엔드에서 모두 유지보수해야 함
        • 둘 다 Presigned URL 방식으로 통일하면 백엔드 코드가 훨씬 깔끔
    • 업로드 속도 향상
      • 서버를 한 번 거치는 것보다
      • 클라이언트에서 S3로 직접 업로드하는 것이 네트워크 지연(Latency) 측면에서 훨씬 빠름
  • 백엔드는 수정할거 없고 프론트를 수정하면 됨

// Toast UI Editor 인스턴스를 생성할 때 설정하는 옵션 객체입니다.
editor = new toastui.Editor({
    // 에디터가 렌더링될 HTML 요소의 선택자입니다.
    el: document.querySelector('#editor'),
    // 에디터의 높이를 설정합니다.
    height: '700px',
    // 에디터의 초기 입력 모드를 마크다운으로 설정합니다.
    initialEditType: 'markdown',
    // 마크다운 작성 시 오른쪽에 미리보기를 띄워주는 수직 분할 모드를 사용합니다.
    previewStyle: 'vertical',
    // (write.html의 경우) 아무것도 입력하지 않았을 때 보여줄 안내 문구입니다.
    placeholder: '자유롭게 이야기를 풀어보세요...',
    
    // 새롭게 추가되는 이미지 가로채기 훅(hook) 설정
    hooks: {
        // 이미지가 에디터에 추가될 때(드래그 앤 드롭 또는 버튼 클릭) 가로채어 실행되는 함수입니다.
        addImageBlobHook: async (blob, callback) => {
            // 로컬 스토리지에서 사용자의 로그인 인증 토큰(JWT)을 꺼내옵니다.
            const token = localStorage.getItem('access_token');
            
            // 토큰이 없다면 (비정상적인 접근이거나 로그아웃 상태라면)
            if (!token) {
                // 사용자에게 경고창을 띄워 알립니다.
                alert("이미지를 업로드하려면 로그인이 필요합니다.");
                // 함수 실행을 즉시 중단합니다.
                return;
            }

            // 서버 통신 중 발생할 수 있는 에러를 처리하기 위해 try-catch 블록을 엽니다.
            try {
                // 1. 우리 백엔드 서버(PresignedUrlAPIView)에 S3 업로드용 임시 URL을 요청합니다.
                // 파일 이름에 특수문자나 한글이 있을 경우를 대비해 encodeURIComponent로 안전하게 인코딩하여 쿼리스트링으로 넘깁니다.
                const urlResponse = await fetch(`/api/v1/post/image/presigned-url/?filename=${encodeURIComponent(blob.name)}`, {
                    // 인증된 사용자만 API를 쓸 수 있으므로 헤더에 Bearer 토큰을 담아줍니다.
                    headers: { 'Authorization': 'Bearer ' + token }
                });
                
                // HTTP 응답 상태가 200번대(성공)가 아니라면 에러를 발생시켜 catch 블록으로 보냅니다.
                if (!urlResponse.ok) throw new Error('백엔드 URL 발급 실패');
                
                // 백엔드에서 정상적으로 응답해준 데이터(presigned_url, image_url)를 JSON 객체로 변환합니다.
                const urlData = await urlResponse.json();
                
                // 2. 발급받은 Presigned URL(urlData.presigned_url)을 타겟으로 하여 S3에 이미지를 직접 업로드합니다.
                const s3Response = await fetch(urlData.presigned_url, {
                    // S3 Presigned URL을 통한 파일 업로드는 PUT HTTP 메서드를 사용해야 합니다.
                    method: 'PUT',
                    // S3가 이 파일이 이미지임을 올바르게 인식하도록 실제 파일의 MIME 타입(: image/png)을 헤더에 명시합니다.
                    headers: { 'Content-Type': blob.type },
                    // 실제 이미지 파일 데이터(Blob 객체)를 요청 본문(body)에 담아 쏘아 올립니다.
                    body: blob
                });
                
                // S3 업로드에 실패했을 경우 (CORS 에러나 권한 만료 등) 에러를 발생시킵니다.
                if (!s3Response.ok) throw new Error('S3 직접 업로드 실패');
                
                // 3. 업로드가 완벽하게 성공하면, 백엔드가 미리 만들어준 '최종 접속 이미지 주소(urlData.image_url)'를 추출합니다.
                // Toast UI Editor가 제공하는 콜백 함수를 호출하여 에디터 본문에 이미지를 마크다운 문법으로 쏙 집어넣습니다.
                // 첫 번째 인자는 이미지 주소, 두 번째 인자는 대체 텍스트(alt 속성)로 사용됩니다.
                callback(urlData.image_url, blob.name);
                
            } catch (error) {
                // 백엔드 URL 발급이나 S3 업로드 과정에서 에러가 터지면 개발자 도구 콘솔에 빨간 글씨로 기록합니다.
                console.error('본문 이미지 업로드 오류:', error);
                // 일반 사용자도 알 수 있도록 친절하게 알림창을 띄워줍니다.
                alert('이미지 업로드에 실패했습니다. 네트워크 상태를 확인해주세요.');
            }
        }
    }
});


코딩정원 리펙토링

  • 구성은 좋음 / 디자인이 뭔가 조잡한 느낌

개선점

  • 투박한 SVG 제거
    • 대신 시각적으로 깔끔한 이모지(🌱, 🌳 등)나 고해상도 아이콘을 크게 배치하고
    • 레벨업 애니메이션(CSS Bouncing)을 줌
  • 시각적 프로그레스 바(Progress Bar) 추가
    • 텍스트로만 '몇 개 남음'을 보여주는 것보다
    • 경험치 바(EXP Bar)처럼 시각적으로 채워지는 UI가 좋음
  • 잔디밭(Heatmap) 렌더링 최적화
    • 현재 반복문 안에서 document.createElement를 365번 호출하여 DOM에 매번 붙임
    • 이는 성능에 좋지 않으므로 한 번에 그려서 붙이도록 최적화 필요

디자인 변경

  • 기존 SVG

<div id="grade-card" class="card border-0 shadow-lg rounded-4 overflow-hidden"
                 style="background: white; display: none; transition: transform 0.3s ease;">
                <div class="card-header bg-transparent border-0 pt-4 px-4 pb-0">
                    <h5 class="fw-bold mb-0" style="color: var(--brand-dark-green);">나의 코딩 정원</h5>
                </div>

                <div class="card-body p-4 text-center">

                    <div id="svg-plant-container" class="level-0 mb-3"
                         style="height: 140px; display: flex; align-items: flex-end; justify-content: center;">
                        <svg viewBox="0 0 100 120" style="width: 100px; height: 120px; overflow: visible;">
                            <ellipse cx="50" cy="115" rx="7" ry="5" fill="#8d6e63"/>

                            <path class="plant-path stem" d="M50,115 Q40,60 50,15" fill="none"
                                  stroke="var(--brand-accent-green)" stroke-width="5" stroke-linecap="round"/>
                            <path class="plant-path branch branch-1" d="M47,70 Q20,60 15,40" fill="none"
                                  stroke="var(--brand-accent-green)" stroke-width="3" stroke-linecap="round"/>
                            <path class="plant-path branch branch-2" d="M48,40 Q75,35 85,25" fill="none"
                                  stroke="var(--brand-accent-green)" stroke-width="3" stroke-linecap="round"/>

                            <circle class="plant-leaf leaf-group-1" cx="50" cy="10" r="6"
                                    fill="var(--brand-accent-green)" style="transform-origin: 50px 10px;"/>
                            <circle class="plant-leaf leaf-group-1" cx="88" cy="23" r="5"
                                    fill="var(--brand-accent-green)" style="transform-origin: 88px 23px;"/>
                            <circle class="plant-leaf leaf-group-2" cx="12" cy="38" r="5"
                                    fill="var(--brand-accent-green)" style="transform-origin: 12px 38px;"/>
                            <circle class="plant-leaf leaf-group-2" cx="30" cy="62" r="4"
                                    fill="var(--brand-accent-green)" style="transform-origin: 30px 62px;"/>
                            <circle class="plant-leaf leaf-group-2" cx="65" cy="45" r="4"
                                    fill="var(--brand-accent-green)" style="transform-origin: 65px 45px;"/>
                        </svg>
                    </div>

                    <h4 id="grade-label" class="fw-bold text-success mb-1"></h4>
                    <p id="grade-message" class="text-muted small mb-2"></p>

                    <div class="mb-2">
                        <span id="next-grade-info"
                              class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25 px-3 py-2 rounded-pill fw-medium"
                              style="display: none; font-size: 0.75rem;">
                        </span>
                    </div>

                    <div class="mt-4 pt-3 border-top text-start">
                        <div class="d-flex justify-content-between align-items-end mb-2">
                            <span class="fw-bold text-dark" style="font-size: 0.85rem;">1년 간의 발자취 (365일)</span>
                            <span class="text-muted" style="font-size: 0.7rem;">초록색 = 글 작성일</span>
                        </div>

                        <div class="heatmap-scroll-wrapper">
                            <div id="heatmap-grid" class="d-grid gap-1"
                                 style="grid-template-rows: repeat(7, 1fr); grid-auto-flow: column;">
                            </div>
                        </div>
                    </div>

                </div>
            </div>
  • <style>

/* 1. SVG 식물 애니메이션 CSS 유지 */
    .plant-path {
        stroke-dasharray: 150;
        stroke-dashoffset: 150;
        transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
    }

    .plant-leaf {
        opacity: 0;
        transform: scale(0);
        transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.8s;
    }

    .level-1 .stem {
        stroke-dashoffset: 90;
    }

    .level-2 .stem {
        stroke-dashoffset: 0;
    }

    .level-3 .stem, .level-3 .branch-1 {
        stroke-dashoffset: 0;
    }

    .level-4 .stem, .level-4 .branch-1, .level-4 .branch-2 {
        stroke-dashoffset: 0;
    }

    .level-5 .stem, .level-5 .branch-1, .level-5 .branch-2 {
        stroke-dashoffset: 0;
    }

    .level-5 .leaf-group-1 {
        opacity: 1;
        transform: scale(1);
    }

    .level-6 .stem, .level-6 .branch-1, .level-6 .branch-2 {
        stroke-dashoffset: 0;
    }

    .level-6 .leaf-group-1, .level-6 .leaf-group-2 {
        opacity: 1;
        transform: scale(1);
    }
  • JavaScript

const GRADE_SETTINGS = [
        {min: 50, label: '울창한 숲', msg: '완벽하게 피어난 아름다운 정원입니다.', level: 6},
        {min: 40, label: '풍성한 나무', msg: '잎사귀가 아주 풍성하게 열렸어요!', level: 5},
        {min: 30, label: '커다란 나무', msg: '가지가 무성한 듬직한 나무가 자랐어요.', level: 4},
        {min: 20, label: '튼튼한 묘목', msg: '새로운 가지가 힘차게 뻗어나갑니다.', level: 3},
        {min: 10, label: '어린 묘목', msg: '어엿한 줄기가 형태를 잡았어요.', level: 2},
        {min: 5, label: '파릇한 새싹', msg: '흙을 뚫고 예쁜 줄기가 올라왔어요!', level: 1},
        {min: 0, label: '희망찬 씨앗', msg: '글을 작성해서 씨앗을 틔워주세요.', level: 0},
    ];

    async function fetchMyGardenStats() {
        const token = localStorage.getItem('access_token');
        if (!token) return;

        const gradeCard = document.getElementById('grade-card');
        const svgContainer = document.getElementById('svg-plant-container');
        const label = document.getElementById('grade-label');
        const message = document.getElementById('grade-message');
        const nextInfo = document.getElementById('next-grade-info');

        try {
            let allPosts = [];
            let totalCount = 0;
            let nextUrl = '/api/v1/post/my/';

            while (nextUrl) {
                const response = await fetch(nextUrl, {
                    headers: {'Authorization': 'Bearer ' + token}
                });
                const data = await response.json();

                if (response.ok) {
                    const posts = data.results || data;
                    allPosts = allPosts.concat(posts);
                    if (totalCount === 0) totalCount = data.count !== undefined ? data.count : posts.length;
                    nextUrl = data.next || null;
                } else {
                    break;
                }
            }

            const currentGrade = GRADE_SETTINGS.find(g => totalCount >= g.min);
            const nextGrade = [...GRADE_SETTINGS].reverse().find(g => g.min > totalCount);

            label.innerText = currentGrade.label;
            message.innerText = currentGrade.msg;

            if (nextGrade) {
                const remain = nextGrade.min - totalCount;
                nextInfo.innerText = `🚀 다음 '${nextGrade.label}'까지 ${remain}개 남음!`;
            } else {
                nextInfo.innerText = `🎉 최고 레벨 달성! 당신은 블로그 마스터!`;
            }
            nextInfo.style.display = 'inline-block';

            svgContainer.className = `mb-3 level-${currentGrade.level}`;
            gradeCard.style.display = 'block';

            renderHeatmap(allPosts);

        } catch (error) {
            console.error('Garden Stats Load Error:', error);
        }
    }

    function renderHeatmap(posts) {
        const heatmapGrid = document.getElementById('heatmap-grid');
        const scrollWrapper = document.querySelector('.heatmap-scroll-wrapper');
        heatmapGrid.innerHTML = '';

        const postDates = posts.map(p => {
            const d = new Date(p.created_at);
            return `${d.getFullYear()}-${d.getMonth()+1}-${d.getDate()}`;
        });

        const totalDays = 365;
        const today = new Date();

        for (let i = totalDays - 1; i >= 0; i--) {
            const targetDate = new Date(today);
            targetDate.setDate(today.getDate() - i);
            const dateString = `${targetDate.getFullYear()}-${targetDate.getMonth()+1}-${targetDate.getDate()}`;

            const isPosted = postDates.includes(dateString);
            const box = document.createElement('div');
            box.className = 'heatmap-box ' + (isPosted ? 'active' : '');

            const displayDate = targetDate.toLocaleDateString('ko-KR', {
                year: 'numeric',
                month: 'long',
                day: 'numeric'
            });
            box.title = isPosted ? `${displayDate} : 글을 작성했습니다 🌱` : `${displayDate} : 기록 없음`;

            heatmapGrid.appendChild(box);
        }

        setTimeout(() => {
            scrollWrapper.scrollLeft = scrollWrapper.scrollWidth;
        }, 100);
    }

잔디밭(Heatmap) 렌더링 최적화

  • 기존

    • 브라우저가 화면에 요소를 그리는 작업(DOM 조작)은 굉장히 무거운 작업
    • 이걸 365번이나 반복해서 화면에 붙이면 브라우저가 365번 화면을 다시 계산해야 해서
      • 로딩 시 버벅거림(렌더링 지연)이 발생 가능
  • 수정

    • 가장 무거운 작업인 '화면에 그리기(DOM 조작)'를 365번에서 단 1번으로 줄임
      • 이를 통해 페이지 로딩 속도가 훨씬 빨라지고 쾌적해졌음
for (let i = 364; i >= 0; i--) {
    // 1. 매번 새로운 HTML 태그 객체를 메모리에 생성함
    const box = document.createElement('div'); 
    box.className = 'heatmap-box ...';
    
    // 2. 화면(DOM)에 만들어진 태그를 직접 하나씩 붙임 (365번 반복)
    heatmapGrid.appendChild(box); 
}
——————————————————————————————————————[비교]—————————————————————————————————————————
// 1. 빈 텍스트(문자열) 변수를 하나 만듭니다.
let htmlString = '';

for (let i = 364; i >= 0; i--) {
    // 2. 화면에 직접 그리지 않고, 텍스트 형태의 HTML 조각만 메모리에 차곡차곡 이어 붙입니다.
    htmlString += `<div class="heatmap-box ${activeClass}" title="${titleText}"></div>`;
}

// 3. 365개의 태그가 하나로 합쳐진 거대한 텍스트를, 화면에 딱 1번만 집어넣습니다!
heatmapGrid.innerHTML = htmlString;

이모지 커스텀

  • 이모지가 아닌 <img> 태그나 <i> 태그를 넣기 위해서는
    • 자바스크립트가 이를 단순한 글자가 아닌 'HTML 요소' 로 인식하게 만들어야 함
  • fetchMyGardenStats() 함수 안에서 innerTextinnerHTML로 변경 필요
// 찾은 정보(아이콘, 라벨, 메시지, 레벨)를 HTML 요소에 텍스트로 넣어줍니다.
            gardenIcon.innerText = currentGrade.icon; // ❌ 텍스트(이모지)만 들어감
——————————————————————————————————————[비교]—————————————————————————————————————————
// 아이콘 데이터에 HTML 태그(<img>, <i>)가 섞여있으므로 innerHTML을 사용해야 화면에 그림이 나옵니다.
            gardenIcon.innerHTML = currentGrade.icon; // ✅ HTML 태그가 정상적으로 그림으로 바뀜
  • 양식

// 💡 1. 원하는 방식으로 아이콘을 커스텀합니다. (이모지, HTML 아이콘, 커스텀 이미지 모두 가능!)
    const GRADE_SETTINGS = [
        // [방법 A] 다른 이모지로 변경 (: 개발자 테마)
        { min: 50, label: '마스터 해커', msg: '당신은 전설의 개발자입니다.', level: 6, icon: '💻' },
        
        // [방법 B] 부트스트랩 아이콘 사용 (기존 사이트 디자인과 제일 잘 어울림)
        { min: 40, label: '시니어', msg: '코드가 예술의 경지에 올랐습니다.', level: 5, icon: '<i class="bi bi-cpu-fill text-success"></i>' },
        
        // [방법 C] 외부 커스텀 이미지 (직접 만든 귀여운 이미지나 GIF 파일 사용)
        { min: 30, label: '미들', msg: '실력이 쑥쑥 자라고 있어요.', level: 4, icon: '<img src="https://cdn-icons-png.flaticon.com/512/1187/1187595.png" style="width: 80px; height: 80px;">' },
        
        // (아래는 이모지 조합 예시)
        { min: 20, label: '주니어', msg: '새로운 기술을 스폰지처럼 흡수합니다.', level: 3, icon: '☕️' },
        { min: 10, label: '인턴', msg: '어엿한 개발자의 형태를 잡았어요.', level: 2, icon: '🔋' },
        { min: 5,  label: '뉴비', msg: '코딩의 세계로 들어오셨군요!', level: 1, icon: '⌨️' },
        { min: 0,  label: '비기너', msg: '글을 작성해서 경험치를 쌓아주세요.', level: 0, icon: '😴' },
    ];

이미지 url 얻기

  • 방법 1. S3 기능 구현

    • 본문(에디터) 입력창에 내가 원하는 이미지를 드래그 앤 드롭으로 끌어다 놓습니다.
    • 코드가 잠시 S3에 업로드를 진행한 뒤, 에디터에 아래와 같은 마크다운 코드가 생성됨
      • ex. ![image](https://내-s3-버킷주소.../어쩌구.png)
      • 여기서 괄호 () 안에 있는 https://... 로 시작하는 주소만 복사
  • 방법 2. 깃허브(GitHub) 이슈 활용하기

    • 내 GitHub 레포지토리 아무 곳이나 들어가서 Issues -> New issue를 누릅니다.
    • 내용(Write) 탭에 원하는 이미지를 마우스로 끌어다 놓습니다(드래그 앤 드롭)
      • ![image](https://github.com/user-attachments/assets/...)
      • 형태로 텍스트가 생김
    • 이슈는 등록하지 않아도 주소는 영구 유지됨
  • 방법 3. 디스코드(Discord) 활용하기

    • 디스코드의 '나와의 채팅방'이나 아무 개인 서버에 이미지를 업로드
    • 올라간 이미지를 클릭하여 크게 띄운 뒤, 우측 하단의 [브라우저에서 열기] 를 누릅니다.
    • 새 인터넷 창이 열리면서 이미지가 보이면
      • 맨 위 인터넷 주소창에 있는 주소를 그대로 복사해서 사용

포스트 작성 쉘 스크립트

# 1. Django에 등록된 커스텀 유저 모델을 가져오기 위한 함수를 임포트합니다.
from django.contrib.auth import get_user_model

# 2. 방금 올려주신 Post 모델을 정확한 경로에서 임포트합니다.
from post.models.post import Post

# 3. 현재 프로젝트에서 사용 중인 유저 모델 클래스를 가져옵니다.
User = get_user_model()

# 4. 포스트의 작성자(user)로 지정할 유저를 DB에서 찾습니다. 
# 보통 개발 환경에서는 본인이 첫 번째로 가입한 유저이므로 first()를 사용해 첫 번째 유저를 가져옵니다.
user = User.objects.first()

# 5. 만약 DB에 유저가 하나도 없다면 에러가 날 수 있으므로 방어 코드를 작성합니다.
if not user:
    print("❌ DB에 유저가 없습니다. 회원가입을 먼저 진행해주세요.")
else:
    # 6. 누구의 계정으로 글이 작성되는지 터미널에 안내 메시지를 띄웁니다.
    print(f"🚀 '{user.email}' 님의 계정으로 450개의 더미 포스트 생성을 준비합니다...")
    
    # 7. 450개의 Post 객체를 잠시 담아둘 빈 리스트(배열)를 만듭니다.
    posts_to_create = []
    
    # 8. 1부터 450까지 총 450번 반복하는 파이썬 반복문을 엽니다.
    for i in range(1, 200):
        
        # 9. Post 모델의 인스턴스(객체)를 생성합니다. (아직 DB에 저장되지는 않고 메모리에만 존재합니다)
        post = Post(
            user=user,                                                # 작성자 지정
            title=f"코딩 정원 폭풍 성장을 위한 더미 포스트 {i}",            # 포스트 제목
            content=f"이 글은 Django shell을 통해 초고속으로 생성된 {i}번째 포스트입니다.\n쑥쑥 자라라 내 정원! 🌱", # 포스트 본문 내용
            visibility=Post.Visibility.PUBLIC,                        # 전체 공개 설정
            is_temp=False                                             # 임시저장 아님
        )
        
        # 10. 생성한 객체를 리스트에 차곡차곡 담아줍니다.
        posts_to_create.append(post)
    
    # 11. bulk_create 메서드를 사용해 리스트에 담긴 450개의 객체를 '단 한 번의 INSERT 쿼리'로 DB에 밀어 넣습니다.
    # 이 방식이 하나씩 save() 하는 것보다 압도적으로 빠릅니다.
    Post.objects.bulk_create(posts_to_create)
    
    # 12. 처리가 완료되었음을 알리는 축하 메시지를 띄웁니다.
    print("🎉 450개의 포스트가 1초 만에 생성되었습니다! 홈 화면을 새로고침하여 9레벨 생명의 숲을 확인하세요!")

전체 등급 이미지 변환

  • 이전

  • 이후

profile
안녕하세요.

0개의 댓글