Back-end/Spring

[Spring] Spring Boot + MySQL 연동 & JPA 활용 심화 (2)

랑 이 2026. 1. 3. 23:17
반응형

이전 포스팅에서 Spring Boot와 MySQL 연동하여 프로젝트 실행 후 출력되는 로그에 대해서 살펴봤다면 이번에는

JPA를 실제 프로젝트에서 어떻게 사용되고 활용하는지에 대해서 작성해보려고 합니다

 

이번 글에서는 간단한 게시판 프로젝트를 예제로 사용하여 설명합니다

엔티티 설계 방식

엔티티는 DB 테이블과 매핑되는 클래스@Entity 어노테이션이 붙은 객체를 의미한다

Spring에서는 JPA를 사용할 때 DB 테이블을 자바 클래스로 설계하고 관리하기 위해 주로 사용된다

 

그런데 엔티티를 "테이블을 자바 클래스로 옮긴 것" 정도로 생각하기 쉬운데 실제로는 JPA가 상태를 관리하는 객체 입니다

JPA는 엔티티를 영속성 컨텍스트에 올려두고, 트랜잭션 동안 엔티티의 상태 변화를 추적하여 커밋 시점에서 필요한 SQL을

생성한다

 

엔티티는 값이 아닌 상태와 생명주기를 가진 객체이며 엔티티 설계는 시스템 안정성과 유지보수성,성능까지 영향을 끼친다

엔티티를 설계할 때 어떤 점을 주의하면서 설계해야 하는지 설명하려고 합니다

1. 엔티티는 Setter가 아닌 메서드로 변경하도록 설계한다

엔티티는 Setter 없이, 의도를 가진 메서드로만 상태를 변경하고, 저장은 JPA 변경 감지(Dirty Checking)에 맡긴다

 

Setter를 사용하게 되면 엔티티는 어디서든 값이 변경이 가능하게 되며 어떤 이유로 변경됐는지 추적하기 어려워진다

user.setNickname("A");
user.setRole(ADMIN);

이런 식으로 Service 단에서 닉네임,권한을 변경하는 코드를 작성을 했을 때 규모가 커졌을 때 다음과 같은 문제점이 발생할 수 있다

 

- 닉네임 변경이라는 의도가 코드에 남지 않는다

- 어디에서 값이 변경되었는지 확인하기 쉽지 않다

 

그래서 주로 Setter를 사용하는 대신 엔티티에 메서드를 정의하여 사용한다

@Entity
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String nickname;

    @Enumerated(EnumType.STRING)
    private Role role;

    // 의미 있는 상태 변경 메서드
    public void changeProfile(String nickname, Role role) {
        this.nickname = nickname;
        this.role = role;
    }

    // 역할만 변경하는 경우
    public void changeRole(Role role) {
        this.role = role;
    }
}

 

이렇게 설계하면 메서드 이름 자체가 작업 의도를 보여주고,내부에서 검증도 같이 처리할 수 있다

2. 생성자를 통제하여 설계 + 정적 팩토리/Builder

JPA는 엔티티 생성에 기본 생성자를 요구한다

그래서 기본 생성자(@NoArgsConstructor)가 사실상 필수로 사용되는데 중요한 점은 접근 제어입니다

@NoArgsConstructor(access = AccessLevel.PROTECTED)

- JPA는 내부적으로 엔티티 생성 가능

- 외부에서는 기본 생성자로 객체 생성을 막을 수 있다

 

엔티티 생성은 정해진 경로로만 생성할 수 있게 됩니다

그래서 주로 아래처럼 정적 팩토리 메서드를 정의하여 사용하는 방식을 많이 사용되고 있습니다

public static User create(...) { ... }

회원 생성이라는 행위가 코드에 드러나게 되며 기본값을 안전하게 설정이 가능하고 엔티티 생성 시 지켜야 할 규칙을 한 곳에서 관리가능하기 때문에 좋은 설계 방향이라고 볼 수 있습니다

 

Builder 패턴도 함께 사용 가능 합니다

파라미터가 많아질 때 Builder 패턴을 사용하면 가독성을 크게 높여주기 때문에 많이 사용하는 편입니다

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Builder(access = AccessLevel.PRIVATE)
    private User(String email, String nickname, Role role) {
        this.email = email;
        this.nickname = nickname;
        this.role = role;
    }

    public static User create(String email, String nickname) {
        return User.builder()
                .email(email)
                .nickname(nickname)
                .role(Role.USER) // 기본값 보장
                .build();
    }
}

- Builder는 엔티티 내부에서만 사용

- 외부에서는 정적 팩토리 메서드를 통해서만 생성

3. Getter를 사용하여 읽기 전용 접근 처리하기

JPA에서 @Getter는 필수가 아니지만 사용하게 되면 필드 접근 방식으로도 엔티티값을 조회할 수 있기 때문에 사용됩니다

 

