oz_externship - trouble

김기훈·2025년 12월 10일

부트캠프 프로젝트

목록 보기
16/39

추천

  • warp 설치 사용 권장

  • OWASP


TroubleShooting


2025/12/08

  • DB_HOST = localhost
    • postgres://postgres:pw1234@localhost:5432/ozcoding_externship 연결시도
  • 문제 1
    • docker에서 postgres 컨테이너가 충돌 상태였음
    • 그러니 localhost:5432 에 접속하면 → 접속 거절(Connection Refused)
  • 문제 2
    • docker-compose 환경에서는 DB_HOST=postgres 를 사용해야 정상 작동
  • 해결
    • docker compose up db -d
    • docker compose는 postgres 라는 서비스명을 가진 Postgres 컨테이너를 생성
    • Django가 실제로는 localhost를 보고 있었지만,
      • compose 환경에서는 DB가 정상적으로 뜨니 충돌이 사라진 것

1) Django의 DATABASE 설정이 docker-compose 기준(HOST=db)인데,
단독 docker run으로 실행한 Postgres는 해당 이름/네트워크가 아니어서
Django가 DB를 찾지 못해 connection refused 발생.

2) Django가 PostgreSQL(DB)에 접속하려고 했는데, localhost:5432에서 실행 중인 Postgres 서버가 아예 없음. → 그래서 showmigrations가 DB에 연결을 못하고 터짐.

3) docker compose -f docker-compose.local.yml up db -d 로 실행하면
compose 네트워크·서비스 이름(db)·환경변수가 Django 설정과 정확히 일치하여
Django → Postgres 연결이 정상적으로 이루어져 문제 해결됨.

2025/12/09

  • git pull 오류
    • 원인

      • 현재 작업 중인 브랜치(develop)가 원격(origin)의
      • 어떤 브랜치를 추적해야 하는지 설정이 안 되어 있어서 git pull을 못함
    • 해결 1

      • develop 브랜치를 사용하는 거라면 원격의 origin/develop을 추적하도록 설정
      • git branch --set-upstream-to=origin/develop develop
      • 결과: develop 브랜치에서 그냥 git pull만 입력해도 자동으로 origin/develop에서 가져옴
    • 해결 2

      • git pull -u origin develop
      • -u 옵션: 이제 앞으로 이 브랜치는 origin/develop을 기본으로 사용하겠다.
  • 결과

    • 내가 develop 브랜치에 있을 경우 git pull 하면 origin/develop 에서 가져옴
    • 주의 ⚠️: 다른 브랜치(feature/login, main 등)에 있을 때는 develop이 pull 불가

  • 문제:
    • develop브랜치 내용 pull하고 새로운 브랜치 파고나니 로컬에서는 코드의 변화가 없고
    • 레포에는 develop 내용 푸시되어 있음
  • 해결:
    • 아직 코드 작성이 많이 진행된것도 아니기 때문에 그냥 develop상태로 강제 리셋
    • develop 최신 코드 가져오기
      • git fetch origin
    • 현재 브랜치를 origin/develop 기준으로 강제 리셋
      • git reset --hard origin/develop

  • 스웨거 질문 등록 specapi 테스트중 401 뜸
  • 이유: permission_classes가 설정되지 않아서
    • settings.py 에서 기본 권한을 지정하지 않았다면 대부분의 프로젝트에서는 “인증 필요” 함
    • 즉, 로그인하지 않으면 → 모든 API가 401 Unauthorized 를 발생시키는 상태가 되는 것.
  • 해결: Spec API에 명시적으로 AllowAny 추가
    • permission_classes = [AllowAny]

  • 이번에는 500에러 발생
    • 이유
      • specapi에서 category는 ForeignKeyField이기 때문에 category가 실제로 DB에 존재하는지 검증
      • 하지만 Spec API에서는 DB 테이블을 만들지 않았음
      • ModelSerializer는 모델 기반으로 자동 필드를 만들기 때문에
        • ForeignKeyField도 자동으로 "queryset 기반 검증"을 만듬
        • 즉, ModelSerializer는 무조건 ForeignKey를 DB에서 검증하려고 한다.
    • 해결 방법
      • category를 IntegerField로 override 해서 DB 조회를 막는다
      • Spec API에서는 category를 FK로 검증할 필요가 없음
      • 그냥 “번호(integer)”만 받게 함
        • 즉, ModelSerializer에서 category 필드를 재정의

