본문 바로가기

DEVELOPER/Programming

[트러블 슈팅] '채식이들' 메인 페이지-제품 출력 에러 해결(java.lang.NullPointerException)

안녕하세요😎 백엔드 개발자 제임스입니다 :)

오늘은 채식이들 쇼핑 서비스에서 발생했던 에러와 해결 과정을 포스팅하도록 하겠습니다. '채식이들' 쇼핑 서비스는 2022년 9월에 협업 프로젝트로 개발했던 서비스입니다. 프로젝트를 진행할 당시에는 에러 없이 성공적으로 배포했다고 생각했습니다. 하지만 웹 사이트에 들어갔을 때, 제품이 출력되어야 하는 메인 페이지에서 불규칙적으로 전체 제품이 출력되지 않는 문제가 발생했었습니다. 문제를 인지하고, 몇 번을 테스트했을 때 로그인 이전에 기능들이 정상적으로 동작하지 않는 것을 확인했습니다.

처음 문제를 접했을 때는 'AWS 서버나 가비아의 저렴한 서비스를 이용한 것이 원인인가?'라는 안일한 생각으로 크게 신경 쓰지 않았습니다. 정말 바보 같은 생각이었습니다. 그 이후에도 웹 사이트를 재방문했을 때, 문제는 동일하게 발생하고 있었습니다.

이러한 상태의 결과물을 포트폴리오에서 대표 프로젝트로 자랑했었다니 정말 민망합니다. 😭따라서 이번 기회에 문제를 찾아서 해결하고, 이에 대한 내용을 포스팅하게 되었습니다. 진행을 하다 보니 채식이들의 프론트와 백엔드 전역을 다루게 되었습니다. 먼저 독단적인 행동일 수도 있어서 팀원분들에겐 죄송하다는 말을 전합니다. 문제를 해결하기 위해선 React, axios 등, 당시 프론트 팀원들이 사용했던 기술들을 알아야 했습니다. 이전에는 사용하지 않았던 기술이었기 때문에, 클라이언트 영역을 구현했던 팀원 분에게 코드리뷰와 기술 사용 방법을 배웠습니다. 덕분에 기술 스펙트럼을 넓히고, 학습에 도움이 되었습니다.

프론트엔드 코드리뷰 및 기술 스터디


👿 'Version 1. 채식이들'에서 발생했던 문제


v1 채식이들 메인 페이지

위 이미지는 v1 채식이들에 들어갔을 때, 종종 발생하는 문제입니다. 해당 페이지에는 쇼핑몰에서 다루는 제품 리스트가 보여야 합니다. 하지만 보다시피 아무 제품도 보이지 않습니다. 

위 문제는 이상하게도 처음 웹사이트에 로그인을 하지 않은 상태로 접근했을 때만 발생합니다. 뿐만 아니라 우측에 보이는 노란색 박스에 버튼을 클릭했을 때도 아무 동작을 수행하지 않습니다.

 

🧐 원인 분석 


1) 서버 로그 출력

테스트 서버와 로컬 클라이언트에서도 동일한 문제가 발생했습니다. (이제까지 AWS와 가비아를 탓했던 제 자신이 민망해지는 순간이었습니다.😂) 

처음 서버의 로그를 확인하기 전에 브라우저의 콘솔에 찍힌 로그를 확인했습니다. 콘솔에는 500 에러 코드와 Member Not Found라는 로그가 찍혀 있었습니다. 여기서 두 가지 이상한 점을 발견했습니다. 첫 번째로 프로젝트를 진행할 때, 이와 같이 예기치 못한 상황을 방지하기 위해 대부분 예외처리를 했다고 생각했습니다. 그럼에도 500 코드인 에러가 발생했기 때문에 서버에서 처리가 되지 않았다고 판단했습니다. 두 번째는 분명 비회원으로 웹 사이트에 들어왔는데 Member Not Found라는 로그가 이상했습니다.

문제가 발생한 시점에 로그

아래 이미지는 문제가 발생했을 시점에 서버에 찍힌 에러 로그입니다. 500 에러 코드는 클라이언트에서 전달하는 Jwt를 검증하면서 발생한  NullPointerException이었습니다.

하지만 분명 로그인을 하지 않고 메인 페이지에 접근했는데, 어떻게 Jwt 검증 필터가 작동한 것일까요?

 

2) 디버깅 결과

채식이들은 메인 페이지에 접근할 때, 최우선으로 '회원인지, 비회원인지, 회원의 채식주의자 유형이 무엇인지'를 체크합니다. 따라서 API가 때, SecurityContextHolder에 어떤 인증 정보를 갖고 있는지 체크합니다.

저는 해당 API를 디버깅했습니다. 결과는 SecurityContextHolder에 알 수 없는 이메일이 찍혀있었습니다. 이를 통해서 클라이언트가 전송하는 토큰이 만료되었음에도 불구하고, 서버로 들어오고 있음을 알게 되었습니다.

* 문제를 정리하자면
(1) 클라이언트에서 만료된 토큰이 삭제되지 않은 채 서버에 전송되고 있다.
(2) Jwt 검증 필터에서 제대로 동작하지 않고, NullPointerException을 던지고 있다.

 

🚀 트러블 슈팅  


1) 문제 해결 전략

먼저 Jwt 검증 필터가 제대로 동작하지 않은 것은 정말 큰 문제입니다. 현재 발생하는 현상 외에도 다방면으로 문제가 발생할 수 있기 때문입니다. (이제까지 다른 문제가 발생하지 않았던 것이 신기하네요,,)

