Skip to content

Commit 0fcd5b6

Browse files
Merge pull request #184 from prgrms-web-devcourse-final-project/feat/#168
[Concert] Redis 기반 공연 제목 자동완성
2 parents dc92342 + 9f38583 commit 0fcd5b6

10 files changed

Lines changed: 222 additions & 24 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/Web79CodecreteBeApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

78
@EnableJpaAuditing
9+
@EnableScheduling
810
@SpringBootApplication
911
public class Web79CodecreteBeApplication {
1012

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/controller/ConcertAdminController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,18 @@ public RsData<String> sendTicketingEmail(){
136136
return RsData.success(resultMessage,null);
137137
}
138138

139+
@Operation(summary = "자동 검색어를 세팅합니다.", description = "검색어 자동완성을 위해 필요한 데이터를 저장합니다.")
140+
@PostMapping("autoSet")
141+
public RsData<Void> autoCompleteSetConcert(){
142+
concertService.setAutoComplete();
143+
return RsData.success(null);
144+
}
145+
146+
@Operation(summary = "세팅된 자동 검색어를 삭제합니다.", description = "검색어 자동 완성을 위해 세팅된 데이터를 삭제합니다.")
147+
@PostMapping("autoDelete")
148+
public RsData<Void> autoDeleteConcert(){
149+
concertService.resetAutoComplete();
150+
return RsData.success(null);
151+
}
152+
139153
}

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/controller/ConcertController.java

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
package com.back.web7_9_codecrete_be.domain.concerts.controller;
22

3-
import com.back.web7_9_codecrete_be.domain.concerts.dto.KopisApiDto.concert.ConcertListResponse;
4-
import com.back.web7_9_codecrete_be.domain.concerts.dto.KopisApiDto.concertPlace.ConcertPlaceDetailResponse;
5-
import com.back.web7_9_codecrete_be.domain.concerts.dto.KopisApiDto.concertPlace.ConcertPlaceListResponse;
63
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.*;
74
import com.back.web7_9_codecrete_be.domain.concerts.dto.concertPlace.PlaceDetailResponse;
85
import com.back.web7_9_codecrete_be.domain.concerts.dto.ticketOffice.TicketOfficeElement;
9-
import com.back.web7_9_codecrete_be.domain.concerts.entity.TicketOffice;
106
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertService;
11-
import com.back.web7_9_codecrete_be.domain.concerts.service.KopisApiService;
127
import com.back.web7_9_codecrete_be.domain.users.entity.User;
138
import com.back.web7_9_codecrete_be.global.rq.Rq;
149
import com.back.web7_9_codecrete_be.global.rsData.RsData;
@@ -17,16 +12,9 @@
1712
import io.swagger.v3.oas.annotations.tags.Tag;
1813
import lombok.RequiredArgsConstructor;
1914
import lombok.extern.slf4j.Slf4j;
20-
import org.hibernate.sql.ast.tree.expression.Summarization;
21-
import org.springdoc.core.converters.models.PageableAsQueryParam;
22-
import org.springframework.data.domain.Page;
23-
import org.springframework.data.domain.PageRequest;
2415
import org.springframework.data.domain.Pageable;
25-
import org.springframework.data.domain.Sort;
26-
import org.springframework.stereotype.Controller;
2716
import org.springframework.web.bind.annotation.*;
2817

29-
import javax.swing.*;
3018
import java.util.List;
3119

3220
@Slf4j
@@ -201,12 +189,41 @@ public RsData<PlaceDetailResponse> placeDetail(
201189
@Schema(description = """
202190
<h3>조회 기준이 되는 concertId입니다.</h3>
203191
<hr/>
204-
DB에 저장되어 있는 공연의 ID 값을 기준으로 해당 공연의 공연장 상세 정보를조회합니다. <br/>
192+
DB에 저장되어 있는 공연의 ID 값을 기준으로 해당 공연의 공연장 상세 정보를 조회합니다. <br/>
205193
<strong>?concertId={concertId}</strong> 로 값을 넘기시면 됩니다.
206194
""")
207195
long concertId
208196
){
209197
return RsData.success(concertService.getConcertPlaceDetail(concertId));
210198
}
211199

