실전 API 연습 1. 일대일 관계

변현섭·2023년 6월 29일
0
post-thumbnail

이번 포스팅부터 제대로 된 API를 작성해보려 합니다. 지금까지는 실전 API를 위한 코드를 작성했다기 보다는 이해하기 쉽고 구현하기 쉽게 코드를 작성하는 데에 초점을 두었습니다.

그래서 아마 JPA 고수님들께선 제 시리즈의 글을 보시고 이상한 점을 많이 느끼셨을 거 같습니다. 예를 들어 게시글을 수정하기 위해 요청자의 이메일을 요청바디에 포함시킨 것이나, 이메일로 멤버를 찾은 결과와 JWT로 멤버를 찾은 결과를 비교하여 사용자 인증을 처리한 것, 로그아웃을 구현하기 위해 멤버에 저장된 토큰을 비워버리는 것 등이 여기에 해당합니다.

물론 이렇게 구현해도 상관은 없지만, 사용자 편의성이 매우 떨어집니다. 그래서 이제는 조금 더 완성도 있게 API를 바꿔보려 합니다. 지금까지의 내용을 이해하셨다면, 이번 포스팅의 내용도 이해하는 데에 큰 어려움이 없을 것입니다.

그렇다고 해서 이전에 배운 내용이 의미 없는 것은 아닙니다. 아이디어톤이나 해커톤 같이 기본적인 프로토타입만 구현하는 경우에는 이와 같이 작성하는 게 효율적일 수 있습니다. 또한 바로 실전 API로 들어가면 난이도가 매우 높기 때문에 이를 이해하는 단계로써도 의미가 있을 것입니다.

아래는 이번 포스팅에서 사용할 코드의 깃허브 링크입니다.
>> 깃허브 링크

소셜로그인 코드에 대한 설명은 4th UMC Server-Spring 시리즈에 올려두었습니다. 아래의 링크를 참조하시기 바랍니다.
>> 소셜로그인 코드 설명

구현하고자 하는 서비스에 대해 간략히 설명하겠습니다. 멤버, 게시글, 댓글의 클래스로 구성되어 있으며, 멤버는 프로필 사진(1개만 허용)을 사용할 수 있고, 게시글에는 여러 사진을 게시할 수 있습니다. 이 과정에서 필요한 CRUD API와 일반 로그인 및 소셜로그인 기능을 제공합니다.

Ⅰ. Member 클래스

1. 일대일 관계

Member 클래스에 Profile 클래스와의 연관관계가 추가되었다. 멤버의 프로필 사진은 하나만 지정할 수 있기 때문에 일대일 관계가 된다. 일대일 관계로 매핑할 때 사용하는 어노테이션은 @OneToOne이다.

사실 멤버에 프로필 사진을 추가하기 위해선 두 가지 방법을 고려할 수 있다(모든 일대일 관계가 다 그렇다). 멤버 테이블에 프로필 관련 정보를 모두 넣어 하나의 테이블로 구성하거나, 위 예시와 같이 멤버 테이블과 프로필 사진 테이블을 따로 관리하는 것이다. 두 방법에는 어떤 차이가 있을까?

1) 하나의 테이블(Member)로 구성

멤버의 속성과 프로필 사진의 속성이 함께 저장되면, 데이터의 중복을 줄일 수 있다는 장점이 있어 데이터의 일관성을 유지하기에 유리하다. 하지만, 프로필 사진은 필수 사항이 아닌만큼 아무런 입력 값 없이 비어 있는 경우도 많을 것이다. 전체 멤버의 50% 정도가 프로필 사진을 설정하지 않는다고 가정해보자. 이러란 경우, 프로필 사진의 URL과 파일명을 저장하는 두 필드의 50%는 공간을 낭비하고 있는 것이다.

2) 두 개의 테이블로 구성

멤버와 프로필 사진을 각각 별도의 테이블로 구성하면, 데이터베이스의 공간 낭비를 줄일 수 있다. 구체적인 예로, 이번에도 프로필 사진을 지정하지 않은 멤버가 50% 정도 존재한다 하자. 이러한 경우에도 프로필 사진이 존재하는 경우에만 DB에서 관리하기 때문에 아무런 문제가 되지 않는다. 물론, 프로필 사진에 대한 내용만 조회하기에도 편리해질 수 있을 것이다. 하지만, 두 테이블 간의 연관관계를 매핑해야 한다는 점이나, join 연산으로 인해 성능이 약간 떨어질 수 있다는 점은 단점이다.

3) 최선의 선택