서버 전략

  • Jwt 검증 필터에서 Null이 발생하는 지점을 찾고, Catch를 통해 적절한 로직 수행
  • Jwt 토큰을 검증할 때 발생할 수 있는 예외마다 정확한 로그 출력

클라이언트 전략

  • Jwt 토큰을 보관하는 로컬스토리지 처리

 

2) Jwt 검증 필터에서 발생하는 NullPointerException 해결

기존에 문제가 되었던 코드

기존 문제의 Jwt 검증 메서드

위 이미지가 문제의 코드입니다. Map 타입의 claims가 초기값으로 Null을 갖고 있습니다. 그 아래에는 try/catch를 통해서 jwt의 값을 갖고 오고 있습니다. 만약 제대로 된 jwt 값을 갖고 오지 못한다면 (또는 만료된 토큰이라면) Exception이 발생해서 Catch로 넘어가게 됩니다. 하지만 내부에는 ErrorResponse를 생성만 할 뿐, 특별하게 동작하는 코드가 없습니다. 즉, 문제의 jwt가 들어온다면 , claims는 초기값인 Null 상태로 반환되게 됩니다.

반환된 claims를 활용하는 메서드

이후 claims는 위 두 이미지의 코드에서 활용됩니다. 어떠한 곳에서도 Null을 처리하는 코드는 없습니다. 따라서 Jwt 검증 필터는 NullPointerException으로 문제를 일으켰습니다.


이후 해결된 코드

첫 번째, 발생하는 예외에 따라 원인을 정확하게 표시하는 로그를 출력하도록 작성했습니다. 하지만 여전히 claims는 Null로 반환됩니다.

두 번째, verifyJwt(request) 메서드를 선언한 위치에서 NullPointerException을 Catch 했습니다. 추가로 발생하는 사전에 구현했던 핸들링 코드를 활용해서 예외 메시지를 ResponseBody에 JSON으로 전송하도록 했습니다.

 

3) Jwt 토큰을 보관하는 클라이언트의 Local Storage(로컬 스토리지) 처리

'채식이들' 서비스는 서버에서 전송하는 Jwt 토큰을 클라이언트 Local Storage에 보관했습니다. 여기서 문제는 로그아웃 외에 토큰을 제거하는 코드가 없었습니다. 따라서 로그아웃 없이 브라우저를 종료하거나, 서버가 갑작스럽게 종료했을 때에 토큰이 그대로 남아있게 되었습니다.

사실 서버에서 Jwt 검증 필터가 제대로 동작한다면, 만료된 토큰이 들어와도 문제가 발생하지 않습니다. 그럼에도 불구하고, 저는 클라이언트가 만료된 토큰을 계속 갖고 있을 필요가 없다고 생각했습니다. 따라서 토큰이 만료되면, 클라이언트 측에서도 삭제하는 전략을 선택하게 되었습니다.

단, 위 전략은 또 다른 문제가 있습니다.^^
보통 Jwt 토큰은 만료 기한이 짧은 Access Token과 기한이 긴 Refresh Token을 사용합니다. 즉, 로그인된 유저의 갑작스럽운 로그아웃을 방지하기 위해 Refresh Token으로 Access Token의 만료 기한을 갱신합니다.

저의 문제 해결 전략이라면,
Refresh Token을 활용하지 못하여 유저가 금방 로그아웃 되거나, Access Token의 만료 기한을 길게 설정함으로써 보안의 문제를 갖게 됩니다. 그럼에도 전략을 그대로 진행하는 이유는 당장의 문제를 해결하는 것을 목표로 했습니다. 이후에 발생할 문제를 인지하고 있기 때문에 다음 업데이트 미션으로 남겨두었습니다.

(좌) 클라이언트 코드, (우) 서버 코드

첫 번째, 유저가 로그인할 때, 서버에서 Header에 Jwt 토큰과 Jwt 만료 날짜(시간)를 함께 전송했습니다.

두 번째, 클라이언트에서 현재 날짜(시간)와 토큰 만료 날짜(시간)를 비교하고, 지났을 경우 로컬 스토리지에서 삭제합니다.

* 또 다른 문제 🥹
(1) 서버에서 보내는 Date는 Json으로 전송하기 때문에 문자열로 전송됩니다. 하지만 클라이언트의 Date는 Number 타입입니다. 따라서 이를 비교하기 위해 많은 고민을 했습니다. 이 과정에서 dayjs() 라이브러리를 알게 되었고, 이를 활용해서 포맷과 타입을 변경해서 두 데이터를 비교했습니다.
(2) AWS EC2를 통해 배포할 시 서버의 Date zone이 세계 표준시로 변경되는 것을 알게 되었습니다. 하지만 클라이언트는 서울 표준시였기 때문에 저는 서버에서 서울 시간이 적용될 수 있도록 노력했습니다.

Reference

[채식이들.v2 업데이트 코드]

https://github.com/NOT-ERROR-056/Chaesik2s_shop_Project

 

GitHub - NOT-ERROR-056/Chaesik2s_shop_Project: 채식유형만 선택하세요, 상품은 "채식이들"이 찾아드릴게요.

채식유형만 선택하세요, 상품은 "채식이들"이 찾아드릴게요.🥕. Contribute to NOT-ERROR-056/Chaesik2s_shop_Project development by creating an account on GitHub.

github.com

반응형