2025/12/10

  • python manage.py migrate 오류
    • psycopg2.errors.DuplicateTable: relation "users" already exists
  • 원인 1 — DB는 초기화 안 됐는데, migrations는 초기 상태라고 생각함
    • DB 안에는 users 테이블이 이미 존재 (이전에 로컬에서 만듬)
    • 하지만 Django는 user 앱의 0001_initial을 처음 실행한다고 착각해서
      • 이미 존재하는 테이블을 또 만들려고 함
  • 해결방법
    • 로컬 DB 완전 초기화 후 마이그레이션
docker compose -f docker-compose.local.yml down -v
docker compose -f docker-compose.local.yml up -d
python manage.py migrate

  • 로컬에서는 poetry run black . 통과했지만 CI(GitHub Actions)에서는 black에 잡힘
  • 원인
    • 로컬에서는 poetry run black .을 그냥 실행(자동 수정)
      • black이 자동으로 파일을 고쳐줌
      • 그래서 실행 후에는 모두 포맷이 맞음 → 성공
    • CI(GitHub Actions)에서는 poetry run black . --check
      • (수정하지 않고 "형식이 맞는지"만 검사) 를 실행
      • black이 자동 수정은 하지 않음, “어떤 파일이 수정되어야 하는지”만 검사
      • spec/views.py, models.py 두 파일이 black 규칙에 맞지 않음
  • 해결방법
poetry run black .
git add .
git commit -m "chore: format code with black"
git push

  • poetry run dmypy run -- . 오류
    • 테스트 함수는 반환값이 없으므로 -> None 추가
def create(self, validated_data):from typing import Any, Dict

def create(self, validated_data: Dict[str, Any]) -> Question:
from rest_framework.request import Request
from rest_framework.response import Response

def post(self, request: Request) -> Response:

  • 해결: 각각 타입 지정

  • 문제: develop를 리베이스 하고나서 아래의 문장 뜸
You have 2 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): courses.
  • 원인
    • 리베이스를 하면 develop 브랜치에 존재하는 모든 변경사항(코드 + 마이그레이션 파일)을
      • feature 브랜치에 재적용함
    • 로컬에서는 적용되지 않은 courses 앱의 마이그레이션 파일이 develop 브랜치에 존재했었기 때문에
      • 리베이스 과정에 그 파일이 내려온 것
  • 해결
    • python manage.py migrate
    • 이미 develop 브랜치에 있는 migration이기 때문에 makemigrations는 안해도 됨
      • 오히려 하면 불필요한 migration 파일이 생길 가능성 있음
      • 팀 프로젝트에서 migration 충돌 위험 증가

2025/12/11

  • 문제: 시리얼라이저 / 뷰 코드 작성 완료 후 테스트 코드 실행했으나 모두 성공처리
    • 실패할 테스트도 통과해버림
  • 원인: 유저테이블에서 필요로하는 정보를 다 채우지않고 유저를 생성해버려서 오류 발생
  • 해결: 유저 모델에서 요구하는 필드 전부 추가하여 다시 테스트

  • 문제: 코드 구성 완료 후 PR을 올렸더니 CI/TEST에서 잡힘 (3번)
  • 원인: views.py는 분기 6개를 가지고 있는데 Test코드는 3개만 커버했었음
    • 각각 전부 추가했지만 401은 DRF가 알아서 처리 하기 때문에 굳이 적을 필요 없음
    • 401은 IsAuthenticated 이거 때문에 절대 내부 진입이 불가
  • 해결: 각각의 분기마다 테스트코드 작성 및 401은 그냥 삭제
    • permission_classes가 이미 처리
  • ci는 특히 views.py에 검증코드를 많이 넣어서 그런것으로 예상