① 하나의 테이블로 구성하는 게 유리한 경우

  • 데이터베이스의 공간 낭비를 크게 신경 쓸 필요가 없는 경우
  • 프로필 사진이 필수적으로 등록되어야 하는 경우
  • 데이터 용량보다 성능을 중시하고, 단순한 연산을 원하는 경우

② 두 개의 테이블로 구성하는 게 유리한 경우

  • 프로필 사진이 선택사항인 경우
  • 데이터 베이스의 공간 사용을 최소화해야 하는 경우
  • 프로필 사진만 별도로 조회하는 기능이 필요한 경우

이번 예시에서는 두 개의 테이블로 구성한다. 이러한 결정을 내린 이유는 프로필 사진이 선택사항이기 때문이다. 이로써, 다수의 멤버가 프로필 사진을 지정하지 않더라도 데이터베이스에서 공간이 낭비되지 않는다.

2. 주종 관계

일반적으로 일대일 관계에서 누가 주인이 되든 큰 문제는 없지만, 멤버와 프로필 사진의 관계에선 프로필 사진이 주인이 되는 것이 무조건 유리하다.

그 이유는 이미 멤버에서 관리하는 필드가 많으므로, 다른 쪽에서 관리할 수 있는 필드는 최대한 떠넘기는게 데이터베이스 가독성을 높이기 때문이다.

또한 프로필 사진이 주인이어야 멤버가 부모가 되면서 orphanRemoval 속성이 자연스러워진다. 만약 프로필 사진이 삭제될 때 멤버도 같이 삭제할지를 결정하라고 하면, 이는 매우 어색할 것이다. 당연히 멤버가 사라짐에 따라 프로필 사진도 지울 것인지를 설정하는 것이 더 자연스럽다. 이외에도 다양한 이유가 있을 수 있지만, 이 정도만 알아도 충분할 것 같다.

Ⅱ. Profile 클래스

Ⅲ. Member Controller - "login" 요청

1. utilService

기존에는 멤버를 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 여부를 확인한다.

2. JwtResponseDTO

1) inner class

  • TokenInfo 클래스는 JwtResponseDTO 클래스의 멤버로 정의되었기 때문에 TokenInfo 클래스는 JwtResponseDTO에 닷 오퍼레이터로만 접근할 수 있다.
  • 이와 같이 외부에서의 직접적인 접근을 제한하는 방법을 캡슐화라 한다.
  • 캡슐화를 사용한 가장 큰 이유는 데이터의 무결성을 유지하기 위함이다. 외부에서 토큰 값을 임의로 변경하거나 조작하는 것을 방지하여 신뢰성을 높인다. 토큰은 보안적으로 중요한 정보일 수 있으므로, 접근에 대한 제한이 필요하다.
  • 이외에도 의도치 않은 변경을 방지할 수 있고, 내부 구현 방식이나 세부 정보를 외부로부터 숨길 수 있다는 장점도 있다.
  • 참고로 완전한 캡슐화는 아니다. 일정 부분 캡슐화되었다는 뜻이다. 토큰 값을 입력하기에 너무 불편해지는 것을 방지하기 위해 완전히 캡슐화하지는 않았다.(완전한 캡슐화란 내부 클래스를 private으로 선언하는 것을 말한다.)

2) static class

  • static이 붙으면 inner 클래스는 외부 클래스와 독립적으로 존재하게 되고 외부 클래스의 인스턴스와 직접적으로 연결되지 않는다.
  • inner 클래스가 static으로 선언되지 않으면, 메모리 누수의 문제가 발생할 수 있다.
  • inner 클래스는 특별한 상황이 아니면 모두 static으로 선언한다.
  • 여기서 말하는 특별한 상황이란, 내부 클래스가 외부 클래스의 멤버를 가져와 사용하는 경우를 말한다. 이 때에는 static을 붙이면 안된다.

3. generateToken()


이전에는 access token만 사용했지만, 이번 포스팅부턴 refresh token까지 모두 사용한다. 이 메서드는 멤버의 Id를 받아 access token과 refrsh token을 모두 생성한다.

1) getTime()

  • new Date()는 현재 날짜와 시간을 나타내는 객체를 생성한다.
  • getTime()은 Date 객체에서 사용하는 메서드로, Date에 해당하는 날짜와 시간을 Long 타입의 밀리초 단위로 변환하여 반환한다.

2) 만료시간

  • 지난번에 다룬 createtoken() 메서드와 원리가 동일하다.
  • Access Token과 Refresh Token의 생성방법은 만료 시간만 다르고 모두 동일하다.
  • 만료 시간은 밀리초 단위로 작성해야 한다.

