Skip to content

Commit 6d373b2

Browse files
authored
feat: 약속 30분 전 최초 스케줄링 책임 백엔드로 이전 (#963)
* feat: 특정 날짜 내 약속 조회 쿼리 구현 * feat: 약속 시간 30분 전 ETA 스케줄링 알림 전송 구현 * feat: ETA 스케줄링 알림 전송 시 약속 검증 추가 * feat: Notice 관련 도메인 분리 * chore: Redis Lettuce 설정 * feat: ETA 스케줄링 알림 및 Redis Keyspace Notifications 구현 * feat: 당일 약속 ETA 스케줄링 알림 등록 * feat: ETA 시에 스케줄링 캐시 업데이트 * test: 실패하는 테스트 코드 수정 * test: fixture 추가 및 레디스 캐시 클리너 적용 * test: 약속 시간별 스케줄링 테스트 케이스 추가 * refactor: 메서드 순서 변경 * test: RedisTestContainer Timeout 변경 * feat: 공지 발송 시 약속 시간 검증 추가 * feat: 사용하지 않는 Redisson 제거 * test: api call 동시성 테스트 disabled 처리 * test: 테스트 클래스명 변경 * fix: TestContainer ListeningPort 설정 추가 * refactor: EtaSchedulingRedisTemplate 상속으로 변경 * test: cleaner 메서드명 통일 * chore: ttl 만료 트리거 로그 수정 * test: 특정 시간에 실패하는 테스트 수정 * refactor: EtaSchedulingKey 생성 시 getMeetingTime() 사용하도록 수정 * refactor: EtaSchedulingRedisTemplate 필드명 수정 * refactor: isPast, isUpcoming TimeUtil 메서드로 분리 * refactor: [Start, End) 의미 함축한 메서드명 수정 * chore: 안드에서 해당 예외상황 처리하기로 합의 후 주석 제거 * refactor: scheduleEtaSchedulingNoticeIfUpcomingMeeting()으로 메서드명 수정 * test: 테스트케이스 이름 수정 * refactor: 콤마 추가 * refactor: 사용하지 않는 메서드 정리 * refactor: 디미터 법칙 준수하도록 meetingDateTime 호출 메서드 수정 * refactor: Fixture 정적 메서드 제거 * test: RedisTestContainer StartUpTimeout 30초로 원상복구 * test: API 호출 카운팅 동시성 테스트 @disabled 제거 * refactor: 메서드명 수정 * refactor: EtaSchedulingRedisTemplate service 레이어로 이동 * refactor: StringRedisTemplate 합성으로 변경
1 parent 2b2ff41 commit 6d373b2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+989
-185
lines changed

backend/build.gradle

+6-6
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,20 @@ repositories {
4343

4444
dependencies {
4545
implementation 'com.google.firebase:firebase-admin:9.2.0'
46-
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
4746
implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5'
47+
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
4848
implementation 'org.springframework.boot:spring-boot-starter'
4949
implementation 'org.springframework.boot:spring-boot-starter-web'
5050
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
5151
implementation 'org.springframework.boot:spring-boot-starter-validation'
52-
implementation "io.jsonwebtoken:jjwt:0.9.1"
53-
implementation 'javax.xml.bind:jaxb-api:2.3.1'
52+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
5453
implementation 'org.springframework.boot:spring-boot-starter-actuator'
55-
implementation 'org.flywaydb:flyway-mysql'
56-
implementation 'org.flywaydb:flyway-core'
5754
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
5855
implementation 'org.testcontainers:testcontainers-bom:1.20.2'
59-
implementation 'org.redisson:redisson-spring-boot-starter:3.38.1'
56+
implementation 'org.flywaydb:flyway-mysql'
57+
implementation 'org.flywaydb:flyway-core'
58+
implementation "io.jsonwebtoken:jjwt:0.9.1"
59+
implementation 'javax.xml.bind:jaxb-api:2.3.1'
6060

6161
runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
6262

backend/docker-compose-local.yml

+1
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ services:
2222
--save ""
2323
--appendonly yes
2424
--auto-aof-rewrite-percentage 0
25+
--notify-keyspace-events Ex
2526
environment:
2627
TZ: Asia/Seoul
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,36 @@
11
package com.ody.common.config;
22

3-
import java.time.Duration;
4-
import org.redisson.Redisson;
5-
import org.redisson.api.RedissonClient;
6-
import org.redisson.config.Config;
7-
import org.redisson.spring.data.connection.RedissonConnectionFactory;
83
import org.springframework.beans.factory.annotation.Value;
9-
import org.springframework.cache.CacheManager;
104
import org.springframework.cache.annotation.EnableCaching;
115
import org.springframework.context.annotation.Bean;
126
import org.springframework.context.annotation.Configuration;
137
import org.springframework.context.annotation.Profile;
14-
import org.springframework.data.redis.cache.RedisCacheConfiguration;
15-
import org.springframework.data.redis.cache.RedisCacheManager;
168
import org.springframework.data.redis.connection.RedisConnectionFactory;
17-
import org.springframework.data.redis.core.RedisTemplate;
18-
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
19-
import org.springframework.data.redis.serializer.RedisSerializationContext;
20-
import org.springframework.data.redis.serializer.StringRedisSerializer;
9+
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
10+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
11+
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
2112

2213
@Profile({"local", "dev", "prod"})
2314
@Configuration
2415
@EnableCaching
2516
public class RedisConfig {
2617

27-
private static final String REDISSON_HOST_PREFIX = "redis://";
28-
private static final int THREE_SECOND = 3000;
29-
3018
@Value("${spring.data.redis.host}")
3119
private String redisHost;
3220

3321
@Value("${spring.data.redis.port}")
3422
private int redisPort;
3523

3624
@Bean
37-
public RedissonClient redissonClient() {
38-
Config config = new Config();
39-
config.useSingleServer()
40-
.setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort)
41-
.setConnectTimeout(THREE_SECOND)
42-
.setRetryAttempts(3);
43-
44-
return Redisson.create(config);
45-
}
46-
47-
@Bean
48-
public RedisTemplate<String, Object> redisTemplate() {
49-
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
50-
redisTemplate.setConnectionFactory(new RedissonConnectionFactory(redissonClient()));
51-
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
52-
return redisTemplate;
53-
}
54-
55-
@Bean
56-
public RedisCacheConfiguration redisCacheConfiguration() {
57-
return RedisCacheConfiguration.defaultCacheConfig()
58-
.serializeKeysWith(
59-
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
60-
)
61-
.serializeValuesWith(
62-
RedisSerializationContext.SerializationPair.fromSerializer(
63-
new GenericJackson2JsonRedisSerializer()
64-
)
65-
)
66-
.entryTtl(Duration.ofHours(1L));
25+
public RedisConnectionFactory redisConnectionFactory() {
26+
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
27+
return new LettuceConnectionFactory(config);
6728
}
6829

6930
@Bean
70-
public CacheManager cacheManager(RedisConnectionFactory cf) {
71-
return RedisCacheManager.RedisCacheManagerBuilder
72-
.fromConnectionFactory(cf)
73-
.cacheDefaults(redisCacheConfiguration())
74-
.build();
31+
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
32+
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
33+
container.setConnectionFactory(redisConnectionFactory);
34+
return container;
7535
}
7636
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.ody.eta.domain;
2+
3+
import com.ody.common.exception.OdyServerErrorException;
4+
import com.ody.mate.domain.Mate;
5+
import java.time.LocalDateTime;
6+
import java.util.StringTokenizer;
7+
8+
public record EtaSchedulingKey(
9+
String deviceToken,
10+
long meetingId,
11+
LocalDateTime meetingDateTime
12+
) {
13+
14+
private static final String DELIMITER = "/";
15+
16+
public static EtaSchedulingKey from(Mate mate) {
17+
return new EtaSchedulingKey(
18+
mate.getMember().getDeviceToken().getValue(),
19+
mate.getMeeting().getId(),
20+
mate.getMeeting().getMeetingTime()
21+
);
22+
}
23+
24+
public static EtaSchedulingKey from(String key) {
25+
StringTokenizer tokenizer = new StringTokenizer(key, DELIMITER);
26+
if (tokenizer.countTokens() != 3) {
27+
throw new OdyServerErrorException("유효하지 않은 EtaSchedulingKey 입니다.");
28+
}
29+
return new EtaSchedulingKey(
30+
tokenizer.nextToken(),
31+
Long.parseLong(tokenizer.nextToken()),
32+
LocalDateTime.parse(tokenizer.nextToken())
33+
);
34+
}
35+
36+
public String serialize() {
37+
return deviceToken + DELIMITER
38+
+ meetingId + DELIMITER
39+
+ meetingDateTime;
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.ody.eta.service;
2+
3+
import com.ody.eta.domain.EtaSchedulingKey;
4+
import com.ody.mate.repository.MateRepository;
5+
import com.ody.meeting.domain.Meeting;
6+
import java.time.LocalDateTime;
7+
import java.util.concurrent.TimeUnit;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.data.redis.connection.RedisConnectionFactory;
11+
import org.springframework.data.redis.core.StringRedisTemplate;
12+
import org.springframework.stereotype.Component;
13+
14+
@Component
15+
public class EtaSchedulingRedisTemplate {
16+
17+
private final long ttlMs;
18+
private final MateRepository mateRepository;
19+
private final StringRedisTemplate redisTemplate;
20+
21+
@Autowired
22+
public EtaSchedulingRedisTemplate(
23+
RedisConnectionFactory redisConnectionFactory,
24+
@Value("${spring.data.redis.ttl}") long ttlMs,
25+
MateRepository mateRepository
26+
) {
27+
this.ttlMs = ttlMs;
28+
this.mateRepository = mateRepository;
29+
this.redisTemplate = new StringRedisTemplate(redisConnectionFactory);
30+
}
31+
32+
public void addAll(Meeting meeting) {
33+
mateRepository.findFetchedAllByMeetingId(meeting.getId())
34+
.forEach(mate -> add(EtaSchedulingKey.from(mate)));
35+
}
36+
37+
public void add(EtaSchedulingKey etaSchedulingKey) {
38+
redisTemplate.opsForValue()
39+
.set(
40+
etaSchedulingKey.serialize(),
41+
LocalDateTime.now().toString(),
42+
ttlMs,
43+
TimeUnit.MILLISECONDS
44+
);
45+
}
46+
47+
public String get(EtaSchedulingKey etaSchedulingKey) {
48+
return redisTemplate.opsForValue()
49+
.get(etaSchedulingKey.serialize());
50+
}
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.ody.eta.service;
2+
3+
import com.ody.eta.domain.EtaSchedulingKey;
4+
import com.ody.mate.domain.Mate;
5+
import com.ody.meeting.domain.Meeting;
6+
import com.ody.meeting.repository.MeetingRepository;
7+
import com.ody.notification.domain.FcmTopic;
8+
import com.ody.notification.domain.message.DirectMessage;
9+
import com.ody.notification.domain.message.GroupMessage;
10+
import com.ody.notification.domain.notice.EtaSchedulingNotice;
11+
import com.ody.notification.service.NoticeService;
12+
import com.ody.util.InstantConverter;
13+
import com.ody.util.TimeUtil;
14+
import java.time.Instant;
15+
import java.time.LocalDateTime;
16+
import lombok.RequiredArgsConstructor;
17+
import lombok.extern.slf4j.Slf4j;
18+
import org.springframework.scheduling.TaskScheduler;
19+
import org.springframework.stereotype.Service;
20+
21+
@Slf4j
22+
@Service
23+
@RequiredArgsConstructor
24+
public class EtaSchedulingService {
25+
26+
private static final long NOTICE_TIME_DEFER = 30L;
27+
28+
private final TaskScheduler taskScheduler;
29+
private final MeetingRepository meetingRepository;
30+
private final EtaSchedulingRedisTemplate etaSchedulingRedisTemplate;
31+
private final NoticeService noticeService;
32+
33+
public void sendNotice(Meeting meeting) {
34+
if (TimeUtil.isPast(meeting.getMeetingTime())) {
35+
return;
36+
}
37+
LocalDateTime noticeTime = meeting.getMeetingTime().minusMinutes(NOTICE_TIME_DEFER);
38+
EtaSchedulingNotice notice = new EtaSchedulingNotice(noticeTime, meeting.getId(), meeting.getMeetingTime());
39+
sendNowOrScheduleLater(noticeTime, () -> sendEtaSchedulingNoticeAndCache(notice));
40+
}
41+
42+
private void sendNowOrScheduleLater(LocalDateTime noticeTime, Runnable task) {
43+
if (TimeUtil.isUpcoming(noticeTime)) {
44+
Instant startTime = InstantConverter.kstToInstant(noticeTime);
45+
taskScheduler.schedule(task, startTime);
46+
return;
47+
}
48+
task.run();
49+
}
50+
51+
private void sendEtaSchedulingNoticeAndCache(EtaSchedulingNotice notice) {
52+
meetingRepository.findById(notice.getMeetingId())
53+
.ifPresent(meeting -> {
54+
GroupMessage groupMessage = GroupMessage.create(notice, new FcmTopic(meeting));
55+
noticeService.send(notice, groupMessage);
56+
etaSchedulingRedisTemplate.addAll(meeting);
57+
});
58+
}
59+
60+
public void sendFallbackNotice(EtaSchedulingKey etaSchedulingKey) {
61+
if (TimeUtil.isPast(etaSchedulingKey.meetingDateTime())) {
62+
return;
63+
}
64+
EtaSchedulingNotice notice = new EtaSchedulingNotice(TimeUtil.nowWithTrim(), etaSchedulingKey);
65+
DirectMessage directMessage = DirectMessage.create(notice, etaSchedulingKey.deviceToken());
66+
noticeService.send(notice, directMessage);
67+
etaSchedulingRedisTemplate.add(etaSchedulingKey);
68+
}
69+
70+
public void updateCache(Mate mate) {
71+
etaSchedulingRedisTemplate.add(EtaSchedulingKey.from(mate));
72+
}
73+
}

backend/src/main/java/com/ody/eta/service/EtaService.java

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class EtaService {
2828

2929
private final RouteService routeService;
3030
private final EtaRepository etaRepository;
31+
private final EtaSchedulingService etaSchedulingService;
3132

3233
@Transactional
3334
public Eta saveFirstEtaOfMate(Mate mate, RouteTime routeTime) {
@@ -40,6 +41,7 @@ public MateEtaResponsesV2 findAllMateEtas(MateEtaRequest mateEtaRequest, Mate ma
4041
Eta mateEta = findByMateId(mate.getId());
4142

4243
updateMateEta(mateEtaRequest, mateEta, meeting);
44+
etaSchedulingService.updateCache(mate);
4345

4446
return etaRepository.findAllByMeetingId(meeting.getId()).stream()
4547
.map(eta -> MateEtaResponseV2.of(eta, meeting))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.ody.eta.service;
2+
3+
import com.ody.eta.domain.EtaSchedulingKey;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.data.redis.connection.Message;
6+
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
7+
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
8+
import org.springframework.stereotype.Component;
9+
10+
@Slf4j
11+
@Component
12+
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {
13+
14+
private final EtaSchedulingService etaSchedulingService;
15+
16+
public RedisKeyExpiredListener(
17+
RedisMessageListenerContainer listenerContainer,
18+
EtaSchedulingService etaSchedulingService
19+
) {
20+
super(listenerContainer);
21+
this.etaSchedulingService = etaSchedulingService;
22+
}
23+
24+
@Override
25+
public void onMessage(Message message, byte[] pattern) {
26+
log.info("TTL 만료 트리거 동작 - redis key : " + message.toString());
27+
EtaSchedulingKey etaSchedulingKey = EtaSchedulingKey.from(message.toString());
28+
etaSchedulingService.sendFallbackNotice(etaSchedulingKey);
29+
}
30+
}

backend/src/main/java/com/ody/mate/repository/MateRepository.java

+10-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public interface MateRepository extends JpaRepository<Mate, Long> {
2121
select mate
2222
from Mate mate
2323
join fetch mate.member
24-
where mate.meeting.id = :meetingId
24+
where mate.meeting.id = :meetingId
2525
and mate.meeting.overdue = false
2626
""")
2727
List<Mate> findAllByOverdueFalseMeetingId(Long meetingId);
@@ -45,6 +45,15 @@ public interface MateRepository extends JpaRepository<Mate, Long> {
4545
""")
4646
List<Mate> findFetchedAllByMemberId(long memberId);
4747

48+
@Query("""
49+
select mate
50+
from Mate mate
51+
join fetch mate.member
52+
join fetch mate.meeting
53+
where mate.meeting.id = :meetingId
54+
""")
55+
List<Mate> findFetchedAllByMeetingId(long meetingId);
56+
4857
boolean existsByMeetingIdAndMemberId(Long meetingId, Long memberId);
4958

5059
int countByMeetingId(Long meetingId);

backend/src/main/java/com/ody/meeting/repository/MeetingRepository.java

+17
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.ody.meeting.repository;
22

33
import com.ody.meeting.domain.Meeting;
4+
import java.time.LocalDate;
5+
import java.time.LocalTime;
46
import java.util.List;
57
import java.util.Optional;
68
import org.springframework.data.jpa.repository.JpaRepository;
@@ -26,6 +28,21 @@ public interface MeetingRepository extends JpaRepository<Meeting, Long> {
2628
@Query("select m from Meeting m where m.overdue = true and CAST(m.updatedAt AS LOCALDATE) = CURRENT_DATE")
2729
List<Meeting> findAllByUpdatedTodayAndOverdue();
2830

31+
@Query("""
32+
select m
33+
from Meeting m
34+
where m.date > :startDate and m.date < :endDate
35+
or (m.date = :startDate and m.time >= :includeStartTime)
36+
or (m.date = :endDate and m.time < :excludeEndTime)
37+
"""
38+
)
39+
List<Meeting> findAllByDateTimeInClosedOpenRange(
40+
LocalDate startDate,
41+
LocalTime includeStartTime,
42+
LocalDate endDate,
43+
LocalTime excludeEndTime
44+
);
45+
2946
Optional<Meeting> findByIdAndOverdueFalse(Long id);
3047

3148
Optional<Meeting> findByInviteCode(String inviteCode);

0 commit comments

Comments
 (0)