본문 바로가기
JPA

JPA 조회 전략

by 이상한나라의개발자 2024. 3. 7.

JPA를 활용한 쿼리 방식 선택은 성능 최적화와 개발 효율성을 고려하여 결정되어야 합니다. 아래는 JPA 쿼리 방식 선택 시 권장하는 순서 입니다.

 

먼저 예시를 위해 Member, Team 엔티티를 만들겠습니다.

@Entity
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;

    private String name;

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

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

/-------------------------/

@Entity
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String username;

    private int age;

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

    public Member(String username) {
        this(username, 0);
    }
    public Member(String username, int age) {
        this(username, age, null);
    }
    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

엔티티를 그대로 노출했을 경우의 문제점

만약 entity를 그대로 노출하게 되면 아래와 같은 문제점이 발생할 수 있습니다. ( 양방향 연관관계 시에 )

또한, 성능 및 유지보수 측면에서 많은 어려움이 따르게 됩니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/member")
public class MemberController {

    private final MemberService memberService;

    @GetMapping
    public List<Member> findMembers() {
        return memberService.findMembersEntity();
    }
}

 

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    public final MemberRepository memberRepository;

    public List<MemberResponse> findMembers() {
        List<Member> members = memberRepository.findAll();
        return MemberResponse.from(members);
    }
}

 

public interface MemberRepository extends JpaRepository<Member, Long>{
}

 

 

위 화면은 무한루프에 빠진 모습입니다. 엔티티를 직접 클라이언트에 노출할 경우 JSON 변환 과정에서 양쪽 엔티티가 서로를 계속 참조하기 때문입니다. 

 

JSON 직렬화 문제

rest api를 통해 클라이언트 데이터를 제공할 때, 일반적으로 엔티티 객체를 json 형태로 변환하여 전송합니다. 이 과정을 직렬화(serialization) 라고 합니다. springboot에서는 Jackson 라이브러리를 사용하여 객체를 json으로 자동 변환하는데, 이 때 양방향 연관관계에 있는 엔티티를 직렬화 하려고 하면 문제가 발생합니다.

무한루프 문제

  • Member 엔티티를 json으로 변환하려 할 때, 해당 member가 속한 team 도 함께 변환해야 합니다.
  • Team 엔티티의 변환 과정에서는 Team에 속한 모든 Member 엔티티를 다시 변환해야 합니다.
  • 이러한 과정이 반복되면서 서로를 끊임없이 참조하게 되어, 직렬화 과정이 무한히 계속되는 무한 루프에 빠지는 것입니다.

해결방법

  • DTO 사용 : 직접 엔티티를 반환하는 대신, 엔티티 데이터를 담는 DTO를 생성하여 반환하는 방법입니다. DTO를 사용하면 원하는 데이터만 클라이언트에 전달할 수 있으며, 직렬화 과정에서의 문제점을 회피할 수 있습니다.

 

엔티티를 DTO로 변환하는 방법을 선택한다.

엔티티는 데이터베이스의 테이블과 매핑되어 있는 객체 입니다. DTO(Data Transfer Object)는 계층 간 데이터 교환을 위해 사용되는 객체로, 필요한 데이터만 담아서 전달하는데 사용됩니다. 엔티티를 적접 노출 하는 것은 보안, 성능, 유지보수 측면에서 바람직 하지 않으며 절대 사용해서는 안됩니다. 따라서, 클라이언트에게 데이터를 전달할 때는 DTO로 변환하여 전환 제공하는 것이 좋습니다.

 

