Java

JPA에서 조회 값을 Optional로 바꾸어보자 - 1편

Recfli 2024. 3. 21. 15:50

  스프링 데이터 JPA를 사용하면 자동으로 만들어주는 함수들 중에 Optional 타입으로 반환하는 것들이 있다. 강의에서도 Optional은 그냥 get쓰면 안된다고 말을 하는데, 왜인지 잘 모르겠고 Optional이 더 불편하게 느껴졌었다. Optional을 잘 몰라서 그런 것 같아, Optional을 계속 보고 기존 비 Optional을 Optional로 바꾸어보며 어떻게 변했는지 알아보는 과정을 가져보려고 한다.

[ Optional을 통한 null 체크 분기 줄이기와 orElse, orElseGet ]

 비 Optional을 사용해서 객체를 내보내는 함수이다. 기존의 코드는 리포지토리와 컨트롤러에 각각 다음과 같이 작성이 되어있었다. 로직이 복잡한 것은 아니라 문제성이 크게 드러나진 않는다. 하지만 분기문이 저렇게 늘어나는 건 이후에 더 깊은 Member내의 값이 깊어지면 가독성이 떨어지고 알아보기 어려워진다. 따라서 Optional로 findByUserEmail 메서드를 변경했다.

// 리포지토리 코드
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m join fetch m.authorities where m.userEmail = :userEmail")
    Optional<Member> findByUserEmailWithAuthorities(@Param("userEmail") String userEmail);

    Member findByUserEmail(String userEmail);
}

// 컨트롤러 코드
    @RequestMapping("/user")
    public Member getUserDetailsAfterLogin(Authentication authentication) {
        Member findMember = memberRepository.findByUserEmail(authentication.getName());
        if (findMember != null) {
            return findMember;
        } else {
            return null;
        }
    }

 

 Optional로 바꾼 결과는 아래와 같다. 따로 확인을 위해 컨트롤러 대신 서비스로 만들었다. 또한 이 과정에서 orElse와 orElseGet의 차이를 잘 모르겠어서 이 글에서 알아보니 무슨 차이인지 이해가 돼서 아래처럼 코드를 작성했다. 이에 대한 설명은 아래에서 하도록 하겠다.

// 리포지토리, findByUseEmail에 Optional 추가
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m join fetch m.authorities where m.userEmail = :userEmail")
    Optional<Member> findByUserEmailWithAuthorities(@Param("userEmail") String userEmail);

    Optional<Member> findByUserEmail(String userEmail);
}

// 서비스, orElse와 orElseGet으로 가져올 때 차이 확인용
    public Member orElseMember(String userEmail){
        Optional<Member> findMember = memberRepository.findByUserEmail(userEmail);
        return findMember.orElse(new Member("Unknown"));
    }

    public Member orElseGetMember(String userEmail){
        Optional<Member> findMember = memberRepository.findByUserEmail(userEmail);
        return findMember.orElseGet(()-> new Member("Unknown"));
    }
    
 // Member 엔티티, 내부에 이름만 파라미터로 있는 생성자를 만들었다.
 // 함수 실행 여부를 확인하기 위해 출력문을 넣어주었다.
     public Member(String userEmail){
        System.out.println("this method is operated");
        this.userEmail = userEmail;
    }

 

  처음에 Optional에 대해서 공부한 데로 orElse를 통해 분기문을 줄였다. 이 부분은 크게 어렵지 않다. 아래를 보면 어떤 차이가 있는지 확실하게 보일 것이다. 불필요한 분기문을 줄여주고 값이 없을 때, 무슨 값을 줘야 하는지 알 수 있어서 가독성도 올라갔다.

