다음과 같은 테이블 구조에서 쿼리를 작성하게 되었다.

필요한 쿼리는 memberId로 pomodoro_prgoress들을 찾아서 pomodoro_room을 조회하는 것.

1. 쿼리를 두 번 나눠서 실행하기

List<PomodoroProgress> pomodoroProgresses = em.createQuery("select pp from PomodoroProgress pp where pp.member.id = :memberId", PomodoroProgress.class)
        .setParameter("memberId", memberId)
        .getResultList();

 

먼저 memberId가 같은 pomodoroProgress를 찾는다.

List<PomodoroRoom> pomodoroRooms = pomodoroProgresses.stream()
        .map(PomodoroProgress::getPomodoroRoom)
        .distinct()
        .toList();

그 다음 메모리에서 스트림을 돌면서 getPomodoroRoom을 통해 객체 그래프 탐색으로 PomodoroRoom들을 찾는다. PomodoroProgress와 PomodoroRoom은 다대일 관계이기 때문에 distinct를 해주었다. 프록시 객체를 생성할 때 id가 같으면 동일한 객체를 반환하는 것 같다. distinct를 제거하고 중복을 만들어서 출력을 해보니 해시 코드와, 동일한 프록시 객체임을 확인했다.

주의해야 할 점은 getPomodoroRoom까지만 수행했을 때는 PomodoroRoom이 프록시 상태이지만 distinct 연산을 수행하게 되면 쿼리를 수행해 실제 객체를 가져온다는 점을 인지해야 한다.

이러한 방식은 메모리에서 스트림을 돌면서 PomodoroRoom을 찾기 때문에 데이터가 많아지면 메모리 문제나 속도 문제가 발생할 수 있다.

 

2. 서브 쿼리

return em.createQuery("select pr from PomodoroRoom pr where pr in (select pp.pomodoroRoom from PomodoroProgress pp where pp.member.id = :memberId)", PomodoroRoom.class)
        .setParameter("memberId", memberId)
        .getResultList();

 

1번에서 수행한 쿼리보다 조금 더 나은 선택지. 한 번에 쿼리를 수행하며 메모리에서 연산을 하지도 않는다.

작성하기 쉽고 직관적이기도 하다.

하지만 서브 쿼리는 성능 최적화가 잘 안되고 느리다는 단점을 갖고 있다.

직접 확인해보기 위해 memberId가 1이고 pomodoroRoom1을 참조하는 PomodoroProgress를 1만개 저장하고 조회해봤더니 위와 같이 크게 느리지는 않은 것을 확인했다.

 

3. 조인

가장 빠른 방법.

return em.createQuery("select pr from PomodoroRoom pr join pr.pomodoroProgresses pp where pp.member.id = :memberId", PomodoroRoom.class)
        .setParameter("memberId", memberId)
        .getResultList();

 

쿼리가 한 번에 수행된다.

그런데 속도를 측정해보니 조인 쿼리가 더 느리다...?

서브쿼리를 사용하는 jpql쿼리를 수행했을 때 발생하는 sql쿼리를 복사해서 쿼리 실행계획을 확인해봤다.

풀테이블 스캔을 실행하며 인덱스를 타지 않는다. 그런데 select_type을 보면 jpql 쿼리를 서브 쿼리로 작성했음에도 subquery가 아닌 simple로 조회가 되는것을 확인했다. 다시 복사해온 쿼리를 확인해 보니 sql쿼리는 서브 쿼리로 수행되지 않고 있다.

조인쿼리의 쿼리 실행계획을 확인해봤다.

pomodoro_room인 p1은 풀테이블 스캔을 실행하며 인덱스를 타지 않고, pomodoro_progress인 p2는 기본키로 인덱스를 탄다.

두 쿼리 모두 simple 타입으로 수행되고 있고 인덱스 등 최적화도 똑같이 이루어지고 있지 않기 때문에 하나의 테이블을 조회하는 서브 쿼리 쪽이 더 속도가 빨랐다는 결론을 내릴 수 있다.

