로그인 사용자 대비 채팅 사용자 비율 계산
하루 동안 로그인 한 사용자와 채팅을 한 사용자 수를 저장 한 후
하루가 지나면 데이터를 기반으로 비율을 계산하여 db에 저장 한다.
Code
applicaton.properties
1
spring.datasource.url=jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
Table
1
2
3
4
5
6
7
8
9
CREATE TABLE stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
stat_date DATE NOT NULL, -- 해당 통계의 날짜
logged_in_users BIGINT DEFAULT 0, -- 하루 동안 로그인한 총 사용자 수
chat_users BIGINT DEFAULT 0, -- 하루 동안 채팅에 참여한 고유 사용자 수
participation_rate DECIMAL(5, 2) DEFAULT 0, -- 참여 비율 (ex. 10.00 → 10%)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (stat_date) -- 날짜별로 유일하게 설정
);
stat_date는 해당 통계의 날짜라면 created_at은 DB에 저장된 시점 의미한다.
stat_date가 2024-09-16이라면 created_at의 날짜는 2024-09-17이 될 것 이다.
Entity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stats {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private LocalDate statDate; // 통계 날짜
@Column
private Long loggedInUsers; // 로그인한 사용자 수
@Column
private Long chatUsers; // 채팅 참여 사용자 수
@Column
private Double participationRate; // 참여 비율
@Column
private LocalDateTime createdAt = LocalDateTime.now(); // 저장 시점
@Builder
public Stats(LocalDate date, Long users, Long chat, Double participationRate ){
this.statDate = date;
this.loggedInUsers = users;
this.chatUsers = chat;
this.participationRate = participationRate;
}
}
Redis Config
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
의존성 주입
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
@EnableCaching
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
Redis Cache를 이용한 사용자 수 집계
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RedisService {
private final RedisTemplate<String, String> redisTemplate;
private static final long STATSTIME = 26;
public void addLoginUserCount(String key, String nickname){
redisTemplate.opsForSet().add(key, nickname);
redisTemplate.expire(key, STATSTIME, TimeUnit.HOURS);
}
public void deleteLoginUserCount(String key){
redisTemplate.delete(key);
}
public void addChatUserCount(String key, String nickname){
redisTemplate.opsForSet().add(key, nickname);
redisTemplate.expire(key, STATSTIME, TimeUnit.HOURS);
}
public void deleteChatUserCount(String key){
redisTemplate.delete(key);
}
}
정의된 RedisTemplate<String, String>
설정을 사용하여 opsForSet()
을 통해 Redis의 Set 자료구조를 적용했다.
Redis를 이용하여 로그인 및 채팅 사용자 수를 집계하고, 26시간 동안 데이터를 유지하도록 설정했다.
Scheduler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RequiredArgsConstructor
@Slf4j
@EnableScheduling // 스케줄링 활성화
@Service
public class StatsScheduler {
private final RedisService redisService;
private final RedisTemplate<String, String> redisTemplate;
private final StatsRepository repository;
@Scheduled(cron = "0 5 0 * * *") // 매일 새벽 12시 5분에 실행
public void calculateDailyStats() {
try{
LocalDate now = LocalDate.now();
Long totalLoginUser = Optional.ofNullable(redisTemplate.opsForSet().size(now.minusDays(1) + "-LoginUser")).orElse(0L);
Long totalChatUser = Optional.ofNullable(redisTemplate.opsForSet().size(now.minusDays(1) + "-ChatUser")).orElse(0L);
double participationRate = 0;
if(totalLoginUser>0){
participationRate = ((double) totalChatUser / totalLoginUser) * 100;
}
Stats stats = Stats.builder()
.date(now.minusDays(1))
.users(totalLoginUser)
.chat(totalChatUser)
.participationRate(participationRate)
.build();
repository.save(stats);
redisService.deleteLoginUserCount(now.minusDays(1) + "-LoginUser");
redisService.deleteChatUserCount(now.minusDays(1) + "-ChatUser");
log.info("성공적으로 통계 저장 완료. 날짜: {}", stats.getStatDate());
}catch (Exception e){
log.error("일일 통계 계산 중 오류 발생 ", e);
}
}
}
매일 새벽 12시 5분에 Redis에 저장된 데이터를 기반으로 통계를 계산하고
DB에 저장한 후 Redis에서 해당 데이터를 삭제한다.
따라서 cron
표현식을 사용해 정해진 시간에 작업이 실행되게 했다.(@Scheduled(cron = "0 5 0 * * *")
)
결과
Test
테스트를 위해 데이터를 10분 간격으로 저장하고 stat_date
를 문자열로 변경했다.
테스트를 위해서 id값을 초기화했다.
1
ALTER TABLE Stats AUTO_INCREMENT = 1;
Entity 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stats {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String statDate; // 통계 날짜 (시간 포함)
@Column
private Long loggedInUsers; // 로그인한 사용자 수
@Column
private Long chatUsers; // 채팅 참여 사용자 수
@Column
private Double participationRate; // 참여 비율
@Column
private LocalDateTime createdAt = LocalDateTime.now();
@PrePersist
public void prePersist() {
// 서울 시간대로 변환 후 "yyyy-MM-dd HH:mm" 형식으로 포맷하여 저장
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
this.statDate = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
.format(formatter); // 포맷된 문자열 생성
}
@Builder
public Stats(Long users, Long chat, Double participationRate ){
this.loggedInUsers = users;
this.chatUsers = chat;
this.participationRate = participationRate;
}
}
test를 위해 date 대신 varchar 적용
*기존에는 날짜만 저장하기 때문에 date를 사용했지만 10분단위로 test를 해야하므로 시간까지 적용했다.
Scheduler 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@RequiredArgsConstructor
@Slf4j
@EnableScheduling // 스케줄링 활성화
@Service
public class StatsScheduler {
private final RedisTemplate<String, String> redisTemplate;
private final StatsRepository repository;
@Scheduled(fixedRate = 600000) // 10분(600,000ms) 간격으로 실행
public void calculateDailyStats() {
log.info("[stats scheduler] 스케줄러 실행");
try{
LocalDate now = LocalDate.now();
Long totalLoginUser = Optional.ofNullable(redisTemplate.opsForSet().size(now.getDayOfMonth() + "-LoginUser")).orElse(0L);
Long totalChatUser = Optional.ofNullable(redisTemplate.opsForSet().size(now.getDayOfMonth() + "-ChatUser")).orElse(0L);
double participationRate = 0;
if(totalLoginUser>0){
participationRate = ((double) totalChatUser / totalLoginUser) * 100;
}
Stats stats = Stats.builder()
.users(totalLoginUser)
.chat(totalChatUser)
.participationRate(participationRate)
.build();
repository.save(stats);
log.info("성공적으로 통계 저장 완료. 날짜: {}", stats.getStatDate());
}catch (Exception e){
log.error("일일 통계 계산 중 오류 발생 ", e);
}
}
}
@Scheduled(fixedRate = 600000)
정해진 시간이 아닌 10분 단위로 실행 하기 위해서 cron 대신 fixedRate를 사용했다.
fixedDelay와 fixedRate
fixedDelay는 작업이 완료된 시점부터 시간을 세기 때문에
작업이 오래 걸릴 경우 작업 간의 간격이 고정되기 때문에 안전하게 실행할 수 있다.
1
2
3
4
5
@Scheduled(fixedDelay = 5000)
public void hh() throws InterruptedException {
log.info("fixedDelay");
Thread.sleep(1000);
}
작업 시간(1초) + 대기 시간(5초) = 약 6초 단위로 출력되고 있다.
fixedRate는 작업이 시작된 시점부터 시간을 센다.
1
2
3
4
5
@Scheduled(fixedRate = 5000)
public void gg() throws InterruptedException {
log.info("fixedRate");
Thread.sleep(1000);
}
5초 단위로 출력되고 있다.
따라서 fixedDelay
는 해당 작업의 실행 시간이 긴 경우 사용하면 좋고
fixedRate
는 작업 간격을 일정하게 유지해야 하는 경우 사용하면 좋다.
지금은 메소드의 실행시간이 길지 않고 시간 단위로 분석하기 위해서 fixedRate
를 사용했다.
테스트 결과
- 로그인 x
- 로그인 o, 채팅 x
- 로그인 o, 채팅 o
REFERENCE