// 변경 전, 분기문이 생기고 결과가 여러 곳에 있어 확인이 어려움.
	@RequestMapping("/user")
    public Member getUserDetailsAfterLogin(Authentication authentication) {
        Member findMember = memberRepository.findByUserEmail(authentication.getName());
        if (findMember != null) {
            return findMember;
        } else {
            return null;
        }
    }
    
 // 변경 후, 분기문을 없애고 값이 없을 때 나올 결과를 orElseGet 내부로 확인이 가능.
     public Member orElseGetMember(String userEmail){
        Optional<Member> findMember = memberRepository.findByUserEmail(userEmail);
        return findMember.orElseGet(()-> new Member("Unknown"));
    }

 

 그런데, orElse랑 orElseGet이랑 같이 Optional에 있는데 무슨 차이인지 이해가 잘 안갔다. 처음에 궁금해서 찾아본 글을 봤을 때 잘 이해가 안됐었는데, 직접 해보니 이해가 갔다. 우선, Optional 내부 코드를 보면 orElse와 orElseGet은 다음과 같이 구현이 되어있다.

    public T orElse(T other) {
        return value != null ? value : other;
    }

    public T orElseGet(Supplier<? extends T> supplier) {
        return value != null ? value : supplier.get();
    }

 

 만약에 orElse에 String 혹은 Integer 같은 데이터 타입이 들어간 것이라면 작성자의 의도에 맞게 사용한 것이다. 그 객체를 비교해서 null이면 지정한 other값을, 아니면 value 값을 내보내기 때문이다. 하지만, orElse에는특정 객체 타입을 리턴으로 하는 메서드도 들어갈 수 있다. 즉, 생성자의 메서드의 반환 값이 Member이기 때문에 아래의 findMember.orElse(new Member("Unknown"));이 들어갈 수 있던 것이다.

// orElse에도 메서드가 들어갈 수 있다! 반환타입이 사용가능한 객체 타입 메서드라면 말이다.
public Member orElseMember(String userEmail){
    Optional<Member> findMember = memberRepository.findByUserEmail(userEmail);
    return findMember.orElse(new Member("Unknown"));
}

public Member(String userEmail){
    System.out.println("this method is operated");
    this.userEmail = userEmail;
}

 

 그런데 구현 방식에서 이 값을 other로 값을 미리 가져올 수 밖에 없게 구현해놓았다. 그러면 T other를 위해 new Member는 내부의 값이 있건 없건 상관없이 반드시 실행이 되게 되어있다. 따라서 다음과 같이 테스트를 만들어서 확인해보면 orElse에서는 회원이 있건 없건 상관없이 new Member("Unknown")이 실행되는 걸 확인할 수 있고 orElseGet에서는 값이 없는 경우에서만 Supplier에서 get함수로 new Member("Unknown")이 실행되기 때문에 없는 회원에서만 생성자 실행을 확인할 수 있다.

@Test
@DisplayName("orElse 확인하기")
void orElseTest(){
    // 있는 회원
    Member member = loginService.orElseMember("test@example.com");
    System.out.println("member = " + member);

    // 없는 회원
    Member member2 = loginService.orElseMember("test2@example.com");
    System.out.println("member = " + member2);
}

@Test
@DisplayName("orElseGet 확인하기")
void orElseGetTest(){
    // 있는 회원
    Member member = loginService.orElseGetMember("test@example.com");
    System.out.println("member = " + member);

    // 없는 회원
    Member member2 = loginService.orElseGetMember("test2@example.com");
    System.out.println("member = " + member2);
}

 

 결론을 내보면 JPA에서 Optional 객체를 반환 받는 경우에 직접적으로 객체를 리턴값으로 사용한다면 orElse 또는 orElseGet을 사용해서 기본 값을 설정해주어야 한다. orElse는 String이나 Integer 같이 메서드가 아닌 객체를 넘길 때 사용하고 orElseGet은 새로 지정한 Member 같은 객체를 생성자로 넘겨줄 때 사용하면 된다가 정리이다.

 

 그런데, 어차피 Unknown이 값이면 private으로 스태틱 영역에다가 올리고 getUnkown으로 가져오는 방법을 고려해보면 매번 Unknown을 생성하는 메모리도 줄이고 좋지 않을까라는 생각이 들 수도 있지 않을까 생각해봤다. 아래의 코드처럼 말이다. 

// Member 클래스 내 코드 변경 사항
private static final Member unknownMember = new Member("Unknown");

public static Member getUnknown(){
    return unknownMember;
}

