Skip to content

비회원 인터뷰#354

Merged
unifolio0 merged 3 commits into
developfrom
feature
Apr 30, 2026
Merged

비회원 인터뷰#354
unifolio0 merged 3 commits into
developfrom
feature

Conversation

@unifolio0

Copy link
Copy Markdown
Contributor

closed #

작업 내용

스크린샷

참고 사항

@unifolio0 unifolio0 self-assigned this Apr 30, 2026
@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Summary by CodeRabbit

  • 새로운 기능

    • 비회원(게스트) 면접 시작 지원—로그인 없이 시작 가능, IP당 1회로 제한
    • 비회원이 자신의 면접 결과를 조회할 수 있음
    • 일부 면접 관련 API가 비인증(게스트) 호출을 허용하도록 확장됨
  • API 변경

    • 면접 상태/결과 응답에 is_demo(Boolean) 필드 추가 (게스트 여부 표시)
  • 문서화

    • 비회원 면접 엔드포인트 및 예제(요청/응답/CURL) 추가

Walkthrough

게스트(비회원) 인터뷰를 IP 기반으로 시작·진행·조회할 수 있도록 클라이언트 IP 파라미터와 게스트 전용 흐름(락, 검증, 저장, 결과 조회)을 서비스·컨트롤러·도메인·DB 마이그레이션에 추가했습니다.

Changes

Cohort / File(s) Summary
문서 및 DB 마이그레이션
src/docs/asciidoc/index.adoc, src/main/resources/db/migration/V43__allow_guest_interview.sql
게스트 인터뷰 문서 추가, interview 테이블에 nullable member_idguest_ip 칼럼 추가, guest_ip 인덱스 생성
컨트롤러
src/main/java/com/samhap/kokomen/interview/controller/InterviewController.java, src/main/java/com/samhap/kokomen/interview/controller/InterviewControllerV2.java
게스트 시작 엔드포인트 추가(POST /api/v1/interviews/guest), @Authentication을 선택적(required=false)으로 변경, ClientIp 파라미터 전달 추가
도메인 모델
src/main/java/com/samhap/kokomen/interview/domain/Interview.java
member nullable 변경, guestIp 필드 추가, forGuest() 팩토리 및 게스트 관련 헬퍼 메서드 추가, DB 인덱스 반영
레포지토리
src/main/java/com/samhap/kokomen/interview/repository/RootQuestionRepository.java
findAllByState(RootQuestionState) 메서드 추가
서비스 — 시작(InterviewStartFacadeService)
src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java
startGuestInterview(ClientIp) 추가: IP당 1회 Redis 락(365일 TTL), 랜덤 활성 질문 선택, 게스트 인터뷰 저장 및 응답 생성; 락 키 생성 유틸·상수 추가
서비스 — 진행(InterviewProceedFacadeService, Infra Async)
src/main/java/com/samhap/kokomen/interview/service/InterviewProceedFacadeService.java, src/main/java/com/samhap/kokomen/interview/service/infra/InterviewProceedBedrockFlowAsyncService.java
ClientIp 전달 및 게스트/회원별 검증 분기 추가, 게스트 전용 Redis 락 네임스페이스 사용, 비동기 호출에 lockKey 전달로 서명 변경 및 토큰 사용 조건화
서비스 — 핵심(InterviewService, InterviewProceedService)
src/main/java/com/samhap/kokomen/interview/service/core/InterviewService.java, src/main/java/com/samhap/kokomen/interview/service/core/InterviewProceedService.java
checkInterview/findMyInterviewResultClientIp 추가, 게스트 검증 경로(validateGuestInterviewee) 및 게스트 결과 조회 구현, memberId 선택적 처리로 토큰/멤버 로드 조건화
서비스 — 쿼리/유틸
src/main/java/com/samhap/kokomen/interview/service/InterviewQueryService.java, src/main/java/com/samhap/kokomen/interview/service/question/RootQuestionService.java
쿼리 서비스에 ClientIp 전달 추가; 활성 질문 리스트에서 랜덤 선택하는 readRandomActiveRootQuestion() 추가
DTO/응답 모델
src/main/java/com/samhap/kokomen/interview/service/dto/...
게스트용 createMineForGuest() 팩토리 추가, 체크/완료 응답 레코드에 isDemo(Boolean) 필드 추가로 게스트 여부 포함
테스트 보조/테스트 케이스
src/test/java/.../InterviewFixtureBuilder.java, src/test/java/.../InterviewControllerTest.java, src/test/java/.../GuestInterviewServiceTest.java, 기타 테스트 파일
테스트 픽스처에 guestIp·memberSet 추가, 컨트롤러 및 서비스 테스트에 게스트 시나리오(시작, 중복 검사, 결과 조회) 및 시그니처 업데이트 반영

