본문 바로가기

TIL & WIL

WIL_220808_항해99 실전프로젝트 트러블 슈팅 회고

항해99 실전 프로젝트가 끝이 났다.

실전 프로젝트 전 미니프로젝트 2회, 클론 코딩 프로젝트 1회, 주특기 기초/숙련/심화, 알고리즘 등을 거치며

14주 간의 짧지만 긴 여정이 마무리 되었다.

조원들과 실전 프로젝트를 거치며 크고 작은 트러블 슈팅을 겪었고 그때마다 Google Sheets에 정리를 해놓았다.

프로젝트를 마무리하며 해당 트러블 슈팅들을 블로그에 정리해보고자 한다.

 

* 실전 프로젝트 URL

https://mungfriend.com/


 

1. WebSocket 통신 시 발생한 인증 관련 오류

  1) 사실 수집 및 원인 추론 :

  • WebSocket 통신 시, SecurityContextHolder에 인증 정보가 없는 문제가 발생했다.
  • 기존 우리 웹 애플리케이션의 인증 방식은 프론트에서 보낸 요청의 Header에 담긴 JWT Bearer Token 값을 JWT Filter에서 검증하는 방식이다.(JWT Filter 는 OnePerRequestFilter를 구현한 구현체 클래스)
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • JWT Filter에서는 HTTP 통신으로 오는 request의 Header 정보를 기준으로 검증하므로, ws 통신으로 오는 요청은 해당 필터에 걸리지 않는 문제가 발생했다.

 

  2) 해결 방안 결정 및 구현

  • ws 통신으로 오는 request에 담긴 JWT를 검증하는 로직이 필요하다.
  • StompHandler를 거친 뒤, ChatMessageController에서 요청을 받게 되는데, 이 때 HTTP의 request와 같은 역할을 하는 ws통신의 message 객체로부터 JWT 토큰값을 조회하고 validate check 한 뒤, 유효하다면 authentication 객체에 담아 SecurityContextHolder에 저장하는 과정을 추가하였다.
@MessageMapping("/api/chat/message")
//    @SendTo("/sub/api/chat/rooms/")
    public void message(@RequestBody ChatMessageRequestDto requestDto, Message<?> message) {

        // ws 통신에 담겨온 토큰 값으로 인증 정보 저장하기.
        chatMessageService.saveAuthentication(message);

        String username = SecurityUtil.getCurrentMemberUsername();
        Member member = memberRepository.findByUsername(username).orElse(null);

        // 메시지 생성 시간 삽입
        SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd HH:mm");
        Calendar cal = Calendar.getInstance();
        Date date = cal.getTime();
        sdf.setTimeZone(TimeZone.getTimeZone("Asia/Seoul"));
        String dateResult = sdf.format(date);
        requestDto.setCreatedAt(dateResult);

        // DTO 로 채팅 메시지 객체 생성
//        assert member != null;
        ChatMessage chatMessage = new ChatMessage(requestDto);

        // MySql DB에 채팅 메시지 저장
        chatMessageService.save(chatMessage);

        // 웹소켓 통신으로 채팅방 토픽 구독자들에게 메시지 보내기
        chatMessageService.sendChatMessage(chatMessage);


    }
    public void saveAuthentication(Message<?> message){
        // accessor를 이용하면 내용에 패킷에 접근할 수 있게된다.
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

        String jwtToken = accessor.getFirstNativeHeader("token");
        boolean tokenValid = tokenProvider.validateToken(jwtToken);
        // ws 통신으로 올바른 토큰이 왔을 경우 SecurityContextHolder에 저장하는 작업 추가
        if(tokenValid) {
            Authentication authentication = tokenProvider.getAuthentication(jwtToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
    }

  3) 결과

  • ws 통신 상황에서도 인증정보가 잘 저장되어 SecurityUtil(인증정보를 가져오는 모듈화 객체)을 사용할 수 있게 되었다.
           

2. ws 통신 시 DB 저장 순서에 따른 오류

  1) 사실 수집 및 원인 추론 :

  • 프론트에서 메시지를 send 하는 상황에서 간헐적으로 send 한 메시지가 채팅방 화면에서 조회되지 않고 다음 채팅이 보내질 때 함께 보여지는 현상이 나타났다.
  • 우리 프로젝트의 구현된 채팅 구조는 1) 백엔드에서 pub prefix가 붙은 백엔드 서버의 url로 메시지를 send 하게되면
    2) @MessageMapping 어노테이션이 달려있는 ChatMessageController에서 해당 메시지를 저장하고 3) sub prefix가 붙은 해당 채팅방을 구독한 위치로 메시지를 돌려 보내는 구조이다.
  • 디버깅을 해보며 해당 흐름의 로그를 남겨보았고, 결과는 백엔드에서 해당 채팅방을 구독하고 있는 구독자들에게
    메시지를 보내주는 순서가 메시지 저장 순서보다 간헐적으로 빠르게 진행된다는 것임을 알게 되었다.

  2) 해결 방안 결정 및 구현 : 

  • ChatMessageController에서 메시지를 저장하는 코드와, 구독하고 있는 채팅방으로 메시지를 보내주는 코드의 위치를
    변경하여 1) 저장이 먼저 되고, 2) 그 다음 프론트엔드로 채팅 메시지가 전송될 수 있도록 수정 하였다.
