티스토리 뷰

문제상황

성능 개선에 관심이 생겨서 그동안 안보고 피했던 로그를 보던 중 성능을 저하시키는 쿼리가 나가고 있었다. 바로 로그인을 할 때 알람 엔티티가 조회되는 것이다. 유저와 알람은 1대1 양방향 관계고, 알람이 유저의 pk를 들고 있다. 

 

로그인시 이메일과 비밀번호 확인을 위한 유저 조회

 

 

유저조회 쿼리를 날린 후 알람 엔티티 조회한다. 

 

 

먼저 어느부분에서 알람 조회 쿼리가 날라가는지 알기 위해 어설프지만 로그를 찍어봤다. 

    @Override
    public TokenResponseDto login(UserLoginRequestDto requestDto) {
        log.info(">>>>>>>>>1");
        User user = getUserPort.getUserByEmail(requestDto.getEmail());
        log.info(">>>>>>>>>2");
        checkPassword(requestDto, user);
        log.info(">>>>>>>>>4");
        String accessToken = jwtTokenProvider.createAccessToken(user);
        log.info(">>>>>>>>>5");
        return userMapper.of(accessToken);
    }
    private void checkPassword(UserLoginRequestDto requestDto, User user) {
        log.info(">>>>>>>>>3");
        String requestPassword = requestDto.getPassword();
        String userPassword = user.getPassword();
        if(!passwordEncoder.matches(requestPassword, userPassword)) {
            throw new InvalidPasswordException();
        }
    }

 

다시 실행을 해보니 1번과 2번 사이에서 쿼리가 날라간다. 

 

 

 

즉, 요청으로 들어온 이메일로 유저를 조회할 때 알람이 같이 조회되는 것이다.

log.info(">>>>>>>>>1");
User user = getUserPort.getUserByEmail(requestDto.getEmail());
log.info(">>>>>>>>>2");

 

인자로 이메일을 넣고 유저를 들고오는 로직이다. 

Optional<User> findByEmailValue(String email);

 

 

비즈니스 로직쪽에는 문제가 없어 연관관계에 초점을 두었다. 먼저 확실하게 해결되는지 확인하기 위해, 연관관계를 단방향으로 바꾸고 해보았다. 역시 더 이상 알람 엔티티 조회 쿼리가 날라가지 않았다.

 

찾아보던 중 OneToOne 양방향을 맺을 땐 주의해야할 점이 있다고 한다. OneToOne 양방향 관계에서, 연관관계의 주인이 아닌 곳에서 호출한다면 지연로딩이 먹히지 않고 즉시로딩이 된다는 것이었다. 나 역시 연관관계의 주인은 알람인 상태에서 유저를 조회하고 있었다. 

 

User.java

    @OneToOne(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true)
    private Notification notification;

 

Notification.java

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id")
    private User user;

 

즉시로딩이 되는 이유는 다음과 같다.

 

 

하지만 지금 상태로는 정확하게 이해할 수 없었다. 모르는 것들이 너무 많았기 때문에 한 번 정리를 하고 넘어가보려고 한다.

 

지연로딩이란?

가장 많이 드는 예시인 Member와 Team 관계로 설명하겠다. 가장 많이 예시로 드는 만큼 직관적이다. 

먼저 현실적으로 생각해보자. A는 Team에 가입할 수 있다. B도 Team에 가입할 수 있다. 즉 Member와 Team의 관계는 다대일이다. 다대일을 코드로 본다면 다음과 같다.

 

Member.java

@ManyToOne
@JoinColumn(name = "team_id")
private Team team;

 

Team.java

@OneToMany(mappedBy = "team")
private final List<Member> members = new ArrayList<>();

 

그리고 Member와 Team을 저장하고 조회해보자. 

    @Test
    void test() {
        Team team = new Team("수영팀");
        em.persist(team);

        Member member = new Member("A");
        em.persist(member);
        member.setTeam(team);

        em.flush();
        em.clear();

        Member findMember = em.find(Member.class, member.getId());
        System.out.println("team class : " + findMember.getTeam().getClass());
    }

 

