1. H2 Database 홈페이지에서 다운로드

다운로드 링크 : https://www.h2database.com/html/main.html


최신 버전보다는 안정화된 버전이 괜찮습니다.



2. 압축 풀고 실행

다운 받은 파일의 압축을 풀면 다음과 같은 구성으로 되어 있습니다.

여기서 ./bin/h2.sh 를 입력하면 h2 console 을 실행합니다.

만약 권한이 없으면 chmod 755 ./bin/h2.sh 로 권한을 부여합니다.



3. 한번 연결해서 ~.mv.db 파일 생성 후 실행

처음 진입하면 아래와 같은 화면이 나옵니다.

다른 칸은 전부 그대로 두고 JDBC URL 부분만 표시한 것처럼 바꿔줍니다.

그리고 연결 버튼을 눌러서 진입합니다.


그럼 다음과 같이 내 Root 폴더에 my-db-test.mv.db 파일이 생깁니다.

이후에 다시 h2 console 에서 연결 끊기 후 jdbc:h2:tcp://localhost/~/my-db-test 로 접속해서 사용하면 됩니다.



4. Spring Boot 에 연결

application.yml 에서 DB 설정할 때 spring.datasource.url 에 JDBC URL 과 동일하게 세팅합니다.

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/my-db-test
    username: sa
    password:
    driver-class-name: org.h2.Driver

Reference

Overview

Spring Boot 로 REST API 를 테스트 하다가 이상한 이슈에 직면했습니다.

클라이언트에서 @RequestBody 로 요청을 받기 위한 DTO 클래스를 만들고 값을 입력 받았는데 null 값이 입력되는 겁니다.

처음에는 오타가 있거나 잘못 만든 건 줄 알았는데 변수명을 바꾸니 잘 동작했습니다.

예를 들어, 변수명이 aCount 일 때는 동작하지 않았는데 aaCount 로 바꾸니 제대로 값이 들어왔습니다.

그래서 이것저것 바꿔가면서 테스트를 하였고 Jackson, Lombok 에 대해서 알게 된 사실을 정리합니다.

쓰다보니 글이 장문이 되었는데 결론과 해결법만 알고 싶으면 마지막만 보면 됩니다.


1. Jackson

Spring 은 JSON 데이터를 매핑하기 위한 Message Converter 로 Jackson 을 사용합니다.

(Http Message Converters with the Spring Framework - Baeldung 참고)

위에서 제시한 문제의 원인은 Lombok 이었지만 Jackson 의 JsonMessageConverter 의 동작에도 원인이 숨겨져 있습니다.

이를 확인하기 위해서는 Jackson 의 DTO <-> Json 과정이 어떻게 이루어지는 지 먼저 파악이 필요합니다.


1.1. Jackson 은 Getter 의 이름을 기반으로 Json Key 값을 만든다

Jackson 에는 한 가지 재미있는 사실이 있습니다.

Object -> Json 으로 변환 하면 해당 Object 의 필드명을 기준으로 될거라고 생각했는데 사실 Getter의 이름 기준으로 바뀝니다.


public class JacksonDto {
    private String name;

    public String getNameChange() {
        return name;
    }
}
  • 필드명은 name 이지만 Getter 이름은 getNameChange() 입니다.

public class DtoTest {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Test
    void test_jackson_dto() throws Exception {
        JacksonDto jacksonDto = new JacksonDto("my name");
        String content = objectMapper.writeValueAsString(jacksonDto);

        // 출력 = Jackson : {"nameChange":"my name"}
        System.out.println("Jackson : " + content);
    }
}
  • 필드명 대신 Getter 의 이름으로 Json Key 값이 설정되었습니다.
  • Getter 의 이름은 당연하게도 필드명과 동일하게 지어와서 지금까지 눈치 채지 못했습니다.

1.2. Jackson 이 Json Key 이름을 변환하는 데는 일정한 규칙이 있다

Object 의 필드명을 Getter 로 바꿀 때 일반적으로 맨 앞 글자를 대문자로 바꿔줍니다.

ex) name -> getName()

Jackson 은 Getter 를 기준으로 변환시키기 때문에 Jackson 내부적으로도 나름의 기준을 갖고 변홥합니다.

기본적으로는 JavaBeans 규약을 따르지만 다른 부분이 있었습니다.

먼저 JavaBeans 규약을 먼저 알아봅니다.


2. JavaBeans 규약

JavaBeans 는 메서드 이름에서 필드명을 추출할 때 일정한 규칙이 존재합니다.

stack overflow 의 Naming convention for getters/setters in Java 의 답변을 보면 Java Bean 규약을 첨부한 답변이 있습니다.

여기서 8.8 Capitalization of inferred names 챕터를 보면 아래와 같습니다.


When we use design patterns to infer a property or event name, we need to decide what rules to follow for capitalizing the inferred name.
If we extract the name from the middle of a normal mixedCase style Java name then the name will, by default, begin with a capital letter.
Java programmers are accustomed to having normal identifiers start with lower case letters.
Vigorous reviewer input has convinced us that we should follow this same conventional rule for property and event names.

Thus when we extract a property or event name from the middle of an existing Java name, we normally convert the first character to lower case.
However to support the occasional use of all upper-case names, we check if the first two characters of the name are both upper case and if
so leave it alone. So for example,

“FooBah” becomes “fooBah”
“Z” becomes “z”
“URL” becomes “URL”

We provide a method Introspector.decapitalize which implements this conversion rule.


간단히 요약하면 클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 때문에 첫 번째 글자를 소문자로 변환한다는 겁니다.

다만, 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이런 경우는 예외로 둔다고 합니다.

그리고 예외 케이스를 판별하기 위해 첫 두 문자가 모두 대문자인지를 확인합니다.

그리고 java.beans 패키지에 있는 Introspector 클래스를 확인해보면 실제로 어떤 로직이 들어가있는 지 알 수 있습니다.


public class Introspector {
    // ...

    public static String decapitalize(String name) {
        if (name == null || name.length() == 0) {
            return name;
        }
        if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
                        Character.isUpperCase(name.charAt(0))){
            return name;
        }
        char chars[] = name.toCharArray();
        chars[0] = Character.toLowerCase(chars[0]);
        return new String(chars);
    }

    // ...
}
  • 맨 앞 두개가 전부 대문자라면 그대로 리턴하고 아니라면 맨 앞 문자 하나만 소문자로 바꿔서 리턴합니다.

3. 그렇다면 Jackson 에서는?

Jackson 도 JavaBeans 규약을 따르지만 다른 점이 하나 있습니다.

테스트로 알아본 Jackson 의 규칙은 다음과 같습니다.

  1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
  2. 나머지 모든 케이스에서는 맨 앞 글자만 소문자로 바꿔준다.

JavaBeans 규약과 다른 부분은 1 번입니다.

JavaBeans 규약에서는 앞 두 글자가 대문자인 경우 그대로 사용한다고 했으나 Jackson 은 맨 앞부터 이어진 대문자를 모두 소문자로 변경합니다.

예제를 통해서 확인해보겠습니다.


3.1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.

사실 JavaBeans 규약과 다른 게 이 부분입니다.

Jackson 에서는 맨 앞 두글자가 대문자라면 이어진 모든 대문자를 소문자로 변경합니다.

  • AAaa -> aaaa : 앞 두 글자가 대문자라서 소문자로 변경
  • BBBb -> bbbb : 앞 두 글자가 대문자라서 이어진 세번째 문자까지 소문자로 변경
  • CCcC -> cccC : 앞 두 글자를 소문자로 변경하지만 맨 뒤의 대문자는 이어져 있지 않아서 그대로 사용
  • DDDD -> dddd : 앞 두 글자부터 이어진 대문자를 모두 소문자로 변경

3.1.1. DTO 정의

@ToString
@NoArgsConstructor
public class OneDto {
    private String AAaa;
    private String BBBb;
    private String CCcC;
    private String DDDD;

    public String getAAaa() {
        return AAaa;
    }

    public String getBBBb() {
        return BBBb;
    }

    public String getCCcC() {
        return CCcC;
    }

    public String getDDDD() {
        return DDDD;
    }
}

3.1.2. Controller 작성

@RestController
public class HelloController {

    @PostMapping("/one")
    public ResponseEntity<OneDto> postOne(@RequestBody OneDto dto) {
        System.out.println("----- Request POST /one ------");
        System.out.println(dto);

        return ResponseEntity.ok(dto);
    }
}
  • 실제로 요청이 왔을 때 값이 어떻게 들어오는 지 확인합니다.
  • 받은 @RequestBody 값을 그대로 다시 Response 로 내려줍니다.

3.1.3. Request

POST http://localhost:8080/one
Content-Type: application/json

{
  "AAaa": "a",
  "BBBb": "b",
  "CCcC": "c",
  "DDDD": "d"
}
  • IntelliJ 에서 제공하는 http request tool 을 사용했습니다.

3.1.4. Log

----- Request POST /one ------
OneDto(AAaa=null, BBBb=null, CCcC=null, DDDD=null)
  • Controller 에서 찍어둔 print 입니다.
  • 값이 전부 null 로 들어옵니다.

3.1.5. Response

{
  "aaaa": null,
  "bbbb": null,
  "cccC": null,
  "dddd": null
}
  • 예측한 대로 나오는 걸 확인할 수 있습니다.
  • 요청으로 들어온 OneDto 값을 그대로 리턴했을 뿐인데 Message Converter 에 의해 요청값과 응답값의 Json Key 값이 바꼈습니다.

