2026. 4. 24.·base·
JPA 연관관계와 fetch join, 그리고 LAZY 로딩
JPA를 알아보자
JPA를 처음 공부할 때 가장 헷갈리는 지점 중 하나는, 분명 데이터베이스에는 user_id 같은 외래키만 들어 있는데 왜 코드에서는 comment.user처럼 필드에 객체가 들어가냐는 부분이라고 생각이 든다.
이걸 이해하려면 먼저 JPA가 SQL처럼 테이블을 직접 다루는 방식이 아니라 엔티티 객체와 엔티티 사이의 관계를 다루는 방식이라는 점을 기억해야한다.
예를 들어 댓글 테이블이 아래처럼 생겼다고 하자.
comment
-------
id
content
user_id
post_id
created_atDB 관점에서는 댓글이 작성자를 가리키기 위해 user테이블의 PK인 user_id를 외래키로 가지고 있다.
그런데 JPA 엔티티는 보통 이렇게 작성한다.
@Entity
class Comment(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,
val content: String,
val createdAt: LocalDateTime = LocalDateTime.now(),
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
val user: User,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
val post: Post
)엔티티 클래스에는 userId: Long 같은 필드가 없고 user: User처럼 엔티티 타입을 그대로 참조하는 필드가 있다.
이건 JPA가 comment 테이블의 user_id 컬럼을 보고 "이 값은 User 엔티티와 연결되는 외래키구나"라고 이해한 뒤, 코드에서는 숫자 대신 객체 참조로 다루게 만들었기 때문이다.
JPA는 테이블과 컬럼을 그대로 코드에 드러내기보다, 객체 그래프를 따라가듯 comment -> user , comment -> post 처럼 접근하게 해준다.
그럼 여기서 헷갈리는 부분이 하나가 있는데, findById같은 레포지토리 메서드로 Comment 엔티티 인스턴스를 가져오면 그 안에 User 타입의 필드는 어떻게 채워져서 comment.user로 접근할 수 있게되는걸까?
여기서 알아야될 것은 해당 user필드가 실제 User 데이터가 채워진 객체인지, 아니면 나중에 필요할 때 불러오기 위한 프록시인지이다.
아래와 같은 코드가 있다고 하자.
interface CommentRepository: JpaRepository<Comment, Long>그리고 서비스에서 이렇게 댓글 하나를 가져올때
val comment = commentRepository.findById(1L).orElseThrow()
println(comment.content)
println(comment.user)
println(comment.user.name)엔티티 정의에서 fetch = FetchType.LAZY라고 해두었다면 comment테이블을 조회하는 순간에는 Comment만 읽어오고 user는 실제 User 객체가 아닐 수 있다.
comment.user는 코드상 존재하지만 내부적으로는 프록시 객체일 수 있고 comment.user.name처럼 진짜 필드에 접근하는 순간 그때 별도의 SQL로 User를 조회하게 되는데 이것이 지연로딩이다.
다시말해 LAZY는 "연관 객체 필드가 아예 없는 상태"가 아니라 프록시로 잠깐 떼워 "필드는 연결되어 있지만 실제 데이터는 필요할 때 가져오겠다는"의미이다.
이 구조는 댓글 하나를 조회하는 정도의 간단한 방식에서는 큰 문제를 발생시키지 않지만 전체 댓글 목록불러오기처럼 여러 엔티티를 한 번에 다룰 때 문제가 발생할 수 있다.
예를 들어 어떤 게시글의 댓글 목록을 가져온 뒤, 각 댓글 작성자의 이름을 같이 보여주고 싶다고 해보자.
val comments = commentRepository.findAllByPostId(postId)
comments.forEach {
println("${it.content} - ${it.user.name}")
}만약 findAllByPostId()가 그냥 Comment만 가져오고 user는 지연로딩상태라면 먼저 댓글 목록을 가져오는 쿼리가 한 번 나가고 그 다음에 반복문에서 각 댓글마다 it.user.name에 접근을 할 때에 User를 따로 조회하는 쿼리가 추가로 나갈 수 있다.
댓글이 10개면 처음 댓글 목록 조회 1번, 작성자 조회 10번, 총 11번이 된다.
이런 문제를 줄이기 위해 등장하는 것이 fetch join이다.
@Query("""
select c
from Comment c
join fetch c.user
where c.post.id = :postId
order by c.createdAt asc
""")
fun findAllByPostIdWithUser(postId: Long): List<Comment>여기서 join fetch는 단순한 join이 아니라 JPA가 Comment를 조회할 때 연관된 User도 같은 쿼리에서 함께 로딩하라는 뜻을 갖는다.
내부적으로 이런 SQL이 만들어진다고 이해하면 된다.
select c.*, u.*
from comment c
join user u on c.user_id = u.id
where c.post_id = ?
order by c.created_at asc즉 데이터베이스에는 원래 comment.user_id만 저장되어 있지만, JPA는 그 외래키와 @ManyToOne, @JoinColumn(name = "user_id") 매핑 정보를 이용해서 User 테이블과 조인하고, 최종적으로는 아래와 비슷한 상태의 객체를 만들어낸다.
Comment(
id = 1L,
content = "좋은 글이네요",
createdAt = ...,
user = User(
id = 10L,
name = "kim"
),
post = Post(...)
)결국 DB에서 조회할 때 많은 데이터를 가져와야하니까 또이또이아닌가라고 생각이 들 수 있지만 DB입장에서는 "한 번의 큰 조회"와 "여러 번의 작은 조회"의 비용이 크게 차이난다.
SQL이 1번으로 처리되고 네트워크 왕복도 마찬가지로 1번이므로, 댓글 목록을 조회하고 댓글 개수만큼 쿼리를 매번보낼때와 훨씬 차이가 날 수 밖에 없는 것이다.
Join the thread
Leave feedback, ask for clarification, or keep a focused discussion attached to this article.