엔티티를 JPA 내부에서만 사용되는 객체가 아닌 애플리케이션 전반에서 읽기 용도로 자주 사용되기 때문에 대부분의

엔티티에서 Getter 메서드를 사용한다 여기서는 Lombok의 어노테이션 @Getter를 사용하여 구현하였다

@Entity
@Getter
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String nickname;

    @Enumerated(EnumType.STRING)
    private Role role;
 }

엔티티 객체는 외부에서 조회는 가능하지만 변경은 오직 내부 메서드를 통해서만 가능하게 된다

4. 연관관계 매핑 설정

연관관계 매핑은 DB 테이블 간의 외래 키를 자바 객체 간의 참조 관계로 표현한 것을 의미한다

연관관계를 매핑할 때는 설정해야 하는 것이 존재하는데 크게 5가지로 나눌 수 있습니다

 

- 관계의 방향: 단방향 / 양방향

- 관계의 종류: 1:1, 1:N, N:1, N:M

- 외래키 관리 엔티티: 연관관계의 주인

- 로딩 전략: LAZY,EAGER

- 생명주기 설정: cascade 옵션

 

4-1. 연관관계의 방향 설정

엔티티에서 다른 엔티티로 객체 탐색이 가능한 방향을 의미하는데 단방향,양방향이 존재한다

 

단방향 연관관계

한쪽 엔티티만 다른 엔티티를 참조하는 구조
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

 

 

현재 엔티티가 User 엔티티를 참조하는 형태로 게시글 등 특정 사용자를 기준으로 생성되는 데이터를 표현할 때 사용된다

이 구조의 특징은 다음과 같다

 

- 현재 엔티티만 User 엔티티를 참조 가능

- User 엔티티는 현재 엔티티를 전혀 알지 못함

- 데이터베이스에서 현재 엔티티의 테이블에 User 기본키를 참조하는 외래키가 생성된다

 

단방향 연관관계는 외래 키를 가진 쪽에서만 객체 참조를 가지는 구조로 설계가 단순하고 기본적으로 사용된다

초기 설계 단계에서는 단방향으로 시작하고 요구사항에 따라 양방향 연관관계로 확장하는 게 일반적이다

 

양방향 연관관계

다른 두 엔티티가 서로를 참조하는 구조
// Board
@OneToMany(mappedBy = "board")
private List<Reply> replies;

// Reply
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;

한쪽에서만 참조하며 한쪽에서만 객체 탐색이 가능한 단방향과 달리 양쪽 엔티티 모두에서 상대 엔티티에 접근이 가능한 구조이다

Reply 엔티티가 Board 엔티티를 참조, Board 엔티티가 자신에게 달린 Reply 목록을 리스트 형태로 관리한다

 

Reply 엔티티에 @ManyToOne을 설정하는 이유는 데이터베이스 관점에서 살펴보면

외래 키(board_id)는 댓글 테이블(REPLY)에 존재하기 때문에 외래 키를 가지고 있는 Reply 엔티티에 설정하는 것이 자연스럽다

 

Board 쪽의 @OneToMany는 외래 키를 직접 관리하지 않는 읽기 전용 연관관계이다

이런 연관관계가 필요한 경우는 다음과 같다

 

- 게시글을 기준으로 댓글 목록을 자주 조회할 때

- 게시글 상세 화면에서 댓글 리스트를 같이 조회해야 할 때

 

이러한 요구사항에 주로 양방향 연관관계를 사용한다

 

그 외 자세한 사항은 이전에 포스팅한 JPA 어노테이션 정리에 다룬 내용과 동일하여 생략하고 로딩 전략에 대해 자세하게 다룹니다

 

[Spring] Spring Data JPA 어노테이션 정리 (초기화 전략,연관관계,Repository)

Spring의 대표적인 ORM Spring Data JPA에 대해서 작성해보려고 합니다 먼저 ORM은 객체 지향 프로그래밍 언어의 객체와 관계형 데이터베이스의 테이블 간의 데이터를 자동으로 변환해 주는 기술입니다

white63ser.tistory.com

4-2. 로딩 전략 (Fetch Strategy)

JPA에서 로딩 전략이란 연관된 엔티티를 언제 데이터베이스에서 조회할 것인지를 결정하는 설정이다

연관관계 매핑에서 JPA는 두 가지 로딩 전략을 제공하고 있습니다

 

LAZY (지연 로딩)

연관된 엔티티를 실제로 접근하는 시점에 조회하는 방식
@ManyToOne(fetch = FetchType.LAZY)
private User user;

엔티티를 조회할 때는 자기 자신만 조회하고

해당 객체에 접근하면 추가 쿼리를 실행하는 동작 구조를 가지고 있습니다

Board board = boardRepository.findById(id);
board.getUser(); // 이 시점에 User 조회 쿼리 실행

 

EAGER (즉시 로딩)

엔티티를 조회하면 연관된 엔티티를 함께 로딩하는 방식
@ManyToOne(fetch = FetchType.EAGER)
private User user;

현재 엔티티만 조회하기만 해도 연관된 엔티티를 함께 로딩하는 동작 구조를 가지고 있습니다

이 과정에서 JOIN 또는 추가 쿼리가 발생할 수 있습니다

Board board = boardRepository.findById(id);

 

EAGER 로딩 전략은 엔티티 조회 시 연관 엔티티까지 자동으로 로딩되기 때문에 의도치 않은 추가 쿼리가 발생할 수 있다

이러한 이유로 연관관계의 로딩 전략을 LAZY로 설정하고 필요한 시점에만 연관된 엔티티를 조회하는 방식을 주로 사용합니다

 

또한 조회 시점에서 코드로 처리하는 게 아닌 JPQL을 통해 SQL을 작성하여 JOIN을 제어한다면

불필요한 쿼리를 줄여 성능을 개선할 수도 있습니다

N + 1 문제란? (문제 해결 및 JPQL을 통한 성능 개선) 

다음 코드는 특정 사용자가 작성한 게시글을 조회하는 로직입니다

List<Board> boards = boardRepository.findAll();

boards.stream()
      .filter(b -> b.getUser().getId().equals(userId))
      .toList();

JPA와 Java의 컬렉션 API를 활용하여 처리하는 로직인데 기능적으로 정상 작동하게 된다

하지만 게시글이 많아지고 동시 접속자 수가 늘어나는 상황에서는 성능 저하가 발생할 수 있는 가능성이 높습니다

 

해당 로직을 순서대로 보면 다음과 같다

 

조건에 상관없이 모든 게시글 데이터를 데이터베이스에서 한 번에 모두 가져온다

 

여기서 문제는 조건에 맞지 않는 게시글까지 포함하여 모두 가져오는 쿼리가 발생하게 되면서 네트워크 전송 및 메모리 사용이 증가한다 물론 사용자가 얼마 없는 애플리케이션에서는 괜찮을 수 있지만 게시글이 몇만 개가 있다면 애기가 달라진다

 

이후 메모리에 올라간 게시글 목록을 순회하면서 각 게시글이 참조하는 사용자의 기본키(user_id)와 입력받은 userId가 같은지 Java 코드를 통해 비교하게 된다 필터링 작업을 DB에서 하는 게 아닌 애플리케이션 레벨에서 수행하게 된다

 

이러한 구조는 데이터의 양이 증가할수록 불필요한 데이터 전송,메모리 사용 증가,CPU 연산 증가로 인한 서버 부하 및 성능 저하로 이어지며 결과적으로 전체 응답 시간을 증가시키게 된다

 

Board 전체 조회 쿼리 

SELECT * FROM board;

게시글이 1만 건이면 1만 건 전부 조회하게 됩니다 LAZY 전략은 이 시점에서 User 정보는 조회되지 않습니다

 

User 조회 쿼리

b.getUser().getId()

SELECT * FROM user
WHERE user_id = ?;

게시글이 1만 건이면 user_id 조건 쿼리가 1만 건 발생

 

이 구조는 다음과 같은 형태를 가지고 있습니다

- 게시글 전체 조회 쿼리 1번

- 게시글 수만큼 사용자 조회 쿼리 N번

 

즉 1번의 조회 이후 N번의 추가 조회가 발생하는 전형적인 N + 1 문제 구조입니다

 

JPQL로 변경하여 N + 1 문제 해결하기

@Query("""
    SELECT b
    FROM Board b
    JOIN b.user u
    WHERE u.id = :userId
""")
List<Board> findBoardsByUserId(Long userId);

이러한 경우 조건이 SQL 레벨에서 바로 적용되기 때문에

DB에서 해당 사용자가 작성한 게시글만 선별적으로 조회하고 필요한 데이터만 애플리케이션으로 전달하게 됩니다

 

불필요한 조회가 발생하지 않고 하나의 쿼리로 필요한 데이터를 조회하기 때문에 네트워크 전송량과 메모리 사용량이 줄어들어

전체 성능이 향상되는 결과를 얻을 수 있습니다

 

복잡한 프로젝트의 경우에는 Mybatis 등을 사용하여 직접 SQL을 통해 제어하는 경우도 볼 수 있습니다

 

초기에는 Java 컬렉션 API로 처리해도 문제는 없지만 데이터가 증가하는 시점부터는 조회 로직을 JPQL로 확장하는 것이 성능과 유지보수 측면에서 더 적합하다고 느끼고 있으며 요즘 SQL 기본기를 중요시 하여 학습하고 있습니다

 

반응형