2026/03/10 Blog - 20

김기훈·2026년 3월 10일

TIL

목록 보기
160/194
post-thumbnail

코딩테스트(28702)


구현해야 할 기능

  • 소셜 로그인(discord)
  • ai 요약기능 (버튼을 누르면 진행되도록)
    • 연타는 막을것
  • 자동 임시 저장 (Auto-save) & 글자 수 세기

디스코드 소셜 로그인

디스코드 개발자 포털 설정 및 환경변수 추가

  • Discord Developer Portal
    • Discord Developer Portal 에 접속하여 'New Application'을 생성
    • 좌측 메뉴의 OAuth2로 이동하여 CLIENT IDCLIENT 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) 파일에 아래 값을 추가
# settings.py (혹은 .env)
DISCORD_CLIENT_ID = "발급받은_CLIENT_ID"
DISCORD_CLIENT_SECRET = "발급받은_CLIENT_SECRET"

service

class DiscordLoginService:
    @staticmethod
    def discord_login(code: str, redirect_uri: str):
        # 1. 디스코드 토큰 발급 URL
        token_req_url = "https://discord.com/api/oauth2/token"

        # 2. 토큰 요청 페이로드 (디스코드는 grant_type과 redirect_uri가 필수)
        data = {
            "client_id": settings.DISCORD_CLIENT_ID,
            "client_secret": settings.DISCORD_CLIENT_SECRET,
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": redirect_uri,
        }

        # 3. 디스코드 API 권장 헤더 포맷
        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        # 4. 토큰 요청
        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")

        # 5. 유저 정보 요청 URL
        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()

        # 6. 유저 정보 추출 (디스코드는 id와 username, email을 반환)
        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"

        # 7. DB 트랜잭션
        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()  # type: ignore
                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
                )

        # 8. JWT 발급
        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
        # urls.py에 등록할 콜백 주소와 동일해야 합니다.
        redirect_uri = "http://127.0.0.1:8000/api/v1/user/login-page/"

        # scope에 identify(프로필)와 email을 필수로 요청합니다.
        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):
        # 디스코드는 보통 GET 파라미터로 code를 넘겨줍니다 (프론트/백엔드 분리 구조에 따라 POST로 받을 수도 있음)
        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"
        
        # URL에 들어갈 수 있도록 특수문자(:, /, ? 등)를 안전하게 인코딩합니다.
        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]

    # 프론트엔드가 fetch로 POST 요청을 보내므로 POST로 받습니다.
    def post(self, request):
        # GET.get이 아닌 data.get으로 body에 담긴 JSON 데이터를 꺼냅니다.
        code = request.data.get("code")
        
        # 토큰을 요청할 때도 위(APIView)에서 적었던 주소와 완벽히 똑같아야 합니다!
        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():
            # 1. 먼저 디스코드 고유 ID로 가입된 소셜 계정이 있는지 찾습니다.
            social_account = SocialAccount.objects.filter(
                provider="discord", social_id=discord_id
            ).first()
            
            if social_account:
                # 2-A. 이미 연동된 계정이 있다면, 그 유저 정보로 로그인을 진행합니다.
                user = social_account.user
            else:
                # 2-B. 연동된 계정이 없다면? 
                # 디스코드에서 받아온 '이메일'로 기존 유저를 검색합니다.
                user = User.objects.filter(email=email).first() 
                
                if not user:
                    # 3-A. 같은 이메일을 쓰는 유저가 아예 없으면, '완전 신규 회원가입'을 진행합니다.
                    user = User.objects.create_user(...)
                    
                # 3-B. 같은 이메일을 쓰는 유저가 있다면? (테스트 계정 등)
                # 신규 가입을 하지 않고, 찾아낸 기존 유저(테스트 계정)에 디스코드 소셜 정보를 '연결(Link)' 해버립니다.
                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에게 역할을 부여하는 프롬프트를 서비스 로직과 분리하여
    • 나중에 문체를 수정할 때 이 파일만 고치면 되도록 유지보수성을 극대화
# 'IT 전문가' 문체를 위한 시스템 프롬프트 상수입니다.
TONE_PROFESSIONAL = "당신은 10년 차 전문 IT 블로거입니다. 주어진 텍스트를 명확하고 논리적이며 신뢰감 있는 평어체(~다, ~임)로 수정해 주세요."

