운영 출시 직전 12 개 핫픽스 — 회원 탈퇴(PIPA) · DB 자동 백업 · SLA 알림 · CSP / HSTS · brute-force 잠금 · SEO 보강 — POP-SPOT v2.17 (+v2.17.2/3)
운영 출시 직전 종합 점검에서 진단된 55 개 개선점 중 가장 시급한 12 개. 회원 탈퇴(개인정보보호법 §17 의무) · DB 자동 백업 · SLA 24h 알림 · 보안 헤더(CSP/HSTS) · 로그인 brute-force 잠금 · JSON-LD SEO · 모바일 BottomDock 가로 스크롤 · 푸터 톤 정비.
운영 출시 종합 점검에서 55 개 개선점이 나왔고, 그중 시급도 가장 높은 12 개를 한 번에 정리. 법적 의무(회원 탈퇴), 데이터 손실 방지(DB 백업), 보안(CSP·brute-force), SLA 추적, 모바일 UI 등 영역별로.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| PIPA | 개인정보보호법 (Personal Information Protection Act). §17 에 정보주체의 처리 정지·삭제 권리 규정 |
| CSP | Content Security Policy. "이 페이지에서 이 도메인의 스크립트만 허용" 같은 보안 헤더 |
| HSTS | HTTP 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 분 잠금 |
| SEO | layout.tsx default metadata 만 | WebSite + Organization JSON-LD + 페이지별 metadata + canonical |
| 모바일 BottomDock | 7 탭 좁아짐 — <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 와 구분)
// 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) 작성 콘텐츠는 "탈퇴회원" 으로 마스킹 (삭제는 보류)
}해석 — 한 줄씩
프론트 — 2 단계 확인
파일: popspot-frontend/src/app/profile/edit/page.tsx 의 탈퇴 버튼 핸들러
// 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. DB 자동 백업
Spring Scheduler
파일: popspot-backend/src/main/java/com/popspot/scheduler/DatabaseBackupScheduler.java (v2.17 신규)
// 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);
}
}해석
3. SLA 24h 알림
파일: popspot-backend/src/main/java/com/popspot/scheduler/SlaAlertScheduler.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 + " 건"
);
}해석
4. 보안 헤더 (CSP / HSTS / X-Frame)
파일: popspot-frontend/next.config.ts (Next.js 빌드 설정. 이후 v2.17.3, v2.21 S8 에서 계속 수정)
// 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=()' },
],
}];
}해석 — 디렉티브별
5. 로그인 brute-force 잠금
파일: popspot-backend/src/main/java/com/popspot/service/AuthService.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("이메일 또는 비밀번호 확인");
}
}해석
6. SEO — JSON-LD
파일: popspot-frontend/src/app/layout.tsx <head> 안
// 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',
},
],
}),
}}
/>해석
7. 모바일 BottomDock 가로 스크롤
파일: popspot-frontend/src/components/layout/BottomDock.tsx
<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>해석
v2.17.2 — UserProfileController @RequiredArgsConstructor 제거
파일: popspot-backend/src/main/java/com/popspot/controller/UserProfileController.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 위반 경고 도배. 누락된 두 곳:
CSP 의 font-src 와 connect-src 에 추가.