DTO 사용 시 장점

  1. API 스펙과 엔티티의 분리
    • 엔티티 구조가 변경되더라도 DTO를 통해 API 응답 구조를 유지할 수 있어 API 스펙의 안전성을 보장할 수 있습니다.
    • 예를 들어, API를 호출해서 데이터를 조회하는 쪽이 2군데라면 API 스펙이 변경되지 않으므로 영향을 주지 않습니다.
  2. 필요한 데이터만 노출
    • 클라이언트에 전달할 데이터를 선택적으로 구성할 수 있어 민감한 정보의 노출을 방지하고, 데이터 전송량을 최적화할 수 있습니다.
  3. 커스텀 데이터 포맷팅
    • 특정 필드의 데이터 포맷을 API 응답에 맞게 조정할 수 있습니다. 예를들어, 날짜 형식을 클라이언트가 요구하는 포맷으로 변환하여 제공할 수 있습니다.
  4. 성능 최적화
    • 지연로딩 (Lazy Loading) 으로 인한 성능 문제를 해결할 수 있습니다. 직렬화 과정에서 지연 로딩된 필드가 불필요하게 로딩될 수 있습니다. DTO를 사용하면 이런한 문제를 방지하고, 필요한 데이터만 명시적으로 로딩하여 성능 최적화할 수 있습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/member")
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/dto")
    public List<MemberResponse> findMemberDto() {
        return memberService.findMembers();
    }
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    public final MemberRepository memberRepository;

    public List<MemberResponse> findMembers() {
        List<Member> members = memberRepository.findAll();
        return MemberResponse.from(members);
    }
}
@Data
@Builder
public class MemberResponse {
    private Long id;
    private String username;

    public static List<MemberResponse> from(List<Member> members) {
        return members.stream()
                .map(member -> new MemberResponse(member.getId(), member.getUsername()))
                .collect(Collectors.toList());
    }
}

 

[
    {
        "id": 1,
        "username": "member1"
    },
    {
        "id": 2,
        "username": "member2"
    },
    {
        "id": 3,
        "username": "member3"
    },
    {
        "id": 4,
        "username": "member4"
    }
]

 

여기서 Member이 속한 Team 정보도 함께 노출하고 싶다면 MemberResponse 클래스에 Team과 관련된 별도의 DTO를 만들어서 반환해야만 합니다. 만약, Team entity를 MemberResponse 객체에 그대로 포함시켜 노출하게 되면 무한루프 문제는 발생하지 않지만 DTO 사용시 장점이 사라지게 됩니다.

 

아래는 연관관계에 있는 Team 정보도 같이 DTO로 변환하여 조회합니다.

@Data
@Builder
public class TeamResponse {

    private Long id;
    private String teamName;

}

 

@Data
@Builder
public class MemberResponse {
    private Long id;
    private String username;
    private TeamResponse team;

    public static List<MemberResponse> from(List<Member> members) {
        return members.stream()
                .map(member -> MemberResponse.builder()
                        .id(member.getId())
                        .username(member.getUsername())
                        .team(TeamResponse.builder()
                                .id(member.getTeam().getId())
                                .teamName(member.getTeam().getName())
                                .build()
                        )
                        .build()
                )
                .collect(Collectors.toList());
    }
}

 

[
    {
        "id": 1,
        "username": "member1",
        "team": {
            "id": 1,
            "teamName": "teamA"
        }
    },
    {
        "id": 2,
        "username": "member2",
        "team": {
            "id": 1,
            "teamName": "teamA"
        }
    },
    {
        "id": 3,
        "username": "member3",
        "team": {
            "id": 2,
            "teamName": "teamB"
        }
    },
    {
        "id": 4,
        "username": "member4",
        "team": {
            "id": 2,
            "teamName": "teamB"
        }
    }
]

 

위와 같이 하게되면 데이터가 정확히 나오는 것을 확인할 수 있습니다. 하지만 여기서 또다른 문제를 만나게 되는데요. 데이터가 작을때는 문제 없지만 데이터가 많다면 성능 문제가 발생하게 됩니다. 

 

여러번의 쿼리 발생 화면

 

team의 데이터를 가져오기 위해서 지연로딩을 사용하게 되는데, 이때 쿼리가 여러번 나가는 문제가 발생하게 됩니다.

이 문제는 N+1 문제로 지난번 글에서도 언급 드린바 있습니다. 첫 번째 쿼리로 회원 리스트를 가져올때 각 회원의 팀 정보는 초기화 되지 않고 프록시 객체로 남아 있기 때문에, 각 회원의 팀 정보에 접근할 때마다 추가적인 쿼리가 발생하여 성능 저하를 일으키는 현상입니다.

 

