Skip to content

재오픈 배포 전 보안·운영 견고성 강화#24

Open
coldmans wants to merge 4 commits into
mainfrom
fix/reopen-predeploy-hardening
Open

재오픈 배포 전 보안·운영 견고성 강화#24
coldmans wants to merge 4 commits into
mainfrom
fix/reopen-predeploy-hardening

Conversation

@coldmans

Copy link
Copy Markdown
Owner

개요

재오픈(2026-07) 전 종합 코드리뷰에서 발견한 배포 블로커 + 보안 노출 항목을 수정합니다. 기존 운영 서비스의 재배포라 실제 사용자 데이터/트래픽이 들어오는 점을 기준으로 우선순위를 잡았습니다.

변경 내용 (커밋별)

1. feat: 운영 프로파일 보안 설정 강화

  • 세션 쿠키 Secure/SameSite=None + forward-headers-strategy(prod) — Secure 기본값 false 문제 해결
  • Swagger/OpenAPI 운영 비활성화, actuator info.env 비노출 + metrics 개별 노출 제거
  • CORS 허용 오리진 환경변수 외부화 (운영은 localhost 제외, allowCredentials(true)와의 결합 위험 제거)
  • multipart 업로드 크기 상한 명시(20MB) — 미설정 OOM 방어
  • 프로필 활성 변수를 비표준 SPRING_PROFILE → 표준 SPRING_PROFILES_ACTIVE로 통일 (dev 폴백 위험 제거)
  • .dockerignore.env/덤프 배제, .gitignoredb/baseline 예외 추가

2. fix: 동시성·입력검증·예외처리 보강

  • 온라인/시간미지정 과목 시간표 추가 시 NPE(요일·시간 null) 가드 — 비대면 과목 추가 500 장애 해결
  • DataIntegrityViolationException409 매핑, 가입/위시리스트/시간표 추가에 @Transactional — 더블클릭/동시요청 시 500 대신 409
  • 비밀번호 길이 정책(872자)·아이디 길이 상한, 조합 maxCombinations(1100)/targetCredits(1~40) 및 페이지 size(≤100) 상한, 검색어 최소 길이(≥2)
  • 파싱 컨트롤러의 내부/외부 예외 메시지 응답 노출 제거(로깅 대체)
  • 로그인 레이트리밋의 X-Forwarded-For 직접 신뢰 제거(위조로 잠금 우회 방지)

3. feat: Flyway 핵심 테이블 베이스라인 스키마 초안

  • 핵심 테이블 생성 마이그레이션 부재로 빈 DB(신규/DR/스테이징) 기동 불가 → 엔티티 역산 베이스라인 초안 제공
  • db/migration 자동 스캔 경로 (db/baseline)에 두어 기존 운영 배포 흐름 불변
  • 운영 pg_dump 대조 및 빈 DB 리허설 절차를 스크립트 상단에 명시

테스트

  • ./gradlew compileJava compileTestJava — BUILD SUCCESSFUL
  • ./gradlew test — BUILD SUCCESSFUL (전체 통과)
  • (운영) 빈 PostgreSQL에서 Flyway 마이그레이션 전체 리허설 — 머지/배포 전 필수
  • (운영) baseline 스크립트를 운영 pg_dump --schema-only와 대조

⚠️ 배포 전 운영 액션 (코드 밖, 직접 수행)

  1. 시크릿 회전: .env의 Supabase DB 비밀번호·Gemini 키 재발급, 배포 환경변수로만 주입, git 히스토리 노출 이력 점검
  2. Flyway baseline 리허설 + pg_dump 대조 (위 테스트 항목)
  3. nginx에서 /actuator/prometheus IP 제한 — Prometheus scrape는 인증 불가라 네트워크 레벨 보호 필요
  4. 운영 HTTPS 필수SameSite=NoneSecure 동반 필수라 HTTP 노출 시 세션 쿠키 거부됨

범위에서 의도적으로 제외

  • 인메모리 상태(로그인 차단·작업락)의 멀티 인스턴스 무력화 — Redis/pg_advisory_lock은 아키텍처 변경. 단일 인스턴스면 현재 동작 OK
  • DatabaseInitializer 런타임 ALTER 제거 — Flyway 베이스라인 확정 후 진행 권장
  • 데이터 정확성 버그(탈퇴 회원 인기도 집계 왜곡, wishlistCount 오채움) — 보안 아닌 별도 이슈