결과는 다음과 같다.

 

 

분명 Member만 조회했는데 Team까지 join되어 쿼리가 나갔다. 어떻게 보면 당연한 결과이다. Member와 Team은 연관되어있기 때문이다. 

 

수행시간은 약 467ms가 나왔다.

 

 

1초도 안되는 속도다. 하지만 만약 비즈니스 로직에서 팀 정보는 필요없고 멤버만 필요한 상황에서 멤버를 조회할 때마다 팀이 조인되는 것을 굳이 놔둘 이유가 있을까? 성능상 손해다. 이 때 지연 로딩 방식을 쓴다.

 

간단하게 @ManyToOne 속성에 fetch 타입을 지정할 수 있다. LAZY로 설정해보자. 

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

 

그리고 다시 테스트를 돌려보면, Team이 조인되지 않고 나오는 것을 볼 수 있다. 

 

 

어떻게 조인되지 않고 필요한 Member만 들고올 수 있는 걸까? 바로 그 유명한 프록시 객체를 사용한다. 우리는 Team객체에 지연로딩을 설정했었다. 

 

Member.java

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

 

그리고 Member을 조회하게 되면 JPA에서 지연 로딩이 설정된 Team객체를 상속받은 프록시 객체를 생성한다. 이 프록시 객체는 겉만 똑같을 뿐, 안의 값은 들어있지 않아 가짜 객체라고도 불린다. 

 

 

특이하게 Team의 필드값 대신 Team의 참조값을 가지고 있다. 이 참조값이 핵심이다. 이제 이 프록시 객체를 호출할 getTeam()에 진짜 객체 대신 넣는다.

 

 

여기까지의 내용을 정리하면 다음과 같다.

 

  • Member와 Team은 다대일 관계
  • Team에 지연로딩을 설정함
  • Member을 조회하려고 할 때, Team을 상속받은 가짜 클래스인 프록시 객체를 생성함
  • Member 안에 실제 Team객체 대신 프록시 객체를 넣는다.

실제로 getTeam().getClass()를 출력해보면 Hibernate에서 만든 Proxy라는 것을 알 수 있다. 

 

 

이렇게 프록시 기술을 사용하여 실제 객체처럼 행동하기 때문에 조인이 되지 않는 것이다. 

이제 Member가 가입한 Team의 이름을 들고오고 싶다. 그럼 보통 아래와 같은 코드로 짤 것이다. 

System.out.println(findMember.getTeam().getName());

 

대충보면 실제 Team객체의 메서드를 호출한다. 하지만 우리는 getTeam()의 반환값은 실제 객체가 아닌 프록시 객체라는 것을 알 것이다. 이제 여기서 참조값을 사용한다. 하지만 처음 지연로딩을 실행할 때는 당연히 참조값이 없다. 그래서 JPA가 영속성 컨텍스트에 초기화 요청을 한다. 그럼 영속성 컨텍스트는 데이터베이스를 조회해서 실제 객체를 찾은 후 참조 값을 채운다. 그 후 참조값이 채워진 프록시 객체에 접근해 실제 객체의 메서드에 접근한다. 이를 프록시 객체 초기화라고 한다. 더 복잡한 내용이 있지만, 이 포스팅에 나올만한 내용(심화 내용)은 아니기에 넘어가도록 하겠다. 

 

수행시간은 다음과 같다.

 

 

이게 무슨일일까? 분명 필요없는 조인을 하지 않음으로써 성능향상을 기대했지만 오히려 수행시간이 더 걸렸다. 이건 좀 의문이다. 하지만 지금까지의 배운 내용을 가지고 유추해보면, 일단 처음 생성된 프록시 객체의 참조값은 없다.

Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();
//findTeam의 참조값은 비어있는 상황

 

그래서 영속성 컨텍스트에 초기화를 요청하고, 영속성 컨텍스트는 DB에서 실제 객체를 찾아 참조값을 채운다. 이 과정에서 DB에 접근하는 I/O 오버헤드가 발생하기 때문에 더 느려진 것은 아닐까 생각한다. 그래서 제대로 된 수행시간을 재려면 먼저 한번 참조값을 채운 후 다시 조회할 때의 수행시간을 재야한다. 

 

