마스터 슬레이브 구조를 적용하기 전, 근본적으로 커넥션 풀의 갯수를 좀 늘려보려 한다.
여기를 참고해서, 히카리에서 제공하는 풀 사이즈 대로 계산을 해보자
Tn * (Cm -1) +1
즉, 최대 쓰레드 수 * ( 하나의 쓰레드가 작업에 필요한 커넥션의 수 -1) +1 이 최적의 커넥션 풀 크기이다.
현재 그룹 정보 조회 API를 호출하게 되면 생기는 트랜잭션은 총 4개 이다.
해당 API가 그룹 서비스에서 가장 많은 로직이 포함 된 메소드를 사용하기에 우선 커넥션의 수를 4로하고 커넥션 풀의 크기를 계산해보면
10*3+1 =31이 된다.
이를 application.yml에 추가해보자.
spring:
datasource:
hikari:
maximum-pool-size: 40
VUser=50으로 한 테스트의 결과다
풀 사이즈를 늘렸는데, 큰 변화는 없었다.
아직 펜딩되는 경우가 발생해서 풀 사이즈를 50까지 늘려보고 다시 테스트 해보자.
조금 개선은 되었으나, 역시 큰 차이는 없다.
전 게시물에서 처럼 DB를 Replication해서 읽기 전용 Slave DB 서버를 만들어 분산 시키는 것을 시도해 볼 수 있다.
하지만, 다시한번 생각해보면 지금 서버가 죽고 응답이 느려지는 이유는 DB서버 및 WAS의 부하 때문이라고 볼 수 있다.
CPU자원이 모두 소모 되어, 성능이 저하되고 서버가 종료되는 현상이 발생하는데 만약 DB를 복제하여 Master-Slave구조로 변경하고 서버를 운영한다고 달라질 수 있을까?
DB서버를 한대의 로컬 PC에서 두개로 복제하게 되면 어떻게 될까?
조금만 생각해봐도 알 수 있겠지만, CPU자원 소모가 줄어들 일은 없다.
100개의 API Request에 대해서 Read가 90% CUD가 10%라면 3306번의 master DB엔 10%의 요청이가고, 3307번의 slave에겐 90%의 요청이 간다.
근데, 어차피 같은 pc라면 동일한 CPU의 자원을 사용하게되므로 이 둘을 나누어도 전체적 CPU사용량이 줄어들지 않는다. (오히려 slave DB운영하는데 더 비용이 많이 들 것 같다..)
물론, DB자체에 걸리는 부하량이 분산되기에 펜딩되는 상태가 줄어들게 되고 그 시간 또한 줄어들게 되어 전반적 성능의 향상이 있을 수 있다.
하지만, 현재 PC내에 백엔드 서버 + 모니터링 서버 여러개 + DB 서버를 띄운 상태로 운영하고 그 외에도 브라우저, 카카오톡.. 등등 여러 프로세스가 실행되고 있기에 Master-Slave 구조를 사용한다고 해서 성능에 개선을 가져다 줄 것 같지 않다.
따라서, Oracle이나 AWS를 사용해서 백엔드 서버 + DB 서버 이런식으로 분할해서 테스트를 진행하지 않는 이상 현 상황에서 Master-Slave 구조를 적용하는 것이 의미있다고 생각되지 않는다.
캐시 적용하기
가만 생각해보면, 현재 같은 계정으로 같은 그룹의 정보를 반복해서 조회하는 테스트를 진행하고있다.
즉, 캐시를 적용하면 매우 효율적으로 정보를 가져올 수 있게 된다.
이를 위해, spring boot가 제공해주는 Cache를 사용해보려 한다.
우선 gradle에 의존성을 추가해준다.
implementation 'org.springframework.boot:spring-boot-starter-cache'
캐시를 사용하기 위해선 캐시 매니저를 빈으로 등록해줘야 한다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(){
return new ConcurrentMapCacheManager();
}
}
Redis,Ehcache등 ConcurrentMapCacheManger말고도 여러 캐시 매니저가 존재한다.
여기선 간단히 ConcurrentMapCacheManger를 구현체로 사용하려 한다.
캐시에서 주로 사용되는 어노테이션은 아래와 같다. (여기 에서 가져왔습니다.)
@EnableCaching | Spring Boot Cache를 사용하기 위해 '캐시 활성화'를 위한 어노테이션을 의미합니다. |
@CacheConfig | 캐시정보를 '클래스 단위'로 사용하고 관리하기 위한 어노테이션을 의미합니다. |
@Cacheable | 캐시정보를 메모리 상에 ‘저장’하거나 ‘조회’ 해오는 기능을 수행하는 어노테이션입니다. |
@CachePut | 캐시 정보를 메모리상에 '저장'하며 존재 시 갱신하는 기능을 수행하는 어노테이션입니다. |
@CacheEvict | 캐시 정보를 메모리상에 '삭제'하는 기능을 수행하는 어노테이션입니다. |
@Caching | 여러 개의 ‘캐시 어노테이션’을 ‘함께 사용’할 때 사용하는 어노테이션입니다. |
쉽게 생각해보면
@Cacheable은 캐시 저장 공간에 해당 데이터를 value, key와 같은 정보 (그냥 이름하나만 적어도 됨)를 저장하는 역할 (단, 해당 이름의 캐시가 없다면 생성하는 역할)
@CachePut은 해당 이름의 정보를 캐시 저장 공간에 저장하는 역할 (단, 이미 존재한다면 갱신하는 역할)
@CacheEvict는 해당 이름의 정보를 캐시에서 찾아 삭제 시키는 역할
@Caching은 이런 어노테이션들을 묶어서 쓸 때 사용하는 것 (뭐, 저장을 여러개 동시에 하든... 수정을 하든.. )
@CacheConfig는 클래스 단위에 적용해서 하위 메소드 단위에서 캐시 어노테이션에 공통적으로 사용할 설정 정보를 묶어서 쓸 수 있게 해주는 것
대충 이렇게 정리했다.
그 외에도, @Cacheable의 sync옵션을 true로 하면 동기화를 사용하게 된다.
즉, thread-safe하지 않은 경우 사용하기 좋고 동기화를 하게 되니 하나의 쓰레드만 해당 캐시를 접근할 수 있게 된다.
개념은 대충 이렇게만 알아도 될 것 같으니 이제 코드에 적용을 해보자.
API호출에 사용되는 메인 서비스 로직
public GroupDto.DetailGroupRes getGroupDetails(Long groupId){
Team team = teamRepository.findFirstWithCategoryById(groupId).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
if(!this.checkBelong(groupId)&&!this.checkPublic(groupId))
throw new BaseException(BaseResponseStatus.NOT_PUBLIC_ERROR);
List<CommentDto.CommentRes> detailComments = boardService.getDetailComments(groupId);
Member member = memberService.getMember();
String status="no";
Optional<MemberTeam> any = memberTeamRepository.findWithTeamByMember(member).stream().filter(mt -> mt.getTeam().equals(team)).findAny();
if(any.isPresent()){
if(any.get().isBelong())
status="in";
else status="mid";
}
return GroupDto.DetailGroupRes.builder()
.groupId(groupId).groupName(team.getName()).oneLineInfo(team.getOneLineInfo())
.groupDescription(team.getDescription())
.nowMember(Long.valueOf(memberTeamRepository.findByTeam(team).stream().filter(memberTeam -> memberTeam.isBelong())
.collect(Collectors.toList()).size()))
.maxMember(team.getMaxMember()).isPublic(team.isPublic()).isAnonymous(team.isAnonymous())
.categoryLabel(categoryService.getLabelByCategory(team.getCategory()))
.commentList(detailComments)
.amIOwner(team.getOwner().equals(member))
.categoryName(categoryService.getCategoryNameByCategory(team.getCategory()))
.memberStatus(status)
.build();
}
public boolean checkBelong(Long groupId){
Member member = memberService.getMember();
Team team = this.getTeamById(groupId);
Optional<MemberTeam> opt = memberTeamRepository.findFirstByMemberAndTeam(member, team);
return opt.isPresent()&&opt.get().isBelong();
}
public boolean checkPublic(Long groupId) {
Team teamById = this.getTeamById(groupId);
return teamById.isPublic();
}
boardService.getDetailComments()
public List<CommentDto.CommentRes> getDetailComments(Long groupId){
Team team = getTeam(groupId);
Member member = memberService.getMember();
List<Comment> byTeam = commentRepository.findByTeam(team);
List<CommentDto.CommentRes> result = new ArrayList<>();
byTeam.stream().forEach(comment -> {
if(comment.getParentComment()==null){
CommentDto.CommentInfo parent = CommentDto.CommentInfo.builder().commentId(comment.getId()).text(comment.getText()).dateTime(comment.getDate())
.isOwner(comment.getMember().equals(team.getOwner()))
.writerName(team.isAnonymous()?comment.getMember().getNickname():comment.getMember().getName())
.amIWriter(member.equals(comment.getMember()))
.build();
List<CommentDto.CommentInfo> childList=new ArrayList<>();
comment.getChildComment().forEach(child->childList.add(
CommentDto.CommentInfo.builder().commentId(child.getId()).text(child.getText())
.dateTime(child.getDate()).isOwner(child.getMember().equals(team.getOwner()))
.writerName(team.isAnonymous()?child.getMember().getNickname():child.getMember().getName())
.amIWriter(member.equals(child.getMember()))
.build()
));
result.add(CommentDto.CommentRes.builder().parent(parent).childList(childList).build());
}
});
return result;
}
(memberService.getMember는 필터에서 받은 JWT토큰으로 인증 정보를 SecurityContext에 저장하고 그 정보를 서비스 로직에서 꺼내 회원 엔티티를 찾아 반환하는 메소드이다.)
여튼, 대충 요정도만 봐도 어떤 로직인지 감은 올거라 생각한다.
캐시를 적용하기 위해선 먼저 적용할 위치를 정해야 한다.
1. 그룹 서비스의 getDetailComments메소드
2. 해당 메소드 내부의 모든 Repository접근에 대해서
1.의 위치에 적용하는게 성능면에선 아주 우수할 것이다. (히트율이 훨씬 높을 테니..)
코드를 보면 현재 회원이 해당 그룹에 속해있는지, 그룹장인지 여부를 확인하고
또, 해당 그룹에 가입된 상태인지, 가입 신청을 하거나 받은 상태인지, 아무런 관계가 없는 상태인지에 따라 response의 값이 다르게 된다.
때문에, 해당 로직에 캐싱을 적용하려면
a. belong, owner로직을 컨트롤러로 빼거나
b. 다른 서비스 메소드를 만들어 조건문을 먼저 검증하고 해당 조건문의 값을 파라미터로하는 메소드를 따로 분리해서 그 파라미터의 값을 key로 캐싱을 해야한다.
a의 방법을 사용하면 team엔티티를 영속성 컨텍스트에 가져오게 되는데, 그룹 상세 정보를 리턴하기 위해선 팀 엔티티와 category를 페치조인해서 사용해야 한다.
즉, 그냥 findById로 팀 객체를 끌고와버리면 그 후에 카테고리 정보를 조회할 수 없다 (이미 영속성 컨텍스트에 해당 id의 team객체가 존재하니까)
따라서, b의 방법을 사용해서 로직을 분리해보려 한다.
public GroupDto.DetailGroupRes getGroupDetails(Long groupId){
Team team = teamRepository.findFirstWithCategoryById(groupId).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
if(!this.checkBelong(groupId)&&!this.checkPublic(groupId))
throw new BaseException(BaseResponseStatus.NOT_PUBLIC_ERROR);
Member member = memberService.getMember();
String status="no";
Optional<MemberTeam> any = memberTeamRepository.findWithTeamByMember(member).stream().filter(mt -> mt.getTeam().equals(team)).findAny();
if(any.isPresent()){
if(any.get().isBelong())
status="in";
else status="mid";
}
// List<CommentDto.CommentRes> detailComments = boardService.getDetailComments(groupId);
//
// return GroupDto.DetailGroupRes.builder()
// .groupId(groupId).groupName(team.getName()).oneLineInfo(team.getOneLineInfo())
// .groupDescription(team.getDescription())
// .nowMember(Long.valueOf(memberTeamRepository.findByTeam(team).stream().filter(memberTeam -> memberTeam.isBelong())
// .collect(Collectors.toList()).size()))
// .maxMember(team.getMaxMember()).isPublic(team.isPublic()).isAnonymous(team.isAnonymous())
// .categoryLabel(categoryService.getLabelByCategory(team.getCategory()))
// .commentList(detailComments)
// .amIOwner(team.getOwner().equals(member))
// .categoryName(categoryService.getCategoryNameByCategory(team.getCategory()))
// .memberStatus(status)
// .build();
return this.makeDetailRes(status,team,member);
}
@Cacheable(value = "groupDetail",key = "#status")
private GroupDto.DetailGroupRes makeDetailRes(String status, Team team,Member member){
List<CommentDto.CommentRes> detailComments = boardService.getDetailComments(team.getId());
return GroupDto.DetailGroupRes.builder()
.groupId(team.getId()).groupName(team.getName()).oneLineInfo(team.getOneLineInfo())
.groupDescription(team.getDescription())
.nowMember(Long.valueOf(memberTeamRepository.findByTeam(team).stream().filter(memberTeam -> memberTeam.isBelong())
.collect(Collectors.toList()).size()))
.maxMember(team.getMaxMember()).isPublic(team.isPublic()).isAnonymous(team.isAnonymous())
.categoryLabel(categoryService.getLabelByCategory(team.getCategory()))
.commentList(detailComments)
.amIOwner(team.getOwner().equals(member))
.categoryName(categoryService.getCategoryNameByCategory(team.getCategory()))
.memberStatus(status)
.build();
}
이런식으로 분리해서 적용하는 것이다.
이제 이렇게 캐시를 저장했을 떄 발생할 수 있는 상황을 정리해보자.
1. 키 값이 status 하나면 다른 그룹을 조회해도 캐시에 있는 그룹의 정보가 리턴된다.
2. 여전히 getGroupDetails 내부에 DB에 접근하는 즉, 커넥션을 얻어야 하는 로직들이 존재한다.
3. makeDetailRes에서 boardService.getDetailComments도 그룹에 의존적이다.
차근차근 하나씩 해결해보자
1. 키 값이 status 하나면 다른 그룹을 조회해도 캐시에 있는 그룹의 정보가 리턴된다.
@Cacheable에선 Default key generator로 SimpleKeyGenerator를 사용한다.
해당 클래스는 파라미터들의 정보를 가지고 hashCode를 만든다.
만약, 파라미터가 없다면 SimpleKey.Empty가 키값이 되고, 하나만 있다면 해당 파라미터로 해시코드를 만들며, 여러개라면 해당 값들 모두에 대해 hashCode를 만들어낸다.
따라서, 파라미터에 객체가 온다면 equals and hashcode를 정의해야 정상적으로 해시 코드를 만들어 key값으로 사용할 수 있게 된다.
키 값을 "'makeDetailRes'+'#status'+'#team.id'" 이런식으로 저장한다면 문제가 되는 중복은 해결된다.
다만, 이렇게 사용하게 되면 가독성도 좀 떨어지기도 하고, 아무래도 캐시 메소드 모두에 이런 반복을 하기엔 너무나도 귀찮다.
이제, 어떻게 keyGenerator가 동작하는지 알았으니 필요한 custom key generator를 만들어 적용시켜보자.
메소드이름, 파라미터들을 이용해서 해시코드를 만들어내는 key generator를 만들어 사용하겠다는 뜻이다.
먼저, 코드를 다시 고쳐보자.
지금, makeDetailRes는 파라미터로 Team,Member 엔티티를 직접 받고있다.
equals, hashcode를 엔티티에 박아넣는건 매우 불경한(?)일이니 다른 방식을 고안해보자.
member의 경우 그룹장 여부를 확인하기 위해서만 쓰인다.
즉,
team.getOwner().equals(member)
이 부분을
team.getOwner().getId().equals(memberId)
이렇게 바꿔주면 Member엔티티를 받을 필요가 없어진다.
Team의 경우 메소드 내부에서 사용하는 곳이 많고 페치조인 때문에 무작정 아이디만 가지고 DTO를 생성해내기엔 무리가 있다.
따라서, 코드의 순서를 바꿔보려한다.
현재, Category를 페치조인해서 팀을 가져오고 이 팀을 바탕으로 소속여부,공개여부를 확인한 후 현재 접근한 회원이 팀과 맺어진 관계 (소속, 준소속, 관계없음)에 따라 makeDetailRes가 달라지는 것이다.
그렇다면, 이 status값을 먼저 판단한 후 바로 makeDetailRes에서 groupId를 가지고 DTO를 만들고 이를 리턴하는 형태로 바꾸면 문제가 해결될 것이라 생각된다.
public GroupDto.DetailGroupRes getGroupDetails(Long groupId){
Member member = memberService.getMember();
String status="no";
Optional<MemberTeam> any = memberTeamRepository.findWithTeamByMember(member).stream().filter(mt -> mt.getTeam().getId().equals(groupId)).findAny();
if(any.isPresent()){
if(any.get().isBelong())
status="in";
else status="mid";
}
return this.makeDetailRes(status,member.getId(),groupId);
}
@Cacheable(cacheNames = "groupDetail",keyGenerator = "myCustomKeyGenerator")
private GroupDto.DetailGroupRes makeDetailRes(String status,Long memberId, Long groupId){
Team team = teamRepository.findFirstWithCategoryById(groupId).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
if(!this.checkBelong(groupId)&&!this.checkPublic(groupId))
throw new BaseException(BaseResponseStatus.NOT_PUBLIC_ERROR);
List<CommentDto.CommentRes> detailComments = boardService.getDetailComments(team.getId());
return GroupDto.DetailGroupRes.builder()
.groupId(team.getId()).groupName(team.getName()).oneLineInfo(team.getOneLineInfo())
.groupDescription(team.getDescription())
.nowMember(Long.valueOf(memberTeamRepository.findByTeam(team).stream().filter(memberTeam -> memberTeam.isBelong())
.collect(Collectors.toList()).size()))
.maxMember(team.getMaxMember()).isPublic(team.isPublic()).isAnonymous(team.isAnonymous())
.categoryLabel(categoryService.getLabelByCategory(team.getCategory()))
.commentList(detailComments)
.amIOwner(team.getOwner().getId().equals(memberId))
.categoryName(categoryService.getCategoryNameByCategory(team.getCategory()))
.memberStatus(status)
.build();
}
이렇게 만들면, status, memberId,groupId의 조합으로 myCustomKeyGenerator가 키 값을 만들어서 캐시 메모리에 저장을 하게 될 것이다.
그렇다면, 이제 myCustomKeyGenerator만 만들면 캐시가 완성된다.
public class MyCustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuilder keyBuilder = new StringBuilder();
keyBuilder.append(method.getName());
keyBuilder.append(SimpleKeyGenerator.generateKey(params));
return keyBuilder.toString();
}
}
MyCustomKeyGenerator 클래스를 만들어주고
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(){
return new ConcurrentMapCacheManager();
}
@Bean("myCustomKeyGenerator")
public KeyGenerator keyGenerator(){
return new MyCustomKeyGenerator();
}
}
CacheConfig에 ( 굳이 뭐 여기 안넣어도 되긴 함) 빈으로 등록해준다.
그 후, API 요청을 해보면
..캐시가 적용되지 않는다.
해당 메소드를 바로 호출하는 API를 만들어서 테스트 해보니 캐싱은 정상적으로 되는 것 같았다.
그렇다면 왜 Controller-> service -> service로의 요청은 캐싱이 적용되지 않고 controller -> service로의 요청은 캐싱이 적용될까 ?
우선, service->service가 문제인지 확인하기 위해 key값을 generator를 사용하지 않고 status로만 유지해보자.
@Cacheable(cacheNames = "groupDetail", key = "#status")
를 달아주고 다시 테스트해보면
..안된다
왜 안될까 ?
답은 아주 근본적인 이유에 있다.
스프링 캐시는 AOP를 사용한다
즉, 자기 자신을 호출하는 경우 ( 내부 메소드를 호출하는 경우) 프록시가 적용되지 않는다.
따라서, Aspect가 적용되지 않게 되고, Cacheable어노테이션 또한 Aspect이기에 적용이 되지 않는 것이다.
그렇기에 해당 코드를 내부 메소드 호출이 아닌 Controller->Service -> Controller->Service의 형식으로 바꿔야 한다.
Controller
@GetMapping("/getGroupDetail/{groupId}")
@Operation(summary = "그룹 상세 조회 페이지",description = "그룹의 상세정보를 조회하는 API(비공개 그룹은 그룹원만 조회 가능)")
public ResponseEntity<GroupDto.DetailGroupRes> getGroupDetail(@PathVariable Long groupId){
String status = groupService.getStatus(groupId);
return new ResponseEntity<>(groupService.makeDetailRes(status,memberService.getMember().getId(),groupId),HttpStatus.OK);
}
컨트롤러를 이렇게 수정해주고
GroupService
public String getStatus(Long groupId){
Member member = memberService.getMember();
String status="no";
Optional<MemberTeam> any = memberTeamRepository.findWithTeamByMember(member).stream().filter(mt -> mt.getTeam().getId().equals(groupId)).findAny();
if(any.isPresent()){
if(any.get().isBelong())
status="in";
else status="mid";
}
em.clear();
return status;
}
@Cacheable(cacheNames = "groupDetail",keyGenerator = "myCustomKeyGenerator")
public GroupDto.DetailGroupRes makeDetailRes(String status,Long memberId, Long groupId){
log.debug("got status : "+status+ " / member Id : "+memberId+ " / groupId : "+groupId);
Team team = teamRepository.findFirstWithCategoryById(groupId).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
if(!this.checkBelong(groupId)&&!this.checkPublic(groupId))
throw new BaseException(BaseResponseStatus.NOT_PUBLIC_ERROR);
List<CommentDto.CommentRes> detailComments = boardService.getDetailComments(team.getId());
return GroupDto.DetailGroupRes.builder()
.groupId(team.getId()).groupName(team.getName()).oneLineInfo(team.getOneLineInfo())
.groupDescription(team.getDescription())
.nowMember(Long.valueOf(memberTeamRepository.findByTeam(team).stream().filter(memberTeam -> memberTeam.isBelong())
.collect(Collectors.toList()).size()))
.maxMember(team.getMaxMember()).isPublic(team.isPublic()).isAnonymous(team.isAnonymous())
.categoryLabel(categoryService.getLabelByCategory(team.getCategory()))
.commentList(detailComments)
.amIOwner(team.getOwner().getId().equals(memberId))
.categoryName(categoryService.getCategoryNameByCategory(team.getCategory()))
.memberStatus(status)
.build();
}
내부 메소드 호출 형식에서 이제 컨트롤러에서 서비스 메소드를 두 번 호출하는 형식으로 변경했다.
동일하게 테스트를 해보면
getStatus메소드와 memberService의 getMember메소드를 호출 하는 부분을 제외하곤 로그가 더이상 찍히지 않았다,
즉, 드디어 그룹 상세정보에 대해서 캐시가 적용된 것이다.
1번은 이제 해결 된 문제니 2번을 살펴보자..
2. 여전히 getGroupDetails 내부에 DB에 접근하는 즉, 커넥션을 얻어야 하는 로직들이 존재한다.
3. makeDetailRes에서 boardService.getDetailComments도 그룹에 의존적이다.
로직이 좀 수정되었으니 2,3번 같이 생각해보려한다.
우선, 기존 로직에서 커넥션을 얻어야 하는 로직들이 남아있다는 부분은
1.groupService.getStatus를 하는 과정
2.makeDetails의 파라미터로 memberId를 넘겨주기 위해 memberService.getMember를 하는 과정
에서 추가적인 커넥션이 필요하다.
3번의 경우, boardService.getDetailComments의 내용이 변경된다면 즉, 그룹 상세 페이지의 댓글이 추가된다면 상세 페이지의 캐시 또한 갱신되어야 한다. 다행히 상세 페이지를 수정하는 서비스 로직은 존재하지 않기에 페이지 내 댓글의 조작 여부가 유일한 캐시 메모리의 변동 원인이 된다.
그럼, 2번부터 해결해보자.
getStatus엔
memberService.getMember();
memberTeamRepository.findWithTeamByMember(member).stream().filter(mt -> mt.getTeam().getId().equals(groupId)).findAny();
이 두 메소드가 DB 커넥션을 사용한다.
public Member getMember() {
String userName = SecurityUtil.getCurrentUsername().orElseThrow(() -> new BaseException(BaseResponseStatus.FAILED_TO_LOGIN));
Member user = memberRepository.findFirstByUsername(userName).orElseThrow(() -> new BaseException(BaseResponseStatus.FAILED_TO_LOGIN));
return user;
}
getMember의 경우 jwt토큰으로부터 사용자 엔티티를 불러오는 메소드이기에 캐시를 사용하기엔 무리가 있다.
따라서, 적용을 하려면 service단이 아닌 repository단에 해야 한다.
memberTeamRepository.findWithTeamByMember 또한 AOP적용을 위해 repository에서 해당 메소드에 캐시를 적용해줘야 한다
성능을 위해선 다 적용하는게 좋겠지만, 지금 캐시 메모리 자체가 ConcurentHashMap에 저장하는 상황이기에 무작정 캐시를 늘려대다보면 캐시 관리가 힘들어지고 힙을 어느순간 다 잡아먹게 될 것이다.
또, 글을 찾아보니 엔티티를 스프링이나 외부 캐시에 저장하면 안된다는 김영한 강사님의 답변을 찾을 수 있었다.
뭐, username으로 멤버 엔티티를 찾는 쿼리의 경우 캐시 히트율이 매우 높을 것 같지만 여러 글을 보다 보니 올바른 방법은 아니라 생각되었고
findWithTeamByMember메소드 또한 엔티티를 바로 반환하기도 하고, 변경이 잦은 내용이기에 캐싱에 적합하지 않다고 생각 되었다.
따라서, 회원 객체에 DTO매퍼를 만들어 회원 엔티티를 DTO로 바꿔서 사용하는 메소드를 따로 만드는게 아니라면 위 두 메소드에 캐시를 적용하는게 좋은 방법은 아니라는 결론을 내리게 되었다.
3번의 경우를 살펴보자
사실, boardService.getDetailComments 가 의존적이라는 말이 좀 모호한데
쉽게 예를 들면, 해당 그룹 상세 정보 페이지에 누군가 댓글을 추가적으로 달면 그룹 상세페이지 캐시 값 자체가 변경되어야 한다는 것이다.
또한, 그룹 상세 페이지의 글 내용 자체는 변하지 않지만
만약, 해당 그룹이 삭제된다면 캐시에서도 당연히 삭제시켜 줘야 한다.
또, 현재 회원 수를 가져오는 부분 또한 변경될 수 있는 부분이다.
즉, 캐시 사용을 위해 이런 무결성을 보장해주는 부분이 추가되어야 한다.
그렇다면 해결책은 3가지라고 생각한다.
1. 해당 변경지점들에 대해 groupDetail 캐시를 Evict해주고 새로 put해주는 방법
2. 서비스 로직을 분리하고 각각에 캐시를 적용하고 makeDetailsRes메소드는 단순히 이 값들을 들고 합쳐주는 역할만 하게 변경
3. 다른 캐시 매니저를 사용해서 캐시를 자동으로 Evict하는 방법
3번의 경우, 간편하게 바꿔볼 수 있지만 우선은 현재 처럼 힙에 저장해버리는 캐시를 사용해서 위험도를 가진 상태로 테스트를 하고 싶기에 일단 pass하자
2번의 경우 변경의 원인이 하나가 되고, 관리도 수월해지기에 제일 합리적이라 생각된다.
그렇지만, 2번 처럼 만들기 위해선 DTO 매퍼를 만들고 메소드도 잔뜩 만들어줘야 사용하기 용이하다고 판단된다.
즉, 2번이 제일 합리적인 방법이지만 조금의(?) 편의를 위해 1번의 방법으로 가려 한다. (현재 서비스 메소드가 너무나도 많고 이걸 다 테스트하는 것이 목적이 아니기에..)
우선, 그룹이 삭제되는 경우 캐시에서 그 값을 삭제시켜야 한다.
그런데, key값에 status와 memberId를 삭제하는 로직에 붙일 수 없기에 딱 해당 캐시만 삭제하는 건 무리가 있다.
여기서 잘 생각해보면 status와 memberID는 사용자마다 각각 다른 값이다.
또한, 해당 부분은 각각 하나의 data에만 매핑이 되어 있고, 각각은 캐시 적용 여부와 상관없이 getStatus단에서 이미 커넥션을 잡는다.
즉, 따로 빼도 되는 값이 된다.
또, 빼주게 되면 굳이 동일한 그룹의 정보 조회에 대해서 여러 키를 가질 필요가 없게 된다.
근데, 이를 위해서 team 엔티티의 Owner 애트리뷰트를 사용해야 하는데
굳이 또 team을 새로 조회 하는 건 상당히 불편한 일이다.
API는 그룹 ID를 가지고 해당 그룹의 상세 정보를 조회하는 것이 목적이기에 굳이 getStatus를 먼저 부르고 그 값을 makeDetailRes를 넣어야 하는 법은 없다는 뜻이다.
따라서, 이 둘의 순서를 바꾸고 getStatus에서 Owner여부와 status값을 설정 하는 것으로 변경해보자.
또, 두 메소드가 같은 서비스에 있기에 트랜잭션의 범위가 같다.
즉, makeDetailRes에서 조회한 team이 영속성 컨텍스트의 1차 캐시안에 존재하기에 getStatus 메소드에서 team을 또 조회해도 1차 캐시에서 바로 꺼내서 사용할 수 있게 된다.
즉 Service 두 메소드를
public GroupDto.DetailGroupRes getStatus(GroupDto.DetailGroupRes detailGroupRes){
Member member = memberService.getMember();
String status="no";
Optional<MemberTeam> any = memberTeamRepository.findWithTeamByMember(member).stream().filter(mt -> mt.getTeam().getId().equals(detailGroupRes.getGroupId())).findAny();
if(any.isPresent()){
if(any.get().isBelong())
status="in";
else status="mid";
}
detailGroupRes.setMemberStatus(status);
Team team = teamRepository.findById(detailGroupRes.getGroupId()).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
detailGroupRes.setAmIOwner(team.getOwner().equals(member));
return detailGroupRes;
}
@Cacheable(cacheNames = "groupDetail",keyGenerator = "myCustomKeyGenerator")
public GroupDto.DetailGroupRes makeDetailRes(Long groupId){
Team team = teamRepository.findFirstWithCategoryById(groupId).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
if(!this.checkBelong(groupId)&&!this.checkPublic(groupId))
throw new BaseException(BaseResponseStatus.NOT_PUBLIC_ERROR);
List<CommentDto.CommentRes> detailComments = boardService.getDetailComments(team.getId());
return GroupDto.DetailGroupRes.builder()
.groupId(team.getId()).groupName(team.getName()).oneLineInfo(team.getOneLineInfo())
.groupDescription(team.getDescription())
.nowMember(Long.valueOf(memberTeamRepository.findByTeam(team).stream().filter(memberTeam -> memberTeam.isBelong())
.collect(Collectors.toList()).size()))
.maxMember(team.getMaxMember()).isPublic(team.isPublic()).isAnonymous(team.isAnonymous())
.categoryLabel(categoryService.getLabelByCategory(team.getCategory()))
.commentList(detailComments)
// .amIOwner(team.getOwner().getId().equals(memberId))
.categoryName(categoryService.getCategoryNameByCategory(team.getCategory()))
// .memberStatus(status)
.build();
}
이렇게 바꿔주게 되면 makeDetailRes의 key값이 메소드이름과 groupId로 이루어진 값이 된다.
따라서, 해당 그룹이 삭제될 때 해당 그룹의 상세정보 캐시를 없앨 수 있게 된다.
여기서, 우리가 앞전에 만든 MyCustomKeyGenerator를 사용해서 키를 만들었기에 삭제할 때 키값을 이 로직에 맞게 작성해서 넣어줘야 한다.
근데, 이건 너무 귀찮고 유지보수에도 좋지 않다.
또한, 굳이 메소드이름을 이용해서 구분하지 않아도 groupDetail이란 값은 makeDetailRes말고는 딱히 쓰이는 곳도 없다.
그렇기에 이 부분을 과감히 없애주자.
그 후, 그룹을 삭제하는 로직에 Evict를 달아주자
@CacheEvict("groupDetail")
public void deleteGroup(Long groupId){
Team team = teamRepository.findById(groupId).orElseThrow(() -> new BaseException(BaseResponseStatus.INVALID_TEAM_ID));
teamRepository.delete(team);
}
이제 테스트를 해보자
캐시는 정상적으로 동작하고 있고
그룹을 삭제한 후 다시 조회를 해보면
이렇게 makeDetailRes메소드 내에서 예외를 던지는 것을 확인할 수 있다.
즉, 캐시가 삭제되었다는 것을 의미한다 !
그 후, 댓글 로직들에도 모두 Evict를 달아주고 테스트를 해보면
캐시가 초기화 되어 그룹 상세 페이지 정보를 조회하면 makeDetailRes메소드가 다시 실행된다.
코드를 수정하면서 좀 짜친다는 생각이 강하게 들었다만..
다음 수정땐 제대로 구성을 갖춰서 책임도 단일화하는 느낌으로 코드를 수정해야할 듯 싶다.
(서로 엮여있는 부분도 있고 같은 댓글이라도 id에 따라 게시물, 상세페이지로 나뉘다 보니 일관성이 없고 변경될 이유가 없는 경우에도 변경되기도 한다)
여튼, 이제 테스트할 API가 사용하는 로직에 캐시도 적용했으니 다시 테스트를 해보자 !
참고
https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
About Pool Sizing
光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP
github.com
인스턴스 상태검사 실패, Thread starvation or clock leap detected, Dead Lock, hikari 오류-1
EC2 서버에 배포한 스프링 부트 프로젝트가 오늘 아침까지는 잘 돌아가다가 저녁에 확인을 해보니 요청을 처리 못하고 있었다. 바로 EC2서버를 확인해보니 인스턴스 상태 검사에서 CPU사용량 99.9%
velog.io
https://adjh54.tistory.com/165
[Java] Spring Boot Cache 이해하고 설정하기 -1 : 정의, 환경 설정
해당 글에서는 API Cache에 대해서 이해하고 REST API 환경에서 이를 적용하는 방법에 대해서 작성한 글입니다. 1) 개발환경 구성 💡 개발환경은 MyBatis를 기반으로 RDBMS로부터 전달받은 데이터를 캐
adjh54.tistory.com
https://pamyferret.tistory.com/8
SpringBoot의 기본 Cache 사용하기
개발을 하다보면 어라? 이 데이터 계속 똑같이 사용되고 업데이트 될 일이 없는데? 하는 것들이 보인다. 데이터 업데이트가 자주 이뤄지지도 않고 자주 호출되는 데이터인데 계속 DB에 가서 데이
pamyferret.tistory.com
[무신사 watcher] @Cacheable 중복되는 key값 어떻게 처리할까?
무신사 왓쳐의 데이터의 업데이트는 하루마다 이뤄지므로 캐시의 의존성이 크다. 그런데 spring에서 제공하는 @Cacheable을 사용했을 때 발생가능한 key중복 문제에 대해서 정리하고 무신사 왓쳐에
jgrammer.tistory.com
[devNine] 어디까지 캐싱할 수 있을까? - WAS(Spring Boot) 편
안녕하세요! devNine 입니다.최근 메일링 서비스 를 오픈했어요! 둘러보시고 매일 올라오는 IT 기업 블로그, 유튜브 컨텐츠를 받아보세요! 이 외에도 매주 스택오버플로우의 Q&A들의 키워드를 분석
velog.io
'Spring' 카테고리의 다른 글
동시성 문제 해결하기 (0) | 2023.08.02 |
---|---|
부하 테스트 하기 (7) (0) | 2023.07.30 |
부하 테스트 하기 (5) - 사용 시나리오별 테스트 하기 (0) | 2023.07.15 |
부하 테스트 하기 (4) - nGrinder 설치 및 스크립트 만들기 (0) | 2023.07.13 |
부하 테스트 하기(3) -핀포인트 오류 잡기 (1) | 2023.07.13 |