Sequence Diagram(s)

sequenceDiagram
    participant Client as 클라이언트
    participant Controller as InterviewController
    participant FacadeStart as InterviewStartFacadeService
    participant Redis as Redis
    participant RootService as RootQuestionService
    participant Repo as InterviewRepository
    participant DB as Database

    Client->>Controller: POST /api/v1/interviews/guest (ClientIp)
    Controller->>FacadeStart: startGuestInterview(ClientIp)
    FacadeStart->>Redis: SET lock:interview:started:guest:<ip> (NX, TTL)
    alt lock 획득
        Redis-->>FacadeStart: OK
        FacadeStart->>RootService: readRandomActiveRootQuestion()
        RootService->>Repo: findAllByState(ACTIVE)
        Repo->>DB: SELECT ...
        DB-->>Repo: [rows]
        Repo-->>RootService: questions
        RootService-->>FacadeStart: chosen question
        FacadeStart->>Repo: save(Interview member=null, guestIp)
        Repo->>DB: INSERT ...
        DB-->>Repo: interview_id
        FacadeStart-->>Controller: InterviewStartResponse
        Controller-->>Client: 200 OK
    else lock 실패
        Redis-->>FacadeStart: exists
        FacadeStart-->>Controller: BadRequestException
        Controller-->>Client: 400 Bad Request
    end
Loading
sequenceDiagram
    participant Client as 클라이언트
    participant Controller as InterviewController
    participant FacadeProceed as InterviewProceedFacadeService
    participant Service as InterviewService
    participant Redis as Redis
    participant Async as InterviewProceedBedrockFlowAsyncService
    participant DB as Database

    Client->>Controller: POST /api/v1/interviews/{id}/proceed (ClientIp, optional auth)
    Controller->>FacadeProceed: proceedInterviewBlockAsync(..., memberAuth?, ClientIp)
    FacadeProceed->>Service: (if unauthenticated) validateGuestInterviewee(interviewId, ClientIp)
    Service->>DB: SELECT interview WHERE id=?
    DB-->>Service: interview (guestIp)
    alt guestIp 일치
        Service-->>FacadeProceed: 검증 통과
        FacadeProceed->>Redis: SET lock:interview:proceed:guest:<ip> (NX)
        Redis-->>FacadeProceed: lockKey
        FacadeProceed->>Async: proceedInterviewByBedrockFlowAsync(memberId?, ..., interviewId, lockKey, lockValue)
        Async-->>FacadeProceed: ack
        FacadeProceed-->>Controller: 202 Accepted
        Controller-->>Client: 202 Accepted
    else 불일치
        Service-->>FacadeProceed: ForbiddenException
        FacadeProceed-->>Controller: 403 Forbidden
        Controller-->>Client: 403 Forbidden
    end