// orElseMember 코드 변경
public Member orElseMember(String userEmail){
    Optional<Member> findMember = memberRepository.findByUserEmail(userEmail);
    return findMember.orElse(Member.getUnknown());
}

 

 그리고 테스트 코드를 다음과 같이 짜봤다. 이 때 생기는 문제는 이 객체가 멀티 쓰레드 상황에서 돌아가는 것으로부터 비롯될 것 같다. 이 객체가 컨트롤러에서 완전히 최종적으로 리턴되는 값임이 보장되는 것이라면 모르겠다. 하지만 생으로 Member 객체를 컨트롤러로 내보낼 일도 없고 가장 큰 문제는 내부에 필드를 변경할 수 있다고 생각해보자. 이 결과 값이 최종 반환 값이 아니라면 어딘 가에선 이게 private final 객체인지 아니면 DB에서 찾아와서 가져온 객체인지 구분을 못하는 상황이 벌어질 수 있다. 이 때 이 설계를 모르는 누군가 값을 이렇게 변경해버리면? 앞으로 모든 쓰레드는 이름만 Unknown인 객체가 아니라 이름이 Unknown에 12345라는 패스워드를 가진 객체를 공유한다. 따라서 결론은 Member 같은 경우엔 그냥 orElseGet으로 새로 생성해서 쓰자는 게 내 결론이다. 

@Test
@DisplayName("Unknown 확인하기")
void unknownTest(){
    // 없는 회원
    Member member = loginService.orElseMember("test2@example.com");
    System.out.println("Before: member.getPassword() = " + member.getPassword());
    member.setEncodedPassword("12345");
    System.out.println("After: member.getPassword() = " + member.getPassword());
}

[ Optional과 isPresent, ifPresent ]

 isPresent를 쓰면 null을 덜 써도 되나 싶어 한 번 억지로 코드를 만들어보았다. 한참 생각해봤는데, 개인적으로 변경 전과 비교했을 때 비교 후에 가독성은 아무런 차이가 없고 오히려 Optional을 불필요하게 사용했다는 느낌이 들었다. 그러니까 굳이? 그리고 사실 많은 내용이 생략되었지만 이 코드에서 findMember가 null인지 확인할 필요가 없는게 이미 필터에서 확인을 하고 요청 자체를 BadCredentialException로 처리하게 해놓았기에 사실은 Optional이 필요없는 부분이다.

 

 여기서 얻은 교훈은 Optional은 JPA에서 사용할 때 명확성을 드러낼 때만 사용하는 게 좋을 것 같다는 생각이 들었다. 괜히 Optoinal 내부 메서드 가지고서 너무 뭘하려는 생각을 안하는 게 좋은 것 같다고 생각한다. 

 

변경 전 코드:

@PostMapping("/register")
public ResponseEntity<Book> registerBook(@RequestBody BookRegisterDto registerDto){

    String userEmail = SecurityContextHolder.getContext().getAuthentication().getName();
    Member findMember = memberRepository.findByUserEmail(userEmail);
    if(findMember == null){
        return (ResponseEntity<Book>) ResponseEntity.badRequest();
    } else{
        Book book = registerDto.makeBookWithMember(findMember);
        bookRepository.save(book);
        return ResponseEntity.ok(book);
    }
}

 

변경 후 코드:

@PostMapping("/register")
public ResponseEntity<Book> registerBook(@RequestBody BookRegisterDto registerDto){

    String userEmail = SecurityContextHolder.getContext().getAuthentication().getName();
    Member findMember = memberRepository.findByUserEmail(userEmail);

    if(findMember.isPresent()){
        Book book = registerDto.makeBookWithMember(findMember);
        bookRepository.save(book);
        return ResponseEntity.ok(book);
    } else{
        return (ResponseEntity<Book>) ResponseEntity.badRequest();
    }
}

 

 내가 내린 생각은 다음과 같다. 만약에 토큰에서 유저 정보를 가지고서 인증된 정보로 Member를 DB에서 찾는다면 컨트롤러까지 그 요청이 넘어온 이상 100퍼센트 그 유저는 존재하는 것이다. 그러니 이런 경우에는 Member 객체를 내보낸다.이 경우가 위의 코드에 적힌 상황이다. 그래서 사실 저기서 굳이 isPresent하면서 찾았을 때 null인 상황을 처리해줄 필요가 없다.

 

 하지만 만약에 티스토리처럼 개인 블로그를 주고 URL 형식이 tistory.com/{userEmail} 이런 방식으로 되어있다고 하자. 그러면 인터넷 브라우저에 앞의 인증정보와 다르게 userEmail은 검증되지 않은 정보이다. 없는 회원을 가져다가 놓은 것일 수도 있다. 그런 경우는 404 NOT_FOUND 에러를 주기 위해 아래처럼 코드를 짜놓으면 후에 누가 이 코드를 바꾸려고 했을 때 여기서 찾은 건 없을 수도 있겠구나 생각할 수 있을 것 같다.