2025/12/12

  • 문제: qna구조로 구조 전체를 변경하면서 코드를 분리했는데 테스트 코드가 전부 201을 뱉음
  • 원인
    • API 테스트가 기대하는 것
      • ✅ 성공 → 201 + { question_id }
      • ✅ 권한 실패 → 403
      • ✅ 제목 중복 → 409
      • ✅ 잘못된 카테고리 → 404
    • 수정한 API View 코드가 실제로 호출되지 않고 있다
    • 그래서 아무리 View를 고쳐도 DRF 기본 동작(CreateAPIView 스타일) 이 계속 실행되고,
    • 결과가 전부 201 + serializer.data로 나온다.
  • 해결
    • url이 specapi의 views를 가리키고 있었음
      • 수정후의 4개의 오류에서 1개로 감소
  • 문제 1. 400 - title / content / category 없는 경우
    • 400을 출력해야 하지만 404 - 존재하지 않는 카테고리 때문에
      • 카테고리가 없으면 무조건 404 출력
  • 해결
    • "category" 에러여도 ‘없는 값’이면 400, ‘존재하지 않는 id’면 404로 설정

2025/12/14

  • 문제: 질문 조회 API의 SpecAPI를 테스트하다가 갑자기 405 뜸
  • 원인
        path("spec/questions", QuestionCreateSpecAPIView.as_view()),
        path("spec/questions", QuestionListSpecAPIView.as_view()),
    • Django가 같은 URL에 서로 다른 View를 두 개 등록했다 로 해석함
    • Django는 “GET이면 이 View, POST면 저 View” 이런 판단은 절대 안 함
      • 그래서 Django는 spec/questions에 대해 마지막에 선언된 View 하나만 연결
      • 그 View에 요청 메소드가 없으면 405 Method Not Allowed
  • 해결
# 실제 API → View가 하나 → Method는 View 내부에서 분기
class QuestionAPIView(APIView):
    def get(self): ...
    def post(self): ...
  • specapi에서도 이렇게 사용하면 같은 url로 처리 가능하지만 specapi는
    • 각 기능마다 요구사항이 다르기 때문에 한 view에 섞으면
    • 가독성 ↓ / Swagger 문서 혼란 / 이후 유지보수 지옥 → Spec API는 URL을 분리하는 게 정석
/spec/questions          # GET  (목록 스펙)
/spec/questions/create   # POST (등록 스펙)

  • 문제2. 질문등록 예외처리 메세지가 조회에서도 동일하게 나옴

    • 원인: 등록하고 조회를 같은 APIView에서 정의해야 하기 때문에
      • isinstance(view, QuestionCreateAPIView)로는 더 이상 구분이 안 됨
    • 해결: 예외처리 메세지의 기준을 View 클래스 이름이나 url이 아닌
      • HTTP Method (request.method) / Exception 타입 으로 한다.
    • 정석 해결책
      • request.method를 기준으로 분기
# 기존
def custom_exception_handler(
    exc: Exception,
    context: dict[str, Any],
) -> Optional[Response]:
    response = exception_handler(exc, context)
    if response is None:
        return None

    view = context.get("view")
    is_question_create_api = isinstance(view, QuestionCreateAPIView)

    # "포맷 통일" detail -> error_detail(메시지 내용은 바꾸지 않음)
    ## 403도 따로 설정하지 않아도 포맷되서 error_detail로 나옴
    if isinstance(response.data, dict) and "detail" in response.data:
        response.data = {"error_detail": response.data["detail"]}

    # 1) 400: serializer validation
    if isinstance(exc, ValidationError) and is_question_create_api:
        response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}

    # 2) 401: 인증 안 됨
    elif isinstance(exc, NotAuthenticated) and is_question_create_api:
        response.data = {"error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."}

    # 3) 404: 카테고리 없음
    elif isinstance(exc, CategoryNotFoundError) and is_question_create_api:
        response.data = {"error_detail": str(exc.detail)}

    # 4) 409: 중복제목
    elif isinstance(exc, DuplicateQuestionTitleError) and is_question_create_api:
        response.data = {"error_detail": str(exc.detail)}

    return response

