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

Property 값 주입

Spring Boot 프로젝트가 커지면 공통으로 사용되는 글로벌 값을 별도로 관리할 필요가 생긴다.

@Value 어노테이션은 properties 파일에 세팅한 내용을 Spring 변수에 주입하는 역할을 한다.

@Value 어노테이션의 사용법에 대해 알아보도록 하자.


1. @Value("") 사용

greetingMessage 변수에 "Hello World" 를 주입해서 사용할 수 있다.

@RestController
public class ValueController {

    @Value("Hello World")
    private String greetingMessage;

    @GetMapping("")
    public String sendGreeting(){
        return greetingMessage;
    }
}

String 뿐만 아니라 다른 타입에도 사용할 수 있다.

@Value("1.234")
private double doubleValue; //could be Double

@Value("1234")
private Integer intValue; //could be int

@Value("true")
private boolean boolValue; //could be Boolean

@Value("2000")
private long longValue;

2. @Value("${...}") 사용

application.properties 에 정의한 내용을 가져와서 사용할 수 있다.

# application.properties
greeting.message=Hello World!
@RestController
public class ValueController {

    @Value("${greeting.message}") 
    private String greetingMessage;

    @GetMapping("")
    public String sendGreeting(){
        return greetingMessage;
    }
}

속성 값은 런타임에 변수로 주입되며 만약 속성값이 properties 파일에 없으면 아래와 같은 오류가 발생한다.

Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2020-02-29 21:54:43.953 ERROR 2996 --- [main] o.s.boot.SpringApplication: Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'valueController': Injection of autowired dependencies failed; nested exception is java.lang.IllegalArgumentException: Could not resolve placeholder 'greeting.message' in value "${greeting.message}"

오류를 방지하기 위해 @Value 어노테이션에는 기본값을 세팅할 수 있다.

@Value("${greeting.message:Greeting not found!}") 처럼 콜론을 붙이고 뒤에 기본값을 붙이면 된다.

만약 콜론 앞에 공백이 있으면 에러가 발생한다.

# application.properties

my.int.value=20
my.double.value=3.142
my.boolean.value=true
@Value("${my.int.value:0}")
private int intValue; // 런타임에 20 주입

@Value("${my.double.value: 0.0}")
private double doubleValue; // 런타임에 3.142 주입

// property 에 값이 있어도 공백 때문에 기본값이 들어감
@Value("${my.boolean.value :false}")
private boolean boolValue; // 공백 때문에 false 주입

// proprety 에 값이 없어서 기본값 사용
@Value("${my.long.value:300}")
private long longValue; // 런타임에 300 주입

3. @Value("${...}") 로 List 주입

# application.properties
my.weekdays=Mon,Tue,Wed,Thu,Fri
@Value("${my.weekdays}")
private List<String> strList; // injects [Mon, Tue, Wed, Thu, Fri]

//Providing default value
@Value("${my.weekends:Sat,Sun,Fri}")
private List<String> strList2; // injects [Sat, Sun, Fri]

4. @Value("#{${...}}") 로 Map 주입

# application.properties
database.values={url:'http://127.0.0.1:3306/', db:'mySql', username:'root', password:'root'}
@RestController
public class ValueController {

    @Value("#{${database.values: {url: 'http://127.0.0.1:3308/', db: 'mySql', username: 'root', password: ''}}}")
    private Map<String, String> dbValues;

    @GetMapping("")
    public Map getDBProps(){
        return dbValues;
    }
}

5. @Value("${...}") 생성자 파라미터에 주입

생성자에 파라미터로 넘기면서 값을 주입할 수도 있다.

# application.properties

company.name= Scopesuite Pty ltd
# company.location= Sydney
@Service
public class CompanyService {
   private String compName;
   private String location;

   public CompanyService(
    @Value("${company.name}") String compName,
    @Value("${company.location:Washington}") String location
   ) {
       this.compName = compName;
       this.location = location;
   }
}

Spring 이란?

간단히 정리하면 스프링은 "자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 프레임워크" 이며

코드의 가독성과 의존성 (클래스가 다른 클래스에 종속적임) 을 해결하기 위해 Application Context 라는 컨테이너를 제공한다.

이 컨테이너에서는 Bean 들을 관리하며 각 클래스에서 사용할 수 있도록 Bean 을 생성해주는 것을 DI (의존성 주입) 이라고 한다.

개발자가 아닌 컨테이너가 직접 Bean 을 생성, 관리하기 때문에 IoC (제어의 역전) 컨테이너라고도 한다.

스프링을 한 문장으로 표현하자면 자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크다.

스프링은 스프링 애플리케이션 컨텍스트 (Spring Application Context) 라는 컨테이너 (Container) 를 제공하는데, 이것은 애플리케이션 컴포넌트들을 생성하고 관리한다.

애플리케이션 컴포넌트 또는 빈 (Bean) 들이 컨테이너 내부에서 서로 연결되어 완전한 애플리케이션을 만든다.


