Skip to content

Commit fee49af

Browse files
authored
Merge branch 'develop' into feat/kafka/1
2 parents 59b2e06 + a019b95 commit fee49af

File tree

14 files changed

+1145
-50
lines changed

14 files changed

+1145
-50
lines changed

src/main/java/org/dfbf/soundlink/domain/chat/repository/ChatRoomRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import org.dfbf.soundlink.domain.user.entity.User;
66
import org.springframework.data.jpa.repository.JpaRepository;
77

8+
import java.util.List;
9+
810
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long>, ChatRoomCustomRepository {
911
boolean existsByRequestUserIdAndRecordId(User requestUserId, EmotionRecord recordId);
12+
List<ChatRoom> findByRecordId(EmotionRecord recordId);
1013
}

src/main/java/org/dfbf/soundlink/domain/chat/service/ChatRoomService.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.dfbf.soundlink.domain.user.entity.User;
2323
import org.dfbf.soundlink.domain.user.exception.NoUserDataException;
2424
import org.dfbf.soundlink.domain.user.repository.UserRepository;
25+
import org.dfbf.soundlink.domain.user.service.UserStatusService;
2526
import org.dfbf.soundlink.global.comm.enums.RoomStatus;
2627
import org.dfbf.soundlink.global.exception.ErrorCode;
2728
import org.dfbf.soundlink.global.exception.ResponseResult;
@@ -34,8 +35,10 @@
3435

3536
import java.sql.Timestamp;
3637
import java.time.Duration;
38+
3739
import java.util.*;
3840

