Union Project - learning - 5주차

김기훈·2026년 2월 4일

부트캠프 프로젝트

목록 보기
39/39
post-thumbnail

[2026.02.01] 참조 명령어 재료

[2026.02.02] OneToOneField / 무한스크롤 원리

[2026.02.03] parser_classes / multipart/form-data

[2026.02.04]

[2026.02.05]

[2026.02.06]


2026.02.01 ✅

참조

__

  • 테이블 건너가기 또는 필드 속성 들어가기 버튼

    • 정참조 (game, genre)

      • 내 모델에 정의된 필드명을 그대로 사용
    • 역참조 (game_genres)

      • 다른 모델이 나를 참조할 때, 그 모델에 정의된 related_name 사용

game__game_genres__genre__genre

  • 목표: Review (리뷰)에서 시작해 Genre (장르 이름)를 찾아가기
    • 하지만 Review와 Genre는 직접 연결되어 있지 않음

game

  • Review → Game (정참조)
  • 의미: "이 리뷰가 달린 게임 정보를 가져와라"
  • 이동: Review 테이블 ➡ Game 테이블
class Review(models.Model):
    game = models.ForeignKey(Game, ...)
    ...

__game_genres

  • Game → GameGenre (역참조)
  • Game 테이블에 도착
    • 여기서 장르로 가려면 중간 테이블(GameGenre)을 거쳐야 함
    • 하지만 Game 모델 안에는 GameGenre를 가리키는 필드가 없음
    • 반대로 GameGenre가 Game을 바라보고 있음
      • 이때 사용하는 것이 related_name (역참조)
  • 의미: "이 게임과 연결된 모든 GameGenre 연결 데이터들을 가져와라"
  • 이동: Game 테이블 ➡ GameGenre 테이블
  • 핵심: Game 입장에서 자기를 참조하고 있는 GameGenre들을 부를 때
    • 사용하는 별명(related_name)을 사용
class GameGenre(models.Model):
    game = models.ForeignKey(
        Game, 
        related_name="game_genres"  # 이게 'game_genres'
    )
    genre = models.ForeignKey(Genre, ...)

__genre

  • GameGenre → Genre (정참조)
  • 중간 테이블인 GameGenre에 도착
    • 여기에는 genre라는 필드가 있고, 이 필드는 진짜 Genre 테이블을 가리킴
  • 의미: "이 연결 데이터가 가리키는 진짜 장르 정보를 가져와라"
  • 이동: GameGenre 테이블 ➡ Genre 테이블
# apps/game/models/game_genre.py
class GameGenre(models.Model):
    game = models.ForeignKey(...)
    genre = models.ForeignKey(Genre, ...) # 이 필드이름이 'genre'

__genre

  • Genre → 값 (필드 접근)
  • Genre 테이블에 도착
    • 이제 필터링하고 싶은 실제 컬럼(값)을 지정해야 함
# apps/game/models/genre.py
class Genre(models.Model):
    genre = models.CharField(...) # 이 필드명이 'genre'
    slug = models.SlugField(...)

정리

# 요청: ?genre=RPG
Review.objects.filter(
    game__game_genres__genre__genre="RPG"
)
  • Review: 리뷰 테이블에서 찾을 건데...
  • game__: 그 리뷰에 연결된 게임으로 가서...
  • game_genres__: 그 게임에 연결된 중간 테이블(GameGenre) 목록들을 훑어서...
  • genre__: 그 중간 테이블이 가리키는 진짜 장르(Genre) 객체로 가서...
  • genre: 그 장르 객체의 이름(genre 컬럼)이 "RPG"인 것만 남겨라.

욕설 필터

korcen

  • ai에게 넘겨줄지 말지만 결정하면되기 때문에 굳이 마스킹은 필요없음
    • 리뷰에 작성된 욕설은 보여줄거임
      • 통과 기준 단순화 (utils.py):
        • 욕설이 없으면 -> 통과 (True)
        • 욕설이 있고 내용이 짧으면 (단순 비방) -> 탈락 (False)
        • 욕설이 있지만 내용이 길면 (정보 포함) -> 통과 (True, AI가 알아서 문맥 파악)

