재오픈 배포 전 보안·운영 견고성 강화#24
Conversation
- 세션 쿠키 Secure/SameSite=None + forward-headers-strategy(prod) - Swagger/OpenAPI 운영 비활성화, actuator info.env 비노출 및 metrics 개별 노출 제거 - CORS 허용 오리진을 환경변수로 외부화(운영은 localhost 제외) - multipart 업로드 크기 상한 명시(20MB) - 프로필 활성 변수를 표준 SPRING_PROFILES_ACTIVE로 통일 - .dockerignore에 .env/덤프 배제, .gitignore에 db/baseline 예외 추가
- 온라인/시간미지정 과목 시간표 추가 시 NPE(요일·시간 null) 가드 - DataIntegrityViolationException을 409로 매핑, 가입/위시리스트/시간표 추가에 @transactional - 비밀번호 길이 정책(8~72자)·아이디 길이 상한, 조합 maxCombinations/targetCredits 및 페이지 size 상한, 검색어 최소 길이 - 파싱 컨트롤러의 내부/외부 예외 메시지 응답 노출 제거(로깅으로 대체) - 로그인 레이트리밋의 X-Forwarded-For 직접 신뢰 제거(프록시 보정 IP 사용)
- users/subjects/schedules/user_timetables/wishlist_items 생성 마이그레이션 부재로 빈 DB(신규/DR/스테이징) 기동 불가 → 엔티티 역산 베이스라인 초안 제공 - db/migration 자동 스캔 경로 밖(db/baseline)에 두어 기존 운영 배포 흐름 불변 - 운영 pg_dump 대조 및 빈 DB 리허설 절차를 스크립트 상단에 명시
There was a problem hiding this comment.
Code Review
This pull request introduces various security, stability, and configuration improvements, including production-ready CORS and session configurations, rate-limiting IP spoofing prevention, input validation, transaction management, and a baseline database schema. The review feedback highlights several critical improvement opportunities: adding missing indexes to the subjects table in the baseline schema to prevent full table scans, using TIMESTAMP WITH TIME ZONE to avoid timezone discrepancies, validating password length in bytes rather than characters to prevent silent Bcrypt truncation, and refining the handling of DataIntegrityViolationException to distinguish unique constraint violations from other database errors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| -- 자주 쓰이는 조회 인덱스 (성능용 — ddl-auto=validate 는 인덱스를 검증하지 않으므로 best-effort). | ||
| -- 유니크 인덱스(user_timetables / wishlist_items) 및 추가 인덱스는 db/migration 의 후속 마이그레이션이 생성한다. | ||
| CREATE INDEX IF NOT EXISTS idx_schedules_subject_id ON schedules (subject_id); | ||
| CREATE INDEX IF NOT EXISTS idx_user_timetables_user_id ON user_timetables (user_id); | ||
| CREATE INDEX IF NOT EXISTS idx_user_timetables_subject_id ON user_timetables (subject_id); | ||
| CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_id ON wishlist_items (user_id); |
There was a problem hiding this comment.
현재 subjects 테이블은 SubjectController 등에서 다양한 필터 조건(subjectName, professor, department, grade 등)으로 빈번하게 조회됩니다. Subject.java 엔티티에는 @Table(indexes = ...)를 통해 idx_subject_name, idx_subject_course_code, idx_subject_semester_active, idx_professor, idx_department, idx_grade, idx_subject_type, idx_is_night, idx_search_filter 등 다수의 인덱스가 정의되어 있습니다.\n\n하지만 이 베이스라인 SQL 스크립트에는 subjects 테이블에 대한 인덱스 생성문이 완전히 누락되어 있습니다. 신규 DB 구축(시나리오 B) 시 이 스크립트로 테이블을 생성하면, 운영 환경에서 인덱스 없이 전체 테이블 스캔(Full Table Scan)이 발생하여 심각한 성능 저하가 발생할 수 있습니다.\n\n따라서 subjects 테이블에 정의된 인덱스들을 베이스라인 스크립트에도 추가해 주어야 합니다.
-- 자주 쓰이는 조회 인덱스 (성능용 — ddl-auto=validate 는 인덱스를 검증하지 않으므로 best-effort).
-- 유니크 인덱스(user_timetables / wishlist_items) 및 추가 인덱스는 db/migration 의 후속 마이그레이션이 생성한다.
CREATE INDEX IF NOT EXISTS idx_schedules_subject_id ON schedules (subject_id);
CREATE INDEX IF NOT EXISTS idx_user_timetables_user_id ON user_timetables (user_id);
CREATE INDEX IF NOT EXISTS idx_user_timetables_subject_id ON user_timetables (subject_id);
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_id ON wishlist_items (user_id);
-- subjects 테이블의 조회 성능 향상을 위한 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_subject_name ON subjects (subject_name);
CREATE INDEX IF NOT EXISTS idx_subject_course_code ON subjects (course_code);
CREATE INDEX IF NOT EXISTS idx_subject_semester_active ON subjects (semester, active);
CREATE INDEX IF NOT EXISTS idx_professor ON subjects (professor);
CREATE INDEX IF NOT EXISTS idx_department ON subjects (department);
CREATE INDEX IF NOT EXISTS idx_grade ON subjects (grade);
CREATE INDEX IF NOT EXISTS idx_subject_type ON subjects (subject_type);
CREATE INDEX IF NOT EXISTS idx_is_night ON subjects (is_night);
CREATE INDEX IF NOT EXISTS idx_search_filter ON subjects (subject_name, grade, department);| if (password.length() < PASSWORD_MIN_LENGTH || password.length() > PASSWORD_MAX_LENGTH) { | ||
| throw ApiException.badRequest( | ||
| "비밀번호는 " + PASSWORD_MIN_LENGTH + "자 이상 " + PASSWORD_MAX_LENGTH + "자 이하로 입력해주세요."); | ||
| } |
There was a problem hiding this comment.
Bcrypt 알고리즘은 입력값을 최대 72바이트(Bytes)까지만 처리하고, 그 이후의 입력은 자동으로 잘라냅니다(Silent Truncation). 현재 코드에서는 password.length() > 72로 글자 수(Character length) 기준 상한을 72자로 제한하고 있습니다. 하지만 한글이나 이모지 등 멀티바이트 캐릭터가 비밀번호에 포함될 경우, UTF-8 기준으로 한 글자당 3~4바이트를 차지하므로 실제로는 24자 미만에서도 72바이트 제한을 초과하여 뒷부분이 무시되는 보안 및 일관성 문제가 발생할 수 있습니다.\n\n따라서 글자 수가 아닌 실제 바이트 길이를 기준으로 상한을 검증하도록 수정하는 것이 안전합니다.
| if (password.length() < PASSWORD_MIN_LENGTH || password.length() > PASSWORD_MAX_LENGTH) { | |
| throw ApiException.badRequest( | |
| "비밀번호는 " + PASSWORD_MIN_LENGTH + "자 이상 " + PASSWORD_MAX_LENGTH + "자 이하로 입력해주세요."); | |
| } | |
| if (password.length() < PASSWORD_MIN_LENGTH || password.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > PASSWORD_MAX_LENGTH) { | |
| throw ApiException.badRequest( | |
| "비밀번호는 " + PASSWORD_MIN_LENGTH + "자 이상이어야 하며, 최대 " + PASSWORD_MAX_LENGTH + "바이트 이하여야 합니다."); | |
| } |
| deleted_at TIMESTAMP, | ||
| created_at TIMESTAMP, |
There was a problem hiding this comment.
PostgreSQL에서 TIMESTAMP (without time zone) 타입을 사용하면 애플리케이션 서버와 데이터베이스 서버 간의 시간대(Timezone) 설정이 다를 때(예: UTC vs KST) 데이터가 왜곡되어 저장되거나 조회될 위험이 있습니다. 글로벌 서비스나 클라우드 환경(AWS RDS 등)에서의 안정적인 운영을 위해, 절대적인 시점을 기록하는 컬럼(created_at, deleted_at, added_at 등)은 TIMESTAMP WITH TIME ZONE (또는 TIMESTAMPTZ) 타입을 사용하는 것이 권장됩니다.
deleted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE,| @ExceptionHandler(DataIntegrityViolationException.class) | ||
| public ResponseEntity<ApiErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException exception) { | ||
| log.warn("Data integrity violation", exception); | ||
| return error(HttpStatus.CONFLICT, "이미 존재하거나 중복된 데이터입니다."); | ||
| } |
There was a problem hiding this comment.
DataIntegrityViolationException은 유니크 제약 조건 위반(중복 데이터)뿐만 아니라, 외래 키 제약 조건 위반(FK Violation), NOT NULL 제약 조건 위반, 데이터 타입 불일치 등 다양한 데이터베이스 제약 조건 위반 시 발생합니다.\n\n모든 DataIntegrityViolationException을 일괄적으로 409 CONFLICT와 "이미 존재하거나 중복된 데이터입니다." 메시지로 응답하면, 잘못된 외래 키 입력이나 필수 값 누락 등의 오류가 발생했을 때 클라이언트에게 잘못된 원인 분석을 제공하게 됩니다.\n\n따라서 예외의 원인(예: SQLState가 23505인 유니크 위반인지 여부 등)을 확인하여 유니크 위반일 때만 409 CONFLICT를 반환하고, 그 외의 제약 조건 위반은 400 BAD REQUEST 등으로 세분화하여 처리하는 것이 좋습니다.
- 베이스라인에 subjects 조회/필터 인덱스 9종 추가(신규 DB 풀스캔 방지) - 비밀번호 상한을 글자 수 → UTF-8 바이트(72) 검증으로 변경(bcrypt silent truncation 대응) - DataIntegrityViolationException 중 유니크 위반(SQLState 23505)만 409, 그 외는 400으로 구분
|
리뷰 감사합니다. 4건 중 3건 반영, 1건은 사유와 함께 보류했습니다. (커밋 57abec8) ✅ 반영
⏸️ 보류 —
|
개요
재오픈(2026-07) 전 종합 코드리뷰에서 발견한 배포 블로커 + 보안 노출 항목을 수정합니다. 기존 운영 서비스의 재배포라 실제 사용자 데이터/트래픽이 들어오는 점을 기준으로 우선순위를 잡았습니다.
변경 내용 (커밋별)
1.
feat: 운영 프로파일 보안 설정 강화Secure/SameSite=None+forward-headers-strategy(prod) — Secure 기본값 false 문제 해결info.env비노출 +metrics개별 노출 제거allowCredentials(true)와의 결합 위험 제거)SPRING_PROFILE→ 표준SPRING_PROFILES_ACTIVE로 통일 (dev 폴백 위험 제거).dockerignore에.env/덤프 배제,.gitignore에db/baseline예외 추가2.
fix: 동시성·입력검증·예외처리 보강DataIntegrityViolationException→ 409 매핑, 가입/위시리스트/시간표 추가에@Transactional— 더블클릭/동시요청 시 500 대신 40972자)·아이디 길이 상한, 조합100)/maxCombinations(1targetCredits(1~40) 및 페이지size(≤100) 상한, 검색어 최소 길이(≥2)X-Forwarded-For직접 신뢰 제거(위조로 잠금 우회 방지)3.
feat: Flyway 핵심 테이블 베이스라인 스키마 초안db/migration자동 스캔 경로 밖(db/baseline)에 두어 기존 운영 배포 흐름 불변pg_dump대조 및 빈 DB 리허설 절차를 스크립트 상단에 명시테스트
./gradlew compileJava compileTestJava— BUILD SUCCESSFUL./gradlew test— BUILD SUCCESSFUL (전체 통과)pg_dump --schema-only와 대조.env의 Supabase DB 비밀번호·Gemini 키 재발급, 배포 환경변수로만 주입, git 히스토리 노출 이력 점검/actuator/prometheusIP 제한 — Prometheus scrape는 인증 불가라 네트워크 레벨 보호 필요SameSite=None은Secure동반 필수라 HTTP 노출 시 세션 쿠키 거부됨범위에서 의도적으로 제외
pg_advisory_lock은 아키텍처 변경. 단일 인스턴스면 현재 동작 OKwishlistCount오채움) — 보안 아닌 별도 이슈