// 컨트롤러에서 명확성을 위해 Optional 추가 
@GetMapping("/{userEmail}")
public ResponseEntity<List<Book>> getBooks(@PathVariable("userEmail") String userEmail){
    Optional<Member> findMember = memberRepository.findOptionalByUserEmail(userEmail);

    if(findMember.isEmpty()){
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
    }

    List<Book> books = bookRepository.findByIdWithMember(findMember.get().getId());
    return ResponseEntity.ok(books);
}

// findMember를 같은 UserEmail로 찾아오더라도 상황에 따라 나눴음
Member findByUserEmail(String userEmail);
Optional<Member> findOptionalByUserEmail(String userEmail);

 

 그리고 ifPresent를 사용해보면 어떻게 줄지 않을까하고 시도를 해봤었다. 그런데 아래와 같은 코드가 나와버렸는데 문제는 저장된 Book 개체를 가져올 수도 없고 ifPresent 내부에서 리턴이 void 타입으로 설정되어있기 때문에 return은 되지만 그 이 외의 값은 반환이 불가능했다.

 

Optional 내부의 ifPresent 메서드

public void ifPresent(Consumer<? super T> action) {
    if (value != null) {
        action.accept(value);
    }
}

 

 그래서 아래처럼 반환 값을 작성받아야하는 뭔가가 있을 때도 ifPresent 내부에 작성해서 사용하진 않을 것 같다. 아래는 완전히 잘못된 코드이다. 

@PostMapping("/register")
public String registerBook(@RequestBody BookRegisterDto registerDto){

    String userEmail = SecurityContextHolder.getContext().getAuthentication().getName();
    Optional<Member> findMember = memberRepository.findOptionalByUserEmail(userEmail);

    findMember.ifPresent(member -> {
        Book book = registerDto.makeBookWithMember(member);
        bookRepository.save(book);
        // return "your book is registered"; 사용 불가능
    });

    return findMember.isPresent() ? "your book is registered" : "register is fail";
}

 

 모던자바인액션 같은 코드를 봐도 ifPresent는 위처럼 아무 조건 없이 있는지 확인용이 아니고 필터와 함께 사용했을 때 필터 조건을 통과한 객체가 있는지와 그 객체를 써서 뭔가 정보가 남지 않는 그런 일을 할 때 사용하는 것 같다. 그나마 사용한다면 특정 이름일 때나 특정 내용을 작성하면 로그를 남기는 정도로 사용할 것 같다.

@PostMapping("/register")
public String registerBook(@RequestBody BookRegisterDto registerDto){

    String userEmail = SecurityContextHolder.getContext().getAuthentication().getName();
    Optional<Member> findMember = memberRepository.findOptionalByUserEmail(userEmail);

    findMember.filter(member -> "test@example.com".equals(member.getUsername()))
            .ifPresent(x -> System.out.println("x = " + x));

    return findMember.isPresent() ? "your book is registered" : "register is fail";
}

 

 마지막으로 Optional 리스트로 만드는 걸 생각을 해봤다. 이건 아무리 생각해도 그냥 메모리 낭비이다. 어차피 Collections 자체로도 비어있는지 확인할 수 있고 List 자체로도 빈 리스트일 때는 null이 아니며 사이즈를 체크하면 확인할 수 있다.  그러니 Optional을 리스트로 사용하는 건 없다고 생각하려고 한다.

[ 참고 자료 ]

https://mangkyu.tistory.com/70

모던자바인액션 - 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트

https://escapefromcoding.tistory.com/247