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

Feat/#543 로컬 캐시에 좋아요 데이터가 실시간으로 반영되도록 수정 #549

Merged
merged 20 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0ed68c1
feat: 로컬 인터셉터 추가
Cyma-s Oct 29, 2023
64e0a88
refactor: TreeMap 으로 노래 캐싱 및 좋아요 실시간 반영
Cyma-s Oct 31, 2023
44f67c5
refactor: 삽입 정렬로 정렬 속도 개선
Cyma-s Oct 31, 2023
21f7976
refactor: 로컬 실행 시 토큰이 아닌 Authorization의 memberId 를 사용하도록 변경
Cyma-s Oct 31, 2023
1fa1b45
config: 로컬 config 롤백
Cyma-s Oct 31, 2023
d0d8fa2
fix: 삽입 정렬 로직 추가
Cyma-s Oct 31, 2023
0f1c9e3
refactor: EntityManager 의존성 이동 및 메서드, 필드 이름 리팩터링
Cyma-s Nov 4, 2023
fd5f35e
refactor: 조건문 메서드 분리
Cyma-s Nov 4, 2023
2c0a4ee
refactor: 접근 제어자 private 으로 변경
Cyma-s Nov 4, 2023
12ae35c
refactor: KillingPart 가 AtomicInteger 를 필드로 갖도록 수정
Cyma-s Nov 4, 2023
4266541
refactor: 사용하지 않는 dto 클래스 삭제
Cyma-s Nov 4, 2023
2373790
refactor: 사용하지 않는 메서드 삭제
Cyma-s Nov 4, 2023
1d3fdcc
refactor: 접근 제어자 private 으로 변경
Cyma-s Nov 4, 2023
43c5651
refactor: 중복 범위 검증 제거
Cyma-s Nov 4, 2023
e700811
refactor: 분기문 제거
Cyma-s Nov 4, 2023
2e8398a
feat: likeCount AtomicInteger 로 변경 및 좋아요 개수 데이터베이스와 동기화하는 로직 추가
Cyma-s Nov 20, 2023
03654ec
fix: setUp 메서드 실행 시점 변경
Cyma-s Nov 20, 2023
dc73563
refactor: synchronized 로 동시성 처리
Cyma-s Dec 15, 2023
358254e
fix: cache 재생성 제거
Cyma-s Jan 16, 2024
1e18d62
Merge branch 'main' into feat/#543
Cyma-s Jan 16, 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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
Expand All @@ -12,6 +13,7 @@
import shook.shook.auth.ui.interceptor.PathMethod;
import shook.shook.auth.ui.interceptor.TokenInterceptor;

@Profile("!local")
Copy link
Collaborator

Choose a reason for hiding this comment

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

LocalAuthConfig가 생기면서 product와 local를 분리할 때 "!"연산자를 이용해 분리한 것 처음 배워갑니다!

@Configuration
public class AuthConfig implements WebMvcConfigurer {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package shook.shook.auth.config;

import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import shook.shook.auth.ui.argumentresolver.AuthArgumentResolver;
import shook.shook.auth.ui.interceptor.LocalInterceptor;

@Profile("local")
Copy link
Collaborator

Choose a reason for hiding this comment

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

전반적인 궁금증인데 LocalInterceptorLocalAuthConfig 를 추가하신 이유가 있나용?
헤더로는 반드시 Authorization {memberId} 를 전달해야 하는 것 같은데 Bearer 형식이 아닌 이유도 궁금해요~!

Copy link
Collaborator

Choose a reason for hiding this comment

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

그리고 LocalAuthConfig에도 토큰 검증이 필요한 경로와 아닌 경로를 명시해주는게 좋을 것 같습니당
로컬에서 swgger를 확인하러 들어갔는데 interceptor에서 토큰 파싱에 대해 NPE가 터지더라구요

image

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Bearer 형식으로 지정하지 않았던 이유는 Local 에서 테스트할 때 필요한 오버헤드를 최대한 줄이고 싶어서입니다! 지금까지는 로컬에서 테스트 할 때도 항상 토큰을 만들어야 해서 너무 불편했는데, 이런 구조의 LocalInterceptor 를 사용하게 되면 테스트가 훨씬 원활해질 거라 생각해서 만들었습니다 :)

@Configuration
public class LocalAuthConfig implements WebMvcConfigurer {

private final AuthArgumentResolver authArgumentResolver;
private final LocalInterceptor localInterceptor;

public LocalAuthConfig(final AuthArgumentResolver authArgumentResolver, final LocalInterceptor localInterceptor) {
this.authArgumentResolver = authArgumentResolver;
this.localInterceptor = localInterceptor;
}

@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(localInterceptor);
}

