Skip to content

[Refactor] pickeat 플로우에서 인메모리 캐싱 적용#417

Draft
jinu0328 wants to merge 37 commits intoback/devfrom
407-feat-pickeat-플로우에서-인메모리-캐싱-적용

Hidden character warning

The head ref may contain hidden characters: "407-feat-pickeat-\ud50c\ub85c\uc6b0\uc5d0\uc11c-\uc778\uba54\ubaa8\ub9ac-\uce90\uc2f1-\uc801\uc6a9"
Draft

[Refactor] pickeat 플로우에서 인메모리 캐싱 적용#417
jinu0328 wants to merge 37 commits intoback/devfrom
407-feat-pickeat-플로우에서-인메모리-캐싱-적용

Conversation

@jinu0328
Copy link
Copy Markdown
Contributor

@jinu0328 jinu0328 commented Oct 20, 2025

Issue Number

#407

기존 객체참조 부분을 모두 간접참조로 바꾸다보니 변경이 전파된 부분이 많습니다 ㅠㅠ
커밋별로 확인해주시는 편이 파악하시기 수월할 것 같아요.
커밋별로 한 작업 간단히 정리해봤습니당
flyway 관련해서는 로컬에서 테스트 완료하긴했는데 검수 한번씩 꼭 부탁드려요!!
질문 지적 환영합니다~~

기반 세팅

2935a85 Spring Cache + Caffeine 의존성 추가
→ 이후 전역 캐싱 도입을 위한 기반 세팅입니다.

전역 캐시 구성

a79ad1c Caffeine 기반 글로벌 CacheManager 구성
→ TTL·사이즈·캐시 이름을 한 곳에서 관리하도록 중앙화했습니다.

도메인 구조 정리

c52ad63 Restaurant 계열 엔티티가 Pickeat을 Long ID로 간접 참조하도록 리팩토링
→ 양방향 연관 제거로 JPA 로딩/삭제 부담을 줄였습니다.

4df9574 Participant 엔티티도 Pickeat을 Long ID로 간접 참조하도록 변경
→ 순환 참조 위험을 줄이고 구조를 단순화했습니다.

830b09b PickeatResult에서 Pickeat/Restaurant 직접 필드를 Long ID로 교체
→ 도메인 전반을 간접 참조 방식으로 통일했습니다.

6974468 Pickeat의 참가자 수 필드 및 로직 제거
→ 스키마와 도메인 상태를 일관되게 유지했습니다.

508c2ed PickeatResult의 동점 여부 필드 제거 및 관련 로직 정리
→ 사용되지 않는 필드를 정리해 도메인 단순화.

0b22cee Restaurants 도메인을 PickeatResultGenerator로 대체
→ 레스토랑 선택 로직을 별도 서비스로 분리하여 테스트 용이성 및 응집도 향상.

캐시 도입 및 최적화

bc6a006 RestaurantRepositoryImpl에 Pickeat별 식당 조회 캐시 적용
→ 기존 도메인 기반 조회 대신 캐시 결과를 사용하도록 개선.

20e32f8 RestaurantLikeRepositoryImpl 캐싱 도입
→ 좋아요 수·참여자별 좋아요 목록 캐싱, save/delete 시 캐시 무효화 처리 추가.

13ca77e PickeatRepositoryImpl 캐싱 적용
→ 코드 기반 Pickeat 조회에 캐시 추가 및 비활성화 시 캐시 무효화.

ab464e9 ParticipantRepositoryImpl에 Spring Cache 적용
→ Pickeat별 참가자 목록 캐싱 및 저장/조회 로직 리팩토링.

이벤트 기반 캐시 무효화

eeb93d6 Pickeat 비활성화 이벤트 신설
→ Pickeat 비활성화 시 식당·Pickeat 캐시를 트랜잭션 이벤트로 통합 처리.

a047a2a Pickeat 비활성화 시 참가자 캐시도 함께 비우도록 이벤트 핸들러 보강
→ 기존 Pickeat/식당 캐시만 비워지던 문제를 해결.

9e44b1c 참가자 완료 상태 변경 이벤트 및 캐시 무효화 핸들러 추가
→ 참가자 상태 업데이트 시 캐시가 즉시 갱신되도록 개선.

Flyway 및 DB 정합성

fd54f64 PickeatResult·Restaurant 간 직접 연관을 Long ID 기반으로 변경
→ Flyway V10~V14 스키마 정리 및 인덱스 재구성 포함.

Check List

  • 빌드, 테스트가 전부 통과되었나요?
  • merge할 branch를 확인했나요?
  • Assignee를 지정했나요?
  • Label을 지정했나요?

(Optional) Additional Description

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 캐싱 레이어 추가로 API 응답 성능 개선
  • 버그 수정

    • 인증 토큰 생성 로직 안정성 개선
    • 응답 로그에서 불필요한 바디 데이터 제거
  • 개선 사항

    • 데이터 접근 패턴 최적화
    • 이벤트 기반 캐시 무효화 메커니즘 구현
    • 내부 데이터 모델 정규화

- 캐시 활용을 위한 spring-boot-starter-cache 및 caffeine 의존성 추가
- 로깅 간결화를 위해 body 필드 제거
- LoggingEventCompositeJsonEncoder를 활용하여 JSON 형식의 로그 출력 적용
- 로깅 타임스탬프, 레벨, 쓰레드명, 메시지 등의 정보를 구조화하여 기록
- ParticipantTokenProvider와 ProviderTokenProvider 생성 및 로직 분리
- JwtProvider 리팩토링으로 추가 정보를 포함한 토큰 생성 기능 도입
- 관련 컨트롤러 및 서비스 로직 수정
- Pickeat 엔티티의 participantCount 필드 삭제
- hasEqualLike 관련 로직 삭제
- RestaurantResultResponse -> PickeatResultResponse로 클래스명 변경
- PickeatResult에 동점 여부 필드 제거
- SQL 및 테스트 코드 수정
- 불필요한 코드를 간소화하고 가독성을 개선
…간접참조로 대체

- Pickeat 및 Restaurant를 ID로만 저장하도록 수정
- Repository 및 Service 로직 변경
- 테스트 코드 수정 및 불필요한 OneToOne 매핑 제거
- Pickeat을 ID로만 저장하도록 수정
- Repository 및 Service 로직 개선
- 테스트 코드 수정 및 불필요한 ManyToOne 매핑 제거
- 코드 가독성 향상 및 성능 최적화
- Pickeat을 ID로만 저장하도록 수정
- Restaurant의 likeCount 및 관련 로직 제거
- RestaurantService 및 Repository 로직 변경 및 개선
- Tests 내부 로직 및 픽스처 생성 방식 수정
- 기존 Restaurants 도메인 클래스 제거
- 코드 가독성 향상 및 무결성 검증 로직 단순화
- 단위 테스트 불가한 도메인 제거 후 애플리케이션 레이어로 이동
- CacheManagerConfig 클래스 생성 및 글로벌 캐시 매니저 설정
- CacheType enum 정의 및 캐시 타입별 속성 관리
- RESTAURANT 캐시 타입 추가 (TTL, 최대 크기, 초기 용량 설정 포함)
- RestaurantRepository를 JpaRepository와 JdbcTemplate 기반으로 분리해 RestaurantJpaRepository와 RestaurantJdbcRepository로 구현
- Pickeat 관련 캐싱 설정 도입 및 삭제 시 캐시 무효화 로직 적용
- Pickeat 비활성화 시 발생하는 PickeatDeactivatedEvent 생성
- 해당 이벤트를 처리하여 Restaurant 캐시를 무효화하는 PickeatDeactivatedEventHandler 추가
- RestaurantService에서 사용자 좋아요 데이터 조회 최적화
- RestaurantLike 케싱 관련 테스트 코드 추가
- PickeatRepository 인터페이스 분리 및 PickeatRepositoryImpl 클래스 생성
- Pickeat 검색 결과 캐싱 로직 추가 (Caffeine 활용)
- Pickeat 캐시 사용 테스트 코드 추가
- PickeatJpaRepository로 리팩토링 및 관련 로직 최적화
- Pickeat 비활성화 시 캐시 무효화 처리 로직 강화 (PickeatDeactivatedEventHandler 수정)
- ParticipantRepository 인터페이스 분리 및 ParticipantRepositoryImpl 클래스 생성
- 참가자 검색 결과 캐싱 로직 추가 (Caffeine 활용)
- 참가자 캐시 사용 테스트 코드 추가
- ParticipantJpaRepository로 리팩토링 및 관련 코드 최적화
- ParticipantStateUpdatedEvent 도메인 이벤트 클래스 생성
- ParticipantStateUpdatedEventHandler 구현 및 트랜잭션 후 이벤트 처리 로직 추가
- 참가자 캐시 무효화 로직 적용 (ParticipantRepositoryImpl 활용)
- PickeatDeactivatedEventHandler에 참가자 캐시 무효화 처리 로직 적용
- PickeatRepositoryImpl의 캐싱 키 표현식 수정 (value.toString() 사용)
- PickeatResult에서 has_equal_like 컬럼 제거
- PickeatResult 테이블의 FK 제약 조건 삭제 및 pickeat_id, restaurant_id 필드 NOT NULL로 수정
- Restaurant에서 like_count, type 컬럼 제거 및 기존 외래키 제약 조건 삭제
- Participant 및 Restaurant 테이블에 대한 새로운 인덱스 생성
- 간접 참조 처리 기반 데이터 무결성 유지 및 불필요한 컬럼 정리
@jinu0328
Copy link
Copy Markdown
Contributor Author

@coderabbitai
리뷰해줘

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Oct 20, 2025

@jinu0328 네, PR을 리뷰하겠습니다!

커밋 단위로 잘 정리해주셔서 변경 사항을 파악하기 수월할 것 같습니다. 특히 다음 부분들을 중점적으로 확인하겠습니다:

  • 캐싱 전략: Spring Cache + Caffeine 설정 및 TTL/사이즈 구성
  • 간접 참조 전환: 객체 참조에서 Long ID 기반으로의 리팩토링
  • 캐시 무효화: 이벤트 기반 캐시 무효화 로직의 적정성
  • Flyway 마이그레이션: 스키마 변경 및 인덱스 구성

바로 리뷰를 시작하겠습니다! 🐰

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 34

Caution

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

⚠️ Outside diff range comments (4)
backend/src/main/java/com/pickeat/backend/global/log/dto/ResponseLog.java (1)

1-33: 테스트 코드가 필요합니다!

ResponseLog의 구조가 변경되었으므로, 다음 테스트 케이스들이 필요합니다:

1. 단위 테스트 (ResponseLogTest.java)

@Test
void of_메서드로_ResponseLog를_생성한다() {
    // given
    ContentCachingResponseWrapper response = mock(ContentCachingResponseWrapper.class);
    when(response.getStatus()).thenReturn(200);
    String requestURI = "/api/test";
    
    // when
    ResponseLog responseLog = ResponseLog.of(response, requestURI);
    
    // then
    assertThat(responseLog.logType()).isEqualTo(LogType.RESPONSE);
    assertThat(responseLog.uri()).isEqualTo(requestURI);
    assertThat(responseLog.status()).isEqualTo(200);
}

@Test
void fields_메서드는_body를_포함하지_않는다() {
    // given
    ResponseLog responseLog = new ResponseLog(LogType.RESPONSE, "/api/test", 200);
    
    // when
    Map<String, Object> fields = responseLog.fields();
    
    // then
    assertThat(fields).containsKeys("logType", "uri", "status");
    assertThat(fields).doesNotContainKey("body");
    assertThat(fields.get("status")).isEqualTo(200);
}

@Test
void summary_메서드는_올바른_형식으로_문자열을_반환한다() {
    // given
    ResponseLog responseLog = new ResponseLog(LogType.RESPONSE, "/api/test", 404);
    
    // when
    String summary = responseLog.summary();
    
    // then
    assertThat(summary).isEqualTo("[RESPONSE] /api/test 404");
}

2. 통합 테스트 또는 필터 테스트

  • 로깅 필터/인터셉터와 함께 ResponseLog가 올바르게 생성되고 기록되는지 검증
  • body가 로그에 포함되지 않는지 확인
#!/bin/bash
# 기존 테스트 코드 확인

echo "=== ResponseLog 관련 테스트 파일 찾기 ==="
fd -t f -e java "ResponseLog" backend/src/test

echo -e "\n=== 로깅 관련 테스트 파일 찾기 ==="
fd -t f -e java backend/src/test | xargs rg -l "ResponseLog|LogType"
backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java (1)

85-95: 좋아요 동시성 문제: 데이터 무결성 보장 필요

레이스 컨디션이 실제로 존재합니다. RestaurantLike 엔티티와 restaurant_like 테이블 모두에 (restaurant_id, participant_id) 복합 유니크 제약이 없어서, 두 스레드가 동시에 existsLike() 체크를 통과한 후 save()를 호출하면 중복된 좋아요가 데이터베이스에 저장될 수 있습니다.

해결 방법:

  1. 데이터베이스 제약 추가 (필수)

    • 마이그레이션: ALTER TABLE restaurant_like ADD UNIQUE KEY uk_restaurant_participant (restaurant_id, participant_id);
    • JPA 엔티티: @Table(uniqueConstraints = @UniqueConstraint(columnNames = {"restaurantId", "participantId"}))
  2. 예외 처리 (필수)

    try {
        restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
    } catch (DataIntegrityViolationException e) {
        throw new BusinessException(ErrorCode.PARTICIPANT_RESTAURANT_ALREADY_LIKED);
    }

    이렇게 하면 existsLike() 실패 후 누군가 먼저 저장해도 idempotent하게 처리됩니다.

  3. 테스트 케이스 추가 제안

    • 동시성 테스트: 두 스레드가 동시에 like() 호출 시 중복 저장 방지 확인
    • 예외 처리 테스트: DataIntegrityViolationException 발생 시 BusinessException 변환 확인

이것은 single point of truth(데이터베이스 제약)에 application logic(예외 처리)을 덧붙이는 방어 전략입니다. 같은 방식을 unlike() 메서드에도 적용해주세요.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/ui/api/RestaurantApiSpec.java (1)

120-123: RequestBody 충돌로 인한 바인딩 실패 가능성(치명적)

이 위치의 @RequestBody는 현재 파일 상단에서 임포트한 io.swagger.v3.oas.annotations.parameters.RequestBody로 해석됩니다. 따라서 Spring MVC가 본 파라미터를 HTTP 바디로 바인딩하지 못할 수 있어 400/Null 바인딩이 발생합니다. 위/아래 메서드들처럼 Spring의 @org.springframework.web.bind.annotation.RequestBody로 통일해주세요.

권장 수정안:

-    @Valid @RequestBody WishRestaurantRequest request);
+    @Valid @org.springframework.web.bind.annotation.RequestBody WishRestaurantRequest request);

또는 Swagger RequestBody는 OpenAPI 문서의 operation.requestBody에만 사용하고, 파라미터에는 반드시 Spring의 @RequestBody를 사용하도록 일관성을 유지해주세요.

backend/src/main/java/com/pickeat/backend/pickeat/ui/api/PickeatApiSpec.java (1)

240-254: createPickeatResult의 응답 코드 정책을 문서에서 명확히 통일해 주세요

컨트롤러는 201만 반환합니다. 스펙은 신규 201/기존 200을 함께 기재합니다. 운영 혼선을 막기 위해

  • A안: 201만 반환하도록 스키마·예시를 단일화, 또는
  • B안: 코드 변경으로 201/200 분기
    중 하나로 결정하여 문서/코드 동기화가 필요합니다.

Also applies to: 278-282

♻️ Duplicate comments (1)
backend/src/main/java/com/pickeat/backend/restaurant/ui/RestaurantController.java (1)

57-64: (v2와 동일) 서비스 인자 형태 일관화 권장

위 v2 컨트롤러 코멘트와 동일한 맥락입니다. 두 버전이 공존하므로 규칙을 맞춰두면 마이그레이션에 유리합니다.

🧹 Nitpick comments (96)
backend/src/main/resources/db/migration/V13__drop_participant_pickeat_foreign_key.sql (1)

21-35: 인덱스 생성 후 검증 단계 추가를 고려해보세요.

현재 마이그레이션은 idx_participant_pickeat_id 인덱스를 조건부로 생성하지만, 생성 후 실제로 성공했는지 확인하지 않습니다. 특히 프로덕션 환경에서 대규모 테이블에 인덱스를 추가할 때 타임아웃이나 메모리 부족 문제가 발생할 수 있습니다.

고려사항:

  • 마이그레이션 전 participant 테이블의 예상 행 수를 문서로 남기거나, 성능 영향도를 별도 이슈/ADR로 기록해두세요.
  • pickeat_id 컬럼이 실제로 쿼리 성능 개선에 필요한지, 그리고 다른 컬럼과 복합 인덱스가 더 나을지 확인하셨나요?

이는 필수 수정은 아니지만, 향후 마이그레이션 문제 추적 시 도움이 될 것 같습니다.

backend/src/main/resources/logback-local.xml (1)

4-24: 구조화된 로깅 필드 설정 — MDC 및 민감 정보 필터링 검토 제안

제공자(providers) 구성이 명확하고 표준에 맞게 설정되었습니다. 몇 가지 고려 사항을 제안합니다:

  1. MDC(Mapped Diagnostic Context) 활용 정책: <mdc/>가 모든 MDC 값을 로그에 포함하도록 설정되어 있습니다. 현재 코드에서 MDC에 어떤 정보를 담고 있는지, 그리고 요청별 추적(request tracing)이나 사용자 정보 등이 포함되는지 확인해주세요.

  2. 민감 정보 보호: 특히 <arguments/><mdc/>는 의도치 않게 비밀번호, 토큰, 개인정보(이메일, 전화번호 등)를 노출할 수 있습니다. 로그 필터링 또는 마스킹 정책을 수립하면 좋습니다. 예를 들어:

    • logstash-logback-encoder의 JsonGeneratorDecorator를 활용한 필터
    • 또는 Spring Boot의 로그 필터 구현
  3. 로그 크기 및 성능 영향: <arguments/>를 통해 모든 메서드 인자를 로깅하면, 특히 큰 객체나 대용량 데이터가 포함될 경우 로그 크기가 증가하고 성능에 영향을 줄 수 있습니다. 필요한 정보만 선택적으로 로깅하는 방식도 검토해보세요.

참고: Logback 공식 문서 - Configuration, LogstashEncoder

backend/src/main/resources/db/migration/V12__update_pickeat_result_indirect_reference.sql (2)

1-9: 마이그레이션 작업 순서와 원자성을 검토하세요.

현재 마이그레이션은 외래 키 제거와 NOT NULL 제약 추가를 분리된 ALTER 문으로 수행하고 있습니다. 동시성 환경에서 이 두 작업 사이의 간격은 데이터 정합성 위험을 초래할 수 있습니다.

개선 방법:

하나의 원자적 트랜잭션으로 통합하는 것을 권장합니다:

START TRANSACTION;

ALTER TABLE `pickeat_result`
    DROP FOREIGN KEY `FKg5rkj84lmqukrfiuhos82thdx`,
    DROP FOREIGN KEY `FKlkhf7ufetto289l3lyx35f39o`,
    MODIFY COLUMN `pickeat_id` bigint NOT NULL,
    MODIFY COLUMN `restaurant_id` bigint NOT NULL;

COMMIT;

또는 Flyway의 executeInTransaction 설정 확인:

# application.properties 또는 application.yml
spring.flyway.execute-in-transaction=true

참고: 우테코의 공식 컨벤션 문서(docs/back-code-convention.md)에서 DB 마이그레이션 안내를 확인하세요.


7-9: 롤백(Downgrade) 전략이 없습니다.

이 마이그레이션이 실패하거나 롤백해야 하는 경우, 반대 작업을 수행할 방법이 없습니다. 프로덕션 환경에서 긴급 롤백이 필요할 때 큰 부담이 됩니다.

권장 사항:

  1. 마이그레이션 전에 데이터베이스 백업 수행:

    mysqldump -h ${DB_HOST} -u ${DB_USER} -p${DB_PASSWORD} ${DB_NAME} > backup_v12.sql
  2. 롤백 계획 수립:

    • V12가 실패하면 이전 버전(V11)으로 자동 롤백
    • 또는 명시적인 Undo 마이그레이션 파일(V12_undo.sql) 준비
  3. 변경 영향도 검증:

    -- 외래 키 제거 후 참조 무결성 위반 확인
    SELECT pr.id, pr.pickeat_id, pr.restaurant_id
    FROM pickeat_result pr
    LEFT JOIN pickeat p ON pr.pickeat_id = p.id
    LEFT JOIN restaurant r ON pr.restaurant_id = r.id
    WHERE p.id IS NULL OR r.id IS NULL;
  4. 점진적 롤아웃 전략:

    • 스테이징 환경에서 충분히 테스트
    • 피크 시간 외(야간/휴일)에 프로덕션 배포
    • 모니터링 대기 후 다음 마이그레이션 진행
backend/src/main/resources/db/migration/V14__update_restaurant_schema_for_indirect_reference.sql (1)

14-14: SELECT 1을 no-op으로 사용하는 것은 작동하지만, 가독성과 관례를 위해 개선을 권장합니다.

현재 코드는 조건부 실행에서 "아무것도 하지 않아야 할 때" SELECT 1을 사용합니다(lines 14, 30, 46). 이는 동작하지만 SQL 관례상 다음과 같은 문제가 있습니다:

  1. 의도 불명확: SELECT 1이 실제로 "no-op"인지 명시적이지 않습니다.
  2. 성능 고려: MySQL은 실제로 쿼리를 실행해야 합니다.

개선 방안:

방법 1 - 주석 추가 (현재 방식 유지):

'SELECT 1'  -- 아무 작업 안 함 (no-op)

방법 2 - MySQL 표준 문법 (권장):

-- MySQL 5.7.4+ 에서 지원
IF NOT EXISTS (...) THEN
    -- 작업 수행
END IF;

다만 현재 마이그레이션 스타일(PREPARE/EXECUTE 패턴)을 유지한다면, 명시적 주석을 추가하는 것이 충분합니다:

SET @drop_like_count_sql := (
    SELECT IF(EXISTS (...),
              'ALTER TABLE `restaurant` DROP COLUMN `like_count`',
              'SELECT 1'  /* 컬럼이 이미 없으면 스킵 */)
);

참고: 이 부분은 기능상 문제는 아니지만, 향후 유지보수 시 의도를 명확히 하는 데 도움이 됩니다.

Also applies to: 30-30, 46-46

backend/src/main/java/com/pickeat/backend/global/log/dto/ResponseLog.java (1)

12-18: 팩토리 메서드 시그니처를 더 일반적으로 개선할 수 있겠네요.

코드는 깔끔하게 정리되었지만, 한 가지 개선할 점이 있습니다.

개선 제안:

현재 ResponseLog.of()response.getStatus() 하나만 사용하고 있습니다. ContentCachingResponseWrapper의 바디 캐싱 기능을 활용하지 않는다면, 더 일반적인 HttpServletResponse 인터페이스로 매개변수를 변경하는 것이 좋습니다.

public static ResponseLog of(HttpServletResponse response, String requestURI) {
    return new ResponseLog(
            LogType.RESPONSE,
            requestURI,
            response.getStatus()
    );
}

왜 이렇게 하면 좋을까요?

  • ResponseLog가 불필요하게 구체적인 타입(ContentCachingResponseWrapper)에 의존하지 않음
  • 호출 시 더 유연한 Response 객체를 전달 가능
  • 테스트 시에도 Mock이나 다른 HttpServletResponse 구현체 사용 가능
  • 관심사 분리: ResponseLog는 상태 정보만 관심, 바디 캐싱 메커니즘과 무관

참고: LogFilter에서는 여전히 ContentCachingResponseWrapper가 필요합니다. copyBodyToResponse()를 호출하여 response body를 클라이언트에 전달해야 하기 때문입니다. 따라서 filter의 cacheResponse 래핑 자체는 유지하되, ResponseLog.of()로 전달할 때만 인터페이스 타입으로 변환하면 됩니다.

backend/src/main/java/com/pickeat/backend/global/auth/principal/ParticipantPrincipal.java (1)

1-4: Record 사용이 적절합니다! 불변 데이터 전달에 딱이네요. 👏

Principal 타입으로 인증 컨텍스트를 명확히 표현한 점이 좋습니다. 몇 가지 개선 제안:

  1. JavaDoc 추가: Principal의 역할과 사용 시점을 문서화하면 팀원들이 이해하기 쉽습니다.

    /**
     * 픽잇 참가자의 인증 정보를 담는 Principal 객체
     * @param id 참가자 식별자
     * @param rawPickeatCode 참가자가 속한 픽잇의 코드
     */
    public record ParticipantPrincipal(Long id, String rawPickeatCode) {
    }
  2. 유효성 검증 고려: rawPickeatCode가 null이거나 빈 문자열일 수 있나요? 필요하다면 compact constructor에서 검증을 추가할 수 있습니다.

    public record ParticipantPrincipal(Long id, String rawPickeatCode) {
        public ParticipantPrincipal {
            if (id == null || id <= 0) {
                throw new IllegalArgumentException("id는 양수여야 합니다");
            }
            if (rawPickeatCode == null || rawPickeatCode.isBlank()) {
                throw new IllegalArgumentException("rawPickeatCode는 필수입니다");
            }
        }
    }

현재 설계가 의도된 것이라면 무시하셔도 됩니다!

backend/src/test/java/com/pickeat/backend/pickeat/domain/ParticipantTest.java (1)

24-29: Assertion 검증 로직이 명확합니다! ✨

getPickeatId()를 사용한 검증으로 깔끔하게 업데이트되었네요.

추가 테스트 제안:

  • 유효하지 않은 pickeatId 테스트: pickeatId가 null이거나 음수일 때의 동작을 검증하는 테스트를 추가하면 좋겠습니다.
    @Test
    void pickeatId가_null이면_생성_실패() {
        assertThatThrownBy(() -> new Participant("테스트", null))
            .isInstanceOf(IllegalArgumentException.class);
    }

현재 Participant 생성자에 validation이 없다면 추가를 고려해보세요!

backend/src/test/java/com/pickeat/backend/fixture/ParticipantFixture.java (1)

7-8: 고정 닉네임은 충돌 가능 — 랜덤/파라미터화 권장

