Skip to content

Conversation

@genius00hwan
Copy link
Contributor

@genius00hwan genius00hwan commented Nov 14, 2025

⚒️develop의 최신 커밋을 pull 받았나요?

#️⃣ 작업 내용

어떤 기능을 구현했나요?
기존 기능에서 어떤 점이 달라졌나요?
자세한 로직이 필요하다면 함께 적어주세요!
코드에 대한 설명이라면, 코맨트를 통해서 어떤 부분이 어떤 코드인지 설명해주세요!

단위기능에는 변화가 없습니다.

사용자 필터를 위한 redis 집합 연산이 원자적이지 않는 문제가 있었습니다.
트래픽이 낮은 지금은 문제가 없지만 여러 사용자가 동시에 라이프스타일을 수정해서 같은 집합을 수정한 경우엔 문제가 생길수 있습니다.

그래서 루아스크립트를 통해 원자적 연산을 수행합니다.

또, 트랜잭션이 커밋되면 레디스에 저장하게끔 커밋이벤트를 발행하고 레디스에 저장하게끔 설계했습니다.

동작 확인

기능을 실행했을 때 정상 동작하는지 여부를 확인하고 스샷을 올려주세요

라스 수정에 성공합니다.
수정성공

로컬서버에 필터 연산
로컬서버

운영서버에 필터 연산
운영서버

텍스트비교기: 개행이나 공백말곤 다른게 없어요
결과 비교한거