결론

원하는 데이터를 조회하기 위한 여러 방법이 쿼리를 작성해 보았고 장단점들을 비교해보았다.

단순히 궁금증에서 시작했던 시간 측정에서 jpql을 서브 쿼리가 더 빨리 수행된다는 예상과는 다른 결과를 확인했고, 이 원인을 분석하기 위해 실제 수행되는 sql 문으로 쿼리 실행 계획을 확인해보았다. 서브쿼리로 작성한 jpql의 실제로 수행되는 sql문은 서브쿼리가 아니였고 이에 따라 더 빠른 속도를 내는 것을 확인했다. 쿼리의 속도를 예상만 하지 말고 속도를 측정하고 쿼리 실행 계획을 확인하자.

study를 조회할 때 @PathVariable로 studyId로 조회하는 것과 @RequestParam으로 participantCode로 조회하는 것을 묶어보았다.

    @GetMapping("/api/studies/{studyId}")
    public ResponseEntity<PomodoroRoomResponse> findStudy(
            @PathVariable(required = false) Long studyId,
            @RequestParam(required = false) String participantCode
    ) {
        PomodoroRoomResponse pomodoroRoom = pomodoroRoomService.findPomodoroRoomWithFilter(studyId,
                participantCode);
        return ResponseEntity.ok(pomodoroRoom);
    }

 

 

 

 

 

@PathVariable과 @RequestParam 둘 다 require가 default ture이고 required = false로 설정하면 다음과 같이 두 요청 모두 핸들링이 가능하다고 생각했다.

- /api/stuides/{studyId}

- /api/studies?participantCode

하지만 @PathVVariable의 required를 false를 하더라도 다음과 같이 예상한대로 작동하지는 않는다.

@PathVariable에 required를 false를 하더라도 /api/stuides/{studyId} 형태가 아니면 핸들러 맵핑이 안되는 것.

이러한 경우는 핸들러를 분리하여 따로 작성해야 한다.

@OneToMany 관계에서의 N + 1 문제 분석하기

@OneToMany 관계에서 N + 1 문제가 발생할 때 실제로 몇 번의 쿼리가 더 수행되는지 질문을 받았다. 지연 로딩이든 즉시 로딩이든 One 쪽의 개수만큼 쿼리가 추가로 발생한다고 대답했으나 다른 경우는 없는지 확신을 갖고 대답하지 못했다. 따라서 직접 SQL 쿼리문이 어떻게 나가는지 확인하고 몇 개의 쿼리가 추가로 수행되는지 확인해보고자 한다.

다음은 One에 해당하는 Team 엔티티이다. (생성자나, getter, setter등은 생략했다.)

연관관계의 주인(외래키를 관리하는)은 Many쪽이므로 mappedBy로 양방향 맵핑을 해주었다.

@Entity
public class Team {

    @Id
    @GeneratedValue
    private Long id;
    
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

    ...
}

 

다음은 Many에 해당하는 Member 엔티티이다. 연관관계의 주인이기 때문에 @JoinColumn으로 team_id를 외래키로 명시했다.

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    
    private String username;

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

 

이제 테스트 코드를 통해 쿼리문이 어떻게 수행되는지 직접 확인해보자.

@DataJpaTest
class TeamRepositoryTest {
    
    @Autowired
    private TeamRepository teamRepository;
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private TestEntityManager em;
    