의존성 주입 (Dependency Injection, DI)

빈의 상호 연결은 의존성 주입 이라고 알려진 패턴을 기반으로 수행된다.

컨테이너에서 모든 애플리케이션 컴포넌트를 생성, 관리하고 해당 컴포넌트를 필요로 하는 빈에 주입 (연결) 한다.

일반적으로 생성자 인자 또는 속성의 접근자 메서드를 통해 처리된다.

지금까지 스프링 버전에서는 XML 파일을 사용해서 빈을 상호 연결하도록 컨테이너에 전달했다.

그러나 최신 버전의 스프링에서는 @Configuration 애노테이션을 사용하여 각 빈을 컨테이너에 제공하는 클래스라는 걸 명시한다.

아래 두 코드는 똑같은 설정을 각각 XML 과 애노테이션을 사용한 예시이다.

<bean id="inventoryService" class="com.example.InventoryService" />
<bean id="productService" class="com.example.ProductService" />
  <constructor-arg ref="inventoryService" />
</bean>
@Configuration
public class ServiceConfiguration {

  @Bean
  public InventoryService inventoryService() {
    return new InventoryService();
  }

  @Bean
  public ProductService productService() {
    return new ProductService(inventoryService());
  }
}

애노테이션 (Annotation)

애노테이션은 클래스, 인터페이스, 함수, 매개변수, 속성, 생성자에 어떤 의미를 추가할 수 있는 기능이며, 자바 컴파일러가 컴파일 시에 처리한다. 소스 코드에 추가된 애노테이션 자체는 바이트 코드로 생성되지 않고 주석으로 처리되지만, 컴파일러가 작업을 수행해준다.


제어의 역전 (Inversion of Control, IoC)

간단히 말하면 객체에 대한 제어권이 개발자로부터 컨테이너로 넘어간 것

객체의 생성부터 생명주기 관리까지 전부 컨테이너가 맡아서 하기 때문에 제어를 컨테이너가 갖고 있다.

스프링에서 제공하는 컨테이너를 IoC 컨테이너라고 하기도 한다.

컨테이너가 직접 빈을 생성/관리하기 때문에 개발자는 코드에 new 등으로 선언하지 않아도 되며 이는 각 클래스들의 의존도를 줄여준다.


IoC 용어 정리

  • bean : 스프링에서 제어권을 가지고 직접 만들어 관계를 부여하는 오브젝트
    • 스프링을 사용하는 애플리케이션에서 만들어지는 모든 오브젝트가 빈은 아니다. 스프링의 빈은 스프링 컨테이너가 생성하고 관계설정, 사용을 제어해주는 오브젝트를 말한다.
  • bean factory : 스프링의 IoC를 담당하는 핵심 컨테이너
    • Bean을 등록/생성/조회/반환/관리 한다. 보통 bean factory를 바로 사용하지 않고 이를 확장한 application context를 이용한다. BeanFactory는 bean factory가 구현하는 interface이다. (getBean() 등의 메서드가 정의되어 있다)
  • application context : bean factory를 확장한 IoC 컨테이너
    • Bean의 등록/생성/조회/반환/관리 기능은 bean factory와 같지만, 추가적으로 spring의 각종 부가 서비스를 제공한다. ApplicationContext 는 application context 가 구현해야 하는 interface이며, BeanFactory를 상속한다.
  • configuration metadata : application context 혹은 bean factory가 IoC를 적용하기 위해 사용하는 메타정보
    • 스프링의 설정정보는 컨테이너에 어떤 기능을 세팅하거나 조정하는 경우에도 사용하지만 주로 bean을 생성/구성하는 용도로 사용한다.
  • container (ioC container) : IoC 방식으로 bean을 관리한다는 의미에서 bean factory나 application context를 가리킨다.
    • application context는 그 자체로 ApplicationContext 인터페이스를 구현한 오브젝트를 말하기도 하는데, 하나의 애플리케이션에 보통 여러개의 ApplicationContext 객체가 만들어진다. 이를 통칭해서 spring container라고 부를 수 있다.

자동 구성 (Auto Configuration)

자동 연결 (Autowiring) 과 컴포넌트 스캔 (Component Scanning) 이라는 스프링 기법을 기반으로 한다.

스프링은 컴포넌트 스캔을 사용하여 애플리케이션의 classpath 에 지정된 컴포넌트를 찾은 후 컨테이너의 빈으로 생성한다.

스프링 부트의 Auto Configuration 라이브러리는 다음 일들을 수행한다.

  • 스프링 MVC 를 활성화 하기 위해 컨테이너 (스프링 애플리케이션 컨텍스트) 에 관련된 Bean 들을 구성한다.
  • 내장된 Tomcat 서버를 컨테이너에 구성한다.
  • 스프링 MVC 뷰를 나타내기 위해 사용하는 템플릿 (JSP, Thymeleaf, Mustache 등) 의 뷰 리졸버 (View Resolver) 를 구성한다.

