Skip to content

Commit a47d24d

Browse files
authored
[DDING - 126] 동아리 목록 조회 및 폼지 조회 로컬 캐싱 적용 (#297)
1 parent 64edf8b commit a47d24d

File tree

10 files changed

+168
-17
lines changed

10 files changed

+168
-17
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies {
3232
implementation 'org.springframework.boot:spring-boot-starter-validation'
3333
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
3434
implementation 'org.springframework.boot:spring-boot-configuration-processor'
35+
implementation 'org.springframework.boot:spring-boot-starter-cache'
3536

3637
compileOnly 'org.projectlombok:lombok'
3738
annotationProcessor 'org.projectlombok:lombok'
@@ -57,6 +58,7 @@ dependencies {
5758
implementation 'com.github.f4b6a3:uuid-creator:6.0.0'
5859
implementation 'software.amazon.awssdk:ses:2.24.0'
5960
implementation 'com.google.guava:guava:32.1.3-jre'
61+
implementation 'com.github.ben-manes.caffeine:caffeine'
6062

6163
//security
6264
implementation 'org.springframework.boot:spring-boot-starter-security'
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package ddingdong.ddingdongBE.common.config.cache;
2+
3+
import com.github.benmanes.caffeine.cache.Caffeine;
4+
import com.github.benmanes.caffeine.cache.Expiry;
5+
import ddingdong.ddingdongBE.domain.form.entity.Form;
6+
import ddingdong.ddingdongBE.domain.form.service.FormService;
7+
import java.time.Duration;
8+
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
10+
import java.util.ArrayList;
11+
import java.util.List;
12+
import java.util.concurrent.TimeUnit;
13+
import java.util.regex.Pattern;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.cache.CacheManager;
16+
import org.springframework.cache.annotation.EnableCaching;
17+
import org.springframework.cache.caffeine.CaffeineCache;
18+
import org.springframework.cache.support.SimpleCacheManager;
19+
import org.springframework.context.annotation.Bean;
20+
import org.springframework.context.annotation.Configuration;
21+
22+
23+
@Configuration
24+
@EnableCaching
25+
@RequiredArgsConstructor
26+
public class CacheConfig {
27+
28+
private final FormService formService;
29+
30+
private static final Pattern FORM_CACHE_PATTERN = Pattern.compile("^form_\\d+.*$");
31+
private static final Pattern FORM_SECTION_CACHE_PATTERN = Pattern.compile("^form_\\d+_formSection$");
32+
33+
@Bean
34+
public CacheManager cacheManager() {
35+
SimpleCacheManager cacheManager = new SimpleCacheManager();
36+
List<CaffeineCache> caches = new ArrayList<>();
37+
38+
caches.add(new CaffeineCache("clubsCache",
39+
Caffeine.newBuilder()
40+
.expireAfterWrite(1, TimeUnit.DAYS)
41+
.maximumSize(100)
42+
.recordStats()
43+
.build()));
44+
caches.add(new CaffeineCache("formsCache",
45+
Caffeine.newBuilder()
46+
.expireAfter(new FormDynamicExpiry())
47+
.maximumSize(200)
48+
.recordStats()
49+
.build()));
50+
caches.add(new CaffeineCache("formSectionsCache",
51+
Caffeine.newBuilder()
52+
.expireAfter(new FormDynamicExpiry())
53+
.maximumSize(100)
54+
.recordStats()
55+
.build()));
56+
57+
cacheManager.setCaches(caches);
58+
return cacheManager;
59+
}
60+
61+
public class FormDynamicExpiry implements Expiry<Object, Object> {
62+
63+
@Override
64+
public long expireAfterCreate(Object key, Object value, long currentTime) {
65+
return calculateExpiryNanos(key);
66+
}
67+
68+
@Override
69+
public long expireAfterUpdate(Object key, Object value, long currentTime, long currentDuration) {
70+
return calculateExpiryNanos(key);
71+
}
72+
73+
@Override
74+
public long expireAfterRead(Object key, Object value, long currentTime, long currentDuration) {
75+
return currentDuration;
76+
}
77+
78+
private long calculateExpiryNanos(Object key) {
79+
Long formId = extractFormId(key);
80+
if (formId == null) {
81+
return TimeUnit.HOURS.toNanos(1); // 기본값: 1시간
82+
}
83+
84+
try {
85+
Form form = formService.getById(formId);
86+
LocalDate endDate = form.getEndDate();
87+
LocalDateTime now = LocalDateTime.now();
88+
LocalDateTime endDateTime = endDate.atTime(23, 59, 59);
89+
90+
Duration duration = Duration.between(now, endDateTime);
91+
return Math.min(duration.toNanos(), TimeUnit.DAYS.toNanos(1));
92+
93+
} catch (Exception e) {
94+
return TimeUnit.HOURS.toNanos(1);
95+
}
96+
}
97+
98+
/**
99+
* 캐시 키에서 폼 ID 추출
100+
*/
101+
private Long extractFormId(Object key) {
102+
if (key instanceof String cacheKey) {
103+
if (FORM_CACHE_PATTERN.matcher(cacheKey).matches() ||
104+
FORM_SECTION_CACHE_PATTERN.matcher(cacheKey).matches()) {
105+
String[] parts = cacheKey.split("_");
106+
if (parts.length >= 2) {
107+
try {
108+
return Long.parseLong(parts[1]);
109+
} catch (NumberFormatException e) {
110+
return null;
111+
}
112+
}
113+
}
114+
}
115+
return null;
116+
}
117+
}
118+
}

src/main/java/ddingdong/ddingdongBE/domain/club/controller/UserClubController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.time.LocalDate;
99
import java.util.List;
1010
import lombok.RequiredArgsConstructor;
11+
import org.springframework.cache.annotation.Cacheable;
1112
import org.springframework.web.bind.annotation.RestController;
1213

1314
@RestController
@@ -16,8 +17,8 @@ public class UserClubController implements UserClubApi {
1617

1718
private final FacadeUserClubService facadeUserClubService;
1819

19-
2020
@Override
21+
@Cacheable(value = "clubsCache", key = "'clubs'")
2122
public List<UserClubListResponse> getClubs() {
2223
return facadeUserClubService.findAllWithRecruitTimeCheckPoint(LocalDate.now()).stream()
2324
.map(UserClubListResponse::from)

src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImpl.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ public class FacadeUserClubServiceImpl implements FacadeUserClubService {
3333

3434
@Override
3535
public List<UserClubListQuery> findAllWithRecruitTimeCheckPoint(LocalDate now) {
36-
3736
List<UserClubListInfo> userClubListInfos = clubService.findAllClubListInfo();
3837
return userClubListInfos.stream()
3938
.map(info -> UserClubListQuery.of(info, checkRecruit(now, info.getStart(), info.getEnd()).getText()))

src/main/java/ddingdong/ddingdongBE/domain/club/service/GeneralClubService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.List;
99
import lombok.RequiredArgsConstructor;
1010
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.cache.annotation.CacheEvict;
1112
import org.springframework.stereotype.Service;
1213
import org.springframework.transaction.annotation.Transactional;
1314

@@ -21,6 +22,7 @@ public class GeneralClubService implements ClubService {
2122

2223
@Override
2324
@Transactional
25+
@CacheEvict(value = "clubsCache", allEntries = true)
2426
public Long save(Club club) {
2527
Club savedClub = clubRepository.save(club);
2628
return savedClub.getId();
@@ -62,6 +64,7 @@ public void update(Club club, Club updatedClub) {
6264

6365
@Override
6466
@Transactional
67+
@CacheEvict(value = "clubsCache", allEntries = true)
6568
public void delete(Long clubId) {
6669
Club club = getById(clubId);
6770
clubRepository.delete(club);

src/main/java/ddingdong/ddingdongBE/domain/form/controller/UserFormController.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import ddingdong.ddingdongBE.domain.form.service.dto.query.FormSectionQuery;
88
import ddingdong.ddingdongBE.domain.form.service.dto.query.UserFormQuery;
99
import lombok.RequiredArgsConstructor;
10+
import org.springframework.cache.annotation.Cacheable;
1011
import org.springframework.web.bind.annotation.RestController;
1112

1213
@RestController
@@ -16,12 +17,14 @@ public class UserFormController implements UserFormApi {
1617
private final FacadeUserFormService facadeUserFormService;
1718

1819
@Override
20+
@Cacheable(value = "formSectionsCache", key = "'form_' + #root.args[0] + '_formSection'")
1921
public FormSectionResponse getFormSections(Long formId) {
2022
FormSectionQuery query = facadeUserFormService.getFormSection(formId);
2123
return FormSectionResponse.from(query);
2224
}
2325

2426
@Override
27+
@Cacheable(value = "formsCache", key = "'form_' + #root.args[0] + '_' + #root.args[1]")
2528
public UserFormResponse getForm(Long formId, String section) {
2629
UserFormQuery query = facadeUserFormService.getUserForm(formId, section);
2730
return UserFormResponse.from(query);

src/main/java/ddingdong/ddingdongBE/domain/form/service/FacadeCentralFormServiceImpl.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import ddingdong.ddingdongBE.domain.club.entity.Club;
1010
import ddingdong.ddingdongBE.domain.club.service.ClubService;
1111
import ddingdong.ddingdongBE.domain.clubmember.entity.ClubMember;
12-
import ddingdong.ddingdongBE.domain.filemetadata.service.FileMetaDataService;
1312
import ddingdong.ddingdongBE.domain.form.entity.Form;
1413
import ddingdong.ddingdongBE.domain.form.entity.FormField;
1514
import ddingdong.ddingdongBE.domain.form.entity.FormStatus;
@@ -42,6 +41,8 @@
4241
import java.util.concurrent.TimeUnit;
4342
import lombok.RequiredArgsConstructor;
4443
import lombok.extern.slf4j.Slf4j;
44+
import org.springframework.cache.annotation.CacheEvict;
45+
import org.springframework.cache.annotation.Caching;
4546
import org.springframework.stereotype.Service;
4647
import org.springframework.transaction.annotation.Transactional;
4748

@@ -56,7 +57,6 @@ public class FacadeCentralFormServiceImpl implements FacadeCentralFormService {
5657
private final ClubService clubService;
5758
private final FormStatisticService formStatisticService;
5859
private final FormApplicationService formApplicationService;
59-
private final FileMetaDataService fileMetaDataService;
6060
private final SesEmailService sesEmailService;
6161

6262
@Transactional
@@ -74,6 +74,11 @@ public void createForm(CreateFormCommand createFormCommand) {
7474

7575
@Transactional
7676
@Override
77+
@Caching(evict = {
78+
@CacheEvict(value = "formsCache", key = "'form_' + #root.args[0].formId() + '_*'", allEntries = true),
79+
@CacheEvict(value = "formSectionsCache", key = "'form_' + #root.args[0].formId() + '_formSection'"),
80+
@CacheEvict(value = "clubsCache", allEntries = true)
81+
})
7782
public void updateForm(UpdateFormCommand command) {
7883
Club club = clubService.getByUserId(command.user().getId());
7984
validateDuplicationDateExcludingSelf(club, command.startDate(), command.endDate(), command.formId());
@@ -242,7 +247,9 @@ private void validateDuplicationDateExcludingSelf(
242247
}
243248

244249
private void validateEndDate(LocalDate startDate, LocalDate endDate) {
245-
if (endDate.isBefore(startDate)) { throw new InvalidFormEndDateException(); }
250+
if (endDate.isBefore(startDate)) {
251+
throw new InvalidFormEndDateException();
252+
}
246253
}
247254

248255
private List<FormField> toUpdateFormFields(Form originform,

src/main/java/ddingdong/ddingdongBE/domain/form/service/GeneralFormService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import java.util.Comparator;
1010
import java.util.List;
1111
import lombok.RequiredArgsConstructor;
12+
import org.springframework.cache.annotation.CacheEvict;
13+
import org.springframework.cache.annotation.Caching;
1214
import org.springframework.stereotype.Service;
1315
import org.springframework.transaction.annotation.Transactional;
1416

@@ -21,6 +23,9 @@ public class GeneralFormService implements FormService {
2123

2224
@Transactional
2325
@Override
26+
@Caching(evict = {
27+
@CacheEvict(value = "clubsCache", allEntries = true)
28+
})
2429
public Form create(Form form) {
2530
return formRepository.save(form);
2631
}
@@ -33,6 +38,11 @@ public Form getById(Long formId) {
3338

3439
@Transactional
3540
@Override
41+
@Caching(evict = {
42+
@CacheEvict(value = "formsCache", key = "'form_' + #root.args[0].id + '_*'", allEntries = true),
43+
@CacheEvict(value = "formSectionsCache", key = "'form_' + #root.args[0].id + '_formSection'"),
44+
@CacheEvict(value = "clubsCache", allEntries = true)
45+
})
3646
public void delete(Form form) {
3747
formRepository.delete(form);
3848
}

src/main/java/ddingdong/ddingdongBE/email/SesEmailService.java

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public class SesEmailService {
3131

3232
@Async
3333
public CompletableFuture<Void> sendBulkResultEmails(List<FormApplication> formApplications,
34-
EmailContent emailContent) {
34+
EmailContent emailContent) {
3535
return CompletableFuture.allOf(
3636
formApplications.stream()
3737
.map(application -> CompletableFuture.runAsync(() -> {
@@ -43,7 +43,13 @@ public CompletableFuture<Void> sendBulkResultEmails(List<FormApplication> formAp
4343
sendEmail(emailContent, application);
4444
break;
4545
} catch (LimitExceededException e) {
46-
retryFixedInterval(application, e, attempt, maxRetries, retryDelayMs);
46+
try {
47+
retryFixedInterval(application, e, attempt, maxRetries, retryDelayMs);
48+
} catch (InterruptedException ie) {
49+
Thread.currentThread().interrupt();
50+
log.error("이메일 전송 중 인터럽트 발생 - {}", application.getEmail());
51+
throw new RuntimeException("이메일 전송 중 인터럽트 발생: " + application.getEmail(), ie);
52+
}
4753
} catch (Exception e) {
4854
log.error("이메일 전송 실패 - {}: {}", application.getEmail(), e.getMessage());
4955
throw new RuntimeException("이메일 전송에 실패했습니다: " + application.getEmail(), e);
@@ -85,21 +91,18 @@ private void sendEmail(EmailContent emailContent, FormApplication application) {
8591
}
8692

8793
private void retryFixedInterval(FormApplication application,
88-
LimitExceededException e,
89-
int attempt,
90-
int maxRetries,
91-
long retryDelayMs) {
94+
LimitExceededException e,
95+
int attempt,
96+
int maxRetries,
97+
long retryDelayMs) throws InterruptedException {
9298
if (attempt == maxRetries) {
9399
log.error("최대 재시도 횟수 초과. 이메일 전송 실패 - {}", application.getEmail());
94100
throw new RuntimeException("이메일 전송 최대 재시도 횟수 초과: " + application.getEmail(), e);
95101
}
96102
log.warn("Rate limit 발생. {}ms 후 재시도 ({}/{}): {}",
97103
retryDelayMs, attempt + 1, maxRetries, application.getEmail());
98-
try {
99-
Thread.sleep(retryDelayMs);
100-
} catch (InterruptedException ie) {
101-
Thread.currentThread().interrupt();
102-
throw new RuntimeException("재시도 대기 중 인터럽트 발생", ie);
103-
}
104+
105+
// InterruptedException을 다시 던져서 호출자가 처리할 수 있게 함
106+
Thread.sleep(retryDelayMs);
104107
}
105108
}

src/main/resources/application-local.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ spring:
1717
format_sql: true
1818
show-sql: true
1919
defer-datasource-initialization: false
20+
21+
logging:
22+
level:
23+
org.springframework.cache: TRACE
24+
org.springframework.cache.interceptor.CacheInterceptor: TRACE

0 commit comments

Comments
 (0)