    @Test
    void oneToMany() {
        Team team1 = new Team("team1");
        Team team2 = new Team("team2");
        Member member1 = new Member("member1");
        Member member2 = new Member("member2");
        Member member3 = new Member("member3");
        Member member4 = new Member("member4");
        member1.setTeam(team1);
        member2.setTeam(team1);
        member3.setTeam(team1);
        member4.setTeam(team2);
        teamRepository.save(team1);
        teamRepository.save(team2);
        memberRepository.save(member1);
        memberRepository.save(member2);
        memberRepository.save(member3);
        memberRepository.save(member4);

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

        System.out.println("=====select=====");
        List<Team> teams = teamRepository.findAll();

        for (Team team : teams) {
            for (Member member : team.getMembers()) {
                System.out.println("member.getUsername() = " + member.getUsername());
            }
        }
    }
}

 

team1과 team2 두 개의 Team을 생성하고, 4개의 Member를 생성하여 member1,2,3은 team1로, member4는 team2로 설정했다.

JpaRepository를 상속하는 각 TeamRepository와 MemberRepository를 통해 엔티티를 영속화하고, TestEntityManger를 통해 영속성 컨텍스트를 flush하고, clear해주었다. (이후 조회시 쿼리문을 수행하지 않고 1차 캐시에서 가져오는 것을 방지하기 위함)

이제 실제로 수행되는 쿼리를 확인해보자.

우선 team을 먼저 가져온다.

다음으로는 for문을 돌면서 team1에서 member.getUsername()을 처음 호출할 때 member.team_id가 team1과 같은 member를 모두 가져온다.

빨간색 박스 부분의 where절을 보면 쿼리 한 번으로 team1에 해당하는 member1,2,3을 가져온다는 것을 확인할 수 있다.

그 다음 for문을 돌면서 team2에서 member.getUsername()을 호출할 때 똑같이 member.team_id가 team2와 같은 member를 가져온다.

위와 같이9 쿼리가 한 번더 수행되고 team2에 해당하는 member4를 가져온다.

 

결론

member1, 2, 3을 team1과 맵핑하고 member4를 team2와 맵핑하고 모든 Team을 조회하여 Team의 member.getUsername으로 출력했을 때 수행되는 쿼리는 1(Team을 조회하는 쿼리) + 2(team_id에 해당하는 member를 조회하는 쿼리)로 총 3번이 수행되었다. 추가로 수행된 2번의 쿼리는 Team의 개수만큼 추가로 수행된 것이므로 @OneToMany 관계에서 One의 개수만큼 쿼리가 추가로 수행되는 것을 확인했다.

@OneToMany 관계에서 외래키를 target table에 두기 위해 다음과 같이 작성했었다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class PomodoroRoom {
	...
    @OneToMany(mappedBy = "pomodoroRoom", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PomodoroProgress> pomodoroProgresses = new ArrayList<>();
    ...
}

실제로 필요한 맵핑은 PomodoroRoom에서의 단방향만 필요했으나 @JoinColumn을 사용한 쪽에서 외래키를 관리한다고 생각하여 Many쪽에서 맵핑을 해주고 One쪽에서 mappedBy로 양방향 맵핑을 해주었다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class PomodoroProgress {
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "pomodoro_progress_id")
    private PomodoroProgress pomodoroProgress;
    ...
}

 

하지만 굳이 그럴 필요가 없었다..!

@JoinColumn의 주석에 있는 설명을 보면 다음과 같다.

 

조인하고자 하는 대상에 따라 다음과 같이 적용된다.

  • OneToOne 또는 ManyToOne인 경우 외래키는 source entity 또는 embeddable에 존재한다.
  • OneToMany 단방향 맵핑인 경우 외래키는 target entity에 존재한다.
  • ManyToMany 맵핑 또는 OneToOne 양방향 맵핑인 경우 외래키는 join table에 존재한다.
  • element collection인 경우 외래키는 collection table에 존재한다.

나는 두 번째 케이스로 사용하면 되는것이었다. 불필요하게 양방향 맵핑을 하고 있었던 것...

이로써 불필요한 양방향 맵핑을 줄일 수 있게 되었다.

이를 깨우치게 해준 허브신께 무한한 감사를...

다음 날이 2차 스프린트의 마지막 날이기 때문에 오늘 배포를 하고 api를 직접 테스트 했다.