coldmans added 3 commits June 22, 2026 21:58
- 세션 쿠키 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 리허설 절차를 스크립트 상단에 명시

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +104 to +109
-- 자주 쓰이는 조회 인덱스 (성능용 — 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 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);

Comment on lines +134 to +137
if (password.length() < PASSWORD_MIN_LENGTH || password.length() > PASSWORD_MAX_LENGTH) {
throw ApiException.badRequest(
"비밀번호는 " + PASSWORD_MIN_LENGTH + "자 이상 " + PASSWORD_MAX_LENGTH + "자 이하로 입력해주세요.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Bcrypt 알고리즘은 입력값을 최대 72바이트(Bytes)까지만 처리하고, 그 이후의 입력은 자동으로 잘라냅니다(Silent Truncation). 현재 코드에서는 password.length() > 72로 글자 수(Character length) 기준 상한을 72자로 제한하고 있습니다. 하지만 한글이나 이모지 등 멀티바이트 캐릭터가 비밀번호에 포함될 경우, UTF-8 기준으로 한 글자당 3~4바이트를 차지하므로 실제로는 24자 미만에서도 72바이트 제한을 초과하여 뒷부분이 무시되는 보안 및 일관성 문제가 발생할 수 있습니다.\n\n따라서 글자 수가 아닌 실제 바이트 길이를 기준으로 상한을 검증하도록 수정하는 것이 안전합니다.

Suggested change
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 + "바이트 이하여야 합니다.");
}

Comment on lines +52 to +53
deleted_at TIMESTAMP,
created_at TIMESTAMP,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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,

Comment on lines +45 to +49
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException exception) {
log.warn("Data integrity violation", exception);
return error(HttpStatus.CONFLICT, "이미 존재하거나 중복된 데이터입니다.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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으로 구분
@coldmans

Copy link
Copy Markdown
Owner Author

리뷰 감사합니다. 4건 중 3건 반영, 1건은 사유와 함께 보류했습니다. (커밋 57abec8)

✅ 반영

  • subjects 인덱스 누락 (HIGH): 베이스라인에 Subject 엔티티 정의와 일치하는 인덱스 9종 추가. 신규 DB(시나리오 B) 풀스캔 방지.
  • Bcrypt 72바이트 (SEC-MED): 글자 수 → getBytes(UTF_8).length 기준으로 변경. 한글/이모지 비밀번호의 silent truncation 방지.
  • DataIntegrityViolation 세분화 (MED): getMostSpecificCause()의 SQLState가 23505(unique_violation)일 때만 409, 그 외(FK/NOT NULL 등)는 400으로 응답. duplicateRegisterReturnsConflictErrorResponse 테스트로 409 동작 확인.

⏸️ 보류 — TIMESTAMPTIMESTAMPTZ (MED)

일반론으로는 맞는 권장사항이나, 이 파일의 목적이 기존 운영 스키마의 재현(빈 DB/DR 복구) 이라 보류했습니다.

  • 엔티티가 java.time.LocalDateTime을 사용 → Hibernate는 이를 timestamp without time zone으로 매핑합니다. 기존 운영 DB도 같은 타입으로 생성됐을 가능성이 높습니다.
  • 베이스라인만 TIMESTAMPTZ로 바꾸면 ddl-auto=validate(prod) 단계에서 타입 불일치로 기동 실패할 위험이 있고, 운영 정본과도 어긋납니다.
  • 시간대 안전성을 제대로 가져가려면 엔티티를 Instant/OffsetDateTime으로 바꾸고 컬럼을 timestamptz로 전환 + 기존 데이터 마이그레이션이 필요한데, 이는 재오픈 직전 변경으로는 리스크가 커 별도 작업으로 분리하는 게 안전하다고 판단했습니다.
  • 그리고 이 베이스라인 자체가 "운영 pg_dump --schema-only와 대조 후 교체" 전제의 초안이므로, 최종 타입은 운영 정본을 따르게 됩니다.

./gradlew test 전체 통과(BUILD SUCCESSFUL) 확인했습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant