2026/03/09 Blog - 19

김기훈·2026년 3월 9일

TIL

목록 보기
159/194
post-thumbnail

코딩테스트(1966)


문제 해결

env파일 업로드

  • 처음 프로젝트 구성할때 env가 올라갔던거 같음
    • 이미지 처리하면서 aws키를 넣고 푸시했을때 오류가 발생하여 확인했음
    • SECRET_KEY / POSTGRES_PASSWORD 가 유출되었다고 생각해서 재발급 받을예정

env 만들기

  • 어제 디벨럽에서 env를 지우려고 좀 두두리다보니 로컬에서도 삭제됨
    • 이전 커밋에서 .env를 복구하기위해서 git checkout <커밋해시> -- .env를 사용하면
    • git이 다시 .env를 추적할 수 있기 때문에 과거 기록에서 복사하여 새로 파일을 만들자

과거 커밋에서 기존 .env 내용만 복사하기

# 이전 커밋 기록 중에서 .env 파일이 포함되었던 커밋들의 해시(고유번호)를 한 줄씩 짧게 출력하여 찾음
git log --oneline -- .env

# 위 명령어 결과로 나온 최신 커밋 해시(예: abc1234)를 이용해 당시의 .env 파일 내용을 터미널에 텍스트로만 출력
git show abc1234:.env

Git 히스토리에서 .env 도려내기

# 저장소의 모든 과거 커밋 내역을 탐색하며 .env 파일을 삭제하는 강력한 명령어를 강제로 실행
git filter-branch --force \

# 각 커밋의 스테이징 영역(캐시)에서 .env 파일을 삭제하며, 파일이 없더라도 에러 없이 무시하고 넘어감
--index-filter "git rm --cached --ignore-unmatch .env" \

# .env 파일 삭제로 인해 내용이 전혀 없어진 빈 커밋들을 히스토리에서 깔끔하게 제거
--prune-empty \

# 태그가 있다면 태그 정보도 변경된 히스토리에 맞게 그대로 유지하며 덮어씀
--tag-name-filter cat \

# 현재 브랜치뿐만 아니라 저장소 내의 모든 브랜치와 태그(전체 히스토리)에 대해 위 작업들을 일괄 적용
-- --all

진행 결과

(.venv) ➜  django_blog_project git:(develop) git filter-branch --force \
> --index-filter "git rm --cached --ignore-unmatch .env" \
> --prune-empty \
> --tag-name-filter cat \
> -- --all