41+
3942
@Service
4043
@RequiredArgsConstructor
4144
@Slf4j
@@ -49,6 +52,7 @@ public class ChatRoomService {
4952
private final AlertService alertService;
5053
private final DevChatClient devChatClient;
5154
private final KafkaProducer kafkaProducer;
55+
private final UserStatusService userStatusService;
5256

5357
private static final String CHAT_REQUEST_KEY = "chatRequest";
5458
private static final String TOPIC = "alert-topic";
@@ -210,8 +214,10 @@ public ResponseResult createChatRoom(Long userId, Long recordId, String requestN
210214
if (chatRoomId.isPresent()) {
211215
Map<String, Object> map = new HashMap<>();
212216
map.put("chatRoomId", chatRoomId.get());
217+
213218
Alert alert = alertService.createAlert(requestUserId, "accept", map);
214219
kafkaProducer.send(TOPIC, alert);
220+
215221
return new ResponseResult(map);
216222
}
217223

@@ -241,7 +247,10 @@ public ResponseResult createChatRoom(Long userId, Long recordId, String requestN
241247
Alert alert = alertService.createAlert(requestUserId, "accept", map);
242248
kafkaProducer.send(TOPIC, alert);
243249

244-
return new ResponseResult(map);
250+
userStatusService.setChatting(userId, true);
251+
252+
return new ResponseResult(ErrorCode.SUCCESS, map);
253+
245254
} else {
246255
return new ResponseResult(400, "ChatRequest not found or expired.");
247256
}
@@ -271,6 +280,9 @@ public ResponseResult closeChatRoom(@AuthenticationPrincipal Long userId, Long c
271280
chatRoomRepository.save(chatRoom); // DB에 저장
272281

273282
redisTemplate.delete("Room::"+chatRoomId); // 레디스에서 삭제
283+
284+
userStatusService.setChatting(userId, false);
285+
274286
return new ResponseResult(ErrorCode.SUCCESS);
275287
} catch (Exception e) {
276288
return new ResponseResult(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage());
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package org.dfbf.soundlink.domain.emotionRecord.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.dfbf.soundlink.domain.emotionRecord.dto.response.EmotionRecordPageResponseDTO;
6+
import org.dfbf.soundlink.domain.emotionRecord.dto.response.EmotionRecordResponseMainDTO;
7+
import org.dfbf.soundlink.domain.emotionRecord.entity.EmotionRecord;
8+
import org.dfbf.soundlink.domain.emotionRecord.repository.EmotionRecordRepository;
9+
import org.dfbf.soundlink.global.comm.enums.Emotions;
10+
import org.dfbf.soundlink.global.util.CacheKeyGenerator;
11+
import org.springframework.data.domain.Page;
12+
import org.springframework.data.domain.PageRequest;
13+
import org.springframework.data.domain.Pageable;
14+
import org.springframework.data.domain.Sort;
15+
import org.springframework.data.redis.core.RedisTemplate;
16+
import org.springframework.stereotype.Service;
17+
18+
import java.util.*;
19+
20+
@Slf4j
21+
@Service
22+
@RequiredArgsConstructor
23+
public class EmotionRecordCacheService {
24+
25+
private final RedisTemplate<String, Object> redisTemplate;
26+
private final EmotionRecordRepository emotionRecordRepository;
27+
28+
/**
29+
* 캐시 조회 및 Fallback 처리
30+
* 조회 시, 캐시 키를 통해 데이터를 가져오고, 캐시 접근 오류 시,
31+
* DB에서 데이터를 조회한 결과를 캐시에 저장 후 반환
32+
*/
33+
public EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO> getEmotionRecords(
34+
Long userId,
35+
List<String> emotionList,
36+
String spotifyId,
37+
int page,
38+
int size) {
39+
40+
// emotion 별 개별 키 생성
41+
List<String> keys = CacheKeyGenerator.generateKeysForEmotions(userId, spotifyId, emotionList, page, size);
42+
43+
// multiGet을 사용하여 각 키에 해당하는 캐시 데이터를 가져옴
44+
List<Object> cachedResults = null;
45+
46+
try {
47+
cachedResults = redisTemplate.opsForValue().multiGet(keys);
48+
} catch (Exception e) {
49+
log.error("캐시 접근 오류 - keys {}: {}", keys, e.getMessage());
50+
}
51+
52+
// 캐시에서 결과가 모두 존재 시, (모든 감정에 대해 캐시 값이 있다면)
53+
// 여러 감정 조건에 따른 결과(각각 캐싱해 둔 것)를 합쳐서 반환
54+
if (cachedResults != null && cachedResults.stream().allMatch(Objects::nonNull)) {
55+
log.info("캐시 hit: {}", keys);
56+
57+
List<EmotionRecordResponseMainDTO> emotionRecords = new ArrayList<>();
58+
for (Object cachedResult : cachedResults) {
59+
@SuppressWarnings("unchecked")
60+
EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO> dto =
61+
(EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO>) cachedResult;
62+
emotionRecords.addAll(dto.records());
63+
}
64+
65+
int totalElements = emotionRecords.size();
66+
int totalPages = (int) Math.ceil(totalElements / (double) size);
67+
68+
// 결합된 결과와 새로 계산한 페이징 정보를 이용해 최종 DTO 생성
69+
return new EmotionRecordPageResponseDTO<>(emotionRecords, page, totalPages, totalElements);
70+
}
71+
log.info("캐시 miss 또는 일부 캐시 없음 - keys: {}", keys);
72+
73+
// Fallback(캐싱 실패 시) 처리 : DB에서 전체 데이터를 조회
74+
EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO> dbResult = fetchFromDB(userId, emotionList, spotifyId, page, size);
75+
76+
// 조회된 결과를 각 개별 캐시 키에 저장
77+
for (String key : keys) {
78+
storeInCache(key, dbResult);
79+
}
80+
81+
return dbResult;
82+
}
83+
84+
/**
85+
* 주어진 조건(userId, spotifyId, emotions)과 관련된 캐시 키를 모두 무효화(삭제)
86+
* 페이지(page)와 사이즈(size)도 결과 캐시에 영향을 미치므로 와일드카드(*)를 사용
87+
*/
88+
public void evictEmotionRecordCache(Long userId, String spotifyId, String emotion) {
89+
String keyPattern = generateCacheKeyPattern(userId, spotifyId, emotion);
90+
91+
// 패턴에 매칭되는 모든 키를 가져옴
92+
Set<String> keys = redisTemplate.keys(keyPattern);
93+
if (keys != null || !keys.isEmpty()) {
94+
redisTemplate.delete(keys);
95+
}
96+
}
97+
98+
/**
99+
* 캐시 키 패턴 생성 규칙
100+
* "user:{userId}:spotifyId:{spotifyId 또는 *}:emotion:{emotion 또는 *}:page:*:size:*"
101+
*/
102+
private String generateCacheKeyPattern(Long userId, String spotifyId, String emotion) {
103+
StringBuilder keyPattern = new StringBuilder("user:" + userId);
104+
105+
// spotifyId가 유효하면 그대로 사용 없으면 와일드 카드(*)
106+
if (spotifyId != null && !spotifyId.isBlank()) {
107+
keyPattern.append(":spotify:").append(spotifyId);
108+
} else {
109+
keyPattern.append(":spotify*");
110+
}
111+
112+
// emotion이 유효하면 그대로 사용 없으면 와일드 카드(*)
113+
if (emotion != null && !emotion.isEmpty()) {
114+
keyPattern.append(":emotion:").append(emotion);
115+
} else {
116+
keyPattern.append(":emotion:*");
117+
}
118+
119+
// 페이지, 사이즈는 결과에 따라 계속 바뀌므로 와일드카드 사용
120+
keyPattern.append(":page:*:size:*");
121+
122+
return keyPattern.toString();
123+
}
124+
125+
/**
126+
* DB에서 데이터를 조회
127+
*/
128+
private EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO> fetchFromDB(
129+
Long userId,
130+
List<String> emotions,
131+
String spotifyId,
132+
int page,
133+
int size) {
134+
Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending());
135+
List<Emotions> emotionEnums = new ArrayList<>();
136+
if (emotions != null && !emotions.isEmpty()) {
137+
emotionEnums = emotions.stream()
138+
.map(e -> {
139+
try {
140+
return Emotions.valueOf(e.toUpperCase());
141+
} catch (Exception ex) {
142+
throw new IllegalArgumentException("잘못된 감정 값이 포함되어 있습니다: " + e);
143+
}
144+
})
145+
.toList();
146+
}
147+
Page<EmotionRecord> recordsPage = emotionRecordRepository.findByFilters(userId, emotionEnums, spotifyId, pageable);
148+
List<EmotionRecordResponseMainDTO> dtoList = recordsPage.getContent().stream()
149+
.map(EmotionRecordResponseMainDTO::fromEntity)
150+
.toList();
151+
return EmotionRecordPageResponseDTO.fromPage(recordsPage, dtoList);
152+
}
153+
154+
/**
155+
* 캐시에 데이터 저장
156+
*/
157+
private void storeInCache(String key, EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO> dto) {
158+
try {
159+
redisTemplate.opsForValue().set(key, dto);
160+
log.info("캐시 데이터 저장 성공 - key: {}", key);
161+
} catch (Exception e) {
162+
log.error("캐시 데이터 저장 중 오류 발생 - key {}: {}", key, e.getMessage());
163+
}
164+
}
165+
}

src/main/java/org/dfbf/soundlink/domain/emotionRecord/service/EmotionRecordService.java

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import lombok.RequiredArgsConstructor;
44
import lombok.extern.slf4j.Slf4j;
5+
import org.dfbf.soundlink.domain.chat.entity.ChatRoom;
6+
import org.dfbf.soundlink.domain.chat.repository.ChatRoomRepository;
57
import org.dfbf.soundlink.domain.emotionRecord.dto.request.EmotionRecordRequestDTO;
68
import org.dfbf.soundlink.domain.emotionRecord.dto.request.EmotionRecordUpdateRequestDTO;
79
import org.dfbf.soundlink.domain.emotionRecord.dto.response.*;
@@ -14,7 +16,6 @@
1416
import org.dfbf.soundlink.domain.emotionRecord.repository.SpotifyMusicRepository;
1517
import org.dfbf.soundlink.domain.user.entity.User;
1618
import org.dfbf.soundlink.domain.user.repository.UserRepository;
17-
import org.dfbf.soundlink.global.comm.enums.Emotions;
1819
import org.dfbf.soundlink.global.exception.ErrorCode;
1920
import org.dfbf.soundlink.global.exception.ResponseResult;
2021
import org.springframework.dao.DataAccessException;
@@ -36,6 +37,9 @@ public class EmotionRecordService {
3637
private final EmotionRecordRepository emotionRecordRepository;
3738
private final UserRepository userRepository;
3839

40+
private final EmotionRecordCacheService emotionRecordCacheService;
41+
private final ChatRoomRepository chatRoomRepository;
42+
3943
@Transactional
4044
public ResponseResult saveEmotionRecordWithMusic(Long userId, EmotionRecordRequestDTO request) {
4145

@@ -63,6 +67,13 @@ public ResponseResult saveEmotionRecordWithMusic(Long userId, EmotionRecordReque
6367
.build();
6468
emotionRecordRepository.save(emotionRecord);
6569

70+
// 게시글 생성 시, 해당 조건에 맞는 캐시 키 삭제
71+
emotionRecordCacheService.evictEmotionRecordCache(
72+
userId,
73+
request.spotifyId(),
74+
request.emotion().name()
75+
);
76+
6677
return new ResponseResult(ErrorCode.SUCCESS);
6778
} catch (UserNotFoundException e) {
6879
return new ResponseResult(ErrorCode.FAIL_TO_FIND_USER, e.getMessage());
@@ -99,33 +110,22 @@ public ResponseResult getEmotionRecordsByLoginId(String userTag, int page, int s
99110
}
100111
}
101112

113+
// 동적 검색 (로그인 사용자를 제외한 EmotionRecord 조회) - 캐시 적용
114+
@Transactional(readOnly = true)
102115
public ResponseResult getEmotionRecordsExcludingUserIdByFilters(Long userId, List<String> emotionList, String spotifyId, int page, int size) {
103-
List<Emotions> emotionEnums = null;
104116
ResponseResult pageValidationResult = validateAndCreatePageable(page, size);
105117
if (pageValidationResult.getCode() != 200 /*SUCCESS*/) {
106118
return pageValidationResult;
107119
}
108120

109-
Pageable pageable = (Pageable) pageValidationResult.getData();
110-
111-
if (emotionList != null && !emotionList.isEmpty()) {
112-
try {
113-
emotionEnums = emotionList.stream()
114-
.map(e -> Emotions.valueOf(e.toUpperCase()))
115-
.toList();
116-
} catch (IllegalArgumentException e) {
117-
return new ResponseResult(ErrorCode.FAIL_TO_FIND_EMOTION, "잘못된 감정 값이 포함되어 있습니다.");
118-
}
119-
}
120-
121121
try {
122-
Page<EmotionRecord> recordsPage = emotionRecordRepository.findByFilters(userId, emotionEnums, spotifyId, pageable);
123-
List<EmotionRecordResponseMainDTO> dtoList = recordsPage.getContent()
124-
.stream()
125-
.map(EmotionRecordResponseMainDTO::fromEntity)
126-
.toList();
127-
128-
return new ResponseResult(ErrorCode.SUCCESS, EmotionRecordPageResponseDTO.fromPage(recordsPage, dtoList));
122+
// EmotionRecordCacheService의 결합 캐시 조회 및 Fallback 처리
123+
EmotionRecordPageResponseDTO<EmotionRecordResponseMainDTO> result =
124+
emotionRecordCacheService.getEmotionRecords(userId, emotionList, spotifyId, page, size);
125+
return new ResponseResult(ErrorCode.SUCCESS, result);
126+
} catch (IllegalArgumentException e) {
127+
// 잘못된 감정 값이 포함된 경우
128+
return new ResponseResult(ErrorCode.FAIL_TO_FIND_EMOTION, e.getMessage());
129129
} catch (DataAccessException e) {
130130
return new ResponseResult(ErrorCode.DB_ERROR, e.getMessage());
131131
} catch (Exception e) {
@@ -172,7 +172,7 @@ public ResponseResult getVideoIdBySpotifyId(String spotifyId) {
172172
public ResponseResult updateEmotionRecord(Long recordId, EmotionRecordUpdateRequestDTO updateDTO) {
173173
try {
174174
// 기존 감정 기록 조회
175-
EmotionRecord record = emotionRecordRepository.findByRecordId(recordId)
175+
EmotionRecord emotionRecord = emotionRecordRepository.findByRecordId(recordId)
176176
.orElseThrow(EmotionRecordNotFoundException::new);
177177

178178
// SpotifyMusic이 DB에 있는지 먼저 확인
@@ -190,11 +190,17 @@ public ResponseResult updateEmotionRecord(Long recordId, EmotionRecordUpdateRequ
190190
return spotifyMusicRepository.save(newMusic);
191191
});
192192

193-
record.updateEmotionRecord(updateDTO.emotion(), updateDTO.comment(), spotifyMusic);
193+
emotionRecord.updateEmotionRecord(updateDTO.emotion(), updateDTO.comment(), spotifyMusic);
194194

195195
// 수정된 정보를 Response DTO로 변환
196-
EmotionRecordUpdateResponseDTO responseDTO = EmotionRecordUpdateResponseDTO.fromEntity(record);
197-
196+
EmotionRecordUpdateResponseDTO responseDTO = EmotionRecordUpdateResponseDTO.fromEntity(emotionRecord);
197+
198+
// 게시글 수정 시, 해당 조건에 맞는 캐시 키 삭제
199+
emotionRecordCacheService.evictEmotionRecordCache(
200+
emotionRecord.getUser().getUserId(),
201+
updateDTO.spotifyId(),
202+
updateDTO.emotion()
203+
);
198204
return new ResponseResult(ErrorCode.SUCCESS, responseDTO);
199205
} catch (EmotionRecordNotFoundException e) {
200206
return new ResponseResult(ErrorCode.FAIL_TO_FIND_EMOTION_RECORD, e.getMessage());
@@ -208,7 +214,27 @@ public ResponseResult updateEmotionRecord(Long recordId, EmotionRecordUpdateRequ
208214
@Transactional
209215
public ResponseResult deleteEmotionRecord(Long recordId) {
210216
try {
217+
EmotionRecord emotionRecord = emotionRecordRepository.findByRecordId(recordId)
218+
.orElseThrow(EmotionRecordNotFoundException::new);
219+
220+
// 해당 EmotionRecord를 참조하는 채팅방 조회
221+
List<ChatRoom> chatRooms = chatRoomRepository.findByRecordId(emotionRecord);
222+
223+
// 채팅방이 존재하면 모두 삭제 후 삭제 내용을 즉시 반영
224+
if (chatRooms != null && !chatRooms.isEmpty()) {
225+
chatRoomRepository.deleteAll(chatRooms);
226+
chatRoomRepository.flush(); // 즉시 DB에 반영
227+
}
228+
211229
int deletedCount = emotionRecordRepository.deleteByRecordId(recordId);
230+
emotionRecordRepository.flush(); // 즉시 DB에 반영
231+
232+
// 게시글 삭제 시, 해당 조건에 맞는 캐시 키 삭제
233+
emotionRecordCacheService.evictEmotionRecordCache(
234+
emotionRecord.getUser().getUserId(),
235+
emotionRecord.getSpotifyMusic().getSpotifyId(),
236+
emotionRecord.getEmotion().name()
237+
);
212238

213239
// 삭제할 데이터가 없는 경우
214240
if (deletedCount == 0) {

0 commit comments

Comments
 (0)