alt-profanity-check

  • 텍스트 데이터에서 욕설이나 비속어(Profanity)를 탐지하기 위해 만들어진 라이브러리
  • 특징

    • 단순한 단어 매칭(Keyword Matching) 방식을 사용하지 않는다
      • 기계 학습(Machine Learning) 기반

        • 단순히 금지어 리스트를 만들어두고 단어가 포함되었는지 확인하는 방식이 아니라,
        • 많은 양의 트윗 데이터로 훈련된 선형 SVM(Linear SVM) 모델을 사용
      • 문맥 파악 능력

        • 단순 매칭 방식은 "cassoulet"(요리 이름)에 "ass"가 들어갔다고 욕설로 오판가능
        • 반면, 이 라이브러리는 주변 문맥과 단어의 조합을 분석하여
          • 오탐(False Positive)을 최소화
      • 가벼운 의존성

        • TensorFlow나 PyTorch 같은 무거운 딥러닝 프레임워크 없이
        • scikit-learn과 joblib 정도만 사용하므로 속도가 매우 빠르고 가벼움
  • 세팅

    • 설치: pip install alt-profanity-check
      • 설치는 alt-profanity-check로 하지만
      • 코드에서 import 할 때는 profanity_check라는 이름을 사용

기본 사용법(predict)

  • 텍스트가 욕설인지 아닌지 판별
    • 1: 욕설 있음 (Profane) / 0: 욕설 없음 (Not Profane)
from profanity_check import predict

texts = ['Hello check this out', 'Go to hell']
result = predict(texts)

print(result)
# 출력: [0 1] 
# (첫 번째 문장은 0, 두 번째 문장은 1)

확률 예측 (predict_prob)

  • 단순히 0과 1이 아니라,
    • 욕설일 확률(Probability)을 반환 (민감도를 조절하고 싶을 때 유용)
from profanity_check import predict_prob

texts = ['Go to hell']
result = predict_prob(texts)

print(result)
# 출력: [0.9823...] (1에 가까울수록 욕설일 확률이 높음)

장단점

  • 장점

    • 정확도: 단순 필터링보다 훨씬 똑똑하게 문맥을 파악합니다.
    • 속도: 모델이 가벼워 실시간 채팅이나 댓글 필터링에 적합합니다.
    • 편의성: 별도의 금지어 리스트(Blacklist)를 관리할 필요가 없습니다.
  • 단점

    • 한국어 미지원: 영어 데이터로 학습되었기 때문에 한국어 욕설은 거의 탐지하지 못합니다.

korcen

  • 설치: pip install korcen

  • 파이썬으로 작성된 한국어 비속어 차단 및 탐지 라이브러리
    • 규칙(Rule)과 사전(Dictionary) 기반으로 작동합니다.
      • 하지만 단순한 단어 일치(Ctrl+F) 수준을 넘어,
      • 한국어 욕설 사용자들이 흔히 쓰는 변형/우회 패턴을 잡아내는 데 특화

핵심 특징 (변형 탐지)

  • 단순히 "시발"을 막는 게 아니라, 다음과 같은 회피형 욕설을 매우 잘 잡아냅니다.
    • 의미 없는 문자 삽입: 시!@#발, 개...새..끼
    • 자음/모음 분리: ㅅㅂ, ㅄ
    • 숫자/특수문자 혼용: ㅅ1발, 10새
    • 반복 문자: 미친ㄴㄴㄴㄴ
  • AI 모델처럼 문맥을 읽지는 못하지만
    • 욕설을 작정하고 숨기려는 시도를 패턴 매칭으로 찾아내는 능력이 탁월

사용법

  • check(탐지)와 filter(마스킹) 두 가지가 메인
from korcen import korcen

text = "이런 시!@#발 진짜 게임 족같이 만드네"

# 1. 욕설 여부 확인 (True/False)
is_bad = korcen.check(text)
print(f"욕설 포함 여부: {is_bad}") 
# 출력: True

# 2. 욕설 필터링 (마스킹 처리)
cleaned_text = korcen.filter(text)
print(f"필터링 결과: {cleaned_text}")
# 출력: "이런 ****** 진짜 게임 **같이 만드네"