WARNING: git-filter-branch has a glut of gotchas generating mangled history
         rewrites.  Hit Ctrl-C before proceeding to abort, then use an
         alternative filtering tool such as 'git filter-repo'
         (https://github.com/newren/git-filter-repo/) instead.  See the
         filter-branch manual page for more details; to squelch this warning,
         set FILTER_BRANCH_SQUELCH_WARNING=1.
Proceeding with filter-branch...

Rewrite b3db6d465cd97b3208deded33468932bd1c06777 (4/121) 
(0 seconds passed, remaining 0 predicted) rm '.env'
...
Rewrite 2aa9d3e3a3c52b9037ba757d696c0b265b73d3c7 (95/121) 
(4 seconds passed, remaining 1 predicted) rm '.env'
Ref 'refs/heads/develop' was rewritten
Ref 'refs/heads/feature/11-create-signup' was rewritten
...
Ref 'refs/remotes/origin/feature/59-env-error' was rewritten
Ref 'refs/remotes/origin/feature/9-social-google' was rewritten
WARNING: Ref 'refs/remotes/origin/main' is unchanged

안전한 새 .env 파일 만들기 및 키 발급

# 프로젝트 최상단 폴더에 아무 내용이 없는 빈 .env 파일을 새롭게 생성
touch .env

# Mac/Linux 또는 Git Bash에서 32바이트 길이의 강력한 무작위 난수를 생성하여 새로운 SECRET_KEY 용도로 출력
openssl rand -base64 32

깃허브(원격 저장소)에 덮어씌우기

  • 로컬의 과거 기록이 조작되었기 때문에 일반적인 푸시는 거절당함
    • 강제 푸시로 깃허브의 develop 브랜치 기록을 덮어씌워야 함
    • git push origin develop --force


테스트

터미널(로컬)에서 확인하기

# 저장소의 전체 히스토리(--all)를 샅샅이 뒤져서, 과거에 .env 파일이 포함되었거나 수정되었던 모든 커밋 기록을 찾아 출력합니다.
git log --all -- .env
  • 결과

commit c38cfb42b247a6f262c098735ef16ee8bb7fe5d9
Author: kihoon <nike6736@naver.com>
Date:   Sun Mar 8 17:49:44 2026 +0900

    S3 연동 작업 적용 (시크릿 키 완전히 제외)

commit eeaffc2fae640050a3396bc5ab53e1bc293f7951
Author: kihoon <nike6736@naver.com>
Date:   Sun Jan 25 13:20:26 2026 +0900

    ✨ feat(#2): 마이그레이션 진행

commit 951f32ff9ae1f6d7950a31615511447cb12c6a3c
Author: kihoon <nike6736@naver.com>
Date:   Sat Jan 24 12:49:34 2026 +0900

    💡 chore(#1): 프로젝트 세팅 - 2

commit b3db6d465cd97b3208deded33468932bd1c06777
Author: kihoon <nike6736@naver.com>
Date:   Fri Jan 23 17:03:22 2026 +0900

    ✨ feat(#1): docker 세팅
  • 아직 히스토리가 남아있음

    • --all 옵션으로 인해 출력된것
      • git filter-branch 명령어로 히스토리를 도려내면
      • Git은 혹시 모를 사고를 대비해 원본 히스토리를 .git/refs/original/ 경로에 몰래 백업함
        • --all 옵션은 이 숨겨진 백업본까지 전부 뒤져서 보여줌

develop 브랜치에서만 확인

# 전체(--all) 백업본을 제외하고, 현재 작업 중인 develop 브랜치의 히스토리에서만 .env 파일의 흔적을 찾음
git log develop -- .env
  • 결과


Git 백업본 완전히 삭제하기

# Git이 filter-branch 작업 시 만약을 위해 복사해둔 원본 백업(refs/original) 경로를 강제로 삭제
rm -rf .git/refs/original/

# Git의 내부 창고를 청소(Garbage Collection)하여 어디에도 연결되지 않은 과거 커밋과 찌꺼기 파일들을 
지금 당장(--prune=now) 영구 삭제
git gc --prune=now
  • 결과

  • 깃허브 원격 저장소에 반영 (최종)

    • git push origin develop --force

    • 지금 한 작업은 로컬을 비운것이기 때문에 푸시 안되긴함

실제 DB비밀번호 수정

  • .env 파일에 새로운 비밀번호를 수정한 것은
    • 장고(Django) 프로젝트가 접속할 때 제시할 "새로운 비밀번호 메모장"을 고친 것일 뿐임
  • 정작 본체인 PostgreSQL 데이터베이스 내부의 '자물쇠 비밀번호'는 아직 유출된 비밀번호 그대로인 상태
    • 이대로 두면 장고 서버를 켰을 때 데이터베이스 접속 에러(Authentication failed)가 발생함

DBeaver

  • 데이터베이스 아이디(User) 찾기

# 현재 데이터베이스에 등록된 모든 유저(role)의 이름을 조회하여 표 형태로 보여줌
SELECT rolname FROM pg_roles;
  • 비밀번호 변경 쿼리 실행

# 본인의 아이디를 넣고, 새로운 비밀번호를 설정
ALTER USER 본인_진짜_아이디 WITH PASSWORD '여기에_env에_적은_새_비밀번호_입력';

DBeaver 연결 설정(Connection) 업데이트

  • DBeaver 좌측 '데이터베이스 탐색기(Database Navigator)'에서 현재 사용 중인 코끼리 모양의 DB 연결 아이콘을 우클릭
  • 메뉴에서 [Edit Connection (연결 편집)]을 클릭
    • 'Password (비밀번호)' 입력 칸에 기존에 적혀있던 내용을 지우고 .env에 적었던 새로운 비밀번호를 붙여넣음
  • 좌측 하단의 [Test Connection (Test 연결)] 버튼을 눌러 성공(Connected) 메시지가 뜨는지 확인한 후, [확인]을 눌러 저장

기능구현

AWS

  • 어제 env유출로 무서워서 버킷이랑 iam 삭제했는데 다시 연결해야함

IAM (Identity and Access Management)

  • 비용: 100% 무료
  • 설명
    • IAM은 단순히 클라우드 환경의 출입증을 만들고 권한을 관리하는 서비스
    • 계정을 여러 개 만들거나 권한 정책을 계속 켜두어도 요금은 0원

S3 (Simple Storage Service)

  • 비용: 철저한 종량제(Pay-as-you-go) (즉, 쓴 만큼만 냄)
  • 설명
    • 버킷이라는 '빈 상자'를 만들어 두는 건 무료
    • 요금은 그 상자 안에
      • 1) 파일을 얼마나 많이 저장했는지(용량)
      • 2) 파일을 얼마나 자주 넣고 뺐는지(요청 횟수)
      • 3) 데이터를 밖으로 얼마나 내보냈는지(트래픽)에 따라 계산
      • 따라서 아무 파일도 올리지 않은 빈 버킷이라면 요금은 나가지 않음

AWS 프리티어 (신규 가입 1년 혜택)

  • 저장 용량: 매월 5GB까지 무료로 저장 가능
  • 요청 횟수: 매월 읽기(GET) 20,000건, 쓰기(PUT) 2,000건 무료 제공

문제

버킷하고 iam 바꿨더니 이미지 업로드 안됨

  • '글로벌 주소 리다이렉트로 인한 브라우저의 CORS(보안) 차단'

    • 엉뚱한 주소로 찾아가서 발생한 차단 (가장 큰 원인)

      • 파이썬(boto3)이 처음 만들어준 임시 URL은
        • 리전(서울)이 명시되지 않은 글로벌 기본 주소(s3.amazonaws.com)
      • 프론트엔드가 이 주소로 파일을 보내면
        • 글로벌 S3 서버는 "어? 이 버킷은 서울(ap-northeast-2)에 있네? 서울로 다시 가!" 하고
          • 리다이렉트(방향 틀기)
      • 그런데 웹 브라우저는 보안(CORS 정책)이 매우 엄격해서
        • 내가 처음 요청한 주소가 아닌 다른 주소로 튕겨내면 "해킹 시도인가?" 하고
        • 요청 자체를 그 자리에서 차단 (이때 발생한 에러가 아까 보신 CORS 에러)
      • 해결 방법
        • 그래서 백엔드 코드에 endpoint_url을 추가해서
        • 처음부터 글로벌 주소를 거치지 않고 곧바로 '서울 주소'로 직행하도록 내비게이션을 고쳐준 것
    • S3 버킷의 출입증(CORS) 발급

      • S3 버킷 입장에서는 브라우저(http://127.0.0.1:8000)가 직접 파일을 쑤셔 넣으려고 하니
        • 허락된 사이트인지 검사
      • 기존에는 이 검사 설정이 느슨하거나 꼬여 있었는데
        • 방금 CORS 설정을 명시적으로 업데이트하면서
        • S3가 "아, 127.0.0.1:8000은 우리 주인이 허락한 안전한 사이트구나!" 하고 문을 활짝 열어준 것

근거

# 콘솔 로그 
Access to fetch at 'https://hoon-blog-uploader-0112.s3.amazonaws.com/post/thumbnails/
2026/03/09/cf5bc13c8eca430db0a6c3aa3b1a0e54.png?...
  • Access to fetch at 'https://hoon-blog-uploader-0112.s3.amazonaws.com...'
    • 보통 서울 리전(ap-northeast-2)에 버킷을 만들고 정상적으로 URL을 발급받으면
    • 주소가 ...s3.ap-northeast-2.amazonaws.com 형식으로 나와야 함
    • 콘솔로그에는 리전 이름이 없었음
Response to preflight request doesn't pass access control check: 
No 'Access-Control-Allow-Origin' header is present...
  • 'preflight request' 에러 메시지
    • 웹 브라우저는 다른 도메인(S3)으로 무거운 파일(PUT 요청)을 보내기 전에
      • "나 파일 보낼 건데 허락해 줄 거야?"
      • 하고 가벼운 사전 질문(OPTIONS 메서드, 즉 preflight request)을 먼저 던짐
    • 판단 로직
      • 이 사전 질문에 S3가 허락 문구(Access-Control-Allow-Origin)를 주지 않고 문전박대했다는 뜻
      • S3에 분명 "*"(모두 허용)를 설정했는데 거절당했다면?
        • "최신 브라우저들은 서명(Signature)이 포함된 복잡한 요청을 할 때
        • "*" 같은 대충 만든 출입증은 무시하고 깐깐하게 차단하는 경우가 많음
        • 따라서 명확하게 http://127.0.0.1:8000을 적음
<Error>
<Code>SignatureDoesNotMatch</Code>
			...
<CanonicalRequest>GET /post/thumbnails/2026/03/09/661480bcf5ca4651bdb142f7a9ef7f93.png ...
</CanonicalRequest>
			...
</Error>
  • SignatureDoesNotMatch 에러와의 연결
    • 브라우저 창에서 URL을 직접 열었을 때 GET 방식으로 요청이 가서 서명 에러가 났음
    • 판단 로직
      • 에러 메시지를 통해 파이썬 백엔드에서 서명(Signature) 자체는 정상적으로 잘 만들어지고 있다는 것을 인지
      • 서명 로직이 멀쩡한데 업로드가 안 된다면, 백엔드 코드의 문법 문제가 아니라
        • 100% 프론트엔드와 AWS 사이의 '네트워크 보안(CORS) 규칙' 문제로 좁혀서 생각할 수 있었음

해결방안

  • 원인 1. Boto3가 생성한 URL에서 "리전(Region)"이 누락

s3_client = boto3.client(
            "s3",
            region_name=settings.AWS_S3_REGION_NAME,
            
            # 이 줄 추가 
            endpoint_url=f"https://s3.{settings.AWS_S3_REGION_NAME}.amazonaws.com",
            
            aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
            aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
            config=Config(signature_version="s3v4")
        )
  • 원인 2. S3 CORS 설정에 로컬호스트 명시

    • 기존 설정 "AllowedOrigins": ["*"]
      • 보안 서명(v4)이 포함된 요청의 경우 크롬(Chrome) 등의 브라우저가
      • *(모든 곳 허용)를 무시하고 깐깐하게 차단하는 경우가 있음
      • 현재 개발 중인 로컬호스트 주소를 명시적으로 뚫어주기
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "DELETE",
            "HEAD"
        ],
        "AllowedOrigins": [
            "http://127.0.0.1:8000",
            "http://localhost:8000",
            "*"
        ],
        "ExposeHeaders": [
            "ETag"
        ]
    }
]