3.2. 맨 앞 두글자가 대문자가 아니면 맨 앞 글자만 소문자로 바꿔준다

이거는 그냥 단순하게 1 번을 제외한 모든 케이스에서는 맨 앞글자만 소문자로 바꿔줍니다.

뒤에 오는 대문자나 소문자는 신경쓰지 않습니다.


3.2.1. DTO 정의

@NoArgsConstructor
public class TwoDto {
    private String aaaa;
    private String bbbB;

    private String Cccc;
    private String DddD;

    private String eEee;
    private String fFfF;

    public String getAaaa() {
        return aaaa;
    }

    public String getBbbB() {
        return bbbB;
    }

    public String getCccc() {
        return Cccc;
    }

    public String getDddD() {
        return DddD;
    }

    public String geteEee() {
        return eEee;
    }

    public String getfFfF() {
        return fFfF;
    }
}
  • DTO 를 정의하구 Controller 코드는 OneDto 와 동일하게 실행합니다.

3.2.2. Request

POST http://localhost:8080/two
Content-Type: application/json

{
  "aaaa": "a",
  "bbbB": "b",
  "Cccc": "c",
  "DddD": "d",
  "eEee": "e",
  "fFfF": "f"
}

3.2.3. Log

----- Request POST /two ------
TwoDto(aaaa=a, bbbB=b, Cccc=null, DddD=null, eEee=e, fFfF=f)
  • Cccc, DddD 를 제외한 나머지는 전부 값이 제대로 들어옵니다.

3.2.4. Response

{
  "aaaa": "a",
  "bbbB": "b",
  "cccc": null,
  "dddD": null,
  "eEee": "e",
  "fFfF": "f"
}
  • 예측한 대로 잘 나옵니다.
  • 맨 앞 글자가 대문자였던 CcccDddD 만 바뀌고 나머지는 그대로입니다.
  • 중요하게 볼 점은 TwoDto 의 필드명과 달라진 애들은 값이 제대로 들어오지 않는다는 사실입니다.

3.3. Jackson 결론

우리는 지금까지의 테스트를 통해서 한 가지 사실을 알았습니다.

DTO 의 필드명이 대문자로 시작하면 Request 요청 시 값이 제대로 들어오지 않습니다.

필드명이 대문자로 시작하면 Getter 도 대문자로 시작하는 수밖에 없습니다.

Jackson 의 규칙에 따라서 get 이후가 대문자로 시작하면 최소한 첫 글자는 항상 소문자로 바뀝니다.

따라서 필드명과 일치하지 않아 데이터가 들어가지 않는 현상입니다.

필드명을 대문자로 시작하는 경우는 많이 없지만 URL 처럼 모두 대문자로 사용했다가 안될 가능성도 있습니다.


4. Lombok 은 무슨 관계일까?

Lombok 은 개발자들이 일일히 만들어야 하는 반복적인 코드를 줄일수 있게 도와주는 라이브러리입니다.

그 중에서도 @Getter 어노테이션은 거의 모든 Object 에 필수적으로 사용됩니다.

제가 이슈를 겪었던 DTO 오브젝트도 롬복을 사용했습니다.

그렇다면 롬복의 문제점은 무엇일까요?


4.1. Lombok 의 Getter 생성 규칙

Lomobk 의 @Getter 어노테이션을 붙이면 클래스의 Getter 메소드를 자동으로 생성해줍니다.

그런데 @Getter 의 생성 규칙은 굉장히 단순합니다.

get 다음에 무조건 필드명의 맨 앞 글자를 대문자로 바꿔서 만들어줍니다.

lombok 의 Github Issue 에도 이 내용에 대한 문의가 있습니다.

제가 문제를 겪었던 필드명도 aCount 였습니다.

Lombok 이 getACount 로 생성해주고 Jackson 을 거치니 acount 가 되어서 필드명이 일치하지 않아 문제가 발생했었습니다.

반면 aaCountgetAaCount 가 되고 Jackson 을 거쳐도 aaCount 가 되어서 정상적으로 값이 들어오죠.


4.2. 인텔리제이 Generator 의 Getter 생성 규칙

public class CountDto {
    private int aCount;

    public int getaCount() {
        return aCount;
    }
}

Lombok 대신 인텔리제이에서 제공하는 제네레이터로 Getter 를 만들면 위 이슈를 회피할 수 있습니다.

getACount 대신에 getaCount 로 만들어주기 때문에 Jackson 을 거쳐도 aCount 라는 필드명과 일치합니다.


Conclusion

지금까지 정리한 내용을 요약하면 아래와 같습니다.

  1. Spring 의 Json Message Converter 는 Jackson 라이브러리를 사용
  2. lombok 의 Getter 는 필드명 맨 앞을 항상 대문자로 만듬
  3. Jackson 라이브러리는 Getter 의 맨 앞 두글자가 전부 대문자인 경우 필드명과 Json key 값이 달라짐
  4. aCount 라는 필드명을 lombok 을 사용해서 Getter 를 만들면 getACount() 가 되기 때문에 이슈가 발생

위 문제를 해결하려면 필드명을 작성할 때 첫 번째는 소문자, 두 번째는 대문자인 케이스로 만들지 않으면 됩니다.

그래도 꼭 사용해야 한다면 lombok 의 @Getter 대신 직접 Getter 를 만들거나, @JsonProperty 를 사용하면 됩니다.


Reference

Overview

토이 프로젝트를 하면서 개발 초반에 작성했던 코드를 보았습니다.

JPA 메서드로 조건을 걸어서 가져오는 로직인데 실제로 쿼리가 날라가는 걸 확인해보니 제가 생각한 것과 다르게 동작하는 걸 알게 되었습니다.


1. Entity

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column
    private String email;

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();
}


@Entity
public class Post {

    @Id @GeneratedValue
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Column
    private String content;
}

일반적인 두 개의 Entity 가 존재합니다.

사용자 Member 는 여러 개의 게시글 Post 를 작성할 수 있으므로 1:N 관계입니다.


2. Repository

public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findAllByMember(Member member);
    List<Post> findAllByMemberId(Long memberId);
}

이제 post 테이블에서 post.member_id 를 기준으로 일치하는 데이터들을 가져오는 메서드를 작성합니다.

가능한 메서드는 두 종류가 있습니다.

  1. Member 라는 엔티티를 조건으로 검색하는 findAllByMember
  2. memberId 라는 필드값을 조건으로 검색하는 findAllByMemberId

결론부터 말하자면 1 번 메서드를 사용해야 합니다.


3. Test

두 메서드를 사용했을 때 실제로 쿼리가 어떻게 날라가는 지 확인해보겠습니다.


3.1. Member ID 로 조회

@Test
void testFindPosts() {
    Long memberId = 2L;
    postRepository.findAllByMemberId(memberId);
}

제가 처음에 작성했던 코드입니다.

  • 이유는 단순하게 memberId 값은 이미 알고 있는 데 굳이 Member 엔티티를 한번 조회해야 할 필요가 있을까??
  • Post 에도 member_id 라는 필드가 있으니까 바로 조회하자!

라는 생각으로 사용했습니다.

SELECT * FROM post WHERE post.member_id = ? 를 기대했지만 실제로 날라가는 쿼리는 달랐습니다.


select
    post0_.post_id as post_id1_5_,
    post0_.content as content5_5_,
    post0_.member_id as member_i8_5_,
from
    post post0_ 
left outer join
    member member1_ 
        on post0_.member_id=member1_.member_id 
where
    member1_.member_id=?

예상과는 달리 LEFT OUTER JOIN 쿼리가 발생합니다.


3.2. Member 엔티티로 조회

@Test
void testFindPosts() {
    Long memberId = 2L;
    Member member = memberRepository.findById(memberId).get();
    postRepository.findAllByMember(member);
}

Member 를 한번 조회한 후에 조건으로 엔티티를 넣어봅니다.


select
    post0_.post_id as post_id1_5_,
    post0_.content as content5_5_,
    post0_.member_id as member_i8_5_,
from
    post post0_ 
where
    post0_.member_id=?

우리가 원하던 쿼리가 정상적으로 날라갑니다.


3.3. Query 를 직접 짜서 조회

@Query(value = "SELECT p FROM Post p WHERE p.member.id = :memberId")
List<Post> findAllByMemberIdQuery(@Param("memberId") Long memberId);

만약 극한의 성능 최적화를 해야 한다면 이렇게 직접 쿼리를 짜는 방법도 있습니다.

정상적으로 동작합니다.


Conclusion

처음에는 단순하게 memberId 정보를 내가 갖고 있고 Post 엔티티에도 memberId 필드가 존재하는데 바로 조회하면 되지 않을까?

하는 생각에서 코드를 짰었습니다.

그런데 실제로 쿼리가 날라가는 걸 확인하니 비효율적으로 동작하고 있었던 걸 알 수 있었네요.

보통 Member 데이터에 대해 검증을 하기 위해 조회를 한번 하니 쿼리 한번 아깝다고 생각하지 말고 엔티티로 조건을 거는 게 좋아보입니다.

JPA 에서 @ManyToOne 인 필드로 조건을 걸 때는 항상 Entity 를 사용하자