장단점

  • 장점

    • 속도
      • 딥러닝 모델을 로드할 필요 없이 순식간에 처리하므로 대량의 리뷰를 전처리하기에 좋음
    • 비용 0원
      • API 호출이 아닌 로컬 연산이므로 추가 비용이 없음
    • 변형 욕설 탐지
      • 게임 리뷰 특성상 ㅅㅂ, ㅈ망겜 같은 초성/변형 욕설이 많은데
      • 이를 잘 걸러내어 AI에게 불필요한 데이터를 보내지 않게 해줌
  • 단점

    • "이 게임 타격감 미쳤네" (긍정) "조작감 개같네" (부정 - 개선점 정보 포함) 같은
      • 정보를 포함한 데이터까지 날려버릴 수 있음
  • 정리

    • 1차 필터링 로직을 "무조건 삭제"가 아닌 "선별적 삭제 및 마스킹"으로 작성필요

2026.02.02 ✅

OneToOneField 역참조의 특성

  • OneToOne 관계에서 역참조 속성인 summary에 접근할 때,
    • Django는 해당 데이터가 메모리에 있는지 먼저 확인합니다.
    • 데이터가 없다면 DB에 쿼리를 날려 데이터를 가져오려 시도합니다.
  • 보통 "역참조(Reverse Relation)는 prefetch_related를 써야 한다"고 인지함
    • 1:1 관계(OneToOneField)는 예외적으로 select_related를 지원하며 성능상 더 유리
    • ForeignKey의 역참조(game.reviews 등)는
      • 결과가 여러 개(List)라서 DB의 JOIN으로 처리하기 까다롭지만(데이터 뻥튀기 문제),
      • OneToOneField의 역참조는 결과가 무조건 1개(또는 0개)입니다.
    • 따라서 데이터베이스 차원에서 JOIN을 해도 결과 행(Row) 수가 늘어나지 않으므로,
      • Django는 역참조임에도 불구하고 select_related를 통한 SQL JOIN을 지원합니다.

무한스크롤 작동 원리

  • User: 스크롤을 내림.
  • Front: "어? 바닥이네? 2페이지 주세요." (GET /reviews?page=2)
  • Back: 2페이지 데이터(10개)만 줌.
  • Front: 기존 목록 아래에 이어 붙임.

multipart/form-data

  • 웹 클라이언트(브라우저 등)가 서버로 데이터를 전송할 때 사용하는 HTTP Content-Type 중 하나
    • 가장 핵심적인 특징은 "파일 업로드"가 필요할 때 사용된다는 점
    • 텍스트 데이터와 바이너리 데이터(이미지, 영상 등)를 하나의 요청(Request)에 섞어서 보낼 수 있도록 설계된 형식
  • 사용 목적

    • 일반적인 HTML Form의 기본 전송 방식은 application/x-www-form-urlencoded
      • 이 방식은 데이터를 긴 문자열(key=value&key2=value2)로 변환해서 보내는데,
      • 대용량 파일이나 이미지 같은 바이너리 데이터를 이렇게 변환하면 비효율적이고 데이터 크기가 너무 커짐
    • 반면, multipart/form-data 는 데이터를 변환하지 않고
      • 있는 그대로 쪼개서(Part) 보낼 수 있어 파일 전송에 최적화
  • 구조의 핵심

    • 이 형식의 가장 큰 특징은 Boundary(경계선)
      • 여러 종류의 데이터(텍스트, 파일 등)를 한 번에 보내야 하므로
      • 각 데이터가 어디서 시작하고 끝나는지 구분할 식별자가 필요함
    • 헤더(Header)
      • "이제부터 보낼 데이터는 ----WebKitFormBoundary... 라는 구분선으로 나뉘어 있어"라고 선언
    • 바디(Body)
      • 실제로 그 구분선을 사용해 데이터를 구역별로 나눔
  • 실제 형태

POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW  <-- 구분선 정의

------WebKitFormBoundary7MA4YWxkTrZu0gW   <-- 첫 번째 파트 시작 (텍스트 데이터)
Content-Disposition: form-data; name="username"

user123
------WebKitFormBoundary7MA4YWxkTrZu0gW   <-- 두 번째 파트 시작 (파일 데이터)
Content-Disposition: form-data; name="profile_pic"; filename="photo.jpg"
Content-Type: image/jpeg