# '친근한 리뷰어' 문체를 위한 시스템 프롬프트 상수입니다.
TONE_FRIENDLY = "당신은 친근하고 발랄한 리뷰 블로거입니다. 주어진 텍스트를 이모티콘을 적절히 섞어 부드럽고 친절한 존댓말(~해요, ~죠)로 수정해 주세요."

# '감성 에세이' 문체를 위한 시스템 프롬프트 상수입니다.
TONE_EMOTIONAL = "당신은 감성적인 에세이 작가입니다. 주어진 텍스트를 서정적이고 감성적인 분위기가 느껴지는 문체로 다듬어 주세요."

# API 요청으로 들어온 문체 이름과 실제 프롬프트를 매핑해 주는 딕셔너리입니다. (확장성을 위한 구조)
TONE_MAPPING = {
    # 키워드 'professional'이 들어오면 TONE_PROFESSIONAL 프롬프트를 사용합니다.
    "professional": TONE_PROFESSIONAL,
    # 키워드 'friendly'가 들어오면 TONE_FRIENDLY 프롬프트를 사용합니다.
    "friendly": TONE_FRIENDLY,
    # 키워드 'emotional'이 들어오면 TONE_EMOTIONAL 프롬프트를 사용합니다.
    "emotional": TONE_EMOTIONAL
}

OpenAI API 호출 서비스 로직

  • OpenAI API와 통신하는 역할만 담당하는 파일
    • 단일 책임 원칙(SRP)을 지키기 위함
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)  # type: ignore


def convert_text_tone(text: str, tone: str) -> str:
    try:
        # 클라이언트가 요청한 문체(tone)에 맞는 프롬프트를 찾습니다.
        system_prompt = TONE_MAPPING.get(tone)
        if not system_prompt:
            raise BaseCustomException(ErrorMessage.UNSUPPORTED_TONE)

        # Gemini 모델 인스턴스를 생성합니다.
        model = genai.GenerativeModel(
            model_name="gemini-1.5-flash", system_instruction=system_prompt
        )

        # 모델에게 실제 변환할 사용자의 텍스트를 전달하고 결과(응답)를 생성하도록 요청합니다.
        response = model.generate_content(
            text,  # 유저가 작성한 텍스트
            # 결과물 생성을 위한 세부 옵션을 설정합니다.
            generation_config=genai.types.GenerationConfig(
                # 0.7은 너무 뻔하지도, 너무 엉뚱하지도 않은 적절하고 자연스러운 문장을 만듬
                temperature=0.7,
                # 결과물이 너무 길어져서 토큰(비용)을 과다하게 쓰는 것을 방지
                max_output_tokens=1000,
            ),
        )

        # Gemini의 응답 객체에서 생성된 텍스트 문자열만 쏙 뽑아서 반환
        return response.text

    # 우리가 위에서 직접 발생시킨 '지원하지 않는 문체' 에러를 잡음
    except ValueError as ve:
        # 서버 로그에 에러 원인을 기록함
        logger.error(f"잘못된 문체 요청: {ve}")
        # 이 에러를 API View로 다시 던져서 클라이언트에게 400 에러를 내려주도록 함
        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):
        # 클라이언트가 보낸 JSON 데이터 중 'text'를 가져옵니다.
        text = request.data.get("text")
        # 클라이언트가 보낸 JSON 데이터 중 'tone'을 가져옵니다.
        tone = request.data.get("tone")

        # 텍스트나 톤 중 하나라도 비어있다면 (유효성 검사 실패)
        if not text or not tone:
            raise BaseCustomException(ErrorMessage.INVALID_INPUT)

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

            # 에러 없이 무사히 변환되었다면, 200 성공 코드와 함께 변환된 텍스트를 돌려줌
            return Response(
                {"converted_text": converted_text}, status=status.HTTP_200_OK
            )

        # 지원하지 않는 문체 등 사용자의 잘못된 요청으로 인한 에러를 처리
        except ValueError as e:
            # 400 Bad Request와 함께 서비스 계층에서 던진 에러 메시지를 반환
            return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

        # 구글 API 서버 장애 등 백엔드 측의 예기치 않은 에러를 처리
        except RuntimeError as e:
            # 500 Internal Server Error와 함께 안전하게 포장된 에러 메시지를 반환
            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

# API 키 인증
genai.configure(api_key=settings.GEMINI_API_KEY)

# 내 키로 사용 가능한 생성형 모델 이름 모두 출력하기
for m in genai.list_models():
    if 'generateContent' in m.supported_generation_methods:
        print(m.name)

결과

profile
안녕하세요.

0개의 댓글