@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(authArgumentResolver);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package shook.shook.auth.ui.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import shook.shook.auth.ui.AuthContext;

@Profile("local")
@Component
public class LocalInterceptor implements HandlerInterceptor {

private final AuthContext authContext;

public LocalInterceptor(final AuthContext authContext) {
this.authContext = authContext;
}

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler)
throws Exception {
final long memberId = Long.parseLong(request.getHeader("Authorization"));
authContext.setAuthenticatedMember(memberId);

return true;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package shook.shook.song.application;

import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import shook.shook.song.domain.InMemorySongs;
import shook.shook.song.domain.Song;
import shook.shook.song.domain.killingpart.KillingPart;
import shook.shook.song.domain.repository.SongRepository;

@RequiredArgsConstructor
Expand All @@ -17,6 +21,7 @@ public class InMemorySongsScheduler {

private final SongRepository songRepository;
private final InMemorySongs inMemorySongs;
private final EntityManager entityManager;
Copy link
Collaborator

Choose a reason for hiding this comment

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

따봉쓰 👍


@PostConstruct
public void initialize() {
Expand All @@ -26,6 +31,25 @@ public void initialize() {
@Scheduled(cron = "${schedules.in-memory-song.cron}")
public void recreateCachedSong() {
log.info("InMemorySongsScheduler worked");
inMemorySongs.recreate(songRepository.findAllWithKillingParts());
final List<Song> songs = songRepository.findAllWithKillingPartsAndLikes();
detachSongs(songs);
inMemorySongs.refreshSongs(songs);
}

private void detachSongs(final List<Song> songs) {
songs.stream()
.peek(entityManager::detach)
.flatMap(song -> song.getKillingParts().stream())
.forEach(entityManager::detach);
}

@Transactional
@Scheduled(cron = "${schedules.in-memory-song.update-cron}")
public void updateCachedSong() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

일정 주기마다 캐싱된 노래 데이터와 DB 싱크를 맞춰주는 코드 같아요!
준영속 되어있던 킬링파트를 영속화 시키면 해당 데이터가 영속성 컨텍스트에 들어가면서 데이터베이스에 변경사항이 반영되는 흐름이 맞을까요?.?

  1. 이 부분에서 킬링파트만 영속화 시켜서 좋아요 개수만 싱크를 맞추는 이유가 궁금해요! (좋아요 상태는 DB에 실시간으로 반영하기 위함인가요??)
  2. 아직 prod 환경에서의 update-cron은 설정되어 있지 않은 것 같은데, 주기가 어떻게 되는지도 궁금합니다~!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

  1. 빈번하게 조회되는 데이터인 좋아요 개수만 싱크를 맞추도록 했습니다. 좋아요 상태도 한 번에 반영하면 어떨까 생각하기도 했는데 아무래도 마이페이지에 들어갔을 때 좋아요 취소한 파트가 있으면 이상할 것 같더라고요. 그래서 개수만 싱크를 맞추는 것으로 최종 선택했습니다. 어떤가요?
  2. 설정해두었는데 security 싱크가 안 맞고 있는 것 같아요. 이번에 맞춰두었습니다!

log.info("InMemorySongsScheduler LikeCount update progressed");
final List<KillingPart> killingParts = inMemorySongs.getSongs().stream()
.flatMap(song -> song.getKillingParts().stream())
.toList();
killingParts.forEach(entityManager::merge);
Copy link
Collaborator

Choose a reason for hiding this comment

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

그리구 한 번 merge 한 다음에 다시 KillingPart를 detach 해주는 작업도 필요할 것 같은데 이건 따로 작성하지 않아도 되는건가용?.? (몰라서 하는 질문임니닷)

Copy link
Collaborator

Choose a reason for hiding this comment

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

저도 이 부분이 궁금하네요! 따로 다시 detach를 안해도 괜찮은지 궁금합니다.
트랜잭션이 끝나면 영속성 컨텍스트도 같이 종료되니까 따로 안해도 괜찮을 것 같기도 하네요 (저도 이 부분을 잘 모르겠습니다.)

Copy link
Collaborator

Choose a reason for hiding this comment

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

저 궁금한 것이 있는데 updateCachedSong이 동작하는 중간에 killingPart에 좋아요가 발생하는 상황은 따로 고려하지 않아도 되는 것인지도 궁금합니다. (updateCachedSong의 경우 영속성 컨텍스트로 정보를 merge하기에 이 때 좋아요가 발생해도 문제가 없는 것인지 궁금했습니다.)
제가 detach에 대한 개념이 부족해서 이상한 질문일 수도 있습니다..

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

동시성 관련된 부분은 당시 코드에서 고려되지 않았기 때문에 아코가 말씀하셨던 것처럼 updateCacheSong 이 동작하는 중간에 killingPart 에 좋아요가 발생하면 좋아요가 유실될 가능성이 있습니다. synchronized 키워드로 동시성을 보장하도록 코드를 작성했습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

그리고 원래라면 detach 를 해야 하지만, 위에서 말씀드린 것처럼 좋아요 데이터의 deleted 여부는 실시간으로 DB 에 반영되어야 할 것 같아서 하지 않았습니다...!

Copy link
Collaborator

Choose a reason for hiding this comment

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

detach를 하지 않는 이유를 혹시 조금만 더 자세하게 설명해주실 수 있을까요? 제가 잘 이해를 하지 못해서 그렇습니다!🥲

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import shook.shook.member.domain.Member;
import shook.shook.member.domain.repository.MemberRepository;
import shook.shook.member.exception.MemberException;
import shook.shook.song.application.dto.RecentSongCarouselResponse;
import shook.shook.member_part.domain.MemberPart;
import shook.shook.member_part.domain.repository.MemberPartRepository;
import shook.shook.song.application.dto.RecentSongCarouselResponse;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import shook.shook.member.domain.repository.MemberRepository;
import shook.shook.member.exception.MemberException;
import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest;
import shook.shook.song.domain.InMemorySongs;
import shook.shook.song.domain.killingpart.KillingPart;
import shook.shook.song.domain.killingpart.KillingPartLike;
import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository;
Expand All @@ -22,6 +23,7 @@ public class KillingPartLikeService {
private final KillingPartRepository killingPartRepository;
private final MemberRepository memberRepository;
private final KillingPartLikeRepository likeRepository;
private final InMemorySongs inMemorySongs;

@Transactional
public void updateLikeStatus(
Expand Down Expand Up @@ -54,8 +56,8 @@ private void create(final KillingPart killingPart, final Member member) {
final KillingPartLike likeOnKillingPart = likeRepository.findByKillingPartAndMember(killingPart, member)
.orElseGet(() -> createNewLike(killingPart, member));
if (likeOnKillingPart.isDeleted()) {
inMemorySongs.like(killingPart, likeOnKillingPart);
Copy link
Collaborator

Choose a reason for hiding this comment

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

결과적으로 DB에 좋아요가 반영되는 코드는 없는 것 같은데, 캐시 데이터에 저장된 좋아요 개수가 언제 DB에 반영되는 건가요?

아직까지는 동시성 테스트를 해봤을 때, DB의 좋아요 개수는 증가/감소하지 않는 것 같아서요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

DB 좋아요 개수 증감 로직을 안 짰네요... Scheduler 로 좋아요를 batch update 하는 로직도 만들어 보겠습니다! 좋은 지적 감사합니다 역시 바론이네요 ^^

likeRepository.pressLike(likeOnKillingPart.getId());
killingPartRepository.increaseLikeCount(killingPart.getId());
}
}

Expand All @@ -68,8 +70,8 @@ private KillingPartLike createNewLike(final KillingPart killingPart, final Membe
private void delete(final KillingPart killingPart, final Member member) {
killingPart.findLikeByMember(member)
.ifPresent(likeOnKillingPart -> {
inMemorySongs.unlike(killingPart, likeOnKillingPart);
likeRepository.cancelLike(likeOnKillingPart.getId());
Copy link
Collaborator

Choose a reason for hiding this comment

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

좋네용~ 그런데 좋아요 취소할 때는 likeRepository.cancelLike을 호출하지만 좋아요를 누를 때는 pressLike를 호출하지 않는 이유가 있을까요?.?

// create 메서드 일부
if (likeOnKillingPart.isDeleted()) {
    inMemorySongs.like(killingPart, likeOnKillingPart);
    likeRepository.pressLike(likeOnKillingPart.getId());
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

이 부분은 제 실수인 것 같아요 호출하는 코드로 바꿔두었습니다 👍🏻

killingPartRepository.decreaseLikeCount(killingPart.getId());
});
}
}
Loading
Loading