Skip to content

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

Merged
coldmans merged 6 commits into
mainfrom
fix/reopen-predeploy-hardening
Jul 1, 2026
Merged

재오픈 배포 전 보안·운영 견고성 강화#24
coldmans merged 6 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) 확인했습니다.

coldmans added 2 commits June 28, 2026 16:36
- 공식 엑셀 임포트가 학수번호 없는 레거시(PDF/AI 파싱) 과목을 매번 전량 비활성화하던 문제 차단 (#1)
- 조합 생성: 빈 시간표([]) 추천 제외(#5), 필수 학점이 목표+tolerance 초과 시 필수 시간표 최소 1개 보장(#3), dedup이 필수 분반을 버려 필수 제약이 소실되던 문제(#4)
- PDF parseTime: 비-범위 연속교시의 마지막 A/B 접미사를 무시해 종료시간이 0.5교시 길어지던 오류(#9)
- 위시리스트/시간표 중복검사를 (userId,subjectId,semester) 기준으로 변경 — 다른 학기에 같은 과목 추가 가능, semester null 구분(#14)
- update/remove를 '해당 과목의 모든 학기' 처리로 전환해 NonUniqueResultException 위험 제거(#14)
- 시간표 전체 비우기를 멱등 처리 + @transactional(빈 학기 404 오발생 제거)(#19)
- 회원가입/프로필에서 PRIMARY(주전공) 2개 이상 저장되던 정규화 누락 차단(#13)
- 활동기록: 캐시 add 후 저장 실패 시 캐시 복구, 동시 요청 unique 위반을 멱등 흡수, 컨트롤러에서 기록 실패가 가입/로그인을 막지 않게 처리(#6/#7)
@coldmans

Copy link
Copy Markdown
Owner Author

추가: 로직 버그 헌팅 결과 핵심 9건 수정 (커밋 faf5353, b02a2a7)

별도 버그 헌팅(5개 영역 fan-out + 적대적 검증)에서 확정한 19건 중, 영향이 크고 수정이 명확한 핵심 9건을 반영했습니다.

# 버그 수정
#1 공식 엑셀 임포트가 학수번호 없는 레거시 과목을 매번 전량 비활성화 diff().removedhasText(courseCode) 필터
#3 필수 학점 > 목표+tolerance 면 0개 반환 필수 시간표 최소 1개 보장
#4 dedup이 필수 분반을 버려 필수 제약 소실 같은 과목명 그룹에서 필수 분반 우선 보존
#5 목표학점 1~3일 때 빈 시간표[] 추천 !current.isEmpty() 가드
#9 PDF 비-범위 연속교시의 마지막 A/B 무시 → 종료시간 0.5교시 오류 convertToTimeEnd 규칙 적용
#13 API로 PRIMARY 전공 2개 저장 정규화에서 PRIMARY>1 거부
#14 위시리스트/시간표 중복검사가 학기 무시 → 다른 학기 추가 차단 + NonUniqueResult(500) (user,subject,semester) 기준 + update/remove를 '모든 학기' 처리
#19 시간표 전체삭제가 빈 학기에 404(멱등성 위반) 멱등 처리 + @Transactional
#6/#7 활동기록 캐시 add 후 저장실패 시 DAU 영구 누락 / unique 위반이 가입·로그인을 500으로 캐시 복구 + 멱등 흡수 + 컨트롤러에서 흡수

동작 변경 주의(#14): 위시리스트/시간표 update(priority/required/memo)·remove가 이제 해당 과목의 모든 학기 항목에 적용됩니다(컨트롤러가 semester를 받지 않아 일관성을 위해 선택).

테스트: ./gradlew test 전체 통과. 회귀 테스트 2건 추가(#1 레거시 유지, #13 PRIMARY 거부), 버그 동작을 정답으로 간주하던 기존 테스트 1건 정정.

미반영(LOW/리팩토링): #2(조합 절단-정렬 순서, 알고리즘 재설계 필요), #8(학과 증식), #10/#11/#12(파싱), #15~#18.

@coldmans coldmans merged commit b2674fd into main Jul 1, 2026
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