2024. 3. 21. 15:50ㆍJava
스프링 데이터 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
모던자바인액션 - 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트
'Java' 카테고리의 다른 글
Extends와 Implements - 삽질 일기 (1) | 2024.03.02 |
---|---|
자바의 예외 계층 - 체크 예외와 언체크 예외 (0) | 2024.02.26 |
Spring JDBC로 알아보는 예외 덩어리 처리 방법 (0) | 2024.02.22 |
자바의 람다와 스트림 - 1(TIL) (0) | 2024.01.20 |
static initializer block와 initializer block (0) | 2024.01.19 |