(이미지의 실제 바이너리 데이터 내용...)
------WebKitFormBoundary7MA4YWxkTrZu0gW-- <--(마지막에 대시 2개가 붙음)
  • 중요

    • 이 형식을 백엔드(서버)에서 처리하려면 데이터를 파싱해서 파일과 텍스트를 분리하는 과정이 필요
      • 때문에 DRF에서는 parser_classes를 사용

parser_classes

  • DRF(Django REST Framework)가 들어오는 요청(Request)의 'Content-Type'을 보고
    • 그 거대한 덩어리의 데이터를 "어떻게 해석해서 Python 변수로 만들지" 결정하는 도구
  • 특히 multipart/form-data처럼 구조가 복잡한 데이터는 MultiPartParser가 없으면
    • 서버가 데이터를 읽을 수 없음
  • Parser가 하는 일 (비포 & 애프터)

    • 서버 입장에서는 데이터가 처음 도착했을 때 그냥 '0과 1로 된 긴 바이트 스트림'
      • 파서(Parser)가 개입해야 우리가 아는 데이터가 됨
  • 즉, parser_classesMultiPartParser를 등록하면,
    • DRF가 알아서 경계선(Boundary)을 기준으로 데이터를 쪼개고
    • 텍스트는 request.data 에, 파일은 request.FILES 에 예쁘게 담아줌
- 1. JSONParser								| - 2. MultiPartParser
  - Raw Data (파싱 전)						|  - Raw Data (파싱 전)
    - {"name": "kim"} (그냥 문자열)			|    - ----WebKitBound... (복잡한 텍스트/파일 혼합)
  - Python 객체 (파싱 후)						|  - Python 객체 (파싱 후)
    - request.data (딕셔너리)					|    - request.data (텍스트)
    										|    - request.FILES (파일)
  • 웹 클라이언트(브라우저 등)가 서버로 데이터를 전송할 때 사용하는 HTTP Content-Type 중 하나
    • 가장 핵심적인 특징은 "파일 업로드"가 필요할 때 사용된다는 점
    • 텍스트 데이터와 바이너리 데이터(이미지, 영상 등)를 하나의 요청(Request)에 섞어서 보낼 수 있도록 설계된 형식
  • 사용 목적

    • 일반적인 HTML Form의 기본 전송 방식은 application/x-www-form-urlencoded
      • 이 방식은 데이터를 긴 문자열(key=value&key2=value2)로 변환해서 보내는데,
      • 대용량 파일이나 이미지 같은 바이너리 데이터를 이렇게 변환하면 비효율적이고 데이터 크기가 너무 커짐
    • 반면, multipart/form-data 는 데이터를 변환하지 않고
      • 있는 그대로 쪼개서(Part) 보낼 수 있어 파일 전송에 최적화
  • 구조의 핵심

    • 이 형식의 가장 큰 특징은 Boundary(경계선)
      • 여러 종류의 데이터(텍스트, 파일 등)를 한 번에 보내야 하므로
      • 각 데이터가 어디서 시작하고 끝나는지 구분할 식별자가 필요함
    • 헤더(Header)
      • "이제부터 보낼 데이터는 ----WebKitFormBoundary... 라는 구분선으로 나뉘어 있어"라고 선언
    • 바디(Body)
      • 실제로 그 구분선을 사용해 데이터를 구역별로 나눔
  • 실제 형태

POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW  <-- 구분선 정의

------WebKitFormBoundary7MA4YWxkTrZu0gW   <-- 첫 번째 파트 시작 (텍스트 데이터)
Content-Disposition: form-data; name="username"

user123
------WebKitFormBoundary7MA4YWxkTrZu0gW   <-- 두 번째 파트 시작 (파일 데이터)
Content-Disposition: form-data; name="profile_pic"; filename="photo.jpg"
Content-Type: image/jpeg

(이미지의 실제 바이너리 데이터 내용...)
------WebKitFormBoundary7MA4YWxkTrZu0gW-- <--(마지막에 대시 2개가 붙음)
  • 중요

    • 이 형식을 백엔드(서버)에서 처리하려면 데이터를 파싱해서 파일과 텍스트를 분리하는 과정이 필요
      • 때문에 DRF에서는 parser_classes를 사용