# 변화
def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:
        return None

    request = context.get("request")
    method = request.method if request else None

    # 공통 포맷 통일 -> 403도 알아서 처리 
    if isinstance(response.data, dict) and "detail" in response.data:
        response.data = {"error_detail": response.data["detail"]}

    # POST /questions (등록)
    if method == "POST":
        if isinstance(exc, ValidationError):
            response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}

        elif isinstance(exc, NotAuthenticated):
            response.data = {"error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."}

        elif isinstance(exc, CategoryNotFoundError):
            response.data = {"error_detail": str(exc.detail)}

        elif isinstance(exc, DuplicateQuestionTitleError):
            response.data = {"error_detail": str(exc.detail)}

    # GET /questions (조회)
    elif method == "GET":
        if isinstance(exc, ValidationError):
            response.data = {"error_detail": "유효하지 않은 질문 목록 조회 요청입니다."}

    return response
  • 단점: 다른 api의 GET 기능이랑 메세지 겹칠 수 있음

2025/12/17

  • 문제: view안에서 view를 잘못 호출함
    • 질문 등록 / 조회 두가지 api가 같은 엔드포인트를 가지고
    • 서로 권한부여가 다르기에 그냥 아래와 같이 관리하기로 하였으나 테스트할때 펑펑
class QuestionAPIView(APIView):
    def get(self, request, *args, **kwargs):
        return QuestionListAPIView.as_view()(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return QuestionCreateAPIView.as_view()(request, *args, **kwargs)
  • 원인: DRF가 QuestionAPIView에 들어오면서 HttpRequest → DRF Request 로 변환
    • 그 DRF Request를 다시 QuestionListAPIView.as_view()에 넘김
    • 그런데 .as_view()는 HttpRequest를 기대함 그래서 타입 충돌 → AssertionError
  • 해결: 뷰를 하나로 합침
    • 권한때문에 나눈 거였는데 아래와 같이 사용해도 되는걸 몰랐음
      • self.validation_error_message = "유효하지 않은 목록 조회 요청입니다."

  • 문제2: 원래 잘 되던 질문등록 api 테스트 코드가 갑자기 터짐
    • 401에러 메세지가 포매팅이 안되고 drf자체 에러 메세지 표기 됨
  • 원인: Authentication 단계에서 NotAuthenticated 발생 → Permission까지 오지도 않음
    • 그리하여 Permission에서 raise QuestionCreateNotAuthenticated() 해도 안됨
  • 해결: 뷰에서 동적으로 처리하도록 변경
    • 클래스단에서 전체 허용해놓고 포스트에서만 권한처리 했는데 전혀안되서 그냥 전체적으로 관리

  • 문제3: 문제 2를 해결하기 위해 아래와 같이 작성했으나 POST가 항상 401
class QuestionAPIView(APIView):
    authentication_classes = []

    def get_permissions(self) -> list[BasePermission]:
        # GET: 모두 허용
        if self.request.method == "GET":
            return []

        # POST: 질문 등록 권한
        if self.request.method == "POST":
            return [QuestionCreatePermission()]

        return []
  • 원인: 클래스 레밸에 authentication_classes = []를 박아버림
    • 이렇게 되면 DRF가 아예 인증을 수행하지 않음 그렇게 되면
      • 토큰/세션을 보내도 request.user가 AnonymousUser로 남고,
      • QuestionCreatePermission에서 무조건 401 예외
  • 해결: GET만 인증 비활성, POST는 기본 인증 사용
    • 클래스 레밸에 있는 authentication_classes = []만 지우면
  • GET → permission 검사 ❌ (누구나 가능) / POST → QuestionCreatePermission에서 검사
    • 기존 조건에 맞기 때문에 작동은 하지만 안전하지 않다
    • DRF 요청 흐름은 Authentication → Permission → View
      • get_permissions()는 인증이 끝난 다음에 실행됨
class QuestionAPIView(APIView):

    def get_authenticators(self):
        if self.request.method == "GET":
            return []
        return super().get_authenticators()

    def get_permissions(self):
        if self.request.method == "POST":
            return [QuestionCreatePermission()]
        return []
# GET /questions
get_authenticators()
→ []
→ 인증 생략
→ request.user = AnonymousUser

get_permissions()
→ []
→ 권한 검사 생략

→ 목록 조회 성공 (200)

POST /questions
get_authenticators()
→ super()
→ [JWTAuthentication]

JWTAuthentication.authenticate()
→ 토큰 검증
→ request.user = User

get_permissions()
→ [QuestionCreatePermission]
→ request.user.is_authenticated == True
→ 질문 생성 성공

profile
안녕하세요.

0개의 댓글