@MessageMapping("/api/chat/message")
//    @SendTo("/sub/api/chat/rooms/")
    public void message(@RequestBody ChatMessageRequestDto requestDto, Message<?> message) {

        // ws 통신에 담겨온 토큰 값으로 인증 정보 저장하기.
        chatMessageService.saveAuthentication(message);
        String username = SecurityUtil.getCurrentMemberUsername();
        Member member = memberRepository.findByUsername(username).orElse(null);

        // 메시지 생성 시간 삽입
        SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd HH:mm");
        Calendar cal = Calendar.getInstance();
        Date date = cal.getTime();
        sdf.setTimeZone(TimeZone.getTimeZone("Asia/Seoul"));
        String dateResult = sdf.format(date);
        requestDto.setCreatedAt(dateResult);

        // DTO 로 채팅 메시지 객체 생성
//        assert member != null;
        ChatMessage chatMessage = new ChatMessage(requestDto);

        // MySql DB에 채팅 메시지 저장
        chatMessageService.save(chatMessage);

        // 웹소켓 통신으로 채팅방 토픽 구독자들에게 메시지 보내기
        chatMessageService.sendChatMessage(chatMessage);


    }

 

  3) 결과

  • 간헐적으로 발생했던, 보낸 메시지가 다음 채팅 메시지를 보낼 때 함께 보여지는 오류가 사라지게 되었다. 

 

3. Concurrent Modification Exception 에러 핸들링

  1) 사실 수집 및 원인 추론 :

  • 향상된 for문을 사용하다가 아래와 같은 에러를 마주치게 되었다.
java.util.concurrentmodificationexception is typically thrown when code attempts to modify a data collection while that collection is actively in use, such as being iterated.
  • 해당 오류를 검색해본 결과, 이는 향상된 for문의 동작 구조에 있는 iterator 인터페이스의 특성에 기인한 오류였다는
    것을 파악하게 되었다. iterator 인터페이스가 자바 컬렉션에 저장된 요소들을 순회하는 역할을 하는데, 이 순회 과정 중 어느 한 컬렉션 요소의 값이 변경되게 되면 무결성이 깨지며 해당 오류가 나타나게 된다.
  • expectedModCount 변수는 next() 메서드를 실행할 때 기존 자신이 참조하고 있는 ArrayList의 변경사항이 있는지 체크하게 되는데 이 때 참조 리스트의 modCount와 자신의 지역변수인 expectedModCount를 비교하여 다를 경우
    java.util.ConcurrentModificationException를 발생시킨다.
// ArrayList.Itr class
public E next() {
    checkForComodification();
    ...
}

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
  • 우리 코드에는 향상 된 for문을 도는 중에 바뀐 프로필 이미지를 setter를 통해 member 객체에 적용하고, 정보가 바뀐
    객체를 save하는 로직이 있었는데, 그 경우 member 객체와 연관관계로 매핑 되어있는 dog 객체들의 프로필 이미지
    정보도 함께 바뀌어 modCount가 증가했고, 따라서 해당 익셉션이 발생했던 것이었다.

 

  2) 해결 방안 결정 및 구현 : 

  • 반복문 진입 이전에 대상 객체를 null로 초기화 하고, 바뀐 프로필 이미지를 변경하는 로직은 for문 바깥으로 재배치하여 해당 오류를 해결하고자 하였다.