parser_classes

  • DRF(Django REST Framework)가 들어오는 요청(Request)의 'Content-Type'을 보고
    • 그 거대한 덩어리의 데이터를 "어떻게 해석해서 Python 변수로 만들지" 결정하는 도구
  • 특히 multipart/form-data처럼 구조가 복잡한 데이터는 MultiPartParser가 없으면
    • 서버가 데이터를 읽을 수 없음
  • Parser가 하는 일 (비포 & 애프터)

    • 서버 입장에서는 데이터가 처음 도착했을 때 그냥 '0과 1로 된 긴 바이트 스트림'
      • 파서(Parser)가 개입해야 우리가 아는 데이터가 됨
  • 즉, parser_classesMultiPartParser를 등록하면,
    • DRF가 알아서 경계선(Boundary)을 기준으로 데이터를 쪼개고
    • 텍스트는 request.data 에, 파일은 request.FILES 에 예쁘게 담아줌
- 1. JSONParser								| - 2. MultiPartParser
  - Raw Data (파싱 전)						|  - Raw Data (파싱 전)
    - {"name": "kim"} (그냥 문자열)			|    - ----WebKitBound... (복잡한 텍스트/파일 혼합)
  - Python 객체 (파싱 후)						|  - Python 객체 (파싱 후)
    - request.data (딕셔너리)					|    - request.data (텍스트)
    										|    - request.FILES (파일)

2026.02.03 ✅

Pillow

  • Django에서 ImageField를 사용하여 이미지 파일의 유효성(이미지인지 아닌지 등)을 검사하려면
    • Pillow라는 라이브러리가 반드시 필요
    • poetry add Pillow

# 1. 실행 중인 컨테이너 중지 및 삭제
docker-compose down

# 2. 이미지 새로 빌드 및 실행 (이 과정에서 Pillow가 설치됩니다)
docker-compose up -d --build

image_file.read()

  • 업로드 시 image_file.read() 로 전체를 메모리에 올려 ContentFile 저장.
  • 현재 5MB 제한이면 괜찮지만 상향 시 메모리 스파이크 가능합니다.
    • 해석
      • "지금은 파일이 작아서(5MB) 괜찮지만,
      • 나중에 큰 파일을 허용하거나 동시에 많은 사람이 업로드하면 서버가 멈출 수 있다"
file_path = f"profile_images/{new_filename}"
saved_path = default_storage.save(file_path, ContentFile(image_file.read()))

file_path = f"profile_images/{new_filename}"
saved_path = default_storage.save(file_path, image_file)
구분ContentFile(image_file.read()) (기존)image_file (수정 후)
작동 방식파일을 통째로 RAM에 읽어 들인 후 저장파일을 조각(Chunk) 내어 조금씩 흘려보내며 저장
메모리 사용량파일 크기만큼 증가 (100MB 파일이면 100MB 점유)아주 적음 (설정된 Chunk 크기, 약 64KB 수준 유지)
위험성대용량 파일 업로드 시 OOM(Out Of Memory) 에러 발생 가능파일 크기가 커져도 서버 메모리에 부담 없음
비유물 10L를 한 번에 입에 머금고 옮기기호스로 연결해서 계속 흘려보내기

2026.02.04 ✅

이미지 처리 방식

저장 과정

  • user/services/profile_img_service.py
    • 경로 및 파일명 생성

      • ProfileImageService.update_profile_image 메서드 내부에서 uuid.uuid4()를 사용해
        • 파일명을 랜덤한 고유 문자열로 변경합니다.
      • 저장 경로는 profile_images/ 폴더로 지정됩니다.
      • 예: profile_images/550e8400-e29b-41d4-a716-446655440000.jpg
    • Django 스토리지 저장

      • default_storage.save(file_path, image_file)가 호출됩니다.
      • 이때 settings.py에 설정된 MEDIA_ROOT 경로가 기준이 됩니다.
      • settings.py에 MEDIA_ROOT = os.path.join(BASE_DIR, "media")로 설정되어 있으므로,
        • 컨테이너 내부에서는 /app/media/profile_images/ 경로에 파일이 생성됩니다.