4. save()

생성된 토큰을 계속 사용할 수 있도록 memberRepository의 save() 메서드로 영속화해주어야 한다. 간혹 member.updateAccessToken()과 member.updateRefreshToken() 메서드를 사용하더라도 이를 save 해주지 않으면 변경사항이 저장되지 않는다.

Ⅳ. Member Controller - "logout" 요청

1. getLogoutMemberIdx()

1) getJwt()

  • HTTP 요청 헤더의 Authorization 필드에서 값을 꺼내 String형으로 반환하는 메서드이다.
  • 즉, 요청 헤더의 Authorization 필드에 담긴 JWT를 반환한다.

2) checkBlackToken()


이번 포스팅부터 로그아웃을 구현하기 위해 Redis 서버를 이용할 것이다. Redis 서버에 대한 설명은 다음 포스팅에서 자세히 다룰 예정이다. 여기서는 Redis 서버가 토큰 블랙리스트로 동작한다는 사실만 알고 넘어가자. 토큰 블랙리스트에 저장된 토큰은 무효가 되어 해당 토큰으로 오는 요청은 모두 거절된다. 이렇게 무효화된 토큰을 편의상 블랙토큰이라 부른다.

즉, 이 메서드는 Redis에 해당 토큰이 저장되어 있는지 여부를 판단해 true, false를 반환하는 역할을 수행한다. 반환 값이 true라는 것은 최근에 로그인 한 적이 있는데, 지금은 로그아웃을 한 상태라는 것이다. 이러한 의미에서 아래의 LOG_OUT_MEMBER를 예외로 호출한다.

3) Jwt Parsing

  • JWT를 파싱하여 클레임 정보를 가져와 claims 객체에 저장하고 있다.
  • 여기서, JWS란 Java Web Token라이브러리로 서명된 페이로드를 담는다.
  • 이번에는 parser() 대신 parserBuilder()를 사용했다. 이에 대해 간단히만 이야기하면, parserBuilder()가 JWT Parser를 구성하기 더 편리하다.
  • claims 객체를 통해 JWT에 접근한다. JWT의 본문(Claims)에 get() 메서드를 사용하여 memberId를 key로 갖는 value를 추출한다.

4) ExpiredJwtException

JWT가 만료되었을 때 발생하는 예외이다. 만약 요청 헤더에 포함된 JWT가 만료된 JWT일 경우, 이 예외에 해당하는 catch 문이 실행된다.

로그아웃을 제외한 모든 요청에 대해 이 예외가 발생하면, refresh token의 유효성을 평가한 후 access token을 재발급하는 로직을 넣어주어야 한다. 하지만, 로그아웃 요청만큼은 좀 특별하다. 로그아웃을 하려는 사용자의 access token이 만료되었다고 해서 다시 발급해줄 필요가 있을까?

로그아웃된 사용자의 access token은 Redis를 이용해 무효화 해줄 것인데, access token이 이미 만료되었다면, 굳이 무효화하지 않고 바로 로그아웃 시켜주면 된다. 이는 refresh token이 만료된 경우에 대해서도 동일하다. 즉, access token과 refresh token의 만료를 제외한 모든 부분이 유효하다면, 0L을 반환한다. 메서드의 반환 값이 0L인 경우, 컨트롤러 측에서 로그아웃하려는 멤버의 토큰이 만료 되었음을 인식하게 된다.

5) SecurityException or MalformedJwtException

① SecurityException

  • 보안 관련 작업에서 일어날 수 있는 예외이다.
  • 여기서는 사용자의 자격증명(JWT)이 올바르지 않음 등의 이유로 인증 절차를 통과하지 못한 경우에 발생한다.
  • 별도의 핸들러 메서드는 정의하지 않았기 때문에, INVALID_JWT가 호출된다.
  • 필요다하면 핸들러 메서드를 정의해도 된다.

② MalformedJwtException

  • JWT의 형식이 올바르지 않을 때 발생하는 예외이다.
  • 이에 대한 핸들러 메서드는 아래와 같이 정의되었다.

즉, 이 메서드는 Authorization 필드에 담긴 JWT에서 멤버의 ID를 추출하는 역할을 수행한다. 추출하는 과정에서 Access token이 만료되어 예외가 호출되면, 토큰 재발급은 하지 않고 멤버의 Refresh token만 지워 로그아웃을 처리한다.

profile
Java Spring, Android Kotlin, Node.js, ML/DL 개발을 공부하는 인하대학교 정보통신공학과 학생입니다.

0개의 댓글