Skip to content

Commit ef8cead

Browse files
authored
Merge pull request #113 from prgrms-web-devcourse-final-project/Fix/109
Fix: WebSocket 세션 관리 Redis 통합 + 직렬화 문제 해결 (#109)
2 parents b6a2364 + 8a8057a commit ef8cead

File tree

6 files changed

+72
-89
lines changed

6 files changed

+72
-89
lines changed

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ dependencies {
6464
// Test
6565
testImplementation("org.springframework.boot:spring-boot-starter-test")
6666
testImplementation("org.springframework.security:spring-security-test")
67+
testImplementation("org.testcontainers:testcontainers:1.19.3")
68+
testImplementation("org.testcontainers:junit-jupiter:1.19.3")
6769
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
6870

6971
// Redis

src/main/java/com/back/global/config/EmbeddedRedisConfig.java

Lines changed: 0 additions & 69 deletions
This file was deleted.
Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.back.global.config;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.SerializationFeature;
5+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
36
import org.springframework.context.annotation.Bean;
47
import org.springframework.context.annotation.Configuration;
58
import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -13,24 +16,25 @@
1316
@Configuration
1417
public class RedisConfig {
1518

16-
/**
17-
* RedisTemplate 설정
18-
* - Key: String
19-
* - Value: JSON (Jackson)
20-
*/
2119
@Bean
2220
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
2321
RedisTemplate<String, Object> template = new RedisTemplate<>();
2422
template.setConnectionFactory(connectionFactory);
2523

26-
// Key: String
24+
// ObjectMapper 설정
25+
ObjectMapper objectMapper = new ObjectMapper();
26+
objectMapper.registerModule(new JavaTimeModule());
27+
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
28+
29+
// Jackson2JsonRedisSerializer 사용
30+
Jackson2JsonRedisSerializer<Object> serializer =
31+
new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
32+
2733
template.setKeySerializer(new StringRedisSerializer());
2834
template.setHashKeySerializer(new StringRedisSerializer());
29-
30-
// Value: JSON
31-
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
32-
template.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
35+
template.setValueSerializer(serializer);
36+
template.setHashValueSerializer(serializer);
3337

3438
return template;
3539
}
36-
}
40+
}

