Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] 약속 조회와 약속 추천 서버 캐시 추가 :) #437

Open
wants to merge 19 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab297e1
chore(build.gradle): cache에 사용할 redis 및 embedded redis 의존성 주입
ikjo39 Nov 16, 2024
dbec7a2
chore: 서브 모듈 변경 반영
ikjo39 Nov 16, 2024
4b8bee4
chore: 환경 별 원격 캐시 설정 추가
ikjo39 Nov 16, 2024
061d71a
feat: Redis 환경 설정 추가
ikjo39 Nov 16, 2024
66abf4a
feat: Embedded Redis 환경 설정 추가
ikjo39 Nov 16, 2024
ad6d842
feat(ScheduleCache): 캐싱 기능 추상화
ikjo39 Nov 16, 2024
bac5d47
feat(ScheduleService): 일정 조회, 일정 추천에 대한 캐시 적용
ikjo39 Nov 16, 2024
318ae4a
test: 테스트 환경 내 Embedded Redis 설정 추가
ikjo39 Nov 16, 2024
c2c419d
test: 캐시 격리를 위한 Listener 및 어노테이션 추가
ikjo39 Nov 16, 2024
db5831b
test: 캐싱을 사용하는 테스트에 대해 테스트 격리를 위한 어노테이션 추가
ikjo39 Nov 16, 2024
f92f6c1
fix: 추천 방식 별로 다르게 캐싱 관리하도록 수정
ikjo39 Nov 17, 2024
ffa87cf
refactor: 동시성 문제 방지를 위한 evict 로직 수정
ikjo39 Nov 17, 2024
b05849f
refactor: 캐시 조회 예외 상황에 대한 에러 코드 수정
ikjo39 Nov 17, 2024
de8f1d3
fix: 테스트 도중 예외 발생시 redis 종료 안되던 오류 해결
ikjo39 Nov 18, 2024
8d54c75
fix(ScheduleCache): 일정 등록시 의도 하지 않는 값 캐싱 오류 수정
ikjo39 Nov 18, 2024
5b39a04
test: 캐싱 여부 테스트 내 추가
ikjo39 Nov 18, 2024
c01f4fe
chore: 서브모듈 해시 충돌 해결
ikjo39 Nov 22, 2024
3f44fde
fix(PortKiller): 불필요한 어노테이션 제거
ikjo39 Nov 22, 2024
6a559de
refactor(ScheduleCache): 가독성을 위한 메서드 분리
ikjo39 Nov 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link
Member

Choose a reason for hiding this comment

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

codemonstur의 fork를 사용한 이유가 있나요?

Copy link
Contributor Author

@ikjo39 ikjo39 Nov 22, 2024

Choose a reason for hiding this comment

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

maven repository에서 embedded redis로 유명한 라이브러리는 2개가 있습니다.

  1. com.github.kstyrc

  2. it.ozimov

두 의존성 모두 정식 출시되지 않는 major version 1이 출시되지 않았으며, 업데이트 기간이 5년 이상 지난 라이브러리로 식별하였습니다.
image
그로 인해 라이브러리 사용으로 인해 발생할 수 있는 CVE에 등록된 취약점들이 해결되지 않는 것을 확인할 수 있었습니다.

현재 사용하고 있는 라이브러리('com.github.codemonstur:embedded-redis')는 위의 1번 com.github.kstyrc의 fork 기반으로 버전 1 이상을 유지하고 있고 주기적으로 업데이트를 거치며 잠재적인 취약점을 개선하고 있음을 확인할 수 있었기에 해당 의존성을 사용하였습니다.


compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

Expand Down
32 changes: 32 additions & 0 deletions backend/src/main/java/kr/momo/config/EmbeddedRedisConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
32 changes: 32 additions & 0 deletions backend/src/main/java/kr/momo/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
14 changes: 14 additions & 0 deletions backend/src/main/java/kr/momo/config/constant/CacheType.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package kr.momo.service.schedule;
Copy link
Member

Choose a reason for hiding this comment

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

이 클래스는 왜 service 패키지에 위치하나요?

Copy link
Contributor Author

@ikjo39 ikjo39 Nov 22, 2024

Choose a reason for hiding this comment

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

외부 의존성(캐시)를 사용하는 메서드를 다루는 객체이다 보니 도메인에 두기에 어색하다 생각했습니다.
때문에 위치를 고민하다 의존하는 위치인 서비스에 두게되었습니다.
따로 분리할만한 패키지 명이 떠오르지 않네요 😅
이 부분은 함께 얘기해보면 좋을 것 같습니다 ㅎ


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> T get(CacheType cacheType, String key, Class<T> clazz) {
String cacheName = cacheType.getName();
Cache cache = cacheManager.getCache(cacheName);
if (cache == null || cache.get(key, String.class) == null) {
Copy link
Member

Choose a reason for hiding this comment

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

메서드로 분리하는게 좋아 보여요!

throw new MomoException(CacheErrorCode.CACHE_NOT_FOUND);
}
String value = cache.get(key, String.class);
log.debug("CACHE NAME: {}, KEY: {}, STATE: HIT", cacheName, key);
return convertObject(cacheName, key, clazz, value);
}

private <T> T convertObject(String cacheName, String key, Class<T> 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 <T> 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 <T> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -56,6 +60,10 @@ public void create(String uuid, long attendeeId, ScheduleCreateRequest request)
scheduleRepository.deleteByAttendee(attendee);
List<Schedule> 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) {
Expand Down Expand Up @@ -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<Attendee> attendees = attendeeRepository.findAllByMeeting(meeting);
List<Schedule> 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)
Expand Down Expand Up @@ -125,6 +140,10 @@ public AttendeeScheduleResponse findMySchedule(String uuid, long attendeeId) {
public RecommendedSchedulesResponse recommendSchedules(
String uuid, String recommendType, List<String> 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));
Expand All @@ -140,6 +159,12 @@ public RecommendedSchedulesResponse recommendSchedules(
List<RecommendedScheduleResponse> 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;
}
}
1 change: 1 addition & 0 deletions backend/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion backend/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ spring:
activate.on-profile: local
import:
- classpath:datasource.yml

jpa:
hibernate:
ddl-auto: create
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/security
68 changes: 68 additions & 0 deletions backend/src/test/java/kr/momo/config/PortKiller.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package kr.momo.config;

import groovy.util.logging.Slf4j;
Copy link
Member

Choose a reason for hiding this comment

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

lombok꺼 써 주세요~

import java.io.BufferedReader;
import java.io.InputStreamReader;
import org.springframework.boot.test.context.TestComponent;

@Slf4j
@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);
}
}
Loading