극한의 성능 최적화가 필요하다면 JPQL 이나 QueryDSL 로 직접 짜자

Overview

토이 프로젝트로 인스타그램 클론 코딩을 진행 중인데 여기서 1 + N 문제를 만났습니다.

인스타그램에 접속했을 때 사용자에게 보여주는 첫 화면은 내가 팔로우 중인 사람들의 최신 게시글 목록입니다.

내가 팔로우 중인 사람은 여러명이고 또 그 여러명이 여러개의 게시글을 작성할 수 있으므로 1 + N 문제가 발생합니다.


1. Domain

사용 중인 Entity 를 간단히 정의합니다.

자잘한 건 생략하고 Member, Post, Follow 세 개의 엔티티만 정의하겠습니다.


1.1. Member

@Table(name = "member")
@Entity
@Getter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String email;

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();

    @OneToMany(mappedBy = "fromMember")
    private List<Follow> followings = new ArrayList<>();

    @OneToMany(mappedBy = "toMember")
    private List<Follow> followers = new ArrayList<>();
}
  • email 정보만 갖고 있는 간단한 Member 엔티티입니다.
  • 멤버는 여러 개의 게시글을 가질 수 있고, 여러명을 팔로우 할 수 있으며 반대로 여러 명에게 팔로우 당할 수 있으므로 전부 @OneToMany 로 매핑해줍니다.

1.2. Follow

@Table(name = "follow")
@Entity
@Getter
@NoArgsConstructor
public class Follow {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "follow_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "from_member_id")
    private Member fromMember;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "to_member_id")
    private Member toMember;
}
  • Follow 에는 팔로우 하는 사람 (from) 과 팔로우 대상 (to) 의 Member ID 만 존재합니다.
  • 사용자는 팔로우라는 액션에 대해서 N:M 관계입니다.
  • 그래서 Follow 라는 관계 테이블을 하나 생성해서 @ManyToOne 으로 매핑해줍니다.

1.3. Post

@Table(name = "post")
@Entity
@Getter
@NoArgsConstructor
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @Column(columnDefinition = "text")
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}
  • content 정보만 갖고 있는 Post 엔티티입니다.
  • 멤버 한명은 여러 개의 게시글을 작성할 수 있기 때문에 @ManyToOne 으로 매핑해줬습니다.


2. 명세

제가 구하고자 하는건 인스타 피드입니다.

로그인 한 내 정보를 갖고 있고, 내가 팔로우한 사용자들을 follow 테이블에서 가져와야 합니다.

그리고 그 사용자들의 member_id 와 일치하는 데이터들을 post 테이블에서 뽑아야 합니다.

하지만 내가 팔로우 하는 대상이 엄청 많고 또 그 사람들이 작성한 글도 엄청 많다면 어떻게 될까요?

클라이언트에게 데이터를 내려주는 것은 물론이고 DB 에서 조회하는 것부터 오래걸릴 겁니다.

따라서 모든 데이터를 한번에 내려주지 말고 당장 필요한 데이터만 짤라서 전달해주는 페이지네이션 기법이 필요합니다.

인스타는 페이지 형식이 아니라 무한 스크롤 형식이므로 lastPostId 를 받아와서 offset 으로 사용하고 Post 데이터는 최대 5 개만 내려주도록 구현하려고 합니다.


2.1. 단순하게 Entity Collection 호출

public List<Post> getFeeds(Long lastPostId) {
    Member currentMember = getCurrentMember();

    List<Post> posts = new ArrayList<>();

    // 내가 팔로우 하는 "모든" 대상들이 작성한 "모든" 게시글을 가져옴
    currentMember.getFollowings()
            .stream()
            .map(Follow::getToMember)
            .forEach(member -> posts.addAll(member.getPosts()));

    return posts.stream()
            .filter(post -> post.getId() < lastPostId)
            .sorted((a, b) -> (int) (b.getId() - a.getId()))
            .limit(5L)
            .collect(Collectors.toList());
}
  • 가장 심플하게 조회하는 방법입니다.
  • getCurrentMember() 는 로그인 된 내 정보를 가져옵니다.
  • 내가 팔로우 하고 있는 대상들을 getFollowings() 메서드로 구합니다.
  • 멤버 정보를 조회하여 getPosts() 메서드로 게시글들을 가져옵니다.
  • 구한 모든 게시글들을 필터링 하고 최신순으로 만큼만 잘라서 리턴합니다.

-- 팔로우 하는 모든 대상 구하기
SELECT * FROM follow WHERE follow.from_member_id = ?

-- 첫 번째 팔로우 유저의 정보를 가져와서 게시글 가져오기
SELECT * FROM member WHERE member.member_id = ?
SELECT * FROM post WHERE post.member_id = ?

-- 두 번째 팔로우 유저의 정보를 가져와서 게시글 가져오기
SELECT * FROM member WHERE member.member_id = ?
SELECT * FROM post WHERE post.member_id = ?

-- 팔로우 대상들 만큼 반복..
-- ...
  • 내가 팔로우 중인 대상의 수 * 2 만큼 추가 쿼리가 나갑니다.

2.2. Fetch Join

1 + N 문제를 해결하기 위한 가장 일반적인 방법입니다.

여러 개의 쿼리를 날리지 않고 쿼리 한번에 데이터를 가져옵니다.


2.2.1. 사용

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {

    @Query(value = "SELECT p" +
            " FROM Post p" +
            " JOIN FETCH p.member m" +
            " JOIN FETCH m.followers f" +
            " WHERE f.fromMember.id = :memberId AND p.id < :lastPostId")
    List<Post> findByFetchJoin(@Param("memberId") Long memberId, @Param("lastPostId") Long lastPostId, Pageable pageable);
}
  • 쿼리를 날리는 PostRepository 입니다.
  • 인스타 피드 조건에 맞는 데이터를 쿼리로 한번에 뽑아오기 때문에 추가 쿼리가 발생하지 않습니다.
  • Fetch Join 은 연관된 테이블끼리만 사용 가능한데, FollowPost 는 직접적인 연관 관계가 없기 때문에 Member 까지 같이 조인해줘야 합니다.

public List<Post> getFeeds(Long lastPostId) {
    Member currentMember = getCurrentMember();
    PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id").descending());

    return postRepository.findByFetchJoin(currentMember.getId(), lastPostId, pageRequest);
}
  • PageRequest.of(0, 5) 을 사용해서 일정한 사이즈 만큼의 데이터를 가져옵니다.
  • 무한 스크롤은 lastPostId 를 사용해서 데이터를 필터링 하기 때문에 무조건 0 번째 페이지를 가져오도록 설정했습니다.

2.2.2. Fetch Join Paging 결과가 실제로는 어떻게 나올까?

o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • Fetch Join 과 Pageable 을 함께 사용하면 나오는 로그입니다.
  • 둘을 같이 사용하면 LIMIT 쿼리가 제대로 적용되지 않습니다.
  • DB 에서 Fetch Join 한 결과물을 모두 가져온 후 애플리케이션 메모리에서 직접 골라내기 때문에 데이터 수가 많다면 OutOfMemory 에러가 발생할 가능성이 높아집니다.

SELECT *
FROM post
INNER JOIN member ON post.member_id = member.member_id
INNER JOIN follow ON member.member_id = follow.to_member_id
WHERE follow.from_member_id = ? AND post.post_id < ?
ORDER BY post.post_id DESC
  • 실제로 날라가는 쿼리입니다.
  • 분명 Pageable 을 사용했음에도 불구하고 LIMIT 조건이 추가되지 않는 것을 볼 수 있습니다.
  • 왜 이런 결과가 발생하는지는 여기서 너무 깊게 들어가면 내용이 길어지기 때문에 생략하겠습니다.
  • 어쨌든 Fetch Join 과 Pageable 은 함께 사용 불가능합니다.

2.3. Batch Size

1 + N 문제의 또다른 해결법으로 알려진 Batch Size 는 어떨까요?


spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100   # 전역으로 배치 사이즈 적용
  • 100 정도만 적용해서 2.1. 번의 Collection 호출하는 코드를 다시 실행시켜 보겠습니다.

-- 내가 팔로우 한 사람들 목록 조회 (member.getFollowings())
SELECT * FROM follow WHERE follow.from_member_id = ?

-- 내가 팔로우 한 사람들의 정보들을 IN 쿼리로 조회 (follow.getToMember())
SELECT * FROM member WHERE member.member_id IN (?, ?, ...)

-- 내가 팔로우 한 사람들의 게시글 조회 (member.getPosts())
SELECT * FROM post WHERE post.member_id IN (?, ?, ...)
  • 쿼리의 갯수가 줄어들어 N + 1 문제가 해결되었습니다.
  • 그러나 LIMIT 쿼리를 넣지 못해서 모든 데이터를 애플리케이션 레이어로 가져온 후에 처리합니다.

  • 영한님에게 한번 질문했었는데 OOM 이슈가 있기 때문에 적절한 해결방법이 아닙니다.
  • 만약, 데이터가 일정 개수 이하라는 보장이 있다면 Batch Size 적용만으로도 충분할 것 같습니다.

2.4. IN 쿼리로 조회

post 테이블에 member_id 가 존재하는데 굳이 테이블 조인을 해야하나?

해당하는 member_id 리스트를 구한 다음에 IN 쿼리를 쓰면 조인할 필요도 없고 간단하지 않을까?