실제 저장 위치

  • 여기부터 Docker가 개입하여 파일의 실제 위치를 결정
    • 컨테이너 내부 경로: /app/media/profile_images/
      • Django 애플리케이션은 이미지가 여기에 저장되었다고 생각합니다.
    • Docker 볼륨 연결 (Mount)
      • docker-compose.yml 설정에 의해 /app/media는 media_volume이라는 도커 볼륨과 연결됨
        • 즉, 파일이 /app/media에 써지는 순간,
        • 이 데이터는 컨테이너 밖으로 빠져나와 호스트(서버 컴퓨터)의 Docker 관리 영역에 저장됩니다.
    • 실제 물리적 위치 (Host Server)
      • 일반적으로 리눅스 서버 기준
        • /var/lib/docker/volumes/프로젝트명_media_volume/_data/profile_images/
        • 위치에 파일이 영구 저장됩니다.

안전한 이유

  • "안전하다"의 뜻

    • 서버(컨테이너)를 껐다 켜거나,
    • 업데이트를 위해 기존 컨테이너를 삭제하고 새로 만들어도 데이터가 사라지지 않음
  • 생명주기의 분리

    • 컨테이너(Container)
      • 일회용
      • 삭제하면 내부 데이터(Layer)도 다 날아갑니다.
    • 볼륨(Volume)
      • 영구적
      • docker-compose down으로 컨테이너를 지워도 볼륨은 삭제되지 않습니다.
        • docker volume rm 명령어를 따로 치지 않는 한 유지됨
  • 데이터의 지속성

    • Django가 이미지를 저장할 때,
      • 실제로는 컨테이너 내부가 아닌 볼륨(호스트 디스크)에 데이터를 씁니다.
        • 따라서 새 버전의 코드를 배포하기 위해 backend 컨테이너를 삭제하고 다시 build 하더라도,
        • 새로 뜬 컨테이너가 다시 media_volume을 /app/media에 연결(Mount)하기만 하면
          • 기존 이미지를 그대로 읽을 수 있습니다.

요약

[사용자 업로드] 
      ⬇️
[Django 코드] (uuid 변환)
      ⬇️
[컨테이너 내부] /app/media/profile_images/파일명.jpg (여기에 쓰는 척 하지만)
      ⬇️  (Docker Volume 연결) 🪝
[실제 서버 디스크] /var/lib/docker/volumes/.../파일명.jpg (여기에 실제로 저장됨 💾)

docker-compose.yml

# docker-compose.yml

services:
  backend:
    # ...
    volumes:
      - ./:/app                       
      - media_volume:/app/media       # 핵심!
  
  nginx:
    # ...
    volumes:
      - media_volume:/app/media       # Nginx도 같은 볼륨을 공유함

volumes:
  media_volume:                       # 도커가 관리하는 별도 저장소 생성

주의

  • "컨테이너 재시작"에는 안전하지만, "서버 컴퓨터 자체의 고장"에는 취약
    • S3 사용 시
      • AWS가 데이터를 분산 저장해 주므로 서버가 불타도 이미지는 안전합니다.
    • 현재 설정 시
      • 서버(EC2 등)의 디스크가 깨지거나 실수로 서버 인스턴스를 삭제하면 이미지도 함께 사라집니다.

2026.02.05 ✅

소셜로그인

로드맵

  • 플랫폼 개발자 센터 등록

    • 구글과 디스코드 개발자 콘솔에서 프로젝트를 만들고 Client ID와 Client Secret을 발급받아야 합니다.
  • Redirect URI 설정
  • 구글 로그인 구현
    • 가장 레퍼런스가 많은 구글로 기본 로직(인증 요청 -> 코드 수신 -> 토큰 교환 -> 유저 정보 획득)을 완성
  • 디스코드 확장
    • 구글 로직을 복사하여 End-point(주소)와 Scope(권한)만 수정합니다.
