생활 로그/회고록

JPA에서 N+1 문제를 어떻게 해결하고 계신가요?

MingyuKim 2025. 7. 4.

최근 타사 개발팀 팀장님과 대화를 할 기회가 생겼다.

대화 중 “JPA에서 N+1 문제를 어떻게 해결하고 계신가요?”라는 질문을 받았다.

연차가 쌓이면 이런 기초적인 질문을 받는 기회가 적어지기 때문에 굉장히 참신하게 다가온 질문이였고, 내가 오랜 시간 ORM을 사용하며 고민하고 실천해온 철학을 되돌아보게 만들었다.

“저는 연관관계를 무작정 맺지 않습니다. 예를 들어 이미지나 파일과 같은 엔티티는 ComAttachFile처럼 별도로 두고, 직접 연관관계를 맺어 데이터를 수정하지 않습니다.
@ManyToOne(fetch = LAZY)와 insertable = false, updatable = false 조합으로 읽기 전용 단방향 관계를 걸어두고, 실질적인 DB 쓰기는 식별자 컬럼을 통해 처리합니다.
조회 시에는 Projection이나 DTO 기반으로 필요한 정보만 명확하게 가져오도록 쿼리를 분리합니다.”

 

이 방식은 ‘내가 만든 엔티티를 누가, 어떤 방식으로 사용할지까지는 예측할 수 없다’는 가정 하에
불필요한 의존성을 최소화하고, 지연 로딩에 의한 N+1 문제를 사전에 방지하기 위한 일종의 방어적인 설계다.

예전에는 JPA의 연관관계를 그대로 활용해 코드를 우아하게 구성하고 싶었다.
하지만 연관관계를 깊게 맺어두면, 의도치 않은 지연 로딩으로 다수의 쿼리가 발생하고, 실무에서는 그게 곧 성능 이슈로 이어졌다.
그래서 지금은 DB 저장은 식별자(ID) 기반으로 처리하고, 연관 객체는 조회 용도로만 명확하게 사용하는 패턴을 주로 사용하고 있다.

하지만 그 팀장님의 반응은 다소 당황스러웠다. 마치 “그건 좋은 접근은 아닌데요?“라는 인상을 받았기 때문이다.

 

왜 나는 이렇게 설계하게 되었는가?

JPA의 가장 큰 장점은 객체 간 연관관계를 통해 도메인 모델을 풍부하게 표현할 수 있다는 점이다.
하지만 그 강점은 동시에 가장 큰 위험이기도 하다.
JPA의 연관관계는 결국 SQL 쿼리로 변환되며, 이를 제대로 통제하지 못하면 지연 로딩이 반복되면서 N+1 문제가 터지기 쉽다.


특히 엔티티를 여러 사람이 함께 사용하는 팀 환경에서
내가 연관관계를 맺어둔 엔티티가 어디선가 반복 호출되며 성능 병목을 유발하는 사례를 여럿 경험했다.


그래서 나는 다음 두 가지 원칙을 실천하고 있다.

 

데이터 저장은 ID 기반으로 하고, 연관 객체는 조회용으로만 설정

→ @ManyToOne(fetch = LAZY) + insertable = false, updatable = false 조합으로 연관 객체를 읽기 전용으로 사용

조회 시에는 명확한 쿼리와 Projection 기반으로 필요한 정보만 가져오기

→ 필요한 관계만 fetch join 또는 DTO 형태로 명시적으로 로딩

 

내가 생각하는 N+1 문제의 실질적 해결책 몇가지

사실 JPA와 같은 ORM에서는 N+1 문제를 완전히 제거하기는 어렵다. 하지만 다음과 같은 전략을 병행하면 대부분의 문제를 효과적으로 예방할 수 있다고 생각한다.

 

첫 번째는 Fetch Join을 명시적으로 사용하는 것.

Fetch Join은 N+1 문제를 해결하는 가장 대표적이고 간단한 방법이다. JPA 지연 로딩(LAZY)은 기본적으로 성능 최적화에 유력하지만, 조회 대상이 다수의 연관 엔티티를 포함할 때는 각 엔티티마다 추가 쿼리가 발생하며 N+1 문제가 발생하기 때문인데 이를 하나의 쿼리로 연관된 엔티티까지 함께 조회하는 Fetch Join을 사용하여 방지할 수 있다.

 

그렇지만, Fetch Join을 무조건 남용해서는 안된다. 1:N 관계를 Fetch Join 할 경우 결과가 곱집합(Cartesian Product)처럼 늘어나게되어 성능 저하나 ㅍㅔ이징 실패 등의 부작용이 발생되는 것을 많이 봐왔기 때문.

 

때문에 1:1, N:1 관계에서는 Fetch Join을 사용하고 1:N 관계에서는 필요한 경우에 QeuryDSL의 fetchJoin() + distinct() 조합이나 EntityGraph 등을 대체로 고려해야한다.

 

두 번째는 읽기 전용 연관관계를 통해 저장과 조회를 분리하는 등 Entity 설계를 신중히 하는 것. 

DB 저장은 colum 으로 처리하고 객체 참조는 Domain 객체로 읽기 전용 조회를 하는 것, 내가 선호하는 방식이다. 관계는 맺되, 쓰기 연산은 막아두어 예상할 수 없는 N+1을 방지하는 방법이다. 내 생각에는 이 방식이 연관관계를 완전히 없애지 않으면서도, 설계의 명확성과 성능 안정성을 동시에 추구할 수 있다.

class JpaComAttachFileEntity {
    @Id
    @Comment("FILE_ID")
    @Column(name = "file_id", columnDefinition = "BINARY(16)")
    private UUID fileId;
}

## 나는 되도록 연관관계를 맵핑지어 사용하지 않는다.
class JpaUserEntity {
    ## 무조건 User 데이터를 가져올 때 profileImageFile은 꼭 가져온다면 아래와 같이 사용한다.
    @Comment("프로필 이미지 FILE_ID")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_image_file_id", insertable = false, updatable = false)
    private JpaComAttachFileEntity profileImageFile;
    ## 아래 Image는 특정 API에서만 select함으로 연관관계를 설정하지 않는다.
    private UUID mainImageFileId;
    private UUID introImageFileId;
}

 

연관관계를 너무 객체지향적으로 맺는 것은 오히려 퍼포먼스를 망칠 수 있고 특히 이미지, 첨부파일, 로그 등과 같은 다양한 도메인에서 참조되는 엔티티는 연관관계를 맺지 않고 ID로만 들고 있도록 하고있다. 이는 단순히 N+1을 방지하기 위함이 아니라, 도메인의 독립성과 책임을 분리를 명확히 하려는 목적(이 개념은 DDD의 Aggregate Root간 ID 참조 규칙과도 맞닿아 있음)이기도 했다. 

 

세 번째로 쿼리 전용 DTO 혹은 Projection을 활용하는 것.

서비스/프론트에 필요한 데이터는 엔티티가 아닌 전용 DTO나 Projection으로 받아오는 것이 N+1 회피와 성능 측면에서 훨씬 유리하다.
Spring Data JPA에서는 Interface 기반 Projection 또는 Class 기반 DTO Projection이 모두 가능하다. QueryDSL 사용 시 더 유연한 DTO Projection이 가능하며, 성능 측정 도구와의 궁합도 좋다.

 

EntityGraph, BatchSize 등의 어노테이션 기능들을 활용해서 쿼리 정의 없이 특정 연관 엔티티를 함께 로딩하거나 여러 엔티티를 batch select 하는 방법 등도 있지만 나는 위의 세 가지 방법을 주로 활용한다.

 

내가 틀렸던 부분이 무엇일까

돌이켜보면, 그 팀장님의 반응은 "연관관계를 너무 배제하면 JPA의 철학을 무시하는 것 아닌가?" 라는 우려였을 수도 있겠다. 실제로 JPA의 철학은 객체 그래프 탐색을 통해 풍부한 도메인 모델을 표현하고, 애플리케이션 코드에서 관계를 따라가며 로직을 구현하는 것에 있을테니깐.

 

하지만 이 장점은 팀 전체가 ORM의 동작 방식, 영속성 컨텍스트, 지연 로딩 등 개념에 대한 공통된 이해를 바탕으로 할 때 비로소 빛을 발한다고 생각하고, 유지보수 난이도와 복잡도 증가를 감안하면 의도적인 ID 기반 저장과 제한적 조회 방식을 사용하는 현실적인 절충안이 충분히 가치 있는 선택이라는게 내 생각이다.

 

이번 대화를 계기로 다시 한 번 ORM과 연관관계의 본질에 대한 고민을 하게 되었다. 항상 느끼는 것이지만, 개발에 있어서 정답은 없는 것 같다. 마치 서울에서 부산으로 가는 방법이 수많은 것과 같이.

'생활 로그 > 회고록' 카테고리의 다른 글

나는 이제, 기술자가 되고 싶다  (4) 2025.07.04

댓글