하는 생각에서 시도해봤습니다.


@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findByIdLessThanAndMemberIn(Long lastPostId, List<Member> members, Pageable pageable);
}
  • JPA 메서드로 한번에 작성했습니다.
  • ByIdLessThan : lastPostId 보다 Post ID 값이 작은 게시글들만 가져옵니다.
  • MemberIn : post.member_id 기준으로 IN 쿼리를 추가합니다.

public List<Post> getFeeds(Long lastPostId) {
    Member currentMember = getCurrentMember();

    List<Member> followings = currentMember.getFollowings()
            .stream()
            .map(Follow::getToMember)
            .collect(Collectors.toList());

    PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id").descending());
    return postRepository.findByIdLessThanAndMemberIn(lastPostId, followings, pageRequest);
}
  • getFollowings() 로 팔로우 대상들을 먼저 가져옵니다.
  • post 테이블을 조회할 때 대상 ID 들을 IN 쿼리에 넣어줍니다.

-- 팔로우 대상들 가져오기
SELECT * FROM follow WHERE follow.from_member_id = ?

-- IN 쿼리를 사용해서 팔로우 대상들이 작성한 게시글들 전부 가져오기
SELECT * 
FROM post 
WHERE post.post_id < ? AND post.member_id IN (?, ?, ...) 
ORDER BY post.post_id DESC 
LIMIT ?
  • 쿼리 한 두번에 데이터를 모두 가져오고 LIMIT 쿼리도 정상적으로 날라갑니다.
  • IN 쿼리 조건 컬럼에 인덱스만 걸려있다면 성능도 보장됩니다.

쿼리만 보면 성능적으로 많이 개선되었습니다.

그러나 여기엔 한 가지 함정이 있습니다.

만약 followings 의 사이즈가 엄청나게 많다면??

IN 쿼리는 분명 효율적이긴 하지만 갯수가 1000 이 넘어가면 역시 성능적인 문제를 피할 수 없습니다.

그래도 IN 쿼리를 사용하겠다면 임의로 1000 개씩 자른 후 나누어서 호출해야 합니다.


2.5. 일반 JOIN + LIMIT JPQL

위 코드에서 말했던 것처럼 IN 쿼리를 사용하기 위해 구한 followings 값 역시 엄청나게 큰 값이 될 수 있습니다.

따라서 다른 방법을 생각해봐야 하는데, 제가 실무에서 JPA 를 사용하지 않으니 어떤 방법으로 해야 할 지 감이 오지 않았습니다.

그래서 인프런에서 JPA 권위자이신 김영한 님에게 질문을 드렸고 답변을 받았습니다.

솔루션은 JPQL 로 Join, Limit 조건을 작성해서 직접 post 테이블을 조회하는 거였습니다.

JPA 에서 Fetch Join 은 Pageable 사용이 불가능하지만 일반 Join 은 사용할 수 있습니다.

사실 생각해보면 단순한 거였는데 바보 같이 네이티브 쿼리를 짜지 않는 방법만 찾다 보니 멀리 돌아왔습니다.


@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @Query(value = "SELECT p" +
            " FROM Post p" +
            " JOIN Follow f" +
            " ON p.member.id = f.toMember.id" +
            " WHERE f.fromMember.id = :memberId AND p.id < :lastPostId")
    List<Post> findByJoinFollow(@Param("memberId") Long memberId, @Param("lastPostId") Long lastPostId, Pageable pageable);
}
  • JPQL 로 직접 짰는데 QueryDSL 을 적용하면 좀더 이쁘게 나올 것 같네요.

public List<Post> getFeeds(Long lastPostId) {
    PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id").descending());
    return postRepository.findByJoinFollow(getCurrentMember().getId(), lastPostId, pageRequest);
}
  • 쿼리는 다 짜놓았기 때문에 호출만 하면 됩니다.

SELECT *
FROM post
INNER JOIN follow ON post.member_id = follow.to_member_id
WHERE post.post_id < ? AND follow.from_member_id = ?
ORDER BY post.post_id DESC
LIMIT ?
  • 단 하나의 쿼리로 원하는 데이터를 가져옵니다 !

Conclusion

@OneToMany 조건이 걸려있는 컬렉션을 호출할 때는 성능상 문제가 없는 지 고민이 필요합니다.

만약 데이터가 너무 많다면 Limit 조건을 추가해서 DB 에서 가져오는 데이터 크기를 조절해야 합니다.

이런 경우 1 + N 문제의 일반적인 해결책인 Fetch Join 이나 Batch Size 를 사용할 수 없었고 결국 네이티브 쿼리로 해결했습니다.

네이티브 쿼리를 짜지 않고 Entity 내에서 해결하는 것이 JPA 스럽게 작성하는 거라고 생각했는데 잘못 생각하고 있었던 것 같습니다.

성능 최적화를 위해선 복잡한 쿼리도 작성할 수 밖에 없다는 점을 깨달았네요.

JPQL 로 짜고 보니 QueryDSL 의 필요성을 느끼게 되었고, 귀찮은 질문에도 친절하게 답변해주신 영한님의 강의를 구매했습니다.

1. Java Exception

Java 에는 Checked ExceptionUnchecked Exception 이 존재합니다.

이 둘은 헷갈리기 쉽지만 사실 큰 차이가 존재합니다.

Checked Unchecked
예외 처리 필수 필수 아님
트랜잭션 롤백 안됨 기본값으로 들어있어서 진행
검증 컴파일 단계 런타임 단계

1.1. Checked Exception

  • 예외 처리 필수
    • try catch 로 잡아서 예외를 처리하거나 상위 메소드로 넘겨줘야함
  • Transaction 기본 롤백 대상이 아니라서 롤백 처리하려면 추가 처리 필요
  • 컴파일 단계에서 체크

1.2. Unchecked Exception (RuntimeException)

  • 예외 처리 필수 아님
    • 예측할 수 없는 예외라서 필수 처리 불가능
  • Transaction 롤백 대상
    • @Transactional rollbackFor 에 기본 옵션으로 들어가있기 때문에 예외 발생 시 롤백 처리됨
  • 런타임 단계에서 체크



2. Spring Exception 의 HTTP Status

Spring 에는 HTTP Status 응답 처리를 위한 여러가지 방법이 있습니다.


2.1. @ResponseStatus

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Data Not Found")
public class DataNotFoundException extends RuntimeException { }

Spring 3 부터는 HTTP Status 와 Response 를 제공하는 @ResponseStatus 어노테이션이 생겼습니다.

개발자가 정의한 Exception 이 발생하면 해당 Status 와 Message 를 전달합니다.

테스트를 위해 Controller 에서 강제로 Exception 을 발생 시켜보겠습니다.


2.1.1. @ResponseStatus 없이 그냥 NotFoundException("No Data")

{
  "timestamp": "2021-03-13T07:23:00.732+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "No Data",
  "path": "/auth/signup"
}

2.1.2. @ResponseStatus 정의된 Custom Exception 사용

{
  "timestamp": "2021-03-13T07:30:38.299+00:00",
  "status": 404,
  "error": "Not Found",
  "message": "Data Not Found",
  "path": "/auth/signup"
}

2.1.3. 한계점

@ResponseStatus 에 정의한 대로 잘 나오는 걸 확인할 수 있습니다.

이 방법은 별다른 설정 없이 어노테이션 추가만으로 간단하게 Custom Exception 을 만들 수 있지만, 한 가지 단점이 있습니다.

위에서 예시로 데이터가 없는 경우 DataNotFoundException 를 리턴하게 했는데, 이 Exception 은 항상 동일한 HTTP Status 와 Message 를 리턴합니다.

같은 Exception 이 발생하는 상황이더라도 다른 Message 를 보내는게 불가능합니다.


2.2. ResponseStatusException

ResponseStatusException@ResponseStatus 의 대체제로 Spring 5 에 등장했습니다.

RuntimeException 을 상속하며 마찬가지로 HTTP Status 와 Message 를 설정할 수 있습니다.

public ResponseStatusException(
  HttpStatus status, 
  @Nullable String reason, 
  @Nullable Throwable cause
) {}
  • status: HTTP Status
  • reason: HTTP response Message
  • cause: ResponseStatusException 을 발생시킨 Exception

스프링에서는 HandlerExceptionResolver 가 모든 exception 을 가로채서 처리합니다.

이 중에서 ResponseStatusExceptionResolver 라는 클래스가 ResponseStatusException 또는 @ResponseStatus 어노테이션이 붙은 Exception 을 찾아서 처리해줍니다.


다음과 같은 장점이 있습니다.

  • 비슷한 유형의 예외를 별도로 처리할 수 있고, 응답마다 다른 상태 코드를 세팅 가능합니다.
  • 불필요한 Exception 클래스 생성을 피할 수 있습니다.
  • Exception 처리를 추가적인 어노테이션 없이 코드 단에서 자연스럽게 처리할 수 있습니다.



3. Spring 전역으로 공통 Exception 처리하기

2 번에서 Exception 에 따라 HTTP Status 제공하는 여러 가지 방법을 알아봤는데, 토이 프로젝트를 하면서 만들어보니 위 설정대로 쓸 일이 없었습니다.


throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No Data");
  • Spring 5 부터 제공하는 ResponseStatusException 의 일반적인 사용입니다.
  • 생성자로 HTTP Status 와 String 만을 받습니다.
  • 만약 없는 데이터의 종류가 다르다면 ?? No Member Data, No Profile Data 등등.. 이렇게 표현해야 합니다.
  • 하지만 이렇게 명확한 규격 없이 String 으로만 받으면 여러 사람들이 작업할 때 중복된 응답을 주거나 이미 있는 응답을 새로 만들거나, 오타로 인해 실수할 가능성도 있습니다.

위와 같은 이유로 Spring Exception 처리는 다른 방법으로 하게 되었습니다.

앞으로 사용할 클래스들입니다.

  • ErrorCode : 핵심. 모든 예외 케이스를 이곳에서 관리함
  • CustomException : 기본적으로 제공되는 Exception 외에 사용
  • ErrorResponse : 사용자에게 JSON 형식으로 보여주기 위해 에러 응답 형식 지정
  • GlobalExceptionHandler : Custom Exception Handler
    • @ControllerAdvice : 프로젝트 전역에서 발생하는 Exception 을 잡기 위한 클래스
    • @ExceptionHandler : 특정 Exception 을 지정해서 별도로 처리해줌

3.1. Error 관련 properties 설정

단순한 설정입니다.

작성하지 않아도 상관 없으며 필요한 경우에 참고해서 설정하시면 됩니다.

# application.yml
server:
  error:
    include-exception: false      # Response 에 Exception 을 표시할지
    include-message: always       # Response 에 Exception Message 를 표시할지 (never | always | on_param)
    include-stacktrace: on_param  # Response 에 Stack Trace 를 표시할지 (never | always | on_param) on_trace_params 은 deprecated
    whitelabel.enabled: true      # 에러 발생 시 Spring 기본 에러 페이지 노출 여부 

3.2. ErrorCode

@Getter
@AllArgsConstructor
public enum ErrorCode {

    /* 400 BAD_REQUEST : 잘못된 요청 */
    INVALID_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰이 유효하지 않습니다"),
    MISMATCH_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰의 유저 정보가 일치하지 않습니다"),
    CANNOT_FOLLOW_MYSELF(BAD_REQUEST, "자기 자신은 팔로우 할 수 없습니다"),

    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */
    INVALID_AUTH_TOKEN(UNAUTHORIZED, "권한 정보가 없는 토큰입니다"),
    UNAUTHORIZED_MEMBER(UNAUTHORIZED, "현재 내 계정 정보가 존재하지 않습니다"),

    /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */
    MEMBER_NOT_FOUND(NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다"),
    REFRESH_TOKEN_NOT_FOUND(NOT_FOUND, "로그아웃 된 사용자입니다"),
    NOT_FOLLOW(NOT_FOUND, "팔로우 중이지 않습니다"),

    /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */
    DUPLICATE_RESOURCE(CONFLICT, "데이터가 이미 존재합니다"),

    ;

    private final HttpStatus httpStatus;
    private final String detail;
}
  • 에러 형식을 Enum 클래스로 정의합니다.
  • 응답으로 내보낼 HttpStatus 와 에러 메세지로 사용할 String 을 갖고 있습니다.
  • ResponseStatusException 과 비슷해 보입니다. 하지만 가장 큰 차이점은 개발자가 정의한 새로운 Exception 을 모두 한 곳에서 관리하고 재사용 할 수 있다는 점입니다.

3.3. CustomException

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;
}
  • 전역으로 사용할 CustomException 입니다.
  • RuntimeException 을 상속받아서 Unchecked Exception 으로 활용합니다.
  • 생성자로 ErrorCode 를 받습니다.

3.4. ErrorResponse

@Getter
@Builder
public class ErrorResponse {
    private final LocalDateTime timestamp = LocalDateTime.now();
    private final int status;
    private final String error;
    private final String code;
    private final String message;

    public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorCode errorCode) {
        return ResponseEntity
                .status(errorCode.getHttpStatus())
                .body(ErrorResponse.builder()
                        .status(errorCode.getHttpStatus().value())
                        .error(errorCode.getHttpStatus().name())
                        .code(errorCode.name())
                        .message(errorCode.getDetail())
                        .build()
                );
    }
}
  • 실제로 유저에게 보낼 응답 Format 입니다.
  • 일부러 500 에러 났을 때랑 형식을 맞췄습니다. status, code 값은 사실 없어도 됩니다.
  • ErrorCode 를 받아서 ResponseEntity<ErrorResponse> 로 변환해줍니다.

3.5. @ControllerAdvice and @ExceptionHandler

@ControllerAdvice 는 프로젝트 전역에서 발생하는 모든 예외를 잡아줍니다.

@ExceptionHandler 는 발생한 특정 예외를 잡아서 하나의 메소드에서 공통 처리해줄 수 있게 해줍니다.

따라서 둘을 같이 사용하면 모든 예외를 잡은 후에 Exception 종류별로 메소드를 공통 처리할 수 있습니다.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class})
    protected ResponseEntity<ErrorResponse> handleDataException() {
        log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE);
        return ErrorResponse.toResponseEntity(DUPLICATE_RESOURCE);
    }

    @ExceptionHandler(value = { CustomException.class })
    protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
        log.error("handleCustomException throw CustomException : {}", e.getErrorCode());
        return ErrorResponse.toResponseEntity(e.getErrorCode());
    }
}
  • View 를 사용하지 않고 Rest API 로만 사용할 때 쓸 수 있는 @RestControllerAdvice 를 사용합니다.
  • handleDataException 메소드에서는 hibernate 관련 에러를 처리합니다.
  • handleCustomException 메소드는 직접 정의한 CustomException 을 사용합니다.
  • Exception 발생 시 넘겨받은 ErrorCode 를 사용해서 사용자에게 보여주는 에러 메세지를 정의합니다.

3.6. Exception 사용

@RequiredArgsConstructor
@Service
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public boolean follow(Long memberId) {
        Member currentMember = getCurrentMember();

        // 팔로우할 상대방 정보가 없는 경우
        Member targetMember = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(MEMBER_NOT_FOUND));

        // 자기 자신을 팔로우 하려는 경우
        if (currentMember.equals(targetMember))  {
            throw new CustomException(CANNOT_FOLLOW_MYSELF);
        }

                // code...
    }
}
  • 토이 프로젝트의 일부 코드입니다.
  • 상대방의 Member ID 를 입력 받아서 팔로우 하는 기능입니다.
  • Exception 에 담겨지는 ErrorCode 만 보고도 어떤 종류의 문제가 발생한 건지 알 수 있습니다.
  • 또한 불필요하게 여러 Exception 을 만들지 않고 ErrorCode 만 새로 추가하면 사용 가능합니다.

3.6.1. MEMBER_NOT_FOUND 실제 응답

{
  "timestamp": "2021-03-14T03:29:01.878659",
  "status": 404,
  "error": "NOT_FOUND",
  "code": "MEMBER_NOT_FOUND",
  "message": "해당 유저 정보를 찾을 수 없습니다"
}

3.6.2. CANNOT_FOLLOW_MYSELF 실제 응답

{
  "timestamp": "2021-03-14T03:16:25.98361",
  "status": 400,
  "error": "BAD_REQUEST",
  "code": "CANNOT_FOLLOW_MYSELF",
  "message": "자기 자신은 팔로우 할 수 없습니다"
}

3.7. 결론

Spring 에는 프로젝트 전역에서 발생하는 Exception 을 한 곳에서 처리할 수 있다.

Enum 클래스로 ErrorCode 를 정의하면 Exception 클래스를 매번 생성하지 않아도 된다.

실제 클라에게 날라가는 응답에서 code 부분만 확인하면 어떤 에러가 발생했는지 쉽게 파악 가능하다.


Reference

Introduction

이 글에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현했습니다.

JWT 와 Spring Security 코드는 인프런 Spring Boot JWT Tutorial (정은구) 강의를 수강하면서 만들고 제 스타일에 맞게 수정했습니다.

오로지 Security 와 인증 로직에 초점을 맞추기 위해 불필요한 코드는 제거했습니다.

구현하고자 하는 전체 로직은 다음과 같습니다.


(2022. 11. 22 추가)


1. JWT

JWT 에 관련된 글은 따로 작성했기 때문에 링크로 대체합니다.


2. Spring Security

Spring Security 는 사용자 정보 (ID/PW) 검증 및 유저 정보 관리 등을 쉽게 사용할 수 있도록 제공합니다.

JWT 와 같이 소개되는 경우가 많은데 스프링 시큐리티는 원래 세션 기반 인증을 사용하기 때문에 JWT 와 별개로 생각해야 합니다.

사용자 로그인 뿐만 아니라 보안 관련된 여러가지 설정들도 제공하기 때문에 실제 업무에서 사용한다면 꼭 개념을 미리 학습하고 사용하는 것을 권장합니다.

여기서는 겉핥기식으로 필요한 시큐리티 정보만 세팅하고 어떤식으로 동작하는 지만 파악합니다.

  • User Role 을 꼭 설정해야 하나요?
    • Spring Security 자체에서 내부적으로 사용하는 것 같음
    • ROLE_USER 처럼 정확히 형식을 지켜줘야 함

// build.gradle
plugins {
    id 'org.springframework.boot' version '2.7.5'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id 'java'
}

group = 'com.tutorial'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

        // security 관련 의존성
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

        // jwt 관련 의존성
    compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}

