[2026.02.01] 참조 명령어 재료
[2026.02.02] OneToOneField / 무한스크롤 원리
[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"
)
genre = models.ForeignKey(Genre, ...)
__genre
- GameGenre → Genre (정참조)
- 중간 테이블인 GameGenre에 도착
- 여기에는 genre라는 필드가 있고, 이 필드는 진짜 Genre 테이블을 가리킴
- 의미: "이 연결 데이터가 가리키는 진짜 장르 정보를 가져와라"
- 이동: GameGenre 테이블 ➡ Genre 테이블
class GameGenre(models.Model):
game = models.ForeignKey(...)
genre = models.ForeignKey(Genre, ...)
__genre
- Genre → 값 (필드 접근)
- Genre 테이블에 도착
- 이제 필터링하고 싶은 실제 컬럼(값)을 지정해야 함
class Genre(models.Model):
genre = models.CharField(...)
slug = models.SlugField(...)
정리
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"가 들어갔다고 욕설로 오판가능
- 반면, 이 라이브러리는 주변 문맥과 단어의 조합을 분석하여
가벼운 의존성
- 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)
확률 예측 (predict_prob)
- 단순히 0과 1이 아니라,
- 욕설일 확률(Probability)을 반환 (민감도를 조절하고 싶을 때 유용)
from profanity_check import predict_prob
texts = ['Go to hell']
result = predict_prob(texts)
print(result)
장단점
장점
- 정확도: 단순 필터링보다 훨씬 똑똑하게 문맥을 파악합니다.
- 속도: 모델이 가벼워 실시간 채팅이나 댓글 필터링에 적합합니다.
- 편의성: 별도의 금지어 리스트(Blacklist)를 관리할 필요가 없습니다.
단점
- 한국어 미지원: 영어 데이터로 학습되었기 때문에 한국어 욕설은 거의 탐지하지 못합니다.
설치: pip install korcen
- 파이썬으로 작성된 한국어 비속어 차단 및 탐지 라이브러리
- 규칙(Rule)과 사전(Dictionary) 기반으로 작동합니다.
- 하지만 단순한 단어 일치(Ctrl+F) 수준을 넘어,
- 한국어 욕설 사용자들이 흔히 쓰는 변형/우회 패턴을 잡아내는 데 특화
핵심 특징 (변형 탐지)
- 단순히 "시발"을 막는 게 아니라, 다음과 같은 회피형 욕설을 매우 잘 잡아냅니다.
- 의미 없는 문자 삽입: 시!@#발, 개...새..끼
- 자음/모음 분리: ㅅㅂ, ㅄ
- 숫자/특수문자 혼용: ㅅ1발, 10새
- 반복 문자: 미친ㄴㄴㄴㄴ
- AI 모델처럼 문맥을 읽지는 못하지만
- 욕설을 작정하고 숨기려는 시도를 패턴 매칭으로 찾아내는 능력이 탁월
사용법
- check(탐지)와 filter(마스킹) 두 가지가 메인
from korcen import korcen
text = "이런 시!@#발 진짜 게임 족같이 만드네"
is_bad = korcen.check(text)
print(f"욕설 포함 여부: {is_bad}")
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: 기존 목록 아래에 이어 붙임.
- 웹 클라이언트(브라우저 등)가 서버로 데이터를 전송할 때 사용하는 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_classes 에 MultiPartParser를 등록하면,
- 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_classes 에 MultiPartParser를 등록하면,
- 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
docker-compose down
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
services:
backend:
volumes:
- ./:/app
- media_volume:/app/media
nginx:
volumes:
- media_volume:/app/media
volumes:
media_volume:
주의
- "컨테이너 재시작"에는 안전하지만, "서버 컴퓨터 자체의 고장"에는 취약
- S3 사용 시
- AWS가 데이터를 분산 저장해 주므로 서버가 불타도 이미지는 안전합니다.
- 현재 설정 시
- 서버(EC2 등)의 디스크가 깨지거나 실수로 서버 인스턴스를 삭제하면 이미지도 함께 사라집니다.
2026.02.05 ✅
소셜로그인
로드맵
플랫폼 개발자 센터 등록
- 구글과 디스코드 개발자 콘솔에서 프로젝트를 만들고 Client ID와 Client Secret을 발급받아야 합니다.
- Redirect URI 설정
- 구글 로그인 구현
- 가장 레퍼런스가 많은 구글로 기본 로직(인증 요청 -> 코드 수신 -> 토큰 교환 -> 유저 정보 획득)을 완성
- 디스코드 확장
- 구글 로직을 복사하여 End-point(주소)와 Scope(권한)만 수정합니다.
| 구분 | Google (구글) | Discord (디스코드) |
|---|
| 개발자 콘솔 | Google Cloud Console | Discord Developer Portal |
| 인증 요청 URL | https://accounts.google.com/o/oauth2/v2/auth | https://discord.com/api/oauth2/authorize |
| 토큰 교환 URL | https://oauth2.googleapis.com/token | https://discord.com/api/oauth2/token |
| 유저 정보 URL | https://www.googleapis.com/oauth2/v2/userinfo | https://discord.com/api/users/@me |
| 필수 Scope | email, profile | identify, 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 ✅
소셜
준비물
| 항목 | 역할 | 소셜 로그인 시 필요 여부 | 비유 |
|---|
| 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번 서버로 직접 찾아와서 "자, 인증 코드 여기 있어!" 하고 줍니다. |
환경별 분기
- 프론트가 로컬(3000)에서 작업 중일 때
- 프론트가 배포(Vercel) 서버에서 실행 중일 때
- 만약 프론트가 배포된 Vercel 사이트에서 테스트하는데,
- redirect_uri를 localhost:8000으로 보내버리면 "redirect_uri_mismatch" 에러가 뜹니다.

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