public DogCaptainResponseDto selectCaptainDog(Long id) {
        String username = SecurityUtil.getCurrentMemberUsername();
        Member member = memberRepository.findByUsername(username).orElseThrow(
                () -> new IllegalArgumentException("해당하는 ID의 회원이 존재하지 않습니다."));

        List<Dog> dogList = member.getDogList();

        Dog representativeDog = null;
        for (Dog dog : dogList) {
            if(dog.getId().equals(id)) {
                dog.setIsRepresentative(true);
                dogRepository.save(dog);
                representativeDog = dog;
            }else {
                dog.setIsRepresentative(false);
                dogRepository.save(dog);
            }
        }

        //대표 멍멍이 사진 변경
        assert representativeDog != null;
        setDogProfileImgUrl(member, representativeDog.getDogImageFiles().get(0).getImageUrl());

        return new DogCaptainResponseDto("true", "대표 멍멍이가 바뀌었습니다.");
    }

 

  3) 결과 : 

  • 해당 오류는 더 이상 발생하지 않았다.
  • 향상된 for문의 동작원리에 대해 공부해볼 수 있는 좋은 트러블 슈팅이었다.

 

4. JMeter 부하 테스트와 TPS 성능 개선(최대 64.8% 개선)

  1) 사실 수집 및 원인 추론 :

  • 실전 프로젝트 5주차에 배포 후, 유저 테스트를 진행하였고, (실제로 많은 유저들의 트래픽이 발생하기 이전에) 코드 로직에서 가장 빈번하게, 그리고 가장 쿼리가 많이 발생할 수 있는 API들을 점검하기로 하였다. 따라서 우리는 '전체 게시글 조회'와 '거리순 조회' API에 대한 JMeter TPS 테스트를 하고자 했다.
  • 측정 조건은 Threads 갯수 1000개(사용자 1000명과 같은 의미), 지연시간 1초(1초만에 1000명의 유저가 접속한다는 의미), 그리고 loop count 10(1명의 사용자가 10번씩 요청을 반복한다는 의미)으로 설정하였다.
  • 따라서 총 10,000회의 requests가 발생한 것이고, 최초로 측정했던 TPS는 다음과 같다.
    * 전체 게시글 조회 : 23.0 TPS
    * 게시글 거리순 조회 : 33.5 TPS
  • 해당 API 요청의 쿼리를 찍어보았는데, post 객체를 JPA QueryMethod로 findAll() 할 때, post 객체에 @ManyToOne
    연관 관계로 설정된 모든 member 객체를 불필요하게 조회해오는 것을 알 수 있었다.
  •  ~ToOne 어노테이션은 기본값으로는 Eager Loading을 하지만, 해당 객체가 여러개라면 객체의 모든 갯수만큼
    추가적으로 쿼리가 날아가는 N+1 문제가 발생함을 알게 되었다. 

 

  2) 해결 방안 결정 및 구현 : 

  • ~ToOne으로 mapping 된 member 객체를 @EntityGraph(attributePath='member')로 JPQL 쿼리에서 바로 사용할 수 있도록 했으며 Left Join Fetch를 통해 outer join을 걸어주어 모든 post 객체를 member와 동시에 조회하도록 했다.

  3) 결과

  • 모든 member 객체의 수만큼 select 쿼리가 날아가는 것을 방지할 수 있었고 TPS 성능을 유의미하게 개선할 수 있었다.

게시글 전체조회 TPS 비교 그래프(34.1% 개선)
거리순 조회 TPS 비교 그래프(64.8% 개선)

  • JPA는 자바 객체 지향적인 관점에서 DB를 편리하게 관리할 수 있다는 장점이 있지만 N+1문제는 꼭 신경써야 한다는 것을 깨달았다.
  • JPA를 더 효과적으로 사용하기 위해서는 JPQL과 SQL 학습도 필수라는 점도 느끼게 되었다.