Fetch Join으로 쿼리 최적화

  • JPQL이나 QueryDsl을 사용하여 쿼리를 작성할 때, 페치 조인을 사용하여 문제를 해결할 수 있습니다. 페치 조인을 사용하면, 연관된 엔티티를 한 번의 쿼리로 함께 가져오므로 N+1 문제를 방지할 수 있습니다.
  • Member 와 그에 속한 Team을 함께 조회합니다. JPA 구현체는 join fetch 쿼리를 실행한 후, 결과 집합에서 얻은 Team 데이터를 사용하여 각 Member 인스턴스 내의 Team 프록시 객체를 초기화 합니다. 초기화 과정에서 team 프록시 객체는 실제 Team 엔티티의 인스턴스로 대체되며, 이 인스턴스는 데이터베이스에서 조회된 실제 데이터를 포함하게 됩니다.
    @Query("select m " +
            " from Member m " +
            " join fetch m.team")
    List<Member> findAllFetchJoin();

 

페치 조인 쿼리 수행 화면

 

  • Fetch Join 전략의 경우 OneToOne, ManyToOne 관계에만 최적화 되며  OneToMany, ManyToMany 관계에서 사용하는 것은 가능하지만 한계와 주의사항이 존재하게 됩니다. 관련 내용은 밑에서 설명 하도록 하겠습니다.

Hibername.default_batch_fetch_size 사용

해당 옵션은 JPA 에서 Lazy Loading 을 최적화하는 방법중 하나입니다. 이 옵션을 사용하면, 연관된 엔티티나 컬렉션을 조회할 때 지정된 크기만큼의 데이터를 일괄적으로 로딩하는 방식으로 성능을 개선할 수 있습니다.

# application.yml 예시
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

 

# application.properties 예시
spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

default_batch_fetch_size 적용 화면

 

DTO로 직접 조회

DTO로 직접 조회하는 방식은 JPQL 쿼리를 사용하여 어플리케이션의 성능 최적화와 API 스펙 관리 측면에서 여러 장단점을 가집니다.

장점

  • 성능최적화 : 필요한 데이터만 선택적으로 조회하여 가져오므로, 전체적으로 성능을 향상시킬 수 있습니다. 이는 데이터베이스로 부터 불필요한 데이터를 로드하지 않으므로, 네트워크 비용과 메모리 사용량을 줄입니다. ( 그러나.. *로 가져오나, 원하는 것만 가져오나.. 요즘 서버의 성능이 좋아서 크게 차이 나지는 않는거 같습니다 ) 
  • 안전한 데이터 노출 : 엔티티의 모든 정보를 노출하지 않고 클라이언트에 전달해야 할 데이터만 DTO에 담아서 전달할 수 있습니다. 이를 통해 민감한 정보 노출을 방지하고 보안성을 강화할 수 있습니다. 

단점 

  • 영속성 컨텍스트 사용의 제한 : DTO로 직접 조회할 경우, 반환된 DTO는 단순한 데이터 전달 객체이므로 JPA 영속성 컨텍스트와 상호작용 하지 않습니다. 이는 엔티티의 생명주기 관리, 변경 감지(dirty checking), 지연로딩 같은 JPA의 핵심 기능을 사용할 수 없습니다.
  • 유연성의 제한 : DTO를 통한 직접 조회는 쿼리 결과를 특정 DTO 구조에 맞춰야 하기 때문에, 어플리케이션의 요구사항이 변경되어 다른 형태의 데이터가 필요하게 되었을 때, 기존의 DTO와 쿼리를 수정하거나 새로운 DTO와 쿼리를 작정해야 하는 번거로움이 있습니다.
  • 종속적 : 만약 해당 DTO를 여러 군데이서 사용한다면, 요구사항이 변경 되었을 경우 변경이 크게 일어날 수 있습니다.
@GetMapping("/dtosearch")
public List<MemberDtoResponse> findMemberDtoSearch() {
    return memberService.findMembersDtoSearch();
}
public List<MemberDtoResponse> findMembersDtoSearch() {
    List<MemberDtoResponse> responses = memberRepository.findDtoSearch();
    return responses;
}
    @Query("SELECT new com.study.datajpareview.controller.response.MemberDtoResponse" +
            "(" +
                "m.id, m.username, " +
                "m.team.id, m.team.name) " +
            "FROM Member m JOIN m.team t")
    List<MemberDtoResponse> findDtoSearch();

 