ci문제(mypy잡힘)

  • mypy가 Django 플러그인을 로드하면서 config.settings를 초기화하려고 할 때
    • AWS_ACCESS_KEY_ID 환경 변수를 찾지 못해 발생한 ImproperlyConfigured 에러
Traceback (most recent call last):
Error constructing plugin instance of NewSemanalDjangoPlugin
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/environ/environ.py", line 409, in get_value
    value = self.ENVIRON[var_name]
            ~~~~~~~~~~~~^^^^^^^^^^
						...
"/home/runner/work/indi_Blog_Project/indi_Blog_Project/config/settings.py", line 229, in <module>
    AWS_ACCESS_KEY_ID = env(
                        ^^^^
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/environ/environ.py", line 207, in __call__
    return self.get_value(
           ^^^^^^^^^^^^^^^
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/environ/environ.py", line 413, in get_value
    raise ImproperlyConfigured(error_msg) from exc
django.core.exceptions.ImproperlyConfigured: Set the AWS_ACCESS_KEY_ID environment variable

원인

  • mypy가 Django 프로젝트의 타입을 정확히 검사하려면 모델과 설정 정보를 알아야 하므로
    • 내부적으로 settings.py를 실행
  • 이때 파일 내에 있는 django-environ이 필수 환경 변수들을 찾게 되는데
    • 테스트/CI 환경에는 해당 변수가 주입되지 않아서 검증 단계에서 프로그램이 뻗어버린 것

해결

  • settings.py에서 default 값 주기
    • 당장의 오류는 피할 수 있음
    • 실제 운영(Production) 환경에서 실수로 환경 변수를 누락했을 때
      • 빠르게 에러를 뱉지 않고 조용히 넘어가게 만들어, 나중에 더 찾기 힘든 장애를 유발할 수 있음
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")  
AWS_S3_REGION_NAME = env("AWS_REGION", default="ap-northeast-2") 

——————————————————————————————————————[비교]—————————————————————————————————————————
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID', default='dummy')
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", , default='dummy')
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", , default='dummy')  
AWS_S3_REGION_NAME = env("AWS_REGION", default="ap-northeast-2") 

진짜 해결

  • CI 환경(GitHub Actions)에서 더미(Dummy) 환경 변수 주입
      - name: Run Mypy (Type Checking)
        run: poetry run mypy .
        
——————————————————————————————————————[비교]—————————————————————————————————————————
      - name: Run Mypy (Type Checking)
        run: poetry run mypy .
        env: 
          AWS_ACCESS_KEY_ID: "dummy"   
          AWS_SECRET_ACCESS_KEY: "dummy" 
          AWS_STORAGE_BUCKET_NAME: "dummy" 

ci문제(migrate 잡힘)

Traceback (most recent call last):
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/environ/environ.py", line 409, in get_value
    value = self.ENVIRON[var_name]
            ~~~~~~~~~~~~^^^^^^^^^^
  File "<frozen os>", line 714, in __getitem__
KeyError: 'AWS_ACCESS_KEY_ID'

The above exception was the direct cause of the following exception:
											...
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/django/conf/__init__.py", line 62, in _setup
    self._wrapped = Settings(settings_module)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/django/conf/__init__.py", line 162, in __init__
    mod = importlib.import_module(self.SETTINGS_MODULE)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/hostedtoolcache/Python/3.12.12/x64/lib/python3.12/importlib/__init__.py", line 90, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1387, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1360, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1331, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 935, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 999, in exec_module
  File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
  File "/home/runner/work/indi_Blog_Project/indi_Blog_Project/config/settings.py", line 229, in <module>
    AWS_ACCESS_KEY_ID = env(
                        ^^^^
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/environ/environ.py", line 207, in __call__
    return self.get_value(
           ^^^^^^^^^^^^^^^
  File "/home/runner/.cache/pypoetry/virtualenvs/django-blog-project-dIrepBIM-py3.12/lib/python3.12/site-packages/environ/environ.py", line 413, in get_value
    raise ImproperlyConfigured(error_msg) from exc
django.core.exceptions.ImproperlyConfigured: Set the AWS_ACCESS_KEY_ID environment variable

해결

  • 장고는 어떤 명령을 실행하든 일단 settings.py를 모두 읽어들이기 때문에
    • 데이터베이스 마이그레이션(migrate)이나 테스트(test) 단계에서도 동일하게 환경 변수가 필요
      - name: Run Django Migration
        run: |
          poetry run python manage.py migrate
          
——————————————————————————————————————[비교]—————————————————————————————————————————
      - name: Run Django Migration
        run: |
          poetry run python manage.py migrate
        env:
          # DB 및 AWS 관련 필수 환경 변수들을 주입합니다.
          AWS_ACCESS_KEY_ID: "dummy"
          AWS_SECRET_ACCESS_KEY: "dummy"
          AWS_STORAGE_BUCKET_NAME: "dummy"

전역 env 사용

  • 두번까지는 참았지만 커버리지에서도 잡혀서 찾아보니 전역에서 처리하는게 가장 좋다는걸 알았음
    • DRY(Don't Repeat Yourself)
      • 각 step이 매우 날씬해졌고, 나중에 환경 변수 값이 바뀔 때도 최상단 한 곳만 고치면 됨
    • 안정성
      • DJANGO_SETTINGS_MODULE까지 전역에 넣어두면
      • mypy 등이 설정 파일을 못 찾는 실수를 원천 차단할 수 있음
on:
  pull_request:
    branches:
      - "main"
      - "develop"

jobs:

——————————————————————————————————————[비교]—————————————————————————————————————————
on:
  pull_request:
    branches:
      - "main"
      - "develop"

# ────────── [여기에 정의하면 모든 Job과 Step에서 공유됩니다] ──────────
env: # 워크플로우 전체에서 사용할 전역 환경 변수 설정입니다.
  AWS_ACCESS_KEY_ID: "dummy"         # 모든 스텝에서 공통으로 쓰일 더미 액세스 키입니다.
  AWS_SECRET_ACCESS_KEY: "dummy"     # 모든 스텝에서 공통으로 쓰일 더미 시크릿 키입니다.
  AWS_STORAGE_BUCKET_NAME: "dummy"   # 모든 스텝에서 공통으로 쓰일 더미 버킷 이름입니다.
  DJANGO_SETTINGS_MODULE: "config.settings" # 장고 설정을 명시적으로 지정하여 에러를 방지합니다.

배운점

Network 탭과 Console탭

  • 프론트엔드에서 API 통신이나 S3 연동을 할 때 에러가 나면
    • 개발자 도구의 Network 탭과 Console 탭에 뜨는 빨간 글씨가 정답지

대기 기능

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

프론트 수정

이미지 탬플릿 수정

수정 전

수정 후


기능구현

소셜로그인 (GitHub)

핵심 동작 원리

    1. 유저가 login.html에서 [GitHub로 로그인] 버튼을 누릅니다.
    1. 백엔드(GET)를 거쳐 깃허브 로그인 창으로 이동합니다.
    1. 로그인 성공 후 깃허브가 인가 코드(?code=...)를 달아서 다시 login.html로 돌려보냅니다.
    1. login.html의 자바스크립트가 주소창의 코드를 낚아채서 백엔드에 POST로 보냅니다.
    1. 백엔드에서 토큰을 JSON으로 반환하면
    • 자바스크립트가 기존처럼 localStorage에 저장하고 메인 화면으로 이동합니다!

1단계: 소셜 앱 등록 및 키 발급

  • GitHub 계정의 Settings -> Developer settings -> OAuth Apps에서 새로운 앱을 만듬

  • Authorization callback URL

    • 프론트엔드의 주소(예: http://localhost:3000/auth/github/callback)를 적음
  • 발급된 Client IDClient Secret을 Django 프로젝트의 .env 파일에 저장

  • 과정 이미지

  • 과정 상세화

    • 1단계: GitHub OAuth App 생성 화면 접속
      • 웹 브라우저에서 GitHub(https://github.com)에 로그인
      • 우측 상단의 본인 프로필 아이콘을 클릭하고 [Settings](설정)를 누름
      • 왼쪽 메뉴 스크롤을 맨 아래로 내려서 [Developer settings]를 클릭
      • 왼쪽 메뉴에서 [OAuth Apps]를 클릭
      • 우측 상단의 [New OAuth App] 버튼을 클릭
    • 2단계: 앱 정보 입력
      • 다음과 같이 항목을 채워 넣습니다.
        • Application name
          • 앱 이름 / 개발용이므로 임의로 적음 (예: MyBlog Dev)
        • Homepage URL
        • Application description
          • (선택 사항) 비워두셔도 됨
        • Authorization callback URL
          • 가장 중요한 부분 / GitHub 로그인 완료 후 되돌아올 우리 사이트의 주소
          • 입력
            • http://localhost:8000/login/github/callback/
            • 향후 Django urls.py에 이 주소를 만들어 줘야함
        • 다 적으셨으면 초록색 [Register application] 버튼을 클릭
    • 3단계: Client ID와 Client Secret 발급
      • 앱이 생성되면 화면 상단에 Client ID가 보임 (예: Iv1.a1b2c3d4e5... 형태)
        • 이것을 메모장에 잠시 복사해 둠
      • 그 아래에 있는 [Generate a new client secret] 버튼을 클릭
        • (필요시 비밀번호 확인 과정을 거치면) Client secrets 항목에 새로운 문자열이 생성됨
        • 주의: 이 값은 지금 당장만 보이고 창을 벗어나면 다시는 볼 수 없음 (반드시 바로 복사해야함)
    • 4단계: Django .env 파일에 저장
GITHUB_CLIENT_ID=복사한_클라이언트_ID
GITHUB_CLIENT_SECRET=복사한_클라이언트_시크릿
  • 5단계: Django settings.py에 환경변수 불러오기 설정
# settings.py의 적당한 위치
import os

# .env 파일에서 키를 읽어와 Django 설정 변수로 만듭니다.
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
  • Callback URL 포트 번호: 3000 vs 8000?

    • 프론트엔드를 어떤 기술로 구현하느냐에 따라 다름
  • 경우 A: React, Vue, Svelte 등 (SPA) 프론트엔드 프레임워크를 따로 사용하는 경우
    • 보통 프론트엔드 개발 서버가 localhost:3000이나 localhost:5173 등으로 뜸
    • 이 경우 사용자가 보는 화면이 3000번 포트이므로, 콜백도 프론트엔드 쪽으로 받아야 함
    • Callback URL: http://localhost:3000/auth/github/callback
  • 경우 B: Django Template (.html 파일)을 사용하여 프론트엔드를 그리는 경우
    • 이 경우 백엔드와 프론트엔드가 모두 Django 하나에서 돌아가므로 8000번 포트를 사용
    • Callback URL: http://localhost:8000/api/user/login/github/callback
    • 또는 원하는 URL 경로

2단계: 서비스 로직 구현

# apps/user/services/social_login_service.py
import requests # HTTP 요청을 보내기 위한 라이브러리입니다.
from django.conf import settings # Django 설정에서 Client ID 등을 가져오기 위해 임포트합니다.
from django.contrib.auth import get_user_model # 커스텀 User 모델을 안전하게 가져옵니다.
from django.db import transaction # DB 상태를 안전하게 유지하기 위해 트랜잭션을 사용합니다.
from rest_framework_simplejwt.tokens import RefreshToken # JWT 토큰 생성을 위해 임포트합니다.
from apps.user.models.social_account import SocialAccount # 만들어두신 소셜 계정 모델을 임포트합니다.

User = get_user_model() # User 모델 클래스를 변수에 할당합니다.

class GithubLoginService:
    # 메서드를 인스턴스화 없이 사용할 수 있도록 정적 메서드로 선언합니다.
    @staticmethod
    def github_login(code: str):
        # 1. 깃허브에서 Access Token을 받아오기 위한 URL입니다.
        token_req_url = "https://github.com/login/oauth/access_token"
        
        # 2. Access Token을 요청할 때 보낼 데이터(페이로드)를 구성합니다.
        data = {
            "client_id": settings.GITHUB_CLIENT_ID, # 환경변수에 저장된 Client ID
            "client_secret": settings.GITHUB_CLIENT_SECRET, # 환경변수에 저장된 Client Secret
            "code": code, # 프론트엔드로부터 전달받은 인가 코드
        }
        
        # 3. 깃허브 API가 JSON 형태로 응답하도록 헤더를 설정합니다.
        headers = {"Accept": "application/json"}
        
        # 4. 깃허브 서버로 POST 요청을 보내 토큰을 발급받습니다.
        token_req = requests.post(token_req_url, data=data, headers=headers)
        
        # 5. 응답받은 JSON 데이터에서 access_token 값만 추출합니다.
        token_json = token_req.json()
        error = token_json.get("error")
        
        # 6. 토큰 발급 중 에러가 발생했다면 예외를 발생시킵니다.
        if error is not None:
            raise ValueError("GitHub 토큰을 받아오는데 실패했습니다.")
            
        access_token = token_json.get("access_token")

        # 7. 발급받은 토큰으로 깃허브 유저 정보를 요청할 URL입니다.
        user_req_url = "https://api.github.com/user"
        
        # 8. 토큰을 Authorization 헤더에 담아 GET 요청을 보냅니다.
        user_req = requests.get(
            user_req_url, headers={"Authorization": f"Bearer {access_token}"}
        )
        
        # 9. 응답받은 유저 정보를 JSON 객체로 변환합니다.
        user_json = user_req.json()
        
        # 10. 깃허브의 유저 고유 ID와 아이디(login)를 가져옵니다.
        github_id = str(user_json.get("id"))
        nickname = user_json.get("login")
        
        # 11. 깃허브는 이메일을 비공개할 수 있으므로, 이메일이 없다면 추가 API 호출이 필요할 수 있습니다. (여기선 제공됐다고 가정)
        email = user_json.get("email", f"{github_id}@github.dummy.com")

        # 12. DB 작업 중 오류 발생 시 롤백하기 위해 트랜잭션 블록을 엽니다.
        with transaction.atomic():
            # 13. SocialAccount 테이블에서 깃허브 ID로 등록된 소셜 계정이 있는지 찾습니다.
            social_account = SocialAccount.objects.filter(
                provider="github", social_id=github_id
            ).first()

            # 14. 소셜 계정이 이미 존재한다면 (기존 가입 유저)
            if social_account:
                user = social_account.user # 해당 소셜 계정과 연결된 User 객체를 가져옵니다.
                
            # 15. 소셜 계정이 없다면 (신규 가입 유저)
            else:
                # 16. 혹시 같은 이메일로 가입한 기존 일반 유저가 있는지 확인합니다.
                user = User.objects.filter(email=email).first()
                
                # 17. 일반 유저도 없다면 새로운 유저를 생성합니다. (UserManager의 create_user 활용)
                if not user:
                    user = User.objects.create_user(
                        email=email,
                        nickname=nickname,
                        password=None # 소셜 로그인이므로 비밀번호는 사용 불가 처리됩니다.
                    )
                
                # 18. 새로 생성한(혹은 기존) 유저와 깃허브 ID를 연결하는 SocialAccount 레코드를 생성합니다.
                SocialAccount.objects.create(
                    user=user,
                    provider="github",
                    social_id=github_id
                )

        # 19. 유저 인증이 완료되었으므로, 프론트엔드에 전달할 자체 JWT 토큰을 생성합니다.
        refresh = RefreshToken.for_user(user)

        # 20. Access Token, Refresh Token, 그리고 유저 정보를 딕셔너리 형태로 반환합니다.
        return {
            "access_token": str(refresh.access_token),
            "refresh_token": str(refresh),
            "user": user,
        }

callback

  • 깃허브에서 로그인이 끝나면 유저를 개발중인 사이트로 다시 돌려보내는데
    • 그때 받을 마중 나오는 문(callback URL)
  • 로그인 시작 (/login/github/)
    • 유저가 "깃허브로 로그인" 버튼을 누르면, 이 주소에서 유저를 깃허브 서버로 리다이렉트(이동) 시켜줌
  • 콜백 처리 (/login/github/callback/)
    • 깃허브에서 로그인을 마친 유저가 ?code=어쩌구를 달고 이 주소로 돌아오면
    • 코드를 가로채서 이전 단계에서 만든 서비스 로직을 실행

3단계: View 로직 구현

  • 프론트엔드(React/Vue 등)와 백엔드가 분리된 환경

from django.shortcuts import redirect
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from apps.user.services.social_login_service import GithubLoginService
from rest_framework.response import Response
from rest_framework import status

class GithubLoginAPIView(APIView):
    """
    1. 유저가 '깃허브 로그인' 버튼을 눌렀을 때 깃허브 서버로 보내주는 역할만 합니다.
    """
    permission_classes = [AllowAny]

    def get(self, request):
        # 1. settings에 저장된 클라이언트 ID를 가져옵니다.
        client_id = settings.GITHUB_CLIENT_ID

        # 2. 깃허브에 등록한 콜백 주소입니다. (이리로 다시 돌려보내 달라는 뜻)
        redirect_uri = "http://127.0.0.1:8000/api/v1/user/login-page/"

        # 3. 깃허브의 권한 인증 페이지 URL을 만듭니다.
        github_auth_url = f"https://github.com/login/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}"

        # 4. 유저의 브라우저를 깃허브 로그인 창으로 강제 이동(리다이렉트) 시킵니다.
        return redirect(github_auth_url)


class GithubLoginCallbackAPIView(APIView):
    """프론트엔드가 넘겨준 코드를 받아 토큰을 발급하는 API (POST)"""
    permission_classes = [AllowAny]

    def post(self, request):
        code = request.data.get("code")

        if not code:
            return Response({"error": "인가 코드가 필요합니다."}, status=status.HTTP_400_BAD_REQUEST)

        try:
            # 1. 서비스 호출
            login_data = GithubLoginService.github_login(code)

            # 2. JSON 형태로 토큰 응답 (프론트엔드가 받아서 localStorage에 저장할 데이터)
            return Response(
                {
                    "message": "GitHub 로그인 성공",
                    "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)

소셜로그인 문제

Authorization callback URL 문제

  • 백엔드 코드에서 깃허브로 보낸 redirect_uri 주소와
    • 깃허브 개발자 센터에 등록해 둔 Authorization callback URL이 "단 한 글자라도 다를 때" 발생

소셜 로그인 실패: 이메일은 필수 입력값입니다.

class UserManager(BaseUserManager):
    def create_user(self, email, nickname, password=None, **extra_fields):
        if not email:
            raise ValueError("이메일은 필수 입력값입니다.")
		...
  • UserManager에서 if not email: 일 때 발생시키는 에러
    • 원인은 깃허브의 정책
      • 유저가 깃허브 계정 설정에서 이메일을 '비공개(Private)'로 설정해 둔 경우
      • 깃허브 API가 유저 정보를 줄 때 이메일 자리에 {"email": null}을 넣어서 줌
    • 기존 코드에 작성했던 user_json.get("email", "기본값")은 아예 키가 없을 때는 기본값을 주지만
      • 깃허브처럼 null 값으로 키를 보내주면 기본값이 아닌 None을 반환해 버림
      • 그래서 email 변수에 None이 들어가서 모델 매니저에서 에러가 터진 것

해결

  • email 값이 None일 때 확실하게 더미(가짜) 이메일이 들어가도록 파이썬의 if문으로 한 번 더 걸러주기
  • 이메일이 없는 유저도 안전하게 숫자아이디@github.dummy.com 이라는
    • 고유한 더미 이메일로 가입 처리가 되면서 정상적으로 로그인
# 10. 깃허브의 유저 고유 ID와 아이디(login)를 가져옵니다.
        github_id = str(user_json.get("id"))
        nickname = user_json.get("login")

        # 11. 깃허브는 이메일을 비공개할 수 있으므로, 이메일이 없다면 추가 API 호출이 필요할 수 있습니다. (여기선 제공됐다고 가정)
        email = user_json.get("email", f"{github_id}@github.dummy.com")
        
——————————————————————————————————————[비교]—————————————————————————————————————————
# 10. 깃허브의 유저 고유 ID와 아이디(login)를 가져옵니다.
        github_id = str(user_json.get("id"))
        nickname = user_json.get("login")

        # 11. 깃허브 이메일이 비공개(null)로 올 경우를 대비하여 확실하게 처리합니다.
        email = user_json.get("email")
        if not email:
            email = f"{github_id}@github.dummy.com"

성공


의문

'콜백(Callback)'

  • '콜백(Callback)'의 진짜 의미와 현재 우리 코드에서 어떻게 사용되고 있는가?
    • Callback(콜백)이란

      • "깃허브야, 유저가 로그인 승인하면 이 주소로 다시 돌려보내 줘(Call back)!"
        • 라고 지정해 두는 '마중 나가는 정거장'
    • 왜 따로 콜백 페이지(HTML)는 안만드는가?

      • callback.html 이라는 빈 화면을 따로 만들어서 처리할 수도 있음
      • 하지만 굳이 만들지 않고 기존의 login.html을 콜백 정거장으로 재활용한 이유가 있음
    • 이유

      • 현재 내 코드는 로그인 성공 시 JWT 토큰을 브라우저의 localStorage에 저장함
      • 서버(Django 백엔드)는 유저의 브라우저 localStorage에 직접 접근해서 값을 넣을 수 없음
        • 오직 브라우저에 띄워진 HTML 안의 자바스크립트(<script>)만이 저장 가능
      • 그래서 유저가 깃허브 로그인을 마치고 돌아올 때, 아예 새로운 빈 화면으로 보내는 것보다
        • 원래 있던 로그인 페이지(login-page)로 돌려보내는 것이 자연스럽기 때문에 이렇게 함

Callback이 하고있는 일

  • 프론트엔드 콜백 (login.html)
  • 백엔드 콜백 (views/social_login.py의 GithubLoginCallbackAPIView)

    • 프론트엔드 자바스크립트가 POST 요청으로 코드를 넘겨주면, 백엔드 API가 작동
  • 즉, callback이라는 이름의 웹페이지 화면을 안 만들었을 뿐, login.html이 그 역할을 대신하고 있음


profile
안녕하세요.

0개의 댓글