src/main/java/com/back/global/security/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
3636
.authorizeHttpRequests(
3737
auth -> auth
3838
.requestMatchers("/api/auth/**", "/oauth2/**", "/login/oauth2/**").permitAll()
39-
.requestMatchers("/api/ws/**").permitAll()
39+
.requestMatchers("api/ws/**", "/ws/**").permitAll()
4040
.requestMatchers("/api/rooms/*/messages/**").permitAll() //스터디 룸 내에 잡혀있어 있는 채팅 관련 전체 허용
4141
//.requestMatchers("/api/rooms/RoomChatApiControllerTest").permitAll() // 테스트용 임시 허용
4242
.requestMatchers("/","/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용

src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
import com.back.global.exception.CustomException;
44
import com.back.global.exception.ErrorCode;
55
import com.back.global.websocket.dto.WebSocketSessionInfo;
6+
import com.fasterxml.jackson.databind.DeserializationFeature;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
69
import lombok.RequiredArgsConstructor;
710
import lombok.extern.slf4j.Slf4j;
811
import org.springframework.data.redis.core.RedisTemplate;
912
import org.springframework.stereotype.Service;
1013

1114
import java.time.Duration;
15+
import java.util.LinkedHashMap;
1216
import java.util.Set;
1317
import java.util.stream.Collectors;
1418

@@ -24,7 +28,7 @@ public class WebSocketSessionManager {
2428
private static final String SESSION_USER_KEY = "ws:session:{}";
2529
private static final String ROOM_USERS_KEY = "ws:room:{}:users";
2630

27-
// TTL 설정 (10분) - Heartbeat와 함께 사용하여 정확한 상태 관리
31+
// TTL 설정 (10분)
2832
private static final int SESSION_TTL_MINUTES = 10;
2933

3034
// 사용자 세션 추가 (연결 시 호출)
@@ -70,7 +74,22 @@ public boolean isUserConnected(Long userId) {
7074
public WebSocketSessionInfo getSessionInfo(Long userId) {
7175
try {
7276
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
73-
return (WebSocketSessionInfo) redisTemplate.opsForValue().get(userKey);
77+
Object value = redisTemplate.opsForValue().get(userKey);
78+
79+
if (value == null) {
80+
return null;
81+
}
82+
83+
// LinkedHashMap으로 역직렬화된 경우 또는 타입이 맞지 않는 경우 변환
84+
if (value instanceof LinkedHashMap || !(value instanceof WebSocketSessionInfo)) {
85+
ObjectMapper mapper = new ObjectMapper();
86+
mapper.registerModule(new JavaTimeModule());
87+
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
88+
return mapper.convertValue(value, WebSocketSessionInfo.class);
89+
}
90+
91+
return (WebSocketSessionInfo) value;
92+
7493
} catch (Exception e) {
7594
log.error("세션 정보 조회 실패 - 사용자: {}", userId, e);
7695
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
@@ -106,7 +125,6 @@ public void updateLastActivity(Long userId) {
106125
log.warn("세션 정보가 없어 활동 시간 업데이트 실패 - 사용자: {}", userId);
107126
}
108127
} catch (CustomException e) {
109-
// 이미 처리된 CustomException은 다시 던짐
110128
throw e;
111129
} catch (Exception e) {
112130
log.error("사용자 활동 시간 업데이트 실패 - 사용자: {}", userId, e);
@@ -192,7 +210,7 @@ public Set<Long> getOnlineUsersInRoom(Long roomId) {
192210

193211
if (userIds != null) {
194212
return userIds.stream()
195-
.map(obj -> (Long) obj)
213+
.map(this::convertToLong) // 안전한 변환
196214
.collect(Collectors.toSet());
197215
}
198216
return Set.of();
@@ -220,16 +238,17 @@ public Long getUserCurrentRoomId(Long userId) {
220238
return sessionInfo != null ? sessionInfo.currentRoomId() : null;
221239
} catch (CustomException e) {
222240
log.error("사용자 현재 방 조회 실패 - 사용자: {}", userId, e);
223-
return null; // 조회용이므로 예외 대신 null 반환
241+
return null;
224242
}
225243
}
226244

227245
// 내부적으로 세션 제거 처리
228246
private void removeSessionInternal(String sessionId) {
229247
String sessionKey = SESSION_USER_KEY.replace("{}", sessionId);
230-
Long userId = (Long) redisTemplate.opsForValue().get(sessionKey);
248+
Object userIdObj = redisTemplate.opsForValue().get(sessionKey);
231249

232-
if (userId != null) {
250+
if (userIdObj != null) {
251+
Long userId = convertToLong(userIdObj); // 안전한 변환
233252
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
234253

235254
// 방에서 퇴장 처리
@@ -243,4 +262,15 @@ private void removeSessionInternal(String sessionId) {
243262
redisTemplate.delete(sessionKey);
244263
}
245264
}
265+
266+
// Object를 Long으로 안전하게 변환하는 헬퍼 메서드
267+
private Long convertToLong(Object obj) {
268+
if (obj instanceof Long) {
269+
return (Long) obj;
270+
} else if (obj instanceof Number) {
271+
return ((Number) obj).longValue();
272+
} else {
273+
throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long");
274+
}
275+
}
246276
}

src/test/java/com/back/global/config/RedisConnectionTest.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,29 @@
55
import org.springframework.boot.test.context.SpringBootTest;
66
import org.springframework.data.redis.core.StringRedisTemplate;
77
import org.springframework.test.context.ActiveProfiles;
8+
import org.springframework.test.context.DynamicPropertyRegistry;
9+
import org.springframework.test.context.DynamicPropertySource;
10+
import org.testcontainers.containers.GenericContainer;
11+
import org.testcontainers.junit.jupiter.Container;
12+
import org.testcontainers.junit.jupiter.Testcontainers;
813

914
import static org.assertj.core.api.Assertions.assertThat;
1015

1116
@SpringBootTest
17+
@Testcontainers
1218
@ActiveProfiles("test")
1319
class RedisConnectionTest {
1420

21+
@Container
22+
static GenericContainer<?> redis = new GenericContainer<>("redis:latest")
23+
.withExposedPorts(6379);
24+
25+
@DynamicPropertySource
26+
static void registerRedisProperties(DynamicPropertyRegistry registry) {
27+
registry.add("spring.data.redis.host", redis::getHost);
28+
registry.add("spring.data.redis.port", redis::getFirstMappedPort);
29+
}
30+
1531
@Autowired
1632
private StringRedisTemplate redisTemplate;
1733

@@ -28,4 +44,4 @@ void redisShouldStoreAndGetValue() {
2844
// then
2945
assertThat(result).isEqualTo(value);
3046
}
31-
}
47+
}

0 commit comments

Comments
 (0)