테스트 도중 비즈니스 로직을 수행해도 엔티티가 DB에 제대로 저장되지 않는 문제가 발생했다.

Study에 Member가 참여하기 위해 ParticipateService에서 study.participate(member) 를 수행하면 멤버의 스터디 참여 정보인 MemberProgress 엔티티가 생성되고 저장돼야 한다.

서비스 부터 코드를 살펴보자.

@RequiredArgsConstructor
@Transactional
@Service
public class ParticipateService {

    private final MemberRepository memberRepository;
    private final StudyRepository studyRepository;

    public Long participate(Long studyId, String nickname) {
        Member member = memberRepository.save(new Member(nickname));
        Study study = studyRepository.findById(studyId)
                .orElseThrow(IllegalArgumentException::new);

        study.participate(member);
        return member.getId();
    }
}

 

닉네임을 받아 멤버를 생성하고, 이 멤버를 스터디에 참여시킨다.

 

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "study_type")
public abstract class Study extends BaseTimeEntity {

    private static final int MIN_NAME_LENGTH = 1;
    private static final int MAX_NAME_LENGTH = 10;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    @Column(length = 10)
    private String name;

    @OneToMany(mappedBy = "study")
    private List<MemberProgress> memberProgresses = new ArrayList<>();
    
    ...
}

 

MemberProgress가 이 1:다 관계에서 다 쪽이기 때문에 연관 관계의 주인(외래키 관리자)이다.

Study 엔티티에서 참여한 멤버들의 스터디 참여 정보를 조회하기 위해 Study쪽에서도 OneToMany(mappedBy = "study")로 양방향 맵핑을 해주었다.

하지만 맵핑을 이렇게 해주니 다음 로직에서 문제가 발생했다.

public void participate(Member member) {
        validateDuplicatedNickname(member);
        MemberProgress pomodoroProgress = new PomodoroProgress(this, member);
        memberProgresses.add(pomodoroProgress);
}

 

member와 이 study에 해당하는 PomodoroProgress 엔티티를 생성하여 meberProgresses.add로 리스트에 추가를 해주었으나 PomodoroProgress가 실제로 영속화되는 과정은 없다.

이에 대한 해결책으로 다음 두 방법을 생각했다.

  1. 영속성 전이로 Study가 영속될 때 MemberProgress도 영속되도록 cascade = CascadeType.PERSIST 옵션을 준다.
  2. 로직을 엔티티에서 수행하지 않고 서비스로 빼내서 MemberProgressRepository.save()를 호출하여 직접 영속화한다.

테오의 의견은 2번으로 아직 JPA가 익숙지 않아 cascade를 사용해서 어떤 문제가 발생할지 모르기 때문에 일단 서비스로 빼서 명시적으로 영속화를 해주자고 했다.

JPA가 익숙하지 않은 것에는 동의하여 일단 서비스로 빼내었으나, 이와 관련한 검증 로직도 서비스로 빼야했다. 로직이 분산되어 응집성이 떨어져 관리가 어렵다는 문제도 있고, 실제로 에러도 발생했다.

서비스로 로직을 뺴내려면 당장 배포를 해야하는 상황에서 테스트 코드도 수정해야 하고, 수정해야 할 코드도 많아져서 우선은 cascade를 붙여서 해결하고 다음에 좀 더 이야기를 해보기로 했다.

문제를 해결하고 천천히 다시 생각해보았다.

이렇게 서비스로 로직을 빼내게 되면 도메인 모델 패턴보다는 트랜잭션 스크립트 패턴에 가까워 진다고 생각하고 이렇게 되면 굳이 객체지향적 코드를 작성하고, 관계형 DB에 저장하기 위해 JPA를 사용하는 의미가 퇴색된다고 생각이 들었다.