Loading
sequenceDiagram
    participant Client as 클라이언트
    participant Controller as InterviewController
    participant QueryService as InterviewQueryService
    participant Service as InterviewService
    participant DB as Database

    Client->>Controller: GET /api/v1/interviews/{id}/my-result (ClientIp, optional auth)
    Controller->>QueryService: findMyInterviewResult(interviewId, memberAuth?, ClientIp)
    QueryService->>Service: findMyInterviewResult(interviewId, memberAuth?, ClientIp)
    Service->>DB: SELECT interview WHERE id=?
    DB-->>Service: interview (maybe guest)
    alt unauthenticated
        Service->>Service: validateGuestInterviewee(interviewId, ClientIp)
        alt guestIp 일치
            Service->>DB: fetch answers/feedback
            DB-->>Service: data
            Service-->>QueryService: InterviewResultResponse.createMineForGuest(...)
        else 불일치
            Service-->>QueryService: ForbiddenException
        end
    else authenticated
        Service-->>QueryService: member result flow
    end
    QueryService-->>Controller: InterviewResultResponse
    Controller-->>Client: 200 OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • nak-honest
  • kargowild

Poem

🐰 게스트도 문을 두드려 인터뷰를 시작해,
IP로 한 번만 허락된 발걸음 가볍게,
락을 걸고 질문을 뽑아 담담히,
결과는 안전하게, 닉네임은 숨겨서,
당근 한 조각 기념으로 폴짝! 🥕✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR 설명은 템플릿 형식만 포함되어 있고 구체적인 작업 내용이나 설명이 전혀 없습니다. 작업 내용, 변경사항의 이유, 테스트 결과 등 구체적인 설명을 추가해주시기 바랍니다.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '비회원 인터뷰'는 변경사항의 핵심 내용과 명확하게 일치합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이번 변경 사항은 비회원 사용자가 인터뷰 기능을 체험할 수 있도록 시스템을 확장하는 것입니다. IP 주소를 활용하여 비회원을 식별하고, 인터뷰 생성 및 결과 조회 로직을 비회원도 사용할 수 있도록 범용화했습니다. 또한, 비회원 인터뷰는 1회로 제한하며, 기존 회원 인터뷰 로직과의 호환성을 유지하면서도 보안을 위해 비회원 인터뷰 결과는 공개되지 않도록 설정했습니다.

Highlights

  • 비회원 인터뷰 기능 추가: 비회원도 인터뷰를 시작하고 결과를 조회할 수 있는 기능을 추가했습니다. IP 기반으로 비회원을 식별하며, 1회만 체험 가능하도록 제한을 두었습니다.
  • 데이터베이스 스키마 변경: Interview 테이블의 member_id를 nullable로 변경하고, 비회원 식별을 위한 guest_ip 컬럼과 인덱스를 추가했습니다.
  • 인터뷰 로직 수정: 기존 회원 전용 로직을 비회원도 지원하도록 수정하고, 인터뷰 진행 및 결과 조회 시 인증 여부에 따라 분기 처리하도록 변경했습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


비회원도 면접을 보네, 질문은 쏟아지고 땀은 흐르네. IP로 식별하고 락을 걸어, 한 번의 기회 소중히 여기네.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@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 implements a guest interview feature, allowing unauthenticated users to participate in a limited text-mode interview identified by their IP address. Key changes include modifying the Interview entity to support nullable members, implementing IP-based rate limiting via Redis, and updating controllers and services to handle unauthenticated sessions. Feedback highlights a potential NullPointerException in InterviewService when memberAuth is null, a performance concern regarding in-memory random question selection in RootQuestionService, and a recommendation to shorten the 365-day Redis TTL for guest IP locks to better accommodate dynamic IP environments.

