프론트엔드백엔드갈아엎기 시리즈

TypeScript any 17 → 0, 편집 흔적 84 → 1 — POP-SPOT v1.5 프론트 클린코드 (+v1.5.1, v1.5.2)

김동현
··5분 읽기

프론트 40 파일 · 편집 흔적 84→1, any 17→0 (SDK 1 제외), 인라인

v1.4 에서 적용한 원칙을 프론트에도 적용한 버전이다. 다만 프론트는 테스트 커버리지가 없어 위험 큰 Wave 5 (거대 컴포넌트 분해) 와 Wave 6 (Tailwind variant) 은 E2E 셋업 후로 미뤄둔 결정.

이 글에서 다루는 것

  • 프론트의 any 가 왜 위험한지 — 잠재 버그 하나를 실제로 제보한 경험이 있음 (?userId=undefined)
  • 🔥 [수정] 같은 편집 흔적 84 건이 프로덕션 용엔 왜 위험인지
  • 5 Wave 중 왜 1·2·3·4·7 만 하고 5·6 은 미뤄됐는지
  • v1.5.1 이 드러낸 16 건의 "타입이 멀어서 안 보이던 버그"
  • v1.5.2 의 백엔드 Repository 디커플링 + 도메인 예외 도입
  • 모르는 단어 한 줄로

    용어한 줄 설명
    any (TypeScript)"아무 타입이나 OK" — 사실상 타입 안 쓴 것과 같은 이스케이프 해치
    도메인 타입내 서비스의 개념 (User, PopupStore 등) 을 TypeScript 인터페이스로 표현한 것
    SDK 경계외부 라이브러리 (카카오 지도 · YouTube IFrame) 가 제공하는 전역 객체와 내 코드가 만나는 경계
    alias import<code>import { User as UserIcon } from 'lucide-react'</code> 으로 이름 충돌 잘라내기
    Repository 디커플링Controller 가 Repository 에 직접 의존하지 않고 Service 를 거치게 하여 트랜잭션/로직을 한 곳으로

    무엇이 바뀌었나 (v1.5 본편)

    Wave이전현재
    1 — 편집 흔적코드 안 <code>🔥 [수정]</code> 마커 84 건UI 의도 1 건만 남김
    2 — any17 건0 건 (SDK 경계 1 건 제외) · src/types/sdk.ts 신규
    3 — 환경변수 일원화<code>process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"</code> 이 여기저기<code>API_BASE_URL</code> 한 곳에서만 읽음
    4 — setInterval/setTimeout숫자 8 군데군데명명 상수화 (<code>SERVER_METRICS_POLL_INTERVAL_MS</code> 등)
    7 — ESLint disable8 군데 사유 없이 넘김모두 코멘트 + intro/page.tsx stale closure 정공법 해결
    5·6 (미뤄둔 이유)거대 컴포넌트 분해 + Tailwind variant테스트 0%, useEffect 의존성 교체 큰 위험 → E2E 셋업 후로 이관

    40 파일, 약 ±230 라인. 외부 동작·API·WebSocket 동등.


    왜 이렇게 했음

    any 는 타입 안전을 다 넘겨버린다 — v1.5 Wave 2 하는 도중 ?userId=${userId} 호출이 있는데 userId 가 가끔 undefined 되는 구간이 있어 서버가 ?userId=undefined 를 받는 호출이 섞여 있었음. 타입이 any 라 어디서 어긋나는지 안 보였고, 구체 타입으로 좁히자마자 드러났다. 이런 케이스를 하나 더 찾은 게 이 장의 가장 큰 수확.

    편집 흔적🔥 [13번 수정] 같은 주석이 84 건 었음. "어느 세션에서 뭐보고 수정했다" 는 정보는 깃 히스토리에 있으면 되고, 코드에는 필요 없음. 포트폴리오 용으로도 그다지 좋은 인상을 주지 않는다.

    Wave 5·6 의도적 미루기 — 거대 app/page.tsx 를 쪼겜는 작업은 useEffect 의존성 배열이 섞이면 조용한 회귀 버그를 숨긴다. E2E 테스트가 없는 상태에서 건드리는 건 "맞지 않는 조각을 계속 다시 맞추는" 일이 될 수 있어, 테스트 커버리지 잡고 다음 하기로 결정함.


    코드로는 어떻게 (v1.5)

    SDK 경계 단 한 곳에만 any 허용 — 그 외 전역은 도메인 타입으로.

    파일: popspot-frontend/src/types/sdk.ts (v1.5 신규 생성)

    typescript
    // v1.5 — src/types/sdk.ts. 외부 스크립트 경계 하나에만 선언 집중
    declare global {
    //      ^^^^^^ 글로벌 스코프에 타입 추가 — window.kakao, window.YT 처럼 외부가 주입하는 객체
      interface Window {
    //          ^^^^^^ 다른 파일에 쓰인 Window 와 병합됨 (interface merging)
        kakao?: KakaoMaps;
    //  ^^^^^^ Kakao Maps SDK 가 로드되면 채워짐. ? 는 "아직 들어있지 않을 수 있음" 표시
        YT?: YouTubeIframeAPI;
    //  ^^^ YouTube IFrame API. 둘 다 외부 <script> 로 주입되므로 초기엔 undefined
      }
    }
    // 포인트: any 가 필요한 경우는 이 파일 하나에서만 을 궁지·관리. 다른 컴포넌트에서는 절대 쓰지 않는다.

    파일: popspot-frontend/src/lib/api.ts (공통 타입 정의 파일)

    typescript
    // v1.5 — src/lib/api.ts. 호출하는 도메인 타입은 모두 구체적으로 정의
    export interface PopupStore { id: number; name: string; }
    //                            ^^^^^^^^^^  ^^^^^^^^^^^^^
    //                            DB 의 테이블 popup_store 컬럼 구조와 동일
    //                            id 가 number 로 확정되어 store.id.toUpperCase() 같은 잘못된 쓰기를 차단
    // any 안 쓴다 — 이 파일부터 서비스 전반에 구체 타입만 전파됨

    이름 충돌 해결 — alias 사용.

    파일: popspot-frontend/src/app/page.tsx 등 User 아이콘/타입을 함께 쓰는 파일들

    typescript
    // v1.4 까지 — 같은 이름 User 이 두 스코프에 충돌
    import { User } from 'lucide-react';
    //       ^^^^                          아이콘 컴포넌트 (React.FC)
    import { User } from '@/types/user';
    //       ^^^^                          도메인 타입 (사용자 테이블 구조)
    // 두 임포트 동시 등장 시 TypeScript: "Duplicate identifier 'User'"
    // 한 쪽을 주석 처리하고 다른 곳으로 옮기는 회피가 자주 일어남

    파일: 같은 파일들 (v1.5 수정 후)

    typescript
    // v1.5 — as 로 이름 분리. 의도가 명확해짐
    import { User as UserIcon } from 'lucide-react';
    //       ^^^^ ^^ ^^^^^^^^
    //       |    |  +-- 사용 시의 이름: <UserIcon /> — 누가 봐도 아이콘임
    //       |    +-- "에이리어스" 키워드
    //       +-- 원래 이름 (레퍼런스 명세)
    import type { User as DomainUser } from '@/types/user';
    //     ^^^^                                              import type — 타입 전용 임포트·컴파일 후 코드에서 제거됨
    //                       ^^^^^^^^^^                       <DomainUser> 로 쓰면 도메인 객체임이 분명

    v1.5.1 의 대표적 타입 좁히기 후 드러난 버그 — ?userId=undefined.

    typescript
    // 갈아엎기 전 — user?.id 가 undefined 면 그대로 URL 에 붙음
    await fetch(`/api/wishlist?userId=${user?.id}`);
    
    // 갈아엎은 후 — 없으면 일찍 종료
    if (!user?.id) return;
    await fetch(`/api/wishlist?userId=${user.id}`);

    v1.5.2 (백엔드 구조 개선) — Controller 가 Repository 에 직접 의존하는 10 곳 제거.

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

    java
    // v1.5.1 까지 — Controller 가 Repository 를 직접 호출
    @GetMapping("/my")
    public List<Wishlist> my(@AuthenticationPrincipal Long userId) {
    //          ^^^^^^^^                                 ^^^^^^      JWT 필터에서 추출한 사용자 PK
    //          엔티티를 그대로 노출 — LAZY 관계가 쓰여있으면 직렬화 중 LazyInitializationException
        return wishlistRepository.findByUserId(userId);
    //         ^^^^^^^^^^^^^^^^^^                       JPA Repository 자동 쿼리. SELECT * FROM wishlist WHERE user_id=?
    //         문제1: 트랜잭션 경계가 어떻게 시작되는지 애매 (Spring 의 기본 설정에 의존)
    //         문제2: 권한 검증 없이 user_id 만 일치하면 반환 (타 사용자 조회 분기 추가 어려움)
    }

    파일: 같은 WishlistController.java (v1.5.2 교체 후) + 새 popspot-backend/.../service/WishlistService.java

    java
    // v1.5.2 — Service 계층에 트랜잭션·인가·DTO 변환 권한 귀속
    @GetMapping("/my")
    public List<WishlistResponseDto> my(@AuthenticationPrincipal Long userId) {
    //          ^^^^^^^^^^^^^^^^^^^^^^                                          이제 반환형에 엔티티가 안 나옴
    //          노출하면 안 되는 필드를 DTO 매핑 단계에서 걸러냄 (콘트롤러가 이제 처리 안 해도 됨)
        return wishlistService.findByOwner(userId);
    //         ^^^^^^^^^^^^^^^                       @Transactional(readOnly = true) 서비스 메서드
    //                         ^^^^^^^^^^^             이름이 의도를 드러냄 — user_id 가 아닌 owner
    //                                                 (권한 검증 의미를 포함. "소유자 기준")
    //                                                 v2.7/v2.9 의 IDOR 차단 계층 여기에 추가됨
    }

    핵심 파일: popspot-frontend/src/types/sdk.ts, popspot-frontend/src/lib/api.ts, popspot-backend/src/main/java/.../service/WishlistService.java


    비하인드 · 사고

    v1.5 Wave 2 가 타입을 좁힌 직후 TypeScript 컴파일 에러 16 건이 터졌다. 몇 개는 아이콘 vs 도메인 이름 충돌, 몇 개는 User | null 을 non-null 프롭으로 넘기던 고죠 등. 조용하게 묻혀 있던 눐팅 수준의 불일치들이었고, 타입 좁힌 결과 빌드 단계에서 이 전부가 한 그룹으로 감지됨.

    교훈 한 줄. 타입 좁히기는 코드를 깔끔하게 만드는 작업이면서, 동시에 쓰고 있던 잠재 버그를 검증해주는 테스트 역할이기도 하다.


    관련 글

  • 이전 — v1.4, 백엔드 70 파일 클린코드
  • 다음 — v1.6, 회원가입 폼 한 바퀴 정리
  • 공유

    댓글