test {
    useJUnitPlatform()
}

3. Member 도메인 설계

시큐리티 설정을 테스트하기 위한 기본적인 사용자 도메인을 만듭니다.

시큐리티 자체적으로 UserDetails 의 구현체인 User 를 사용하기 때문에 헷갈리지 않도록 Account 또는 Member 로 이름 짓는게 좋습니다. (개인적인 생각)

  • Member 도메인
    • Member
    • MemberRepository
    • MemberService
    • MemberController
    • application.yml: h2 database 설정과 jwt secret key 설정

3.1. Member

@Getter
@NoArgsConstructor
@Table(name = "member")
@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String password;

    @Enumerated(EnumType.STRING)
    private Authority authority;

    @Builder
    public Member(String email, String password, Authority authority) {
        this.email = email;
        this.password = password;
        this.authority = authority;
    }
}
  • 최소한의 정보만을 갖고 있는 Member Entity 입니다.

public enum Authority {
    ROLE_USER, ROLE_ADMIN
}
  • 권한은 Enum 클래스로 만들었습니다.

3.2. MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
    boolean existsByEmail(String email);
}
  • 마찬가지로 최소한의 쿼리만 갖고있습니다.
  • Email 을 Login ID 로 갖고 있기 때문에 findByEmail 와 중복 가입 방지를 위한 existsByEmail 만 추가합니다.

3.3. MemberService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberResponseDto findMemberInfoById(Long memberId) {
        return memberRepository.findById(memberId)
                .map(MemberResponseDto::of)
                .orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다."));
    }

    public MemberResponseDto findMemberInfoByEmail(String email) {
        return memberRepository.findByEmail(email)
                .map(MemberResponseDto::of)
                .orElseThrow(() -> new RuntimeException("유저 정보가 없습니다."));
    }
}
  • memberIdemail 로 회원 정보를 가져오는 Service 입니다.

3.4. MemberController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {
    private final MemberService memberService;

    @GetMapping("/me")
    public ResponseEntity<MemberResponseDto> findMemberInfoById() {
        return ResponseEntity.ok(memberService.findMemberInfoById(SecurityUtil.getCurrentMemberId()));
    }

    @GetMapping("/{email}")
    public ResponseEntity<MemberResponseDto> findMemberInfoByEmail(@PathVariable String email) {
        return ResponseEntity.ok(memberService.findMemberInfoByEmail(email));
    }
}
  • 내 정보를 가져올 때는 SecurityUtil.getCurrentMemberId() 를 사용합니다.
  • API 요청이 들어오면 필터에서 Access Token 을 복호화 해서 유저 정보를 꺼내 SecurityContext 라는 곳에 저장합니다.
  • SecurityContext 에 저장된 유저 정보는 전역으로 어디서든 꺼낼 수 있습니다.
  • SecurityUtil 클래스에서는 유저 정보에서 Member ID 만 반환하는 메소드가 정의되어 있습니다.
  • MemberService 가 아닌 MemberController 에서 사용하는 이유는 MemberService 테스트가 SecurityContext 에 의존적이지 않게 하기 위해서입니다.

3.5. application.yml

spring:

  h2:
    console:
      enabled: true

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true

logging:
  level:
    com.tutorial: debug

# HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
# Secret 값은 특정 문자열을 Base64 로 인코딩한 값 사용 (아래 명령어를 터미널에 쳐보면 그대로 나옴)
# $ echo 'spring-boot-security-jwt-tutorial-jiwoon-spring-boot-security-jwt-tutorial' | base64
jwt:
  secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
  • H2 Database 를 사용하기 위한 기본 설정과 JWT 시크릿 키를 설정해둡니다.
  • 시크릿 키도 원래는 깃헙에 올라가지 않게 별도로 보관하는 것이 안전합니다.

4. JWT 와 Security 설정

  • JWT 관련
    • TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
    • JwtFilter: Spring Request 앞단에 붙일 Custom Filter
  • Spring Security 관련
    • JwtSecurityConfig: JWT Filter 를 추가
    • JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
    • JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
    • SecurityConfig: 스프링 시큐리티에 필요한 설정
    • SecurityUtil: SecurityContext 에서 전역으로 유저 정보를 제공하는 유틸 클래스

4.1. TokenProvider

@Slf4j
@Component
public class TokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어집니다.
  • 생성자
    • application.yml 에 정의해놓은 jwt.secret 값을 가져와서 JWT 를 만들 때 사용하는 암호화 키값을 생성합니다.
  • generateTokenDto
    • 유저 정보를 넘겨받아서 Access Token 과 Refresh Token 을 생성합니다.
    • 넘겨받은 유저 정보의 authentication.getName() 메소드가 username 을 가져옵니다.
    • 저는 username 으로 Member ID 를 저장했기 때문에 해당 값이 설정될 겁니다.
    • Access Token 에는 유저와 권한 정보를 담고 Refresh Token 에는 아무 정보도 담지 않습니다.
  • getAuthentication
    • JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼냅니다.
    • Access Token 에만 유저 정보를 담기 때문에 명시적으로 accessToken 을 파라미터로 받게 했습니다.
    • Refresh Token 에는 아무런 정보 없이 만료일자만 담았습니다.
    • UserDetails 객체를 생생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext 를 사용하기 위한 절차라고 생각하면 됩니다..
    • 사실 좀 불필요한 절차라고 생각되지만 SecurityContextAuthentication 객체를 저장하기 때문에 어쩔수 없습니다.
    • parseClaims 메소드는 만료된 토큰이어도 정보를 꺼내기 위해서 따로 분리했습니다.
  • validateToken
    • 토큰 정보를 검증합니다.
    • Jwts 모듈이 알아서 Exception 을 던져줍니다.

4.2. JwtFilter

@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 IOException, ServletException {

        // 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;
    }
}
  • OncePerRequestFilter 인터페이스를 구현하기 때문에 요청 받을 때 단 한번만 실행됩니다.
  • doFilterInternal
    • 실제 필터링 로직을 수행하는 곳입니다.
    • Request Header 에서 Access Token 을 꺼내고 여러가지 검사 후 유저 정보를 꺼내서 SecurityContext 에 저장합니다.
    • 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않습니다.
    • 그리고 요청이 정상적으로 Controller 까지 도착했다면 SecurityContext 에 Member ID 가 존재한다는 것이 보장됩니다.
    • 대신 직접 DB 를 조회한 것이 아니라 Access Token 에 있는 Member ID 를 꺼낸 거라서, 탈퇴로 인해 Member ID 가 DB 에 없는 경우 등 예외 상황은 Service 단에서 고려해야 합니다.

4.3. JwtSecurityConfig

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> 인터페이스를 구현하는 구현체입니다.
  • 여기서 직접 만든 JwtFilter 를 Security Filter 앞에 추가합니다.

4.4. JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • 유저 정보 없이 접근하면 SC_UNAUTHORIZED (401) 응답을 내려줍니다.

4.5. JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • 유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 SC_FORBIDDEN (403) 응답을 내려줍니다.

4.6. SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // h2 database 테스트가 원활하도록 관련 API 들은 전부 무시
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .antMatchers("/h2-console/**", "/favicon.ico");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            // CSRF 설정 Disable
        http.csrf().disable()

            // exception handling 할 때 우리가 만든 클래스를 추가
            .exceptionHandling()
            .authenticationEntryPoint(jwtAuthenticationEntryPoint)
            .accessDeniedHandler(jwtAccessDeniedHandler)

            .and()
            .headers()
            .frameOptions()
            .sameOrigin()

            // 시큐리티는 기본적으로 세션을 사용
            // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
            .and()
            .authorizeRequests()
            .antMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()   // 나머지 API 는 전부 인증 필요

            // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
            .and()
            .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }
}
  • Spring Security 의 가장 기본적인 설정이며 JWT 를 사용하지 않더라도 이 설정은 기본으로 들어갑니다.
  • 각 설정에 대한 설명은 주석을 확인하면 됩니다.
  • (2022. 11. 23 추가) Spring Security 의 WebSecurityConfigurerAdapter 가 deprecated 되어 이에 맞게 SecurityConfig 파일의 수정이 있었습니다.

4.7. SecurityUtil

@Slf4j
public class SecurityUtil {

    private SecurityUtil() { }

    // SecurityContext 에 유저 정보가 저장되는 시점
    // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
    public static Long getCurrentMemberId() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getName() == null) {
            throw  new RuntimeException("Security Context 에 인증 정보가 없습니다.");
        }

        return Long.parseLong(authentication.getName());
    }
}
  • JwtFilter 에서 SecurityContext 에 세팅한 유저 정보를 꺼냅니다.
  • 저는 무조건 memberId 를 저장하게 했으므로 꺼내서 Long 타입으로 파싱하여 반환합니다.
  • SecurityContextThreadLocal 에 사용자의 정보를 저장합니다.

5. Refresh Token 저장소

Access Token 과 Refresh Token 을 함께 사용하기 때문에 저장이 필요합니다.

보통은 Token 이 만료될 때 자동으로 삭제 처리 하기 위해 Redis 를 많이 사용하지만, 귀찮으니 일단 임시로 RDB 에 저장하는 방식으로 구현했습니다.

만약 지금 예제처럼 RDB 를 저장소로 사용한다면 배치 작업을 통해 만료된 토큰들을 삭제해주는 작업이 필요합니다.