Spring JPA

요약

1. ORM (Object-Relational Mapping) 이란?
  - 객체 지향 프로그래밍과 관계형 데이터베이스 사이의 구조적 문제를 해결해주는 프레임워크
  - 개발자가 객체 지향 프로그래밍에 집중할 수 있게 해준다.

2. JPA (Java Persistence API) 란?
  - 자바 ORM 기술에 대한 API 표준 명세로, Java 에서 제공한다 (Spring 아님)
  - JPA 의 구현체로 Hibernate 가 존재한다.

3. Spring Data JPA 란?
  - Hibernate 와 같은 JPA 구현체를 좀 더 쉽게 사용할 수 있도록 Spring 에서 제공하는 모듈

SQL 중심 개발의 문제점

MyBatis 와 같은 SQL Mapper 는 데이터베이스의 쿼리를 직접 작성하기 때문에 객체지향 프로그래밍 보다는 데이터베이스 테이블 모델링에 집중하게 된다.

  • 반복적인 SQL 작업

    • RDB 를 사용하면서 개발자들은 객체 지향 관점보다는 SQL 중심으로 코드를 짜게 된다.
    • RDB 는 SQL 만 인식할 수 있기 때문에 반복되는 SQL 의 사용을 피할 수 없었고 수십, 수백 개의 테이블마다 각각 SQL 문을 작성해줘야 했다.
  • RDB 와 객체지향 프로그래밍의 목적은 다르다

    • RDB 는 데이터 저장에 초점이 맞추어져 있다.
    • 객체지향 프로그래밍은 기능과 속성을 한 곳에서 관리하는 기술이다.
    • 추상화, 캡슐화, 다형성 등 객체지향의 패러다임을 RDB 로는 표현할 수 없다.

JPA (Java Persistence API) 의 등장

RDB 를 사용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있게 하는 자바 표준 ORM (Object Relational Mapping) 기술이 생겼다.

JPA 는 Java 에서 제공하는 기술 명세 인터페이스이다.

JPA 는 RDB 와 객체지향 프로그래밍 두 개의 영역을 연결해주는 역할을 한다.

개발자는 객체 지향적으로 프로그래밍을 하고, JPA 는 RDB 에 맞게 SQL 을 대신 생성해서 실행해준다.


ORM 과 SQL Mapper 의 차이

  • ORM (Object-Relation Mapping)

    • 객체를 매핑하여 간접적으로 DB 를 다룸
    • 개발자는 SQL 쿼리 대신 메서드로 데이터를 조작하며 ORM 이 SQL 을 자동으로 생성해준다.
    • JPA
  • SQL Mapper

    • 쿼리를 매핑하여 SQL 문으로 직접 DB 를 조작한다.
    • MyBatis, jdbcTemplate

Spring Data JPA (Repository)

JPA 는 인터페이스이기 때문에 구현체가 필요하다.

대표적으로 Hibernate, Eclipse Link 등이 있다.

Spring 에서는 이 구현체들을 좀 더 쉽게 사용할 수 있도록 추상화시킨 Spring Data JPA 모듈을 사용한다.

개발자가 Repository 인터페이스에 정해진 규칙대로 메소드를 입력하면, Spring 이 알아서 해당 메소드 이름에 적합한 쿼리를 날리는 구현체를 만들어서 Bean으로 등록해준다.

Hibernate 와 Spring Data JPA 를 사용하는 데에는 사실 큰 차이가 없지만 Spring Data JPA 가 권장되는 이유는 크게 두 가지가 있다.

  • 구현체 교체가 쉽다

    • Hibernate 외에 다른 구현체로 쉽게 교체가 가능하다.
    • Spring Data JPA 내부에서 구현체 매핑을 지원해주기 때문에 언젠가 Hibernate 외의 다른 구현체로 넘어갈 일이 생겨도 쉽게 교체 가능하다.
  • 저장소 교체가 쉽다

    • RDB 외의 다른 DB 로 쉽게 교체 가능하다.
    • Spring Data 의 하위 프로젝트들은 기본적인 CRUD 인터페이스가 같기 때문에 의존성만 교체하면 쉽게 변경이 가능하다.
    • 예를 들어 MongoDB 로 교체가 필요하다면 Spring Data JPA 에서 Spring Data MongoDB 로 의존성만 교체하면 된다.

JPA 의 장단점

  • 장점
    • CRUD 쿼리를 직접 작성할 필요가 없음
    • 부모-자식 관계 표현, 1:N 관계 표현, 상태와 행위를 한 곳에서 관리 등 객체 지향 개발에 집중 가능
  • 단점
    • 높은 러닝커브 (객체지향 프로그래밍과 RDB 를 둘 다 이해해야 함)
    • 제대로 사용하지 못하면 성능 문제가 발생함

+ Recent posts