오늘 할 일
ai 본문 수정
토큰 상향 조정
- 토큰낭비를 방지하기 위해서
max_output_tokens=1000 세팅해놓았는데 생각보다 너무 짧음
고민
- 토큰제한을 좀 널럴하게 하여서 ai실행횟수를 줄이는게 좋을까
- 제한을 유지하고 여러번 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(
temperature=0.7,
max_output_tokens=2500,
),
)
return response.text
——————————————————————————————————————[비교]—————————————————————————————————————————
response = model.generate_content(
text,
stream=True,
generation_config=genai.types.GenerationConfig(
temperature=0.7,
max_output_tokens=2500,
),
)
for chunk in response:
if chunk.text:
yield chunk.text
views
- 장고(Django)에서 스트리밍 데이터를 클라이언트에 전달하려면
StreamingHttpResponse를 사용해야 함.
from django.http import StreamingHttpResponse
try:
converted_text = convert_text_tone(text=text, tone=tone)
return Response(
{"converted_text": converted_text}, status=status.HTTP_200_OK
)
——————————————————————————————————————[비교]—————————————————————————————————————————
try:
converted_text = convert_text_tone(text=text, tone=tone)
return StreamingHttpResponse(
converted_text,
content_type='text/plain'
)
프론트 구현
- 현재
templates/post/write.html
const data = await response.json(); 방식을 사용중
- 이 방식은 백엔드에서 모든 데이터(전체 텍스트)가 완성되어
- 한 번에 JSON 형태로 넘어올 때까지 꼼짝 않고 기다리는 방식
- 백엔드에서 조각(Chunk) 단위로 쪼개서 스트리밍(text/plain)으로 보내주더라도
- 현재 프론트엔드 코드는 이를 이해하지 못하고 에러를 내거나 끝날 때까지 기다리게 됨
- 자바스크립트의 표준 스트림 처리 API인
ReadableStream과 getReader()를 사용하여
- 넘어오는 조각을 실시간으로 화면에 찍어주도록 변경해야함
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:
converted_text = convert_text_tone(text=text, tone=tone)
return StreamingHttpResponse(
converted_text,
content_type='text/plain'
)
——————————————————————————————————————[비교]—————————————————————————————————————————
try:
converted_text = convert_text_tone(text=text, tone=tone)
response = StreamingHttpResponse(
converted_text,
content_type='text/event-stream'
)
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no'
return response
service 수정 (Byte 단위 강제 전송)
- 파이썬 내부나 장고 WSGI 서버에서 텍스트(String)를 인코딩하느라 모아두는 현상을 방지
- 애초에 텍스트를 바이트(Byte)로 변환
for chunk in response:
if chunk.text:
yield chunk.text
——————————————————————————————————————[비교]—————————————————————————————————————————
for chunk in response:
if chunk.text:
yield chunk.text.encode('utf-8')
결과
-
변경 전
- 변환하기를 누르면 9~10초정도의 대기시간이 지나고 전체 변환 내용이 출력됨

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

이미지 처리
- 썸네일 뿐만 아니라 내용에도 이미지가 들어가도록 변경
- Toast UI Editor에 있는 기능을 이용해서 이미지를 업로드하면
- 아래같은 주소가 엄청 길게나옴 100줄은 그냥 넘는거 같음

- 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에 매번 붙임
- 이는 성능에 좋지 않으므로 한 번에 그려서 붙이도록 최적화 필요
디자인 변경
<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>
/* 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);
}
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() 함수 안에서 innerText를 innerHTML로 변경 필요
// 찾은 정보(아이콘, 라벨, 메시지, 레벨)를 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.

- 여기서 괄호 () 안에 있는 https://... 로 시작하는 주소만 복사
방법 2. 깃허브(GitHub) 이슈 활용하기
- 내 GitHub 레포지토리 아무 곳이나 들어가서 Issues -> New issue를 누릅니다.
- 내용(Write) 탭에 원하는 이미지를 마우스로 끌어다 놓습니다(드래그 앤 드롭)

- 형태로 텍스트가 생김
- 이슈는 등록하지 않아도 주소는 영구 유지됨
방법 3. 디스코드(Discord) 활용하기
- 디스코드의 '나와의 채팅방'이나 아무 개인 서버에 이미지를 업로드
- 올라간 이미지를 클릭하여 크게 띄운 뒤, 우측 하단의
[브라우저에서 열기] 를 누릅니다.
- 새 인터넷 창이 열리면서 이미지가 보이면
- 맨 위 인터넷 주소창에 있는 주소를 그대로 복사해서 사용
포스트 작성 쉘 스크립트
from django.contrib.auth import get_user_model
from post.models.post import Post
User = get_user_model()
user = User.objects.first()
if not user:
print("❌ DB에 유저가 없습니다. 회원가입을 먼저 진행해주세요.")
else:
print(f"🚀 '{user.email}' 님의 계정으로 450개의 더미 포스트 생성을 준비합니다...")
posts_to_create = []
for i in range(1, 200):
post = Post(
user=user,
title=f"코딩 정원 폭풍 성장을 위한 더미 포스트 {i}",
content=f"이 글은 Django shell을 통해 초고속으로 생성된 {i}번째 포스트입니다.\n쑥쑥 자라라 내 정원! 🌱",
visibility=Post.Visibility.PUBLIC,
is_temp=False
)
posts_to_create.append(post)
Post.objects.bulk_create(posts_to_create)
print("🎉 450개의 포스트가 1초 만에 생성되었습니다! 홈 화면을 새로고침하여 9레벨 생명의 숲을 확인하세요!")
전체 등급 이미지 변환
이전

이후
