백엔드프론트엔드트러블슈팅

운영 출시 직전 12 개 핫픽스 — 회원 탈퇴(PIPA) · DB 자동 백업 · SLA 알림 · CSP / HSTS · brute-force 잠금 · SEO 보강 — POP-SPOT v2.17 (+v2.17.2/3)

김동현
··8분 읽기

운영 출시 직전 종합 점검에서 진단된 55 개 개선점 중 가장 시급한 12 개. 회원 탈퇴(개인정보보호법 §17 의무) · DB 자동 백업 · SLA 24h 알림 · 보안 헤더(CSP/HSTS) · 로그인 brute-force 잠금 · JSON-LD SEO · 모바일 BottomDock 가로 스크롤 · 푸터 톤 정비.

운영 출시 종합 점검에서 55 개 개선점이 나왔고, 그중 시급도 가장 높은 12 개를 한 번에 정리. 법적 의무(회원 탈퇴), 데이터 손실 방지(DB 백업), 보안(CSP·brute-force), SLA 추적, 모바일 UI 등 영역별로.

이 글에서 다루는 것

  • 회원 탈퇴 — 개인정보보호법(PIPA) §17 의무, 즉시 익명화 + 재로그인 차단
  • DB 자동 백업 — 매일 03:00 KST, 7 일 보관 자동 삭제
  • SLA 24h 알림 — 약관에 약속해놓고 추적 안 하던 걸 cron 으로
  • 보안 헤더 — CSP / HSTS / X-Frame / Permissions-Policy
  • 로그인 brute-force 잠금 — ConcurrentHashMap 으로 5 회 실패 → 15 분 잠금
  • SEO 보강 — JSON-LD + 페이지별 metadata
  • 모바일 BottomDock 가로 스크롤 + 모바일 아바타 노출
  • 푸터 톤 정비 — "포트폴리오" → "정보 안내" 서비스
  • 모르는 단어 한 줄로

    용어한 줄 설명
    PIPA개인정보보호법 (Personal Information Protection Act). §17 에 정보주체의 처리 정지·삭제 권리 규정
    CSPContent Security Policy. "이 페이지에서 이 도메인의 스크립트만 허용" 같은 보안 헤더
    HSTSHTTP Strict Transport Security. "이 사이트는 HTTPS 만" 을 브라우저에 강제
    brute-force비밀번호를 무작위·사전 대입으로 무한 시도하는 공격
    JSON-LD검색엔진이 페이지의 의미를 더 잘 이해하게 해주는 구조화 데이터(스키마)
    cron리눅스의 정기 작업 스케줄러. Spring 의 @Scheduled 도 동일 표현 씀
    익명화식별 정보를 복원 불가능한 형태로 바꾸는 일. 이메일/이름을 랜덤 해시로 등

    무엇이 바뀌었나

    영역v2.16 까지v2.17
    회원 탈퇴 (PIPA §17)기능 없음 — 운영 시작 시 법적 의무 위반<code>DELETE /api/v1/users/me</code> + 2 단계 확인. 즉시 익명화 + 재로그인 차단
    DB 자동 백업없음 — 사고 시 복구 불가매일 03:00 KST <code>pg_dump | gzip → backups/</code>. 7 일 보관 자동 삭제
    SLA 24h 알림약관 §11 약속 했지만 추적 X매시간 cron — Feedback PENDING / TAKEDOWN 24h 초과 → 운영 메일 알림
    보안 헤더기본값만 — CSP / HSTS / X-Frame 없음CSP (외부 OAuth·지도·검색·영상 화이트리스트) · HSTS 1 년 · X-Frame SAMEORIGIN
    로그인 잠금brute-force 무방어 — 무한 시도 가능email 별 실패 카운트. 5 회 → 15 분 잠금
    SEOlayout.tsx default metadata 만WebSite + Organization JSON-LD + 페이지별 metadata + canonical
    모바일 BottomDock7 탭 좁아짐 — <code>w-10</code> 으로 축소가로 스크롤 (<code>overflow-x-auto</code>) + 스크롤바 숨김 + <code>w-11</code>
    푸터 톤"포트폴리오 안내""정보 안내 — 서울 팝업스토어 정보를 모아 안내하는 서비스"

    1. 회원 탈퇴 (PIPA §17 의무)

    백엔드

    파일: popspot-backend/src/main/java/com/popspot/controller/UserController.java (v2.16 의 UserProfileController 와 구분)

    java
    // v2.17 — UserController.withdraw() 신규. PIPA §17 의무
    @DeleteMapping("/me")
    @Transactional
    public void withdraw(Authentication auth) {
      Long userId = Long.parseLong(auth.getName());
      User user = userRepository.findById(userId).orElseThrow();
    
      // 1) 식별 정보 즉시 익명화
      String hash = sha256Hex(user.getEmail() + UUID.randomUUID());
      user.anonymize(
          "deleted-" + hash.substring(0, 12) + "@withdrawn.local",
          "탈퇴회원",
          null  // 프로필 사진
      );
    
      // 2) 재로그인 차단 플래그
      user.markWithdrawn(LocalDateTime.now(clock));
    
      // 3) 작성 콘텐츠는 "탈퇴회원" 으로 마스킹 (삭제는 보류)
    }

    해석 — 한 줄씩

  • sha256Hex(email + UUID) — 이메일에 랜덤 UUID 를 섞어 해시. 같은 이메일로 다시 탈퇴해도 다른 해시가 나와서 충돌 0. 복원 불가능.
  • user.anonymize(...) — 도메인 엔티티 메서드. setter 가 아니라 의도 있는 메서드를 두어 "여기서만 익명화 가능" 을 보장.
  • "deleted-…@withdrawn.local" — 운영 메일 도메인이 아닌 가짜 TLD. 실수로라도 메일 발송이 안 가게.
  • markWithdrawn(...) — 별도 boolean 또는 timestamp 컬럼. AuthService 가 로그인 시 이 값 확인 → 차단.
  • 작성 콘텐츠 삭제 보류 — 댓글/메이트 글을 다 지우면 다른 사용자의 대화 맥락이 깨진다. 작성자만 "탈퇴회원" 으로 표시하고 내용은 유지하는 게 표준.
  • 프론트 — 2 단계 확인

    파일: popspot-frontend/src/app/profile/edit/page.tsx 의 탈퇴 버튼 핸들러

    typescript
    // v2.17 — 회원 탈퇴 2단계 확인
    async function handleWithdraw() {
      const first = await notify.confirm(
        '정말 탈퇴할까요? 복구는 안 됩니다.'
      );
      if (!first) return;
    
      const second = await notify.confirm(
        '한 번 더 확인 — 작성한 글은 "탈퇴회원" 으로 표시됩니다.'
      );
      if (!second) return;
    
      await fetch('/api/v1/users/me', {
        method: 'DELETE',
        credentials: 'include',
      });
      await logout();
      router.replace('/');
    }

    해석

  • 2 단계 확인 — 실수 클릭 방지. 단계마다 메시지가 점점 구체적으로
  • logout() — JWT 쿠키 삭제. 이미 백엔드가 markWithdrawn 했지만 클라이언트 상태도 동기화
  • router.replace('/') — 메인으로 (push 가 아니라 replace — 뒤로 가기 누르면 탈퇴 페이지로 다시 가지 않게)

  • 2. DB 자동 백업

    Spring Scheduler

    파일: popspot-backend/src/main/java/com/popspot/scheduler/DatabaseBackupScheduler.java (v2.17 신규)

    java
    // v2.17 — DatabaseBackupScheduler 신규. 매일 03:00 KST 덤프
    @Component
    @Profile("prod")  // 운영 환경에서만
    public class DatabaseBackupScheduler {
    
      @Value("${BACKUP_DIR:/var/backups/popspot}")
      private String backupDir;
    
      @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
      public void backup() throws IOException, InterruptedException {
        String stamp = LocalDate.now().toString();  // 2026-05-24
        Path out = Path.of(backupDir, "popspot-" + stamp + ".sql.gz");
    
        ProcessBuilder pb = new ProcessBuilder(
            "sh", "-c",
            "pg_dump $POPSPOT_DB_URL | gzip > " + out
        );
        pb.environment().put("PGPASSWORD", dbPassword);
        Process p = pb.start();
        p.waitFor();
    
        deleteOlderThan(7);
      }
    }

    해석

  • @Profile("prod") — 운영 프로필일 때만 빈으로 등록. 로컬에서 매일 새벽에 안 돈다.
  • @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") — 초·분·시·일·월·요일 6 필드. 매일 03:00 KST.
  • pg_dump | gzip — PostgreSQL 의 표준 백업 도구 + 압축 (한 줄 파이프). SQL 텍스트 형식이라 다른 PG 버전으로 복원 호환.
  • PGPASSWORD 환경변수 — pg_dump 가 비밀번호를 인자가 아닌 환경변수로 받음. ps aux 에 비밀번호 노출 안 됨 (v2.22 에서 이 부분 추가 보강).
  • deleteOlderThan(7) — 7 일 이전 파일 삭제. 무한 누적 방지.

  • 3. SLA 24h 알림

    파일: popspot-backend/src/main/java/com/popspot/scheduler/SlaAlertScheduler.java

    java
    // v2.17 — SLA 알림 cron. 매시간 24시간 초과 항목 검사·알림
    @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul")  // 매시간
    public void notifyOverdueItems() {
      LocalDateTime threshold = LocalDateTime.now(clock).minusHours(24);
    
      long pendingFeedback = feedbackRepository
          .countByStatusAndCreatedAtBefore("received", threshold);
      long pendingTakedown = takedownRepository
          .countByStatusAndRequestedAtBefore("PENDING", threshold);
    
      if (pendingFeedback + pendingTakedown == 0) return;
    
      emailService.sendOperatorAlert(
          "[SLA] 24h 초과 항목",
          "피드백 " + pendingFeedback + " 건, takedown " + pendingTakedown + " 건"
      );
    }

    해석

  • @Scheduled(cron = "0 0 * * * *") — 매시간 정각
  • LocalDateTime.minusHours(24) — "24 시간 전" 기준점
  • countByStatusAndCreatedAtBefore — Spring Data JPA 의 쿼리 메서드 네이밍. SQL 생성 자동.
  • 둘 다 0 이면 메일 안 보냄 — 알림 피로 방지

  • 4. 보안 헤더 (CSP / HSTS / X-Frame)

    파일: popspot-frontend/next.config.ts (Next.js 빌드 설정. 이후 v2.17.3, v2.21 S8 에서 계속 수정)

    typescript
    // v2.17 — next.config.ts. CSP/HSTS/X-Frame 등 보안 헤더 일괄 설정
    const CSP_DIRECTIVES = [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' dapi.kakao.com www.youtube.com s.ytimg.com sdk.scdn.co",
      "style-src 'self' 'unsafe-inline' fonts.googleapis.com",
      "img-src 'self' data: blob: https:",
      "connect-src 'self' wss: https://api.popspot.co.kr accounts.spotify.com",
      "frame-src 'self' www.youtube.com sdk.scdn.co accounts.google.com",
      "font-src 'self' fonts.gstatic.com",
    ].join('; ');
    
    async headers() {
      return [{
        source: '/(.*)',
        headers: [
          { key: 'Content-Security-Policy', value: CSP_DIRECTIVES },
          { key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains; preload' },
          { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Permissions-Policy',
            value: 'geolocation=(self), microphone=(), camera=()' },
        ],
      }];
    }

    해석 — 디렉티브별

  • default-src 'self' — 명시 안 한 모든 리소스는 같은 출처만 허용 (전체 기본값)
  • script-src 'self' 'unsafe-inline' … — JS 출처. unsafe-inline 은 Next.js 의 인라인 스크립트 때문에 필요 (제거하려면 nonce 패턴, v2.22 보류 목록)
  • 카카오 지도(dapi.kakao.com) · YouTube(www.youtube.com s.ytimg.com) · Spotify SDK(sdk.scdn.co) 명시
  • frame-src — iframe 허용 출처. YouTube IFrame Player 가 여기 필요
  • HSTS max-age=31536000 — 1 년. preload 는 브라우저 사전로딩 리스트 등록용
  • X-Frame-Options: SAMEORIGIN — 다른 사이트에서 이 페이지를 iframe 으로 못 박게 (clickjacking 방지)
  • nosniff — MIME 추측 비활성. text/html 인 척하는 스크립트 공격 차단
  • Permissions-Policy geolocation=(self) — 위치 정보는 자기 출처만, 마이크·카메라는 전부 비활성

  • 5. 로그인 brute-force 잠금

    파일: popspot-backend/src/main/java/com/popspot/service/AuthService.java

    java
    // v2.17 — AuthService. brute-force 잠금. (v2.22 에서 Caffeine 으로 교체됨)
    private final Map<String, FailureRecord> loginAttempts =
        new ConcurrentHashMap<>();
    
    private static final int MAX_FAILURES = 5;
    private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
    
    public AuthResult login(String email, String rawPassword) {
      FailureRecord rec = loginAttempts.get(email);
      if (rec != null && rec.isLocked(clock)) {
        throw new TooManyRequestsException(
            "잠시 후 다시 시도 (남은 시간 " + rec.remainingSeconds(clock) + "s)"
        );
      }
      try {
        User user = userRepository.findByEmail(email).orElseThrow();
        if (!passwordEncoder.matches(rawPassword, user.getPasswordHash())) {
          registerFailure(email);
          throw new UnauthorizedException("이메일 또는 비밀번호 확인");
        }
        loginAttempts.remove(email);  // 성공 시 리셋
        return AuthResult.of(user);
      } catch (NoSuchElementException ex) {
        registerFailure(email);
        throw new UnauthorizedException("이메일 또는 비밀번호 확인");
      }
    }

    해석

  • ConcurrentHashMap — 멀티스레드 안전 맵. 동시에 여러 요청이 와도 카운트가 깨지지 않음 (단, v2.22 에서 무한 증가 OOM 위험으로 Caffeine 교체).
  • MAX_FAILURES = 5, LOCK_DURATION = 15분 — 표준 설정.
  • 사용자 없음·비밀번호 틀림 둘 다 같은 메시지 — 사용자 열거(user enumeration) 공격 방어. "이메일이 없습니다" 라고 알려주면 공격자가 가입자 명단을 만들 수 있음.
  • 성공 시 remove — 다음 실패 카운트 0 부터.

  • 6. SEO — JSON-LD

    파일: popspot-frontend/src/app/layout.tsx <head> 안

    typescript
    // v2.17 — layout.tsx <head> 에 JSON-LD 인라인
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify({
          '@context': 'https://schema.org',
          '@graph': [
            {
              '@type': 'WebSite',
              name: '팝스팟',
              alternateName: 'POP-SPOT',
              url: 'https://popspot.co.kr',
              potentialAction: {
                '@type': 'SearchAction',
                target: 'https://popspot.co.kr/?q={query}',
                'query-input': 'required name=query',
              },
            },
            {
              '@type': 'Organization',
              name: '팝스팟',
              url: 'https://popspot.co.kr',
              logo: 'https://popspot.co.kr/logo.png',
            },
          ],
        }),
      }}
    />

    해석

  • WebSite + Organization 을 @graph 로 묶음 — 검색엔진이 한 번에 둘 다 인식
  • SearchAction — 구글의 sitelinks search box 활성화 조건. 검색결과에서 우리 사이트 검색창이 함께 노출될 수 있음
  • dangerouslySetInnerHTML — React 가 평소엔 막는 raw HTML 주입을 의도적으로 허용. JSON-LD 는 안전한 데이터라 OK.

  • 7. 모바일 BottomDock 가로 스크롤

    파일: popspot-frontend/src/components/layout/BottomDock.tsx

    typescript
    <nav className="flex md:justify-center
                    overflow-x-auto md:overflow-visible
                    scrollbar-none">
      {/* v2.17 — 모바일 가로 스크롤 BottomDock */}
      {TABS.map(tab => (
        <button className="flex-shrink-0 w-11 md:w-12">…</button>
      ))}
    </nav>

    해석

  • overflow-x-auto md:overflow-visible — 모바일은 가로 스크롤 가능, 데스크탑은 그냥 보임
  • flex-shrink-0 — 화면이 좁아도 버튼이 안 줄어듦. 스크롤로 나머지 접근
  • scrollbar-none — 스크롤바 숨김 (Tailwind 플러그인 또는 커스텀 CSS)
  • 너비 w-11 (44 px) — 손가락 터치 영역 최소 44 px 권장 (Apple HIG)

  • v2.17.2 — UserProfileController @RequiredArgsConstructor 제거

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

    java
    // v2.17 — 의존성 추가하면서 명시 생성자도 고치다가 Lombok 과 중복 구조
    @RequiredArgsConstructor  // Lombok — final 필드 기반 생성자 자동 생성
    public class UserProfileController {
      private final UserProfileService service;
      public UserProfileController(UserProfileService service, /* 추가 */) { … }
      // 위: 명시 생성자 + Lombok 이 자동 생성한 생성자 → "duplicate constructor" 컴파일 에러
    }
    
    // v2.17.2 — 명시 생성자만 남기고 어노테이션 제거
    // public class UserProfileController {
    //   private final UserProfileService service;
    //   public UserProfileController(UserProfileService service) { this.service = service; }
    // }

    문제 — Lombok 의 @RequiredArgsConstructor 가 final 필드 기반으로 생성자를 자동 생성하는데, 같은 클래스에 명시 생성자도 있으면 "중복 생성자" 충돌. v2.17 에서 다른 의존성을 추가하면서 명시 생성자를 같이 두다 잘못 됨.

    수정 — 명시 생성자만 남기고 어노테이션 제거.

    v2.17.3 — CSP 누락 호스트 보강

    운영 배포 후 콘솔에 CSP 위반 경고 도배. 누락된 두 곳:

  • cdn.jsdelivr.net/gh/orioncactus/pretendard — Pretendard 폰트 CDN
  • *.tailc57dd4.ts.net — Tailscale Funnel API 호스트
  • CSP 의 font-srcconnect-src 에 추가.


    관련 글

  • 이전 — v2.16, 프로필 편집 + Header 아바타
  • 다음 — v2.18, 출시 직후 UX 보강 (공통 UI / 글로벌 검색 / 알림센터)
  • 공유

    댓글