컬렉션 조회 최적화 

 

Team 엔티티에서 Member을 조회 하려고 하는 경우에 발생하게 됩니다. Team (1) - (N) Member 관계 이므로 Team에서 Member 을 조회하는 경우는 데이터가 중복으로 발생하게 됩니다. 

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

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

 

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("select t from Team t join fetch t.members")
    Team findTeamFetchJoin();
}

 

컬렉션 조회시 Fetch Join
실제 데이터

 

위와 같이 컬렉션 조회시 패치조인을 사용하게 되면 Team 데이터는 2개인데 Member 인스턴스 프록시를 초기화 하여 데이터를 담기 때문에 실제 데이터는 4개가 됩니다. 이로인해, 데이터의 정합성이 떨어지게 되며 페이징 쿼리에는 사용할 수 없게 됩니다.

 

OneToMany 관계에서는 페이징처리가 안되고 데이터의 정합성이 떨어지기 때문에 지연로딩 방식과 Member를 조회하는 쿼리를 하나 더 만들어서 사용하는게 좋습니다.

 

hiberante.default_batch_fetch_size 적용

default_batch_fetch_size: 5
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/team")
public class TeamController {

    private final TeamService teamService;

    @GetMapping("/teamDefaultBatchSize")
    public List<TeamResponse> findMemberFetchJoin() {
        return teamService.getDefaultBatchSize();
    }
 }

 

@Service
@RequiredArgsConstructor
public class TeamService {

    private final TeamRepository teamRepository;

    public List<TeamResponse> getDefaultBatchSize() {
        List<Team> teams = teamRepository.findAll();
        return TeamResponse.from(teams);
    }
}

 

@Data
@Builder
public class TeamResponse {

    private Long id;
    private String teamName;
    private List<TeamMemberResponse> members;

    public static TeamResponse from(Team team) {
        return TeamResponse.builder()
                .id(team.getId())
                .teamName(team.getName())
                .members(TeamMemberResponse.from(team.getMembers()))
                .build();
    }

    public static List<TeamResponse> from(List<Team> teams) {
        return teams.stream()
                .map(TeamResponse::from)
                .collect(Collectors.toList());
    }
}

 

@Data
@Builder
public class TeamMemberResponse {

    private Long id;
    private String username;

    public static List<TeamMemberResponse> from(List<Member> members) {
        return members.stream()
                .map(member ->
                        TeamMemberResponse.builder()
                        .id(member.getId())
                        .username(member.getUsername())
                        .build()
                )
                .collect(Collectors.toList());
    }
}

 

 

[
    {
        "id": 1,
        "teamName": "teamA",
        "members": [
            {
                "id": 1,
                "username": "member1"
            },
            {
                "id": 2,
                "username": "member2"
            }
        ]
    },
    {
        "id": 2,
        "teamName": "teamB",
        "members": [
            {
                "id": 3,
                "username": "member3"
            },
            {
                "id": 4,
                "username": "member4"
            }
        ]
    }
]

 

위 코드는 팀을 조회하고 팀에 해당하는 멤버도 조회하는 로직입니다.  teamA, teamB에 속하는 회원은 각각 2명입니다. 

 

default_batch_fetch_size를 적용하지 않게 되면 아래와 같이 회원 조회 쿼리가 두번 발생하게 됩니다. 

default_batch_fetch_size 미적용

 

 

default_batch_fetch_size를 적용시 회원 조회 쿼리는 한번 발생합니다 (in절)

 

default_batch_fetch_size 적용

 

데이터가 적을때는 문제가 없겠지만 데이터가 많다면 default_batch_fetch_size로 성능 최적화를 이룰 수 있습니다.

그러므로 프로젝트 초기 세팅시 default_batch_fetch_size 옵션은 필수로 설정하고 진행하시는걸 권장 드립니다.

 

다음 내용에서는 querydsl에 대해서 기술하도록 하겠습니다.

'JPA' 카테고리의 다른 글

JPA 조인&서브쿼리  (2) 2024.03.12
Spring Data JPA  (0) 2024.03.08
JPA Entity 설계시 베스트 프랙티스  (0) 2024.02.22
JPA OSIV  (0) 2024.01.31
JPA Auditing  (0) 2024.01.30