구현해야 할 기능
- 소셜 로그인(discord)
- ai 요약기능 (버튼을 누르면 진행되도록)
- 자동 임시 저장 (Auto-save) & 글자 수 세기
디스코드 소셜 로그인
디스코드 개발자 포털 설정 및 환경변수 추가
- Discord Developer Portal
Discord Developer Portal 에 접속하여 'New Application'을 생성

- 좌측 메뉴의
OAuth2로 이동하여 CLIENT ID와 CLIENT SECRET을 복사

- Redirects
http://127.0.0.1:8000/api/v1/user/login/discord/callback/
http://127.0.0.1:8000/api/v1/user/login-page/
- 또는 실제 사용할 콜백 주소를 추가

- 프로젝트의
.env (또는 settings.py) 파일에 아래 값을 추가
DISCORD_CLIENT_ID = "발급받은_CLIENT_ID"
DISCORD_CLIENT_SECRET = "발급받은_CLIENT_SECRET"
service
class DiscordLoginService:
@staticmethod
def discord_login(code: str, redirect_uri: str):
token_req_url = "https://discord.com/api/oauth2/token"
data = {
"client_id": settings.DISCORD_CLIENT_ID,
"client_secret": settings.DISCORD_CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
token_req = requests.post(token_req_url, data=data, headers=headers)
token_json = token_req.json()
if "error" in token_json:
raise ValueError("Discord 토큰을 받아오는데 실패했습니다.")
access_token = token_json.get("access_token")
user_req_url = "https://discord.com/api/users/@me"
user_req = requests.get(
user_req_url, headers={"Authorization": f"Bearer {access_token}"}
)
user_json = user_req.json()
discord_id = str(user_json.get("id"))
nickname = user_json.get("username")
email = user_json.get("email")
if not email:
email = f"{discord_id}@discord.dummy.com"
with transaction.atomic():
social_account = SocialAccount.objects.filter(
provider="discord", social_id=discord_id
).first()
if social_account:
user = social_account.user
else:
user = User.objects.filter(email=email).first()
if not user:
user = User.objects.create_user(
email=email,
nickname=nickname,
password=None,
)
SocialAccount.objects.create(
user=user, provider="discord", social_id=discord_id
)
refresh = RefreshToken.for_user(user)
return {
"access_token": str(refresh.access_token),
"refresh_token": str(refresh),
"user": user,
}
view
class DiscordLoginAPIView(APIView):
permission_classes = [AllowAny]
def get(self, request):
client_id = settings.DISCORD_CLIENT_ID
redirect_uri = "http://127.0.0.1:8000/api/v1/user/login-page/"
discord_auth_url = f"https://discord.com/api/oauth2/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope=identify%20email"
return redirect(discord_auth_url)
class DiscordLoginCallbackAPIView(APIView):
permission_classes = [AllowAny]
def get(self, request):
code = request.GET.get("code")
redirect_uri = "http://127.0.0.1:8000/api/v1/user/login-page/"
if not code:
return Response(
{"error": "인가 코드가 필요합니다."}, status=status.HTTP_400_BAD_REQUEST
)
try:
login_data = DiscordLoginService.discord_login(code, redirect_uri)
return Response(
{
"message": "Discord 로그인 성공",
"token": {
"access": login_data["access_token"],
"refresh": login_data["refresh_token"],
},
"user": {
"email": login_data["user"].email,
"nickname": login_data["user"].nickname,
},
},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
현재 상황
- 내 프론트엔드는 JWT 토큰을 브라우저의 localStorage에 저장하여 사용하는 방식 사용중
- 현재 백엔드 흐름
- 디스코드 서버에서 인가 코드를 받은 뒤
- 백엔드의
DiscordLoginCallbackAPIView로 직접 리다이렉트 됨
- 백엔드는 토큰을 발급하고 JSON 객체(
Response({...}))를 반환
- 발생하는 문제
- 프론트엔드 화면(HTML)이 떠야 할 브라우저 화면에 날것의 JSON 텍스트만 출력됨
- 로그인 페이지(login.html)의 자바스크립트가 실행되지 않아 localStorage에 토큰을 저장할 수 없게 됨
- 해결
- 깃허브 로그인과 완벽하게 동일한 패턴으로 세팅
- 디스코드 역시 콜백 주소를 백엔드 API가 아닌 프론트엔드의 로그인 페이지(login-page/)로 보냄
- 프론트엔드가 주소창의 code를 읽어들인 뒤
- 백엔드로 AJAX(fetch) 요청을 보내 토큰을 받아오도록 구성
- 이를 구분하기 위해 디스코드 리다이렉트 주소에
?provider=discord라는 꼬리표를 달아주기
view 수정
class DiscordLoginAPIView(APIView):
permission_classes = [AllowAny]
def get(self, request):
client_id = settings.DISCORD_CLIENT_ID
redirect_uri = "http://127.0.0.1:8000/api/v1/user/login-page/?provider=discord"
encoded_redirect_uri = urllib.parse.quote(redirect_uri)
discord_auth_url = f"https://discord.com/api/oauth2/authorize?client_id={client_id}&redirect_uri={encoded_redirect_uri}&response_type=code&scope=identify%20email"
return redirect(discord_auth_url)
class DiscordLoginCallbackAPIView(APIView):
permission_classes = [AllowAny]
def post(self, request):
code = request.data.get("code")
redirect_uri = "http://127.0.0.1:8000/api/v1/user/login-page/?provider=discord"
if not code:
return Response(
{"error": "인가 코드가 필요합니다."}, status=status.HTTP_400_BAD_REQUEST
)
try:
login_data = DiscordLoginService.discord_login(code, redirect_uri)
return Response(
{
"message": "Discord 로그인 성공",
"token": {
"access": login_data["access_token"],
"refresh": login_data["refresh_token"],
},
"user": {
"email": login_data["user"].email,
"nickname": login_data["user"].nickname,
},
},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
문제
디스코드 연결시 기존의 아이디로 연동
- 깃허브처럼 새로운 아이디가 가입되며 로그인이 진행되는 것이 아닌 기존에 존재하는 아이디로 로그인 되어버림
- 원인
- 내가 일반 회원가입으로 만든 아이디의 이메일과 디스코드의 이메일이 동일해서 연결됬던 것
with transaction.atomic():
social_account = SocialAccount.objects.filter(
provider="discord", social_id=discord_id
).first()
if social_account:
user = social_account.user
else:
user = User.objects.filter(email=email).first()
if not user:
user = User.objects.create_user(...)
SocialAccount.objects.create(
user=user, provider="discord", social_id=discord_id
)
새로운 아이디로 진행하면 통과함



ai기능 고민
기존
- ai 내용요약기능을 추가하려 하였지만 너무 흔하고 요약기능으로 인해 오히려 블로그 체류시간이 적어질듯
- 썸네일/커버 이미지 자동 생성
- 챗봇
- 톤앤매너(문체) 변환기
- 떠오르는 생각들을 메모장 쓰듯 막 적어두면, AI가 블로그 성격에 맞게 '전문가스러운 IT 블로그 톤', '감성적인 일기 톤', '친근한 리뷰 톤' 등으로 글을 다듬어줍니다.
썸네일/커버 이미지
프리셋 기반 '제목 & 로고' 텍스트 썸네일 생성
- 이미지를 아예 생성하지 않거나, 아주 기본적인 요소만 생성하고 텍스트 오버레이에 집중하는 방식
작동 방식
- 사용자가 블로그 글 제목을 입력
- 블로그 주인의 '프리셋
- 미리 등록해둔 블로그 로고, 선호하는 폰트
- 블로그 고유 색상 팔레트, 썸네일 레이아웃 템플릿을 불러옴
- AI는 저비용 언어 모델 (예: GPT-3.5)을 사용하여 입력된 제목을 분석
- 썸네일에 넣기에 가장 임팩트 있는 요약 문구를 추출하거나 생성
- 추출된 문구, 블로그 로고, 블로그 색상을 프리셋 템플릿에 맞게 조합하여
- 최종 이미지(썸네일)를 생성
- (HTML/CSS로 썸네일을 렌더링하고 이미지로 변환하는 방식 활용)
AI의 역할
- 본문 분석을 통한 핵심 문구 추출 (저렴한 토큰 비용)
장점
- 이미지 생성 AI를 사용하지 않으므로 토큰 비용이 극도로 저렴함
- 블로그의 일관된 브랜드 아이덴티티를 유지 가능
단점
- 썸네일이 텍스트 중심이므로, 이미지 중심의 썸네일에 비해 시선을 끄는 힘이 약함
핵심 아이콘 생성 + 텍스트 오버레이
- 글의 핵심 키워드를 나타내는 아이콘만 생성하고, 이를 텍스트와 조합하는 방식
작동 방식
- AI가 글 본문을 분석하여 가장 중요하면서도 시각화하기 쉬운
- '핵심 키워드' (예: '로봇', '코드', '여행', '음식')를 하나 추출
- 중저비용 이미지 생성 AI (예: DALL-E 2)에게 매우 구체적인 프롬프트를 주어
- 사용자가 미리 설정해둔 템플릿 위에 생성된 아이콘을 배치하고
AI의 역할
- 핵심 키워드 분석 (저렴한 토큰 비용)
- 매우 구체적인 프롬프트에 기반한 핵심 아이콘 생성
- 이미지 생성 AI 사용
- 상대적으로 높은 비용이지만 고해상도 전체 이미지를 만드는 것보다 저렴함
장점
- 이미지 생성 AI의 기능을 활용하면서도 생성 단위를 '아이콘'으로 제한하여 비용을 낮춤
- 시각적인 정보 전달력이 뛰어남
단점
- 여전히 이미지 생성 AI를 호출하므로, 기획 1보다는 비용이 발생
- 원하는 아이콘이 생성되지 않을 수 있음
톤앤매너 변환기
핵심 포인트
- 다양한 맞춤형 페르소나 버튼
- 'IT 전문가(신뢰감)'
- '맛집 블로거(발랄함/이모티콘)'
- '에세이 작가(감성적/새벽감성)'
- '비즈니스(정중함)' 등 명확한 문체 옵션을 제공
- 키워드 벌크업 (Draft to Post)
- 생각나는 대로 단어만 툭툭 던져놔도 (예: "강남역, 마라탕, 존맛, 웨이팅 김")
- 선택한 문체에 맞춰 자연스러운 하나의 문단으로 살을 붙여 확장해 줌
- 부분 수정 (Drag & Change)
- 글 전체를 통째로 바꾸면 작성자의 원래 의도나 정보가 훼손될 수 있음
- 에디터에서 마우스로 드래그한 특정 문단만 변환할 수 있게 하면 사용성이 훨씬 좋아짐
- 프롬프트 캐싱 및 최적화 (비용 절감)
- 토큰 낭비를 막기 위해, AI에게 "너는 10년 차 IT 전문 블로거야.
- 주어진 텍스트를 명확하고 논리적인 평어체로 수정해"라는 식의
- 강력한 시스템 프롬프트를 서버 단에서 미리 단단하게 세팅
ai app 추가
python manage.py startapp ai
- 외부 API(OpenAI 등)와 통신하고, 프롬프트를 관리하며, 토큰을 계산하는 AI 로직
poetry add openai
poetry add google-generativeai
prompt 분리
- AI에게 역할을 부여하는 프롬프트를 서비스 로직과 분리하여
- 나중에 문체를 수정할 때 이 파일만 고치면 되도록 유지보수성을 극대화
TONE_PROFESSIONAL = "당신은 10년 차 전문 IT 블로거입니다. 주어진 텍스트를 명확하고 논리적이며 신뢰감 있는 평어체(~다, ~임)로 수정해 주세요."
TONE_FRIENDLY = "당신은 친근하고 발랄한 리뷰 블로거입니다. 주어진 텍스트를 이모티콘을 적절히 섞어 부드럽고 친절한 존댓말(~해요, ~죠)로 수정해 주세요."
TONE_EMOTIONAL = "당신은 감성적인 에세이 작가입니다. 주어진 텍스트를 서정적이고 감성적인 분위기가 느껴지는 문체로 다듬어 주세요."
TONE_MAPPING = {
"professional": TONE_PROFESSIONAL,
"friendly": TONE_FRIENDLY,
"emotional": TONE_EMOTIONAL
}
OpenAI API 호출 서비스 로직
- OpenAI API와 통신하는 역할만 담당하는 파일
import logging
from django.conf import settings
import google.generativeai as genai
from apps.ai.prompts.tone_prompts import TONE_MAPPING
from apps.core.exceptions.base import BaseCustomException
from apps.core.exceptions.messages import ErrorMessage
logger = logging.getLogger(__name__)
genai.configure(api_key=settings.GEMINI_API_KEY)
def convert_text_tone(text: str, tone: str) -> str:
try:
system_prompt = TONE_MAPPING.get(tone)
if not system_prompt:
raise BaseCustomException(ErrorMessage.UNSUPPORTED_TONE)
model = genai.GenerativeModel(
model_name="gemini-1.5-flash", system_instruction=system_prompt
)
response = model.generate_content(
text,
generation_config=genai.types.GenerationConfig(
temperature=0.7,
max_output_tokens=1000,
),
)
return response.text
except ValueError as ve:
logger.error(f"잘못된 문체 요청: {ve}")
raise
except Exception as e:
logger.error(f"Gemini API 호출 중 오류 발생: {e}")
raise BaseCustomException(ErrorMessage.AI_CONVERSION_FAILED)
view
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from apps.core.exceptions.base import BaseCustomException
from apps.core.exceptions.messages import ErrorMessage
from apps.ai.services.openai_service import convert_text_tone
class ToneConverterAPIView(APIView):
def post(self, request, *args, **kwargs):
text = request.data.get("text")
tone = request.data.get("tone")
if not text or not tone:
raise BaseCustomException(ErrorMessage.INVALID_INPUT)
try:
converted_text = convert_text_tone(text=text, tone=tone)
return Response(
{"converted_text": converted_text}, status=status.HTTP_200_OK
)
except ValueError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except RuntimeError as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
API KEY 발급
내 API 키로 사용 가능한 모델 확인하기
- python manage.py shell 열고 아래 코드 복붙하기
import google.generativeai as genai
from django.conf import settings
genai.configure(api_key=settings.GEMINI_API_KEY)
for m in genai.list_models():
if 'generateContent' in m.supported_generation_methods:
print(m.name)
결과
