diff --git a/backend/build.gradle b/backend/build.gradle index 277b3e737..18a628fba 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -38,6 +38,9 @@ dependencies { implementation 'org.springframework.security:spring-security-crypto' implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.github.codemonstur:embedded-redis:1.4.3' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/backend/src/main/java/kr/momo/config/EmbeddedRedisConfig.java b/backend/src/main/java/kr/momo/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..07a6623ce --- /dev/null +++ b/backend/src/main/java/kr/momo/config/EmbeddedRedisConfig.java @@ -0,0 +1,32 @@ +package kr.momo.config; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import redis.embedded.RedisServer; + +@Configuration +@Profile("local") +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void start() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void stop() throws IOException { + if (redisServer.isActive()) { + redisServer.stop(); + } + } +} diff --git a/backend/src/main/java/kr/momo/config/RedisConfig.java b/backend/src/main/java/kr/momo/config/RedisConfig.java new file mode 100644 index 000000000..4d3fde403 --- /dev/null +++ b/backend/src/main/java/kr/momo/config/RedisConfig.java @@ -0,0 +1,32 @@ +package kr.momo.config; + +import java.time.Duration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; +import org.springframework.data.redis.serializer.RedisSerializer; + +@EnableCaching +@Configuration +public class RedisConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration cacheConfiguration = getCacheConfiguration(); + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } + + private RedisCacheConfiguration getCacheConfiguration() { + return RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(SerializationPair.fromSerializer(RedisSerializer.string())) + .serializeValuesWith(SerializationPair.fromSerializer(RedisSerializer.json())) + .entryTtl(Duration.ofMinutes(10)) + .enableTimeToIdle(); + } +} diff --git a/backend/src/main/java/kr/momo/config/constant/CacheType.java b/backend/src/main/java/kr/momo/config/constant/CacheType.java new file mode 100644 index 000000000..a5e21688a --- /dev/null +++ b/backend/src/main/java/kr/momo/config/constant/CacheType.java @@ -0,0 +1,14 @@ +package kr.momo.config.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + + SCHEDULES_STORE("schedules-store"), + RECOMMEND_STORE("recommend-store"); + + private final String name; +} diff --git a/backend/src/main/java/kr/momo/exception/code/CacheErrorCode.java b/backend/src/main/java/kr/momo/exception/code/CacheErrorCode.java new file mode 100644 index 000000000..2659c8116 --- /dev/null +++ b/backend/src/main/java/kr/momo/exception/code/CacheErrorCode.java @@ -0,0 +1,33 @@ +package kr.momo.exception.code; + +import org.springframework.http.HttpStatus; + +public enum CacheErrorCode implements ErrorCodeType { + + CACHE_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."), + CACHE_JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."), + DATA_DESERIALIZATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 변환 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); + + private final HttpStatus httpStatus; + private final String message; + + CacheErrorCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String message() { + return message; + } + + @Override + public String errorCode() { + return name(); + } +} diff --git a/backend/src/main/java/kr/momo/service/schedule/ScheduleCache.java b/backend/src/main/java/kr/momo/service/schedule/ScheduleCache.java new file mode 100644 index 000000000..32929eabb --- /dev/null +++ b/backend/src/main/java/kr/momo/service/schedule/ScheduleCache.java @@ -0,0 +1,80 @@ +package kr.momo.service.schedule; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.momo.config.constant.CacheType; +import kr.momo.exception.MomoException; +import kr.momo.exception.code.CacheErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ScheduleCache { + + public static final String INVALID_STATUS = "invalid"; + + private final ObjectMapper objectMapper; + private final CacheManager cacheManager; + + public boolean isHit(CacheType cacheType, String key) { + Cache cache = cacheManager.getCache(cacheType.getName()); + if (cache == null) { + return false; + } + String json = cache.get(key, String.class); + return json != null && !INVALID_STATUS.equals(json); + } + + public T get(CacheType cacheType, String key, Class clazz) { + String cacheName = cacheType.getName(); + Cache cache = cacheManager.getCache(cacheName); + validateCacheNotNull(key, cache); + String value = cache.get(key, String.class); + log.debug("CACHE NAME: {}, KEY: {}, STATE: HIT", cacheName, key); + return convertObject(cacheName, key, clazz, value); + } + + private void validateCacheNotNull(String key, Cache cache) { + if (cache == null || cache.get(key, String.class) == null) { + throw new MomoException(CacheErrorCode.CACHE_NOT_FOUND); + } + } + + private T convertObject(String cacheName, String key, Class clazz, String value) { + try { + return objectMapper.readValue(value, clazz); + } catch (JsonProcessingException e) { + log.error("캐시 값을 JSON으로 변환하는데 실패했습니다. CACHE NAME: {}, KEY: {}", cacheName, key); + throw new MomoException(CacheErrorCode.CACHE_JSON_PROCESSING_ERROR); + } + } + + public void put(CacheType cacheType, String key, T value) { + String cacheName = cacheType.getName(); + Cache cache = cacheManager.getCache(cacheName); + if (cache == null) { + log.error("캐싱에 해당하는 이름이 존재하지 않습니다. 캐싱 이름: {}", cacheName); + return; + } + log.debug("CACHE NAME: {}, KEY: {}, STATE: MISS", cacheName, key); + cache.put(key, convertToJson(cacheName, key, value)); + } + + private String convertToJson(String cacheName, String key, T value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + log.error("캐시 값을 객체로 변환하는데 실패했습니다. CACHE NAME: {}, KEY: {}", cacheName, key); + throw new MomoException(CacheErrorCode.DATA_DESERIALIZATION_ERROR); + } + } + + public void putInvalid(CacheType cacheType, String key) { + put(cacheType, key, INVALID_STATUS); + } +} diff --git a/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java b/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java index 6a48474c0..271d954b8 100644 --- a/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java +++ b/backend/src/main/java/kr/momo/service/schedule/ScheduleService.java @@ -1,8 +1,10 @@ package kr.momo.service.schedule; import java.time.LocalTime; +import java.util.Arrays; import java.util.List; import java.util.stream.Stream; +import kr.momo.config.constant.CacheType; import kr.momo.domain.attendee.Attendee; import kr.momo.domain.attendee.AttendeeGroup; import kr.momo.domain.attendee.AttendeeName; @@ -16,6 +18,7 @@ import kr.momo.domain.schedule.ScheduleBatchRepository; import kr.momo.domain.schedule.ScheduleRepository; import kr.momo.domain.schedule.recommend.CandidateSchedule; +import kr.momo.domain.schedule.recommend.RecommendedScheduleSortStandard; import kr.momo.domain.timeslot.Timeslot; import kr.momo.exception.MomoException; import kr.momo.exception.code.AttendeeErrorCode; @@ -43,6 +46,7 @@ public class ScheduleService { private final AvailableDateRepository availableDateRepository; private final ScheduleBatchRepository scheduleBatchRepository; private final ScheduleRecommenderFactory scheduleRecommenderFactory; + private final ScheduleCache scheduleCache; @Transactional public void create(String uuid, long attendeeId, ScheduleCreateRequest request) { @@ -56,6 +60,10 @@ public void create(String uuid, long attendeeId, ScheduleCreateRequest request) scheduleRepository.deleteByAttendee(attendee); List schedules = createSchedules(request, meeting, attendee); scheduleBatchRepository.batchInsert(schedules); + scheduleCache.putInvalid(CacheType.SCHEDULES_STORE, uuid); + Arrays.stream(RecommendedScheduleSortStandard.values()) + .map(RecommendedScheduleSortStandard::getType) + .forEach(type -> scheduleCache.putInvalid(CacheType.RECOMMEND_STORE, type + uuid)); } private void validateMeetingUnLocked(Meeting meeting) { @@ -89,12 +97,19 @@ private Schedule createSchedule(Meeting meeting, Attendee attendee, AvailableDat @Transactional(readOnly = true) public SchedulesResponse findAllSchedules(String uuid) { + if (scheduleCache.isHit(CacheType.SCHEDULES_STORE, uuid)) { + return scheduleCache.get(CacheType.SCHEDULES_STORE, uuid, SchedulesResponse.class); + } + Meeting meeting = meetingRepository.findByUuid(uuid) .orElseThrow(() -> new MomoException(MeetingErrorCode.NOT_FOUND_MEETING)); List attendees = attendeeRepository.findAllByMeeting(meeting); List schedules = scheduleRepository.findAllByAttendeeIn(attendees); + SchedulesResponse schedulesResponse = SchedulesResponse.from(schedules); - return SchedulesResponse.from(schedules); + scheduleCache.put(CacheType.SCHEDULES_STORE, uuid, schedulesResponse); + + return schedulesResponse; } @Transactional(readOnly = true) @@ -125,6 +140,10 @@ public AttendeeScheduleResponse findMySchedule(String uuid, long attendeeId) { public RecommendedSchedulesResponse recommendSchedules( String uuid, String recommendType, List names, int minimumTime ) { + String key = recommendType + uuid; + if (scheduleCache.isHit(CacheType.RECOMMEND_STORE, key)) { + return scheduleCache.get(CacheType.RECOMMEND_STORE, key, RecommendedSchedulesResponse.class); + } Meeting meeting = meetingRepository.findByUuid(uuid) .orElseThrow(() -> new MomoException(MeetingErrorCode.NOT_FOUND_MEETING)); AttendeeGroup attendeeGroup = new AttendeeGroup(attendeeRepository.findAllByMeeting(meeting)); @@ -140,6 +159,12 @@ public RecommendedSchedulesResponse recommendSchedules( List scheduleResponses = RecommendedScheduleResponse.fromCandidateSchedules( recommendedResult ); - return RecommendedSchedulesResponse.of(meeting.getType(), scheduleResponses); + RecommendedSchedulesResponse recommendedSchedulesResponse = RecommendedSchedulesResponse.of( + meeting.getType(), scheduleResponses + ); + + scheduleCache.put(CacheType.RECOMMEND_STORE, key, recommendedSchedulesResponse); + + return recommendedSchedulesResponse; } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 2332b97a1..64f363983 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -7,6 +7,7 @@ spring: - ${SECURITY_PATH:classpath:security}/cors.yml - ${SECURITY_PATH:classpath:security}/logback.yml - ${SECURITY_PATH:classpath:security}/actuator.yml + - ${SECURITY_PATH:classpath:security}/cache-dev.yml jpa: hibernate: diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index a2fc89459..26da58353 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -3,7 +3,6 @@ spring: activate.on-profile: local import: - classpath:datasource.yml - jpa: hibernate: ddl-auto: create @@ -19,6 +18,13 @@ spring: path: /h2-console settings: web-allow-others: true + cache: + type: redis + data: + redis: + host: localhost + port: ${REDIS_PORT:6379} + timeout: 2000 security: jwt: diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index d97987a28..0125da814 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -7,6 +7,7 @@ spring: - ${SECURITY_PATH:classpath:security}/cors.yml - ${SECURITY_PATH:classpath:security}/logback.yml - ${SECURITY_PATH:classpath:security}/actuator.yml + - ${SECURITY_PATH:classpath:security}/cache-prod.yml jpa: hibernate: diff --git a/backend/src/main/resources/security b/backend/src/main/resources/security index be2952ff1..f424a0a21 160000 --- a/backend/src/main/resources/security +++ b/backend/src/main/resources/security @@ -1 +1 @@ -Subproject commit be2952ff10692da3ab0350592c2f139f6209cc9f +Subproject commit f424a0a2156042a65857d44bd0569d02f3a7e3d3 diff --git a/backend/src/test/java/kr/momo/config/PortKiller.java b/backend/src/test/java/kr/momo/config/PortKiller.java new file mode 100644 index 000000000..f1113d275 --- /dev/null +++ b/backend/src/test/java/kr/momo/config/PortKiller.java @@ -0,0 +1,66 @@ +package kr.momo.config; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +public class PortKiller { + + private static final String OS_NAME = System.getProperty("os.name").toLowerCase(); + private static final String WIN_PORT_FIND_COMMAND = "netstat -ano | findstr :%d"; + private static final String NOT_WIN_PORT_FIND_COMMAND = "lsof -i :%d"; + private static final String WIN_PROCESS_KILL_COMMAND = "taskkill /PID %s /F"; + private static final String NOT_WIN_PROCESS_KILL_COMMAND = "kill -9 %s"; + private static final boolean IS_OS_WINDOW = OS_NAME.contains("win"); + + public void killProcessUsingPort(int port) { + try { + String pid = getProcessIdUsingPort(port); + if (pid != null) { + killProcess(pid); + } + } catch (Exception e) { + System.err.println("포트 종료 중 오류 발생: " + e.getMessage()); + } + } + + private String getProcessIdUsingPort(int port) throws Exception { + String command = getFindPortCommand(port); + Process process = Runtime.getRuntime().exec(command); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + if ((line = reader.readLine()) != null) { + return parseUsingPort(line); + } + } + return null; + } + + private String parseUsingPort(String line) { + if (IS_OS_WINDOW) { + return line.trim().split("\\s+")[4]; + } + return line.trim().split("\\s+")[1]; + } + + private String getFindPortCommand(int port) { + if (IS_OS_WINDOW) { + return WIN_PORT_FIND_COMMAND.formatted(port); + } + return NOT_WIN_PORT_FIND_COMMAND.formatted(port); + } + + private void killProcess(String pid) throws Exception { + String command = getKillCommand(pid); + Process process = Runtime.getRuntime().exec(command); + process.waitFor(); + } + + private String getKillCommand(String pid) { + if (IS_OS_WINDOW) { + return WIN_PROCESS_KILL_COMMAND.formatted(pid); + } + return NOT_WIN_PROCESS_KILL_COMMAND.formatted(pid); + } +} diff --git a/backend/src/test/java/kr/momo/config/TestRedisConfig.java b/backend/src/test/java/kr/momo/config/TestRedisConfig.java new file mode 100644 index 000000000..92a07c3df --- /dev/null +++ b/backend/src/test/java/kr/momo/config/TestRedisConfig.java @@ -0,0 +1,33 @@ +package kr.momo.config; + +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +@TestConfiguration +public class TestRedisConfig { + + private final PortKiller portKiller; + private final RedisProperties redisProperties; + private RedisServer redisServer; + + public TestRedisConfig(PortKiller portKiller, RedisProperties redisProperties) { + this.portKiller = portKiller; + this.redisProperties = redisProperties; + } + + public void start() throws IOException { + portKiller.killProcessUsingPort(redisProperties.getPort()); + redisServer = new RedisServer(redisProperties.getPort()); + redisServer.start(); + } + + @PreDestroy + public void stop() throws IOException { + if (redisServer != null && redisServer.isActive()) { + redisServer.stop(); + } + } +} diff --git a/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java b/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java index 851e56281..8f8f689f1 100644 --- a/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java +++ b/backend/src/test/java/kr/momo/controller/schedule/ScheduleControllerTest.java @@ -22,7 +22,8 @@ import kr.momo.service.attendee.dto.AttendeeLoginRequest; import kr.momo.service.schedule.dto.DateTimesCreateRequest; import kr.momo.service.schedule.dto.ScheduleCreateRequest; -import kr.momo.support.IsolateDatabase; +import kr.momo.support.EnableEmbeddedCache; +import kr.momo.support.IsolateDatabaseAndCache; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -33,7 +34,8 @@ import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; -@IsolateDatabase +@EnableEmbeddedCache +@IsolateDatabaseAndCache @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class ScheduleControllerTest { diff --git a/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java b/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java index 9a4d853d1..2515e0211 100644 --- a/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java +++ b/backend/src/test/java/kr/momo/service/meeting/MeetingServiceTest.java @@ -25,8 +25,8 @@ import kr.momo.exception.code.MeetingErrorCode; import kr.momo.fixture.AttendeeFixture; import kr.momo.fixture.MeetingFixture; -import kr.momo.service.meeting.dto.MeetingHomeResponse; import kr.momo.service.meeting.dto.MeetingCreateRequest; +import kr.momo.service.meeting.dto.MeetingHomeResponse; import kr.momo.service.meeting.dto.MeetingResponse; import kr.momo.service.meeting.dto.MeetingSharingResponse; import kr.momo.support.IsolateDatabase; diff --git a/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java b/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java index 599140c69..55ff9fa93 100644 --- a/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java +++ b/backend/src/test/java/kr/momo/service/schedule/ScheduleServiceTest.java @@ -11,6 +11,7 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import kr.momo.config.constant.CacheType; import kr.momo.domain.attendee.Attendee; import kr.momo.domain.attendee.AttendeeGroup; import kr.momo.domain.attendee.AttendeeRepository; @@ -36,7 +37,8 @@ import kr.momo.service.schedule.dto.RecommendedSchedulesResponse; import kr.momo.service.schedule.dto.ScheduleCreateRequest; import kr.momo.service.schedule.dto.SchedulesResponse; -import kr.momo.support.IsolateDatabase; +import kr.momo.support.EnableEmbeddedCache; +import kr.momo.support.IsolateDatabaseAndCache; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -44,7 +46,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -@IsolateDatabase +@EnableEmbeddedCache +@IsolateDatabaseAndCache @SpringBootTest(webEnvironment = WebEnvironment.NONE) class ScheduleServiceTest { @@ -63,6 +66,9 @@ class ScheduleServiceTest { @Autowired private AvailableDateRepository availableDateRepository; + @Autowired + private ScheduleCache scheduleCache; + private Meeting meeting; private Attendee attendee; private AvailableDate today; @@ -95,8 +101,12 @@ void createSchedulesReplacesOldSchedules() { scheduleService.create(meeting.getUuid(), attendee.getId(), request); long scheduleCount = scheduleRepository.count(); + String scheduleCacheData = scheduleCache.get(CacheType.SCHEDULES_STORE, meeting.getUuid(), String.class); - assertThat(scheduleCount).isEqualTo(4); + assertAll( + () -> assertThat(scheduleCount).isEqualTo(4), + () -> assertThat(scheduleCacheData).isEqualTo("invalid") + ); } @DisplayName("days only 약속의 스케줄 생성 시 하루에 하나의 스케줄을 저장한다.") @@ -165,20 +175,26 @@ void findAllSchedulesInMeetingByUuid() { scheduleRepository.saveAll(List.of(schedule1, schedule2, schedule3, schedule4)); SchedulesResponse response = scheduleService.findAllSchedules(meeting.getUuid()); + SchedulesResponse cacheData = scheduleCache.get( + CacheType.SCHEDULES_STORE, meeting.getUuid(), SchedulesResponse.class + ); - assertThat(response.schedules()).containsExactlyInAnyOrder( - new AttendeesScheduleResponse( - today.getDate(), - Timeslot.TIME_0100.startTime(), - List.of(attendee.name(), attendee2.name())), - new AttendeesScheduleResponse( - today.getDate(), - Timeslot.TIME_0130.startTime(), - List.of(attendee2.name())), - new AttendeesScheduleResponse( - tomorrow.getDate(), - Timeslot.TIME_0100.startTime(), - List.of(attendee.name())) + assertAll( + () -> assertThat(response.schedules()).containsExactlyInAnyOrder( + new AttendeesScheduleResponse( + today.getDate(), + Timeslot.TIME_0100.startTime(), + List.of(attendee.name(), attendee2.name())), + new AttendeesScheduleResponse( + today.getDate(), + Timeslot.TIME_0130.startTime(), + List.of(attendee2.name())), + new AttendeesScheduleResponse( + tomorrow.getDate(), + Timeslot.TIME_0100.startTime(), + List.of(attendee.name())) + ), + () -> assertThat(cacheData).isEqualTo(response) ); } @@ -264,26 +280,34 @@ void recommendLongTermSchedules() { RecommendedSchedulesResponse responses = scheduleService.recommendSchedules( movieMeeting.getUuid(), LONG_TERM_ORDER.getType(), List.of(jazz.name(), daon.name()), 0 ); + RecommendedSchedulesResponse cacheData = scheduleCache.get( + CacheType.RECOMMEND_STORE, + LONG_TERM_ORDER.getType() + movieMeeting.getUuid(), + RecommendedSchedulesResponse.class + ); - assertThat(responses.recommendedSchedules()).containsExactly( - RecommendedScheduleResponse.of( - 1, - LocalDateTime.of(today.getDate(), Timeslot.TIME_0500.startTime()), - LocalDateTime.of(today.getDate(), Timeslot.TIME_0630.endTime()), - new AttendeeGroup(List.of(jazz, daon)) - ), - RecommendedScheduleResponse.of( - 2, - LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0130.startTime()), - LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0230.endTime()), - new AttendeeGroup(List.of(jazz, daon)) + assertAll( + () -> assertThat(responses.recommendedSchedules()).containsExactly( + RecommendedScheduleResponse.of( + 1, + LocalDateTime.of(today.getDate(), Timeslot.TIME_0500.startTime()), + LocalDateTime.of(today.getDate(), Timeslot.TIME_0630.endTime()), + new AttendeeGroup(List.of(jazz, daon)) + ), + RecommendedScheduleResponse.of( + 2, + LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0130.startTime()), + LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0230.endTime()), + new AttendeeGroup(List.of(jazz, daon)) + ), + RecommendedScheduleResponse.of( + 3, + LocalDateTime.of(today.getDate(), Timeslot.TIME_0330.startTime()), + LocalDateTime.of(today.getDate(), Timeslot.TIME_0400.endTime()), + new AttendeeGroup(List.of(jazz, daon)) + ) ), - RecommendedScheduleResponse.of( - 3, - LocalDateTime.of(today.getDate(), Timeslot.TIME_0330.startTime()), - LocalDateTime.of(today.getDate(), Timeslot.TIME_0400.endTime()), - new AttendeeGroup(List.of(jazz, daon)) - ) + () -> assertThat(cacheData).isEqualTo(responses) ); } @@ -304,26 +328,34 @@ void recommendFastestSchedules() { RecommendedSchedulesResponse responses = scheduleService.recommendSchedules( movieMeeting.getUuid(), EARLIEST_ORDER.getType(), List.of(jazz.name(), daon.name()), 0 ); + RecommendedSchedulesResponse cacheData = scheduleCache.get( + CacheType.RECOMMEND_STORE, + EARLIEST_ORDER.getType() + movieMeeting.getUuid(), + RecommendedSchedulesResponse.class + ); - assertThat(responses.recommendedSchedules()).containsExactly( - RecommendedScheduleResponse.of( - 1, - LocalDateTime.of(today.getDate(), Timeslot.TIME_0330.startTime()), - LocalDateTime.of(today.getDate(), Timeslot.TIME_0400.endTime()), - new AttendeeGroup(List.of(jazz, daon)) - ), - RecommendedScheduleResponse.of( - 2, - LocalDateTime.of(today.getDate(), Timeslot.TIME_0500.startTime()), - LocalDateTime.of(today.getDate(), Timeslot.TIME_0630.endTime()), - new AttendeeGroup(List.of(jazz, daon)) + assertAll( + () -> assertThat(responses.recommendedSchedules()).containsExactly( + RecommendedScheduleResponse.of( + 1, + LocalDateTime.of(today.getDate(), Timeslot.TIME_0330.startTime()), + LocalDateTime.of(today.getDate(), Timeslot.TIME_0400.endTime()), + new AttendeeGroup(List.of(jazz, daon)) + ), + RecommendedScheduleResponse.of( + 2, + LocalDateTime.of(today.getDate(), Timeslot.TIME_0500.startTime()), + LocalDateTime.of(today.getDate(), Timeslot.TIME_0630.endTime()), + new AttendeeGroup(List.of(jazz, daon)) + ), + RecommendedScheduleResponse.of( + 3, + LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0130.startTime()), + LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0230.endTime()), + new AttendeeGroup(List.of(jazz, daon)) + ) ), - RecommendedScheduleResponse.of( - 3, - LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0130.startTime()), - LocalDateTime.of(tomorrow.getDate(), Timeslot.TIME_0230.endTime()), - new AttendeeGroup(List.of(jazz, daon)) - ) + () -> assertThat(cacheData).isEqualTo(responses) ); } @@ -482,3 +514,4 @@ private List addNextDaySchedule( return schedules; } } + diff --git a/backend/src/test/java/kr/momo/support/CacheCleanupListener.java b/backend/src/test/java/kr/momo/support/CacheCleanupListener.java new file mode 100644 index 000000000..02bb2d35f --- /dev/null +++ b/backend/src/test/java/kr/momo/support/CacheCleanupListener.java @@ -0,0 +1,38 @@ +package kr.momo.support; + +import java.io.IOException; +import java.util.Objects; +import kr.momo.config.TestRedisConfig; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; +import reactor.util.annotation.NonNull; + +public class CacheCleanupListener extends AbstractTestExecutionListener { + + @Override + public void beforeTestClass(@NonNull TestContext testContext) throws IOException { + TestRedisConfig redisConfig = testContext.getApplicationContext() + .getBean(TestRedisConfig.class); + redisConfig.start(); + } + + @Override + public void afterTestMethod(@NonNull TestContext testContext) { + CacheManager cacheManager = testContext.getApplicationContext() + .getBean(CacheManager.class); + + for (String name : cacheManager.getCacheNames()) { + Cache cache = cacheManager.getCache(name); + Objects.requireNonNull(cache).clear(); + } + } + + @Override + public void afterTestClass(TestContext testContext) throws Exception { + TestRedisConfig redisConfig = testContext.getApplicationContext() + .getBean(TestRedisConfig.class); + redisConfig.stop(); + } +} diff --git a/backend/src/test/java/kr/momo/support/EnableEmbeddedCache.java b/backend/src/test/java/kr/momo/support/EnableEmbeddedCache.java new file mode 100644 index 000000000..93ba8e719 --- /dev/null +++ b/backend/src/test/java/kr/momo/support/EnableEmbeddedCache.java @@ -0,0 +1,15 @@ +package kr.momo.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import kr.momo.config.PortKiller; +import kr.momo.config.TestRedisConfig; +import org.springframework.context.annotation.Import; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import({TestRedisConfig.class, PortKiller.class}) +public @interface EnableEmbeddedCache { +} diff --git a/backend/src/test/java/kr/momo/support/IsolateDatabaseAndCache.java b/backend/src/test/java/kr/momo/support/IsolateDatabaseAndCache.java new file mode 100644 index 000000000..ac1fdcd55 --- /dev/null +++ b/backend/src/test/java/kr/momo/support/IsolateDatabaseAndCache.java @@ -0,0 +1,17 @@ +package kr.momo.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.TestExecutionListeners.MergeMode; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@TestExecutionListeners( + value = {CacheCleanupListener.class, DatabaseCleanupListener.class}, + mergeMode = MergeMode.MERGE_WITH_DEFAULTS +) +public @interface IsolateDatabaseAndCache { +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 9608282e0..be978a75c 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -15,6 +15,11 @@ spring: init: mode: never + data: + redis: + host: localhost + port: 6370 + logging: level: org.hibernate.orm.jdbc: