0. 들어가며
현재 진행 중인 프로젝트에서는 친구 관계를 맺고 소통하는 기능이 포함되어 있으며, 이에 따라 다음과 같은 요구사항이 존재합니다.
사용자는 내가 보낸 친구 요청 목록을 확인할 수 있어야 한다.
사용자는 내가 받은 친구 요청 목록을 확인할 수 있어야 한다.
사용자는 친구 요청이 서로 수락되어 친구 관계가 성립된 사용자 목록, 즉 나의 친구 목록을 확인할 수 있어야 한다.
이러한 기능을 구현하기 위해 JPA 기반의 연관 관계 설정과 조회 로직을 구성했으며, 실제 운영 환경과 유사한 스테이징 환경에서 실제 데이터를 넣고 운영 테스트를 진행하던 중, 특정 API의 응답 속도가 비정상적으로 느려지는 현상이 발생했습니다.
초기에는 데이터가 적어 문제를 인지하지 못했지만, 충분한 양의 데이터를 투입해 테스트를 진행하자 단일 API 요청에 수십 건의 SELECT 쿼리가 발생하는 현상을 확인할 수 있었습니다. 이론상 JPA의 LAZY 로딩 개념과 연관 엔티티 접근 시 추가 쿼리가 발생한다는 것은 알고 있었지만, N+1 문제가 실제로 성능 저하로 이어질 수 있다는 것을 직접 경험한 순간이었습니다.
이 글에서는 제가 실제로 마주한 N+1 문제를 아래 세 관점에서 살펴보려고 합니다.
- 프로젝트에서 마주한 N+1 문제 발견
- N+1 문제의 원인 분석
- 그리고 이를 해결한 과정
1. 프로젝트에서 마주한 N+1 문제 발견
현재 구조에서 N+1 문제가 발생하는 API는 총 3개로,
- 친구 목록 조회 - (/friends)
- 내가 받은 친구 요청 목록 - (/friends/received)
- 내가 보낸 친구 요청 목록 - (/friends/requests)
이 중, 예를 들어 내가 받은 친구 요청 목록 API에서 한 사용자가 20개의 친구 요청을 받았다면, 쿼리는 다음과 같이 발생합니다.
- 1번의 인증에 사용되는 사용자 조회 쿼리 (securityUtil.getMemberIdByUserDetails())
- 1번의 FriendRequest 리스트 조회 쿼리
- 20번의 Member(sender) 조회 쿼리
즉, 총 22번의 쿼리가 실행됩니다.
확인을 위해 친구 요청 데이터를 20, 30개씩 미리 입력한 뒤 API를 호출해 실제 쿼리 수를 확인해 보았습니다.


