๐ ๋ชฉ์ฐจ
SpringBoot๋ฅผ ๊ธฐ๋ฐ์ผ๋ก AIํ์ฉ ๋น์ฆ๋์ค ํ๋ก์ ํธ๋ก '์์ ์ฃผ๋ฌธ ๊ด๋ฆฌ ํ๋ซํผ'์ ๊ฐ๋ฐํ๊ฒ ๋์๋ค.
์์์ ๊ณผ ๋ฆฌ๋ทฐ ๋๋ฉ์ธ์ ๋งก์ ๊ธฐ๋ฅ์ ๊ตฌํํ๊ฒ ๋์๋๋ฐ,
๊ฐ๊ฒ ์กฐํ ์ ๋ฆฌ๋ทฐ์ ํ์ ๊ณผ ๊ฐ์๊ฐ ํจ๊ป ์กฐํ๋์ด์ผ ํ๋ ์๊ตฌ์ฌํญ์ด ์์๋ค.
์ฌ๋ฌ ๋ฒ์ ๊ณ ๋ฏผ์ ๊ฑฐ์ณ ๋ฆฌ๋ทฐ ํ์ ๊ณผ ๋ฆฌ๋ทฐ ๊ฐ์๋ฅผ N+1 ๋ฌธ์ ์์ด ์กฐํ ๊ฐ๋ฅํ๋๋ก ๊ตฌํํ์๋ค.
์ด๋ค ๋ฌธ์ ์ ์ด ์์๋์ง์ ๊ทธ ํด๊ฒฐ๋ฐฉ๋ฒ์ ๋ํด ์์๋ณด์.
๐ ํ๋ก์ ํธ ์๊ตฌ์ฌํญ
์ด ํ๋ก์ ํธ์ ํ์ ๊ธฐ๋ฅ ์๊ตฌ์ฌํญ ์ค ํ๋์ด๋ค. ๊ธฐ๋ฅ ์๊ตฌ์ฌํญ์ ์ดํด๋ณด๋ฉด, ๊ฐ๊ฒ ๋ชฉ๋ก์ ์กฐํ ์ ํ์ ์ ๋ ธ์ถํด์ผ ํ๋ค๊ณ ๋์ด์๋ค.
์ฒ์์ ๊ธฐ๋ฅ๋ง ์๊ฐํ๊ณ ๊ตฌํ์ ํ๋ค ๋ณด๋ ๊ฐ๊ฒ๋ฅผ ์์ธ ์กฐํํ๊ฑฐ๋ ๊ฒ์ํด์ ๊ฐ๊ฒ ๋ชฉ๋ก์ ์กฐํํ ๋, ๋ฆฌ๋ทฐ ์์ ํ์ ์ ๊ทธ๋๊ทธ๋ ๊ณ์ฐํ์ฌ ๋ณด์ฌ์ฃผ๋๋ก ๋ก์ง์ ์์ฑํ๋ค.
๐ก ๋ฆฌ๋ทฐ ๋ฐ ํ์ ๊ธฐ๋ฅ
โ๏ธ ์ฃผ๋ฌธ์ ํตํด ๊ฐ๊ฒ์ ๋ฆฌ๋ทฐ ๋ฐ ํ์ ์ ์ ์ฅํฉ๋๋ค.
โ๏ธ ์ฃผ๋ฌธ ๊ฒ์ ์กฐํ ์ ์์์ ๊ณ ์ณ๊ฐ์ ํตํด ํธ์ถํ๋ฉด ๋ฆฌ๋ทฐ ๋ฆฌ์คํธ๋ฅผ ๋ณผ ์ ์์ต๋๋ค.
โ๏ธ ํ์ ์ 1 - 5์ ์ผ๋ก ๊ณ์ฐํฉ๋๋ค.
โ๏ธ ๊ฐ๊ฒ ๋ชฉ๋ก์ ์กฐํ ์ ํ์ ์ ๋ ธ์ถํ๋ ค๋ฉด ์ด๋ป๊ฒ ๊ตฌํํด์ผ ํ ์ง ๊ณ ๋ฏผํด ๋ด ๋๋ค.
(๊ฐ๊ฒ ๋ชฉ๋ก์ ํธ์ถ ์์ ํ์ ์ ๊ณ์ฐํ๋ฉด n+1 ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.)
@Service
@RequiredArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
private final ReviewRepository reviewRepository;
@Transactional(readOnly = true)
public StoreDetailResponseDto getStore(UUID storeId) {
Store store = findStore(storeId);
Integer reviewCount = reviewRepository.countByStoreId(storeId);
BigDecimal ratingAvg = reviewRepository.calculateAverageRatingByStoreId(storeId);
return new StoreDetailResponseDto(
storeId,
String.valueOf(store.getCategory()),
store.getName(),
store.getContent(),
store.getAddress(),
store.getPhone(),
ratingAvg,
reviewCount
);
}
}
public interface ReviewRepository extends JpaRepository<Review, UUID> {
@Query("SELECT COUNT(r) FROM Review r WHERE r.store.id = :storeId AND r.deletedAt IS NULL")
Integer countByStoreId(UUID storeId);
@Query("SELECT AVG(r.star) FROM Review r WHERE r.store.id = :storeId AND r.deletedAt IS NULL")
BigDecimal calculateAverageRatingByStoreId(UUID storeId);
}
๊ฐ๊ฒ ์กฐํ ์์ ๊ณ์ฐ์ด ํจ๊ป ์ด๋ฃจ์ด์ง๋๋ก ์์ฑํ๊ณ , ๊ทธ ๊ฒฐ๊ณผ๋ก ๊ฐ๊ฒ๋ฅผ ํ ๋ฒ ์กฐํํ ๋๋ง๋ค ์ด๋ ๊ฒ ์ด 3๋ฒ์ ์ฟผ๋ฆฌ๊ฐ ์์ฑ๋์ด ๋ฒ๋ ธ๋ค.
์ค๊ณ๋จ๊ณ์์ ์บ์ฑ๊น์ง๋ ์ฌ์ฉํ์ง ์์ ์๊ฐ์ด์๊ณ , ์ฐ๋ คํ๋ N+1๋ฌธ์ ๋ ์๋์ง๋ง ๊ฒฐ๊ณผ์ ์ผ๋ก๋ ์ฑ๋ฅ์ ์ ์ข์ ์ํฅ์ ๋ผ์น ์ ์๋ค๊ณ ํ๋จ๋์ด ๋ก์ง์ ์์ ํ๊ฒ ๋์๋ค.
๐ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ํ๋ ๋ฐฉ์
์ฐ์ '๋ฆฌ๋ทฐ๊ฐ ์์ฑ/์์ /์ญ์ ๋ ๋๋ง๋ค ์ค์๊ฐ์ผ๋ก ํ๊ท ํ์ ๊ณผ ๊ฐ์๋ฅผ ์ ๋ฐ์ดํธํ ๊ฒ์ธ๊ฐ?'๋ผ๋ ๊ณ ๋ฏผ์ ๋ํ ๋ต์ ์์ฐจ์ ์ผ๋ก ์ฐพ์๋ณด๋๋ก ํ์. ๋ฆฌ๋ทฐ์ ํ์ ๊ณผ ๊ฐ์๋ฅผ ๋ฐ์ํ๋ ๋ฐฉ์์ ๋ ๊ฐ์ง๋ก ๋๋ด๋ค. ์ด ๋ ๊ฐ์ง ๋ฐฉ๋ฒ ์ค์ ์ด๋ค ๋ฐฉ์์ด ๋ ๋์์ง ์ฅ๋จ์ ์ ๋น๊ตํด ๋ณด์.
- DB์ ์ ์ฅํ์ง ์๊ณ ๊ฐ๊ฒ ์กฐํ ์, ๋ฆฌ๋ทฐ๋ฅผ ๋์ ์ผ๋ก ๊ณ์ฐํ์ฌ ๋งค๋ฒ ์ต์ ๋ฐ์ดํฐ๋ฅผ ์ ์งํ๋ค.
- DB์ ์ ์ฅํ์ฌ ์กฐํ๋ฅผ ํด์ค๋ฉฐ, ๋ฆฌ๋ทฐ ๋ณ๊ฒฝ ์์๋ง ์ ๋ฐ์ดํธ๋ฅผ ํ๋ค.
๊ตฌ๋ถ | ๋์ ๊ณ์ฐ | DB ์ ์ฅ ๋ฐฉ์ |
๋ฆฌ๋ทฐ ๊ฐฏ์ ๋ฐ ํ์ ์ ์ฅ ์์น | ๋งค ์์ฒญ๋ง๋ค ๋์ ์ผ๋ก ๊ณ์ฐ | Store ์ํฐํฐ์ reviewCount์ ratingAvg ํ๋์ ์ ์ฅ |
๋ฐ์ดํฐ ์ ํฉ์ฑ ์ ์ง | ํญ์ ์ต์ ์ํ ์ ์ง๋จ | ๋ฆฌ๋ทฐ ์ถ๊ฐ/์์ /์ญ์ ์ ์๋ ์ ๋ฐ์ดํธ ํ์ |
์ฑ๋ฅ | ๋งค๋ฒ ์ฟผ๋ฆฌ๋ฅผ ์คํํด์ผ ํ๋ฏ๋ก ์ฑ๋ฅ ์ ํ ๊ฐ๋ฅ | ์กฐํ ์ ๋ฐ๋ก ๊ฐ์ ธ์ฌ ์ ์์ด ์ฑ๋ฅ ํฅ์ |
์ ์ง๋ณด์์ฑ | ์๋น์ค ๋ก์ง์ด ๋ณต์กํ ์ ์์ | ๋ฆฌ๋ทฐ ๋ณ๊ฒฝ ์์๋ง ์ ๋ฐ์ดํธ ํ๋ฉด ๋๋ฏ๋ก ๋จ์ |
ํธ๋์ญ์ ์ฒ๋ฆฌ | ๋ณ๋ ๊ด๋ฆฌ ํ์ ์์ | ๋ฆฌ๋ทฐ ๋ณ๊ฒฝ ์ ํธ๋์ญ์ ๋ด์์ ์ ๋ฐ์ดํธ ํ์ |
๋ ๊ฐ์ง ๋ฐฉ์์ ์ฅ๋จ์ ๋น๊ต๋ฅผ ํตํด ๋งค๋ฒ ์ฆ์ ๊ณ์ฐ์ ํ๊ฒ ๋๋ฉด ๊ทธ๋๋ง๋ค ์ฟผ๋ฆฌ๊ฐ ์คํ๋๊ณ , ๋ฐ์ดํฐ๊ฐ ๋ง์์ง์๋ก ์ฑ๋ฅ์ ์ํฅ์ ์ค ๊ฒ์ด๋ผ๊ณ ํ๋จํ์ฌ DB์ ์ ์ฅํ๋ ๋ฐฉ์์ ์ ํํ๋ค.
๊ทธ๋ค์์ผ๋ก ์๊ฐํ ๋ถ๋ถ์ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ์ ์ ๋ฐ์ดํธ ์์ ์ด์๋ค. ๋ฐ์ดํฐ๊ฐ ๋ง์์ง์๋ก ์ค์๊ฐ ์ ๋ฐ์ดํธ๋ ๋ถ๋ด์ด ๋ ์ ์๋ค. ๊ทธ๋ ๋ค๋ฉด ์ค์๊ฐ์ด ์๋ ์ผ์ ์ฃผ๊ธฐ๋ง๋ค ํ ๋ฒ์ ์ ๋ฐ์ดํธ๋ฅผ ํ๋ค๋ฉด ์ฑ๋ฅ์ ์ผ๋ก ์ด์ ์ด ์์ง ์์๊น?
๐ก ๋ณ๊ฒฝ๋ ๋ฐ์ดํฐ๋ฅผ ์ผ์ ์ฃผ๊ธฐ๋ก ์ ๋ฐ์ดํธ๋ฅผ ํด๋ณผ๊น?
๋ค์๊ณผ ๊ฐ์ ๊ณ ๋ฏผ์ ํด๋ณธ ๊ฒฐ๊ณผ, ์ค์๊ฐ ์ ๋ฐ์ดํธ๋ฅผ ํ๋ ๊ฒ์ ๋ฐ์ดํฐ๊ฐ ๋ง์์ง์๋ก ๋ถ๋ด์ด ๋ ์ ์๊ธฐ ๋๋ฌธ์ ์ผ์ ์ฃผ๊ธฐ๋ง๋ค ์ ๋ฐ์ดํธ๋ฅผ ํ ์ ์๋๋ก ์คํ๋ง ๋ถํธ์์ ์ง์ํ๋ ์ค์ผ์ค๋ฌ์ ์ฌ์ฉ์ ๊ณ ๋ คํ๊ฒ ๋์๋ค.
๐ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ์ ๋ณ๊ฒฝ ๊ฐ์ง
์ค์ผ์ค๋ฌ์ ๋์ ์ ๊ณ ๋ คํ๋ฉด์ ์ด๋ค ๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋์๋์ง ์ถ์ ํ๋ ๋ฐฉ๋ฒ์ด ํ์ํ๋ค. ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ์ ๋ณ๊ฒฝ ๊ฐ์ง๋ ์ด๋ป๊ฒ ์ด๋ฃจ์ด์ง๋ ๊ฒ์ด ์ข์๊น? ์ฐ์ , ๋ฆฌ๋ทฐ์ ์ด ๊ฐ์์ ํ์ ์ ๊ณ์ฐํ๋ ค๋ฉด ์๋์ ๋ณ๊ฒฝ ์ฌํญ์ ํ์ธํ์ฌ ๋ฐ์ํด์ผ ํ๋ค.
โ๏ธ ๋ฆฌ๋ทฐ๊ฐ ์์ฑ๋์์ ๊ฒฝ์ฐ
→ ์ด ๊ฐฏ์ ์ฆ๊ฐ, ํ๊ท ํ์ ์ ๋ฐ์ดํธ ํ์
โ๏ธ ๋ฆฌ๋ทฐ๊ฐ ์์ ๋์์ ๊ฒฝ์ฐ
→ ํ์ ์ด ๋ณ๊ฒฝ๋ ์ ์์ผ๋ฏ๋ก ํ๊ท ํ์ ์ ๋ฐ์ดํธ ํ์
โ๏ธ ๋ฆฌ๋ทฐ๊ฐ ์ญ์ ๋์์ ๊ฒฝ์ฐ
→ ์ด ๊ฐฏ์ ๊ฐ์, ํ๊ท ํ์ ์ ๋ฐ์ดํธ ํ์
์ฆ, ์์ฑ/์์ /์ญ์ ๋ ๋ฆฌ๋ทฐ๋ฅผ ๋ชจ๋ ์กฐํํด์ ์คํ ์ด๋ณ๋ก ํต๊ณ๋ฅผ ๋ค์ ๊ณ์ฐํด์ผ ํ๋ค.
๐ก ์ด๋, ๋ชจ๋ ๊ฐ๊ฒ๋ฅผ ์กฐํํด์ ๊ฐ ๊ฐ๊ฒ์ ๋ฆฌ๋ทฐ ์์ ํ์ ์ ๋ค์ ๊ณ์ฐํ๊ณ ์ ๋ฐ์ดํธํ๋ค๋ฉด ์ด๋จ๊น?
DB์์ createdAt, updateAt, deletedAt์ ์ง์ ์กฐํํ๋ ๊ฒฝ์ฐ, ๋ฐ์ดํฐ๊ฐ ๋ง์์ง์๋ก OR ์กฐ๊ฑด ๋๋ฌธ์ ์ธ๋ฑ์ค ํ์ฉ์ด ์ด๋ ค์์ง๊ณ ์ฑ๋ฅ ์ ํ์ ๊ฐ๋ฅ์ฑ์ด ์๋ค. ๋ํ, ๋ฆฌ๋ทฐ ํ ์ด๋ธ ์ ์ฒด๋ฅผ ์กฐํํ ๊ฐ๋ฅ์ฑ์ด ๋๊ธฐ ๋๋ฌธ์ Full Table Scan์ ์ํ์ฑ๋ ์๋ค.
์ด๋ฐ ๋ฌธ์ ์ ์ ํด๊ฒฐํ๋ ค๋ฉด ์ด๋ค ๋ฐฉ์์ ํํด์ผ ํ ๊น ๊ณ ๋ฏผํ๋ ์ค์
'๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋ ํด๋น ๊ฐ๊ฒ์ ID๋ง์ ์ฌ์ฉํด์ ๊ทธ ๊ฐ๊ฒ์ ๋ํด์๋ง ํต๊ณ๋ฅผ ์ ๋ฐ์ดํธํ๋ฉด ์ด๋จ๊น?'๋ผ๋ ์๊ฐ์ ํ๊ฒ ๋์๋ค.
๐ ๋ณ๊ฒฝ๋ ๊ฐ๊ฒ์ ID๋ฅผ ๊ธฐ๋กํ๋ ๋ฐฉ์
๊ทธ๋ ๋ค๋ฉด '๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋ ๊ฐ๊ฒ์ ID๋ ์ด๋์ ๊ธฐ๋กํ๋ ๊ฒ ์ข์๊น? ๋ก๊ทธ๋ฅผ ๋จ๊ธธ ์ ์๋๋ก ํ ์ด๋ธ์ ํ๋ ๋ ์์ฑํด์ผ ํ ๊น?' ์ด๋ค ๋ฐฉ๋ฒ์ด ํจ์จ์ ์ผ์ง ๊ณ ๋ฏผํ๋ค๊ฐ ๋ถํ์ํ๊ฒ ๋ฐ์ดํฐ๊ฐ ์์ผ ๊ฒ์ ๊ณ ๋ คํด์ ์ธ๋ฉ๋ชจ๋ฆฌ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ธ Redis๋ฅผ ์ฌ์ฉํด ๋ณด๋ฉด ์ด๋จ๊น ์๊ฐํ๊ฒ ๋์๋ค. ๋ง์นจ ํ์ฌ ํ๋ก์ ํธ์์ Redis๋ฅผ ํ์ฉํ AccessToken๊ณผ RefreshToken์ผ๋ก ๋ก๊ทธ์์ ๊ธฐ๋ฅ์ ๊ตฌํํ๊ณ ์์๊ณ , Redis์ Set์ ์ฌ์ฉํด ๋ณด๊ธฐ๋ก ๊ฒฐ์ ํ๋ค.
Redis์ Set์ ์ฌ์ฉํ๋ค๋ฉด ์๋ฃ๊ตฌ์กฐ ํน์ฑ์ ์ด์ ์ผ๋ก ์ธํด ๋น ๋ฅธ ์กฐํ๊ฐ ๊ฐ๋ฅํ๊ณ , ์ค๋ณต์ ๋ฐฉ์งํ ์ ์๋ค. TTL๋ ์ค์ ํ ์ ์๊ณ , ๋ฆฌ๋ทฐ ํต๊ณ๋ฅผ ์ ๋ฐ์ดํธ ํ ๋ค, ํด๋น ๋ฆฌ์คํธ๋ฅผ ๋น์ธ ์๋ ์๋ค.
โ๏ธ ์ด๋ ๊ฒ ์ต์ข ์ ์ผ๋ก 'Redis Set + Scheduler'์ ์กฐํฉ์ผ๋ก ๊ตฌํํด ๋ณด๊ธฐ๋ก ๊ฒฐ์ ํ๋ค.
ํด๋น ๋ก์ง์ ๋ํด ๋ฆฌํฉํ ๋ง์ ์งํํด ๋ณด์.
์กฐํ๋ฅผ ํธ์ถ ์ ๋งค๋ฒ ๊ณ์ฐ์ด ์ด๋ฃจ์ด์ง๋ ๊ฒ ์๋ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ์ ๋ณ๊ฒฝ์ด ์์ ๊ฒฝ์ฐ์๋ง ๊ณ์ฐ์ ํ๊ณ ์ต์ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ํ ์ ์๋๋ก, ๋ณ๊ฒฝ ์ฌํญ์ด ์๋ค๋ฉด select ์ฟผ๋ฆฌ๋ง ์์ฑ๋ ์ ์๋๋ก ๊ฐ์ ์ ํด๋ณด์.
// ๊ธฐ์กด ์๋น์ค ๋ก์ง
@Service
@RequiredArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
private final ReviewRepository reviewRepository;
@Transactional(readOnly = true)
public StoreDetailResponseDto getStore(UUID storeId) {
Store store = findStore(storeId);
Integer reviewCount = reviewRepository.countByStoreId(storeId);
BigDecimal ratingAvg = reviewRepository.calculateAverageRatingByStoreId(storeId);
return new StoreDetailResponseDto(
storeId,
String.valueOf(store.getCategory()),
store.getName(),
store.getContent(),
store.getAddress(),
store.getPhone(),
ratingAvg,
reviewCount
);
}
}
// ์์ ํ ์๋น์ค ๋ก์ง
@Service
@RequiredArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
@Transactional(readOnly = true)
public StoreDetailResponseDto getStore(UUID storeId) {
Store store = findStore(storeId);
return new StoreDetailResponseDto(
storeId,
String.valueOf(store.getCategory()),
store.getName(),
store.getContent(),
store.getAddress(),
store.getPhone(),
store.getRatingAvg(), // DB์ ์ ์ฅ๋ ๋ฆฌ๋ทฐ ํ์
store.getReviewCount() // DB์ ์ ์ฅ๋ ๋ฆฌ๋ทฐ ๊ฐฏ์
);
}
}
๊ธฐ์กด์ ์์ฑํ๋ ๋ก์ง์์๋ StoreService์์ ๊ฐ๊ฒ๋ฅผ ์กฐํํ ๋๋ง๋ค ๋ฆฌ๋ทฐ ํ์ ๊ณผ ๊ฐ์๋ฅผ ๊ณ์ฐํ๋ค. DB์ ์ ์ฅ๋ ๋ฆฌ๋ทฐ ํ์ ๊ณผ ๊ฐ์๋ฅผ ์กฐํํด ์ค๋๋ก ์์ ํด ์ค๋ค. ๋ฆฌ๋ทฐ ํต๊ณ์ ๊ด๋ จํด์๋ ReviewService์์ ์ฒ๋ฆฌํด ์ฃผ๋๋ก ์์ ํ ๊ฒ์ด๋ค.
ํด๋น ๋ก์ง์์๋ ๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง๋ค ์ด๋ค ๊ฐ๊ฒ์์ ๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋์๋์ง ์ถ์ ํ๊ณ , ํด๋น ๊ฐ๊ฒ์ ID๋ง์ ์ฌ์ฉํ์ฌ ๊ทธ ๊ฐ๊ฒ์ ๋ํด์๋ง ํต๊ณ๋ฅผ ์
๋ฐ์ดํธํ๋๋ก ๊ตฌํํด ๋ณด์๋ค. ๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง๋ค ๋ณ๊ฒฝ๋ ๊ฐ๊ฒ์ ID๋ง์ ๊ธฐ๋กํด ๋๊ณ , ์ค์ผ์ค๋ฌ๋ ๊ทธ ๊ธฐ๋ก๋ ๊ฐ๊ฒ๋ค๋ง ์
๋ฐ์ดํธํ๋๋ก ์ฒ๋ฆฌํ๋ค. ๋ฆฌ๋ทฐ๊ฐ ์์ฑ ์์ ์ญ์ ๋ ๋๋ง๋ค trackStoreId ๋ฉ์๋๋ฅผ ํธ์ถํ์ฌ ํด๋น ๊ฐ๊ฒ์ storeId๋ฅผ ๊ธฐ๋กํ๋ค.
@Service
@RequiredArgsConstructor
public class ReviewService {
private final ReviewRepository reviewRepository;
private final OrderRepository orderRepository;
private final StoreRepository storeRepository;
private final ReviewStatisticsScheduler reviewStatisticsScheduler;
@Transactional(readOnly = true)
public Page<StoreReviewsResponseDto> getReviewsByStore(
UUID storeId,
int page,
int size,
String sortedBy,
Direction direction
) {
Pageable pageable = PageRequest.of(page, size, direction, sortedBy);
Page<Review> reviewPage = reviewRepository.findAllByStoreId(storeId, pageable);
Store store = findStore(storeId);
// DB์ ์ ์ฅ๋ ์ ๋ณด๋ฅผ ๊ฐ์ง๊ณ ์จ๋ค.
Integer reviewCount = store.getReviewCount();
BigDecimal ratingAvg = store.getRatingAvg();
return reviewPage.map(review -> new StoreReviewsResponseDto(
review.getId(), review.getUser().getId(),
review.getOrder().getId(), review.getStar(), review.getContent(), ratingAvg, reviewCount));
}
@Transactional
public ReviewResponseDto createReview(ReviewRequestDto requestDto, User user) {
validateCustomer(user);
Order order = findOrder(requestDto);
validateOrderUser(user, order);
// 3์ผ ์ ํ ๊ฒ์ฆ
validateReviewPeriod(order);
// ์ค๋ณต ๋ฆฌ๋ทฐ ์ฒดํฌ
validateReviewDuplicate(requestDto);
Review review = reviewRepository.save(new Review(requestDto, user, order.getStore(), order));
// ๋ฆฌ๋ทฐ ์์ฑ ์ ํด๋น ๊ฐ๊ฒ ID ์ถ์
reviewStatisticsScheduler.trackStoreId(review.getStore().getId());
return new ReviewResponseDto(review);
}
}
Redis๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด ์์กด์ฑ๊ณผ Config class๋ฅผ ์ถ๊ฐํด ์ค ๋ค ์ค์ผ์ค๋ฌ ํด๋์ค๋ฅผ ์์ฑํด ์ค๋ค. ๊ทธ๋ฆฌ๊ณ ์ค์ ์ค์ผ์ค๋ง ์์ ์ ํ ํด๋์ค๋ฅผ ๋ง๋ค์ด์ค๋ค. ํด๋น ํด๋์ค๋ @Component๋ฅผ ํตํด ์คํ๋ง ๋น์ ๋ฑ๋ก๋ ํด๋์ค์ฌ์ผ ํ๋ค.
@Scheduled ์ด๋ ธํ ์ด์ ์ ์ฌ์ฉํ๊ธฐ ์ํด Application Class์ @EnableScheduling์ ์ถ๊ฐํด ์ค๋ค.
@SpringBootApplication
@EnableScheduling
public class DeliveryApplication {
public static void main(String[] args) {
SpringApplication.run(DeliveryApplication.class, args);
}
}
์ค์ผ์ค๋ฌ ๋ฉ์๋๋ void ํ์
์ด์ด์ผ ํ๋ฉฐ, ํด๋น ๋ฉ์๋๋ ๋งค๊ฐ๋ณ์ ์ฌ์ฉ์ด ๋ถ๊ฐํ๋ค. Cron ํํ์์ผ๋ก ์ผ์ ์ฃผ๊ธฐ๋ฅผ ์ง์ ํ ์ ์๋ค. ์ค์ผ์ค๋ฌ์ ๊ดํ ์์ธํ ๋ด์ฉ์ ์ฌ๋ฌ ํฌ์คํ
์ ์ฐธ๊ณ ํ๊ธธ ๋ฐ๋๋ค.
@Component
@RequiredArgsConstructor
@Slf4j
public class ReviewStatisticsScheduler {
private final ReviewRepository reviewRepository;
private final StoreRepository storeRepository;
private final RedisTemplate<String, Object> redisTemplate;
// Redis key ์ค์
private static final String UPDATED_STORE_ID_KEY = "review:updated_storeId";
// TTL 1์๊ฐ ์ค์
private static final long TTL_IN_SECONDS = 3600;
// ๋ฆฌ๋ทฐ ๋ณ๊ฒฝ ์ ํธ์ถํ์ฌ ๋ณ๊ฒฝ๋ ๊ฐ๊ฒ๋ฅผ ์ถ์
public void trackStoreId(UUID storeId) {
log.info("trackStoreId ํธ์ถ๋จ: {}", storeId);
redisTemplate.opsForSet().add(UPDATED_STORE_ID_KEY, storeId.toString());
log.info("Redis์ ์ ์ฅ ์์ฒญ: {}", storeId);
redisTemplate.expire(UPDATED_STORE_ID_KEY, TTL_IN_SECONDS, TimeUnit.SECONDS);
}
// ์ค์ผ์ค๋ฌ์์ ๋ณ๊ฒฝ๋ ๊ฐ๊ฒ๋ง ํต๊ณ ์
๋ฐ์ดํธ
@Scheduled(cron = "0 */30 * * * *") // 30๋ถ ๊ฐ๊ฒฉ์ผ๋ก ์
๋ฐ์ดํธ
public void updateReviewStatistics() {
log.info("๋ฆฌ๋ทฐ ํต๊ณ ์
๋ฐ์ดํธ ์์");
Optional<Set<Object>> updatedStoreIdsOptional = Optional.ofNullable(
redisTemplate.opsForSet().members(UPDATED_STORE_ID_KEY));
updatedStoreIdsOptional.ifPresent(updatedStoreIds -> {
if (!updatedStoreIds.isEmpty()) {
for (Object storeIdObj : updatedStoreIds) {
UUID storeId = UUID.fromString(storeIdObj.toString());
Integer reviewCount = reviewRepository.countByStoreId(storeId);
BigDecimal ratingAvg = reviewRepository.calculateAverageRatingByStoreId(storeId);
storeRepository.updateReviewStatistics(storeId, reviewCount, ratingAvg);
log.info("๊ฐ๊ฒ {} ํต๊ณ ์
๋ฐ์ดํธ ์๋ฃ", storeId);
}
// ์
๋ฐ์ดํธ ํ ์ถ์ ๋ฆฌ์คํธ ๋น์ฐ๊ธฐ
log.info("์ญ์ ์ ํค ์กด์ฌ ์ฌ๋ถ: {}", redisTemplate.hasKey(UPDATED_STORE_ID_KEY));
redisTemplate.delete(UPDATED_STORE_ID_KEY);
log.info("์ญ์ ํ ํค ์กด์ฌ ์ฌ๋ถ: {}", redisTemplate.hasKey(UPDATED_STORE_ID_KEY));
} else {
log.info("๋ณ๊ฒฝ๋ ๊ฐ๊ฒ๊ฐ ์์ต๋๋ค.");
}
});
}
}
ReviewRepository์์๋ ๋ฆฌ๋ทฐ์ ๊ฐ์์ ํ์ ์ ๊ณ์ฐํ๋ ์ฟผ๋ฆฌ๋ฅผ @Query ์ด๋ ธํ ์ด์ ์ ํตํด ์ง์ ์์ฑํด ์ค๋ค.
public interface ReviewRepository extends JpaRepository<Review, UUID> {
@Query("SELECT r FROM Review r WHERE r.store.id = :storeId AND r.deletedAt IS NULL")
Page<Review> findAllByStoreId(UUID storeId, Pageable pageable);
@Query("SELECT COUNT(r) FROM Review r WHERE r.store.id = :storeId AND r.deletedAt IS NULL")
Integer countByStoreId(UUID storeId);
@Query("SELECT AVG(r.star) FROM Review r WHERE r.store.id = :storeId AND r.deletedAt IS NULL")
BigDecimal calculateAverageRatingByStoreId(UUID storeId);
boolean existsByOrderId(UUID orderId);
}
StoreRepository์์๋ @Modifying ์ด๋ ธํ ์ด์ ์ ์ฌ์ฉํ์ฌ ๋ถํ์ํ ์กฐํ ์ฟผ๋ฆฌ๊ฐ ๋ฐ์ํ์ง ์๊ณ , ๋ฐ๋ก ์ ๋ฐ์ดํธํ ์ ์๋๋ก ์ฟผ๋ฆฌ๋ฅผ ์์ฑํด ์ค๋ค. ์ ๋ฐ์ดํธ ํญ๋ชฉ์ด ์์ ๊ฒฝ์ฐ์๋ ์ฟผ๋ฆฌ๊ฐ ์์ฑ๋์ง ์๋๋ค.
@Repository
public interface StoreRepository extends JpaRepository<Store, UUID> {
@Query("SELECT s FROM Store s WHERE s.name LIKE %:keyword% AND s.deletedAt IS NULL ")
Page<Store> findAllByName(String keyword, Pageable pageable);
@Query("SELECT s FROM Store s WHERE s.category = :category AND s.deletedAt IS NULL")
Page<Store> findAllByCategory(@Param("category") StoreCategory storeCategory, Pageable pageable);
@Transactional
@Modifying
@Query("UPDATE Store s SET s.reviewCount = :reviewCount, s.ratingAvg = :ratingAvg WHERE s.id = :storeId")
void updateReviewStatistics(UUID storeId, Integer reviewCount, BigDecimal ratingAvg);
}
์ ๋ก์ง์์๋ ์ค์ผ์ค๋ฌ๊ฐ ๋์ํ๋ ์๊ฐ์ 30๋ถ ๊ฐ๊ฒฉ์ผ๋ก ์ค์ ํด ๋์์ผ๋, ๊ธฐ๋ฅ์ด ์ ๋๋ก ๋์ํ๋์ง ํ์ธํ๊ธฐ ์ํด ๋ ์งง์ ๊ฐ๊ฒฉ์ผ๋ก ์์ ํ์ฌ ํ ์คํธํด ๋ณด์๋ค. ํ ์คํธ ๊ฒฐ๊ณผ๋ ๋ค์๊ณผ ๊ฐ๋ค.
๋ฆฌ๋ทฐ๋ฅผ ์์ฑํ๊ฑฐ๋ ์ญ์ ํ์ ๋, ๋ณ๊ฒฝ๋ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์งํ์ฌ ํต๊ณ๋ฅผ ์ ๋ฐ์ดํธํ๋ ๊ฒ์ ํ์ธํ ์ ์๋ค. ๋ํ, ๊ฐ๊ฒ๋ฅผ ์กฐํํ์ ๊ฒฝ์ฐ์ ์ ๋ฐ์ดํธ๋ ํต๊ณ๋ฅผ select ์ฟผ๋ฆฌ๋ง์ผ๋ก ์กฐํํด ์ค๋ ๊ฒ์ ํ์ธํ ์ ์๋ค. ํ์ ๋ฐ ๊ฐ์๋ ์ ๋๋ก ๊ณ์ฐ๋๋ ๊ฒ์ ํ์ธํ ์ ์๋ค.
์ด๋ ๊ฒ ๋ฆฌ๋ทฐ๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง ํด๋น ๊ฐ๊ฒ๋ฅผ ์ถ์ ํ์ฌ ์ค์ผ์ค๋ฌ๊ฐ ํด๋น ๊ฐ๊ฒ๋ค๋ง ์ ๋ฐ์ดํธํ๋๋ก ๋ฆฌํฉํ ๋ง์ ํด๋ณด์๋ค. ์ ๋ฐ์ดํธํ ํ์๊ฐ ์๋ ๊ฐ๊ฒ๋ ์กฐํํ์ง ์๊ธฐ ๋๋ฌธ์ ํจ์ฌ ํจ์จ์ ์ผ๋ก ๊ด๋ฆฌ๊ฐ ๊ฐ๋ฅํ๋ค. ๊ธฐ์กด์ 3๋ฒ์ ์ฟผ๋ฆฌ๊ฐ ์์ฑ๋์๋ ๊ฒ์ ์๊ฐํด ๋ณด๋ฉด ๋ถํ์ํ ์กฐํ ์์ด ์ฑ๋ฅ์ ์ผ๋ก ๋์์ง ๊ฒ์ ํ์ธํ ์ ์๋ค.
์ด๋ฒ ํ๋ก์ ํธ์์๋ Redis์ Set๊ณผ ์ค์ผ์ค๋ฌ๋ฅผ ํ์ฉํด ์ฑ๋ฅ ๊ฐ์ ์ ํด๋ณด์๋ค.
๋ค์ ํ๋ก์ ํธ์์๋ ๋ฉ์์ง ํ๋ฅผ ํ์ฉํด ๋ณผ ์ ์๋๋ก ํด์ผ๊ฒ ๋ค.
๐ ์ฐธ๊ณ
[Spring][์ผํ๋ชฐ ํ๋ก์ ํธ][50] ์ํ ํ ์ด๋ธ ํ๊ท ํ์ ๋ฐ์
ํ๋ก์ ํธ Github : https://github.com/sjinjin7/Blog_Project ํ๋ก์ ํธ ํฌ์คํ ์์ธ(index) : https://kimvampa.tistory.com/188 ๋ชฉํ ์ํ ํ ์ด๋ธ ํ์ ์ ์ฉ vam_bookํ ์ด๋ธ์ ratingAvg์ปฌ๋ผ์ ์ถ๊ฐํ๊ณ ๋๊ธ '๋ฑ๋ก', '์์ ', '
kimvampa.tistory.com