여기까지가 지연로딩에 대한 개념이었고, 다시 문제로 돌아와서 문제를 정리해보자. 

 

  • 유저가 조회될 때 알람도 조회가 된다.
  • 유저와 알람은 일대일 양방향 관계이다.
  • 연관관계의 주인은 알람이다.

여기서 특이한 점은, 일대일 양방향 관계라는 점이다. 앞에서도 말했듯이 연관관계의 주인을 조회할 때는 지연로딩이 잘 먹히지만, 연관관계의 주인이 아닌 객체를 조회하려고 하면 지연로딩이 먹히지 않는다. 한번 테스트 해보자. 실제로 내가 겪은 관계를 똑같이 구현하였다. 유저와 알람은 일대일 양방향 관계이고, 알람이 연관관계의 주인이다. 

 

User.java

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
    private Notification notification;

    public User(String name) {
        this.name = name;
    }
}

 

Notification.java

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notification {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String token;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    public Notification(String token) {
        this.token = token;
    }
}

 

그리고 테스트코드를 짜보자. 

    @Test
    void test2() {
        User user = new User("남세원");
        em.persist(user);
        em.flush();
        em.clear();

        User findUser = em.find(User.class, user.getId());
    }

 

유저를 생성하고, 유저를 조회하는 로직밖에 없다. 하지만 실행해보면 뜬끔없이 쿼리가 나가는 것을 볼 수 있다. 

 

 

이게 OneToOne의 특별한 점인데, 연관관계가 아닌 객체(User)는 Notification이 있든 없든 알 수 있는 방법이 없다. 왜냐하면 Notification Column이 존재하지 않기 때문이다. 따라서 Notification의 존재 여부를 확인하기 위해 따로 쿼리가 나가는 것이라고 한다. 

 

대부분의 블로그에서는 아래처럼 말한다. 

 

 

여기서 엄청 헷갈렸던 점은 "지연 로딩으로 설정해도 즉시 로딩으로 동작하게 된다" 부분이었다. 나는 지연 로딩이 먹히지 않고 즉시 로딩이 되었다면 User를 조회하는 쿼리에서 Notification을 조인이 되었어야 한다고 생각한다. 솔직히 이 추측이 맞는 것인지는 잘 모르겠다. 

 

OneToOne은 최대한 피하는게 좋다고 한다. 하지만 나는 유저에서도 알람을 알 필요가 있고, 알람에서도 유저를 알 필요가 있다. 

 

유저를 통해서 알람 엔티티를 얻는다. 

    @Override
    public Notification getNotification(User user) {
        return notificationRepository.findByUser(user)
                .orElseThrow(UserDeviceTokenNotFoundException::new);
    }

 

댓글을 단 유저를 불러온 후, 알람을 허용한 유저들에게만 메세지를 보내야 할 때 hasNotificationToken으로 확인한다. 

List<String> tokens = comments.stream()
                .map(Comment::getUser)
                .distinct()
                .filter(User::hasNotificationToken)
                .map(getNotificationPort::getNotification)
                .map(Notification::getToken)
                .collect(Collectors.toList());

 

User에서 토큰이 있는지 없는지 확인하기 위해 알람 엔티티가 필요하다. 

    public boolean hasNotificationToken() {
        return this.notification != null;
    }

 

만약 OneToOne 양방향을 필연적으로 쓸 수 밖에 없는 상황이라면 아래 방법들이 있다고 한다.

 

  • fetch join
  • entity graph
  • batch fetch size

이번 포스팅에서는 위 방법을 쓰지 않고 연관관계의 주인만 변경하도록 하겠다. 

 

User.java

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "notification_id")
    private Notification notification;

 

Notification.java

    @OneToOne(mappedBy = "notification", fetch = FetchType.LAZY)
    private User user;

 

그리고 실행해보면 유저만 조회되는 것을 볼 수 있다. OneToOne 양방향에서 연관관계의 주인을 조회하면 지연로딩이 제대로 먹히기 때문이다. 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday