@OneToOne 연관에서 발생한 N+1 문제를 해결하자

2025. 1. 17. 01:02·💻 개발

이번에는 N+1 문제를 가져왔습니다.

1. 하려는 것

이전에 프로젝트(코지메이트)에서 Sentry의 기능을 써보면서 모든 Query를 추적하게 했는데요. 적용을 하자마자 효과를 보고 있습니다.

2일도 지나지 않아서 서버 내에서의 쿼리로 인한 문제를 찾아냈기 때문이죠. (추가하길 잘했네요!)

 

자고 일어난 저는 에러 알림을 걸어둔 디스코드에서 다음의 에러를 보게 됩니다.

N+1이 발생했다고 알려주는 Sentry

이렇게 알림까지 보내준 이상 가만둘 수 없죠. 분석하고 해결까지 해봤답니다.

 

참고) Sentry 로그

Sentry에서 확인할 수 있는 정보

Sentry에서 확인할 수 있는 로그입니다. 어떤 쿼리가 발생했고, 어떻게 N+1이 발생했는지 확인할 수 있습니다. 자세히 보면 한군데에서 발생한게 아니라, 두 군데인것을 대충 알 수 있답니다....ㅎ

 

쿼리에 대해 이제라도 열심히 관심을 가져보도록 하겠습니다.

 

2. N+1 문제가 뭔가요?

N+1 문제는 데이터베이스에서 연관된 데이터를 로딩할 때 발생할 수 있는 문제입니다. RDBMS 내에서는 연관관계를 통해서 데이터를 효율적으로 관리하고 처리하게 되는데요. 

 

1. 서버에서 데이터베이스에 접근해 특정 객체를 조회

2. 가져온 객체에 연관된 객체가 N개 존재하고, 해당 연관 객체에 접근해야하는 상황이 발생

3. 연관된 객체도 조회해야하는데, 이 때 Select를 N개의 연관 객체에 대해서 각각 쿼리 실행

4. 개발자의 입장에서는 1번에서 객체 1개를 조회했을 뿐인데, 실행 흐름상 문제로 1(기존) + N(추가)번의 쿼리를 실행하게 되는 문제

 

이런 순서로 진행되어 불필요한 쿼리 실행이 다량 발생하는 경우를 N+1 문제라고 합니다.

추가적인 자료는 여기서 잘 설명하고 있는 것 같으니 참고해주세요!

 

3. N+1 문제를 해결해야 할 필요성

 

  • N+1 문제는 데이터가 많아질수록 쿼리를 실행하는 횟수가 증가합니다.
  • 그대로 둔다면 연쇄적인 문제를 발생시킵니다.
    • 쿼리 실행 횟수 증가 → 데이터베이스 부하 증가 → API 응답시간 증가 → 서버 부하 증가
  • 따라서 실제 서비스를 배포하기 전 문제를 해결할 필요가 있습니다.

 

4. 문제 발생 원인 분석

위에서 말했다시피 두 종류의 N+1 문제가 발생했습니다.

 

Sentry에서 본 데이터를 바탕으로 다시 재현을 해보았고, 동일한 결과를 얻을 수 있었습니다.

맨 위 쿼리 하나만 실행시켰을 뿐인데, 12개의 쿼리가 추가로 실행되었따...

SELECT r FROM Room r
WHERE r.roomType = :roomType
AND r.status != :status
AND r.maxMateNum > r.numOfArrival

위 쿼리 하나를 실행시켰을 뿐인데, 반환된 List<Room>에 대해 Feed를 각각 가져오고, RoomHashtag랑 Hashtag테이블까지 가져오는 쿼리가 엄청나게 실행되었음을 볼 수 있습니다.

 

지금이야 필터링된 쿼리로 가져온 방이 6개뿐이라 성능에 영향은 없었지만, 방이 하나씩 늘어날 때마다 2개의 쿼리가 추가로 발생할 것이었다는 사실에 '그동안 실행되는 쿼리를 너무 대충 확인했구나...'하는 반성을 하게 되었습니다.

 

1) @OneToOne에서 발생한 N+1

우선 Room과 Feed의 관계를 살펴봤습니다. 관련된 엔티티 코드만 가져와보면 아래와 같습니다.

@Entity
public class Room {

    @Id
    private Long id;

    @OneToOne(cascade = CascadeType.ALL, mappedBy = "room")
    private Feed feed;
    
}

@Entity
public class Feed {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "room_id", referencedColumnName = "id")
    private Room room;

}

두 엔티티는 @OneToOne으로 1대 1 연관을 맺고 있는 것을 알 수 있습니다.

 

여기서 문제가 되는 부분은 누가 FK를 가지고 있는지에 대한 것인데요. @OneToOne 이기 때문에 언듯 보면 양방향에서 모두 서로의 FK를 관리할 수도 있지 않을까 싶지만, 이 두 엔티티중에서 FK를 관리하는 것은 하나밖에 존재할 수 없기 때문에 둘 중 하나의 엔티티는 FK를 가지지 않고 있답니다.

 

여기서 FK를 가지고 있는 엔티티는 Feed입니다. Room 엔티티에 있는 Feed는 mappedBy로 Feed의 room에 매핑되어있음을 설정해주고있죠. 그래서 Room에서 Feed에 접근할 때에는 쿼리를 통해서 해당 값이 있는지를 확인하고 접근하게 됩니다.

 

그리고 바로 이 부분이 N+1 문제가 발생한 이유가 되는 것이죠. 이렇게 Room 엔티티에서 Feed에 대한 FK가 없기 때문에 아무리 FetchType.LAZY를 적용해서 Feed에 대한 프록시 객체를 받아온다고 해도, Room 입장에서는 이 Feed 객체가 null인지, 아니면 실제로 존재할 객체일지 확인이 필요한겁니다. null이면 프록시 객체를 생성하지 않고 바로 null을 반환하는 구조로 동작하기 때문입니다.

 

FK가 없는 노예 엔티티의 값을 가져올 때 FK가 null인지 확인하기 위해 주인 엔티티에 쿼리를 날리게 된다.

 

그래서 노예인 Room의 입장에서는 List<Room>을 불러왔을 때 Feed에 대한 프록시 객체를 가져와야 하는데, 해당 객체가 실제로 존재하는 값인지 List<Room>에서는 알 수 없기 때문에 쿼리를 통해서 가져오게 되는거에요. (N+1의 발생 원인)

 

2) FetchType.EAGER일 때 JPQL을 사용해서 발생한 N+1

이제 다음 문제인 Room과 RoomHashtag를 살펴봤습니다.

@Entity
public class Room {

    @Id
    private Long id;
    
    @OneToMany(fetch = FetchType.EAGER, mappedBy = "room")
    private List<RoomHashtag> roomHashtags;
    
}


@Entity
public class RoomHashtag {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Room room;

    @ManyToOne(fetch = FetchType.EAGER)
    private Hashtag hashtag;

}

@Entity
public class Hashtag {

    @Id
    private Long id;

    private String hashtag;

}

 

 

 

Room과 RoomHashtag, Hashtag까지 연관된 엔티티를 모두 가져왔습니다.

 

List<Room>을 호출했을 때 RoomHashtag가 Hashtag 엔티티와 Join되어 쿼리가 추가로 실행되었습니다. Hashtag까지 실행된 것은 RoomHashtag에서 Hashtag에 대한 연관이 FetchType.EAGER로 되어있기 때문에 이 부분은 의도한대로 잘 흘러갔습니다.

 

하지만 Room을 조회했을 때 RoomHashtag를 조회하는 쿼리가 추가 실행되는 것은 의도치 않은 부분인데요. 기존에 EAGER로 설정되어있기 때문에, Join으로 한번의 쿼리로 가져올 것이라고 생각했었습니다. 여기서 문제는 Repository에서 JPQL을 사용한 것에 있습니다.

 

List<Room>을 가져올 때 조건은 3가지가 있었습니다. 필터링 조건이 길어지면 JPA함수의 이름도 길어져서 JPQL로 바꿔 쿼리를 구성했었는데요. 바로 JPQL로 구성하면서 발생하게 된 것이었습니다.

    @Query("""
        SELECT r FROM Room r
        WHERE r.roomType = :roomType
        AND r.status != :status
        AND r.maxMateNum > r.numOfArrival
        """)
    List<Room> findAllRoomListCanDisplay(@Param("roomType") RoomType roomType,
        @Param("status") RoomStatus status);

 

이렇게 조건을 통해 List<Room>을 가져오는데, Room의 객체를 가지고와봤더니 FetchType.EAGER인 객체 연관이 있어서 해당 엔티티 또한 조회하기 위해 추가적인 쿼리가 발생하게 된 것이죠.,

결국 이건 FetchType.LAZY로 수정해서 해결되긴 했습니다... 동일한 구간에서만요.. 이후 진행되다가 중간에 또 N+1의 쿼리로 RoomHashtag의 값을 가져오는 현상이 생겼는데, 이 부분은 제 담당 함수가 아니었기에 임시방편으로 JPQL을 수정하는 것으로 결정했습니다.

6. 적용 과정

 

이제 원인 분석도 했으니 코드를 수정하겠습니다. 우선 엔티티 구성은 다음과 같이 수정했습니다.

@Entity
public class Room {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "feed_id", referencedColumnName = "id") // 외래 키 관리
    private Feed feed;
    
    @OneToMany(fetch = FetchType.EAGER, mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<RoomHashtag> roomHashtags;
    
}

@Entity
public class Feed {

    @Id
    private Long id;

    @OneToOne(mappedBy = "feed")
    private Room room;


@Entity
public class RoomHashtag {

    @Id
    private Long id;

    @ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
    private Room room;

    @ManyToOne(fetch = FetchType.EAGER)
    private Hashtag hashtag;

}

변경사항은 Room과 Feed의 관계에서 주종관계를 변경해준 것이 전부입니다.

기존의 코드에서는 Room에서 Feed 객체에 mappedBy로 노예의 모습을 보여주고 있었는데, 지금은 Room이 FK를 가지고 있는 모습입니다.

 

이제 Repository 변경입니다. EntityGraph를 사용했습니다.

    @EntityGraph(attributePaths = {"roomHashtags", "roomHashtags.hashtag"})
    @Query("""
        SELECT distinct r FROM Room r
        WHERE r.roomType = :roomType
        AND r.status != :status
        AND r.maxMateNum > r.numOfArrival
        """)
    List<Room> findAllRoomListCanDisplay(@Param("roomType") RoomType roomType,
        @Param("status") RoomStatus status);

동일한 코드에서 EAGER로 가져오는 객체들을 EntityGraph를 사용해서 FetchJoin과 같은 효과를 주도록 했습니다.

이렇게 하면 미리 데이터를 다 가져오기 때문에 해당 객체에 대한 추가적인 쿼리를 날리지 않게 됩니다.

 

7. 결과

N+1 문제 해결 완료


이렇게 해결하고 난 뒤에 로그에 찍히는걸 보니 편안해졌습니다. 응답 시간도 감소하긴 했겠지만, 현재 데이터가 많지 않은 상황이라 정말 미세한 차이만 있을 것으로 예상되기 때문에 굳이 테스트를 해보지는 않겠습니다.

 

이렇게 이번에는 막연히 이런 문제도 발생할 수 있구나 하고 생각했던 N+1에 대해서 한번 분석해보고, 해결하는 과정까지 해보았습니다.

해당 문제가 충분히 발생할 수 있음을 인지하고 있기도 했고, 무슨 문제인지도 대강 알고 있었지만 역시 실제로 겪고 해결하는 과정이 훨씬 얻어가는게 많은 것 같습니다.

 

앞으로는 함수를 실행해보면서 N+1로 의심되는 쿼리 실행을 계속 체크해가면서 더욱 효율적인 API 실행 환경을 구성할 수 있도록 노력해야겠습니다.

'💻 개발' 카테고리의 다른 글

블루-그린 무중단 배포에서 Downtime을 측정해보자  (0) 2025.05.12
프리티어에서 블루-그린 무중단 배포를 해보자  (0) 2025.03.16
FastAPI 프로젝트에 Ruff 포맷팅을 적용해보자  (0) 2025.02.03
Spring Boot에서 DB에 초기 데이터를 넣어보자  (0) 2025.02.02
Spring Boot에서 Sentry가 쿼리를 추적하게 해보자  (1) 2025.01.13
'💻 개발' 카테고리의 다른 글
  • 프리티어에서 블루-그린 무중단 배포를 해보자
  • FastAPI 프로젝트에 Ruff 포맷팅을 적용해보자
  • Spring Boot에서 DB에 초기 데이터를 넣어보자
  • Spring Boot에서 Sentry가 쿼리를 추적하게 해보자
가디(Gadi)
가디(Gadi)
dev-gadi 님의 블로그 입니다.
  • 가디(Gadi)
    진짜 개발용 블로그
    가디(Gadi)
  • 전체
    오늘
    어제
    • 분류 전체보기 (9)
      • 💻 개발 (6)
      • 🤔 회고 (3)
  • 태그

    BLACK
    jmeter
    트러블슈팅
    마무리
    isort
    티끌
    FastAPI
    downtime
    Query
    formatting
    CI/CD
    Ruff
    AWS
    회고
    log
    글또
    2025
    CLF
    Python
    SQL
    측정
    N+1
    쉘 스크립트
    코지메이트
    무중단 배포
    블루-그린
    springboot
    Sentry
    자격증
  • 공지사항

  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
가디(Gadi)
@OneToOne 연관에서 발생한 N+1 문제를 해결하자
상단으로

티스토리툴바