200+
@Operation(summary = "검색어 자동완성", description = "주어진 문자열을 가지고 있는 결과를 조회합니다.")
201+
@GetMapping("autoComplete")
202+
public RsData<List<AutoCompleteItem>> autoCompleteConcert(
203+
@RequestParam
204+
@Schema(description = """
205+
<h3>검색어 입니다.</h3>
206+
<hr/>
207+
메모리에 캐싱되어 있는 공연의 정보들을 검색하고 표시합니다. <br/>
208+
검색 결과는 조회수 순으로 나옵니다.
209+
""")
210+
String keyword,
211+
@RequestParam
212+
@Schema(description = """
213+
<h3>검색 시작 인덱스입니다.</h3>
214+
<hr/>
215+
결과 목록 중 start에 입력한 번호의 결과부터 데이터가 나옵니다.<br/>
216+
""")
217+
int start,
218+
@RequestParam
219+
@Schema(description = """
220+
<h3>검색 종료 인덱스입니다.</h3>
221+
<hr/>
222+
end에 입력한 번호까지 데이터가 나옵니다.<br/>
223+
""")
224+
int end
225+
){
226+
return RsData.success(concertService.autoCompleteSearch(keyword,start,end));
227+
}
228+
212229
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.back.web7_9_codecrete_be.domain.concerts.dto.concert;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class AutoCompleteItem {
7+
private String name;
8+
private Long Id;
9+
10+
public AutoCompleteItem(String name, Long id) {
11+
this.name = name;
12+
Id = id;
13+
}
14+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.back.web7_9_codecrete_be.domain.concerts.dto.concert;
2+
3+
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class WeightedString {
8+
private Long concertId;
9+
private String word;
10+
private double score;
11+
12+
public WeightedString(String word, int score) {
13+
this.word = word;
14+
this.score = score;
15+
}
16+
17+
public WeightedString(Concert concert) {
18+
this.concertId = concert.getConcertId();
19+
this.word = concert.getName();
20+
this.score = ((double)concert.getViewCount()) * 0.1;
21+
}
22+
23+
24+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.back.web7_9_codecrete_be.domain.concerts.repository;
2+
3+
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.AutoCompleteItem;
4+
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.WeightedString;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.data.redis.core.RedisCallback;
8+
import org.springframework.data.redis.core.RedisTemplate;
9+
import org.springframework.stereotype.Repository;
10+
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.ArrayList;
13+
import java.util.Collections;
14+
import java.util.List;
15+
import java.util.Set;
16+
17+
@Slf4j
18+
@Repository
19+
@RequiredArgsConstructor
20+
public class ConcertSearchRedisTemplate {
21+
private final RedisTemplate<String,String> redisTemplate;
22+
23+
private static final String INDEX_KEY = "index:";
24+
private static final String DATE_KEY = "data:";
25+
private static final String CONCERT_ID_KEY = "concertName:";
26+
27+
public void addAllWordsWithWeight(List<WeightedString> weightedStrings) {
28+
// PipeLine 사용해서 한번에 처리 -> IO 시간 감소
29+
redisTemplate.executePipelined((RedisCallback<?>) connection ->{
30+
for (WeightedString weightedString : weightedStrings) {
31+
String id = String.valueOf(weightedString.getConcertId());
32+
String word = weightedString.getWord();
33+
double score = weightedString.getScore();
34+
35+
// 개별 문자들에 대한 키-값 설정
36+
connection.commands().set((CONCERT_ID_KEY + word).getBytes(StandardCharsets.UTF_8), id.getBytes(StandardCharsets.UTF_8));
37+
38+
for(int i = 0 ;i<word.length();i++){
39+
for(int j = i+1;j<= word.length();j++ ){
40+
String subWord = word.substring(i,j);
41+
42+
// 공백은 검색어 인덱스에서 제외
43+
if(subWord.isBlank()) continue;
44+
45+
byte[] indexKey = (INDEX_KEY + subWord).getBytes(StandardCharsets.UTF_8);
46+
connection.zAdd(indexKey,score,word.getBytes(StandardCharsets.UTF_8));
47+
}
48+
}
49+
}
50+
return null;
51+
});
52+
}
53+
54+
public List<AutoCompleteItem> getAutoCompleteWord(String keyword, int start, int end) {
55+
Set<String> results = redisTemplate.opsForZSet().reverseRange(INDEX_KEY + keyword, 0, 9);
56+
List<String> resultList = new ArrayList<>(results);
57+
return resultList.stream().map(name ->{
58+
Long id = Long.valueOf(redisTemplate.opsForValue().get(CONCERT_ID_KEY + name));
59+
return new AutoCompleteItem(name,id);
60+
}).toList();
61+
}
62+
63+
public void deleteAutoCompleteWords() {
64+
Set<String> keys = redisTemplate.keys("index:*");
65+
Set<String> datas = redisTemplate.keys("data:*");
66+
if (keys != null || !keys.isEmpty()) redisTemplate.delete(keys);
67+
if (datas != null || !keys.isEmpty()) redisTemplate.delete(datas);
68+
}
69+
70+
public Long getConcertIdByName(String concertName) {
71+
String raw = redisTemplate.opsForValue().get(CONCERT_ID_KEY + concertName);
72+
return raw == null ? null : Long.parseLong(raw);
73+
}
74+
}

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/service/ConcertNotifyService.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626

2727
@Slf4j
2828
@Service
29-
@EnableScheduling
3029
@RequiredArgsConstructor
3130
public class ConcertNotifyService {
3231
private final UserRepository userRepository;
@@ -106,7 +105,6 @@ private Map<String, List<Long>> getSendingEmailFromLikeUser(List<Concert> concer
106105
return emailMap;
107106
}
108107

109-
@Scheduled(cron = "0 0 9 * * *")
110108
public String sendTodayTicketingConcertsNotifyingEmail() {
111109
List<Concert> concerts = getTodayTicketingConcerts();
112110
// 빠른 조회를 위해 Map으로 변환

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/service/ConcertService.java

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
package com.back.web7_9_codecrete_be.domain.concerts.service;
22

33
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.*;
4+
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.WeightedString;
45
import com.back.web7_9_codecrete_be.domain.concerts.dto.concertPlace.PlaceDetailResponse;
56
import com.back.web7_9_codecrete_be.domain.concerts.dto.ticketOffice.TicketOfficeElement;
67
import com.back.web7_9_codecrete_be.domain.concerts.entity.*;
78
import com.back.web7_9_codecrete_be.domain.concerts.repository.*;
89
import com.back.web7_9_codecrete_be.domain.users.entity.User;
910
import com.back.web7_9_codecrete_be.global.error.code.ConcertErrorCode;
10-
import com.back.web7_9_codecrete_be.global.error.code.ErrorCode;
1111
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
1212
import lombok.RequiredArgsConstructor;
1313
import lombok.extern.slf4j.Slf4j;
1414
import org.springframework.data.domain.Pageable;
15-
import org.springframework.scheduling.annotation.EnableScheduling;
16-
import org.springframework.scheduling.annotation.Scheduled;
1715
import org.springframework.stereotype.Service;
1816
import org.springframework.transaction.annotation.Transactional;
1917

@@ -27,7 +25,6 @@
2725
@Slf4j
2826
@Service
2927
@RequiredArgsConstructor
30-
@EnableScheduling
3128
public class ConcertService {
3229
private final ConcertRepository concertRepository;
3330

@@ -41,6 +38,8 @@ public class ConcertService {
4138

4239
private final ConcertRedisRepository concertRedisRepository;
4340

41+
private final ConcertSearchRedisTemplate concertSearchRedisTemplate;
42+
4443
// 공연 목록 조회
4544
public List<ConcertItem> getConcertsList(Pageable pageable, ListSort sort) {
4645
List<ConcertItem> concertItems;
@@ -78,6 +77,26 @@ public List<ConcertItem> getConcertListByKeyword(String keyword, Pageable pageab
7877
return concertRepository.getConcertItemsByKeyword(keyword, pageable);
7978
}
8079

80+
// 자동완성
81+
public List<AutoCompleteItem> autoCompleteSearch(String keyword, int start, int end) {
82+
return concertSearchRedisTemplate.getAutoCompleteWord(keyword, start, end);
83+
}
84+
85+
// 자동완성 초기화
86+
public void resetAutoComplete(){
87+
concertSearchRedisTemplate.deleteAutoCompleteWords();
88+
}
89+
90+
// 자동완성 단어저장 v2
91+
public void setAutoComplete(){
92+
List<Concert> concerts = concertRepository.findAll();
93+
List<WeightedString> weightedStrings = concerts.stream()
94+
.map(WeightedString::new)
95+
.toList();
96+
concertSearchRedisTemplate.addAllWordsWithWeight(weightedStrings);
97+
}
98+
99+
81100
// 공연 상세 조회 조회시 조회수 1 증가 -> 캐싱에 따른 조회수 불일치 해소를 어떻게 할 것인가? V -> 이제 캐싱된거 날리고 새로운 수치 반영 어케할 것인지 + 여러번 조회수 올릴 시 처리 어떻게 할지
82101
@Transactional
83102
public ConcertDetailResponse getConcertDetail(long concertId) {
@@ -100,7 +119,6 @@ public ConcertDetailResponse getConcertDetail(long concertId) {
100119

101120
// 조회수 갱신
102121
@Transactional
103-
@Scheduled(cron = "0 0 5 * * * ")
104122
public void viewCountUpdate(){
105123
Map<Long,Integer> viewCountMap = concertRedisRepository.getViewCountMap();
106124
if(viewCountMap == null || viewCountMap.isEmpty()) {

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/service/KopisApiService.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
@Slf4j
3333
@Service
3434
@EnableAsync
35-
@EnableScheduling
3635
public class KopisApiService {
3736
// 공연예술통합 전산망 조회를 위한 서비스 클래스입니다.
3837
private final ConcertRepository concertRepository;
@@ -174,6 +173,7 @@ public void setConcertsList() throws InterruptedException {
174173
} catch (Exception e) {
175174
log.error("개별 공연 세부 내용 저장 도중 오류 발생");
176175
log.error("오류 내용 : " + e.getMessage());
176+
e.printStackTrace();
177177
return ;
178178
}
179179
ConcertUpdateTime concertUpdateTime = new ConcertUpdateTime(now);
@@ -185,10 +185,7 @@ public void setConcertsList() throws InterruptedException {
185185
concertRedisRepository.unlockSave(key);
186186
}
187187

188-
189-
// 매주 월요일 새벽 2시 기준으로 데이터 갱신
190188
@Transactional
191-
@Scheduled(cron = "0 0 2 * * Mon")
192189
public SetResultResponse updateConcertData() throws InterruptedException {
193190
String key = "init";
194191
String value = concertRedisRepository.lockGet(key);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.back.web7_9_codecrete_be.global.scheduler;
2+
3+
import com.back.web7_9_codecrete_be.domain.concerts.controller.ConcertController;
4+
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertSearchRedisTemplate;
5+
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertNotifyService;
6+
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertService;
7+
import com.back.web7_9_codecrete_be.domain.concerts.service.KopisApiService;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
10+
import org.springframework.scheduling.annotation.Scheduled;
11+
import org.springframework.stereotype.Component;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class ConcertScheduler {
16+
private final ConcertService concertService;
17+
private final KopisApiService kopisApiService;
18+
private final ConcertNotifyService concertNotifyService;
19+
20+
// 공연 데이터 업데이트를 진행합니다.
21+
@Scheduled(cron = "0 0 2 * * *")
22+
public void concertUpdateSchedule() throws InterruptedException {
23+
kopisApiService.updateConcertData();
24+
}
25+
26+
// 공연 관련 정보를 갱신합니다.
27+
@Scheduled(cron = "0 0 3 * * *")
28+
public void concertDataUpdateSchedule() {
29+
concertService.viewCountUpdate();
30+
concertService.resetAutoComplete();
31+
concertService.setAutoComplete();
32+
}
33+
34+
// 이메일 알림을 전송합니다.
35+
@Scheduled(cron = "0 0 9 * * *")
36+
public void notificationSendSchedule() {
37+
concertNotifyService.sendTodayTicketingConcertsNotifyingEmail();
38+
}
39+
40+
}

0 commit comments

Comments
 (0)