이번 포스팅부터 제대로 된 API를 작성해보려 합니다. 지금까지는 실전 API를 위한 코드를 작성했다기 보다는 이해하기 쉽고 구현하기 쉽게 코드를 작성하는 데에 초점을 두었습니다.
그래서 아마 JPA 고수님들께선 제 시리즈의 글을 보시고 이상한 점을 많이 느끼셨을 거 같습니다. 예를 들어 게시글을 수정하기 위해 요청자의 이메일을 요청바디에 포함시킨 것이나, 이메일로 멤버를 찾은 결과와 JWT로 멤버를 찾은 결과를 비교하여 사용자 인증을 처리한 것, 로그아웃을 구현하기 위해 멤버에 저장된 토큰을 비워버리는 것 등이 여기에 해당합니다.
물론 이렇게 구현해도 상관은 없지만, 사용자 편의성이 매우 떨어집니다. 그래서 이제는 조금 더 완성도 있게 API를 바꿔보려 합니다. 지금까지의 내용을 이해하셨다면, 이번 포스팅의 내용도 이해하는 데에 큰 어려움이 없을 것입니다.
그렇다고 해서 이전에 배운 내용이 의미 없는 것은 아닙니다. 아이디어톤이나 해커톤 같이 기본적인 프로토타입만 구현하는 경우에는 이와 같이 작성하는 게 효율적일 수 있습니다. 또한 바로 실전 API로 들어가면 난이도가 매우 높기 때문에 이를 이해하는 단계로써도 의미가 있을 것입니다.
아래는 이번 포스팅에서 사용할 코드의 깃허브 링크입니다.
>> 깃허브 링크
소셜로그인 코드에 대한 설명은 4th UMC Server-Spring 시리즈에 올려두었습니다. 아래의 링크를 참조하시기 바랍니다.
>> 소셜로그인 코드 설명
구현하고자 하는 서비스에 대해 간략히 설명하겠습니다. 멤버, 게시글, 댓글의 클래스로 구성되어 있으며, 멤버는 프로필 사진(1개만 허용)을 사용할 수 있고, 게시글에는 여러 사진을 게시할 수 있습니다. 이 과정에서 필요한 CRUD API와 일반 로그인 및 소셜로그인 기능을 제공합니다.
Member 클래스에 Profile 클래스와의 연관관계가 추가되었다. 멤버의 프로필 사진은 하나만 지정할 수 있기 때문에 일대일 관계가 된다. 일대일 관계로 매핑할 때 사용하는 어노테이션은 @OneToOne이다.
사실 멤버에 프로필 사진을 추가하기 위해선 두 가지 방법을 고려할 수 있다(모든 일대일 관계가 다 그렇다). 멤버 테이블에 프로필 관련 정보를 모두 넣어 하나의 테이블로 구성하거나, 위 예시와 같이 멤버 테이블과 프로필 사진 테이블을 따로 관리하는 것이다. 두 방법에는 어떤 차이가 있을까?
멤버의 속성과 프로필 사진의 속성이 함께 저장되면, 데이터의 중복을 줄일 수 있다는 장점이 있어 데이터의 일관성을 유지하기에 유리하다. 하지만, 프로필 사진은 필수 사항이 아닌만큼 아무런 입력 값 없이 비어 있는 경우도 많을 것이다. 전체 멤버의 50% 정도가 프로필 사진을 설정하지 않는다고 가정해보자. 이러란 경우, 프로필 사진의 URL과 파일명을 저장하는 두 필드의 50%는 공간을 낭비하고 있는 것이다.
멤버와 프로필 사진을 각각 별도의 테이블로 구성하면, 데이터베이스의 공간 낭비를 줄일 수 있다. 구체적인 예로, 이번에도 프로필 사진을 지정하지 않은 멤버가 50% 정도 존재한다 하자. 이러한 경우에도 프로필 사진이 존재하는 경우에만 DB에서 관리하기 때문에 아무런 문제가 되지 않는다. 물론, 프로필 사진에 대한 내용만 조회하기에도 편리해질 수 있을 것이다. 하지만, 두 테이블 간의 연관관계를 매핑해야 한다는 점이나, join 연산으로 인해 성능이 약간 떨어질 수 있다는 점은 단점이다.
① 하나의 테이블로 구성하는 게 유리한 경우
② 두 개의 테이블로 구성하는 게 유리한 경우
이번 예시에서는 두 개의 테이블로 구성한다. 이러한 결정을 내린 이유는 프로필 사진이 선택사항이기 때문이다. 이로써, 다수의 멤버가 프로필 사진을 지정하지 않더라도 데이터베이스에서 공간이 낭비되지 않는다.
일반적으로 일대일 관계에서 누가 주인이 되든 큰 문제는 없지만, 멤버와 프로필 사진의 관계에선 프로필 사진이 주인이 되는 것이 무조건 유리하다.
그 이유는 이미 멤버에서 관리하는 필드가 많으므로, 다른 쪽에서 관리할 수 있는 필드는 최대한 떠넘기는게 데이터베이스 가독성을 높이기 때문이다.
또한 프로필 사진이 주인이어야 멤버가 부모가 되면서 orphanRemoval 속성이 자연스러워진다. 만약 프로필 사진이 삭제될 때 멤버도 같이 삭제할지를 결정하라고 하면, 이는 매우 어색할 것이다. 당연히 멤버가 사라짐에 따라 프로필 사진도 지울 것인지를 설정하는 것이 더 자연스럽다. 이외에도 다양한 이유가 있을 수 있지만, 이 정도만 알아도 충분할 것 같다.
기존에는 멤버를 email이나 id로 찾고, 그 결과가 null인 경우에 별도의 예외처리를 해주었다. 그렇게 하다보면, 코드의 반복이 많아진다는 단점이 있다. 이제부터는 utilService를 이용해 멤버를 찾음으로써 멤버가 null인 경우에 대한 처리 로직을 반복 작성하지 않아도 된다.
findByEmail이라는 쿼리의 반환타입은 Optional이기 때문에 orElse(null)을 붙여줘야 한다. 이는 쿼리의 반환 결과가 null일 경우 멤버가 null이 되도록 한다. 만약 멤버가 null이면 존재하지 않는 사용자라는 예외가 호출된다.
※ Member가 null인지 확인해야 하는 이유
member가 null인지 확인하지 않으면 어떻게 될까? 위 로그인 메서드를 예로 들어보자. decrypt() 메서드의 인자로 member.getPassword()를 넘겨주고 있는데, member가 null일 경우 Null Pointer Exception이 발생하며 메서드가 실행되지 않는다. 이 때, 발생하는 예외는 사용자가 읽기 어렵기 때문에 사용자가 이해하기 쉬운 방식으로 예외를 처리하기 위해 member의 null 여부를 확인한다.
이전에는 access token만 사용했지만, 이번 포스팅부턴 refresh token까지 모두 사용한다. 이 메서드는 멤버의 Id를 받아 access token과 refrsh token을 모두 생성한다.
생성된 토큰을 계속 사용할 수 있도록 memberRepository의 save() 메서드로 영속화해주어야 한다. 간혹 member.updateAccessToken()과 member.updateRefreshToken() 메서드를 사용하더라도 이를 save 해주지 않으면 변경사항이 저장되지 않는다.
이번 포스팅부터 로그아웃을 구현하기 위해 Redis 서버를 이용할 것이다. Redis 서버에 대한 설명은 다음 포스팅에서 자세히 다룰 예정이다. 여기서는 Redis 서버가 토큰 블랙리스트로 동작한다는 사실만 알고 넘어가자. 토큰 블랙리스트에 저장된 토큰은 무효가 되어 해당 토큰으로 오는 요청은 모두 거절된다. 이렇게 무효화된 토큰을 편의상 블랙토큰이라 부른다.
즉, 이 메서드는 Redis에 해당 토큰이 저장되어 있는지 여부를 판단해 true, false를 반환하는 역할을 수행한다. 반환 값이 true라는 것은 최근에 로그인 한 적이 있는데, 지금은 로그아웃을 한 상태라는 것이다. 이러한 의미에서 아래의 LOG_OUT_MEMBER를 예외로 호출한다.
JWT가 만료되었을 때 발생하는 예외이다. 만약 요청 헤더에 포함된 JWT가 만료된 JWT일 경우, 이 예외에 해당하는 catch 문이 실행된다.
로그아웃을 제외한 모든 요청에 대해 이 예외가 발생하면, refresh token의 유효성을 평가한 후 access token을 재발급하는 로직을 넣어주어야 한다. 하지만, 로그아웃 요청만큼은 좀 특별하다. 로그아웃을 하려는 사용자의 access token이 만료되었다고 해서 다시 발급해줄 필요가 있을까?
로그아웃된 사용자의 access token은 Redis를 이용해 무효화 해줄 것인데, access token이 이미 만료되었다면, 굳이 무효화하지 않고 바로 로그아웃 시켜주면 된다. 이는 refresh token이 만료된 경우에 대해서도 동일하다. 즉, access token과 refresh token의 만료를 제외한 모든 부분이 유효하다면, 0L을 반환한다. 메서드의 반환 값이 0L인 경우, 컨트롤러 측에서 로그아웃하려는 멤버의 토큰이 만료 되었음을 인식하게 된다.
① SecurityException
② MalformedJwtException
즉, 이 메서드는 Authorization 필드에 담긴 JWT에서 멤버의 ID를 추출하는 역할을 수행한다. 추출하는 과정에서 Access token이 만료되어 예외가 호출되면, 토큰 재발급은 하지 않고 멤버의 Refresh token만 지워 로그아웃을 처리한다.