구분Google (구글)Discord (디스코드)
개발자 콘솔Google Cloud ConsoleDiscord Developer Portal
인증 요청 URLhttps://accounts.google.com/o/oauth2/v2/authhttps://discord.com/api/oauth2/authorize
토큰 교환 URLhttps://oauth2.googleapis.com/tokenhttps://discord.com/api/oauth2/token
유저 정보 URLhttps://www.googleapis.com/oauth2/v2/userinfohttps://discord.com/api/users/@me
필수 Scopeemail, profileidentify, email
Redirect URI 예시/auth/google/callback/auth/discord/callback

방식

  • Frontend: 사용자를 구글 로그인 창으로 보냄 -> 로그인 성공 시 Authorization Code를 받음.
  • Frontend -> Backend: 받은 Code를 Django API로 전송 (POST /auth/google/).
  • Backend (Django):
  • Code로 구글에게 Access Token 요청.
  • Access Token으로 구글 유저 정보(이메일 등) 요청.
  • DB에 해당 이메일이 없으면 회원가입(User 생성), 있으면 로그인 처리.
  • 최종적으로 서비스 자체 JWT(Access/Refresh)를 발급하여 응답.

참고


2026.02.06 ✅

소셜

Google Cloud Console

준비물

항목역할소셜 로그인 시 필요 여부비유
API 키구글 지도, 번역 등 유료/공용 서비스 사용량 체크X (불필요)신용카드 (서비스 결제용)
OAuth 2.0 클라이언트 ID사용자 인증(로그인) 및 프로필 정보 요청O (필수)사원증 (신원 확인용)
클라이언트 보안 비밀번호
(Client Secret)
클라이언트 ID와 쌍으로 사용되는 비밀키O (필수)사원증 비밀번호

시작

필드명설명입력 규칙실제 입력 예시 (복사해서 쓰세요)
승인된 JavaScript 원본
(JavaScript Origins)
"로그인 버튼이 있는 사이트 도메인"
브라우저가 어디서 요청을 보내는지 확인합니다.
도메인만 입력
❌ 뒤에 / 금지
❌ 경로 금지
http://localhost:3000
https://oz-union-fe-14-team1.vercel.app
승인된 리디렉션 URI
(Redirect URIs)
"로그인 성공 후 돌아올 전체 주소"
구글이 인증 코드를 들고 찾아올 주소입니다.
전체 경로 입력
⭕️ 라이브러리 규칙에 맞는
정확한 엔드포인트
http://localhost:3000/api/auth/callback/google
https://oz-union-fe-14-team1.vercel.app/api/auth/callback/google
설정 필드 (Google Console)입력해야 할 주소설명 (Why?)
승인된 JavaScript 원본
(Origins)
http://localhost:3000
(프론트엔드)
"로그인 버튼을 누르는 곳"입니다.
사용자는 3000번 포트 화면을 보고 있으므로, 구글은 3000번에서의 요청을 허용해야 합니다.
승인된 리디렉션 URI
(Redirect URIs)
http://localhost:8000/설정경로
(백엔드)
"인증 코드를 배달받을 곳"입니다.
로그인이 끝나면 구글이 8000번 서버로 직접 찾아와서 "자, 인증 코드 여기 있어!" 하고 줍니다.

환경별 분기


시작

파일 경로역할설명
user/models/social.py데이터 모델사용자(User)와 소셜 ID(Google sub값)를 연결하는 테이블을 정의합니다.
user/serializers/social_login.py데이터 검증프론트엔드에서 보낸 code 값이 있는지 검증합니다. (새로 생성 추천)
user/services/google_service.py비즈니스 로직구글 서버와 통신하여 액세스 토큰 발급사용자 정보 가져오기를 처리합니다. (새로 생성 추천)
user/views/social_login_view.pyAPI 뷰클라이언트 요청을 받아 로직을 실행하고 JWT 토큰을 응답합니다. (새로 생성 추천)
user/urls.py라우팅/google/login/ 경로를 연결합니다.

설정

  • poetry add requests
# .env
GOOGLE_CLIENT_ID=아까_구글콘솔에서_복사한_ID
GOOGLE_CLIENT_SECRET=아까_구글콘솔에서_복사한_비밀번호


# settings
import os
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET")

리디렉션


2026.02.07 ✅

2026.02.08 ✅

2026.02.09 ✅

profile
안녕하세요.

0개의 댓글