텍스트 비교 돌린거 ```json { "isSuccess": true, "code": "COMMON200", "message": "성공입니다.", "result": { "page": 0, "hasNext": true, "memberList": [ { { "memberDetail": { "memberId": 67, "nickname": "앙규", "gender": "MALE", "birthday": "2004-10-14", "universityName": "인하대학교", "universityId": 1, "majorName": "간호학과", "persona": 12 }, }, "equality": 82, "preferenceStats": [ { { "stat": "wakeUpTime", "value": "10", "color": "blue" }, { }, { "stat": "turnOffTime", "value": "23", "color": "blue" }, { }, { "stat": "heatingIntensity", "value": "약하게 틀어요", "color": "blue" }, { }, { "stat": "coolingIntensity", "value": "약하게 틀어요", "color": "blue" } ] } ] }, { "memberDetail": { "memberId": 66, "nickname": "이서준", "gender": "MALE", "birthday": "2004-09-09", "universityName": "인하대학교", "universityId": 1, "majorName": "스포츠과학과", "persona": 9 }, }, "equality": 75, "preferenceStats": [ { { "stat": "wakeUpTime", "value": "10", "color": "blue" }, { { "stat": "turnOffTime", "value": "23", "color": "blue" }, { { "stat": "heatingIntensity", "value": "약하게 틀어요", "color": "blue" }, { { "stat": "coolingIntensity", "value": "약하게 틀어요", "color": "blue" } ] }, { } ] }, { "memberDetail": { "memberId": 25, "nickname": "레오야", "gender": "MALE", "birthday": "2001-11-22", "universityName": "인하대학교", "universityId": 1, "majorName": "신소재공학과", "persona": 1 }, "equality": 68, "preferenceStats": [ { "stat": "wakeUpTime", "value": "9", "color": "red" }, }, { { "stat": "turnOffTime", "value": "12", "color": "red" }, { { "stat": "heatingIntensity", "value": "적당하게 틀어요", "color": "red" }, { { "stat": "coolingIntensity", "value": "약하게 틀어요", "color": "blue" } ] ] }, { "memberDetail": { "memberId": 73, "nickname": "강현", "gender": "MALE", "birthday": "2005-04-07", "universityName": "인하대학교", "universityId": 1, "majorName": "기계공학과", "persona": 1 }, }, "equality": 67, "preferenceStats": [ { { "stat": "wakeUpTime", "value": "6", "color": "red" }, }, { "stat": "turnOffTime", "value": "22", "color": "red" }, }, { "stat": "heatingIntensity", "value": "적당하게 틀어요", "color": "red" }, }, { "stat": "coolingIntensity", "value": "적당하게 틀어요", "color": "red" } ] }, { } ] }, { "memberDetail": { "memberId": 71, "nickname": "윌슨", "gender": "MALE", "birthday": "2003-07-08", "universityName": "인하대학교", "universityId": 1, "majorName": "경영학과", "persona": 1 }, }, "equality": 64, "preferenceStats": [ { { "stat": "wakeUpTime", "value": "7", "color": "red" }, { }, { "stat": "turnOffTime", "value": "22", "color": "red" }, { }, { "stat": "heatingIntensity", "value": "약하게 틀어요", "color": "blue" }, { }, { "stat": "coolingIntensity", "value": "적당하게 틀어요", "color": "red" } ] } ] } ] } }
</details>

## 💬 리뷰 요구사항(선택)
> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
> 고민사항도 적어주세요.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## 릴리스 노트

* **버그 수정**
  * 회원 탈퇴 시 관련된 모든 데이터가 완벽하게 정리되도록 개선되었습니다.
  * 캐시 및 라이프스타일 매칭 데이터 동기화 안정성이 향상되었습니다.

* **리팩터**
  * 회원 정보 처리 시스템의 내부 구조가 개선되어 데이터 일관성이 강화되었습니다.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@coderabbitai
Copy link

coderabbitai bot commented Nov 14, 2025

Walkthrough

이 변경사항은 멤버 통계 데이터의 라이프사이클을 이벤트 기반 아키텍처로 리팩토링합니다. MemberStat 생성/수정/삭제 시 각각의 도메인 이벤트를 발행하며, MemberStatEventListener가 이를 구독하여 Redis 캐시와 라이프스타일 매치율 데이터를 자동으로 관리합니다. Redis 캐시 연산을 Lua 스크립트 기반의 원자적 연산으로 개선하고, 멤버 탈퇴 시 관련 데이터를 일괄 정리하는 프로세스를 체계화합니다.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CommandService as MemberStatCommandService
    participant Repository as Repository
    participant EventPublisher as ApplicationEventPublisher
    participant EventListener as MemberStatEventListener
    participant CacheService as MemberStatCacheService
    participant MatchRateService as LifestyleMatchRateService

    Client->>CommandService: createMemberStat(...)
    CommandService->>Repository: save(memberStat)
    Repository-->>CommandService: savedMemberStat
    CommandService->>EventPublisher: publishEvent(MemberStatCreatedEvent)
    EventPublisher->>EventListener: onCreated(event)
    EventListener->>CacheService: saveByArgs(SaveCommand)
    CacheService-->>EventListener: ✓
    EventListener->>MatchRateService: saveLifestyleMatchRate(...)
    MatchRateService-->>EventListener: ✓
    EventListener-->>Client: ✓

    Client->>CommandService: modifyMemberStat(...)
    CommandService->>Repository: update(memberStat)
    Repository-->>CommandService: updatedMemberStat
    CommandService->>EventPublisher: publishEvent(MemberStatModifiedEvent)
    EventPublisher->>EventListener: onModified(event)
    EventListener->>CacheService: updateByArgs(UpdateCommand)
    CacheService-->>EventListener: ✓
    EventListener->>MatchRateService: updateLifestyleMatchRate(...)
    MatchRateService-->>EventListener: ✓
    EventListener-->>Client: ✓

    Client->>CommandService: deleteRelatedWithMember(memberId)
    CommandService->>Repository: deleteMemberStat(memberId)
    Repository-->>CommandService: Optional<MemberStat>
    alt MemberStat 존재
        CommandService->>EventPublisher: publishEvent(MemberStatDeleteEvent)
        EventPublisher->>EventListener: onDelete(event)
        EventListener->>CacheService: deleteByArgs(DeleteCommand)
        CacheService-->>EventListener: ✓
        EventListener->>MatchRateService: deleteAllMatchRateByMemberId(...)
        MatchRateService-->>EventListener: ✓
    end
    EventListener-->>Client: ✓
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45-60 minutes

주의가 필요한 영역:

  • MemberStatCacheService: Lua 스크립트 기반 upsert/delete 연산의 정확성 및 원자성 검증, 특히 lifestyle 키 확장 로직과 메모리 내 집합 교집합 연산의 정확성
  • MemberStatEventListener: 지수 백오프를 포함한 재시도 로직(최대 3회 시도), 각 이벤트 핸들러의 트랜잭션 경계 및 예외 처리 동작 확인
  • MemberStatCommandService: 이벤트 발행 타이밍과 저장소 트랜잭션의 상호작용, 마이그레이션 로직의 대량 데이터 처리 안정성
  • 캐시 일관성: 이벤트 기반 캐시 업데이트와 데이터베이스 상태의 동기화 보장, 특히 UpdateCommand에서의 oldAnswers/newAnswers 처리
  • 멀티 스레드 안전성: Redis 스크립트 실행과 동시성 제어 메커니즘

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.79% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description check ❓ Inconclusive PR 설명이 템플릿 구조는 따르고 있으나, 필수 섹션들이 불완전합니다. 작업 내용에서 구체적인 로직 설명과 코드 주석 부분을 상세히 추가하고, 동작 확인 섹션의 스크린샷 가독성을 개선해주세요.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 Redis 커스텀 인덱스 수정이라는 주요 변경사항을 명확하게 반영하고 있으며, 변경집합의 핵심 내용과 일치합니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/COZY-683

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

❤️ Share

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

@genius00hwan genius00hwan self-assigned this Nov 14, 2025
@genius00hwan genius00hwan added the enhancement New feature or request label Nov 14, 2025
Copy link

@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: 2

🧹 Nitpick comments (7)
src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatDeleteEvent.java (1)

5-10: LGTM!

이벤트 레코드 구조가 적절합니다. memberIdLong 타입인 것은 도메인 엔티티 ID를 나타내므로 올바른 설계입니다.

참고: Line 9의 Map<String,String>에서 쉼표 뒤 공백이 없는데, 다른 파일들(SaveCommand, UpdateCommand 등)에서는 Map<String, String>로 공백을 포함하고 있어 미묘한 스타일 불일치가 있습니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatCreatedEvent.java (1)

5-10: LGTM!

이벤트 레코드 구조가 적절하며, memberIdLong 타입으로 사용하는 것이 올바릅니다.

참고: 필드 순서가 MemberStatDeleteEvent(universityId가 첫 번째)와 다릅니다(memberId가 첫 번째). 레코드이므로 기능적 문제는 없지만, 일관성을 위해 이벤트 레코드들의 필드 순서를 통일하는 것을 고려해보세요.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatEventListener.java (1)

25-49: runWithRetry 동작 방식에 대한 의도 확인 제안

현재 runWithRetry가 예외를 캡처하고 로그만 남긴 뒤 상위로 전파하지 않기 때문에, Redis/매치율 처리 실패가 비즈니스 트랜잭션 결과(응답 성공/실패)에 전혀 영향을 주지 않는 ‘best-effort’ 패턴으로 동작합니다. 또, 최대 3회까지 Thread.sleep으로 호출 스레드를 블록하므로, 동기 이벤트 리스너인 현재 구조에서는 최악의 경우(100ms + 200ms) 만큼 요청 레이턴시가 증가할 수 있습니다.

  • 캐시/매치율 불일치가 어느 정도까지 허용 가능한지에 따라,
    • (1) 지금처럼 완전히 swallow 하는 방식,
    • (2) 특정 예외(예: 영구 실패)만 rethrow 해서 알림/롤백 대상으로 만드는 방식,
    • (3) @Async 리스너 + 모니터링(메트릭/알람) 조합
      중 어떤 모델이 팀 합의에 맞는지 한 번 정리해 두면 이후 운영 시 판단이 더 명확해질 것 같습니다.
src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberWithdrawService.java (2)

106-108: 매치율 삭제 로직의 중복 여부

deleteRelatedWithMember에서

  • lifestyleMatchRateRepository.deleteAllByMemberId(member.getId());

를 먼저 호출하고, 이후 MemberStatDeleteEvent를 발행하면 리스너의

  • lifestyleMatchRateService.deleteAllMatchRateByMemberId(e.memberId());

가 다시 한 번 동일한 삭제를 수행하게 됩니다.

동일 쿼리가 두 번 실행되는 정도라 큰 문제는 없겠지만, 책임을 “이벤트 리스너에서 일괄 정리”로 모으고 싶다면:

  • 여기의 직접 deleteAllByMemberId 호출을 제거하고 이벤트만 발행하거나,
  • 반대로 이벤트 리스너에서의 삭제를 제거하고 탈퇴 서비스 쪽에만 유지

하는 식으로 한쪽으로 정리해 두는 것도 고려해볼 만합니다.

Also applies to: 138-145


210-218: deleteMemberStat 주석과 실제 동작 불일치 (문서화 리팩터링)

주석에는 “redis 에서 MemberStat 삭제하고, DB에서 삭제”라고 되어 있지만, 실제 구현은 DB 삭제만 수행하고 Redis 삭제는 MemberStatDeleteEvent → 리스너를 통해 처리됩니다. 주석을 이벤트 기반 플로우에 맞게 갱신해 두면 후속 유지보수 시 혼동을 줄일 수 있을 것 같습니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/service/MemberStatCommandService.java (2)

48-69: modifyMemberStat의 newSnapshot 생성 단계를 단순화 가능

modifyMemberStat에서

Map<String, String> oldAnswers = MemberStatExtractor.extractAnswers(entity);
MemberStat newSnapshot = MemberStatConverter.toEntity(member, req);
Map<String, String> newAnswers = MemberStatExtractor.extractAnswers(newSnapshot);

entity.update(...);

처럼 별도의 newSnapshot을 만들어 newAnswers를 계산하고 있습니다. 기능적으로는 문제 없지만,

  • entity.update(...) 이후 동일한 MemberStatExtractor.extractAnswers(entity)를 호출해도 같은 결과를 얻을 수 있고,
  • 중간 스냅샷을 만들지 않으면 향후 update 로직과 toEntity 로직이 미묘하게 어긋나도 버그 발생 가능성을 줄일 수 있습니다.

예를 들면 다음과 같이 단순화할 수 있습니다:

- Map<String, String> oldAnswers = MemberStatExtractor.extractAnswers(entity);
- MemberStat newSnapshot = MemberStatConverter.toEntity(member, req);
- Map<String, String> newAnswers = MemberStatExtractor.extractAnswers(newSnapshot);
-
- entity.update(
+ Map<String, String> oldAnswers = MemberStatExtractor.extractAnswers(entity);
+
+ entity.update(
     MemberStatConverter.toMemberUniversityStatFromDto(req),
     MemberStatConverter.toLifestyleFromDto(req),
     req.selfIntroduction()
 );
+
+ Map<String, String> newAnswers = MemberStatExtractor.extractAnswers(entity);

이렇게 하면 코드가 더 단순해지고, 도메인 로직이 한 엔티티 인스턴스에 집중되어 유지보수가 쉬워집니다.


71-84: migrate 작업의 부하 및 실행 방식 고려

migrate()에서 모든 MemberStat을 읽어와 Redis에 일괄 반영하고, 마지막에 전체 매치율 재계산을 수행하는 구조는 데이터 양이 많지 않은 환경에서는 충분히 단순하고 이해하기 쉬운 접근입니다.

다만 운영 환경에서 MemberStat 수가 많아질 수 있다면:

  • 배치 크기를 나눠서 처리하거나,
  • 오프라인 배치/관리용 엔드포인트에서만 실행되도록 제약을 두고,
  • 실행 시간/에러 여부를 모니터링할 수 있는 로그/메트릭을 남기는 것

정도만 추가로 고려해 두면 실제 마이그레이션 시 리스크를 줄이는 데 도움이 될 것 같습니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 85abf51 and 346a1ab.

📒 Files selected for processing (13)
  • src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberWithdrawService.java (6 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/lifestylematchrate/redis/service/LifestyleMatchRateCacheService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/lifestylematchrate/repository/LifestyleMatchRateRepositoryService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/lifestylematchrate/service/LifestyleMatchRateService.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatCreatedEvent.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatDeleteEvent.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatEventListener.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatModifiedEvent.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/command/DeleteCommand.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/command/SaveCommand.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/command/UpdateCommand.java (1 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/service/MemberStatCacheService.java (3 hunks)
  • src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/service/MemberStatCommandService.java (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/service/MemberStatCommandService.java (3)
src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/service/MemberStatCacheService.java (1)
  • Slf4j (32-351)
src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/converter/MemberStatConverter.java (1)
  • Slf4j (31-388)
src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/util/MemberStatExtractor.java (1)
  • Slf4j (15-116)
🔇 Additional comments (12)
src/main/java/com/cozymate/cozymate_server/domain/memberstat/lifestylematchrate/repository/LifestyleMatchRateRepositoryService.java (1)

47-49: LGTM!

메서드 구현이 기존 클래스의 패턴을 잘 따르고 있습니다. 단순 위임 패턴으로 레포지토리에 삭제 작업을 전달하며, LifestyleMatchRateService의 멤버 삭제 플로우에서 적절히 사용됩니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/command/UpdateCommand.java (1)

6-11: LGTM!

oldAnswersnewAnswers를 모두 포함한 설계가 원자적 업데이트 연산에 적합합니다. PR의 목적인 Lua 스크립트 기반 원자적 연산을 지원하기 위한 올바른 구조입니다.

참고: memberIdString 타입인 것은 다른 커맨드들과 일관성이 있지만, 관련 이벤트(MemberStatModifiedEvent)와의 타입 불일치는 위의 다른 커맨드 리뷰에서 언급한 것처럼 확인이 필요합니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/lifestylematchrate/redis/service/LifestyleMatchRateCacheService.java (2)

84-98: LGTM!

deleteAllRelatedTo 메서드의 리팩토링이 잘 되었습니다:

  1. null 안전성: Lines 92-93에서 null 체크를 통해 NPE 방지
  2. 성능 개선: 모든 키를 수집한 후 단일 bulk delete 수행
  3. 불필요한 연산 방지: Line 95에서 빈 집합 체크로 불필요한 Redis 호출 방지
  4. 양방향 패턴 매칭: memberId:**:memberId 패턴으로 모든 관련 키 포착

이 구현은 기존 방식보다 더 효율적이고 안전합니다.


100-105: LGTM!

generateKey 메서드의 위치 이동은 단순 코드 정리입니다. 로직은 변경되지 않았으며, Math.min/Math.max를 사용하여 일관된 키 순서를 보장하는 구현은 올바릅니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/command/DeleteCommand.java (1)

5-9: 타입 변환이 의도적으로 처리되고 있음

검증 결과, MemberStatDeleteEventmemberId (Long)에서 DeleteCommandmemberId (String)로의 타입 변환은 MemberStatEventListener.java 83번 줄에서 명시적으로 .toString()을 사용하여 처리되고 있습니다. 이는 Redis 키 생성을 위한 의도된 설계이며, 타입 불일치나 누락된 변환이 없습니다.

Likely an incorrect or invalid review comment.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatModifiedEvent.java (1)

5-10: MemberStatModifiedEvent 레코드 정의 적절

수정 전/후 답변을 모두 포함하는 불변 이벤트 레코드로 잘 설계되어 있고, 필드 구성이 리스너에서 사용하는 데이터와 일치합니다. 현재 요구사항 기준으로 추가적인 보일러플레이트 없이 이 상태로 충분해 보입니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/event/MemberStatEventListener.java (1)

51-90: 이벤트 기반 캐시·매치율 동기화 플로우 일관성

onCreated/onModified/onDelete가 모두

  1. 캐시 연산(SaveCommand/UpdateCommand/DeleteCommand) →
  2. 매치율 서비스 호출
    순으로 처리되고, 세 연산 모두 동일한 AFTER_COMMIT + runWithRetry 패턴을 사용하고 있어 플로우가 일관적입니다. 멤버 생성/수정/탈퇴 케이스가 한 곳에서 정리되어 추후 유지보수도 용이해 보입니다.
src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/service/MemberStatCommandService.java (1)

34-45: 생성 시 이벤트 발행으로 책임 분리된 점 👍

createMemberStat에서 캐시/매치율 갱신을 직접 호출하지 않고, MemberStatCreatedEvent만 발행하도록 분리한 것은 관심사의 분리에 잘 맞는 구조로 보입니다. 엔티티 저장 후 MemberStatExtractor를 통해 일관된 answer 맵을 만들고, 나머지 처리는 리스너에 위임하는 방향이 명확합니다.

src/main/java/com/cozymate/cozymate_server/domain/memberstat/memberstat/redis/service/MemberStatCacheService.java (3)

49-67: Lua 스크립트 기반 upsert/delete 설계 적절

upsertScriptdeleteScript

  • 첫 번째 키를 pool,
  • 이후 키들을 lifestyle set으로 사용하고,
  • ARGV로 uid 및 remove/add 개수를 넘기는 구조가 Java 쪽 키 구성(poolKey + removes + adds)과 잘 맞습니다.

이전의 다중 SADD/SREM 연산을 하나의 Lua 스크립트로 묶어 원자성을 확보한 점이 이번 PR 목적(동시성 이슈 해소)에 정확히 부합해 보입니다.

Also applies to: 70-82, 84-108


135-159: 필터 + hasRoom 조합 로직 일관성 확인

filterUsersexcludeUsersInHasRoom 조합이 다음과 같이 잘 맞춰져 있는 것 같습니다.

  • getBasePool/getUnionedSetPerFilter 단계에서 normalizeUserId를 통해 "123"/123 포맷을 모두 정규화하여 digits-only 문자열 집합으로 유지
  • 교집합은 항상 이 정규화된 ID 기준으로 수행
  • hasRoom 세트는 encodeMemberId"123" 형태로 저장하지만, excludeUsersInHasRoom에서 다시 normalizeUserId를 거쳐 동일 포맷으로 비교
  • 최종적으로 List<Long>로 변환해 상위 서비스에 반환

따라서 ID 인코딩/디코딩 규칙이 읽기/쓰기 경로 전반에서 일관되게 유지되고 있고, hasRoom == true/false/null 세 가지 케이스도 모두 자연스럽게 처리되는 것으로 보입니다.

Also applies to: 161-191, 206-231


267-299: diffKeys의 빈 값 처리 로직이 캐시 갱신 실패를 초래할 수 있음 - 검증 필요

코드 분석 결과:

  1. extractAnswers의 동작 확인:

    • toStringOrEmpty(): null 값을 ""로 변환
    • Multi-value 필드 (sleepingHabits, personalities): 비트마스크 값이 0이면 빈 문자열 생성
  2. Lifestyle 엔티티 제약조건:

    • sleepingHabit: @Range(min = 1, max = 62) → 0은 범위 위반
    • personality: @Range(min = 1, max = 4095) → 0은 범위 위반
    • 주석: "1 : 잠버릇 없음"으로 명시 (1이 "선택 없음" 상태)
  3. 문제점:

    • 도메인 제약(@Range(min=1))이 모든 경로에서 강제되면 0/빈 값은 불가능
    • 하지만 검증이 우회되거나 레거시 데이터가 있다면, sleepingHabit=0 또는 personality=0 상태의 레코드 발생 가능
    • 이 경우 extractAnswers → diffKeys → 예외 발생 → MemberStatEventListener 3회 재시도 후 실패로 끝남
    • 결과: 해당 사용자의 Redis 인덱스가 영구적으로 오래된 상태 유지
  4. MemberUniversityStat 필드:

    • dormitoryName, numberOfRoommate, acceptance: @range 제약 없음 → null 가능
    • 이들은 빈 문자열이 의도된 동작일 수 있음

권장사항:

  • Lifestyle의 sleepingHabit/personality 값이 실제로 항상 >= 1인지 검증 필요
  • 혹은 diffKeys에서 이들 필드에 대해 빈 문자열을 "값 없음"으로 논리적으로 처리 (예외 대신)
  • 기존 데이터에 제약 위반 레코드가 있는지 확인
src/main/java/com/cozymate/cozymate_server/domain/member/service/MemberWithdrawService.java (1)

104-145: DB 삭제 후 LAZY 로딩 필드 접근으로 인한 LazyInitializationException 위험 확인

코드 검증 결과:

  1. MemberStat 엔티티의 LAZY 로딩 (라인 37):
    Member 필드가 @OneToOne(fetch = FetchType.LAZY)로 설정되어 있습니다.

  2. 삭제 순서 문제:

    • 104줄에서 deleteMemberStat(member.getId())로 DB에서 MemberStat 삭제
    • 반환된 Optional<MemberStat>의 엔티티에서 LAZY 필드(member) 미초기화 상태
    • 143줄 MemberStatExtractor.extractAnswers(stat) 호출 시 embedded 필드들과 함께 member.getUniversity() 같은 연관된 필드 접근 가능
  3. lifestyleMatchRate 중복 삭제 (선택적):

    • 107줄: lifestyleMatchRateRepository.deleteAllByMemberId(member.getId())
    • 이벤트 리스너(MemberStatEventListener 88줄): lifestyleMatchRateService.deleteAllMatchRateByMemberId(e.memberId())
      두 곳에서 중복으로 삭제 수행

개선 권장사항:

  • extractAnswers 호출을 DB 삭제 전으로 이동하거나, deleteMemberStat 내부에서 삭제 전 answers를 추출해 Optional<Map<String, String>> 형태로 반환
  • lifestyleMatchRate 중복 삭제 제거 (107줄 또는 이벤트 리스너 중 하나)

@Slf4j
@Component
@RequiredArgsConstructor
public class MemberStatEventListener {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

tx 커밋을 이벤트에 따라 동작하는 메소드들을 만들어놨습니다.
기본적을 3회까지 재시도합니다.

"for i=2,#KEYS do redis.call('SREM', KEYS[i], uid) end\n" +
"return 1\n";
deleteScript = new DefaultRedisScript<>(del, Long.class);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

원자적 연산을 위한 lua script

package com.cozymate.cozymate_server.domain.memberstat.memberstat.redis.command;

import java.util.Map;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

lua script 적용을 위해 command 객체를 만들었습니다.
answers를 문자열로 변환하기 위함입니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberStatCacheService {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

전반적으로 같은 기능을 하는데 Lua를 적용한걸로 바뀐 코드들입니다.

if (!val.isBlank() && !newSet.contains(val)) {
removeLifestyle(universityId, questionKey, val.trim(), userId);
}
private String encodeMemberId(String memberId) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

이 함수는 기존에 key 구조가
university:1:gender:1:lifestyle:wakeUpTime:2가 아니라university:1:gender:1:lifestyle:wakeUpTime:"2"로 되어있어서 그 구조를 유지하기 위함입니다. 기존 구조가 불명확해 수정해야하지만 이 pr이 머지된 다음에 마이그레이션하고 반영하도록 하겠습니다.

return "\"" + memberId + "\"";
}

private String normalizeUserId(String raw) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

위와 마찬가지긴 한데 key 구조를 둘다 적용시키기위해 정규화하는것입니다.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants