본문 바로가기
  • 구름빵의 개발 블로그
Back-End/Spring Boot

[JPA] Spring Boot JPA를 통한 Entity 조회

by 은빛구름빵 2025. 10. 1.

개요

JPA를 사용해 데이터를 조회한다는 개념에 대해 정확히 알게 되어 기록하고자 한다. 지금까지 Spring Boot JPA를 사용함에도 불구하고 JPA의 기능을 완전히 사용하고 있지 않았다는 것을 알게 되었다.

주로 QueryDSL을 같이 사용하다 보니 양방향 참조를 거의 사용하지 않았으며, Entity를 정의할 때도 단방향 참조만을 사용해 정의했었다. 예를 들어, User와 Order Entity가 있다고 할 때 Order는 주문자 정보를 받아야 하기 때문에 User의 정보를 갖고 있어야 한다. 그렇기에 Order에는 User의 ID 값이 들어가도록 명시했다. 하지만 User 테이블에는 Order의 정보를 담을 이유가 없다. 그렇기에 따로 명시하지 않고 Order에서 User로 향하는 단방향 참조만 구현했다.

데이터베이스 테이블을 설계할 때는 이런 구성이 맞지만 이렇게만 만들면 JPA 기능을 완전히 사용하는 것이 아니었다. 만약 User ID가 71인 사용자의 주문 목록을 조회한다고 할 때, 지금까지는 QueryDSL을 통해 아래 SQL문과 같은 동작을 하는 동적 쿼리를 만들어서 사용했다.

SELECT * FROM orders WHERE user_id = 71;

 

private final QueryFactory factory;

BooleanBuilder condition = new BooleanBuilder();
condition.and( order.id.eq( 71 ) );

List<Order> results = factory
	.select( order )
    .from( order )
    .where( condition );
return results;

이런 식으로 작성해서 가져왔다. 하지만 이 방식은 JPA를 100% 활용하는 방법은 아니다. 결국 Query문을 사용해 조회하는 방법이다. 그럼 이 작업을 JPA를 활용해서 가져오는 방법은 무엇이 있는지 작성해보고자 한다.

 

다중성과 방향성의 의미

JPA는 어노테이션을 통해 Entity간의 다중성과 방향성을 표현할 수 있다. 그리고 어노테이션을 통해 현재 Entity에 참조하는 대상의 정보를 기록할지 안할지 정할 수 있다. 우선 Spring Boot JPA에서 다중성과 방향성을 위해 사용할 수 있는 어노테이션을 살펴보자.

 

다중성을 표현하는 어노테이션

다중성이란 자신과 참조하고자 하는 대상이 몇 대 몇 관계에 있는지 명시하는 것이다. 1대 1인지, 1대 다인지 n대 m인지 등을 표현할 수 있다.

// 1:1
@OneToOne

// 1:n
@OneToMany

// n:1
@ManyToOne

// n:m
@ManyToMany

이는 각 Entity 사이의 관계에 따라 무엇을 사용해야 하는지가 달라진다. 이를 확실하게 하기 위해 아래 4개의 예시 Entity를 가정해보았다.

User: 사용자 정보를 담은 Entity

Account: 계정 정보를 담은 Entity ( 한 명의 사용자는 하나의 계정만 가질 수 있다고 한다 )

Order: 주문 정보를 담은 Entity

Product: 상품 정보를 담은 Entity

 

1. User - Account 관계는 1대 1 관계

2. User - Order 관계는 1대 N 관계

3. Order - Product 관계는 N대 M 관계

이 때, 각 Entity는 다음과 같이 정의되어야 한다.

public class User {
	...
    // User-Account는 1:1 관계
    @OneToOne
    private Account account;
    
    // User-Order는 1:N 관계
    @OneToMany
    private List<Order> orders;
    
    ...
}

public class Account {
	...
    // Account-User는 1:1 관계
    @OneToOne
    private User user;
    ...
}

public class Order {
	...
    // Order-User는 N:1 관계
    @ManyToOne
    private User user;
    
    // Order-Product는 N:M관계
    @OneToMany
    private List<Product> products;
    ...
}

public class Product {
	...
    // Product-Order는 M:N 관계
    @OneToMany
    private List<Order> orders
    ...
}

위 코드와 같이 구성할 때 처음에 헷갈렸던 것은 왜 1:N에서도 OneToMany고 M:N에서도 OneToMany를 사용하는 지였다. 도대체 무슨 기준으로 구분을 저렇게 하는가, N:M이면 ManyToMany를 쓰면 되는거 아닌가? 라며 의문을 가졌다. 엄연히 말하자면 '가능'하기는 하다. 하지만 그렇게 사용하지 않는 이유가 N:M 관계의 경우 그 사이에서 추가적으로 필요한 정보가 생기는 경우가 많다고 한다. 그렇기에 실무에서 거의 사용하지 않고 N:M 구현을 위해서는 두 Entity 사이에 새로운 Entity를 하나 만들어서 사용하는 경우가 많다고 한다. 

`

|  User   | --- 1 -- 1 --> | Account |
|  User   | <-- 1 -- 1 --> | Account |
== User와 Account는 1대 1 관계

| User | --- 1 -- N --> | Order |
| User | <-- 1 -- 1 --- | Order |
== User와 Account는 1대 N 관계

| Order | --- 1 -- N --> | Product |
| Order | <-- M -- 1 --- | Product |
== Order와 Account는 M:N 관계

최종적으로 이렇게 정리할 수 있다.

즉, 우리가 코드에 명시하는 다수성은 '한 쪽의 입장에서 관계'를 정의하는 것이고,

멀리서 보는 두 Entity 사이의 관계는 받는 입장에서의 다수성을 합친 것이라고 생각하면 된다.

마지막 문장을 풀어서 말하면

User와 Order를 보았을 때 Order는 N으로 받지만 User는 1로 받는다. 그렇기에 User : Order = 1 : N이다.

Order와 Product를 보았을 때 Product는 N으로 받고 Order는 M으로 받는다. 그렇기에 Order : Product = M : N이다.

정확한 표현일지 모르겠지만 이렇게 외워야 헷갈리지 않게 외울 수 있었다.

 

방향성을 표현하는 어노테이션

관계형 데이터베이스에서는 서로 외래키를 통해 양쪽으로 모두 참조할 수 있으므로 방향성이 존재하지 않는다. 하지만 자바 객체 기준에서 보면 방향성이 존재한다. JPA는 데이터베이스의 데이터들을 자바 객체로써 다루는 ORM 프레임워크이기 때문에 이 방향성을 방향성이 없는 데이터베이스에서 명시할 수 있게 해주어야 한다. 이를 위해 한 쪽의 Entity 테이블에만 이 외래키를 명시해주는 방법으로 방향성을 표현해준다.

@JoinColumn

이 어노테이션을 명시해주면 해당 Entity에 참조하는 대상의 PK 값을 하나의 외래키 필드로 갖게 된다.

@Entity
public class Order {
	@Id	
    @GeneratedValue( strategy=GenerationType.IDENTITY )
    private Long id;
    
    @ManyToOne( fetch=FetchType.LAZY )
    @JoinColumn( name="user_id" )
    private User user;
    ...
}

@Entity
public class User {
	@Id
    @GeneratedValue( strategy=GenerationType.IDENTITY )
    private Long id;
    
    @OneToMany( fetch=FetchType.LAZY, mappedBy="user" )
    private List<Order> orders = new ArrayList<>();
    
    ...
}

위처럼 두 개의 Entity가 있다면 Order 테이블에는 user_id라는 필드가 생성되고 User의 PK인 id를 참조하게 되지만 User 테이블에는 Order에 대한 정보가 명시되지 않게 된다.

이렇게 JoinColumn 어노테이션을 통해서 명시를 해주며 관계형 데이터베이스에서 방향성을 어느정도 묘사할 수 있다.

 

Q. Entity를 만들 때 테이블에 저장되지 않을 필드에 대해서 굳이 명시해주어야 할까?

위 예시를 연장해서 더 살펴보면, User Entity를 통해 만들어지는 테이블에는 order에 대한 정보가 일절 저장되지 않을 것이다. 근데 그러면 굳이 Entity에 @OneToMany를 사용해가며 orders 필드를 만들어야 할까? 이를 만드는 이유는 객체를 통해 데이터를 가져오는 JPA의 데이터 조회를 사용할 수 있게 만들기 위함이다. 만약 우리가 User의 Order 목록을 조회해야 한다고 하면 어떻게 해야 할까? 여기서 떠올렸던 방법이 개요에서 말한 방법이다. WHERE id = 71; 과 같은 Query WHERE문을 통해 조회하는 방법인데, 이 방법은 JPA를 온전히 사용하는 방법이 아니다. 이 온전히 JPA를 사용해서 조회하기 위해 사용할 참조 객체를 만들어 놓기 위해 이렇게 테이블에 저장되지 않을 참조도 만들어 주는 것이다.

 

JPA를 사용한 객체 조회를 위한 참조

그럼 이제 본격적으로 JPA의 자식 요소 조회에 대해 알아보도록 하자.

JPA에서는 양방향으로 Entity를 참조해 서로를 가져올 수 있다. 참조한 대상을 사용할 때 Query를 사용하는 것이 아닌 객체를 사용하는 방법으로 데이터를 다룰 수 있으며, 데이터베이스에는 이 정보를 남기지 않고 관계형 데이터베이스의 규칙을 준수하며 사용할 수 있다.

@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class User {
	@Id
    @GeneratedValue( strategy=GenerationType.IDENTITY )
    private Long id;
    
    private String name;
    
    @OneToMany( fetch=FetchType.LAZY, mappedBy="user" )
    private List<Order> orders = new ArrayList<>();
    
}

위처럼 User Entity가 만들어져 있다고 하자. 눈여겨보아야 할 부분은 Order를 참조하는 부분이다.

1명의 유저는 여러 개의 Order를 가질 수 있기 때문에 OneToMany 어노테이션을 사용한다. 어노테이션 속성에 보면 fetch와 mappedBy가 있는데, 이 의미는 다음과 같다.

fetch: 조회를 하는데 사용할 로딩 방법이다. LAZY를 선택했으니 이 코드에서는 지연 로딩을 사용하고 있다.

mappedBy: Order의 어떤 정보를 자기 자신에 매핑할지 지정하는 부분이다. Order Entity에서 User는 user라는 필드명으로 참조되고 있기 때문에 user라고 작성해준다.

 

@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class Order {
	@Id
    @GeneratedValue( strategy=GenerationType.IDENTITY )
    private Long id;
    
    private LocalDateTime orderedAt;
    
    @ManyToOne( fetch=FetchType.LAZY )
    @JoinColumn( name="user_id" )
    private User user;
    
    public void setUser( User user ){
    	this.user = user;
    	user.getOrders().add( this );
    }
    
}

Order에 보면 위에서 말했듯이 User 객체는 user라는 이름으로 참조가 되고 있다. Order와 User는 N:1 관계이기 때문에 ManyToOne을 사용하는 것이 맞고, Order 테이블에는 주문자에 대한 정보를 남기기 위해 User의 ID를 남길 것이므로 JoinColumn을 사용해주었다. 이 때 JoinColumn 어노테이션의 속성인 name은 테이블에 참조 객체의 PK를 어떤 필드명으로 저장할 지 지정해주는 부분이다. 실제 테이블에 보면 user_id라는 필드에 User의 PK 값인 id가 저장된다.

밑에 보면 setUser()라는 메서드를 만들어주었다. 이는 참조 관계 메서드로 서로 참조 관계일 때, 두 객체가 동일한 데이터를 보유할 수 있도록 만들어주는 메서드이다.

setUser()는 user 객체에 대한 setter 메서드이다. this.user = user; 를 통해 입력 받은 User 객체를 이 객체의 user로 지정하는 것은 동일하다. 다만, 다음 작업이 중요하다. User Entity는 orders라는 필드를 갖는다. 이 필드는 Getter에 의해 getOrders()로 조회될 수 있다. 이 getOrders()를 하면 List<Order> orders를 가져올 수 있다. Order Entity에서 User가 set이 되면 해당 User의 Order가 하나 늘었다는 의미이다. 그렇기에 User의 order 목록에는 이 Order가 추가되어야 한다. 그렇기에 user의 orders를 가져와 this ( 해당 메서드를 사용한 Entity, 즉 Order Entity )를 추가하게 된다. 

그렇게 되면 이후 User를 가져와 getOrders()를 하면 새로 추가된 Order가 추가된 List 목록을 볼 수 있게 된다.

 

결론

위 방법을 사용하면 실제로 객체의 Getter를 통해 데이터를 가져오기 때문에 DB에 Query를 사용하지 않고 데이터를 가져올 수 있다. 이처럼 네이티브 Query문을 사용할 일을 줄이고 객체로서 데이터를 다루는 것이 중요한 JPA에서 이 기능을 얼마나 잘 활용할 수 있는지가 JPA를 얼마나 잘 사용하는지를 가르는 중요한 능력일 것 같다.