백엔드트러블슈팅

같은 IDOR 패턴이 MyCourse · Wishlist 에 그대로 남아 있던 사건 + OAuth 후 권한 재검증 — POP-SPOT v2.9

김동현
··4분 읽기

v2.7 에서 GameController IDOR 만 봉합했는데 같은 패턴이 MyCourse · Wishlist 에 그대로 남아 있었음. OAuth 후 localStorage 의 role/isPremium 의 서버 재검증, RuntimeException 3건 409 격상, 지도 마커 N+1 해결.

v2.7 에서 GameController 하나만 잡았는데, 같은 패턴이 MyCourse · Wishlist 에 또 남아 있었다. 코드 리뷰 하다가 발견. 다른 고감도 이슈 몇 개도 같이 정리한 버전.

이 글에서 다루는 것

  • 같은 종류의 IDOR 가 왜 제각기 있었는지 (같은 패턴으로 쓰인 코드의 일괄 검색 중요성)
  • OAuth 로그인 후 localStorage 에 저장된 role / isPremium 을 감사 없이 믿지 않고 서버 재검증 하는 이유
  • 외부 서비스 (AI · 메일) 장애를 400 이 아닌 409 로 처리하는 이유
  • 지도 마커가 팝업 전체를 메모리에 올리다가 SQL WHERE 절로 좁힌 과정
  • MyCourse 를 JPA 엔티티 그대로 직렬화했는데 DTO 로 변환한 이유
  • 모르는 단어 한 줄로

    용어한 줄 설명
    IDOR주소창/요청 본문의 id 를 서버가 그대로 신뢰해 남의 것을 조작할 수 있는 결함
    N+1"한 번 불러오고 하나씩 조회" 로 인해 쿼리가 수백 번 나가는 성능 문제
    409 ConflictHTTP 상태 코드 — "요청은 맞는데 현재 서버/외부 상태 때문에 안 됨"
    DTOData Transfer Object. JPA 엔티티를 명시적으로 가공해서 클라이언트에 넘길 때 쓰는 도메인 경계 객체

    이슈 여섯 건

    이슈위험도증상해결
    Wishlist IDORCritical주소창 userId 검증 없음토큰 userId 와 일치 검증
    MyCourse IDORCritical코스 저장·조회·삭제 모두 클라이언트 userId 신뢰토큰 userId 검증 + 소유자 검사
    OAuth 권한 위조HighlocalStorage 의 role/isPremium 을 신뢰비공개 페이지 진입 시 서버 재검증
    RuntimeExceptionMediumAI 호출 · 메일 발송 실패가 400 으로 응답외부 서비스 장애로 격상 → 409 응답
    지도 마커 N+1Medium팝업 전체를 메모리로 로드SQL WHERE 절 쿼리 재사용
    MyCourse 엔티티 노출MediumJPA 엔티티 직접 JSON 직렬화DTO 로 변환

    왜 이렇게 했음

    같은 패턴은 다른 워낞에도 있는가 — v2.7 의 GameController 면 수정 후, "클라이언트 userId 를 신뢰" 하는 다른 코드가 어디 있는지 일괄 검색해야 일 하단 닫은 게 되는데, 당시는 "티켓" 이니 명확한 곳만 볼 하고 지나감. v2.9 에서 MyCourse, Wishlist 에 같은 패턴이 남아있는 걸 발견. "한 파일 고치면 같은 패턴의 다른 파일도 보자" 는 그래서 일종의 흡수 규칙처럼 쓰이게 됨.

    권한은 다시 뵤다 — OAuth 로그인 끝나고 localStorage 에 저장한 role/isPremium 을 그대로 참조하면 사용자가 임의로 고쳐 admin 으로 진입할 수 있음. 비공개 페이지는 들어갑 때 서버가 한 번 더 "그게 맞아?" 검증. 추가 호출이 생기지만 권한 고정 수준을 높이는 도리.

    장애 ≠ 입력 오류 — AI 호출·메일 발송이 일시적으로 안 되는 걸 사용자의 "입력이 잘못됨" (400) 으로 내보내면 사용자가 혼란스럽다. "서버 쪽의 일시적 문제" 임을 409 로 알리고 "잠시 후 다시 시도" 안내를 같이 내보낸다.

    엔티티 직접 직렬화 — JPA 엔티티를 그대로 JSON 직렬화하면 양방향 관계를 따라가며 lazy loading 이 터져 JSON 이 폭발한다. 필요한 필드만 가진 DTO 로 변환하는 게 표준.


    코드로는 어떻게 (필요한 부분만)

    IDOR 일괄 해결 패턴.

    파일: popspot-backend/src/main/java/com/popspot/controller/WishlistController.java

    java
    // v2.8 까지 — Wishlist IDOR 잠재
    @GetMapping("/wishlist")
    public List<Wishlist> get(@RequestParam Long userId) {
        //                    ^^^^^^^^^^^^^^^^^^^^^^^^^^   ?userId=7 쿼리 파라미터 그대로 수용
        //                                                  공격자: ?userId=7,8,9,... 다르게 타인의 위시리스트 조회 가능
        return wishlistRepository.findByUserId(userId);
        //                       ^^^^^^^^^^^^^^^^^^^^^^   권한 검사 없이 SELECT * FROM wishlist WHERE user_id=?
        //                                                 타인의 입력만 일치하면 그대로 반환 — 대표적 IDOR
    }

    파일: 같은 WishlistController.java (v2.9 교체 후) + 서비스 계층 자체는 popspot-backend/.../service/WishlistService.java

    java
    // v2.9 — 주소창 userId 무시, 토큰 기반 + 서비스 계층 소유자 검사
    @GetMapping("/wishlist")
    public List<WishlistResponseDto> get(@AuthenticationPrincipal Long tokenUserId) {
    //          ^^^^^^^^^^^^^^^^^^^^                              ^^^^^^^^^^^^^^
    //          |                                                 |
    //          |                                                 +-- Spring Security 가 주입. 토큰 검증 필터에서 구축됨
    //          |                                                     클라이언트가 조작할 수 없음·필자 서명된 JWT 안 주인 ID
    //          +-- DTO 로 변환된 레스폰스 (엔티티 직접 노출 아님 — LAZY 관계·내부 필드 노출 머멑·서택 어·차·단)
        return wishlistService.findOwnedBy(tokenUserId);
        //                     ^^^^^^^^^^^^^^^^^^^^^^^^   메서드명이 도메인 원칙 설명: "소유자가 이 사용자인 wishlist 만 조회"
        //                                                 서비스 계층이 @Transactional(readOnly=true) 로 읽기 전용 설정
        //                                                 wishlist.userId == tokenUserId 조건 일치 안 되면 비어있는 리스트 반환
    }

    권한 재검증 (AuthGuard).

    파일: popspot-frontend/src/components/AuthGuard.tsx

    typescript
    // v2.9 — AuthGuard.tsx. 비공개 페이지에 들어올 때 권한 재검증
    useEffect(() => {
      fetch('/api/auth/me', { credentials: 'include' })
      // 위: 서버에 "나는 누구야?" 물음. credentials: 'include' 는 쿼키 (JWT 세션) 함께 전송
      //      서버가 이 쿼키를 검증하고 실제 사용자 권한 정보를 응답 — 클라이언트가 조작 불가
        .then(r => r.ok ? r.json() : null)
        //      ^^^^^^   ^^^^^^^^   200 OK 면 JSON 파싱, 그외엔 null (토큰 만료·이상)
        .then(user => {
          // localStorage 의 role/isPremium 은 무시 — 클라이언트에서 조작할 수 있는 값이므로 근거로 쓰면 안 됨
          if (!user || !allowedRoles.includes(user.role)) {
            //  ^^^^^   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            //  |       |
            //  |       +-- 서버가 답한 user.role 이 allowedRoles 에 들어 있는지 검사
            //  +-- user 가 null 이면 차단 (로그아웃·토큰 제거·세션 만료 등 모두 포함)
            router.replace('/');
            // 위: 권한 부족 → 루트로 돌려보냄. replace 는 이전 페이지 히스토리에 남기지 않음
          }
        });
    }, []);
    // 필요 비용: 페이지당 한 번의 추가 호출. 그러나 권한 속임수 차단이라 감수할 만한 비용

    400 → 409 에러 격상.

    파일: popspot-backend/src/main/java/com/popspot/exception/GlobalExceptionHandler.java

    java
    // v2.9 — GlobalExceptionHandler 에 외부 서비스 장애 전용 핸들러 추가
    @ExceptionHandler({ ExternalServiceException.class })
    //                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^   AI 호출·메일 발송 등 외부 장애 전용 커스텀 예외
    //                                                  v2.9 이전에는 RuntimeException 으로 더붙으면서 일괄 400 으로 이상
    h andled
    public ResponseEntity<ErrorResponse> handleExternal(ExternalServiceException e) {
        return ResponseEntity.status(409).body(
        //                          ^^^^   HTTP 409 Conflict — "서버 쪽 일시 상태 트래픽 충돌/장애"
        //                                   400 은 클라이언트의 입력 오류 의미 — 사용자가 리트라이 해도 결과가 같음
        //                                   409 는 "잠시 후 다시" 가 함식된 상태 코드라 재시도 유도가 명확
            new ErrorResponse("잠시 후 다시 시도해 주세요."));
            // 위: 메시지 포맷이 공통이라 프론트 에서 일괄 섬세·표시·재시도 UI 처리 가능
    }

    핵심 파일: popspot-backend/.../service/WishlistService.java, popspot-backend/.../controller/MyCourseController.java, popspot-frontend/src/components/AuthGuard.tsx


    관련 글

  • 이전 — v2.8, 게스트 탭 접근 정책
  • 다음 — v2.10, 어드민 대시보드 확장 + 실시간 로그 SSE
  • 공유

    댓글