실제로 API를 호출해본 결과, 단순 조회임에도 불구하고 예상보다 많은 수의 쿼리가 발생하는 것을 확인할 수 있었으며, 사용자의 데이터 양이 증가할수록 쿼리 수가 급격히 늘어나 성능 저하로 이어지는 구조임을 확인할 수 있었습니다.
만약 친구 요청이 100개라면? 1 + 1 + 100 = 102 쿼리
이와 같은 방식으로 보낸 요청, 받은 요청, 친구 수가 많아질수록 쿼리 수도 계속해서 증가하게 됩니다.
2. N+1 문제의 원인 분석
먼저 어떤 엔티티에서 N+1 문제가 발생하고 있는지 살펴보도록 하겠습니다.
@Entity
public class Member {
...
// 내가 보낸 친구 요청 목록
@OneToMany(mappedBy = "sender", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FriendRequest> sendRequests = new ArrayList<>();
// 내가 받은 친구 요청 목록
@OneToMany(mappedBy = "receiver", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FriendRequest> receivedRequests = new ArrayList<>();
// 수락된 친구 요청 목록
@OneToMany(mappedBy = "sender")
private List<FriendRequest> friends = new ArrayList<>();
...
}
@Entity
public class FriendRequest {
...
// 보낸 이
@ManyToOne(fetch = FetchType.LAZY)
private Member sender;
// 받은 이
@ManyToOne(fetch = FetchType.LAZY)
private Member receiver;
...
}
N+1 문제가 발생한 엔티티는 사용자 간 관계를 관리하는 도메인인 FriendRequest 엔티티였습니다.
이 FriendRequest 엔티티는 Member 엔티티와 sender, receiver로 각각 다대일(@ManyToOne) 관계를 맺고 있으며,
이 연관관계는 모두 지연 로딩(LAZY) 으로 설정되어 있었습니다.
@Service
@RequiredArgsConstructor
@Transactional
public class FriendRequestService {
...
@Transactional(readOnly = true)
public FriendsListResponse myRequests() {
Long senderId = securityUtil.getMemberIdByUserDetails();
List<FriendRequest> requests = friendRequestRepository.findMyRequestList(senderId);
FriendsListResponse response = new FriendsListResponse();
for (FriendRequest request : requests) {
Member receiver = request.getReceiver();
if (receiver != null) {
// N+1 문제 발생!
// 연관된 엔티티 "MEMBER"의 'ID'와 'Username' 을 가져오기 위해추가 쿼리 발생
response.getFriends().put(receiver.getId(), receiver.getUsername());
}
return response;
}
...
}
이는 곧, 친구 요청 리스트를 가져온 후 sender나 receiver 정보를 반복문에서 참조할 경우, 매 요청마다 추가적인 SELECT 쿼리가 발생하게 됨을 의미합니다. 즉, 친구 요청 정보는 한 번에 가져오지만, 각 요청마다 연관된 회원 정보를 LAZY 로딩 방식으로 불러오기 때문에, sender 또는 receiver 정보를 조회할 때마다 별도의 쿼리가 실행되어 N+1 문제가 발생하고 있었습니다.
3. N+1 문제 해결 과정
N+1 문제를 해결하고 데이터를 더 효율적으로 불러오기 위해 세 가지 방법을 고민해보았습니다.
- Batch Size
- Fetch Join
- DTO Projection
1. Batch Size
Batch Size는 Lazy 관계에서 발생하는 N+1 문제를 줄이기 위해,
여러 엔티티를 IN 절로 묶어 한 번에 조회하도록 해주는 Hibernate의 성능 최적화 기능입니다.
SELECT * FROM member WHERE id IN (1, 2, 3, ..., 30)
- 예를 들어, 30개의 FriendRequest가 각각 receiver를 참조할 때, 기본 설정이라면 30개의 SELECT 쿼리가 발생합니다.
- BatchSize를 설정하면 Hibernate가 ID를 모아서 한 번에 가져옵니다.

하지만 현재 프로젝트에서 문제가 된 FriendRequest 엔티티 내의 receiver와 같은 @ManyToOne 연관관계 필드에는 @BatchSize를 직접 붙일 수 없기 때문에, 이 방식만으로는 N+1 문제를 완전히 해결하기 어렵습니다.


단일 참조 필드에 배치 사이즈를 적용하고자 한다면, 글로벌 설정으로 hibernate.default_batch_fetch_size 값을 지정하는 방법이 있긴 합니다. 그러나 글로벌 배치 사이즈는 모든 연관 엔티티의 프록시나 컬렉션 조회 시 무조건 적용되므로, 실제로 배치 조회가 필요 없는 곳까지 한꺼번에 묶어서 조회하려 시도하는 문제가 발생합니다.
이에 따라, 필요한 연관 데이터를 한 번에 조회하기 위한 대안으로 Fetch Join 방식을 고민하게 되었습니다. Fetch Join은 쿼리 실행 시점에 연관 엔티티를 조인하여 한꺼번에 조회하기 때문에, @BatchSize가 적용되지 않는 @ManyToOne 관계에서도 효과적으로 N+1 문제를 줄일 수 있다고 생각했습니다.
2. Fetch Join
JOIN FETCH는 JPQL에서 연관 엔티티를 즉시 로딩해 한 번에 조회하도록 도와주는 구문입니다.
단순 JOIN과 달리, 연관된 엔티티까지 영속성 컨텍스트에 함께 로딩해 N+1 문제를 방지할 수 있습니다.
// 기존 JPQL
@Query("SELECT f FROM FriendRequest f JOIN f.sender s WHERE s.id = :memberId AND f.status = 'PENDING'")
List<FriendRequest> findMyRequestList(@Param("memberId") Long memberId);
// FETCH JOIN 적용
@Query("""
SELECT fr FROM FriendRequest fr
JOIN fr.sender s
JOIN FETCH fr.receiver r
WHERE s.id = :memberId AND fr.status = 'PENDING'
""")
List<FriendRequest> findMyRequestList(@Param("memberId") Long memberId);

⚠️ 하지만! 불필요한 데이터까지 모두 조회됨

N+1 문제는 해결되었지만, JOIN FETCH는 엔티티 단위로만 작동하기 때문에, 예를 들어 receiver.username과 같은 단순 필드만 필요하더라도 해당 필드만을 대상으로 FETCH JOIN을 사용할 수는 없습니다. 이는 FETCH JOIN이 지연 로딩 대상인 연관 엔티티 전체를 한 번에 조회해 영속성 컨텍스트에 로딩하기 위한 기능이기 때문입니다. 따라서, 단순히 연관 엔티티의 특정 필드만 필요한 상황에서는 오히려 불필요한 엔티티 전체 로딩으로 인해 성능이 저하될 수 있는 문제가 발생합니다.
이러한 한계를 보완하고자, 실제로 필요한 필드만 조회할 수 있는 DTO Projection 방식을 고려하게 되었습니다.
3-1. DTO Projection
JPA에서 엔티티가 아닌 DTO 형태로 조회 결과를 직접 매핑하는 방법입니다.
DTO Projection은 지연 로딩 자체가 없기 때문에 N+1 문제도 없습니다.
연관 엔티티의 실제 객체를 로딩하지 않고, 필드 값만 한 번의 쿼리로 가져와 DTO에 담기 때문입니다.
즉, JPA가 프록시 객체(지연로딩용 중간 객체)를 만들지 않습니다.
@NoArgsConstructor
@Getter
@Setter
public class FriendRequestReceiverDto {
private Long requestId;
private Long receiverId;
private String receiverUsername;
public FriendRequestReceiverDto(Long requestId, Long receiverId, String receiverUsername) {
this.requestId = requestId;
this.receiverId = receiverId;
this.receiverUsername = receiverUsername;
}
}
- 엔티티 전체를 조회하는 대신, 쿼리 결과를 DTO 클래스의 생성자를 통해 바로 매핑하는 방식입니다.
@Query("""
SELECT new com.switching.study_matching_site.dto.friend.FriendRequestReceiverDto(
fr.sender.id,
r.id,
r.username
)
FROM FriendRequest fr
JOIN fr.receiver r
WHERE fr.sender.id = :memberId AND fr.status = 'PENDING'
""")
List<FriendRequestReceiverDto> findMyRequestDtos(@Param("memberId") Long memberId);
- 주의점으로는 패키지 경로까지 포함한 DTO 생성자 명시가 필요합니다. (new com.example.dto.MyDto(...))
- DTO 클래스에 생성자 필드 순서가 정확히 일치해야 합니다.


DTO Projection은 필요한 필드만 선별적으로 조회할 수 있어 성능 면에서 매우 효율적이지만, 단점도 존재했습니다.
public class Dto {
private Long requestId;
private Long receiverId;
private String receiverUsername;
private String receiverEmail; // 새로 추가된 필드
public Dto(Long requestId, Long receiverId, String receiverUsername, String receiverEmail) {
this.requestId = requestId;
this.receiverId = receiverId;
this.receiverUsername = receiverUsername;
this.receiverEmail = receiverEmail;
}
}
- 변경된 생성자에 맞추어 쿼리도 수정해야 함
@Query("""
SELECT new com.switching.study_matching_site.dto.friend.FriendRequestReceiverDto(
fr.id,
r.id,
r.username,
r.email // 새 필드 추가 반영 필요
)
FROM FriendRequest fr
JOIN fr.receiver r
WHERE fr.sender.id = :memberId AND fr.status = 'PENDING'
""")
List<FriendRequestReceiverDto> findMyRequestDtos(@Param("memberId") Long memberId);
- JPQL 쿼리의 생성자 호출 부분도 꼭 수정해야 해야함!
쿼리와 DTO 생성자가 강하게 결합되어 있어, DTO의 필드나 생성자 시그니처가 변경될 경우 해당 쿼리들도 함께 수정해야 합니다.
이는 유지보수성을 떨어뜨릴 수 있으며, 프로젝트 규모가 커질수록 쿼리 관리가 번거로워질 수 있다고 생각했습니다.
(DTO의 필드나 생성자에 변경이 생기면 해당 쿼리들도 함께 수정해야 해서, 프로젝트 규모가 커질수록 유지보수가 조금 번거로워질 수 있다고 생각했습니다.)
유지보수 문제를 해결할 방안을 찾던 중, QueryDSL의 @QueryProjection 기능을 활용하면 컴파일 타임 타입 안전성을 확보하고 IDE 리팩토링 지원도 받을 수 있어 유지보수에 유리하다고 판단했습니다. 다만, 해당 방식은 DTO가 QueryDSL에 직접 의존하게 되어 일정 수준의 결합도가 발생한다는 점을 고려해 사용했습니다. 이미 프로젝트 내 다른 엔티티에서 동일한 방식을 사용하고 있었기 때문에, 해당 방식의 장점을 살려 자연스럽게 이 부분에도 적용해볼 수 있었습니다.
3-2. QueryDSL을 활용한 DTO Projection
@QueryProjection은 QueryDSL에서 제공하는 기능으로, DTO에 타입 안전한 방식으로 값을 매핑하기 위해 사용하는 어노테이션입니다. 필드 순서나 타입 실수를 런타임에 알 수 없는 단점을 컴파일 타임에 잡아줘 유지보수에 장점이 있습니다.
import com.querydsl.core.annotations.QueryProjection;
public class FriendRequestReceiverDto {
private Long requestId;
private Long receiverId;
private String receiverUsername;
@QueryProjection
public FriendRequestReceiverDto(Long requestId, Long receiverId, String receiverUsername) {
this.requestId = requestId;
this.receiverId = receiverId;
this.receiverUsername = receiverUsername;
}
}
@Repository
public class FriendRequestRepositoryCustomImpl implements FriendRequestRepositoryCustom {
private final JPAQueryFactory queryFactory;
public FriendRequestRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<FriendRequestReceiverDto> findMyRequestDtos(Long memberId) {
QFriendRequest fr = QFriendRequest.friendRequest;
QMember sender = new QMember("sender");
QMember receiver = new QMember("receiver");
return queryFactory
.select(Projections.constructor(
FriendRequestReceiverDto.class,
sender.id,
receiver.id,
receiver.username
))
.from(fr)
.join(fr.sender, sender)
.join(fr.receiver, receiver)
.where(
sender.id.eq(memberId),
fr.status.eq(RequestStatus.PENDING)
)
.fetch();
}
}

@QueryProjection을 사용하면 사진에서 보시다시피, DTO 생성자에 전달하는 필드 순서를 바꿨더니 컴파일 시점에서 오류가 발생했습니다. DTO 생성자에 전달되는 필드의 순서나 타입이 맞지 않을 경우 컴파일 시점에서 오류를 확인할 수 있어, 런타임 오류를 사전에 방지할 수 있습니다.
이러한 점은 문자열 기반으로 생성자를 호출하는 기존의 DTO Projection 방식과 비교할 때 특히 큰 장점입니다. 기존 방식은 필드 순서나 타입 오류를 컴파일 시점에 잡기 어렵고, 런타임에 예외가 발생할 위험이 높아 유지보수에 부담이 되지만, @QueryProjection을 사용하면 컴파일 타임 검증이 가능해 보다 안전하고 효과적으로 코드를 관리할 수 있었습니다.
✍️ 느낀점
N+1 문제를 해결하기 위해 다양한 방식을 고민하며 각각의 장단점을 명확히 파악할 수 있었습니다.
BatchSize는 Lazy 관계의 성능을 향상시키지만, 단일 참조 필드에는 적용이 어려운 한계가 있었고,
Fetch Join은 연관 엔티티를 한 번에 효과적으로 조회할 수 있으나, 필요한 필드만 선택적으로 가져오는 데 한계가 있었습니다.
이런 상황에서 DTO Projection은 필요한 데이터만 선별적으로 조회할 수 있어 성능과 효율성 측면에서 가장 적합한 선택이었지만, 쿼리와 DTO 생성자가 강하게 결합되어 구조 변경 시 유지보수가 어려워질 수 있다는 단점도 존재했습니다.
이에 대해 유지보수 문제를 해결하기 위해 QueryDSL과 @QueryProjection을 도입했습니다. DTO 프로젝션 방식이 갖는 DTO와 쿼리 간의 강결합 문제는 완전히 해소하지는 못했지만, @QueryProjection을 활용하면 자바 코드 기반으로 컴파일 시점에 필드 순서나 타입 오류를 잡아주고, IDE의 리팩토링 기능도 적극 활용할 수 있어, 런타임 오류를 사전에 방지하면서 유지보수성을 크게 개선할 수 있었습니다.
N+1 문제는 단순히 쿼리가 약간 더 나가는 정도가 아니라, 사용자 수가 많아질수록 서비스 전체 성능 저하로 이어질 수 있는 치명적인 문제였습니다. 이번 경험을 통해 단순히 "Fetch Join이 무조건 좋다"거나 "Lazy가 나쁘다"는 단편적인 생각을 넘어, 상황에 맞는 적절한 해결책을 고민하는 시야를 갖게 되었습니다.
'[프로젝트]' 카테고리의 다른 글
[프로젝트 이슈] 사용자 로그인 처리(인증, 인가) (1) | 2025.07.18 |
---|---|
[프로젝트 이슈] 서비스 레이어 테스트 커버리지 후기 (1) | 2025.07.18 |
[프로젝트 이슈] 동시성 문제 원인 및 해결 (1) | 2025.07.03 |
배포 프로세스 정리 (0) | 2025.01.19 |
회원 관리 프로젝트 - 요구사항 분석 (0) | 2024.03.28 |