5.1. RefreshToken

@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshToken {

    @Id
    @Column(name = "rt_key")
    private String key;

    @Column(name = "rt_value")
    private String value;

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}
  • key 에는 Member ID 값이 들어갑니다.
  • value 에는 Refresh Token String 이 들어갑니다.
  • 위에서 언급한대로 RDB 로 구현하게 된다면 생성/수정 시간 컬럼을 추가하여 배치 작업으로 만료된 토큰들을 삭제해주어야 합니다.

5.2. RefreshTokenRepository

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByKey(String key);
}
  • Member ID 값으로 토큰을 가져오기 위해 findByKey 만 추가했습니다.

6. 사용자 인증 과정

지금까지 스프링 시큐리티와 JWT 를 사용하기 위한 설정들을 전부 끝냈습니다.

지금부터는 실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후에 JWT 토큰을 발급하는 과정을 알아봅니다.

  • AuthController
  • AuthService
  • CustomUserDetailsService

6.1. AuthController

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
        return ResponseEntity.ok(authService.signup(memberRequestDto));
    }

    @PostMapping("/login")
    public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto memberRequestDto) {
        return ResponseEntity.ok(authService.login(memberRequestDto));
    }

    @PostMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
        return ResponseEntity.ok(authService.reissue(tokenRequestDto));
    }
}
  • 회원가입 / 로그인 / 재발급 을 처리하는 API 입니다.
  • SecurityConfig 에서 /auth/** 요청은 전부 허용했기 때문에 토큰 검증 로직을 타지 않습니다.
  • MemberRequestDto 에는 사용자가 로그인 시도한 ID / PW String 이 존재합니다.
  • TokenRequestDto 에는 재발급을 위한 AccessToken / RefreshToken String 이 존재합니다.

6.2. AuthService

@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
        if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다");
        }

        Member member = memberRequestDto.toMember(passwordEncoder);
        return MemberResponseDto.of(memberRepository.save(member));
    }

    @Transactional
    public TokenDto login(MemberRequestDto memberRequestDto) {
        // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken = memberRequestDto.toAuthentication();

        // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
        //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 4. RefreshToken 저장
        RefreshToken refreshToken = RefreshToken.builder()
                .key(authentication.getName())
                .value(tokenDto.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        // 5. 토큰 발급
        return tokenDto;
    }

    @Transactional
    public TokenDto reissue(TokenRequestDto tokenRequestDto) {
        // 1. Refresh Token 검증
        if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
        }

        // 2. Access Token 에서 Member ID 가져오기
        Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

        // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
        RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

        // 4. Refresh Token 일치하는지 검사
        if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
        }

        // 5. 새로운 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 6. 저장소 정보 업데이트
        RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
        refreshTokenRepository.save(newRefreshToken);

        // 토큰 발급
        return tokenDto;
    }
}

#회원가입 (signup)

  • 평범하게 유저 정보를 받아서 저장합니다.

#로그인 (login)

  • Authentication
    • 사용자가 입력한 Login ID, PW 로 인증 정보 객체 UsernamePasswordAuthenticationToken를 생성합니다.
    • 아직 인증이 완료된 객체가 아니며 AuthenticationManager 에서 authenticate 메소드의 파라미터로 넘겨서 검증 후에 Authentication 를 받습니다.
  • AuthenticationManager
    • 스프링 시큐리티에서 실제로 인증이 이루어지는 곳입니다.
    • authenticate 메소드 하나만 정의되어 있는 인터페이스며 위 코드에서는 Builder 에서 UserDetails 의 유저 정보가 서로 일치하는지 검사합니다.
    • 그런데 코드상으로는 전혀 구현된게 없는데 어떻게 된 걸까요?
    • 내부적으로 수행되는 검증 과정은 아래의 CustomUserDetailsService 클래스에서 다루겠습니다.
  • 인증이 완료된 authentication 에는 Member ID 가 들어있습니다.
  • 인증 객체를 바탕으로 Access Token + Refresh Token 을 생성합니다.
  • Refresh Token 은 저장하고, 생성된 토큰 정보를 클라이언트에게 전달합니다.

#재발급 (reissue)

  • Access Token + Refresh Token 을 Request Body 에 받아서 검증합니다.
  • Refresh Token 의 만료 여부를 먼저 검사합니다.
  • Access Token 을 복호화하여 유저 정보 (Member ID) 를 가져오고 저장소에 있는 Refresh Token 과 클라이언트가 전달한 Refresh Token 의 일치 여부를 검사합니다.
  • 만약 일치한다면 로그인 했을 때와 동일하게 새로운 토큰을 생성해서 클라이언트에게 전달합니다.
  • Refresh Token 은 재사용하지 못하게 저장소에서 값을 갱신해줍니다.

6.3. CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository.findByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    // DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());

        return new User(
                String.valueOf(member.getId()),
                member.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}
  • UserDetailsService 인터페이스를 구현한 클래스입니다.
  • loadUserByUsername 메소드를 오버라이드 하는데 여기서 넘겨받은 UserDetailsAuthentication 의 패스워드를 비교하고 검증하는 로직을 처리합니다.
  • 물론 DB 에서 username 을 기반으로 값을 가져오기 때문에 아이디 존재 여부도 자동으로 검증 됩니다.
  • loadUserByUsername 메소드를 어디서 호출하는지 내부를 타고 들어가봅니다.

6.3.1. CustomUserDetailsService

loadUserByUsername 는 여러 곳에서 호출하고 있는데 이 중에서 DaoAuthenticationProvider 내부를 확인해봅니다.


6.3.2. DaoAuthenticationProvider

  • username 을 받아서 넘겨주는 retrieveUser 메소드 내부에서 호출합니다.
  • 그럼 이 retrieveUser 는 어디서 호출할까요?

6.3.3. AbstractUserDetailsAuthenticationProvider

  • DaoAuthenticationProvider 의 부모 클래스인 AbstractUserDetailsAuthenticationProvider 에서 호출합니다.
  • 코드를 쭉 보니 받아온 user 변수로 additionalAuthenticationChecks 메소드를 호출합니다.
  • 메소드를 확인해보니 추상 클래스였고, DaoAuthenticationProvider 를 다시 확인해보니 오버라이드 해서 구현이 되어 있었습니다.

6.3.4. 다시 DaoAuthenticationProvider

  • 실제로 비밀번호 검증이 이루어지는 부분입니다 !
  • Request 로 받아서 만든 authentication 와 DB 에서 꺼낸 값인 userDetails 의 비밀번호를 비교합니다.
  • DB 에 있는 값은 암호화된 값이고 사용자가 입력한 값은 raw 값이지만 passwordEncoder 가 알아서 비교해줍니다.
  • 그래서 결국 비밀번호 검증이 시큐리티가 제공하는 클래스에서 이루어지는 것을 확인했는데 로그인 시에 사용되는 AuthenticationManager 와는 무슨 관계일까요?
  • AbstractUserDetailsAuthenticationProviderauthenticate 를 어디에서 호출하는지 확인해봅니다.

  • AbstractUserDetailsAuthenticationProviderauthenticate 는 단 한곳에서 호출합니다.

6.3.5. ProviderManager

  • 여기서도 authenticate 라는 메소드네요.
  • AuthenticationProvider 라는 인터페이스에서 호출하는데요.
  • 이름으로 짐작할 수 있듯이 AbstractUserDetailsAuthenticationProvider 의 상위 인터페이스입니다.
  • 그리고 ProviderManager.authenticate 를 호출하는 곳을 확인해보니 드디어 찾을 수 있었습니다.

6.3.6. AuthService

  • ProviderManagerAuthenticationManager 의 구현체입니다.
  • 지금까지의 탐구 과정을 역으로 다시 가보면 어떤 순서로 비밀번호 검증이 이루어지는 지 알 수 있습니다.

  1. AuthService (그림에서는 오타) 에서 AuthenticationManagerBuilder 주입 받음
  2. AuthenticationManagerBuilder 에서 AuthenticationManager 를 구현한 ProviderManager 생성
  3. ProviderManager 는 AbstractUserDetailsAuthenticationProvider 의 자식 클래스인 DaoAuthenticationProvider 를 주입받아서 호출
  4. DaoAuthenticationProvider 의 authenticate 에서는 retrieveUser 로 DB 에 있는 사용자 정보를 가져오고 additionalAuthenticationChecks 로 비밀번호 비교
  5. retrieveUser 내부에서 UserDetailsService 인터페이스를 직접 구현한 CustomUserDetailsService 클래스의 오버라이드 메소드인 loadUserByUsername 가 호출됨

7. API 호출 테스트

이제 서버를 띄우고 실제로 API 호출을 해봅니다.

API 요청은 인텔리제이에 있는 http Tool 을 사용했습니다.


7.1. 가입

# Request
POST http://localhost:8080/auth/signup
Content-Type: application/json

{
  "email": "test@test.net",
  "password": "1q2w3e4r"
}

# Response
{
  "email": "test@test.net"
}

7.2. 로그인

# Request
POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email": "test@test.net",
  "password": "1q2w3e4r"
}

# Response
{
  "grantType": "bearer",
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g",
  "accessTokenExpiresIn": 1615114280584
}

7.3. 일반 API 요청

# Request
GET http://localhost:8080/api/member/me
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ

# Response
{
  "email": "test@test.net"
}
  • 사용자 요청 -> JwtFiletr (SecurityContext 세팅) -> Controller -> Service

7.4. 재발급

# Request
POST http://localhost:8080/auth/reissue
Content-Type: application/json

{
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g"
}

# Response
{
  "grantType": "bearer",
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDM2NX0.5VXa6Cht_DPEEGe7-BrElvsrs7qRXmVnkDdi4Lm3PxZ0vAgqFdirhe5RlE1D-Wc1zaUepBmGhhw-u-oP_-rbKQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTczNjV9.tZytWyCWkWIYitvT3pa8FSnxilBDMtSevUzKRFK21TGLITf2eLXEwNNS_Q7rylD9uUe3Rx9ZR2NVqE_ZNWxTqg",
  "accessTokenExpiresIn": 1615114365284
}

Reference

Spring 으로 개발하다보면 유닛 테스트 작성은 필수입니다.

많은 사람들이 거쳐가는 프로젝트는 테스트 코드의 크기도 어마어마합니다.

보통 테스트 코드를 작성할 땐 함수명으로 어떤 테스트 인지 명시하는게 관례입니다.

하지만 복잡한 비즈니스 로직을 테스트 하는데 함수명에는 이 정보를 전부 담을 수가 없습니다.

주석을 추가해서 설명을 달아 놓아도 역시 깔끔하지 않습니다.

어떻게 하면 테스트 코드의 가독성을 높일 수 있을까요?


기존의 Test Code

public class DisplayNameTest {

    @Test
    public void testAsuccess() { /* */ }

    @Test
    public void testAfail() { /* */ }

    @Test
    public void test1success() { /* */ }

    @Test
    public void test1success() { /* */ }

    @Test
    public void test2success() { /* */ }

    @Test
    public void test2fail() { /* */}
}