@Transactional(readOnly = true)
public InterviewResultResponse findMyInterviewResult(Long interviewId, MemberAuth memberAuth) {
public InterviewResultResponse findMyInterviewResult(Long interviewId, MemberAuth memberAuth, ClientIp clientIp) {
if (!memberAuth.isAuthenticated()) {

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

InterviewController에서 @Authentication(required = false)를 사용하여 memberAuthnull로 전달될 수 있습니다. 이 경우 memberAuth.isAuthenticated() 호출 시 NullPointerException이 발생합니다. memberAuthnull인 경우를 처리하는 로직을 추가하거나, ArgumentResolver에서 항상 유효한 객체를 반환하도록 보장해야 합니다.

private final QuestionVoicePathResolver questionVoicePathResolver;

public RootQuestion findRandomActiveRootQuestion() {
List<RootQuestion> rootQuestions = rootQuestionRepository.findAllByState(RootQuestionState.ACTIVE);

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

findAllByState를 사용하여 모든 활성 루트 질문을 메모리로 가져온 뒤 랜덤하게 선택하고 있습니다. 질문 데이터가 많아질 경우 성능 저하와 메모리 낭비가 발생할 수 있습니다. DB 레벨에서 랜덤하게 1개만 조회하도록 개선하는 것이 효율적입니다.

public static final String GUEST_INTERVIEW_STARTED_LOCK_KEY_PREFIX = "guest:interview:started:";
public static final int GUEST_INTERVIEW_MAX_QUESTION_COUNT = 3;
public static final InterviewMode GUEST_INTERVIEW_MODE = InterviewMode.TEXT;
public static final Duration GUEST_INTERVIEW_LOCK_TTL = Duration.ofDays(365);

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

비회원 인터뷰 제한을 위한 Redis 키의 TTL이 365일로 설정되어 있습니다. 유동 IP 환경에서는 IP가 재할당되어 다른 사용자가 이용하지 못하게 되는 부작용이 발생할 수 있습니다. 데모 기능임을 고려하여 TTL을 1일 또는 1주일 정도로 단축하는 것을 권장합니다.

@github-actions

github-actions Bot commented Apr 30, 2026

Copy link
Copy Markdown

Test Results

 51 files   51 suites   4m 24s ⏱️
291 tests 290 ✅ 1 💤 0 ❌
293 runs  292 ✅ 1 💤 0 ❌

Results for commit 887e2b6.

♻️ This comment has been updated with latest results.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/samhap/kokomen/interview/domain/Interview.java`:
- Around line 87-88: The guestIp field stores raw IPs which is a
security/privacy risk; change storage and comparison to a server-secret HMAC
instead: replace plain String guestIp persistence with a hashed field (e.g.,
guestIpHmac) and compute HMAC using a server-side secret when persisting and
when comparing in domain methods, update affected methods that read/compare
guestIp (search for guestIp uses in Interview and related comparison logic) and
add a migration to transform existing plain IPs to HMACs (or nullify), plus
ensure the secret is injected via configuration and never logged; apply the same
conversion for other places in this class that currently persist raw IPs.

In
`@src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java`:
- Around line 74-90: The guest-start lock currently uses acquireLock(lockKey) +
releaseLock(lockKey) which unconditionally deletes the key on error; change to
ownership-based locking: have startGuestInterview call the lock that returns a
lockValue (use createGuestInterviewStartedLockKey and the acquire variant that
returns a token), store that lockValue, and call releaseLock(lockKey, lockValue)
so the lock is only removed if the token matches (mirror the proceed-lock
pattern); ensure the catch/finally uses that lockValue when calling releaseLock
and that acquire failure still throws the BadRequestException.

In
`@src/main/java/com/samhap/kokomen/interview/service/question/RootQuestionService.java`:
- Line 30: The method name RootQuestionService.findRandomActiveRootQuestion
violates the team prefix rule because it throws when no value exists; rename it
to readRandomActiveRootQuestion and update all references (interfaces,
implementations, callers, and tests) accordingly so the signature and semantics
remain identical but the prefix reflects that the method guarantees a value;
ensure any overridden/implemented methods and Javadoc/comments are updated to
match the new name.

In `@src/main/resources/db/migration/V43__allow_guest_interview.sql`:
- Around line 5-7: 현재 plain-text guest_ip 컬럼과 인덱스(idx_interview_guest_ip, table
interview)는 개인정보 리스크가 있으니 평문 저장을 제거하거나 대체해야 합니다: 대신 guest_ip_hash 같은 컬럼을 추가하고(예:
VARCHAR(64)) IP를 서버 시크릿(salt)과 함께 안전한 해시(예: SHA-256)로 저장하도록 애플리케이션/마이그레이션을 변경하고,
기존 guest_ip 데이터를 변환해 해시로 마이그레이션한 뒤 원본 guest_ip 컬럼과 인덱스(idx_interview_guest_ip)를
제거하거나 NULL로 비우고 필요 시 해시 컬럼에 인덱스 생성하십시오; 또한 보존기간 기반 삭제 정책(예: interview 테이블에
guest_ip_hash 만 남기고 일정 기간 후 삭제)을 함께 설계해 구현하세요.

In
`@src/test/java/com/samhap/kokomen/interview/controller/InterviewControllerTest.java`:
- Around line 1388-1392: The test is sharing Redis state with other tests by
using a fixed IP and lock key; update the test that sets guestIp and calls
redisService.acquireLock (using
InterviewStartFacadeService.GUEST_INTERVIEW_STARTED_LOCK_KEY_PREFIX and
GUEST_INTERVIEW_LOCK_TTL) so it does not conflict: either generate a unique
guestIp per test (e.g., append a random/suffix) or explicitly clean the Redis
key after the test by removing
InterviewStartFacadeService.GUEST_INTERVIEW_STARTED_LOCK_KEY_PREFIX + guestIp
(or call the corresponding redisService.release/delete method) to ensure test
isolation and deterministic runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c140ef2-8da3-4bcb-a22f-d68c77e5a48e

📥 Commits

Reviewing files that changed from the base of the PR and between cef7b62 and adf9b41.

📒 Files selected for processing (23)
  • src/docs/asciidoc/index.adoc
  • src/main/java/com/samhap/kokomen/interview/controller/InterviewController.java
  • src/main/java/com/samhap/kokomen/interview/controller/InterviewControllerV2.java
  • src/main/java/com/samhap/kokomen/interview/domain/Interview.java
  • src/main/java/com/samhap/kokomen/interview/repository/RootQuestionRepository.java
  • src/main/java/com/samhap/kokomen/interview/service/InterviewProceedFacadeService.java
  • src/main/java/com/samhap/kokomen/interview/service/InterviewQueryService.java
  • src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java
  • src/main/java/com/samhap/kokomen/interview/service/core/InterviewProceedService.java
  • src/main/java/com/samhap/kokomen/interview/service/core/InterviewService.java
  • src/main/java/com/samhap/kokomen/interview/service/dto/InterviewResultResponse.java
  • src/main/java/com/samhap/kokomen/interview/service/dto/check/InterviewCheckResponse.java
  • src/main/java/com/samhap/kokomen/interview/service/dto/check/InterviewCheckTextModeResponse.java
  • src/main/java/com/samhap/kokomen/interview/service/dto/check/InterviewCheckVoiceModeResponse.java
  • src/main/java/com/samhap/kokomen/interview/service/dto/check/InterviewFinishedCheckResponse.java
  • src/main/java/com/samhap/kokomen/interview/service/infra/InterviewProceedBedrockFlowAsyncService.java
  • src/main/java/com/samhap/kokomen/interview/service/question/RootQuestionService.java
  • src/main/resources/db/migration/V43__allow_guest_interview.sql
  • src/test/java/com/samhap/kokomen/global/fixture/interview/InterviewFixtureBuilder.java
  • src/test/java/com/samhap/kokomen/interview/controller/InterviewControllerTest.java
  • src/test/java/com/samhap/kokomen/interview/service/GuestInterviewServiceTest.java
  • src/test/java/com/samhap/kokomen/interview/service/InterviewProceedFacadeServiceTest.java
  • src/test/java/com/samhap/kokomen/interview/service/core/InterviewServiceTest.java

Comment on lines +87 to +88
@Column(name = "guest_ip", length = 45)
private String guestIp;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

게스트 IP 평문 저장은 개인정보/보안 리스크가 큽니다.

guest_ip를 평문으로 영속화하고 비교키로 직접 사용하는 구조는 유출 시 영향이 큽니다. IP는 원문 대신 서버 비밀키 기반 해시(HMAC 등)로 저장/비교하는 방향이 안전합니다(도메인, 마이그레이션, 비교 로직 동반 수정 필요).

Also applies to: 133-137, 151-153

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/interview/domain/Interview.java` around
lines 87 - 88, The guestIp field stores raw IPs which is a security/privacy
risk; change storage and comparison to a server-secret HMAC instead: replace
plain String guestIp persistence with a hashed field (e.g., guestIpHmac) and
compute HMAC using a server-side secret when persisting and when comparing in
domain methods, update affected methods that read/compare guestIp (search for
guestIp uses in Interview and related comparison logic) and add a migration to
transform existing plain IPs to HMACs (or nullify), plus ensure the secret is
injected via configuration and never logged; apply the same conversion for other
places in this class that currently persist raw IPs.

Comment on lines +5 to +7
ADD COLUMN guest_ip VARCHAR(45) NULL;

CREATE INDEX idx_interview_guest_ip ON interview (guest_ip);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

guest_ip 원문 저장은 개인정보 보관 리스크가 큽니다.

IP를 평문으로 누적 저장/인덱싱하면 추적 가능성이 높아져 컴플라이언스 부담이 커집니다. 최소한 해시 저장(서버 시크릿 포함) 또는 보존기간 기반 삭제 정책을 같이 넣는 게 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/db/migration/V43__allow_guest_interview.sql` around lines
5 - 7, 현재 plain-text guest_ip 컬럼과 인덱스(idx_interview_guest_ip, table interview)는
개인정보 리스크가 있으니 평문 저장을 제거하거나 대체해야 합니다: 대신 guest_ip_hash 같은 컬럼을 추가하고(예:
VARCHAR(64)) IP를 서버 시크릿(salt)과 함께 안전한 해시(예: SHA-256)로 저장하도록 애플리케이션/마이그레이션을 변경하고,
기존 guest_ip 데이터를 변환해 해시로 마이그레이션한 뒤 원본 guest_ip 컬럼과 인덱스(idx_interview_guest_ip)를
제거하거나 NULL로 비우고 필요 시 해시 컬럼에 인덱스 생성하십시오; 또한 보존기간 기반 삭제 정책(예: interview 테이블에
guest_ip_hash 만 남기고 일정 기간 후 삭제)을 함께 설계해 구현하세요.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/samhap/kokomen/interview/service/core/InterviewService.java (1)

198-221: ⚠️ Potential issue | 🟠 Major

참고답안 조회 쿼리에서 게스트 인터뷰(member == null)를 명시적으로 제외해야 합니다.

AnswerRepository.findTopAnswersByRootQuestionAndRank() 쿼리가 게스트 인터뷰를 필터링하지 않으므로, A/B 등급의 게스트 답변이 참고답안 후보에 포함될 수 있습니다. findRootQuestionReferenceAnswers() 메서드의 247번 라인에서 answer.getQuestion().getInterview().getMember().getNickname()을 바로 호출하는데, 게스트 인터뷰는 member가 null이므로 NPE가 발생합니다.

SQL 쿼리에서 WHERE i.member IS NOT NULL 조건을 추가하여 게스트 인터뷰를 제외해주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/samhap/kokomen/interview/service/core/InterviewService.java`
around lines 198 - 221, The query for reference answers currently includes guest
interviews (member == null) which leads to NPE when code in
getReferenceAnswers/findRootQuestionReferenceAnswers calls
answer.getQuestion().getInterview().getMember().getNickname(); update the
AnswerRepository query method findTopAnswersByRootQuestionAndRank (and any
JPQL/SQL it uses) to add a WHERE i.member IS NOT NULL clause so guest interviews
are excluded, then re-run tests; also review getReferenceAnswers to ensure it
expects only non-null members after this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java`:
- Around line 75-92: The guest Redis lock is only released in the catch block so
if the transaction fails at commit the lock remains; after acquiring the lock in
startGuestInterview (createGuestInterviewStartedLockKey / acquireLockWithValue),
register a TransactionSynchronization (via
TransactionSynchronizationManager.registerSynchronization or equivalent) that in
afterCompletion checks the transaction status and calls
redisService.releaseLockSafely(lockKey, lockValue) when the transaction did NOT
commit (i.e., rollback or unknown), and keep the existing catch to handle
immediate runtime exceptions before the synchronization runs. Ensure the
synchronization is registered immediately after successful acquireLockWithValue
so the lock is always cleaned on non-commit outcomes.

---

Outside diff comments:
In
`@src/main/java/com/samhap/kokomen/interview/service/core/InterviewService.java`:
- Around line 198-221: The query for reference answers currently includes guest
interviews (member == null) which leads to NPE when code in
getReferenceAnswers/findRootQuestionReferenceAnswers calls
answer.getQuestion().getInterview().getMember().getNickname(); update the
AnswerRepository query method findTopAnswersByRootQuestionAndRank (and any
JPQL/SQL it uses) to add a WHERE i.member IS NOT NULL clause so guest interviews
are excluded, then re-run tests; also review getReferenceAnswers to ensure it
expects only non-null members after this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f1eff466-2890-43a3-9b36-2365e5ce1566

📥 Commits

Reviewing files that changed from the base of the PR and between adf9b41 and 887e2b6.

📒 Files selected for processing (6)
  • src/main/java/com/samhap/kokomen/interview/controller/InterviewController.java
  • src/main/java/com/samhap/kokomen/interview/service/InterviewQueryService.java
  • src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java
  • src/main/java/com/samhap/kokomen/interview/service/core/InterviewService.java
  • src/main/java/com/samhap/kokomen/interview/service/question/RootQuestionService.java
  • src/test/java/com/samhap/kokomen/interview/controller/InterviewControllerTest.java

Comment on lines +75 to +92
@Transactional
public InterviewStartResponse startGuestInterview(ClientIp clientIp) {
String lockKey = createGuestInterviewStartedLockKey(clientIp);
String lockValue = UUID.randomUUID().toString();
if (!redisService.acquireLockWithValue(lockKey, lockValue, GUEST_INTERVIEW_LOCK_TTL)) {
throw new BadRequestException("비회원 면접은 1회만 가능합니다.");
}
try {
RootQuestion rootQuestion = rootQuestionService.readRandomActiveRootQuestion();
Interview interview = interviewService.saveInterview(Interview.forGuest(rootQuestion,
GUEST_INTERVIEW_MAX_QUESTION_COUNT, GUEST_INTERVIEW_MODE, clientIp));
Question question = questionService.saveQuestion(new Question(interview, rootQuestion.getContent()));
return new InterviewStartTextModeResponse(interview, question);
} catch (RuntimeException e) {
redisService.releaseLockSafely(lockKey, lockValue);
throw e;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "InterviewStartFacadeService.java"

Repository: samhap-soft/kokomen-backend

Length of output: 157


🏁 Script executed:

wc -l ./src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java

Repository: samhap-soft/kokomen-backend

Length of output: 161


🏁 Script executed:

cat -n ./src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java | sed -n '70,100p'

Repository: samhap-soft/kokomen-backend

Length of output: 1879


🏁 Script executed:

rg -t java "releaseLockSafely|acquireLockWithValue" --max-count 5

Repository: samhap-soft/kokomen-backend

Length of output: 1712


🏁 Script executed:

cat ./src/main/java/com/samhap/kokomen/global/service/RedisService.java

Repository: samhap-soft/kokomen-backend

Length of output: 3176


🏁 Script executed:

grep -n "GUEST_INTERVIEW_LOCK_TTL" ./src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java

Repository: samhap-soft/kokomen-backend

Length of output: 255


트랜잭션 커밋 실패 시 게스트 락이 영구히 남습니다.

현재 해제 로직이 catch (RuntimeException) 블록에만 있어서, 메서드 본문은 정상 종료됐지만 트랜잭션 커밋 단계에서 예외가 발생하는 경우를 놓칩니다. 그 경우 인터뷰 저장은 롤백되는데 Redis 락은 365일 유지되어 해당 IP가 다시 시작하지 못합니다. 실패 시 해제는 catch가 아니라 트랜잭션 완료 콜백에서 rollback/non-commit 케이스로 처리해 주세요.

예시 수정안
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
...
     public InterviewStartResponse startGuestInterview(ClientIp clientIp) {
         String lockKey = createGuestInterviewStartedLockKey(clientIp);
         String lockValue = UUID.randomUUID().toString();
         if (!redisService.acquireLockWithValue(lockKey, lockValue, GUEST_INTERVIEW_LOCK_TTL)) {
             throw new BadRequestException("비회원 면접은 1회만 가능합니다.");
         }
-        try {
-            RootQuestion rootQuestion = rootQuestionService.readRandomActiveRootQuestion();
-            Interview interview = interviewService.saveInterview(Interview.forGuest(rootQuestion,
-                    GUEST_INTERVIEW_MAX_QUESTION_COUNT, GUEST_INTERVIEW_MODE, clientIp));
-            Question question = questionService.saveQuestion(new Question(interview, rootQuestion.getContent()));
-            return new InterviewStartTextModeResponse(interview, question);
-        } catch (RuntimeException e) {
-            redisService.releaseLockSafely(lockKey, lockValue);
-            throw e;
-        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            `@Override`
+            public void afterCompletion(int status) {
+                if (status != TransactionSynchronization.STATUS_COMMITTED) {
+                    redisService.releaseLockSafely(lockKey, lockValue);
+                }
+            }
+        });
+
+        RootQuestion rootQuestion = rootQuestionService.readRandomActiveRootQuestion();
+        Interview interview = interviewService.saveInterview(Interview.forGuest(rootQuestion,
+                GUEST_INTERVIEW_MAX_QUESTION_COUNT, GUEST_INTERVIEW_MODE, clientIp));
+        Question question = questionService.saveQuestion(new Question(interview, rootQuestion.getContent()));
+        return new InterviewStartTextModeResponse(interview, question);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/samhap/kokomen/interview/service/InterviewStartFacadeService.java`
around lines 75 - 92, The guest Redis lock is only released in the catch block
so if the transaction fails at commit the lock remains; after acquiring the lock
in startGuestInterview (createGuestInterviewStartedLockKey /
acquireLockWithValue), register a TransactionSynchronization (via
TransactionSynchronizationManager.registerSynchronization or equivalent) that in
afterCompletion checks the transaction status and calls
redisService.releaseLockSafely(lockKey, lockValue) when the transaction did NOT
commit (i.e., rollback or unknown), and keep the existing catch to handle
immediate runtime exceptions before the synchronization runs. Ensure the
synchronization is registered immediately after successful acquireLockWithValue
so the lock is always cleaned on non-commit outcomes.

@unifolio0 unifolio0 merged commit da4c403 into develop Apr 30, 2026
4 checks passed
@unifolio0 unifolio0 deleted the feature branch May 1, 2026 06:51
unifolio0 added a commit that referenced this pull request Jun 14, 2026
* fix: 비회원 면접 플로우

* refactor: 필드 추가

* refactor: 리뷰 반영
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