여러 테스트에서 동일 닉네임("참가자")을 쓰면 유니크 제약이나 시나리오 간 간섭이 생길 수 있어요. 기본 닉네임을 랜덤하게 만들거나 파라미터 오버로드를 추가해 주세요. 또한 Pickeat 객체를 가진 테스트를 위해 ID를 추출하는 편의 오버로드도 있으면 좋습니다. 깔끔 리팩 좋았습니다!

예시 diff:

 public class ParticipantFixture {

-    public static Participant create(Long pickeatId) {
-        return new Participant("참가자", pickeatId);
-    }
+    public static Participant create(Long pickeatId) {
+        return new Participant("참가자-" + java.util.UUID.randomUUID(), pickeatId);
+    }
+
+    public static Participant createWithNickname(Long pickeatId, String nickname) {
+        return new Participant(nickname, pickeatId);
+    }
+
+    // 테스트 가독성용 편의 오버로드
+    public static Participant create(com.pickeat.backend.pickeat.domain.Pickeat pickeat) {
+        return create(pickeat.getId());
+    }

테스트 케이스 제안:

  • 동일 Pickeat 내 동일 닉네임 중복 저장 시 예외 발생 확인(유니크 제약 존재 시).
    (backend/** 가이드라인 준수)
backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatScheduler.java (4)

35-37: 스케줄 타이밍과 타임존 명시

서버 JVM 타임존에 의존하면 일자 경계가 흔들릴 수 있어요. @scheduled에 zone을 명시하거나 설정으로 주입해 주세요. 주기 역시 설정값으로 추출하면 운영 편의성이 올라갑니다. 잘 쪼갠 책임 좋습니다!

예시 diff:

-    @Scheduled(cron = "0 0 0 * * *") // 매일 00:00에 실행
+    @Scheduled(cron = "${pickeat.cleanup.cron:0 0 0 * * *}", zone = "${app.timezone:Asia/Seoul}") // 매일 00:00 (KST 기본)

추가로 Clock 주입을 고려하면 테스트 용이성과 일관성이 좋아집니다.


38-44: 삭제 대상 날짜 범위: ‘해당 일 하루’ vs ‘기준 이전 전체’ 의도 확인

현재는 기준일(LocalDate.now().minusDays(3))의 00:00~23:59:59.999 사이만 삭제합니다. 매일 정상 동작하면 “딱 3일 전 것만” 제거되지만, 스케줄이 실패/중단되면 누락분이 계속 남을 수 있어요. 더 견고하게 하려면 “기준일의 다음날 00:00 미만”(< endExclusive) 조건을 권장합니다.

대안:

  • PickeatJpaRepository에 findByUpdatedAtBefore(LocalDateTime endExclusive) 추가 후 endExclusive = targetDate.plusDays(1).atStartOfDay() 사용.
  • 또는 대량 삭제 쿼리(deleteByUpdatedAtBefore(...)) 도입으로 읽기 없이 바로 삭제.

경계/타임스탬프 정밀도(DB millis/ micros vs Java nanos)도 고려해 < nextDayStart가 안전합니다.
(backend/** 가이드라인)

Also applies to: 42-42


57-61: ID 조회 API는 Long으로 반환하도록 정리

findIdsByPickeatIdIn 호출 후 BaseEntity::getId로 매핑하고 있어 반환 타입과 메서드명이 어긋납니다. 메서드는 List<Long>를 직접 반환하는 게 명확하고, 불필요한 엔티티 로딩도 피할 수 있어요.

예시:

- List<Long> restaurantIds = restaurantJpaRepository.findIdsByPickeatIdIn(expiredPickeatIds).stream()
-         .map(BaseEntity::getId)
-         .toList();
+ List<Long> restaurantIds = restaurantJpaRepository.findIdsByPickeatIdIn(expiredPickeatIds);

리포지토리 시그니처도 프로젝션(Long)으로 수정 권장.
(backend/** 가이드라인)


53-55: 레스토랑/픽잇 삭제도 배치 처리 및 결과 검증 로그 권장

오래된 데이터가 많으면 deleteByPickeatIdsdeleteAll도 배치/카운트 로깅이 유용합니다. 또한 스케줄 1회 트랜잭션 크기가 커지지 않도록 페이징/배치를 고려해 주세요.

운영 팁:

  • 삭제 건수 로깅, 실패 시 재시도 전략(다음 스케줄에서 누락분 포착), 성능 메트릭 수집.
backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatRepository.java (1)

13-17: 파라미터 명 오타(room) 정정으로 가독성 개선

rooId, roodIds 오타는 혼란을 줍니다. 의미 있는 이름으로 정리해 주세요. 인터페이스는 팀의 ‘계약’이라 더 중요해요. 리팩 멋집니다!

예시 diff:

-    List<Pickeat> findByRoomId(Long rooId);
+    List<Pickeat> findByRoomId(Long roomId);
@@
-    List<Pickeat> findByRoomIdIn(List<Long> roodIds);
+    List<Pickeat> findByRoomIdIn(List<Long> roomIds);

추가 제안:

  • 스케줄 전용 쿼리(findByUpdatedAtBetween)는 JpaRepository 쪽으로 한정하고, 도메인 리포지토리는 유스케이스 중심 API만 노출하는 것도 고려해 보세요.
    (backend/** 가이드라인)
backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepositoryTest.java (1)

35-37: 테스트 의도 명확화 좋습니다

ID 기반 조회로 전환한 부분 깔끔합니다. 추가로 존재하지 않는 pickeatId 조회 시 빈 리스트 반환 케이스도 하나 있으면 안정감이 더 생깁니다.

(backend/** 가이드라인)

backend/src/main/java/com/pickeat/backend/restaurant/domain/RestaurantLike.java (1)

15-24: 좋아요 중복/탐색 성능 대비: 유니크 제약 + 인덱스 권장

ID 스칼라화는 좋지만, (participantId, restaurantId) 중복 방지와 탐색 성능을 위해 제약/인덱스를 추가하는 게 안전합니다. 또한 DB FK는 JPA 매핑 없이도 Flyway로 걸어두면 데이터 무결성에 도움이 됩니다.

예시 diff:

-@Entity
-@Getter
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@jakarta.persistence.Table(
+    name = "restaurant_like",
+    uniqueConstraints = {
+        @jakarta.persistence.UniqueConstraint(
+            name = "uk_restaurant_like_participant_restaurant",
+            columnNames = {"participantId", "restaurantId"}
+        )
+    },
+    indexes = {
+        @jakarta.persistence.Index(name = "idx_restaurant_like_participant_id", columnList = "participantId"),
+        @jakarta.persistence.Index(name = "idx_restaurant_like_restaurant_id", columnList = "restaurantId")
+    }
+)
 public class RestaurantLike extends BaseEntity {
@@
-    public RestaurantLike(Long participantId, Long restaurantId) {
-        this.participantId = participantId;
-        this.restaurantId = restaurantId;
-    }
+    public RestaurantLike(Long participantId, Long restaurantId) {
+        this.participantId = java.util.Objects.requireNonNull(participantId, "participantId");
+        this.restaurantId = java.util.Objects.requireNonNull(restaurantId, "restaurantId");
+    }

마이그레이션 제안(Flyway):

  • UK(참가자ID, 식당ID) 추가
  • FK(옵션): participantId → participant(id), restaurantId → restaurant(id)

테스트 제안:

  • 동일 (participantId, restaurantId) 중복 저장 시 예외 발생 검증
  • participantId/restaurantId 각각으로 조회 성능(인덱스 사용 여부)은 통계로 확인 가능(운영 시).
    (backend/** 가이드라인)
backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepository.java (2)

14-14: 파라미터명 오타(rooId) 정정 권장

기능엔 영향 없지만 가독성과 일관성을 위해 roomId로 맞춰주세요.

-    List<Pickeat> findByRoomId(Long rooId);
+    List<Pickeat> findByRoomId(Long roomId);

As per coding guidelines.


18-18: 파라미터명 오타(roodIds) 및 컬렉션 타입 개선

  • 변수명: roomIds로 정정
  • IN 절 파라미터는 Collection이 유연합니다.
-    List<Pickeat> findByRoomIdIn(List<Long> roodIds);
+    List<Pickeat> findByRoomIdIn(Collection<Long> roomIds);

운영 팁: updated_at, room_id 기반 조회가 빈번하면 적절한 인덱스(예: updated_at, room_id+is_active)를 Flyway에 반영하세요.
As per coding guidelines.

backend/src/main/java/com/pickeat/backend/restaurant/domain/Restaurant.java (2)

23-24: FK 컬럼 인덱스 확인

pickeat_id로 조회/정리가 빈번하므로 DB 인덱스가 있는지 확인해주세요(Flyway 반영). 캐시 미스/만료 시에도 비용을 줄일 수 있습니다.
As per coding guidelines.


77-82: 메서드 명세-의미 불일치(nit): getPictureUrls가 단수 URL 반환

현재는 단일 String을 반환합니다. 과거 호환이 필요 없다면 getPictureUrl로 이름을 다듬는 것도 가독성에 좋아요. 유지가 필요하면 Javadoc으로 형식(콤마 구분 등)을 명시해주세요.
As per coding guidelines.

backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatSchedulerTest.java (2)

64-75: 테스트 구성 좋아요! 정리 전/후 count와 존재 여부를 함께 검증한 점 굿

추가 아이디어(선택):

  • 연관 데이터가 없는 오래된 Pickeat도 안전하게 정리되는지(빈 케이스)
  • 최근 Pickeat 경계값(예: D-2, D-3 기준 변곡점)

86-91: ID 기반 리팩토링 테스트도 깔끔합니다

Participant/RestaurantLike/PickeatResult를 ID로 연결하는 흐름을 잘 반영했습니다. 캐시 무효화까지 포함한 통합 테스트가 있다면(이벤트 발행→캐시 clear 확인) 더 탄탄해집니다.
As per coding guidelines.

backend/src/test/java/com/pickeat/backend/tobe/user/application/UserServiceTest.java (1)

70-74: ProviderPrincipal 전환 테스트 적절

createUser 시그니처 변경을 잘 검증했습니다. 닉네임 트리밍/길이 제약 등 경계 케이스도 후속으로 추가하면 좋겠습니다.

backend/src/main/java/com/pickeat/backend/global/auth/principal/ProviderPrincipal.java (1)

3-5: Principal 객체의 null 안정성 검토 필요

record 타입으로 간결하게 정의한 점은 좋습니다! 다만 인증 Principal로 사용되는 핵심 객체이므로, 필드에 대한 null 검증을 고려해보면 어떨까요?

제안:

public record ProviderPrincipal(Long providerId, String provider) {
    public ProviderPrincipal {
        if (providerId == null || provider == null) {
            throw new IllegalArgumentException("providerId와 provider는 null일 수 없습니다.");
        }
    }
}

compact constructor를 활용하면 생성 시점에 불변식을 보장할 수 있습니다. 인증 컨텍스트에서 예상치 못한 null로 인한 NPE를 사전에 방지할 수 있어요.

참고: Java record의 compact constructor 패턴은 불변 객체의 유효성 검증에 적합합니다.

backend/src/main/java/com/pickeat/backend/pickeat/domain/ParticipantStateUpdatedEvent.java (1)

3-4: 도메인 이벤트 정의 완료, 문서화 추가 권장

record 타입으로 간결하게 이벤트를 정의한 점은 좋습니다. 다만 도메인 이벤트는 시스템의 흐름을 이해하는 데 중요한 요소이므로, 추가 개선을 제안합니다.

제안 1: 문서화 추가

/**
 * 참가자의 상태가 변경되었을 때 발행되는 도메인 이벤트.
 * <p>
 * 이 이벤트는 참가자의 선택 완료/취소 시 발행되며,
 * Pickeat 관련 캐시를 무효화하는 데 사용됩니다.
 *
 * @param pickeatId 상태가 변경된 참가자가 속한 Pickeat의 ID
 */
public record ParticipantStateUpdatedEvent(Long pickeatId) {
}

제안 2: null 안정성 보장

public record ParticipantStateUpdatedEvent(Long pickeatId) {
    public ParticipantStateUpdatedEvent {
        if (pickeatId == null) {
            throw new IllegalArgumentException("pickeatId는 null일 수 없습니다.");
        }
    }
}

도메인 이벤트는 여러 컴포넌트가 의존하는 계약(contract)이므로, 명확한 문서화와 불변식 보장이 중요합니다.

참고: Spring의 @EventListener@TransactionalEventListener와 함께 사용될 경우, 이벤트 발행 시점과 트랜잭션 경계도 문서화하면 더욱 좋습니다.

backend/src/test/java/com/pickeat/backend/fixture/RestaurantFixture.java (1)

19-21: 픽잇 ID null 방지 + 중복 제거(DRY)

  • 테스트 픽스처라도 pickeat.getId()가 null이면 NPE가 납니다. 픽스처에서 빠르게 실패하도록 선행 검증을 넣어 주세요.
  • createcreateWithName가 거의 동일합니다. 한쪽에서 다른쪽을 호출해 중복을 제거하면 유지보수성이 좋아집니다.

예시:

 public static Restaurant create(Pickeat pickeat) {
-    return new Restaurant(
-            "식당",
-            FoodCategory.KOREAN,
-            10,
-            "도로명 주소",
-            "URL",
-            "태그1,태그2",
-            null,
-            null,
-            pickeat.getId()
-    );
+    return createWithName(pickeat, "식당");
 }
 
 public static Restaurant createWithName(Pickeat pickeat, String name) {
+    if (pickeat.getId() == null) {
+        throw new IllegalStateException("Fixture mis-use: pickeat must be persisted and have an id");
+    }
     return new Restaurant(
         name, FoodCategory.KOREAN, 10,
         "도로명 주소", "URL", "태그1,태그2",
         null, null, pickeat.getId()
     );
 }

Also applies to: 23-35

backend/src/test/java/com/pickeat/backend/tobe/acceptance_test/scenario/PickeatByTemplateScenarioTest.java (5)

61-66: 테스트 이름 오타 수정

텤플릿템플릿으로 정정하면 검색성과 가독성이 좋아집니다.

-    void 텤플릿_기반_픽잇_플로우() {
+    void 템플릿_기반_픽잇_플로우() {

64-69: 전제 조건 검증 추가(예상 데이터 보장)

템플릿/식당 목록 크기에 대한 전제 조건을 명시적으로 확인하면 시나리오 테스트의 플레이키를 줄일 수 있어요.

 List<TemplateResponse> templates = 템플릿_목록_조회();
+assertThat(templates).isNotEmpty();

 ...
 List<RestaurantResponse> restaurants = 픽잇의_식당_조회(pickeat.code(), null, participant1Token.token());
+assertThat(restaurants).hasSizeGreaterThanOrEqualTo(3);

115-120: 제외 식당 검증 강화(부분 포함 → 전부 포함)

containsAnyElementsOf는 일부만 포함돼도 통과합니다. 정확히 제외한 3개 모두가 isExcluded=true인지 검증하도록 강화해주세요.

-assertThat(restaurants)
-    .filteredOn(RestaurantResponse::isExcluded)
-    .extracting(RestaurantResponse::id)
-    .containsAnyElementsOf(excludedRestaurantIds);
+assertThat(restaurants)
+    .filteredOn(RestaurantResponse::isExcluded)
+    .extracting(RestaurantResponse::id)
+    .containsAll(excludedRestaurantIds);

94-99: 인덱스 접근 전 크기 확인으로 IOB 방지

좋아요/취소에서 noneExcludedRestaurant.get(0/1)을 사용하므로 최소 2개 존재를 보장해 주세요.

 List<RestaurantResponse> noneExcludedRestaurant = 픽잇의_식당_조회(pickeat.code(), false, participant1Token.token());
+assertThat(noneExcludedRestaurant).hasSizeGreaterThanOrEqualTo(2);

126-128: 결과 비교: 구조적 동등성 외에 핵심 필드도 명시 검증

동등성 비교가 DTO equals에 의존합니다. 회귀를 줄이려면 핵심 필드를 명시적으로도 비교하면 좋아요(예: 우승 식당 ID, 좋아요 수 합산 등).

 private void checkoutPickeatResult(PickeatResultResponse actual, PickeatResultResponse expected) {
     assertThat(actual).isEqualTo(expected);
+    assertThat(actual.restaurantId()).isEqualTo(expected.restaurantId());
+    assertThat(actual.likeCount()).isEqualTo(expected.likeCount());
 }
backend/src/main/java/com/pickeat/backend/global/auth/annotation/ParticipantInPickeat.java (1)

12-15: 주석으로 기대 타입 명시 + 이전 어노테이션 이행 경로 제공

  • 이 파라미터 어노테이션이 바인딩하는 기대 타입(예: ParticipantPrincipal)을 Javadoc에 명시하면 IDE 힌트와 협업에 도움이 됩니다.
  • 기존 @ParticipantId를 사용하던 코드가 있다면, 동일한 리졸버를 재사용하는 @Deprecated 어노테이션을 한 버전 유지해 점진적 마이그레이션 경로를 제공하는 것도 좋아요. (차기 릴리스에서 제거 예고)

예시:

-@Documented
-public @interface ParticipantInPickeat {
+/**
+ * 컨트롤러 메서드 파라미터를 인증된 참가자 컨텍스트로 주입합니다.
+ * 기대 타입: ParticipantPrincipal
+ */
+@Documented
+public @interface ParticipantInPickeat {
backend/src/main/java/com/pickeat/backend/user/application/UserService.java (1)

30-35: ---

DB 유니크 제약이 이미 존재하므로 마이그레이션 제안 제거, 예외 처리는 선택적 개선안으로 재분류

좋은 소식입니다! V1__init.sql을 확인해보니 라인 23에 UNIQUE KEY UK2ty1xmrrgtn89xt7kyxx6ta7h (nickname)가 이미 적용되어 있네요. 따라서 Flyway 마이그레이션 추가 제안은 불필요합니다.

다만 TOCTOU 경합 위험은 여전히 고려할 만합니다. DB 유니크 제약 + validateDuplicateNickname 선행 검사가 있더라도, 동시 요청에서 수동 검사를 우회하는 경우를 대비하면 좋습니다:

예외 매핑 추가 (선택사항)

 @Transactional
 public UserResponse createUser(SignupRequest request, ProviderPrincipal providerPrincipal) {
   validateDuplicateNickname(request.nickname());
-  User user = new User(request.nickname(), providerPrincipal.providerId(), providerPrincipal.provider());
-  userRepository.save(user);
-  return UserResponse.from(user);
+  try {
+    User user = new User(request.nickname(), providerPrincipal.providerId(), providerPrincipal.provider());
+    userRepository.save(user);
+    return UserResponse.from(user);
+  } catch (DataIntegrityViolationException e) {
+    throw new BusinessException(ErrorCode.ALREADY_NICKNAME_EXISTS);
+  }
 }

추가로, provider 필드를 도메인 타입(예: enum)으로 일치시키면 문자열 오입력을 방지할 수 있습니다.


backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultGenerator.java (1)

16-23: 랜덤 선택 로직이 명확합니다. 다만 테스트 용이성 개선을 고려해보세요.

Collections.shuffle()을 사용한 랜덤 선택은 비즈니스 요구사항에 잘 부합하지만, 테스트에서 재현 가능성(reproducibility)이 떨어질 수 있습니다.

개선 제안 (선택사항):

  • 테스트 용이성을 위해 Random 인스턴스를 주입받도록 변경하면, 테스트에서 시드를 고정할 수 있습니다.
@Component
class PickeatResultGenerator {
    private final Random random;
    
    PickeatResultGenerator() {
        this(new Random());
    }
    
    @VisibleForTesting
    PickeatResultGenerator(Random random) {
        this.random = random;
    }
    
    public Restaurant generate(Map<Restaurant, Integer> likeCounts) {
        validateNotEmpty(likeCounts);
        List<Restaurant> topRatedRestaurants = getTopRatedRestaurants(likeCounts);
        Collections.shuffle(topRatedRestaurants, random);
        return topRatedRestaurants.getFirst();
    }
}

참고: 현재 테스트가 isIn() 같은 방식으로 검증하고 있다면 현재 구현도 충분합니다!

backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatResultGeneratorTest.java (1)

30-132: 테스트 커버리지가 훌륭합니다! 추가 엣지 케이스를 고려해보세요.

주요 시나리오(단건 최고, 동점, 빈 리스트, 모두 0점, 다양한 선호도)를 모두 커버했네요. 랜덤성은 isIn()으로 적절히 검증했습니다.

추가 고려 사항:

  1. 음수 좋아요 수: likeCount가 음수인 경우 (방어 로직 필요 여부)
  2. 대용량 데이터: 매우 많은 식당이 동점일 때 성능 (현재는 shuffle이 있어서 OK)
  3. null 값: Map에 null 키나 값이 있을 때 (현재 Java는 Map.of()로 null 불가하지만 일반 Map의 경우)

현재 테스트로도 충분하지만, 방어 로직이 추가된다면 해당 케이스도 테스트하면 좋겠습니다!

backend/src/test/java/com/pickeat/backend/pickeat/application/ParticipantServiceTest.java (1)

40-52: 테스트 검증을 좀 더 충실하게 다져보세요.

현재는 response가 null이 아닌지만 확인하고 있네요. 생성된 참가자가 실제로 저장되었는지, 토큰이 유효한지까지 검증하면 더 견고한 테스트가 될 겁니다.

개선 제안:

// then
assertThat(response).isNotNull();
assertThat(response.token()).isNotBlank();

// 추가: 실제 Participant 저장 검증
testEntityManager.flush();
testEntityManager.clear();
Participant createdParticipant = testEntityManager.find(Participant.class, participant.getId());
assertThat(createdParticipant.getNickname()).isEqualTo("테스트유저");

참고로 pastCount 필드는 현재 Participant 엔티티에 없으므로 그 부분은 신경 쓰지 않으셔도 좋습니다. 위의 updateCompletion 테스트처럼 실제 상태 변화를 DB에서 직접 조회하여 검증하는 패턴이 우테코의 통상적인 접근이니 참고하세요! 🎯

backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantJdbcRepository.java (3)

47-54: JDBC null 처리: 드라이버 추론에 의존하지 말고 타입을 명시하세요.

ps.setObject(3, r.getDistance())는 null 시 드라이버가 타입을 추론해야 합니다. DB/드라이버에 따라 NPE·SQLType 불일치가 날 수 있어요. 안전하게 SQL 타입을 명시해 주세요.

예시 Diff:

- ps.setObject(3, r.getDistance()); // NULL 허용(Integer)
+ if (r.getDistance() != null) {
+     ps.setObject(3, r.getDistance(), java.sql.Types.INTEGER);
+ } else {
+     ps.setNull(3, java.sql.Types.INTEGER);
+ }

테스트: 거리(null/값) 각각으로 batchInsert 수행해 insert 성공 및 값 검증(Repository 통합 테스트). [backend 컨벤션 1,6 기반]


35-36: 서버시간 vs DB시간: created_at/updated_at는 DB 기본값 혹은 CURRENT_TIMESTAMP 활용 고려

애플리케이션 서버 시간(LocalDateTime.now())과 DB 시간대가 어긋나면 정렬/만료 로직이 흔들릴 수 있어요. 컬럼에 기본값(now())가 있다면 SQL에서 생략하거나 CURRENT_TIMESTAMP 사용을 고려해 주세요.

대안:

  • 컬럼 default(now()) 설정 + INSERT 컬럼에서 생성/수정시각 제거
  • 또는 VALUES (..., CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) 형태

추가로 deleted도 DB 기본값이 false면 코드에서 명시 제거 가능(간결성↑). [backend 컨벤션 1,3]

Also applies to: 67-69


64-66: NPE 우려는 현재 코드 구조상 과도함 - 일관성 관점의 선택적 개선 제안

검증 결과 분석:

  1. pickeatId (Long): create() 메서드에서 pickeat.getId()는 DB 조회 결과로 반드시 유효합니다. Restaurant 생성자의 필수 파라미터이므로 null이 될 수 없습니다.

  2. isExcluded (Boolean): 필드 초기값이 false로 설정되어 있고, 생성자에서 명시적으로 할당하지 않아도 항상 false 또는 true 값을 가집니다. NoArgsConstructor가 실제로 사용되지 않으므로 초기값 미적용 문제도 없습니다.

코드 일관성 측면의 제안:

비록 NPE 위험은 낮지만, 같은 batchInsert() 메서드 내에서 getTags()(6번줄)와 Picture(7-8번줄)는 null 체크를 수행하는데, isExcludedpickeatId는 체크하지 않습니다. 방어적 프로그래밍 관점에서 일관성 있게 처리하는 것을 권장합니다:

// 현재
ps.setBoolean(9, r.getIsExcluded());
ps.setLong(10, r.getPickeatId());

// 제안 (선택적)
if (r.getIsExcluded() != null) {
    ps.setBoolean(9, r.getIsExcluded());
} else {
    ps.setNull(9, java.sql.Types.BOOLEAN);
}

if (r.getPickeatId() != null) {
    ps.setLong(10, r.getPickeatId());
} else {
    ps.setNull(10, java.sql.Types.BIGINT);
}

현재 코드가 동작하지만, 향후 도메인 설계 변경 시 문제를 조기에 방지할 수 있습니다. 🎯

backend/src/main/java/com/pickeat/backend/global/cache/CacheType.java (2)

8-12: 캐시 이름 규칙 통일 제안(colon 사용 일관화)

restaurant:like-count, participant:likes는 콜론 네이밍, pickeat, restaurant는 단일 토큰이에요. 미터링/모니터링, 키 프리픽스 관리에 유리하도록 네이밍 규칙을 통일해 주세요(예: 모두 도메인:리소스).

예:

  • RESTAURANT("restaurant", ...)RESTAURANT("restaurant:by-id", ...)
  • PICKEAT("pickeat", ...)PICKEAT("pickeat:by-id", ...)

변경 시 CacheManagerConfig와 @Cacheable/@CacheEvict 사용처 전반 동기화 필요. 테스트: 캐시 이름 변경에 따른 스파이/통합 테스트 업데이트. [backend 컨벤션 1,5]


15-25: 시간/용량 단위 명확화: Duration 활용 및 외부 설정화 권장

ttlMinutes(Long)와 maxSize(Long)는 단위 오해 소지가 큽니다. Duration를 사용하고, 환경별 튜닝을 위해 @ConfigurationProperties로 외부 설정화하세요.

예시 Diff:

- private final Long ttlMinutes;
+ private final java.time.Duration ttl;

- CacheType(String name, Long ttlMinutes, Long maxSize, Integer initialCapacity) {
+ CacheType(String name, java.time.Duration ttl, Long maxSize, Integer initialCapacity) {
   this.name = name;
-  this.ttlMinutes = ttlMinutes;
+  this.ttl = ttl;
   this.maxSize = maxSize;
   this.initialCapacity = initialCapacity;
 }

그리고 CacheManagerConfig에서 Caffeine.newBuilder().expireAfterWrite(ttl)로 직접 전달. 운영 팁: prod/stage에서 다른 TTL·size를 application-*.yml로 분리. [backend 컨벤션 1,3,4]

backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/ParticipantStateUpdatedEventHandler.java (1)

11-18: 핸들러의 구현체 의존 및 관찰 가능성 개선

여기도 ParticipantRepositoryImpl 직접 의존입니다. 위 파일과 동일하게 포트/어댑터로 추상화해 주세요. 캐시 무효화가 발생했는지 추적할 수 있도록 debug 로그 또는 카운터 메트릭을 추가하면 운영에서 유용합니다.

예시:

  • participantCachePort.evictByPickeatId(pickeatId);
  • log.debug("Evicted participant cache for pickeatId={}", pickeatId);
    테스트: 이벤트 후 해당 키 재조회 시 DB hit를 검증. [backend 컨벤션 1,4]
backend/src/main/java/com/pickeat/backend/pickeat/application/ParticipantService.java (1)

47-51: 불필요한 캐시 무효화 방지: 상태 변경 시에만 이벤트 발행

완료 여부가 이미 동일한 값일 때도 이벤트를 발행하면 캐시 스톰이 날 수 있어요. 변경이 실제로 발생한 경우에만 이벤트를 발행해 주세요.

예시 Diff(메서드 시그니처 가정):

- participant.updateCompletionAs(isCompleted);
- applicationEventPublisher.publishEvent(new ParticipantStateUpdatedEvent(participant.getPickeatId()));
+ boolean changed = participant.updateCompletionAs(isCompleted); // 변경 여부 리턴하도록 도메인 메서드 조정 권장
+ if (changed) {
+     applicationEventPublisher.publishEvent(new ParticipantStateUpdatedEvent(participant.getPickeatId()));
+ }

대안: if (participant.isCompleted() != isCompleted) { ... }로 가드. 테스트: 동일 입력 2회 호출 시 이벤트 1회만 발행. [backend 컨벤션 2,6]

backend/src/main/java/com/pickeat/backend/pickeat/application/ParticipantTokenProvider.java (2)

38-41: 널 안전성: 클레임 미존재 시 NPE 방지

외부에서 들어온 토큰에 pickeatCode가 없을 수도 있어요. getRawPickeatCode에서 null 반환을 허용하되, 사용하는 쪽에서 명확히 가드하거나 Optional로 래핑을 고려해 주세요.

예시:

- public String getRawPickeatCode(String token) {
-   Claims claims = jwtProvider.getClaims(token);
-   return claims.get(PICKEAT_CODE_CLAIM_KEY, String.class);
- }
+ public java.util.Optional<String> getRawPickeatCode(String token) {
+   Claims claims = jwtProvider.getClaims(token);
+   return java.util.Optional.ofNullable(claims.get(PICKEAT_CODE_CLAIM_KEY, String.class));
+ }

사용처에서 orElseThrow 등으로 명시 처리. [backend 컨벤션 2,3]


27-31: 현재 코드 설계는 안전합니다 — 단위 일치 및 상수 관리 검증 완료

검증 결과를 정리하면:

  1. expirationMillis 단위: ✓ 문제 없음

    • JwtProvider.createTokenWithClaims: new Date(now.getTime() + expirationMillis)
    • 모두 밀리초(ms) 기준으로 정렬되어 있습니다
  2. PICKEAT_CODE_CLAIM_KEY 관리: ✓ 구조적으로 안전

    • 상수는 ParticipantTokenProvider 내부에서만 정의되고 사용됨 (라인 16, 29, 40)
    • ParticipantIdArgumentResolvergetParticipantId(), getRawPickeatCode() 같은 public 메서드를 통해 간접 접근하므로, 상수에 직접 의존하지 않음
  3. 현재 설계의 장점:

    • 토큰 생성/파싱 로직이 ParticipantTokenProvider에 집중되어 일관성 있음
    • ArgumentResolver는 TokenProvider의 계약(public 메서드)만 의존하여 느슨한 결합 유지
    • 각 TokenProvider(ParticipantTokenProvider, ProviderTokenProvider)가 자신의 상수를 관리하는 것도 책임 분리 측면에서 명확함

선택적 개선 제안:
만약 향후 새로운 모듈이 토큰 클레임 키에 직접 접근해야 한다면 (예: 다른 서비스 모듈), 그때 AuthClaims 같은 공용 상수 인터페이스를 고려해도 좋습니다. 하지만 현재 구조는 충분히 안전하므로 급하게 리팩토링할 필요는 없습니다.

backend/src/main/java/com/pickeat/backend/global/cache/CacheManagerConfig.java (2)

31-35: TTL 전략: expireAfterAccess → expireAfterWrite 검토 요청

현재 접근 기반 만료(expireAfterAccess)를 사용하면, 무효화 누락 시 “자주 조회되는 오래된 값”이 계속 살아남을 수 있어요. 저장/삭제 이벤트 무효화가 대부분을 커버하더라도, 장애·크론·백필 등 비정형 갱신이 있을 때는 쓰기 기준 만료(expireAfterWrite)가 더 안전한 상한을 보장합니다.

권장:

  • 캐시별 정책 필요 시 CacheType에 만료 방식 필드를 추가하고, 기본값은 expireAfterWrite로 두기.
  • 단일 정책으로 단순화하려면 expireAfterWrite로 전환.

예시(단일 정책 전환):

-                .expireAfterAccess(Duration.ofMinutes(cacheType.getTtlMinutes()))
+                .expireAfterWrite(Duration.ofMinutes(cacheType.getTtlMinutes()))

장단점:

  • expireAfterWrite: 신선도 상한 명확(+), 핫 키도 최대 TTL 초과 시 교체(±)
  • expireAfterAccess: 핫 키 적중률↑(+), 갱신 누락 시 오래된 데이터 유지(−)

31-35: 캐시 메트릭 비용 관리: recordStats 토글 가능화

recordStats는 오버헤드가 있습니다. 운영/부하 테스트에서만 켜도록 프로퍼티로 게이트해주세요.

간단 제안:

  • @Value("${cache.metrics.enabled:true}") 주입 필드 추가
  • if (cacheMetricsEnabled) builder.recordStats();

운영에서는 Micrometer CacheMetrics 바인딩으로 자동 노출됩니다(스프링 캐시 메트릭스). Based on learnings

backend/src/main/java/com/pickeat/backend/login/application/ProviderTokenProvider.java (1)

24-28: 만료 단위 명확화 및 프로퍼티 네이밍

@Value("${provider.jwt.expiration}")가 밀리초 단위임을 코드만 보고 추론해야 해요. 설정 오해를 줄이려면 프로퍼티 키를 provider.jwt.expiration-millis로 바꾸거나, JSR-310 Duration 타입을 받아 Duration.parse(예: PT30M)로 처리하는 방식을 권장합니다. 테스트 구성에도 동일 단위를 명시해주세요.
또한 PROVIDER_CLAIM_KEY가 누락되면(null) 이후 플로우에 영향이 있으니, null 허용/불허 정책을 정하고 검증을 추가하는 것도 좋습니다.

backend/src/test/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImplCachingTest.java (3)

91-101: 프로덕션 캐시와 테스트 캐시의 동작 차이(ConcurrentMapCache vs Caffeine)

현재 테스트는 TTL·사이즈·통계가 없는 ConcurrentMapCache를 사용합니다. 운영은 Caffeine이므로 TTL 관련 회귀를 놓칠 수 있어요. 테스트에서도 CaffeineCache를 써서 동작을 맞추는 걸 권장합니다.

예시(간단 교체):

@Bean
CacheManager cacheManager() {
    SimpleCacheManager manager = new SimpleCacheManager();
    CaffeineCache cache = new CaffeineCache(
        PickeatRepositoryImpl.PICKEAT_CACHE_NAME,
        Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(5)) // 혹은 CacheType의 TTL 참조
            .build()
    );
    manager.setCaches(List.of(cache));
    return manager;
}

54-68: 미존재(EMPTY) 결과 캐싱 정책 테스트 추가 제안

Optional.empty()를 캐시할지 여부가 성능/정합성에 영향을 줍니다. 현재 정책을 명시하고 테스트로 고정해 주세요.

추천 케이스:

  • findByCode가 empty일 때 재호출 시 JPA 호출 횟수(1회 유지인지 2회인지) 검증
  • 추후 정책 변경(negative caching on/off)에 대비한 명시적 테스트 이름 부여

70-89: 무효화 키 검증(코드 기반 키인지 확인)

evictPickeatCache가 코드 기반으로 키를 생성한다면, 동일 코드의 다른 인스턴스로도 캐시가 무효화되어야 합니다. 현재 Mockito로 when(cachedPickeat.getCode()).thenReturn(PICKEAT_CODE)만 검증하고 있으니, “서로 다른 객체(동일 코드)”로도 무효화되는지 추가 검증을 부탁드려요.

backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatServiceTest.java (3)

181-183: 메서드 네이밍/시그니처 불일치: getPickeatByParticipant인데 participant 인자가 없습니다

테스트에서 getPickeatByParticipant(String pickeatCode)를 호출하지만, 이름상 참가자 맥락(Principal/ID)이 필요해 보입니다.
옵션:

  • 실제 의도가 “코드로 픽잇 조회”라면 getPickeatByCode(또는 getPickeat)로 리네이밍
  • 참가자 컨텍스트가 필요한 API라면 getPickeatByParticipant(Long participantId, String code) 등으로 시그니처 조정

이 부분은 서비스/컨트롤러 전반 용어 정합성에 직결됩니다. PR 전체에서 메서드/엔드포인트 명칭을 한 번 정리해보면 좋겠습니다.


58-66: 도메인 생성 일관성: Fixture/Builder 사용 권장

루프 내에서 new Participant(nickname, pickeat.getId())로 직접 생성하면 향후 도메인 제약 변경 시 테스트가 쉽게 깨질 수 있어요. ParticipantFixture.create(id) 또는 Builder를 일관되게 사용해 도메인 불변식을 캡슐화해 주세요.


424-431: 중복 toString 호출 정리

pickeat.getCode().getValue().toString()에서 getValue()가 이미 String/UUID라면 toString() 중복일 수 있어요. 호출 체인을 간결하게 정리하면 가독성이 좋아집니다.

Also applies to: 463-467

backend/src/main/java/com/pickeat/backend/global/auth/JwtProvider.java (2)

66-71: 파서 키 사용 단순화(선택): 기존 secretKey 재사용

verifyWith(Keys.hmacShaKeyFor(secretKey.getEncoded())) 대신 verifyWith(secretKey)로 단순화 가능합니다. 의미 동일, 가독성↑.

-            return Jwts.parser()
-                    .verifyWith(Keys.hmacShaKeyFor(secretKey.getEncoded()))
+            return Jwts.parser()
+                    .verifyWith(secretKey)
                     .build()
                     .parseSignedClaims(token)
                     .getPayload();

29-41: 보안 강화 제안: iss/aud 등 컨텍스트 클레임 추가 검증

토큰 혼용 방지를 위해 발급자(iss), 대상자(aud) 등 컨텍스트 클레임을 포함하고 파서에서 검증하는 것을 권장합니다. 만료·서명 외에도 컨텍스트가 맞는지 확인하면 “토큰 혼동(confusion)” 위험이 줄어듭니다. 구성 예:

  • 발급: .issuer(issuer).audience().add(audience)...
  • 검증: 파서에서 요구 클레임 매칭(필요 시 커스텀 검증)

테스트: 잘못된 iss/aud 토큰이 INVALID_TOKEN으로 처리되는지. Based on learnings

backend/src/main/java/com/pickeat/backend/pickeat/application/dto/response/PickeatResultResponse.java (1)

34-45: Null-안전성 점검 권장(FoodCategory 등)

restaurant.getFoodCategory()가 null일 가능성이 있다면 NPE가 납니다. 도메인 제약으로 null 불가라면 괜찮지만, 그게 아니라면 방어 로직을 추가하거나 사전 검증이 필요합니다.

backend/src/test/java/com/pickeat/backend/restaurant/infrastructure/RestaurantRepositoryImplCachingTest.java (1)

44-51: 캐시 이름 하드코딩을 Repository 상수/중앙 CacheType으로 통일하세요.

현재 "restaurant" 리터럴을 사용합니다. 운영 코드의 캐시 이름 변경 시 테스트가 조용히 깨지거나(혹은 반대로) 드리프트가 발생할 수 있어요. Repository 상수(예: RestaurantRepositoryImpl.RESTAURANT_CACHE_NAME) 또는 중앙 CacheType을 사용하면 단일 소스 오브 트루스로 관리됩니다.

- Cache cache = cacheManager.getCache("restaurant");
+ Cache cache = cacheManager.getCache(RestaurantRepositoryImpl.RESTAURANT_CACHE_NAME);
...
- manager.setCaches(List.of(new ConcurrentMapCache("restaurant")));
+ manager.setCaches(List.of(new ConcurrentMapCache(RestaurantRepositoryImpl.RESTAURANT_CACHE_NAME)));

이 PR에서 CacheManager 중앙화가 도입되었다면 그 네이밍을 참조하도록 맞추는 것을 권장합니다. As per coding guidelines.

Also applies to: 98-103

backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java (5)

58-65: 좋아요 카운트 N+1 쿼리 발생

반복문 내 countAllByRestaurantId(...) 호출로 레스토랑 수만큼 쿼리가 발생합니다. 배치 집계로 바꾸면 효율이 크게 개선됩니다.

권장 방향:

  • Repository에 배치 집계 메서드 추가(예: Map<Long,Integer> countAllByRestaurantIdIn(Set<Long> ids)), 한 번의 쿼리로 집계.
  • 없으면 JDBC/JPQL로 프로젝션 반환해서 매핑.

예시:

-        Set<Long> likedRestaurantIds = getLikedRestaurantIdsByParticipantId(participantId);
+        Set<Long> likedRestaurantIds = (participantId == null) ? Set.of() : getLikedRestaurantIdsByParticipantId(participantId);

-        List<RestaurantResponse> response = new ArrayList<>();
-        for (Restaurant restaurant : targets) {
-            boolean isLiked = likedRestaurantIds.contains(restaurant.getId());
-            Integer likeCount = restaurantLikeRepository.countAllByRestaurantId(restaurant.getId());
-            response.add(RestaurantResponse.of(restaurant, likeCount, isLiked));
-        }
+        Set<Long> targetIds = targets.stream().map(Restaurant::getId).collect(Collectors.toSet());
+        Map<Long, Integer> likeCounts = restaurantLikeRepository.countAllByRestaurantIdIn(targetIds); // 신규 배치 메서드
+        List<RestaurantResponse> response = new ArrayList<>(targets.size());
+        for (Restaurant restaurant : targets) {
+            boolean isLiked = likedRestaurantIds.contains(restaurant.getId());
+            int likeCount = likeCounts.getOrDefault(restaurant.getId(), 0);
+            response.add(RestaurantResponse.of(restaurant, likeCount, isLiked));
+        }

테스트 케이스: 레스토랑 100개 시 단 1쿼리만 나가는지, 빈 리스트/누락 ID 처리. As per coding guidelines.


58-59: participantId null 처리 누락 가능성

비로그인/참가자 미지정 시 findAllByParticipantId(null) 호출 위험이 있습니다. null이면 빈 Set으로 처리하면 안전합니다.

-        Set<Long> likedRestaurantIds = getLikedRestaurantIdsByParticipantId(participantId);
+        Set<Long> likedRestaurantIds = (participantId == null) ? Set.of() : getLikedRestaurantIdsByParticipantId(participantId);

81-83: 불필요한 전체 saveAll 호출

변경된 대상만 저장하는 것이 I/O·경합 측면에서 유리합니다.

-        targets.forEach(Restaurant::exclude);
-        restaurantRepository.saveAll(restaurants);
+        targets.forEach(Restaurant::exclude);
+        restaurantRepository.saveAll(targets);

145-148: 에러 코드 의미가 도메인 동작과 어긋납니다

접근 검증 실패 시 RESTAURANT_ELIMINATION_FORBIDDEN는 “제외/삭제” 맥락입니다. 좋아요/조회 등 공통 접근 제어에는 보다 일반적인 코드(예: FORBIDDEN 또는 RESTAURANT_ACCESS_FORBIDDEN)를 쓰면 가독성과 유지보수성이 좋아집니다.


69-83: 참가자 권한 검증 범위 재확인 권장

exclude(...)ParticipantPrincipal에서 pickeatCode만 사용합니다. 참가자 소속/권한(방장 등)을 컨트롤러/리졸버에서 이미 강제한다면 OK지만, 서비스 레이어에서도 방어 검증(요청한 참가자가 해당 픽잇 소속인지, 권한이 있는지)을 추가하면 안전합니다.

backend/src/main/java/com/pickeat/backend/restaurant/ui/api/RestaurantApiSpec.java (1)

3-3: ParticipantPrincipal 기반으로의 전환 좋습니다!

스펙과 실제 인증 흐름이 일치합니다. Swagger 문서에 인증 실패 케이스를 드러내면 클라이언트 UX가 좋아집니다.

  • 제안: 보안이 필요한 4개 엔드포인트에 401/403 응답 스키마 추가

예시:

@ApiResponses({
  @ApiResponse(responseCode = "204", description = "성공"),
  @ApiResponse(responseCode = "401", description = "인증 필요"),
  @ApiResponse(responseCode = "403", description = "권한 없음")
})

또한 컨트롤러에서는 서비스로 participantPrincipal.id()만 전달해 서비스 레이어가 Principal에 종속되지 않게 유지하는 것을 권장합니다.

As per coding guidelines

Also applies to: 147-147, 180-181, 234-235, 279-279

backend/src/test/java/com/pickeat/backend/restaurant/application/RestaurantServiceTest.java (2)

85-103: 소거 흐름 테스트 보강 제안(필터·권한)

현재는 소거 성공/비활성 예외만 검증합니다. 아래 보강을 추천해요.

  • isExcluded 필터 3가지 모드(null/true/false) 각각 검증
  • 다른 픽잇의 참가자가 소거를 시도할 때 권한 거부 확인

원하시면 케이스별 템플릿 제공해 드릴게요.

As per coding guidelines

Also applies to: 120-125


135-148: 좋아요/취소 경계 케이스 보강

이미 중복 좋아요 예외는 검증되어 있어요. 추가로:

  • 좋아요하지 않은 상태에서 취소 시 예외
  • 동시성(같은 참가자/다른 참가자) 더블클릭 방지 보장
  • getPickeatRestaurants가 좋아요 플래그를 정확히 반영하는지 캐시 적중/미적중 상황 각각 확인

필요 시 테스트 스캐폴딩 드리겠습니다.

As per coding guidelines

Also applies to: 171-189, 208-234

backend/src/main/java/com/pickeat/backend/pickeat/ui/ParticipantController.java (1)

39-42: 서비스 시그니처 일관화 제안(Principal vs ID)

getParticipant은 ID를, 완료/취소는 ParticipantPrincipal을 서비스에 전달합니다. 서비스 레이어가 인증 세부사항에 종속되지 않도록 한 방향으로 통일(가급적 Principal 수용)하면 확장·테스트가 쉬워집니다.

예: participantService.getParticipant(participantPrincipal) 형태로 일원화.

As per coding guidelines

Also applies to: 47-56

backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeRepository.java (1)

13-13: 카운트 타입은 long 권장

좋아요 수는 누적치가 커질 수 있어 Integer보다 long이 안전합니다. JPA/DB 카운트도 보통 long을 반환합니다.

-    Integer countAllByRestaurantId(Long restaurantId);
+    long countAllByRestaurantId(Long restaurantId);

테스트/서비스에서 타입만 맞춰주면 영향은 제한적입니다.

As per coding guidelines

backend/src/test/java/com/pickeat/backend/acceptance_test/scenario/PickeatByPositionScenarioTest.java (1)

99-106: 결과 DTO 전환 굿! + 하드코딩된 subList 경계 보강

  • PickeatResultResponse로의 전환 👍 깔끔합니다.
  • 다만 subList(0,4), (3,7), (6,10)은 식당 수가 기대보다 적을 때 테스트가 깨질 수 있어요. 크기에 따라 안전하게 컷팅하도록 보강을 권장합니다.

아이디어:

int size = restaurantIds.size();
int e1 = Math.min(4, size);
int e2 = Math.min(7, size);
int e3 = Math.min(10, size);
// 시작 인덱스도 size 체크 후 조건부 실행 또는 max(?, size)로 방어

필요하시면 안전한 분할 헬퍼 제공하겠습니다.

Also applies to: 120-122

backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatService.java (3)

68-71: 메서드 명/의미 불일치

getPickeatByParticipant(String pickeatCode)는 참가자 검증 없이 코드로만 조회합니다. 혼동을 피하려면:

  • 실제 의미에 맞게 getPickeat(String pickeatCode)로 rename, 또는
  • 참가자(Principal/ID)를 인자로 받아 접근 검증을 수행

두 번째가 보안·일관성 측면에서 더 안전합니다.

As per coding guidelines


90-99: 쿼리 1회로 축소 제안(Rejoin 가능 여부)

현재 2쿼리(참가자→픽잇)입니다. 아래와 같이 “존재 여부”로 한 번에 확인 가능합니다.

  • Repository에 다음 형태 추가(예시):
    • boolean existsByParticipantIdAndPickeatCode(Long participantId, PickeatCode code);
    • JPQL: select count(p)>0 from Participant p join Pickeat k on k.id=p.pickeatId where p.id=:pid and k.code=:code

그 후 서비스:

boolean rejoinAvailable = participantId != null
    && participantPickeatRepository.existsByParticipantIdAndPickeatCode(participantId, new PickeatCode(pickeatCode));
return new PickeatRejoinAvailableResponse(rejoinAvailable);

또한 Boolean rejoinAvailableboolean으로 원시 타입 사용을 권장합니다.

As per coding guidelines

Also applies to: 114-117


108-112: 메서드 네이밍 정합성

단수 반환인데 getPickeatsByParticipant(복수)입니다. getPickeatByParticipant로 변경하면 읽기가 좋아집니다.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantService.java (3)

41-55: 생성 후 캐시 전략 명확화 필요 (+TODO 대응)

“생성 시점에 미리 캐싱” TODO가 있는데, 현재는 무효화/워밍이 없습니다. 대량 생성 후 첫 요청이 N번의 DB 조회/카운팅을 유발할 수 있어요.

권장:

  • Pickeat 기준으로 레스토랑 목록 캐시를 워밍하거나,
  • 결과 이벤트를 발행해 관련 캐시를 일괄 무효화/리프레시.

Spring Cache 레퍼런스의 CacheEvict(allEntries, beforeInvocation) 및 Caffeine refreshAfterWrite를 참고해보세요.


59-68: N+1 형태의 좋아요 카운트 조회

레스토랑마다 countAllByRestaurantId 호출은 리스트 크기만큼 DB/캐시 조회를 유발합니다. 캐시가 비어있거나 TTL 만료 시 비용이 커집니다.

대안:

  • repository에 countAllByRestaurantIdIn(Set) 형태의 집계 API를 추가해 한 번에 그룹바이 조회.
  • 최소한 likeCount 조회를 병렬 스트림 대신 배치(한 쿼리)로 바꾸기.

추가로, 이 메서드도 ParticipantPrincipal을 받아 일관된 인증 컨텍스트를 유지하는 것이 안전합니다.


149-151: 에러 코드 의미 불일치(경미)

좋아요 접근 검증에서 RESTAURANT_ELIMINATION_FORBIDDEN를 던지면, API 소비자에게 혼동을 줄 수 있습니다. 맥락에 맞는 PICKEAT_ACCESS_DENIED 등으로 통일하는 것을 권장합니다.

backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultService.java (1)

98-105: 좋아요 카운트 집계의 배치화 제안

레스토랑 개수만큼 count 호출이 발생합니다. 캐시가 비었을 때 비용이 큽니다.

대안:

  • repository에 countAllByRestaurantIdIn(Collection) 추가 후 한 번의 그룹바이로 조회.
  • 생성된 Map은 Restaurant 대신 Long id를 키로 사용하면 equals/hashCode 의존도를 낮출 수 있습니다.

예시:

Map<Long, Integer> likeCounts = restaurantLikeRepository.countAllByRestaurantIdIn(ids);
backend/src/main/java/com/pickeat/backend/global/auth/ParticipantIdArgumentResolver.java (1)

24-27: 전반적으로 잘 정리됐습니다 👏

Annotation + 타입 체크, 토큰 파싱 로직이 명확합니다. 소소한 개선만 제안해요.

  • 메서드 명확성: getParticipantIdByHeader → getPrincipalByHeader로 변경하면 반환 타입과 일치합니다.
  • 상수 재사용: 헤더 이름 "Pickeat-Participant-Token"과 "Bearer "는 AuthConstants 등으로 중앙화하면 컨트롤러/필터와 불일치가 줄어듭니다.
  • 테스트 보강:
    • required=true에서 헤더 미존재 시 예외,
    • 잘못된 prefix,
    • 정상 케이스에서 participantId/rawPickeatCode 매핑 검증.

원하시면 슬라이스 테스트 예시를 드릴게요.

Also applies to: 31-47, 53-58

backend/src/test/java/com/pickeat/backend/restaurant/infrastructure/RestaurantLikeRepositoryImplCachingTest.java (3)

88-118: save 이후 캐시 무효화 검증 좋습니다 — 키 검증 추가 제안

현재 상호작용 횟수로 무효화를 확인하고 있어 충분합니다. 추가로 cacheManager.getCache("restaurant:like-count").get(restaurantId) 등을 점검하면 키 수준에서도 확실히 드러납니다(구현 키 전략이 기본이라면 파라미터 값).

또한 테스트에서 캐시 이름은 상수화(CacheType 사용)하면 운영 코드와 드리프트를 줄일 수 있어요.


120-148: delete 경로도 잘 커버됐습니다

삭제 시 양쪽 캐시가 무효화되는 것을 상호작용 카운트로 검증하는 접근이 합리적입니다. 보완으로 “삭제가 이미 없는 경우(idempotent)에도 예외 없이 캐시 무효화가 수행되는지” 테스트를 추가해보세요.


150-169: 테스트 구성 적절 — 매직 스트링 최소화 권장

SimpleCacheManager 구성과 Mockito 주입이 간결합니다. 운영 코드의 CacheType/CacheManager 설정과 이름을 맞추기 위해, 테스트도 동일한 상수를 참조하도록 정리하면 추후 리팩토링에 강해집니다.

backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantRepository.java (1)

16-21: 메서드 명과 반환 타입 불일치

findIdsByPickeatIdIn은 “Ids”를 암시하지만 List를 반환합니다. 혼란을 유발해요.

대안(둘 중 하나):

  • 실제 의도대로 반환 타입을 List로 변경.
  • 혹은 메서드명을 findByPickeatIdIn으로 바꾸고 Restaurant 반환을 유지.

점진적 이전을 원하면 새 메서드 추가 후 기존은 @deprecated로 표시하세요.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/ui/api/RestaurantApiSpec.java (1)

245-246: ParticipantPrincipal 주입 방식 일관화 제안

@Parameter(hidden = true) ParticipantPrincipal 사용은 문서에는 숨기면서 실제 주입은 커스텀 리졸버(예: @ParticipantInPickeat)로 처리하는 의도 같아요. 스펙 인터페이스에도 의미를 드러내기 위해 메타 애너테이션(예: @ParticipantInPickeat)을 함께 표기하거나, 컨트롤러 구현부 파라미터에만 리졸버 애너테이션을 부여하는 방식 중 하나로 일관성을 맞추면 혼선을 줄일 수 있습니다. 또한 SecurityRequirement 이름(ParticipantAuth)과 실제 스키마 설정이 매칭되는지 한 번 더 점검해주세요.

Also applies to: 298-299, 372-373, 436-437

backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantRepositoryImpl.java (1)

30-33: 캐시 스탬피드 완화 제안

고동시 요청에서 동일 pickeatId 미스가 겹치면 DB 부하가 급증할 수 있어요. @Cacheable(sync = true)로 단일 키에 대해 하나의 쓰레드만 로딩하게 하면 완화됩니다(Caffeine 지원).

-    @Cacheable(value = RESTAURANT_CACHE_NAME, key = "#pickeatId")
+    @Cacheable(value = RESTAURANT_CACHE_NAME, key = "#pickeatId", sync = true)
backend/src/main/java/com/pickeat/backend/restaurant/application/dto/response/RestaurantResponse.java (1)

38-40: likeCount의 null 안전성 보강

Integer likeCount가 null이면 응답 스키마가 불안정해집니다. 클라이언트 단 처리 부담을 줄이기 위해 기본값 0을 보장하면 좋아요.

간단 수정(팩토리에서 디폴트):

-    public static RestaurantResponse of(Restaurant restaurant, Integer likeCount, boolean isLiked) {
+    public static RestaurantResponse of(Restaurant restaurant, Integer likeCount, boolean isLiked) {
+        int safeLikeCount = likeCount == null ? 0 : likeCount;
         return new RestaurantResponse(
@@
-                parsePictureUrls(restaurant.getPictureUrls()),
-                likeCount,
+                parsePictureUrls(restaurant.getPictureUrls()),
+                safeLikeCount,
                 isLiked);
     }

장기적으로는 레코드 필드를 int likeCount로 두어 스키마를 강제하는 것도 고려해주세요(마이그레이션 비용 有).

Also applies to: 45-58

backend/src/main/java/com/pickeat/backend/login/ui/LoginController.java (1)

35-41: 의도 확인: 미가입 사용자에 401과 함께 토큰 반환

processCode에서 사용자가 없을 때도 토큰을 바디에 담아 401을 반환합니다. 가입 플로우(프론트가 이 토큰으로 가입 진행)라면 OK지만, 보안·제품 관점에서 합의된 정책인지 재확인 부탁드려요. 선택적으로 실패 케이스에 경량 감사 로그를 남겨 추적성을 높이면 운영에 도움이 됩니다.

backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantLikeRepositoryImpl.java (2)

57-59: 캐시 이름 상수 일관성

여기만 문자열 리터럴("participant:likes")을 사용하고 있어요. 상수로 통일하면 오타·불일치 위험이 줄어듭니다.

-    @Cacheable(value = "participant:likes", key = "#participantId")
+    @Cacheable(value = PARTICIPANT_LIKES_CACHE_NAME, key = "#participantId")

46-49: 스탬피드 완화 및 카운트 타입

  • 다중 요청 동시 미스 시 DB 부하를 줄이기 위해 sync = true를 고려해주세요.
-    @Cacheable(value = RESTAURANT_LIKE_COUNT_CACHE_NAME, key = "#restaurantId")
+    @Cacheable(value = RESTAURANT_LIKE_COUNT_CACHE_NAME, key = "#restaurantId", sync = true)
  • Integer 대신 int(또는 상한을 고려하면 long) 반환을 권장합니다. 카운트는 null이 될 수 없고, 장기적으로 누적치가 커질 수 있습니다.
backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatResultServiceTest.java (3)

59-77: 동률/무득표 케이스 테스트도 추가해 랜덤성으로 인한 플래키를 방지해요

현재는 좋아요 1건(restaurant2)만 검증합니다. 다음 케이스를 추가하면 생성 로직의 경계값을 튼튼히 커버할 수 있어요.

  • 동률 최고 likeCount(예: r1, r2가 동일 최댓값): 결과 id가 {r1.id, r2.id} 중 하나임을 검증. 랜덤성은 테스트에서 시드 고정 또는 허용 집합 검증으로 제어.
  • 전원 0 likeCount: 전체 후보군 중 하나를 반환하는지 허용 집합 검증.

장점: 생성기(PickeatResultGenerator)의 정책 변경에도 테스트가 의미를 잃지 않습니다. 필요하면 예시 테스트 코드 드릴게요.


89-100: 멱등성 검증을 DB 차원까지 확장해 주세요

같은 입력으로 2회 호출 시 동일 응답을 확인하셨는데, DB에 PickeatResult가 1건만 존재하는지도 함께 보장하면 더 좋습니다. 예: 엔티티 매니저로 count 쿼리 추가 후 1건임을 단언(assert).


172-188: 비활성화 플로우 검증 굿 — 캐시 무효화까지 별도 테스트 권장

픽잇 비활성화 상태 단언은 명확합니다. 추가로, 비활성화 이벤트가 캐시 무효화까지 일으키는지(예: findByCode 캐시가 갱신됨) 통합 테스트를 분리해 두면 장애 대응력이 높아집니다. 캐시 매니저 주입 후 측정/검증하는 패턴을 추천드립니다.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/ui/RestaurantController.java (1)

65-72: 서비스 시그니처를 일관화해 가독성과 사용성을 높여요

exclude는 ParticipantPrincipal 전체를, like/unlike/조회는 id만 전달합니다. 한 컨트롤러에서 혼합되면 인지 부하가 큽니다.

  • 제안: 서비스 계층에서 통일된 형태(ParticipantPrincipal 또는 long id)로 받도록 정리하세요. 인증/인가 검증이 필요하면 Principal 쪽이 확장성에 유리합니다.
    장점: API 사용 규칙이 명확해지고, 파라미터 확장 시(예: code, roles) 변경 파급을 줄일 수 있어요.
backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImpl.java (2)

31-44: 파라미터 네이밍 오타(rooId/roodIds) 수정으로 혼란을 줄여요

오탈자는 IDE 검색성, 가독성, 온보딩에 비용을 만듭니다. 아래처럼 정리해 주세요.

- public List<Pickeat> findByRoomId(Long rooId) {
-     return jpaRepository.findByRoomId(rooId);
- }
+ public List<Pickeat> findByRoomId(Long roomId) {
+     return jpaRepository.findByRoomId(roomId);
+ }

- public List<Pickeat> findByRoomIdIn(List<Long> roodIds) {
-     return jpaRepository.findByRoomIdIn(roodIds);
- }
+ public List<Pickeat> findByRoomIdIn(List<Long> roomIds) {
+     return jpaRepository.findByRoomIdIn(roomIds);
+ }

25-29: 캐시 키·부정 캐싱 정책을 명시해 주세요

현재 키는 #pickeatCode.value.toString()입니다. 의도는 “코드 문자열 기준 캐싱”으로 보이는데, 다음을 점검하면 안전합니다.

  • 부정 캐싱(Optional.empty())을 허용할지 여부와 TTL. 허용 시 404 폭주 방지에 유리하나, 생성 직후 가시성 지연이 생길 수 있어요.
  • 키 생성 유틸(예: PickeatCode.toCacheKey())로 중복 SpEL을 줄이면 유지보수가 편합니다.

결정한 정책을 javadoc/코멘트로 남겨 주세요. 필요하면 unless 조건 활용도 검토해요.

backend/src/main/java/com/pickeat/backend/pickeat/ui/PickeatController.java (2)

66-71: 메소드 네이밍과 실제 사용 맥락 점검

getPickeatByParticipant(pickeatCode)를 참가자 컨텍스트 없이 호출합니다. 네이밍과 사용 맥락이 어긋나 혼동 여지가 있어요. 실제 요구가 “참여자 맥락 없이 코드로 조회”라면 이름을 getPickeatByCode 등으로 조정하는 것을 권장합니다.


144-151: TODO 정리 제안

//TODO: 안씀 지우자는 PR 병합 전 정리하거나, 이슈로 승격해 추적 가능하게 남겨 주세요. 작은 빚을 일찍 갚으면 큽니다 🙂

backend/src/main/java/com/pickeat/backend/pickeat/ui/api/PickeatApiSpec.java (1)

540-544: 비회원 재참여 가능 여부 API의 보안 요구사항 재검토

엔드포인트 설명은 “비회원”이지만 security = ParticipantAuth가 설정되어 있습니다. 토큰이 선택 사항이라면 스펙에서 보안을 제거하거나 문서에 “선택적 인증(있으면 개인화)”로 명시해 주세요. 컨트롤러도 required=false이므로 문서와 맞추는 것이 좋습니다.

Comment on lines +67 to 74
//TODO: 결과 만드는중에 좋아요되면 어카지? (2025-10-20, 월, 20:25)
private PickeatResultResponse createNewResultWithConcurrencyHandling(Pickeat pickeat) {
try {
pickeat.deactivate();
RestaurantResultResponse newResult = createNewPickeatResult(pickeat);
return new PickeatResultCreationResponse(newResult, true);
PickeatResultResponse pickeatResult = createNewPickeatResult(pickeat);
applicationEventPublisher.publishEvent(new PickeatDeactivatedEvent(pickeat));
return pickeatResult;
} catch (DataIntegrityViolationException 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

결과 생성 중 좋아요 경쟁 상태(TODO 처리 필요)

deactivate() 이후 동일 트랜잭션에서 좋아요가 계속 반영될 수 있어 결과 합산 기준이 흔들립니다. 현재 like(...) 쪽에 활성 상태 검증이 없어 더 취약합니다.

개선 제안:

  • like(...)에 피킷 활성 상태 검증 추가(서비스 간 일관성).
  • 결과 생성 경로에서 deactivation을 먼저 영속/flush하여 다른 트랜잭션이 isActive=false를 즉시 보도록 처리.
  • 필요 시 Pickeat 행에 대한 비관적 락(SELECT ... FOR UPDATE) 또는 결과 테이블의 유니크 제약 + 재조회 패턴 유지.

이렇게 하면 Race로 인한 결과 왜곡을 줄일 수 있어요.

Also applies to: 80-90

🤖 Prompt for AI Agents
backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultService.java
around lines 67-74 (also apply same changes to 80-90): the current flow
deactivates a Pickeat in-memory and then creates results, but concurrent
like(...) calls can still record likes in parallel transactions causing
inconsistent result totals; fix by 1) enforcing active-state validation in the
like(...) service method so likes are rejected if pickeat is inactive, 2) after
calling pickeat.deactivate() persist and flush the Pickeat entity within the
same transaction so DB state shows isActive=false immediately (or acquire a
pessimistic lock on the Pickeat row via SELECT ... FOR UPDATE before
deactivation), and 3) keep or add a unique constraint on the results and
implement a retry/requery pattern on DataIntegrityViolationException to ensure
idempotent result creation under concurrency.

Comment on lines +97 to 98
restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
}
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

비활성화된 피킷에 대한 좋아요 허용 가능성

like(...)에서 피킷 활성 상태 검증이 없습니다. PickeatResultService가 비활성화 처리를 하더라도, 같은 시점에 좋아요가 계속 들어오면 결과 생성 시점의 합산과 불일치가 발생할 수 있어요.

대안:

  • restaurant.getPickeatId()로 Pickeat을 조회 후 validatePickeatState(pickeat) 수행.
  • 또는 DB 제약은 어려우니, 서비스 레벨에서 일관되게 isActive 검증을 추가.

원하시면 안전하게 적용 가능한 패치 제안을 더 드릴게요.

Comment on lines 23 to +28
@DataJpaTest
@Import({ParticipantService.class, ParticipantTokenProvider.class, JwtProvider.class})
@Import({ParticipantService.class, ParticipantTokenProvider.class, JwtProvider.class, PickeatRepositoryImpl.class,
ParticipantRepositoryImpl.class})
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 | 🔴 Critical

🧩 Analysis chain

Repository 구현체를 포함한 테스트 설정이 적절합니다! 🔧

ParticipantRepositoryImplPickeatRepositoryImpl을 직접 import하여 캐싱 동작을 함께 테스트할 수 있게 했네요.

확인 포인트:
PR 목표가 "인메모리 캐싱 적용"인데, 캐싱 동작을 검증하는 테스트가 별도로 있는지 확인이 필요합니다. 현재 테스트는 기능 검증에 집중되어 있는데, 캐시 히트/미스를 검증하는 테스트도 있으면 좋겠습니다!


🏁 Script executed:

#!/bin/bash
# 캐싱 관련 테스트 확인
rg -n "@Cacheable|@CacheEvict|@CachePut" backend/src/test/ -A 5 -B 5

Length of output: 0


추가 검증이 필요합니다. 테스트 파일 내용과 캐싱 구현 여부를 확인하겠습니다.


🏁 Script executed:

#!/bin/bash
# 1. 테스트 파일 전체 내용 확인
echo "=== ParticipantServiceTest.java 내용 ==="
cat backend/src/test/java/com/pickeat/backend/pickeat/application/ParticipantServiceTest.java

echo -e "\n=== 캐싱 어노테이션 전체 위치 ==="
rg -n "@Cacheable|@CacheEvict|@CachePut" backend/src/ -B 2 -A 2

echo -e "\n=== 캐싱 검증 관련 메소드 (테스트) ==="
rg -n "cache|Cache" backend/src/test/java/com/pickeat/backend/pickeat/application/ParticipantServiceTest.java -i

Length of output: 11568


캐싱 동작 검증 테스트가 필요합니다! 📝

검증 결과, ParticipantServiceTest에는 현재 캐싱 동작을 검증하는 테스트가 없습니다. 구현체(ParticipantRepositoryImpl, PickeatRepositoryImpl)에는 @Cacheable@CacheEvict가 적용되어 있지만, 테스트에서는 기능만 검증하고 있네요.

추가해야 할 테스트 케이스:

  1. 캐시 히트 검증: 동일한 픽잇의 참가자 목록을 두 번 조회할 때, 두 번째는 DB 접근 없이 캐시에서 반환되는지 확인
  2. 캐시 미스 검증: 다른 픽잇의 참가자 목록 조회 시 새로운 캐시 키로 저장되는지 확인
  3. 캐시 무효화 검증: 참가자 생성/수정 후 evictParticipantCache() 호출로 캐시가 정상 삭제되는지 확인

예시 방향:

@Test
void 동일_픽잇의_참가자_목록_조회시_캐시_히트() {
    // 첫 번째 조회 (캐시 미스, DB 접근)
    List<Participant> first = participantService.getParticipants(pickeatId);
    
    // 두 번째 조회 (캐시 히트, DB 접근 없음)
    List<Participant> second = participantService.getParticipants(pickeatId);
    
    // 같은 객체 참조인지 확인 또는 DB 쿼리 횟수 검증
}

Spring의 CacheManager를 테스트에 주입하거나, @SpyBean으로 Repository 메소드 호출 횟수를 검증할 수 있습니다. 공식 문서를 참고하세요: Spring Boot Caching Testing

- 사용자 정의 클레임(extraClaims) 추가 시 예약 클레임(sub, iat, exp) 덮어쓰기 방지 로직 적용
- JwtBuilder의 생성 순서를 명확히 구분 (사용자 정의 클레임 → JWT 예약 클레임 → 최종 토큰 생성)
- 토큰 생성 관련 코드의 가독성과 안정성 향상
- 캐시 이름을 상수로 관리하기 위한 CacheNames 인터페이스 추가
- 기존 캐시 이름을 CacheNames 상수로 대체하여 코드 일관성 및 유지보수성 향상
- 관련된 Repository 구현체의 캐싱 키 설정 코드 리팩토링 (Restaurant, Participant, Pickeat 등)
- Boolean.FALSE.equals 사용으로 NPE 방지 및 코드 가독성 향상
Copy link
Copy Markdown
Contributor

@supernovaMK supernovaMK left a comment

Choose a reason for hiding this comment

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

에드~ 수고 많으셨습니다!

요구사항이 많았는데 모두 잘 해결해주셨네요 . 몇가지 코멘트 달아두었습니다!

}

@Override
@PatchMapping("/restaurants/{restaurantId}/like")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2]
like,cacelLikeRestaurants 가가 Patch인게 이제 어색해진 것 같다는 생각이 듭니다.
어떻게 생각하시나요?

Comment on lines +90 to +91
targets.forEach(Restaurant::exclude);
restaurantRepository.saveAll(targets);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

현재 cache를 적용 후에 restaurantRepository.saveAll()을 해주시고 계신데, 트랜잭션에 캐시가 롤백되지 않을 것 같다고 생각이 듭니다. saveAll()이 실패했을때 cache와의 데이터와 정합성이 맞지 않을 것 같아요. 🤔

만약 의도하신게 아니라면

  • TransactionAwareCacheManagerProxy
  • After commit 이벤트 기반 처리
    등등의 키워드가 있네요!

Comment on lines +41 to +44
@Override
public Optional<Restaurant> findById(Long restaurantId) {
return jpaRepository.findById(restaurantId);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

단일 검색에서는 cache를 사용하고 있지 않네요! 이유가 궁금합니다!

cahche되어있는 List 에서 검색하는 것보다 이게 낫다고 판단하신 걸까요?

- 좋아요 수를 관리하기 위해 RestaurantLikeCount 클래스 추가
- 기존 int 기반 카운트를 AtomicLong 기반의 RestaurantLikeCount로 대체
- 좋아요 및 좋아요 취소 시 카운트 증가/감소 처리 로직 추가
- 캐싱 로직 수정으로 데이터 일관성 강화 및 코드 가독성 향상
- 관련 테스트 코드 및 Repository 구현체 수정
- RestaurantLikeCount 클래스 및 관련 로직 제거
- 좋아요 수 관리를 ParticipantLikesRepository로 통합
- ParticipantLikes를 활용한 좋아요 카운트 및 상태 관리로 코드 간소화
- 캐시 및 테스트 코드 수정으로 불필요한 캐싱 제거 및 유지보수성 개선
- Pickeat 생성 시 캐시 초기화 로직 적용
- 관련 인터페이스 및 Repository 리팩토링
@wodnd0131
Copy link
Copy Markdown
Contributor

@CodeRabbit 코드 리뷰해줘

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Oct 22, 2025

@wodnd0131 네, PR을 리뷰하겠습니다!

이번 PR은 pickeat 플로우에 인메모리 캐싱을 적용한 대규모 리팩토링이네요. 주요 변경 사항들을 확인해보겠습니다:

주요 검토 포인트:

  • 🔍 캐시 설정: Caffeine 기반 CacheManager 구성 및 TTL/사이즈 전략
  • 🔄 도메인 리팩토링: 양방향 연관 → Long ID 간접 참조로의 전환
  • 캐시 무효화: 이벤트 기반 캐시 무효화 로직의 정합성
  • 🗃️ DB 마이그레이션: Flyway 스크립트 및 인덱스 구성

커밋이 잘 정리되어 있어 변경 사항을 단계별로 검토하기 좋겠습니다. 바로 리뷰 시작하겠습니다! 🚀

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@wodnd0131 wodnd0131 moved this from 시작 전 to 진행 중 in 2025-pick-eat Oct 22, 2025
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

♻️ Duplicate comments (9)
backend/src/main/resources/db/migration/V15__update_restaurant_schema_for_indirect_reference.sql (2)

1-2: 외래키 삭제 전 조건부 처리가 필요합니다.

라인 1-2에서 외래키를 무조건 삭제하고 있는데, 다음과 같은 문제가 발생할 수 있습니다:

  1. 멱등성 위반: 마이그레이션을 재실행하면 이미 삭제된 외래키에 대해 에러 발생
  2. 환경별 제약 조건 이름 차이: 데이터베이스나 스키마 생성 방식에 따라 FK 이름이 다를 수 있음
  3. 라인 4-19와 불일치: 같은 파일에서 FK 인덱스는 조건부로 삭제하면서 FK 자체는 무조건 삭제하는 것은 패턴 불일치

라인 4-19에서 이미 구현한 조건부 삭제 패턴을 FK 삭제에도 적용하세요:

-ALTER TABLE `restaurant`
-    DROP FOREIGN KEY `FK37vnutmwssokg056o36w5qax`;
-
-SET @restaurant_fk_index := 'FK37vnutmwssokg056o36w5qax';
-SET @drop_restaurant_index_sql := (
-    SELECT IF(EXISTS (
-                      SELECT 1
-                      FROM INFORMATION_SCHEMA.STATISTICS
-                      WHERE TABLE_SCHEMA = DATABASE()
-                        AND TABLE_NAME = 'restaurant'
-                        AND INDEX_NAME = @restaurant_fk_index
-                  ),
-                  CONCAT('ALTER TABLE `restaurant` DROP INDEX `', @restaurant_fk_index, '`'),
-                  'SELECT 1')
-);
+SET @restaurant_fk_name := 'FK37vnutmwssokg056o36w5qax';
+SET @drop_fk_sql := (
+    SELECT IF(EXISTS (
+                      SELECT 1
+                      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
+                      WHERE TABLE_SCHEMA = DATABASE()
+                        AND TABLE_NAME = 'restaurant'
+                        AND CONSTRAINT_NAME = @restaurant_fk_name
+                  ),
+                  CONCAT('ALTER TABLE `restaurant` DROP FOREIGN KEY `', @restaurant_fk_name, '`'),
+                  'SELECT 1')
+);
+
+PREPARE drop_fk_stmt FROM @drop_fk_sql;
+EXECUTE drop_fk_stmt;
+DEALLOCATE PREPARE drop_fk_stmt;
+
+SET @restaurant_fk_index := @restaurant_fk_name;
+SET @drop_restaurant_index_sql := (
+    SELECT IF(EXISTS (
+                      SELECT 1
+                      FROM INFORMATION_SCHEMA.STATISTICS
+                      WHERE TABLE_SCHEMA = DATABASE()
+                        AND TABLE_NAME = 'restaurant'
+                        AND INDEX_NAME = @restaurant_fk_index
+                  ),
+                  CONCAT('ALTER TABLE `restaurant` DROP INDEX `', @restaurant_fk_index, '`'),
+                  'SELECT 1')
+);

참고: MySQL INFORMATION_SCHEMA.KEY_COLUMN_USAGE를 사용하여 외래키 존재 여부를 안전하게 검증할 수 있습니다.


53-67: pickeat_id 컬럼 존재 여부를 인덱스 생성 전에 검증하세요.

라인 21-51에서는 컬럼 삭제 전 INFORMATION_SCHEMA.COLUMNS로 존재 여부를 확인하지만, 라인 61-63에서는 컬럼 존재 여부를 검증하지 않고 인덱스를 생성합니다. 이는 패턴 불일치이며 다음과 같은 위험이 있습니다:

  1. 일관성 부재: 같은 파일 내 방어적 프로그래밍 패턴이 불일치
  2. 마이그레이션 체인 의존성: 이전 마이그레이션에서 pickeat_id 컬럼 추가가 실패했다면 인덱스 생성 실패
  3. 미래 유지보수: 스키마 변경 시 마이그레이션이 깨질 수 있음

라인 21-35의 패턴을 따라 다음과 같이 개선하세요:

 SET @restaurant_index_exists = (
     SELECT COUNT(*)
     FROM INFORMATION_SCHEMA.STATISTICS
     WHERE TABLE_SCHEMA = DATABASE()
       AND TABLE_NAME = 'restaurant'
       AND INDEX_NAME = 'idx_restaurant_pickeat_id'
 );
 
-SET @create_restaurant_index_sql = IF(@restaurant_index_exists = 0,
-                                      'CREATE INDEX `idx_restaurant_pickeat_id` ON `restaurant` (`pickeat_id`)',
+SET @pickeat_id_exists = (
+    SELECT COUNT(*)
+    FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'restaurant'
+      AND COLUMN_NAME = 'pickeat_id'
+);
+
+SET @create_restaurant_index_sql = IF(@restaurant_index_exists = 0 AND @pickeat_id_exists > 0,
+                                      'CREATE INDEX `idx_restaurant_pickeat_id` ON `restaurant` (`pickeat_id`)',
                                       'SELECT 1');

이렇게 수정하면:

  • ✅ 컬럼 존재 여부를 먼저 검증 → 안전한 마이그레이션
  • ✅ 같은 파일 내 방어적 로직 패턴 일관화 → 가독성 향상
  • ✅ 미래 스키마 변경에 강건한 마이그레이션 → 유지보수성 개선
backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/ParticipantRepository.java (1)

7-14: [이전 리뷰 지적사항 미반영] 캐시 무효화 메서드를 인터페이스에 노출하세요

이전 리뷰에서 지적되었던 evictParticipantCache(Long pickeatId) 메서드가 여전히 인터페이스에 없습니다. 현재 이벤트 핸들러(PickeatDeactivatedEventHandler, ParticipantStateUpdatedEventHandler)들이 ParticipantRepositoryImpl 구현체를 직접 주입받아 캐시 무효화를 수행하고 있다면, 이는 의존성 역전 원칙(DIP) 위반입니다.

문제점:

  • 도메인 계층(이벤트 핸들러)이 인프라 계층(구현체)을 직접 의존 → 계층 구조 위반
  • 단위 테스트 시 실제 캐시 인프라까지 필요 → 테스트 복잡도 증가
  • 구현체 교체 시 이벤트 핸들러까지 수정 필요 → 확장성 저하

해결 방법:

 public interface ParticipantRepository {
 
     List<Participant> findByPickeatId(Long pickeatId);
 
     Participant save(Participant participant);
 
     Optional<Participant> findById(Long participantId);
+
+    /**
+     * 특정 픽잇의 참가자 캐시를 무효화합니다.
+     * 
+     * @param pickeatId 캐시를 무효화할 픽잇 ID
+     */
+    void evictParticipantCache(Long pickeatId);
 }

추가로, 여러 픽잇의 참가자를 한 번에 조회하는 배치 메서드도 고려해보세요:

/**
 * 여러 픽잇의 참가자 목록을 조회합니다.
 * 
 * @param pickeatIds 조회할 픽잇 ID 모음
 * @return 해당 픽잇들의 참가자 목록
 */
List<Participant> findByPickeatIdIn(Collection<Long> pickeatIds);

배치 조회 메서드는 N+1 문제를 방지하고 성능을 개선할 수 있습니다.

다음 스크립트로 실제 의존 관계를 확인해주세요:

#!/bin/bash
# 이벤트 핸들러들이 구현체를 직접 의존하는지 확인
rg -nP 'ParticipantRepositoryImpl' --type=java -C3

# evictParticipantCache 호출부 확인
rg -nP '\bevictParticipantCache\s*\(' --type=java -C3

# findByPickeatIdIn 사용 여부 확인
rg -nP '\bfindByPickeatIdIn\s*\(' --type=java -C3

참고 자료:

As per coding guidelines

backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepository.java (1)

17-19: 과거 리뷰 이슈 해결 여부 확인 필요

이전 리뷰에서 다음 두 가지 중요한 이슈가 지적되었습니다:

  1. @where 필터 미적용: Restaurant 엔티티에 @Where(clause = "deleted = false") 추가 필요
  2. @transactional 보장: 호출하는 코드에서 트랜잭션 보장 필요

현재 코드에서 @Modifying 설정은 적절하게 되어 있습니다. 이전 리뷰에서 PickeatScheduler의 트랜잭션 설정은 확인되었다고 하니, Restaurant 엔티티의 @Where 필터만 추가되었는지 최종 확인이 필요합니다.

#!/bin/bash
# Restaurant 엔티티에 @Where 필터 적용 여부 확인
rg -A 10 "class Restaurant" backend/src/main/java/com/pickeat/backend/restaurant/domain/Restaurant.java | rg -i "@Where|@SQLDelete|deleted"
backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepository.java (2)

16-16: count 반환 타입 일관성(long)으로 변경 권장

Spring Data count 파생쿼리는 long이 표준입니다. Integer는 범위/캐스팅 이슈가 있어요.

-    Integer countByRestaurantId(Long restaurantId);
+    long countByRestaurantId(Long restaurantId);

(이전 코멘트와 동일 제안입니다) As per coding guidelines.


10-26: 중복 좋아요 방지 및 조회 성능 위한 인덱스/제약 권장

  • (restaurant_id, participant_id) UNIQUE 제약으로 중복 좋아요 방지
  • 조회 패턴에 맞춰 restaurant_id, participant_id 인덱스 추가

Flyway에 마이그레이션을 추가해 주세요.

필요하시면 DDL 초안 드리겠습니다.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantService.java (2)

38-54: 배치 삽입 후 캐시 무효화 검증 필요

이전 리뷰에서 지적된 배치 삽입의 캐시 무효화 이슈를 확인해야 합니다. restaurantRepository.batchInsert()가 Pickeat ID 기반 캐시를 자동으로 무효화하는지 검증이 필요해요.

확인이 필요한 사항:

  • RestaurantRepositoryImpl.batchInsert()에서 @CacheEvict 또는 이벤트 기반 캐시 무효화가 적용되어 있는가?
  • 생성 직후 restaurantRepository.findByPickeatId(pickeat.getId()) 호출 시 캐시된 빈 리스트가 반환되지 않는가?

다음 스크립트로 RestaurantRepository의 batchInsert 구현을 확인해주세요:

#!/bin/bash
# RestaurantRepository와 그 구현체에서 batchInsert의 캐시 처리 확인
ast-grep --pattern $'class $_ implements RestaurantRepository {
  $$$
  batchInsert($$$) {
    $$$
  }
  $$$
}'

# 또는 캐시 관련 어노테이션 확인
rg -n -A 10 "batchInsert" --type=java -g '**/RestaurantRepository*.java'

92-104: 비활성 피킷에 대한 좋아요 허용 문제 여전

이전 리뷰에서 지적되었던 피킷 활성 상태 검증이 여전히 누락되어 있습니다. 비활성화된 피킷에도 좋아요가 가능하면, 결과 생성 시점과 좋아요 집계 간 불일치가 발생할 수 있어요.

개선 방안:

@Transactional
public void like(Long restaurantId, Long participantId) {
    if (existsLike(restaurantId, participantId)) {
        throw new BusinessException(ErrorCode.PARTICIPANT_RESTAURANT_ALREADY_LIKED);
    }
    
    Participant participant = getParticipant(participantId);
    Restaurant restaurant = getRestaurantById(restaurantId);
    validateParticipantAccessToRestaurants(List.of(restaurant), participant);
    
    // 피킷 활성 상태 검증 추가
    Pickeat pickeat = getPickeatById(restaurant.getPickeatId());
    validatePickeatState(pickeat);  // 이미 구현된 메서드 재사용
    
    ParticipantLikes participantLikes = participantLikesRepository.findByRestaurantId(restaurantId);
    restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
    participantLikes.addParticipantId(participantId);
}

비활성 피킷에 대한 좋아요를 막아 데이터 정합성을 보장해주세요.

backend/src/main/java/com/pickeat/backend/pickeat/ui/PickeatController.java (1)

74-83: 멱등성 지원 시 HTTP 상태 코드 개선 고려

현재 결과 생성 API는 항상 201 CREATED를 반환하는데, API 스펙 Line 234에서 "멱등성을 지원하여 이미 결과가 있는 경우 기존 결과를 반환"한다고 명시되어 있습니다.

REST 모범 사례:

  • 신규 생성: 201 Created
  • 기존 결과 반환: 200 OK

선택지:

  1. 간단한 방법: 문서를 "항상 201 반환"으로 수정
  2. 표준적인 방법: 서비스에서 생성 여부를 반환받아 분기 처리
    CreateResultResponse response = pickeatResultService.createPickeatResult(...);
    HttpStatus status = response.isNewlyCreated() ? HttpStatus.CREATED : HttpStatus.OK;
    return ResponseEntity.status(status).body(response.data());

팀 내에서 REST API 설계 원칙을 논의하고 일관된 정책을 적용하시길 권장합니다.

🧹 Nitpick comments (35)
backend/src/main/resources/db/migration/V11__drop_participant_count_column_from_pickeat.sql (1)

1-2: 마이그레이션 기본 구조는 올바르지만, 재실행 안전성을 위해 IF EXISTS 절 추가를 권장합니다.

현재 마이그레이션은 SQL 문법이 정확하고 PR 의도(participantCount 필드 제거)와도 완벽히 부합합니다. 다만 Flyway 마이그레이션의 모범 사례 관점에서, 마이그레이션이 재실행되거나 실패 후 재시도되는 상황에 대비하면 좋습니다.

개선 제안:

ALTER TABLE `pickeat`
    DROP COLUMN IF EXISTS `participant_count`;

배경:

  • Flyway는 체크섬으로 마이그레이션 중복 실행을 방지하지만, DB 상태가 예상과 다른 경우(수동 개입, 복구 시나리오 등)를 대비할 수 있습니다.
  • IF EXISTS 절은 컬럼이 없으면 무시하므로 멱등성(idempotency)을 확보합니다.

참고: 팀의 Flyway 정책이나 기존 마이그레이션 관례가 있다면 그에 맞춰 진행하세요. 현재 마이그레이션 시리즈(V10~V14)가 일관되게 IF EXISTS를 사용하지 않는다면, 이 파일도 그 관례를 따르는 것이 통일성 면에서 좋습니다.

backend/src/test/java/com/pickeat/backend/tobe/acceptance_test/scenario/PickeatByTemplateScenarioTest.java (2)

105-112: 타입 변경이 일관되게 적용되었습니다.

PickeatResultResponse 타입으로의 전환이 모든 변수에 일관성 있게 반영되었습니다.

다만, checkoutPickeatResult에서 단순 equality 검증만 수행하는데, 픽잇 결과의 핵심 필드(선택된 식당 정보, 투표 결과 등)를 명시적으로 검증하는 것도 고려해보세요. 이는 테스트의 의도를 더 명확히 하고, 예상치 못한 변경을 감지하는 데 도움이 됩니다.


126-128: 메서드 시그니처 변경이 적절합니다.

타입 변경에 따라 파라미터 타입이 올바르게 업데이트되었습니다.

참고로, 메서드명 checkoutPickeatResult에서 "checkout"이라는 단어가 "검증"의 의미로 사용되고 있는데, 테스트 컨텍스트에서는 verifyPickeatResult 또는 assertPickeatResult가 의도를 더 명확하게 전달할 수 있습니다. 하지만 프로젝트 전반의 네이밍 컨벤션을 따르는 것이라면 현재 상태도 괜찮습니다.

backend/src/main/java/com/pickeat/backend/global/auth/JwtProvider.java (3)

49-57: 예약 클레임 보호 로직 굿! 범위를 등록 클레임 전체로 확장해주세요.

sub/iat/exp 제거는 훌륭합니다. 다만 JWT 등록 클레임(iss, sub, aud, exp, nbf, iat, jti)까지 방어하지 않으면, 추후 iss·aud 등을 도입할 때 충돌 위험이 있어요. 상수 화이트리스트로 관리하면 안전하고 가독성도 좋아집니다.

권장 수정안:

-            safeClaims.keySet().removeAll(Set.of("sub", "iat", "exp"));
+            safeClaims.keySet().removeAll(RESERVED_CLAIMS);

클래스 상단(필드 영역)에 추가:

private static final Set<String> RESERVED_CLAIMS =
        Set.of("iss", "sub", "aud", "exp", "nbf", "iat", "jti");

테스트 제안:

  • extraClaims에 {"exp": 0, "iss": "x"}를 넣어도 sub/iat/exp/iss가 최종 토큰에서 기대대로 설정되는지 검증.

As per coding guidelines


60-63: 서명 알고리즘을 명시해 기본값 변경 리스크 제거

기본값에 의존하면 라이브러리 업그레이드 시 알고리즘이 바뀔 수 있습니다. 명시적으로 HS256을 지정해주세요.

-                .signWith(secretKey);
+                .signWith(secretKey, Jwts.SIG.HS256);

(동일 패턴을 createToken(...)에도 적용 권장)

테스트 제안:

  • 헤더 alg가 "HS256"인지, 서명 검증이 통과하는지 확인.

As per coding guidelines


27-29: 시크릿은 Base64로 관리하고 decode하여 Key 생성 권장

UTF-8 문자열 바이트를 그대로 키로 쓰면 엔트로피·길이 관리가 어렵습니다. Base64로 저장하면 길이 보장이 쉽고 운영 관리도 명확합니다.

권장 수정안:

-import java.nio.charset.StandardCharsets;
+import io.jsonwebtoken.io.Decoders;
@@
-        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));

운영 가이드:

  • application 설정의 jwt.secretKey를 최소 256비트 랜덤 바이트를 Base64 인코딩한 값으로 교체.
  • 잘못된 길이일 때 부트 시점에 명확히 실패하는지 확인.

As per coding guidelines

backend/src/main/java/com/pickeat/backend/pickeat/domain/PickeatDeactivatedEvent.java (1)

5-10: 팩토리 메서드의 타입 변환 체인 검토 권장

pickeat.getCode().getValue().toString() 호출 체인이 다소 길어 보입니다.

확인 사항:

  • PickeatCode.getValue()의 반환 타입이 무엇인가요?
  • 이미 String이라면 .toString()은 불필요합니다.
  • UUID라면 .toString()은 적절하지만, 메서드명을 toStringValue() 같이 명확하게 하는 것도 고려해볼 수 있습니다.

개선 제안:

public static PickeatDeactivatedEvent from(Pickeat pickeat) {
    return new PickeatDeactivatedEvent(
            pickeat.getId(),
            pickeat.getCode().getValueAsString() // 또는 더 명확한 메서드명
    );
}

과거 리뷰에서 제안된 ID 기반 페이로드로 잘 변경되었고, pickeatIdpickeatCode 모두 포함하여 핸들러에서 재조회 없이 캐시 무효화가 가능하게 된 점은 훌륭합니다! 👍

backend/src/main/java/com/pickeat/backend/restaurant/domain/ParticipantLikes.java (1)

7-29: 도메인 객체의 방어적 프로그래밍 및 불변성 강화 제안

ParticipantLikes가 도메인 객체로서 몇 가지 개선이 가능합니다.

1. Null 안전성 부재
현재 모든 public 메서드가 null 체크를 하지 않아 NPE 위험이 있습니다.

public void addAll(List<Long> participantIds) {
    if (participantIds == null) {
        return; // 또는 throw new IllegalArgumentException
    }
    this.participantIds.addAll(participantIds);
}

public void addParticipantId(Long participantId) {
    if (participantId == null) {
        throw new IllegalArgumentException("participantId는 null일 수 없습니다");
    }
    participantIds.add(participantId);
}

2. ConcurrentHashMap 사용 근거 확인 필요
ConcurrentHashMap.newKeySet()은 멀티스레드 환경에서 유용하지만, 추가 오버헤드가 있습니다.

질문:

  • 이 객체가 여러 스레드에서 동시 접근되나요?
  • 캐시에 저장되어 공유되나요?
  • 단일 스레드 서비스 레이어에서만 사용된다면 new HashSet<>()으로 충분합니다.

3. 불변성 고려
도메인 객체라면 외부 변경을 최소화하는 것이 좋습니다. 빌더 패턴이나 생성자를 통한 초기화를 권장합니다.

public class ParticipantLikes {
    private final Set<Long> participantIds;
    
    public ParticipantLikes(List<Long> participantIds) {
        this.participantIds = ConcurrentHashMap.newKeySet();
        if (participantIds != null) {
            this.participantIds.addAll(participantIds);
        }
    }
    
    // add/remove는 신중하게 제공
}

4. 비즈니스 로직 캡슐화 기회
현재는 단순 래퍼지만, 도메인 규칙을 추가할 수 있습니다:

  • 최대 참여자 수 제한
  • 중복 참여 검증
  • 특정 상태에서만 추가/삭제 허용

Based on coding guidelines (객체지향, 최신 트렌드, 클린코드 확인 요청)

backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/ParticipantJpaRepositoryTest.java (1)

29-61: 테스트 커버리지 확대 제안 - 엣지 케이스 추가 필요

현재 Happy Path는 잘 작성되어 있습니다! 👍 Given-When-Then 구조가 명확하고 assertAll을 활용한 다중 검증도 좋습니다.

추가 테스트 케이스 제안 (코딩 가이드라인 #6 준수):

@Nested
class 참가자_삭제 {
    
    @Test
    void 픽잇_ID_목록으로_참가자_일괄_삭제() {
        // 기존 테스트 유지
    }
    
    @Test
    void 빈_목록으로_삭제_시도시_아무것도_삭제되지_않음() {
        // given
        Pickeat pickeat = testEntityManager.persist(PickeatFixture.createWithoutRoom());
        testEntityManager.persist(ParticipantFixture.create(pickeat.getId()));
        testEntityManager.flush();
        long beforeCount = participantJpaRepository.count();
        
        // when
        int deletedCount = participantJpaRepository.bulkSoftDeleteByPickeatIdIn(List.of());
        
        // then
        assertThat(deletedCount).isZero();
        assertThat(participantJpaRepository.count()).isEqualTo(beforeCount);
    }
    
    @Test
    void 존재하지_않는_픽잇_ID로_삭제_시도시_0_반환() {
        // when
        int deletedCount = participantJpaRepository.bulkSoftDeleteByPickeatIdIn(
            List.of(999L, 1000L)
        );
        
        // then
        assertThat(deletedCount).isZero();
    }
    
    @Test
    void 이미_삭제된_참가자는_카운트에_포함되지_않음() {
        // 소프트 삭제 멱등성 검증
    }
}

검증해야 할 추가 시나리오:

  1. Null 안전성: null 파라미터 처리 (현재 DB 레이어에서 예외 발생할 것으로 예상)
  2. 트랜잭션 롤백: 삭제 중 예외 발생 시 롤백 동작
  3. 대용량 데이터: 많은 ID 목록 처리 (IN 절 제한)
  4. 동시성: 동일 참가자를 동시에 삭제하는 경우

코딩 가이드라인에 따라 Repository 단위 테스트 커버리지를 높이는 것을 권장드립니다.

As per coding guidelines (#6: 미작성 테스트 케이스 제안)

backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatService.java (5)

58-60: 이벤트는 커밋 이후에 발행하세요 (캐시 일관성·사이드이펙트 안정화).

동일 트랜잭션 내 동기 발행은 리스너가 DB를 조회할 때 미커밋 상태·구버전 캐시와 레이스가 날 수 있어요. @TransactionalEventListener(phase = AFTER_COMMIT)로 리스너를 전환하거나, 서비스에서 afterCommit 훅으로 발행을 지연시키는 방식을 권장합니다. 캐시 제거/전파(특히 Caffeine)는 커밋 이후가 안전합니다.
As per coding guidelines


68-71: 메서드 명이 의미와 달라요 — 코드로 조회인데 이름은 ‘Participant’.

getPickeatByParticipant(String pickeatCode)는 참가자와 무관하게 코드로만 조회합니다. 혼동을 줄이기 위해 getPickeatByCode로 이름을 바꾸는 것을 제안합니다. 테스트도 함께 치환하세요.

적용 예시(diff):

-    public PickeatResponse getPickeatByParticipant(String pickeatCode) {
+    public PickeatResponse getPickeatByCode(String pickeatCode) {
       Pickeat pickeat = getPickeatByCode(pickeatCode);
       return PickeatResponse.from(pickeat);
     }

94-99: 재입장 여부 조회: 2쿼리를 1쿼리로 줄일 수 있어요.

현재 participant -> pickeatId 조회 + pickeatById 조회의 2번 접근입니다. existsByIdAndCode(id, code)를 리포지토리에 추가하면 단일 쿼리로 끝납니다. 박싱(Boolean)도 불필요하니 primitive로 정리하세요.

적용 예시(diff):

-        //TODO: 한번만 쿼리가 나가도록 할 수 있지 않을까?  (2025-10-20, 월, 17:44)
-        Participant participant = getParticipant(participantId);
-        Pickeat pickeat = getPickeatByParticipant(participant);
-        Boolean rejoinAvailable = pickeat.isEqualPickeatCode(pickeatCode);
-        return new PickeatRejoinAvailableResponse(rejoinAvailable);
+        // 단일 쿼리: pickeatId + code 매칭 여부
+        Participant participant = getParticipant(participantId);
+        boolean rejoinAvailable = pickeatRepository
+                .existsByIdAndCode(participant.getPickeatId(), new PickeatCode(pickeatCode));
+        return new PickeatRejoinAvailableResponse(rejoinAvailable);

리포지토리 시그니처 추가는 PickeatRepository 코멘트 참고. As per coding guidelines


101-105: 빈 방 목록 처리로 불필요 쿼리 회피.

getAllRoomIdsByUserId가 빈 리스트면 즉시 빈 결과를 반환하면 IN () 쿼리 생성·오류 가능성을 줄일 수 있어요.

     public List<PickeatResponse> getPickeatsByUser(Long userId) {
         List<Long> allRoomIds = roomUserRepository.getAllRoomIdsByUserId(userId);
+        if (allRoomIds.isEmpty()) {
+            return List.of();
+        }
         List<Pickeat> roomPickeats = pickeatRepository.findByRoomIdIn(allRoomIds);
         return PickeatResponse.from(roomPickeats);
     }

107-111: 단수/복수 혼용 네이밍 수정 제안.

getPickeatsByParticipant(Long)가 단일 Pickeat을 반환합니다. getPickeatByParticipantId 등 단수형으로 맞추면 API 탐색성이 좋아져요. 테스트 호출부도 함께 치환 부탁드립니다.
As per coding guidelines

backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatServiceTest.java (2)

176-183: 불필요한 이중 persist 호출 제거.

createWithoutRoomPickeat() 내에서 이미 persist를 수행합니다. 반환 엔티티에 다시 persist를 호출하면 혼동을 유발하고 구현체에 따라 예외가 날 수 있어요. 단순 대입으로 정리하세요.

-            Pickeat pickeat = testEntityManager.persist(createWithoutRoomPickeat());
+            Pickeat pickeat = createWithoutRoomPickeat();
             testEntityManager.flush();
             testEntityManager.clear();

182-183: 서비스 메서드 리네이밍 반영.

서비스를 getPickeatByCode로 변경 시 테스트도 함께 수정하세요.

-            PickeatResponse pickeatResponse = pickeatService.getPickeatByParticipant(pickeat.getCode().toString());
+            PickeatResponse pickeatResponse = pickeatService.getPickeatByCode(pickeat.getCode().toString());
backend/src/main/java/com/pickeat/backend/global/cache/CacheNames.java (1)

3-8: 상수 전용 ‘interface’ 대신 final class 권장.

‘상수 인터페이스’는 안티패턴입니다. 인스턴스화 방지와 IDE 네비게이션을 위해 final class + private constructor로 전환을 제안해요. 콜론(participant:like)은 캐시 이름·메트릭 태그 등에서 의미가 섞일 수 있으니 팀 컨벤션을 명시해 두면 좋아요.

-package com.pickeat.backend.global.cache;
-
-public interface CacheNames {
-    String RESTAURANT = "restaurant";
-    String PICKEAT = "pickeat";
-    String PARTICIPANT = "participant";
-    String PARTICIPANT_LIKE = "participant:like";
-}
+package com.pickeat.backend.global.cache;
+
+public final class CacheNames {
+    private CacheNames() {}
+    public static final String RESTAURANT = "restaurant";
+    public static final String PICKEAT = "pickeat";
+    public static final String PARTICIPANT = "participant";
+    public static final String PARTICIPANT_LIKE = "participant:like";
+}

As per coding guidelines

backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatRepository.java (1)

12-16: 파라미터명 오타 정정 제안 (rooId/roodIds).

가독성과 IDE 검색 정확도를 위해 roomId/roomIds로 통일하세요.

-    List<Pickeat> findByRoomId(Long rooId);
+    List<Pickeat> findByRoomId(Long roomId);
...
-    List<Pickeat> findByRoomIdIn(List<Long> roodIds);
+    List<Pickeat> findByRoomIdIn(List<Long> roomIds);

As per coding guidelines

backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java (3)

71-90: exclude: 유효하지 않은 ID 요청 처리와 캐시 무효화 확인

  • 요청 ID 중 현재 Pickeat에 속하지 않는 ID가 섞여 있어도 무시됩니다. 클라이언트 오류를 조기에 드러내려면 유효성 검증 후 예외를 던지는 편이 안전합니다(데이터 일관성).
  • 저장 이후 캐시 무효화(식당 목록/단건, 좋아요 집계 캐시 등)가 리포지토리 레벨에서 보장되는지 확인이 필요합니다.

예시:

-        List<Restaurant> targets = restaurants.stream()
-                .filter(restaurant -> targetIds.contains(restaurant.getId()))
-                .toList();
+        Set<Long> scope = restaurants.stream().map(Restaurant::getId).collect(Collectors.toSet());
+        List<Long> invalid = targetIds.stream().filter(id -> !scope.contains(id)).toList();
+        if (!invalid.isEmpty()) {
+            throw new BusinessException(ErrorCode.RESTAURANT_ELIMINATION_FORBIDDEN);
+        }
+        List<Restaurant> targets = restaurants.stream()
+                .filter(r -> targetIds.contains(r.getId()))
+                .toList();

테스트 제안: (1) 유효/무효 ID 혼합 시 예외, (2) 저장 후 동일 요청 재실행 시 idempotent, (3) 캐시 적중률/무효화 검증(Repository 캐시 단위 테스트).


106-111: cancelLike의 의미를 명확히: 무상태(idempotent) vs 대칭적 예외

현재 존재 여부 확인 없이 삭제합니다. like()가 이미 좋아요인 경우 예외를 던지는 것과 대칭을 맞추려면 “좋아요하지 않았음”에 대한 예외/반환값 정책을 정리해 주세요. 서버-클라이언트 계약을 명시하면 UX/로깅이 깔끔해집니다.

테스트 제안: (1) 미좋아요 상태에서 cancelLike 호출 시 기대 동작(예외/정상)을 명시, (2) 중복 호출 idempotent 보장 여부.


149-151: null‑세이프 비교로 통일해 가독성과 안정성 향상

Long.equals는 좌변 null 시 NPE 위험이 있습니다(도메인 제약으로 null 불가라도 일관성 측면에서 통일 권장). 위에서 Objects.equals를 이미 사용하니 동일하게 맞추면 읽기 좋습니다.

-        if (restaurants.stream()
-                .anyMatch((restaurant -> !restaurant.getPickeatId().equals(participant.getPickeatId())))) {
+        if (restaurants.stream()
+                .anyMatch(r -> !Objects.equals(r.getPickeatId(), participant.getPickeatId()))) {
backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeRepository.java (1)

5-12: 도메인 저장소 계약 명확화: 삭제 반환값/벌크 지원

  • deleteByRestaurantIdAndParticipantIdvoid면 호출 측에서 성공/부재를 구분하기 어렵습니다. 불변성(대칭성) 확보를 위해 boolean 또는 삭제 건수 반환을 고려해 주세요.
  • 비활성화/정리 시 대량 정리 필요가 잦다면 도메인 저장소에도 deleteByRestaurantIdIn 또는 bulkSoftDeleteByRestaurantIdIn을 노출해 애플리케이션 레이어가 JPA 저장소에 직접 의존하지 않도록 경계를 정리하는 것이 좋습니다.

테스트 제안: 존재/부재 케이스 각각에서 반환값/예외를 검증하고, 대량 삭제 시 트랜잭션 경계와 캐시 무효화가 일관되게 작동하는지 확인.

backend/src/main/java/com/pickeat/backend/global/cache/CacheType.java (1)

7-12: 캐시 정책 표현 개선: Duration 사용·이름 중복 제거·운영 가시성 확보

  • 단위 혼동 방지를 위해 Long ttlMinutes 대신 Duration을 권장합니다.
  • CacheNames와 문자열이 이중으로 존재합니다. 한 곳이 소스 오브 트루스가 되도록 중복 제거를 추천합니다.
  • Caffeine recordStats() + 지표(export)로 적중률/만료/축출을 관찰하면 사이징(TTL/size) 튜닝이 쉬워집니다. TODO 대신 프로퍼티 외부화도 고려해 주세요.

예시:

-    private final String name;
-    private final Long ttlMinutes;
+    private final String name;
+    private final Duration ttl;
     private final Long maxSize;
     private final Integer initialCapacity;

-    CacheType(String name, Long ttlMinutes, Long maxSize, Integer initialCapacity) {
+    CacheType(String name, Duration ttl, Long maxSize, Integer initialCapacity) {
         this.name = name;
-        this.ttlMinutes = ttlMinutes;
+        this.ttl = ttl;
         this.maxSize = maxSize;
         this.initialCapacity = initialCapacity;
     }

운영 팁:

  • 초기 값: PICKEAT(코드→Pickeat) 조회는 히트율이 높으므로 TTL↑, size↑; PARTICIPANT_LIKE는 변경 이벤트가 잦아 TTL↓. 실제 트래픽 메트릭으로 보정하세요. As per coding guidelines.

Also applies to: 14-25

backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepositoryTest.java (1)

54-70: 좋아요 벌크 소프트삭제 시나리오 테스트 훌륭해요 👏

삭제 건수·잔존 데이터 검증이 명확합니다. 몇 가지 케이스를 추가하면 더 탄탄해집니다.

추가 제안:

  • 빈 목록/존재하지 않는 ID 목록 입력 시 0건 반환 확인.
  • 중복 ID 포함 시 중복 처리 안전성.
  • 소프트삭제 후 유니크 제약(restaurant_id, participant_id, deleted=false)이 유지되는지 재삽입 테스트.
backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/ParticipantLikesRepository.java (1)

23-26: Projection으로 I/O 줄이기

participantId만 필요합니다. 전체 RestaurantLike를 조회하지 말고, ID 전용 프로젝션/쿼리로 전환하면 메모리·I/O를 줄일 수 있어요.

- List<RestaurantLike> findAllByRestaurantId(Long restaurantId);
+ @Query("select rl.participantId from RestaurantLike rl where rl.restaurantId = :restaurantId")
+ List<Long> findParticipantIdsByRestaurantId(@Param("restaurantId") Long restaurantId)

그에 따라 본 메서드도 participantIds = repository.findParticipantIdsByRestaurantId(…)로 교체.

backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepositoryTest.java (1)

73-105: 엣지/회귀 테스트 보강 제안

현재 케이스 훌륭합니다. 아래 2가지를 추가하면 회귀 방지에 도움됩니다.

  • 빈 리스트 삭제 시: 예외 없이 0 반환 확인 (서비스 가드 로직 기대 동작)
  • 멱등성: 동일 ID로 2회 연속 soft-delete 호출 시 2번째 호출의 deletedCount가 0인지 확인 (AND deleted = false 조건 추가 시 보장)

예시 검증:

// given
List<Long> ids = List.of(pickeat1.getId(), pickeat2.getId());
// when
int first = restaurantJpaRepository.bulkSoftDeleteByPickeatIdIn(ids);
int second = restaurantJpaRepository.bulkSoftDeleteByPickeatIdIn(ids);
// then
assertThat(second).isZero();
backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepository.java (2)

20-23: Bulk 소프트 삭제에도 AND deleted = false 조건 추가

멱등성과 불필요한 쓰기 최소화를 위해 다음과 같이 변경하세요.

-@Query(value = "UPDATE restaurant_like SET deleted = true WHERE restaurant_id IN :restaurantIds",
+@Query(value = "UPDATE restaurant_like SET deleted = true WHERE deleted = false AND restaurant_id IN :restaurantIds",
        nativeQuery = true)
 int bulkSoftDeleteByRestaurantIdIn(@Param("restaurantIds") List<Long> restaurantIds);

12-26: ---

deleteByRestaurantIdAndParticipantId를 명시적 소프트 삭제 쿼리로 통일하기

좋은 소식입니다! 프로젝트가 이미 @SoftDelete로 소프트 삭제를 표준화했기 때문에, 현재 코드는 기능적으로 문제없습니다. 하지만 코드 의도를 더욱 명확히 하기 위해 약간의 개선을 제안합니다.

현황 분석:

  • BaseEntity에 @SoftDelete 적용됨 → RestaurantLike가 자동으로 상속
  • bulkSoftDeleteByRestaurantIdIn()은 명시적 UPDATE 쿼리로 구현됨 ✅
  • deleteByRestaurantIdAndParticipantId()는 Spring Data 파생 메서드 → @SoftDelete로 인해 자동으로 UPDATE로 변환됨

개선 권장사항:

  1. deleteByRestaurantIdAndParticipantId → 명시적 @query로 변경

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("update RestaurantLike rl set rl.deleted = true " +
           "where rl.restaurantId = :restaurantId and rl.participantId = :participantId")
    int softDeleteByRestaurantIdAndParticipantId(@Param("restaurantId") Long restaurantId,
                                                 @Param("participantId") Long participantId);

    이유: bulkSoftDeleteByRestaurantIdIn()과의 일관성, 의도 명확성

  2. bulkSoftDeleteByRestaurantIdIn() 개선 (멱등성 강화)

    @Query(value = "UPDATE restaurant_like SET deleted = true " +
           "WHERE restaurant_id IN :restaurantIds AND deleted = false",
           nativeQuery = true)

    이유: 이미 삭제된 행의 중복 업데이트 방지

  3. deleteByRestaurantIdAndParticipantId 동작을 검증하는 테스트 추가

    @Test
    void 선호_취소가_소프트_삭제하는지_검증() {
        // 삭제 후 findAll()에서 제외되는지 확인
        restaurantLikeJpaRepository.deleteByRestaurantIdAndParticipantId(restaurantId, participantId);
        List<RestaurantLike> remaining = restaurantLikeJpaRepository.findAll();
        assertThat(remaining).doesNotContain(deletedLike); // 소프트 삭제 검증
    }

이렇게 하면 모든 삭제 메서드의 의도가 명확하고, 팀원들이 소프트 삭제 패턴을 한눈에 이해할 수 있습니다. 문서도 코드로 말하는 것이 최고의 코드 컨벤션이니까요! 👌

backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepository.java (1)

15-18: 쿼리에 AND deleted = false 조건을 추가하여 성능 최적화 필요

좋은 소식입니다! 빈 리스트 처리와 트랜잭션 보장이 이미 잘 구현되어 있네요. 다만, 쿼리 성능 개선을 위해 한 가지 수정이 남아있습니다.

PickeatScheduler.cleanupOldPickeats()에서 호출되는 bulkSoftDeleteByPickeatIdIn() 메서드의 쿼리에 AND deleted = false 조건을 추가하면, 이미 삭제된 행에 불필요한 UPDATE 시도를 방지할 수 있습니다. 이를 통해 row lock 경합을 줄이고 undo log 생성량도 감소시킬 수 있습니다.

-@Query(value = "UPDATE pickeat_result SET deleted = true WHERE pickeat_id IN :pickeatIds",
+@Query(value = "UPDATE pickeat_result SET deleted = true WHERE deleted = false AND pickeat_id IN :pickeatIds",
         nativeQuery = true)
 int bulkSoftDeleteByPickeatIdIn(@Param("pickeatIds") List<Long> pickeatIds);

관련 리포지토리에도 동일하게 적용하세요:

  • RestaurantJpaRepository.bulkSoftDeleteByPickeatIdIn()
  • ParticipantJpaRepository.bulkSoftDeleteByPickeatIdIn()

이 패턴은 데이터베이스 레벨의 멱등성(idempotency)을 높이는 좋은 실천 방법입니다. 특히 스케줄러처럼 반복 실행되는 작업에서는 더욱 중요합니다.

backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantRepository.java (1)

7-16: 쓰기 경로 단일화: saveAll 하나로 추상화하고 내부에서 배치 전략을 선택하세요

batchInsert와 saveAll이 공존하면 “서비스 레이어가 어느 것을 언제 호출할지”가 퍼지고, 캐시 무효화/이벤트 발행 책임이 흩어집니다. 공개 API는 saveAll 하나로 두고, 구현체 내부에서 데이터 크기 등에 따라 JDBC 배치 여부를 결정하는 편이 안전합니다. 또한 saveAll이 void인 것도 검증/관측에 불리하니 반영 개수 또는 저장된 엔티티를 반환하는 방향을 권장합니다.

예시(개념 스케치):

-public interface RestaurantRepository {
-    void batchInsert(List<Restaurant> restaurants);
-    List<Restaurant> findByPickeatId(Long pickeatId);
-    void saveAll(List<Restaurant> restaurants);
-    Optional<Restaurant> findById(Long restaurantId);
-}
+public interface RestaurantRepository {
+    List<Restaurant> saveAll(List<Restaurant> restaurants); // 내부에서 JPA or JDBC 배치 선택
+    List<Restaurant> findByPickeatId(Long pickeatId);
+    Optional<Restaurant> findById(Long restaurantId);
+}

구현체는 캐시 무효화/도메인 이벤트를 단일 경로에 모아 주세요. (팀 컨벤션의 모듈 책임 일원화 원칙에 부합)

backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultService.java (2)

81-90: N+1 쿼리: 좋아요 카운트 일괄 조회로 치환

availableRestaurants 각각에 대해 findByRestaurantId를 호출하면서 N+1이 발생합니다. IN 절 기반 일괄 조회로 바꿔주세요. 성능과 캐시 적중률이 크게 개선됩니다.

예시(서비스 측 변경):

-        Map<Restaurant, Integer> likeCounts = getLikeCounts(availableRestaurants);
+        Map<Restaurant, Integer> likeCounts = getLikeCountsBulk(availableRestaurants);

예시(새 메서드):

+    private Map<Restaurant, Integer> getLikeCountsBulk(List<Restaurant> availableRestaurants) {
+        Map<Long, Integer> counts = participantLikesRepository.findCountsByRestaurantIds(
+                availableRestaurants.stream().map(Restaurant::getId).toList()
+        );
+        Map<Restaurant, Integer> map = new HashMap<>();
+        for (Restaurant r : availableRestaurants) {
+            map.put(r, counts.getOrDefault(r.getId(), 0));
+        }
+        return map;
+    }

리포지토리 예시:

// backend/.../ParticipantLikesRepository.java
Map<Long, Integer> findCountsByRestaurantIds(List<Long> restaurantIds);

쿼리 레이어에서 GROUP BY restaurant_id 형태로 구현하면 됩니다.


45-56: 기존 결과 단락 처리 OK + 사전 조건 명시를 고려

Optional 기반의 기존 결과 단락 처리는 명확합니다. 추가로, availableRestaurants가 비면 즉시 RESTAURANTS_IS_EMPTY를 던져 서비스 계층에서 책임을 명시하는 것도 선택지입니다(현재는 생성기에서 처리될 수도 있으므로 팀 합의에 맞춰 결정).

backend/src/main/java/com/pickeat/backend/pickeat/application/ParticipantService.java (1)

47-53: 완료 상태 변경 없을 때 이벤트 생략(노이즈 줄이기)

현재는 동일 상태로 요청해도 매번 ParticipantStateUpdatedEvent를 발행합니다. 변경 발생 시에만 이벤트를 발행해 캐시 스로틀링/부하를 줄이세요.

예시:

-    public void updateCompletion(ParticipantPrincipal participantPrincipal, boolean isCompleted) {
-        Participant participant = getParticipant(participantPrincipal.id());
-        participant.updateCompletionAs(isCompleted);
-        //Todo: 인메모리 도입 / 식당 소거와 같이 핸들링 가능하지 않을까? [2025-10-21 14:58:44]
-        applicationEventPublisher.publishEvent(new ParticipantStateUpdatedEvent(participant.getPickeatId()));
-    }
+    public void updateCompletion(ParticipantPrincipal participantPrincipal, boolean isCompleted) {
+        Participant participant = getParticipant(participantPrincipal.id());
+        boolean before = participant.getIsCompleted(); // 도메인에 맞는 접근자 사용
+        participant.updateCompletionAs(isCompleted);
+        if (before != participant.getIsCompleted()) {
+            applicationEventPublisher.publishEvent(new ParticipantStateUpdatedEvent(participant.getPickeatId()));
+        }
+    }

도메인에 반환값(boolean changed)을 주도록 바꾸는 것도 좋은 대안입니다.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantSearchFacade.java (1)

49-56: 캐시 워밍 실패 시 사용자 경험 저하

warmUpCache()가 실패하면 전체 API 요청이 실패합니다. 하지만 캐시 워밍은 성능 최적화를 위한 것이므로, 실패해도 서비스 자체는 동작해야 하지 않을까요?

개선 제안:

private void warmUpCache(String pickeatCode) {
    try {
        Pickeat pickeat = pickeatRepository.findByCode(new PickeatCode(pickeatCode))
                .orElseThrow(() -> new BusinessException(ErrorCode.PICKEAT_NOT_FOUND));
        List<Restaurant> restaurants = restaurantRepository.findByPickeatId(pickeat.getId());
        for (Restaurant restaurant : restaurants) {
            participantLikesRepository.initial(restaurant.getId());
        }
    } catch (Exception e) {
        // 로깅만 하고 예외는 전파하지 않음
        log.warn("Cache warm-up failed for pickeatCode: {}", pickeatCode, e);
    }
}

캐시 워밍은 best-effort로 처리하고, 실패해도 조회 시점에 cache-miss로 처리되도록 하는 것이 resilient한 설계입니다.

추가로, 식당이 많을 경우 루프가 병목이 될 수 있으니 participantLikesRepository.batchInitial(restaurantIds) 같은 배치 API를 고려해보세요.

backend/src/main/java/com/pickeat/backend/pickeat/ui/PickeatController.java (1)

123-132: 호환성 유지 전략 좋습니다만, 마이그레이션 계획 추가 권장

오타로 인한 엔드포인트 이슈를 호환성을 유지하며 해결한 것은 좋은 판단입니다.

개선 제안:
TODO 주석에 구체적인 마이그레이션 일정을 추가해주세요:

//Todo: 오타로 인한 호환성 문제 해결 이후 단일화 필요
//      - 2025-11 말까지 클라이언트 마이그레이션 완료 후
//      - 2025-12 초에 /deactive 경로 제거 예정 [2025-10-21]
@PatchMapping({"/pickeats/{pickeatCode}/deactivate", "/pickeats/{pickeatCode}/deactive"})

또한 /deactive 경로 사용 시 응답 헤더에 Deprecated: trueWarning 헤더를 추가하면 클라이언트가 마이그레이션을 인지하기 쉽습니다:

response.addHeader("Warning", "299 - \"Deprecated endpoint. Use /deactivate instead\"");
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ab7d67f and 75ee76f.

📒 Files selected for processing (54)
  • backend/src/main/java/com/pickeat/backend/global/auth/JwtProvider.java (2 hunks)
  • backend/src/main/java/com/pickeat/backend/global/cache/CacheManagerConfig.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/global/cache/CacheNames.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/global/cache/CacheType.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/application/ParticipantService.java (3 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultService.java (3 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatScheduler.java (3 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatService.java (5 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/Participant.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/PickeatDeactivatedEvent.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/ParticipantJpaRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/ParticipantRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/ParticipantRepositoryImpl.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/PickeatDeactivatedEventHandler.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImpl.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/ui/PickeatController.java (4 hunks)
  • backend/src/main/java/com/pickeat/backend/pickeat/ui/api/PickeatApiSpec.java (8 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java (6 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/ParticipantLikes.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/ParticipantLikesRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantRepository.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantLikeRepositoryImpl.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantRepositoryImpl.java (1 hunks)
  • backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantSearchFacade.java (2 hunks)
  • backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantService.java (6 hunks)
  • backend/src/main/java/com/pickeat/backend/tobe/user/application/UserService.java (2 hunks)
  • backend/src/main/java/com/pickeat/backend/user/application/UserService.java (2 hunks)
  • backend/src/main/resources/db/migration/V11__drop_participant_count_column_from_pickeat.sql (1 hunks)
  • backend/src/main/resources/db/migration/V12__drop_has_equal_like_column_from_pickeat_result.sql (1 hunks)
  • backend/src/main/resources/db/migration/V13__update_pickeat_result_indirect_reference.sql (1 hunks)
  • backend/src/main/resources/db/migration/V14__drop_participant_pickeat_foreign_key.sql (1 hunks)
  • backend/src/main/resources/db/migration/V15__update_restaurant_schema_for_indirect_reference.sql (1 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatResultServiceTest.java (9 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatSchedulerTest.java (6 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatServiceTest.java (8 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/ParticipantJpaRepositoryTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepositoryTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepositoryTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/infrastructure/ParticipantRepositoryImplCachingTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImplCachingTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/restaurant/application/RestaurantServiceTest.java (13 hunks)
  • backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepositoryTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepositoryTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/restaurant/infrastructure/RestaurantRepositoryImplCachingTest.java (1 hunks)
  • backend/src/test/java/com/pickeat/backend/tobe/acceptance_test/scenario/PickeatByTemplateScenarioTest.java (4 hunks)
  • backend/src/test/java/com/pickeat/backend/tobe/restaurant/RestaurantServiceTest.java (3 hunks)
  • backend/src/test/java/com/pickeat/backend/tobe/user/application/UserServiceTest.java (3 hunks)
  • backend/src/test/java/com/pickeat/backend/user/application/UserServiceTest.java (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (12)
  • backend/src/main/java/com/pickeat/backend/tobe/user/application/UserService.java
  • backend/src/test/java/com/pickeat/backend/tobe/restaurant/RestaurantServiceTest.java
  • backend/src/test/java/com/pickeat/backend/pickeat/infrastructure/ParticipantRepositoryImplCachingTest.java
  • backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/ParticipantRepositoryImpl.java
  • backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantRepositoryImpl.java
  • backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/PickeatDeactivatedEventHandler.java
  • backend/src/main/java/com/pickeat/backend/global/cache/CacheManagerConfig.java
  • backend/src/main/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImpl.java
  • backend/src/test/java/com/pickeat/backend/restaurant/infrastructure/RestaurantRepositoryImplCachingTest.java
  • backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatScheduler.java
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/Participant.java
  • backend/src/test/java/com/pickeat/backend/tobe/user/application/UserServiceTest.java
🧰 Additional context used
📓 Path-based instructions (1)
backend/**

⚙️ CodeRabbit configuration file

backend/**: 1. 팀 및 공식 컨벤션(docs/back-code-convention.md), 가독성, 예외처리, 테스트/확장/유지보수성, 모듈화, API/DB/보안 설계 기준을 기반으로 리뷰해주세요.
2. 객체지향, 최신 트렌드, 불필요한 로직, 클린코드, 리팩토링, 서비스/도메인 설계, 공통 예외 처리, 확장성도 함께 확인해주세요.
3. 각 리뷰 포인트별로 문제점과 대안, 장단점을 논리적으로 제시하고, 필요한 경우 예시 코드도 추가해 주세요.
4. 리뷰가 너무 많아서 피로감을 줄 수 있으니, 꼭 필요한 부분에 집중해주고, 나머지는 캡션으로 설명해주세요.
5. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요.
6. 미작성한 테스트 코드 케이스가 있다면, 어떤 테스트가 필요한지 제안해주세요. (예: API 테스트, Service 단위 테스트, Repository 단위 테스트)

Files:

  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeRepository.java
  • backend/src/main/resources/db/migration/V13__update_pickeat_result_indirect_reference.sql
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/PickeatDeactivatedEvent.java
  • backend/src/main/java/com/pickeat/backend/restaurant/infrastructure/RestaurantLikeRepositoryImpl.java
  • backend/src/main/java/com/pickeat/backend/global/cache/CacheNames.java
  • backend/src/main/java/com/pickeat/backend/global/auth/JwtProvider.java
  • backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultService.java
  • backend/src/main/java/com/pickeat/backend/global/cache/CacheType.java
  • backend/src/main/java/com/pickeat/backend/pickeat/application/ParticipantService.java
  • backend/src/test/java/com/pickeat/backend/user/application/UserServiceTest.java
  • backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantService.java
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepository.java
  • backend/src/main/resources/db/migration/V15__update_restaurant_schema_for_indirect_reference.sql
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepository.java
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepository.java
  • backend/src/main/java/com/pickeat/backend/user/application/UserService.java
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/ParticipantLikes.java
  • backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantSearchFacade.java
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepository.java
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/ParticipantJpaRepository.java
  • backend/src/main/resources/db/migration/V14__drop_participant_pickeat_foreign_key.sql
  • backend/src/main/resources/db/migration/V12__drop_has_equal_like_column_from_pickeat_result.sql
  • backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatResultServiceTest.java
  • backend/src/test/java/com/pickeat/backend/restaurant/application/RestaurantServiceTest.java
  • backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantJpaRepositoryTest.java
  • backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepositoryTest.java
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/ParticipantLikesRepository.java
  • backend/src/test/java/com/pickeat/backend/restaurant/domain/repository/RestaurantLikeJpaRepositoryTest.java
  • backend/src/test/java/com/pickeat/backend/tobe/acceptance_test/scenario/PickeatByTemplateScenarioTest.java
  • backend/src/main/resources/db/migration/V11__drop_participant_count_column_from_pickeat.sql
  • backend/src/main/java/com/pickeat/backend/restaurant/domain/repository/RestaurantRepository.java
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatRepository.java
  • backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatServiceTest.java
  • backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatSchedulerTest.java
  • backend/src/test/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImplCachingTest.java
  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/ParticipantRepository.java
  • backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java
  • backend/src/main/java/com/pickeat/backend/pickeat/ui/api/PickeatApiSpec.java
  • backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/ParticipantJpaRepositoryTest.java
  • backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepositoryTest.java
  • backend/src/main/java/com/pickeat/backend/pickeat/ui/PickeatController.java
  • backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatService.java
🧠 Learnings (1)
📚 Learning: 2025-10-21T07:49:05.932Z
Learnt from: wodnd0131
PR: woowacourse-teams/2025-pick-eat#417
File: backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepository.java:10-12
Timestamp: 2025-10-21T07:49:05.932Z
Learning: Flyway를 사용하는 프로젝트에서는 JPA 엔티티의 `Column(unique = true)` 어노테이션이 자동으로 DB에 제약 조건을 생성하지 않습니다. Flyway 마이그레이션 파일에서 명시적으로 UNIQUE 제약 조건을 생성해야 합니다.

Applied to files:

  • backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepository.java
🔇 Additional comments (26)
backend/src/test/java/com/pickeat/backend/tobe/acceptance_test/scenario/PickeatByTemplateScenarioTest.java (2)

27-27: 타입 변경이 일관되게 적용되었습니다! 👍

도메인 모델 리팩토링에 따라 RestaurantResultResponse에서 PickeatResultResponse로의 import 변경이 적절하게 반영되었습니다.


62-62: 오타 수정 감사합니다! ✨

텤플릿템플릿 수정으로 메서드명의 가독성이 개선되었습니다.

backend/src/test/java/com/pickeat/backend/user/application/UserServiceTest.java (2)

65-80: 테스트 업데이트가 정확합니다! 🎯

ProviderPrincipal로의 마이그레이션이 테스트에도 일관되게 반영되었네요. 검증 로직도 모두 유효합니다.


83-92: 테스트는 적절하지만, 검증 책임의 계층이 명확한지 확인이 필요합니다.

좋은 질문을 던지셨는데, 웹 검색 결과를 바탕으로 조금 더 구체적으로 안내하겠습니다.

Spring Security 모범 사례에 따르면, Principal 객체의 유효성 검증은 인증 레이어(AuthenticationProvider, UserDetailsService)에서 먼저 이루어져야 합니다. 현재 테스트 코드에서 ProviderPrincipal(2L, "google")을 직접 생성해서 통과시키는 것은:

  • 비즈니스 로직 테스트는 좋음: 닉네임 중복 검증 로직이 제대로 작동하는지 확인
  • ⚠️ 하지만 고려할 점: providerIdprovider가 null/invalid인 경우는 실제 인증 과정에서 걸러져야 하는 사항

따라서 다음을 확인해보세요:

  1. 인증 레이어에서 검증 중인가? ProviderPrincipal 생성/검증을 담당하는 AuthenticationProvider 구현체가 있다면, 그곳에서 null/빈값 체크가 이루어져야 합니다.
  2. UserService에서 방어적 검증이 필요한가? 계층 간 신뢰도에 따라, createUser 메서드에서 providerPrincipal != null 정도의 최소한의 검증이 있으면 더 안전합니다.

현재 테스트는 "정상 케이스"를 검증하고 있으므로 그대로 두고, 원본 리뷰의 ProviderPrincipal 엣지 케이스는 인증 레이어의 단위 테스트로 분리하시는 것을 추천드립니다. 이렇게 하면 각 계층의 책임이 명확해집니다.

backend/src/main/java/com/pickeat/backend/user/application/UserService.java (1)

30-35: 깔끔한 타입 마이그레이션입니다! ✨ 다만 원래 리뷰의 확인사항들은 이미 충분히 안전하니 참고하세요.

검증 결과:

  1. ProviderPrincipal 불변성: 해결됨 ✓

    • Java record는 기본적으로 불변성을 언어 수준에서 보장합니다. 모든 필드가 private final로 자동 생성되므로 추가 검증이 불필요합니다.
  2. Null 안전성: 이미 관리 중 ✓

    • LoginController.signup()에서 @Provider 어노테이션으로 주입되므로, ArgumentResolver 계층에서 이미 검증됩니다. 메서드 내부의 null 체크는 오버엔지니어링입니다.

결론: 코드가 정상이므로 원래 리뷰는 승인해도 됩니다. 다만 향후 리뷰 시 참고할 점:

  • Java record = 자동 불변성 보장 (별도 검증 불필요)
  • @Provider 같은 framework annotation = 주입 계층에서의 검증 담당 (메서드 중복 검증 불필요)
backend/src/main/resources/db/migration/V12__drop_has_equal_like_column_from_pickeat_result.sql (1)

1-2: has_equal_like 컬럼 삭제 안전성 확인됨 ✅

코드베이스 전체 검색 결과, 해당 컬럼이 마이그레이션 파일(V1__init.sql에서 생성, 현재 파일에서 삭제) 외에 다른 곳에서 참조되지 않는 것을 확인했습니다.

Java 애플리케이션 코드에서도 hasEqualLike 필드가 사용되지 않으므로, 안전하게 삭제할 수 있습니다. 도메인 모델 정합성도 맞는 상태네요. 👍

배포 시 권장사항:

  • 운영 DB는 마이그레이션 전 백업 진행 (표준 운영 절차)
  • 마이그레이션은 불가역적이므로 스테이징 환경에서 1회 검증 후 배포

코드 변경은 문제없습니다.

backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/ParticipantJpaRepository.java (1)

14-17: 원본 리뷰 코멘트는 잘못된 가정에 기반하고 있습니다. 코드는 올바르게 구현되어 있습니다.

원본 리뷰에서 Participant 엔티티에 @Where 절이 필요하다고 지적했는데, 실제로는 그럴 필요가 없습니다.

🎯 실제 상황:

  • BaseEntity에 Hibernate의 @SoftDelete 애노테이션이 적용되어 있습니다
  • 이 기능은 Hibernate 6.2+의 최신 soft delete 패턴으로, 자동으로 모든 쿼리에서 deleted = true 레코드를 필터링합니다
  • 따라서 findByPickeatId() 같은 메서드도 자동으로 soft-deleted 레코드를 제외합니다

현재 구현이 우수한 이유:

  • 모든 엔티티가 BaseEntity를 상속받아 일관된 soft delete 처리
  • 수동으로 @Where 절을 추가할 필요 없음
  • 레포지토리의 bulk soft delete 패턴이 모두 동일하게 구현됨
  • Hibernate의 현대적인 soft delete 기능 활용

결론: 원본 리뷰 코멘트는 무시해도 됩니다. ParticipantJpaRepositorybulkSoftDeleteByPickeatIdIn 구현이 모두 올바릅니다.

Likely an incorrect or invalid review comment.

backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepository.java (1)

25-27: 검증 완료: 소프트 삭제 패턴이 올바르게 구현되어 있습니다

원본 리뷰 코멘트의 우려사항이 해소되었습니다. Pickeat 엔티티가 상속하는 BaseEntity에 Hibernate의 @SoftDelete 애노테이션이 적용되어 있으며, 이는 다음을 자동으로 처리합니다:

  • deleted 컬럼 매핑
  • 모든 쿼리에 WHERE 필터 자동 적용
  • 소프트 삭제 생명주기 관리

따라서 bulkSoftDeleteByIdIn 메서드는 현대적이고 일관된 패턴으로 올바르게 구현되었습니다. 추가 수정이 필요하지 않습니다.

참고: @Where 필터는 레거시 패턴이며, @SoftDelete는 Spring Data JPA 3.1+에서 지원하는 더 우수한 접근 방식입니다.

backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatService.java (1)

124-129: ID 비교를 통한 접근 검증 좋습니다.

프록시/영속성 컨텍스트와 무관하게 안정적으로 비교돼요. 현재 방식 유지에 동의합니다.

backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatSchedulerTest.java (1)

64-78: 플러시/클리어 타이밍과 존재 여부 검증이 명확합니다.

카운트·존재성 체크로 스케줄러 동작을 깔끔히 보증합니다. 테스트 구조 좋아요.

backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/PickeatJpaRepositoryTest.java (1)

47-53: 대량 소프트 삭제 케이스 커버리지 적절합니다.

삭제 건수·잔존 엔티티 식별까지 검증해 회귀에 강합니다. 굿.

backend/src/test/java/com/pickeat/backend/pickeat/domain/repository/PickeatResultRepositoryTest.java (1)

58-64: 연관 결과 일괄 삭제 시나리오 테스트 좋습니다.

카운트·잔존 항목의 pickeatId 일치까지 체크해 신뢰도가 높습니다.

backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatServiceTest.java (1)

246-246: ---

Java 21 요구사항 확인 완료 — 변경 불필요

프로젝트의 build.gradle에서 languageVersion = JavaLanguageVersion.of(21)로 명시적으로 설정되어 있습니다. List.getFirst()는 Java 21에서 완벽하게 지원되므로, 현재 코드는 호환성 문제가 없습니다. 안심하고 진행하셔도 됩니다! 👍

backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java (1)

61-66: N+1 문제는 실제로 존재하지만, 제안된 솔루션이 구현되지 않았습니다

현재 코드를 분석한 결과, 리뷰 코멘트의 접근은 방향은 맞지만 몇 가지 중요한 문제가 있습니다:

  1. N+1은 캐싱으로 이미 완화됨: ParticipantLikesRepository.findByRestaurantId()@Cacheable이 적용되어 있고, Caffeine 캐시로 10분 TTL 관리 중입니다. 같은 restaurantId 재요청 시 캐시에서 즉시 반환됩니다.

  2. 제안된 메서드가 존재하지 않음:

    • findByRestaurantIdIn() 메서드가 ParticipantLikesRepository에 없습니다
    • ParticipantLikes.empty() 팩토리 메서드가 없습니다
    • 따라서 제시된 예시 코드는 현 상태에서 실행 불가능합니다
  3. 더 시급한 문제: 캐시 일관성 이슈 🚨

    • like() 라인 89에서 캐시된 ParticipantLikes 객체를 직접 수정: participantLikes.addParticipantId(participantId)
    • cancelLike() 라인 98에서도 마찬가지: participantLikes.removeParticipantId(participantId)
    • RestaurantLike DB는 저장/삭제되지만, 캐시된 객체 상태는 동기화되지 않음
    • @CacheEvict 어노테이션이 이 두 메서드에 없어 캐시 무효화가 발생하지 않습니다

권장 개선:

  • like(), cancelLike() 메서드에 @CacheEvict(value = "participant:like", key = "#restaurantId") 추가
  • 또는 더 안전하게, 캐시된 객체 직접 수정 대신 DB 상태 변경 후 캐시 갱신

Likely an incorrect or invalid review comment.

backend/src/main/java/com/pickeat/backend/pickeat/application/PickeatResultService.java (5)

92-96: null‑safe 필터 좋습니다

Boolean.FALSE.equals 비교로 NPE 리스크 제거 깔끔합니다. “가끔은 정석이 최고” 👍


58-66: 레스토랑 조회 예외 처리 적절

ID 기반 조회 + 도메인 예외 매핑이 명확합니다. 유지하세요. “심플 이즈 베스트” 👏


69-75: 이벤트 발행 타이밍: AFTER_COMMIT이 이미 적용되어 있습니다 ✓

검증 결과, PickeatDeactivatedEventHandlerParticipantStateUpdatedEventHandler 모두 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)으로 설정되어 있습니다. 따라서 캐시 무효화 작업이 트랜잭션 커밋 후에 안전하게 실행되며, 커밋 전 상태를 읽을 위험은 없습니다. 현재 구현이 이미 올바르게 처리되어 있으므로 추가 조치는 필요 없습니다.


75-79: 검증 완료: 유니크 제약이 Flyway에 명시적으로 정의되어 있습니다. 예외 처리가 안전합니다. ✓

좋은 소식입니다. 검증 결과 다음을 확인했습니다:

  1. Flyway 마이그레이션 확인: V1__init.sql line 131에 UNIQUE KEY \UK44xseqcxipx2ss3f0jyfwuvy4` (`pickeat_id`)`가 명시적으로 정의되어 있습니다.
  2. 마이그레이션 히스토리: 이후 V12, V13 마이그레이션에서도 이 제약을 제거하지 않아 현재 활성 상태입니다.
  3. 예외 처리 안전성: 데이터베이스 레벨의 유니크 제약이 보장되므로, 중복 생성 시도 시 DataIntegrityViolationException이 확실히 발생하며, fallback 로직은 안전합니다.

예외 메시지나 SQLState에 의존하지 않는 패턴으로 구현된 점도 훌륭합니다. 데이터베이스 제약 + 조용한 조회 패턴의 조합은 멀티스레드 환경에서 매우 안정적입니다.


68-79: 리뷰 의견이 부정확합니다. Pickeat 엔티티에는 like/cancelLike 메커니즘이 없습니다.

코드 검증 결과, 이 리뷰의 핵심 가정이 맞지 않습니다:

  1. like/cancelLike는 Restaurant에만 존재

    • RestaurantService에만 like(), cancelLike() 메서드 있음
    • Pickeat 도메인에는 like 기능 전혀 없음
    • 따라서 "like() 경로에 활성 픽잇만 허용 가드 추가" 제안은 관련 없음
  2. TODO의 실제 의미

    • "결과 만드는중에 좋아요되면 어카지?" → Restaurant에 대한 좋아요를 의미
    • Pickeat 결과 생성 중에 참가자들이 레스토랑에 좋아요를 추가하면, 그 집계에 포함될 수도 안 될 수도 있다는 뜻
  3. 실제 문제는 다릅니다

    private Map<Restaurant, Integer> getLikeCounts(List<Restaurant> availableRestaurants) {
        Map<Restaurant, Integer> restaurantLikeCounts = new HashMap<>();
        for (Restaurant restaurant : availableRestaurants) {
            // 각 레스토랑마다 개별 쿼리 실행 → N+1 문제!
            ParticipantLikes participantLikes = 
                participantLikesRepository.findByRestaurantId(restaurant.getId());
            restaurantLikeCounts.put(restaurant, participantLikes.getCount());
        }
        return restaurantLikeCounts;
    }
    • 이게 실제 동시성/성능 이슈

원본 리뷰의 제안사항들은 코드 구조와 맞지 않으므로 무시하셔도 됩니다.

Likely an incorrect or invalid review comment.

backend/src/main/java/com/pickeat/backend/pickeat/application/ParticipantService.java (1)

33-39: 이벤트 핸들러의 AFTER_COMMIT 설정이 이미 적용되어 있습니다. ✨

검증 결과, ParticipantStateUpdatedEventHandler가 이미 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)으로 올바르게 구현되어 있네요.

createParticipant()에서 발행되는 ParticipantStateUpdatedEvent가 트랜잭션 커밋 이후에 처리되어 캐시 정합성이 보장되고 있습니다. 좋은 설계입니다!

backend/src/test/java/com/pickeat/backend/restaurant/application/RestaurantServiceTest.java (1)

40-43: 리뷰 코멘트가 잘못된 가정을 기반으로 작성되었습니다.

코드 검증 결과, ParticipantLikesRepository인터페이스가 아니라 @Component 어노테이션이 붙은 일반 클래스입니다. 따라서 원래 리뷰에서 우려한 "인터페이스 타입이 빈으로 등록되지 않을 수 있다"는 문제는 해당되지 않습니다.

실제 코드 상태:

  • ParticipantLikesRepository: @Component + @RequiredArgsConstructor 선언된 구체적인 클래스
  • @DataJpaTest@Import(ParticipantLikesRepository.class)는 해당 클래스를 빈으로 정상 등록
  • 테스트에서 @Autowired private ParticipantLikesRepository participantLikesRepository;로 주입받아 실제 사용 중

결론: 테스트 컨텍스트 로딩에 문제가 없으며, 현재 구성은 의도대로 작동합니다. 코드를 수정할 필요가 없습니다.

Likely an incorrect or invalid review comment.

backend/src/main/java/com/pickeat/backend/tobe/restaurant/application/RestaurantService.java (3)

72-90: 이전 리뷰 피드백 반영 확인 완료! 👍

이전에 지적되었던 두 가지 이슈가 모두 개선되었네요:

  1. ✅ 참가자-피킷 일치 검증 추가 (Lines 76-79)
  2. ✅ 변경된 타겟만 저장 (Line 89)

권한 검증과 불필요한 쓰기 방지가 모두 적용되어 안전하고 효율적인 코드가 되었습니다.

다만, supernovaMK님이 언급한 캐시-트랜잭션 정합성 문제는 여전히 남아있습니다. saveAll() 실패 시 캐시가 롤백되지 않는 이슈는 TransactionAwareCacheManagerProxy 또는 커밋 후 이벤트 기반 캐시 무효화로 해결할 수 있어요.


113-121: Null 안전성 개선 완료! 🎯

이전에 지적된 NPE 위험이 Objects.equals()로 깔끔하게 해결되었습니다. Null-safe한 비교로 안정성이 향상되었네요.


123-127: 피킷 상태 검증 헬퍼 메서드 추가 우수

별도의 validatePickeatState() 메서드로 검증 로직을 분리한 것은 좋은 설계입니다. 다만 like() 메서드에서도 이 검증을 활용하면 더욱 일관성 있는 코드가 될 것 같아요.

backend/src/main/java/com/pickeat/backend/pickeat/ui/api/PickeatApiSpec.java (1)

3-3: API 스펙 일관성 있게 업데이트 완료 ✨

ParticipantPrincipal 기반 인증 모델과 PickeatResultResponse 타입 변경이 모든 엔드포인트에 일관되게 적용되었습니다.

특히 다음 부분들이 잘 정리되었네요:

  • 결과 생성/조회 API의 응답 타입 통일
  • 참가자 인증이 필요한 엔드포인트들의 파라미터 타입 통일
  • API 문서화 예시와 설명이 변경사항을 잘 반영

명세와 구현 간 일관성이 유지되어 클라이언트 개발자들이 혼란 없이 사용할 수 있겠습니다!

Also applies to: 8-8, 234-234, 270-274, 314-317, 429-432, 490-492, 532-536

backend/src/main/java/com/pickeat/backend/pickeat/ui/PickeatController.java (1)

134-145: NPE 방어 로직 추가 완벽! 🛡️

이전 리뷰에서 지적된 @ParticipantInPickeat(required = false) 사용 시 NPE 위험이 Line 140의 null 체크로 깔끔하게 해결되었습니다.

Long participantId = (participantPrincipal != null) ? participantPrincipal.id() : null;

Optional 파라미터 처리의 모범 사례입니다! 👏

Comment on lines +8 to 21
public interface PickeatRepository {

Optional<Pickeat> findByCode(PickeatCode uuid);
Optional<Pickeat> findByCode(PickeatCode pickeatCode);

List<Pickeat> findByRoomId(Long rooId);

List<Pickeat> findByRoomIdAndIsActive(Long roomId, Boolean isActive);

List<Pickeat> findByRoomIdIn(List<Long> roodIds);

List<Pickeat> findByUpdatedAtBetween(LocalDateTime startOfDay, LocalDateTime endOfDay);
Optional<Pickeat> findById(Long pickeatId);

Pickeat save(Pickeat pickeat);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

서비스 최적화를 위한 리포지토리 API 보강 (existsByIdAndCode).

getRejoinAvailableToPickeat 단일 쿼리화를 위해 존재 여부 체크 메서드를 추가해 주세요. 구현은 JPA/Querydsl/네이티브 중 팀 표준에 맞춰 주세요.

 public interface PickeatRepository {
     Optional<Pickeat> findByCode(PickeatCode pickeatCode);
     List<Pickeat> findByRoomId(Long roomId);
     List<Pickeat> findByRoomIdAndIsActive(Long roomId, Boolean isActive);
     List<Pickeat> findByRoomIdIn(List<Long> roomIds);
     Optional<Pickeat> findById(Long pickeatId);
+    boolean existsByIdAndCode(Long pickeatId, PickeatCode pickeatCode);
     Pickeat save(Pickeat pickeat);
 }
  • 장점: 쿼리 2→1, 불필요한 엔티티 로딩 제거, 캐시 영향 최소화
  • 트레이드오프: 리포지토리 구현·테스트 추가 필요 (API 명세 합의 요망)
    As per coding guidelines

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
backend/src/main/java/com/pickeat/backend/pickeat/domain/repository/PickeatRepository.java
lines 8-21: add a repository API to check existence by id and code to allow
single-query rejoin availability checks; add a method signature boolean
existsByIdAndCode(Long pickeatId, PickeatCode pickeatCode) to the interface and
implement it according to team standard (preferred: a JPA implementation using a
derived query or @Query, or Querydsl/native if that’s the repo convention) so
the service can replace the two-query flow with one exists check and avoid
loading the full entity.

Comment on lines 56 to 60
public List<RestaurantResponse> getPickeatRestaurants(String pickeatCode, Boolean isExcluded, Long participantId) {
Pickeat pickeat = getPickeatByCode(pickeatCode);
List<Restaurant> restaurants = restaurantRepository.findByPickeatAndIsExcludedIfProvided(pickeat, isExcluded);
List<Restaurant> restaurants = restaurantRepository.findByPickeatId(pickeat.getId());
List<Restaurant> targets = getTargets(restaurants, isExcluded);
List<RestaurantResponse> response = new ArrayList<>();
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

권한 검증 누락: participantId가 다른 Pickeat에 속해도 isLiked 노출 가능

현재 getPickeatRestaurantsparticipantId에 대한 소속 검증이 없어, 다른 방의 참가자 ID로 호출하면 좋아요 여부를 조회할 수 있습니다(정보 노출). exclude와 동일하게 소속 검증을 적용해 주세요. 또한 participantId가 null일 수 있다면 안전하게 처리하세요.

예시:

-    public List<RestaurantResponse> getPickeatRestaurants(String pickeatCode, Boolean isExcluded, Long participantId) {
+    public List<RestaurantResponse> getPickeatRestaurants(String pickeatCode, Boolean isExcluded, Long participantId) {
         Pickeat pickeat = getPickeatByCode(pickeatCode);
+        if (participantId != null) {
+            Participant participant = getParticipant(participantId);
+            if (!Objects.equals(participant.getPickeatId(), pickeat.getId())) {
+                throw new BusinessException(ErrorCode.PICKEAT_ACCESS_DENIED);
+            }
+        }

테스트 제안: (1) 동일 방 참가자 OK, (2) 다른 방 참가자 -> ACCESS_DENIED, (3) participantId=null -> isLiked=false로 정상 응답.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public List<RestaurantResponse> getPickeatRestaurants(String pickeatCode, Boolean isExcluded, Long participantId) {
Pickeat pickeat = getPickeatByCode(pickeatCode);
List<Restaurant> restaurants = restaurantRepository.findByPickeatAndIsExcludedIfProvided(pickeat, isExcluded);
List<Restaurant> restaurants = restaurantRepository.findByPickeatId(pickeat.getId());
List<Restaurant> targets = getTargets(restaurants, isExcluded);
List<RestaurantResponse> response = new ArrayList<>();
public List<RestaurantResponse> getPickeatRestaurants(String pickeatCode, Boolean isExcluded, Long participantId) {
Pickeat pickeat = getPickeatByCode(pickeatCode);
if (participantId != null) {
Participant participant = getParticipant(participantId);
if (!Objects.equals(participant.getPickeatId(), pickeat.getId())) {
throw new BusinessException(ErrorCode.PICKEAT_ACCESS_DENIED);
}
}
List<Restaurant> restaurants = restaurantRepository.findByPickeatId(pickeat.getId());
List<Restaurant> targets = getTargets(restaurants, isExcluded);
List<RestaurantResponse> response = new ArrayList<>();
🤖 Prompt for AI Agents
In
backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java
around lines 56 to 60, add ownership verification for participantId like the
exclude flow: if participantId is null, treat as anonymous and ensure isLiked is
always false; if participantId is provided, fetch the Participant and verify
participant.getPickeatId() (or participant.getPickeat().getId()) equals the
current pickeat.getId(), throwing an AccessDeniedException (or equivalent) when
it does not match; only after this check, compute and expose isLiked for
restaurants. Ensure null-safe handling and reuse existing participant
lookup/error types used elsewhere in the service.

Comment on lines 98 to 104
Participant participant = getParticipant(participantId);
Restaurant restaurant = getRestaurantById(restaurantId);
validateParticipantAccessToRestaurants(List.of(restaurant), participant);
restaurantLikeRepository.save(new RestaurantLike(participant, restaurant));
restaurant.like();
ParticipantLikes participantLikes = participantLikesRepository.findByRestaurantId(restaurantId);
restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
participantLikes.addParticipantId(participantId);
}
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 | 🔴 Critical

🧩 Analysis chain

좋아요 동시성: 중복 삽입 레이스 대비(DB 유니크 + 예외 매핑)

existsLike → save 사이에 경쟁이 생기면 중복 좋아요가 저장될 수 있습니다. (소프트삭제라면 부분 인덱스 필요) DB 유니크 제약과 DataIntegrityViolationException 매핑으로 방어하세요.

예시:

-        restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
+        try {
+            restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
+        } catch (DataIntegrityViolationException e) {
+            throw new BusinessException(ErrorCode.PARTICIPANT_RESTAURANT_ALREADY_LIKED);
+        }

DDL 제안(소프트삭제 컬럼이 deleted인 경우):

  • UNIQUE (restaurant_id, participant_id) WHERE deleted = false

테스트 제안: 멀티 스레드/멀티 요청에서 하나만 성공하는지, 실패는 동일 비즈니스 예외로 매핑되는지. As per coding guidelines.


🏁 Script executed:

#!/bin/bash
# Find the full likeRestaurant method in RestaurantService
find backend -name "RestaurantService.java" -type f | head -5

Length of output: 180


🏁 Script executed:

#!/bin/bash
# Get the full method context (lines around 98-104)
fd RestaurantService.java --type f && cat "$(fd RestaurantService.java --type f)" | head -150 | tail -80

Length of output: 401


🏁 Script executed:

#!/bin/bash
# Search for RestaurantLike entity definition
fd -e java -g "RestaurantLike.java" 

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Check for DataIntegrityViolationException handling in the codebase
rg -n "DataIntegrityViolationException" backend --type java -B 3 -A 3

Length of output: 1992


🏁 Script executed:

#!/bin/bash
# Look for UniqueConstraint or unique index on RestaurantLike
rg -n "@UniqueConstraint|@Unique|unique.*restaurant.*participant" backend --type java -B 2 -A 2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check ParticipantLikes entity and addParticipantId method
fd ParticipantLikes.java --type f

Length of output: 81


🏁 Script executed:

#!/bin/bash
# Search for existing concurrency tests related to likes
rg -n "like.*concurr|concurr.*like|thread|Thread" backend --type java -g "*test*" -i

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check for soft delete implementation (deleted column)
rg -n "deleted.*=.*false|WHERE.*deleted" backend --type java -B 2 -A 2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for existsLike or similar existence check
rg -n "existsLike|exists.*Like" backend --type java -B 3 -A 3

Length of output: 3682


🏁 Script executed:

#!/bin/bash
# Check RestaurantLike repository methods
fd RestaurantLikeRepository.java --type f

Length of output: 100


🏁 Script executed:

#!/bin/bash
# Get the full content of RestaurantService around the method
find backend -name "RestaurantService.java" -exec cat {} \; | grep -n -A 20 "def likeRestaurant\|void likeRestaurant\|public.*likeRestaurant"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# More comprehensive search with proper Java syntax
rg -n "likeRestaurant" backend/src/main/java --type java -B 2 -A 15

Length of output: 14352


🏁 Script executed:

#!/bin/bash
# Find all saveRestaurantLike or similar methods
rg -n "save.*[Ll]ike|add.*[Ll]ike" backend/src/main --type java -C 5

Length of output: 8948


🏁 Script executed:

#!/bin/bash
# Check DDL files for UNIQUE constraint
fd -e sql -e ddl | xargs rg -l "UNIQUE.*restaurant.*participant|restaurant_id.*participant_id.*unique" 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for application.yml or properties files defining JPA behavior
fd -e yml -e yaml -e properties | xargs grep -l "jpa\|hibernate" 2>/dev/null | head -3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check for exception handling patterns in service layer
rg -n "catch.*DataIntegrity|GlobalExceptionHandler|ExceptionHandler" backend/src/main/java --type java -B 1 -A 5

Length of output: 10593


🏁 Script executed:

#!/bin/bash
# Find ErrorCode enum to check if PARTICIPANT_RESTAURANT_ALREADY_LIKED exists
fd ErrorCode.java --type f && cat "$(fd ErrorCode.java --type f)"

Length of output: 2939


🏁 Script executed:

#!/bin/bash
# Look at the complete RestaurantService file
cat $(fd RestaurantService.java --type f --path "*/application/*")

Length of output: 233


🏁 Script executed:

#!/bin/bash
# Check imports in RestaurantService for exception handling
rg -n "import.*Exception|import.*javax.persistence|import.*org.springframework" "$(fd RestaurantService.java --type f)" | head -20

Length of output: 220


🏁 Script executed:

#!/bin/bash
# Search for findByRestaurantId method signature
rg -n "findByRestaurantId" backend --type java -B 2 -A 2

Length of output: 9781


🏁 Script executed:

#!/bin/bash
# Look for participantLikesRepository operations
rg -n "participantLikesRepository\." backend --type java -B 1 -A 1

Length of output: 6247


🏁 Script executed:

#!/bin/bash
# Check RestaurantLike entity definition comprehensively
fd RestaurantLike.java && cat "$(fd RestaurantLike.java)"

Length of output: 729


🏁 Script executed:

#!/bin/bash
# Search for all Like-related repositories and their methods
fd -e java "*LikeRepository.java" backend --type f -exec cat {} \;

Length of output: 446


🏁 Script executed:

#!/bin/bash
# Check test files for concurrency scenarios
find backend -path "*/test/*" -name "*RestaurantService*Test.java" -o -name "*Like*Test.java" | head -5

Length of output: 284


🏁 Script executed:

#!/bin/bash
# List test files related to restaurants or likes
fd -e java -g "*test*" -g "*Like*" backend | head -10

Length of output: 967


🏁 Script executed:

#!/bin/bash
# Get basic structure of the repository to understand paths
find backend -type f -name "*.java" -path "*/entity/*" -o -path "*/domain/*" | grep -i like | head -5

Length of output: 469


🏁 Script executed:

#!/bin/bash
# Final comprehensive search of the likeRestaurant method with full context
rg -n "def\s+likeRestaurant|void\s+likeRestaurant|public.*likeRestaurant" backend --type java -A 30

Length of output: 8027


🏁 Script executed:

#!/bin/bash
# Search for save method calls in restaurants to understand pattern
rg -n "restaurantLikeRepository\.save|participantLikesRepository\." backend/src/main/java --type java -B 5 -A 5 | head -100

Length of output: 11549


🏁 Script executed:

#!/bin/bash
# Check the exact structure - java files with specific patterns
rg -nU "(?s).*restaurantLikeRepository\.save.*participantLikes\.add" backend --type java -B 10 -A 5

Length of output: 30777


🏁 Script executed:

#!/bin/bash
# Direct inspection of RestaurantService methods around line 98-104
awk 'NR>=80 && NR<=150' "$(fd RestaurantService.java)" 2>/dev/null || echo "File not found, searching..."
fd RestaurantService.java && awk 'NR>=80 && NR<=150' "$(fd RestaurantService.java)"

Length of output: 460


🏁 Script executed:

#!/bin/bash
# Get the full RestaurantService file to understand complete context
fd "RestaurantService.java" -path "*/restaurant/application/*" --exec wc -l {} \;

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Show first 200 lines to find the method
fd "RestaurantService.java" -path "*/restaurant/application/*" --exec head -200 {} \;

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Search for the complete method with context using line numbers
file=$(fd "RestaurantService.java" -path "*/restaurant/application/*")
if [ -n "$file" ]; then
  echo "=== RestaurantService content (lines 80-130) ==="
  sed -n '80,130p' "$file"
  echo ""
  echo "=== Full file line count ==="
  wc -l "$file"
fi

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Look for any like-related methods
file=$(fd "RestaurantService.java" -path "*/restaurant/application/*")
if [ -n "$file" ]; then
  echo "=== Methods containing 'like' ==="
  grep -n "like\|Like" "$file" -i
fi

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Check entity definitions
rg -n "class RestaurantLike|class ParticipantLikes" backend --type java -A 20 | head -80

Length of output: 9973


🏁 Script executed:

#!/bin/bash
# Check for UniqueConstraint annotation more specifically
rg -n "@UniqueConstraint|@Column.*unique" backend --type java -B 5 -A 2 | grep -A 7 -B 5 "RestaurantLike\|restaurant_like"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Final direct approach - list all java files in restaurant package
find backend -path "*/restaurant/*" -name "*.java" -type f | sort

Length of output: 5783


동시성 제어: DB 유니크 제약과 예외 매핑으로 레이스 컨디션 방어 필수

existsLike() 체크(94번 라인)와 save() 호출(102번 라인) 사이에 경쟁 상태가 발생합니다. 두 요청이 동시에 existsLike()를 호출하면 모두 false를 반환받아 두 번째 체크를 통과하고, 결과적으로 중복된 좋아요가 저장될 수 있습니다.

문제점:

  • RestaurantLike 엔티티에 @UniqueConstraint 미설정
  • restaurantLikeRepository.save() 호출 시 DataIntegrityViolationException 처리 부재
  • 멀티스레드 시나리오의 테스트 케이스 부재

해결방안:

  1. DB 수준 제약 추가 (소프트삭제 컬럼이 deleted인 경우):

    CREATE UNIQUE INDEX idx_restaurant_like_unique 
    ON restaurant_like (restaurant_id, participant_id) 
    WHERE deleted = false
  2. 예외 매핑 추가 (RestaurantService의 like 메서드):

    try {
        restaurantLikeRepository.save(new RestaurantLike(participant.getId(), restaurant.getId()));
    } catch (DataIntegrityViolationException e) {
        throw new BusinessException(ErrorCode.PARTICIPANT_RESTAURANT_ALREADY_LIKED);
    }

    이는 PickeatResultService의 패턴과 일치합니다.

  3. 테스트 추가:

    • 멀티스레드에서 동일 레스토랑에 동시 좋아요 시도 시 정확히 하나만 성공하고 나머지는 실패하는지 검증
    • 실패 응답이 PARTICIPANT_RESTAURANT_ALREADY_LIKED 에러코드로 통일되는지 확인
🤖 Prompt for AI Agents
In
backend/src/main/java/com/pickeat/backend/restaurant/application/RestaurantService.java
around lines 98 to 104, there is a race between the existsLike() check and
restaurantLikeRepository.save() that can create duplicate likes; add a DB-level
unique constraint/index enforcing uniqueness on (restaurant_id, participant_id)
for non-deleted rows (e.g., WHERE deleted = false), and wrap the save() call in
a try/catch that catches DataIntegrityViolationException and rethrows a
BusinessException(ErrorCode.PARTICIPANT_RESTAURANT_ALREADY_LIKED) to map the DB
error to the domain error (consistent with PickeatResultService); finally add a
multithreaded test that concurrently attempts to like the same restaurant and
asserts exactly one success and others fail with
PARTICIPANT_RESTAURANT_ALREADY_LIKED.

Comment on lines 19 to +46
public void searchByLocation(LocationRestaurantRequest request, String pickeatCode) {
restaurantService.create(locationRestaurantSearchService.searchByLocation(request), pickeatCode);
warmUpCache(pickeatCode);
}

// Todo: 앞서 생성된 픽잇 삭제하는 작업에 대한 보상 필요[2025-09-25 17:33:13]
public void searchByWish(WishRestaurantRequest request, String pickeatCode) {
restaurantService.create(wishRestaurantSearchService.searchByWish(request), pickeatCode);
restaurantService.create(wishRestaurantSearchService.searchByWish(request),
pickeatCode);
warmUpCache(pickeatCode);
}

// Todo: 앞서 생성된 픽잇 삭제하는 작업에 대한 보상 필요[2025-09-25 17:33:13]
public void searchByTemplate(TemplateRestaurantRequest request, String pickeatCode) {
restaurantService.create(templateRestaurantSearchService.searchByTemplate(request), pickeatCode);
warmUpCache(pickeatCode);
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

트랜잭션 경계 밖 캐시 워밍으로 인한 정합성 문제 가능성

restaurantService.create()가 실패하여 롤백되더라도 warmUpCache()는 이미 실행되어 캐시가 생성됩니다. 이는 실제 DB에 없는 데이터가 캐시에 남는 문제를 유발할 수 있어요.

해결 방안:

  1. restaurantService.create() 내부에서 저장 성공 후 캐시 워밍 수행
  2. Spring의 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 활용해 커밋 성공 후에만 캐시 워밍
  3. 또는 캐시 워밍을 조회 시점으로 지연 (cache-aside 패턴)

예시 (이벤트 기반 접근):

// RestaurantService 내부
@Transactional
public void create(...) {
    // 저장 로직
    restaurantRepository.batchInsert(restaurants);
    // 이벤트 발행
    eventPublisher.publishEvent(new RestaurantsCreatedEvent(pickeat.getId()));
}

// 별도 리스너
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void warmUpCacheAfterCommit(RestaurantsCreatedEvent event) {
    // 캐시 워밍
}

현재 구조에서는 create가 실패해도 캐시는 워밍되어 불일치가 발생합니다. 트랜잭션 성공을 보장한 후 캐시 워밍을 수행하도록 개선해주세요.

Comment on lines +1 to +9
ALTER TABLE `pickeat_result`
DROP FOREIGN KEY `FKg5rkj84lmqukrfiuhos82thdx`;

ALTER TABLE `pickeat_result`
DROP FOREIGN KEY `FKlkhf7ufetto289l3lyx35f39o`;

ALTER TABLE `pickeat_result`
MODIFY COLUMN `pickeat_id` bigint NOT NULL,
MODIFY COLUMN `restaurant_id` bigint NOT NULL;
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 | 🔴 Critical

🧩 Analysis chain

NOT NULL 제약 추가 전 데이터 정합성 검증 필요

현재 마이그레이션은 pickeat_idrestaurant_idNOT NULL로 변경하고 있는데, 기존 테이블에 NULL 값이 존재할 경우 마이그레이션이 실패합니다.

개선 제안:

  1. NOT NULL 제약 추가 전에 NULL 값 존재 여부를 검증하는 체크 추가
  2. NULL 값이 있다면 기본값 할당 또는 데이터 정리 로직 선행
-- 안전한 마이그레이션 예시
-- Step 1: 데이터 검증 (선택적)
-- SELECT COUNT(*) FROM pickeat_result WHERE pickeat_id IS NULL OR restaurant_id IS NULL;

-- Step 2: NULL 값 처리 (필요시)
-- UPDATE pickeat_result SET pickeat_id = 0 WHERE pickeat_id IS NULL;

-- Step 3: 제약 조건 변경
ALTER TABLE `pickeat_result`
    MODIFY COLUMN `pickeat_id` bigint NOT NULL,
    MODIFY COLUMN `restaurant_id` bigint NOT NULL;

참고: 로컬에서 Flyway 테스트 완료하셨다고 하셨는데, 프로덕션 데이터 특성상 예외 케이스가 있을 수 있으니 배포 전 스테이징 환경에서 한 번 더 검증하시는 걸 추천드립니다.

또한 FK 제약을 제거한 후 재생성하지 않는 것이 의도적인지 확인이 필요합니다. 성능 최적화를 위한 것이라면 문서화하고, 아니라면 V14나 이후 마이그레이션에서 재생성하는 것을 고려해주세요.


🏁 Script executed:

#!/bin/bash
# V14 이후 마이그레이션에서 FK 재생성 여부 확인
fd "V1[4-9].*\.sql$" backend/src/main/resources/db/migration --exec cat {}

Length of output: 3821


NOT NULL 제약 추가 전 NULL 값 검증이 필수입니다

V13 마이그레이션이 실행될 때 pickeat_idrestaurant_id 컬럼에 NULL 값이 존재하면 마이그레이션이 실패합니다. 현재 스크립트에는 이에 대한 대응책이 없네요.

V14 마이그레이션에서 보이는 패턴처럼, 조건부 검증과 안전한 데이터 처리를 V13에 추가하세요:

-- Step 1: 데이터 상태 확인
SELECT COUNT(*) as null_count 
FROM pickeat_result 
WHERE pickeat_id IS NULL OR restaurant_id IS NULL;

-- Step 2: NULL 값 처리 (필요시)
UPDATE pickeat_result 
SET pickeat_id = -1, restaurant_id = -1 
WHERE pickeat_id IS NULL OR restaurant_id IS NULL;

-- Step 3: 제약 조건 변경
ALTER TABLE `pickeat_result`
    MODIFY COLUMN `pickeat_id` bigint NOT NULL,
    MODIFY COLUMN `restaurant_id` bigint NOT NULL;

참고: V14 마이그레이션을 확인한 결과, FK 제약이 재생성되지 않고 인덱스만 생성되고 있습니다. 이것이 의도적인 설계라면 (예: 성능 최적화), 커밋 메시지나 마이그레이션 주석에 명확히 기록해주세요. 그렇지 않다면 적절한 마이그레이션에서 FK를 재생성하는 것을 검토하세요.

배포 전에 반드시 스테이징 환경에서 전체 마이그레이션 시퀀스를 테스트하시기 바랍니다.

🤖 Prompt for AI Agents
backend/src/main/resources/db/migration/V13__update_pickeat_result_indirect_reference.sql
lines 1-9: 현재 V13 마이그레이션이 pickeat_id 와 restaurant_id 를 NOT NULL로 변경하지만 NULL 값 존재
시 실패하므로, 마이그레이션에 NULL 검사와 안전 처리 로직을 추가하세요 — 먼저 NULL 건수 확인 쿼리를 실행하여 존재 여부를
판단하고(배포/검토 시 출력용), NULL이 있으면 안전한 기본값으로 대체하거나(예: 임시 -1 또는 적절한 FK 연계값) 또는 명시적으로
실패하도록 처리한 후에 NOT NULL 변경을 적용하되 데이터 정합성 유지가 필요하면 FK 재생성 여부도 검토하여 주석으로 의도 기록 또는 이후
마이그레이션에서 FK를 재생성하도록 명시하세요.

Comment on lines +1 to +2
ALTER TABLE `participant`
DROP FOREIGN KEY `FKt0fy0xdd97i4u9om07urapp6g`;
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 | 🔴 Critical

🧩 Analysis chain

외래키 DROP도 인덱스 로직처럼 조건부 처리가 필요합니다.

좋은 소식부터: 라인 4-19의 인덱스 DROP과 21-35의 CREATE 로직에서 INFORMATION_SCHEMA를 통해 조건부로 처리하신 점이 탁월합니다! 🎯

다만 라인 1-2의 외래키 DROP은 여전히 무조건 실행되고 있습니다. 이렇게 하면:

  • 개발 환경에서 마이그레이션을 재실행할 때 오류 발생
  • 배포 재시도 시 같은 문제 재현
  • 마이그레이션의 멱등성(idempotency) 위반

인덱스 로직과 동일한 패턴으로 외래키도 보호해주세요:

SET @fk_name := 'FKt0fy0xdd97i4u9om07urapp6g';

SET @drop_fk_sql := (
    SELECT IF(EXISTS (
                      SELECT 1
                      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
                      WHERE TABLE_SCHEMA = DATABASE()
                        AND TABLE_NAME = 'participant'
                        AND CONSTRAINT_NAME = @fk_name
                        AND REFERENCED_TABLE_NAME IS NOT NULL
                  ),
                  CONCAT('ALTER TABLE `participant` DROP FOREIGN KEY `', @fk_name, '`'),
                  'SELECT 1')
);

PREPARE drop_fk_stmt FROM @drop_fk_sql;
EXECUTE drop_fk_stmt;
DEALLOCATE PREPARE drop_fk_stmt;

그럼 모든 DDL 작업이 일관되게 안전해집니다.

추가 정보: MySQL은 외래키를 제거할 때 자동으로 관련 인덱스를 제거하므로, 라인 4-19의 명시적 인덱스 DROP이 실제로 필요한지도 한 번 확인해보시길 권합니다.


검증을 위해 마이그레이션 파일 내용과 관련 정보를 확인하겠습니다.





🏁 Script executed:

#!/bin/bash
# 마이그레이션 파일 전체 내용 확인
cat backend/src/main/resources/db/migration/V14__drop_participant_pickeat_foreign_key.sql

Length of output: 1389


🏁 Script executed:

#!/bin/bash
# 비교용으로 V13 마이그레이션 파일 확인
if [ -f "backend/src/main/resources/db/migration/V13__drop_participant_pickeat_foreign_key.sql" ]; then
  cat backend/src/main/resources/db/migration/V13__drop_participant_pickeat_foreign_key.sql
fi

Length of output: 0


🏁 Script executed:

#!/bin/bash
# 참여자 테이블 관련 마이그레이션 파일 목록
fd -e sql . backend/src/main/resources/db/migration/ | grep -E "(participant|pickeat)" | sort

Length of output: 363


🌐 Web query:

MySQL외래키 제거 자동 인덱스 제거 동작

💡 Result:

요약: InnoDB는 FOREIGN KEY 제약을 만들 때(참조(자식) 테이블에) 해당 컬럼들로 시작하는 인덱스가 없으면 자동으로 인덱스를 생성합니다. 하지만 FOREIGN KEY를 제거(ALTER TABLE ... DROP FOREIGN KEY ...)해도 그 인덱스를 자동으로 삭제하지 않습니다. (다만 MySQL은 내부적으로 “해당 FK를 유지할 수 있는 다른 인덱스가 만들어지면 자동으로 생성된 인덱스를 조용히 제거할 수 있음”이라고 문서에 명시함.) 필요하면 ALTER TABLE ... DROP INDEX로 인덱스를 수동으로 제거하세요. [1][2]

참고: 자동 생성 인덱스에 대한 동작 설명(및 자동 삭제 예외)은 MySQL 공식 문서를 참조하세요. [1][2]

참고문헌
[1] MySQL Reference Manual — FOREIGN KEY Constraints. [2] 설명/실무 가이드(예: TablePlus 블로그).


라인 1-2 외래키 DROP은 반드시 조건부로 처리해야 합니다.

좋은 소식부터: 라인 4-19의 인덱스 DROP과 21-35의 CREATE 로직에서 INFORMATION_SCHEMA를 통해 탁월하게 조건부 처리하셨습니다! 🎯

문제: 라인 1-2의 외래키 DROP은 여전히 무조건 실행되고 있습니다.

이로 인한 영향:

  • 개발 환경에서 마이그레이션 재실행 시 FOREIGN KEY ... does not exist 오류
  • 배포 자동화 도구(Flyway/Liquibase)의 재시도 메커니즘 동작 불가
  • 다른 개발자의 로컬 환경에서 예측 불가능한 오류 발생

해결책: 인덱스 로직과 동일한 패턴으로 외래키도 보호하세요.

SET @fk_name := 'FKt0fy0xdd97i4u9om07urapp6g';

SET @drop_fk_sql := (
    SELECT IF(EXISTS (
                      SELECT 1
                      FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
                      WHERE CONSTRAINT_SCHEMA = DATABASE()
                        AND CONSTRAINT_NAME = @fk_name
                        AND TABLE_NAME = 'participant'
                  ),
                  CONCAT('ALTER TABLE `participant` DROP FOREIGN KEY `', @fk_name, '`'),
                  'SELECT 1')
);

PREPARE drop_fk_stmt FROM @drop_fk_sql;
EXECUTE drop_fk_stmt;
DEALLOCATE PREPARE drop_fk_stmt;

추가 학습: InnoDB는 FOREIGN KEY 제약을 만들 때 자동으로 인덱스를 생성하지만, FK를 제거해도 그 인덱스를 자동으로 삭제하지 않습니다. 따라서 라인 4-19의 명시적 인덱스 DROP이 필수이며, 현재 라인 21-35에서 idx_participant_pickeat_id를 새로 생성하시는 것도 정확한 설계입니다.

마이그레이션은 멱등성(idempotency)이 핵심이니, 모든 DDL 작업을 이 패턴으로 통일해 주세요. 🚀

Comment on lines +33 to 36
@Import({PickeatResultGenerator.class, PickeatResultService.class, RestaurantJdbcRepository.class,
RestaurantRepositoryImpl.class, RestaurantLikeRepositoryImpl.class, PickeatRepositoryImpl.class,
ParticipantRepositoryImpl.class, ParticipantLikesRepository.class, ParticipantLikesRepository.class})
public class PickeatResultServiceTest {
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 | 🟡 Minor

@import 중복 제거

ParticipantLikesRepository.class가 두 번 등록되어 있습니다. 불필요한 중복이니 제거해 주세요. 테스트 동작에는 영향 없습니다.

적용 예:

-@Import({PickeatResultGenerator.class, PickeatResultService.class, RestaurantJdbcRepository.class,
-        RestaurantRepositoryImpl.class, RestaurantLikeRepositoryImpl.class, PickeatRepositoryImpl.class,
-        ParticipantRepositoryImpl.class, ParticipantLikesRepository.class, ParticipantLikesRepository.class})
+@Import({PickeatResultGenerator.class, PickeatResultService.class, RestaurantJdbcRepository.class,
+        RestaurantRepositoryImpl.class, RestaurantLikeRepositoryImpl.class, PickeatRepositoryImpl.class,
+        ParticipantRepositoryImpl.class, ParticipantLikesRepository.class})
🤖 Prompt for AI Agents
In
backend/src/test/java/com/pickeat/backend/pickeat/application/PickeatResultServiceTest.java
around lines 33 to 36, the @Import annotation contains a duplicate entry of
ParticipantLikesRepository.class; remove the redundant second
ParticipantLikesRepository.class from the list so each repository/class is
imported only once, leaving the rest of the imports unchanged.

Comment on lines +81 to +89
Pickeat cachedPickeat = Mockito.mock(Pickeat.class);
when(cachedPickeat.getCode()).thenReturn(PICKEAT_CODE);

// when
repository.evictPickeatCache(cachedPickeat.getCode().toString());
repository.findByCode(PICKEAT_CODE);

// then
verify(pickeatJpaRepository, times(2)).findByCode(PICKEAT_CODE);
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 | 🔴 Critical

🧩 Analysis chain

캐시 키 일치성 보장: PickeatCode vs String 혼용은 잠재 리스크

테스트에서 findByCode(PickeatCode)로 저장하고 evictPickeatCache(String)으로 무효화합니다. 실제 구현의 @Cacheable/@CacheEvict 키 전략이 완전히 동일하지 않으면(예: #code vs #code.toString()) 운영에서 캐시가 남아 있을 수 있습니다. 현재 테스트는 통과하지만, 키 정합성을 타입 레벨로 고정하면 안전합니다.

테스트/구현 동시 정렬 예시:

  • 구현: @Cacheable(value=..., key="#code") / @CacheEvict(value=..., key="#code"), 시그니처 모두 PickeatCode.
  • 테스트:
-        repository.evictPickeatCache(cachedPickeat.getCode().toString());
+        repository.evictPickeatCache(PICKEAT_CODE);

추가 테스트 제안:

  • 서로 다른 타입 키(String vs PickeatCode)로는 무효화되지 않음을 검증(의도적 실패 케이스)하여 회귀 방지. As per coding guidelines.

원하시면 구현/테스트 쌍으로 키 전략을 통일하는 PR 보조 패치를 제안드릴게요.

Also applies to: 97-108


검증을 위해 실제 구현 코드를 확인하겠습니다.

캐시 키 정합성을 검증하기 위해 구현 코드를 살펴보겠습니다.<!-- [scratchpad_update]
[web_search result]
Spring 문서에 따르면: @Cacheable@CacheEvict의 키가 일치해야 캐시 무효화가 제대로 동작한다. 구체적으로:

  • 키 생성 전략이 동일해야 함 (#code vs #code.toString() 차이 중요)
  • 기본 KeyGenerator는 모든 파라미터를 SimpleKey로 생성
  • SpEL로 명시적 키 정의 시 양쪽이 정확히 일치해야 함

이제 실제 구현 코드를 확인해야 함.
[scratchpad_end] --><!-- [scratchpad_update]
[web_search result 2]
핵심 정보:

  1. Spring 캐시 키는 hashCode()와 equals()에 의존한다.
  2. PickeatCode와 String은 다른 타입이므로 equals()가 다르다.
  3. 기본 KeyGenerator는 SimpleKey를 사용하는데, 타입이 다르면 캐시 키가 다르다.
  4. @Cacheable@CacheEvict의 키가 일치하지 않으면 캐시 무효화가 실패한다.

리뷰 코멘트는 유효해 보인다. 하지만 실제 구현을 확인해야 한다:

  • PickeatRepositoryImpl의 @Cacheable/@CacheEvict 구현
  • PickeatCode의 equals()/hashCode() 구현
  • 테스트 코드 라인 81-89, 97-108
    [scratchpad_end] -->

이제 실제 코드를 검증하기 위해 저장소를 살펴보겠습니다.


🏁 Script executed:

#!/bin/bash

# 1. 테스트 파일의 전체 컨텍스트 확인 (70-110 라인)
echo "=== Test file context (lines 70-110) ==="
find . -type f -name "PickeatRepositoryImplCachingTest.java" -exec head -120 {} \; | tail -60

echo ""
echo "=== Searching for PickeatRepositoryImpl ==="
# 2. PickeatRepositoryImpl 파일 찾기
find . -type f -name "PickeatRepositoryImpl.java"

echo ""
echo "=== Searching for @Cacheable/@CacheEvict annotations ==="
# 3. Cacheable/CacheEvict 어노테이션 찾기
rg -n "@Cacheable|@CacheEvict" --type=java -C 2 backend/src/main

Length of output: 8453


🏁 Script executed:

#!/bin/bash

# 1. 테스트 파일에서 해당 라인 범위 확인
echo "=== Test file lines 70-110 ==="
find . -type f -name "PickeatRepositoryImplCachingTest.java" -exec sed -n '70,110p' {} \;

echo ""
echo "=== Searching for PickeatRepositoryImpl ==="
find . -type f -name "PickeatRepositoryImpl.java"

echo ""
echo "=== Searching for Pickeat cache annotation usage ==="
rg -n "@Cacheable|@CacheEvict" --type=java backend/src/main -A 2 -B 1 | head -50

Length of output: 6765


캐시 키 정합성 개선 필수: 타입 혼용과 키 표현식 불일치 해소

검증 결과, 리뷰 코멘트의 지적이 정확합니다. 현재 구현에서 캐시 키 전략이 일치하지 않습니다:

  • @Cacheable (라인 26): key = "#pickeatCode.value.toString()" ← PickeatCode 타입 파라미터
  • @CacheEvict (라인 56): key = "#pickeatCode" ← String 타입 파라미터

메서드 시그니처도 다르므로 (PickeatCode vs String), 두 메서드의 캐시 무효화 계약이 불명확합니다. 지금은 String 변환이 일치하여 우연히 작동하지만, 유지보수 시 회귀 위험이 큽니다.

개선 방향 (둘 중 선택):

  1. PickeatCode 통일안 (권장 - 타입 안정성):
@Cacheable(key = "#pickeatCode.value")  // .value만 사용하도록 단순화
public Optional<Pickeat> findByCode(PickeatCode pickeatCode)

@CacheEvict(key = "#pickeatCode.value")  // String 대신 PickeatCode
public void evictPickeatCache(PickeatCode pickeatCode)
  1. String 통일안 (심플함):
@Cacheable(key = "#pickeatCode")  // String 직접 사용으로 단순화
public Optional<Pickeat> findByCode(String pickeatCode)

@CacheEvict(key = "#pickeatCode")
public void evictPickeatCache(String pickeatCode)

테스트도 함께 정렬하여, 타입 일치성을 명시적으로 보장하세요. 참고로, 스프링 캐시는 SpEL 표현식을 기반으로 키를 생성하므로, 비명시적 변환은 예상 밖의 동작을 초래할 수 있습니다.

🤖 Prompt for AI Agents
In
backend/src/test/java/com/pickeat/backend/pickeat/infrastructure/PickeatRepositoryImplCachingTest.java
lines 81-89, the cache key expressions and method parameter types are
inconsistent between @Cacheable and @CacheEvict (one uses PickeatCode vs
String), causing fragile behavior; standardize to the PickeatCode-based
approach: change repository method signatures and their cache annotations to use
PickeatCode and the same SpEL key (e.g., key = "#pickeatCode.value"), update the
evict method to accept PickeatCode and use the identical key expression, and
adjust the test to pass/mocK a PickeatCode instance (or its value) so verify
calls use matching types/keys; ensure both annotation keys and method signatures
are aligned and update test assertions accordingly.

@jinu0328 jinu0328 marked this pull request as draft October 30, 2025 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: 진행 중

Development

Successfully merging this pull request may close these issues.

3 participants