위의 테스트 코드는 일반적으로 우리가 작성하는 JUnit 테스트 코드입니다.

함수명이 굉장히 짧고 코드 부분을 생략해서 간단해보이지만 실제 업무에서 사용되는 테스트 코드는 이렇게 간단하지 않습니다.


@Nested

코드를 보면 아시겠지만 관심사가 비슷한 메소드가 몇개 보입니다.

똑같은 기능인데 성공 / 실패 여부만 나눠져 있는 메소드죠

같은 기능이니까 한 메소드에 넣어서 성공 / 실패 여부 두가지를 테스트 하면 어떨까? 하는 생각도 들지만 테스트 코드 하나가 너무 비대해집니다.

그리고 여기선 간단하게 성공 / 실패 여부로만 표현했지만 실제 코드에서는 상황이나 조건에 따른 여러 종류의 Exception 을 던져야 할 수도 있습니다.


이런 경우에 @Nested 클래스로 비슷한 함수를 묶어주면 훨씬 알아보기 쉽습니다.

Junit 5 User Guide - Nested Test 를 보면 좋은 예제를 제공해줍니다.

처음 코드를 @Nested 클래스를 사용해서 수정해보겠습니다.

public class DisplayNameTest {

    @Nested
    class testA {

        @Test
        public void success() { /* */ }

        @Test
        public void fail() { /* */ }
    }

    @Nested
    class testNumber {

        @Nested
        class test1 {

            @Test
            public void success() { /* */ }

            @Test
            public void fail() { /* */ }
        }

        @Nested
        class test2 {

            @Test
            public void success() { /* */ }

            @Test
            public void fail() { /* */ }

        }
    }
}

전체적인 코드의 양은 늘어났지만 계층적인 구조가 되어 훨씬 알아보기 편해졌습니다.

게다가 클래스로 구분되어 있으니 successfail 을 중복으로 사용해도 전혀 문제가 없습니다.

실제로 테스트를 돌리면 장점이 더 명확하게 나타납니다.


테스트 결과에서도 비슷한 테스트끼리 묶고 결과를 좀더 심플하게 표현할 수 있습니다.


@DisplayName

@Nested 클래스로 계층을 나누어도 여전히 함수명은 알아보기 어렵습니다.

우리가 영어권이 아니라서 그런것 같습니다..그런데 실제로 영어권이더라도 camelCase 또는 snake_case 로 이루어진 영어가 한눈에 읽히는건 쉬운 일이 아닙니다.

JUnit 5 User Guide - Display Names 에 나와있는 예제를 보면 함수명이 길어지니 한눈에 들어오지 않지만 @DisplayName 어노테이션을 사용하면 간단하게 표현할 수 있습니다.

@DisplayName@Nested 클래스와 함께 쓰면 더 빛을 발합니다.

위의 코드를 한번 더 수정해보겠습니다.

public class DisplayNameTest {

    @Nested
    @DisplayName("A 테스트")
    class testA {

        @Test
        @DisplayName("성공")
        public void success() { /* */ }

        @Test
        @DisplayName("실패")
        public void fail() { /* */ }
    }

    @Nested
    @DisplayName("숫자")
    class testNumber {

        @Nested
        @DisplayName("1 테스트")
        class test1 {

            @Test
            @DisplayName("성공")
            public void success() { /* */ }

            @Test
            @DisplayName("실패")
            public void fail() { /* */ }
        }

        @Nested
        @DisplayName("2 테스트")
        class test2 {

            @Test
            @DisplayName("성공")
            public void success() { /* */ }

            @Test
            @DisplayName("실패")
            public void fail() { /* */ }

        }
    }
}

어노테이션 때문에 조금 지저분해 보이지만 실제 테스트를 돌리면 결과가 이쁘게 나옵니다.


여기서 한가지 의문이 생길 수 있습니다.

굳이 @DisplayName 어노테이션을 사용해서 코드량을 늘리는 것보다 메소드 명을 한글로 작성하는게 낫지 않을까?

실제로 현업에서도 테스트 코드 작성 시 메소드 이름을 한글로 작성하는 케이스가 많다는 이야기를 종종 들었습니다.

저도 처음엔 고민을 했었는데 다음과 같은 이유들로 @DisplayName 을 쓰기로 결정했습니다.

  • 한글로 작성해도 언더바를 작성해야 해서 가독성이 좋지 않음
  • @Nested 와 함께 쓰려면 클래스를 작성해야 하는데 한글명으로 만드는 것보단 @DisplayName 을 쓰는게 깔끔함
  • 드문 일이지만 외국인과 협업해야하는데 테스트코드명이 전부 한글로 되어 있으면 당황하겠죠? (이러면 DisplayName 도 한글로 못적을 것 같지만..)
  • 가장 주목해야 할 점은 JUnit 개발자들은 영어가 모국어 수준일텐데도 @DisplayName 어노테이션을 추가했다는 점

테스트 결과에서 한글이 제대로 나오지 않는다면?

  1. Preferences > Build, Execution, Deployment > Build Tools > Gradle 로 이동
  2. Run tests usingIntelliJ IDEA 로 변경
  3. Apply and OK 후 적용 안되면 인텔리제이 재시작

'Framework > Spring' 카테고리의 다른 글

Spring Exception Handling  (7) 2021.03.14
Spring Security 와 JWT 겉핥기  (79) 2021.03.07
[Spring] @Before @BeforeClass @BeforeEach @BeforeAll  (0) 2020.11.24
[Spring] Property 값 주입  (1) 2020.11.09
[Spring] IoC, DI  (1) 2020.11.09

@Before @BeforeClass @BeforeEach @BeforeAll

Spring 에서 테스트 코드를 작성할 때, 모든 테스트 코드 전에 반복적으로 해 주어야 하는 작업이 필요할 때가 있습니다.

예를 들어, 사용자 인증이 선행되어야 하는 테스트의 경우, 매 테스트 코드마다 인증하는 코드를 넣어야 합니다.

public class Test {
    @Test
    public void test1(){
        authenticateForTest();  // login
        System.out.println("test 1");
    }

    @Test
    public void test2(){
        authenticateForTest();  // login
        System.out.println("test 2");
    }

    private void authenticateForTest() {
        System.out.println("authenticate");
    }
}

JUnit 에서는 이런 반복적인 코드를 없애기 위해 @Before 어노테이션을 제공합니다.

이 어노테이션에도 여러 종류가 있는데 간단하게 요약하면 아래와 같습니다


@Before (JUnit 4), @BeforeEach (JUnit 5)

  • 클래스 내에 존재하는 각각의 @Test 를 실행하기 전에 매번 실행

@BeforeClass (JUnit 4), @BeforeAll (JUnit 5)

  • 모든 테스트를 실행하기 전 딱 한번만 실행
  • static 으로 선언해야 함

Example

public class Test {

    @BeforeAll
    public static void beforeAll() {
        System.out.println("@BeforeAll");
    }

    @BeforeEach
    public void beforeEach() {
        System.out.println("@BeforeEach");
    }

    @Test
    public void test1(){
        System.out.println("@Test 1");
    }

    @Test
    public void test2(){
        System.out.println("@Test 2");
    }

    @AfterEach
    public void afterEach() {
        System.out.println("@AfterEach");
    }

    @AfterAll
    public static void afterAll() {
        System.out.println("@AfterAll");
    }
}

전체 플로우

@BeforeAll

@BeforeEach
@Test 1
@AfterEach

@BeforeEach
@Test 2
@AfterEach

@AfterAll

+ Recent posts