또한, 엔티티마다 전부 repository를 만들어서 직접 저장하기 보다 엔티티의 생명 주기가 종속적인 경우에는 영속성 전이를 통해 저장하는 것이 좋겠다고 생각했다.

로직을 수행할 때 어느 엔티티를 불러와서 수행할지도 팀원들과 얘기를 해봐야 할 것 같다. 현재는 중구난방으로 엔티티를 불러와 로직을 수행하는데, 진입점을 잘 정하고 일관되게 리팩토링하는 과정이 필요하다고 생각한다.

'회고 > 우아한테크코스' 카테고리의 다른 글

2023.08.08 일일 회고  (0) 2023.08.09
2023.07.31 일일 회고  (1) 2023.08.01
2023.07.04 일일 회고  (0) 2023.07.05
2023.06.20 일일 회고  (0) 2023.06.21
2023.06.17 일일 회고  (0) 2023.06.18

오늘 한 일

  • 알고리즘 1문제
  • 팀 프로젝트 회의
  • 테코톡 편집

오늘 jpa 강의를 들으면서 확실하게 알고 있다는 느낌을 받지 못했다. 내일 오전에 영속성 컨텍스트에 대해 포스팅을 목표로 jpa 개념을 정리해보자.

 

'회고 > 우아한테크코스' 카테고리의 다른 글

2023.07.31 일일 회고  (1) 2023.08.01
2023.07.21 일일 회고  (0) 2023.07.21
2023.06.20 일일 회고  (0) 2023.06.21
2023.06.17 일일 회고  (0) 2023.06.18
2023.06.15 일일 회고  (0) 2023.06.16

오늘 한 일

  • query-dsl 강의 듣기
    • 환경 설정
    • 기본 문법
  • 알고리즘 2문제
  • 오브젝트 읽기

'회고 > 우아한테크코스' 카테고리의 다른 글

2023.07.21 일일 회고  (0) 2023.07.21
2023.07.04 일일 회고  (0) 2023.07.05
2023.06.17 일일 회고  (0) 2023.06.18
2023.06.15 일일 회고  (0) 2023.06.16
2023.06.14 일일 회고  (0) 2023.06.15

오늘 할 일

  • jpa 강의 활용 2편 완강
    • API 개발 고급 - 지연 로딩과 조회 성능 최적화
    • API 개발 고급 - 컬렉션 조회 최적화
    • API 개발 고급 - 실무 필수 최적화
  • 알고리즘 1문제
  • 오브젝트

 

CQRS

update와 같은 것은 커맨드기 때문에 쿼리랑 분리한다. 따라서 update가 엔티티를 반환하기 보다 void나 id정도 반환하는 것이 유지보수에 좋음.

 

1:N 에서 고려할 점

db뻥튀기
1:다 관계에서.(컬렉션을 의존할 때)
order가 1개, orderItem이 n개이면 조회의 row는 n개가 된다. order에 대한 데이터는 중복이 됨.
-> jpql에 distinct를 넣어줌
-> 1. 실제 sql에 distinct가 들어가 동작함. -> 이거는 로우의 모든 값이 중복이어야 제거해줌
-> 2. jpql의 distnct는 하나 하나 객체에 담을 때 id가 같은 id면 중복인 애는 버리고 list에 담아서 반환해줌.
단점
- 페이징이 불가능하다.

1:다 인 경우에만 페치 조인을 하면 페이징이 안나간다!!
llimit나 시퀀스 sql문이 안나가고 jpa가 warn을 알려줌.(애플리케이션의 메모리에서 페이징을 처리했다는)

'회고 > 우아한테크코스' 카테고리의 다른 글

2023.07.04 일일 회고  (0) 2023.07.05
2023.06.20 일일 회고  (0) 2023.06.21
2023.06.15 일일 회고  (0) 2023.06.16
2023.06.14 일일 회고  (0) 2023.06.15
2023.06.13 일일 회고  (0) 2023.06.14

+ Recent posts