Skip to content
Merged
2 changes: 0 additions & 2 deletions src/backend/state_server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,10 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.kafka:spring-kafka'
implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.6'
Copy link
Member

Choose a reason for hiding this comment

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

아래에서 mapper를 사용하시던데 그렇다면 gson을 사용하시는 부분 있으신가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Kafka Producer에서 사용하고 있습니다!

Claude가 objectMapper와의 차이점을 다음과 같이 알려줬습니다!

ObjectMapper가 더 빠른 성능을 보이고 다양한 기능을 제공하는 반면, Gson은 더 간단하고 직관적인 사용법이 특징입니다.
Gson의 가장 큰 장점은 사용하기 쉽고 학습 곡선이 낮다는 점입니다. 단일 jar 파일로 제공되어 의존성 관리가 쉽고, null 값 처리도 자동으로 해주어 별도 설정이 필요 없습니다. 또한 라이브러리 크기가 작아 전체 애플리케이션 크기를 줄일 수 있습니다.
결국 선택은 프로젝트의 요구사항에 따라 달라집니다. 간단한 JSON 처리와 빠른 개발이 중요하다면 Gson을, 고성능과 세밀한 제어가 필요하다면 ObjectMapper를 사용하는 것이 좋습니다.

Kafka Produce 시에는 Json 데이터를 단순히 String으로 변환만 하기 때문에 gson도 괜찮아 보여서 계속 사용하고 있었습니다!

runtimeOnly 'org.postgresql:postgresql:42.7.4'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@ConfigurationPropertiesScan
@SpringBootApplication
public class StateServerApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.jootalkpia.state_server.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@RequiredArgsConstructor
@Configuration
public class RedisConfig {
private final RedisProperties redisProperties;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.host(), redisProperties.port());
}
@Bean
public RedisTemplate<String, String> stringOperRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());

return template;
}

@Bean
public RedisTemplate<String, Object> objectOperRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());

Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);

return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.jootalkpia.state_server.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "spring.data.redis")
public record RedisProperties(
String host,
int port
) {
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.jootalkpia.state_server.entity;

public class RedisKeys {
private static final String SESSION_PREFIX = "sessions";
private static final String USER_PREFIX = "user";
private static final String CHANNEL_PREFIX = "channel";
private static final String SUBSCRIBERS_PREFIX = "subscribers";
private static final String ACTIVE_PREFIX = "active";

public static String userSessions(String userId) {
return USER_PREFIX + ":" + userId + ":" + SESSION_PREFIX;
}

public static String channelActive(String channelId) {
return CHANNEL_PREFIX + ":" + channelId + ":" + ACTIVE_PREFIX;
}

public static String channelSubscribers(String channelId) {
return CHANNEL_PREFIX + ":" + channelId + ":" + SUBSCRIBERS_PREFIX;
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.jootalkpia.state_server.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jootalkpia.state_server.ChatMessageToKafka;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class KafkaConsumer {
private final StateService stateService;

@KafkaListener(
topics = "${topic.chat}",
Expand All @@ -20,11 +23,12 @@ public void processState(String kafkaMessage) {
ObjectMapper mapper = new ObjectMapper();

try {
ChatMessageToKafka chatMessageToKafka = mapper.readValue(kafkaMessage, ChatMessageToKafka.class);
JsonNode rootNode = mapper.readTree(kafkaMessage);
JsonNode commonNode = rootNode.get("common");
JsonNode messagesNode = rootNode.get("message");
Comment on lines +26 to +28
Copy link
Member

Choose a reason for hiding this comment

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

chatMessageToKafka.getCommon()
chatMessageToKafka.getMessage()

로는 안될까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

이 부분에 대해선 FCM 발송 로직 구현 때 테스트 해보겠습니다!


// 유저 상태 검증 로직
stateService.findNotificationTargets(commonNode.get("channelId").asText());

log.info("dto ===> " + chatMessageToKafka.toString());
} catch (Exception ex) {
log.error(ex.getMessage(), ex); // 추후에 GlobalException 처리
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.jootalkpia.state_server.service;

import com.jootalkpia.state_server.entity.RedisKeys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Service
@Slf4j
@RequiredArgsConstructor
public class StateService {
private final RedisTemplate<String, String> stringOperRedisTemplate;
private final RedisTemplate<String, Object> objectOperRedisTemplate;

public Set<String> findNotificationTargets(String channelId) {
Set<String> subscriber = findSubscribers(channelId);
Set<String> onlineSessions = findOnlineSessions(subscriber);
Set<String> activeSessions = findActiveSessions(channelId, subscriber);

log.info("[Notification Targets] Channel: {}", channelId);
log.info("├── Online Sessions: {}", onlineSessions);
log.info("├── Active Sessions: {}", activeSessions);

onlineSessions.removeAll(activeSessions);
log.info("└── Target Sessions: {}", onlineSessions);

return onlineSessions;
}

private Set<String> findSubscribers(String channelId) {
return stringOperRedisTemplate.opsForSet().members(RedisKeys.channelSubscribers(channelId));
}

private Set<String> findOnlineSessions(Set<String> subscriber) {
Set<String> onlineSessions = new HashSet<>();

for (String userId : subscriber) {
Set<String> userSessions = stringOperRedisTemplate.opsForSet()
.members(RedisKeys.userSessions(userId));
if (userSessions != null) {
onlineSessions.addAll(userSessions);
}
}

return onlineSessions;
}

private Set<String> findActiveSessions(String channelId, Set<String> subscriber) {
Set<String> activeSessions = new HashSet<>();

for (String userId : subscriber) {
Object userActiveSessions = objectOperRedisTemplate.opsForHash()
.get(RedisKeys.channelActive(channelId), userId);

if (userActiveSessions != null) {
Set<String> sessions = convertToSet(userActiveSessions);
sessions.forEach(session ->
activeSessions.add("\"" + session + "\""));
Copy link
Member

Choose a reason for hiding this comment

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

redis가 string인데 해당처럼 변환을 해줘야할까요?
activeSessions.add(session);
이렇게도 가능하지않을까요?

Copy link
Collaborator Author

@ki-met-hoon ki-met-hoon Feb 18, 2025

Choose a reason for hiding this comment

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

  • Redis 저장 시 데이터 타입 불일치로 인한 removeAll 연산 실패
    • userActive: Object 타입으로 저장됨 (Redis hash value)
    • onlineSession: String 타입으로 저장됨 (Redis set value)

따라서 두 값의 형식 차이로 인해 Set.removeAll() 연산이 정상 동작하지 않았기 때문에 변환을 했습니다!
보람님 방식으로 처음에 구현했으나 필터링이 되지 않아 변경했습니다!

Copy link
Member

Choose a reason for hiding this comment

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

아아 이해했습니다! 피알 내용이 이부분인지 이해를 못했었네요 고생하셨어요!

}
}

return activeSessions;
}

@SuppressWarnings("unchecked")
private Set<String> convertToSet(Object storedValue) {
if (storedValue instanceof Set) {
return (Set<String>) storedValue;
} else if (storedValue instanceof List) {
return new HashSet<>((List<String>) storedValue);
} else {
return new HashSet<>();
}
}
}
7 changes: 5 additions & 2 deletions src/backend/state_server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ spring:
acks: all # 멱등성 프로듀서를 위해 all로 설정, 0 또는 1이면 enable.idempotence=true 불가
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}

topic:
chat: jootalkpia.chat.prd.notification
chat: jootalkpia.chat.prd.message
group:
status: user-status-handle-consumer-group


server:
port: ${STATE_PORT}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified src/backend/workspace_server/.gradle/file-system.probe
Binary file not shown.