본문으로 건너뛰기
AIDevOps
  • Learn
  • Learning Paths
  • Practice
  • Open Source
  • Books
  • Engineering

    AI DevOpsAI 서비스 개발·운영 전체 지도LLMOpsLLM 배포·평가·관측실전 프로젝트AI Agent 프로젝트 실습

    Knowledge

    Docs기술 문서 모음Blog엔지니어링 아티클Plogger개발 기록 피드

    Validate

    Certification3단계 역량 인증 · 준비 중
AI Models
LlamaMistralGemmaDeepSeekQwen
🌱 Spring Cloud
Spring 입문 & 로드맵Spring Cloud GatewaySpring BootJava|Spring AISpring SecuritySpring BatchSpring JPA
🤖 AI 실전 개발
AI 실전 입문 & 로드맵Hugging FaceLangChainLlamaIndexLLMOps|LangGraphMCPMulti-AgentAgent Evaluation
🧠 AI Core
AI 입문 & 로드맵ML FundamentalsLLM Fundamentals|Python AIC++|PyTorchTensorFlowJAX
🧠 AI Agent 개발
금융 AI AgentLLM API 서버주식 투자 AgentAIOps AI Agent교육 AI Agent코딩 AI Agent
🐳 DevOps
DevOps 입문 & 로드맵LinuxDockerCI/CD|Kubernetes 기본K8s 심화/실무PrometheusGrafana
🧱 인프라
인프라 입문 & 로드맵NginxRedis
☁️ 클라우드
클라우드 입문 & 로드맵AWSGCPAzureNCPCloudflare
🎨 Frontend
Frontend 입문 & 로드맵JavaScriptTypeScript|ReactNext.js|VueNuxt
📱 Mobile
Mobile 입문 & 로드맵KotlinAndroidFlutter
⚙️ Backend
Backend 입문 & 로드맵Python 기본FastAPIDjangoFlask|CGoGinNode.js
💾 Database
DB 입문 & 로드맵공통 SQLOracleMySQLPostgreSQL|MongoDB벡터 DB
🧪 검증
k6JMeternGrinder
AIDevOps

Engineering AI. From Code to Production.
AI와 AI Agent를 개발하고 운영하기 위한 엔지니어링 학습 플랫폼

Learn

  • 전체 가이드
  • Learning Paths
  • Practice
  • Books

Resources

  • AI DevOps
  • LLMOps
  • 실전 프로젝트
  • Docs
  • Blog
  • Plogger
  • Open Source
  • Certification (준비 중)

Start Here

  • AI Core 로드맵
  • AI 실전 개발 로드맵
  • Spring Cloud 로드맵
  • DevOps 로드맵
  • 인프라 로드맵

 

  • 클라우드 로드맵
  • Frontend 로드맵
  • Mobile 로드맵
  • Backend 로드맵
  • Database 로드맵
© 2026 AI DevOps Korea. All rights reserved.
이용약관개인정보처리방침Sitemaptestforge.kr
  1. Home
  2. Learn
  3. Spring Cloud
  4. Spring JPA
Spring Boot 데이터 접근 가이드

🗄️ Spring JPA 완전 가이드

Visitors

Spring Boot 기반 Spring Data JPA와 QueryDSL로 Entity 설계, 연관관계 매핑, JPQL/QueryDSL, N+1 해결, 트랜잭션, 페이징, 성능 최적화까지 실전 데이터 접근 계층을 구성합니다.

  • Intermediate · 중급
  • 업데이트 2026.09.19
  • 약 13분 읽기
  • 10개 섹션
  • 예제 코드 10개

포함된 Learning Path

이 가이드는 아래 경로의 한 단계입니다. 앞뒤 순서와 함께 학습해보세요.

  • Spring Cloud Engineer →
Entity & ORM 설계QueryDSL 동적 쿼리N+1 성능 해결Spring Boot 데이터 계층

관련 프레임워크 & 개발환경

🍃Spring Boot→☕Java→🔐Spring Security→⚙️Spring Batch→

목차

0 / 12
  1. 가이드 사용법
  2. 구조 다이어그램
  3. Spring Data JPA란?
  4. Spring Boot 프로젝트 설정
  5. Entity 설계
  6. 연관관계 매핑
  7. Repository & Query 메서드
  8. JPQL & @Query
  9. QueryDSL 동적 쿼리
  10. N+1 문제 해결
  11. 트랜잭션 & 영속성 컨텍스트
  12. 페이징 & 정렬
목차 12개 섹션
  1. 가이드 사용법
  2. 구조 다이어그램
  3. Spring Data JPA란?
  4. Spring Boot 프로젝트 설정
  5. Entity 설계
  6. 연관관계 매핑
  7. Repository & Query 메서드
  8. JPQL & @Query
  9. QueryDSL 동적 쿼리
  10. N+1 문제 해결
  11. 트랜잭션 & 영속성 컨텍스트
  12. 페이징 & 정렬

가이드 사용법

읽는 방향

Spring JPA를 실무 흐름으로 이해하기

Spring Boot 기반 Spring Data JPA와 QueryDSL로 Entity 설계, 연관관계 매핑, JPQL/QueryDSL, N+1 해결, 트랜잭션, 페이징, 성능 최적화까지 실전 데이터 접근 계층을 구성합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.

핵심 관점

백엔드 / 시스템 개발

문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.

Entity & ORM 설계QueryDSL 동적 쿼리N+1 성능 해결Spring Boot 데이터 계층

구조 다이어그램

글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring JPA를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.

학습 흐름

다이어그램 렌더링 중…

아키텍처 관점

다이어그램 렌더링 중…

Spring Data JPA란?

Spring JPA를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.

Spring Data JPA는 JPA 위에 Repository 추상화를 제공합니다. 메서드 이름만으로 쿼리를 생성하고, @Query, QueryDSL로 복잡한 쿼리를 표현할 수 있어 반복적인 DAO 코드를 크게 줄입니다.
계층기술역할
ORMJPA / Hibernate객체-관계 매핑, JPQL 쿼리 실행
RepositorySpring Data JPA메서드 이름 기반 쿼리 자동 생성
동적 쿼리QueryDSL타입 안전한 조건절 조합
스키마 관리Flyway / LiquibaseDB 마이그레이션 버전 관리

Spring Boot 프로젝트 설정

여기서는 Spring Boot 프로젝트 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

QueryDSL을 함께 추가하면 문자열 기반 JPQL 대신 타입 안전한 쿼리를 컴파일 시점에 검증할 수 있습니다. ddl-auto는 로컬 개발에서는 update로 편하게 쓰더라도 운영에서는 반드시 validate나 none으로 바꿔, 애플리케이션이 실수로 운영 DB 스키마를 변경하는 사고를 막아야 합니다.
build.gradle.ktsKOTLIN
plugins {
    id("java")
    id("org.springframework.boot") version "3.3.5"
    id("io.spring.dependency-management") version "1.1.6"
}

java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
    compileOnly("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")
    runtimeOnly("org.postgresql:postgresql")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
application.ymlYAML
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USER}
    password: ${DB_PASS}
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 100  # N+1 방지

Entity 설계

여기서는 Entity 설계을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

불변 필드는 생성자로만 설정하고, Setter는 최소화합니다. Auditing으로 createdAt, updatedAt을 자동 관리합니다. setStatus() 같은 범용 Setter 대신 activate()/deactivate()처럼 의도가 드러나는 비즈니스 메서드를 두면, 어떤 상태 변경이 허용되는지가 Entity 코드만 봐도 명확해지고 유효하지 않은 상태 전이를 메서드 안에서 막을 수 있습니다.
User.javaJAVA
@Entity @Table(name = "users")
@Getter @Builder @NoArgsConstructor @AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private UserStatus status;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    // 비즈니스 메서드 (Setter 대신)
    public void activate()    { this.status = UserStatus.ACTIVE; }
    public void deactivate()  { this.status = UserStatus.INACTIVE; }
}

연관관계 매핑

여기서는 연관관계 매핑을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

단방향을 기본으로 하고, 필요할 때만 양방향으로 전환합니다. @ManyToMany는 피하고 중간 테이블 Entity를 직접 정의하세요. @ManyToMany는 중간 테이블에 컬럼을 추가하거나 쿼리를 최적화하기가 어려워지므로, assignedAt처럼 관계 자체에 속성이 필요한 경우가 많은 실무에서는 처음부터 중간 Entity를 직접 만들어 두 개의 @ManyToOne으로 표현하는 편이 훨씬 유연합니다.
JAVA
// ── One-to-Many (게시글 : 댓글) ─────────────────
@Entity
public class Post {
    @Id @GeneratedValue(strategy = IDENTITY) private Long id;
    private String title;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }
}

@Entity
public class Comment {
    @Id @GeneratedValue(strategy = IDENTITY) private Long id;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)  // 반드시 LAZY
    @JoinColumn(name = "post_id")
    private Post post;
}

// ── Many-to-Many → 중간 테이블 Entity ────────────
@Entity
public class UserRole {
    @Id @GeneratedValue(strategy = IDENTITY) private Long id;

    @ManyToOne(fetch = LAZY) @JoinColumn(name = "user_id") private User user;
    @ManyToOne(fetch = LAZY) @JoinColumn(name = "role_id") private Role role;

    private LocalDateTime assignedAt;
}

Repository & Query 메서드

여기서는 Repository & Query 메서드을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

JpaRepository를 상속하면 CRUD와 페이징이 기본 제공됩니다. 메서드 이름 규칙으로 단순 쿼리를 자동 생성합니다. 조건이 복잡해지면 메서드 이름이 지나치게 길어지므로 그 시점부터는 @Query로 JPQL을 직접 작성하고, 여러 행을 한 번에 변경하는 벌크 연산에는 @Modifying을 반드시 붙여야 영속성 컨텍스트와 무관하게 DB에 직접 반영됩니다.
UserRepository.javaJAVA
public interface UserRepository extends JpaRepository<User, Long> {

    // 메서드 이름으로 쿼리 자동 생성
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    List<User> findByStatusOrderByCreatedAtDesc(UserStatus status);
    long countByStatus(UserStatus status);

    // @Query — JPQL
    @Query("SELECT u FROM User u WHERE u.createdAt >= :from AND u.status = :status")
    List<User> findActiveAfter(@Param("from") LocalDateTime from,
                               @Param("status") UserStatus status);

    // Bulk update — @Modifying 필수
    @Modifying
    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
    int bulkUpdateStatus(@Param("ids") List<Long> ids,
                         @Param("status") UserStatus status);
}

JPQL & @Query

여기서는 JPQL & @Query을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

JPQL은 Entity 이름과 필드 이름을 기준으로 작성합니다. nativeQuery = true를 사용하면 네이티브 SQL도 실행할 수 있습니다.
JAVA
// ── Fetch Join (N+1 해결) ─────────────────────────
@Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.id = :id")
Optional<Post> findWithComments(@Param("id") Long id);

// ── DTO Projection ────────────────────────────────
@Query("SELECT new com.example.dto.UserSummary(u.id, u.name, u.email) " +
       "FROM User u WHERE u.status = 'ACTIVE'")
List<UserSummary> findActiveSummaries();

// ── Native Query ──────────────────────────────────
@Query(value = "SELECT * FROM users WHERE email ILIKE %:keyword%",
       nativeQuery = true)
List<User> searchByEmailNative(@Param("keyword") String keyword);

QueryDSL 동적 쿼리

여기서는 QueryDSL 동적 쿼리을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

QueryDSL은 타입 안전한 방식으로 조건절을 조합합니다. BooleanBuilder 또는 BooleanExpression으로 null-safe 동적 조건을 구성합니다.
UserQueryRepository.javaJAVA
@Repository
@RequiredArgsConstructor
public class UserQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QUser user = QUser.user;

    public List<User> search(UserSearchCondition cond) {
        return queryFactory
            .selectFrom(user)
            .where(
                emailContains(cond.getEmail()),
                statusEq(cond.getStatus()),
                createdAfter(cond.getFrom())
            )
            .orderBy(user.createdAt.desc())
            .offset(cond.getOffset())
            .limit(cond.getSize())
            .fetch();
    }

    // null이면 조건에서 제외 (null-safe 동적 조건)
    private BooleanExpression emailContains(String email) {
        return StringUtils.hasText(email) ? user.email.containsIgnoreCase(email) : null;
    }
    private BooleanExpression statusEq(UserStatus status) {
        return status != null ? user.status.eq(status) : null;
    }
    private BooleanExpression createdAfter(LocalDateTime from) {
        return from != null ? user.createdAt.goe(from) : null;
    }
}

N+1 문제 해결

여기서는 N+1 문제 해결을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

N+1은 연관 Entity를 건별로 N번 추가 조회하는 문제입니다. Fetch Join, EntityGraph, batch_fetch_size로 해결합니다.
다이어그램 렌더링 중…
JAVA
// ── 문제 상황 ─────────────────────────────────────
// N+1 발생: posts 조회 후 각 post마다 comments SELECT 추가 발생
List<Post> posts = postRepository.findAll();
posts.forEach(p -> p.getComments().size()); // N번 추가 쿼리

// ── 해결 1: Fetch Join ────────────────────────────
@Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();

// ── 해결 2: @EntityGraph ──────────────────────────
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();

// ── 해결 3: application.yml 글로벌 설정 ──────────
// hibernate.default_batch_fetch_size: 100
// → IN 쿼리로 한 번에 100개씩 로딩 (컬렉션 N+1에 효과적)

트랜잭션 & 영속성 컨텍스트

여기서는 트랜잭션 & 영속성 컨텍스트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

@Transactional은 Service 계층에 선언합니다. readOnly=true는 조회 전용 트랜잭션에 적용해 성능을 최적화합니다. 트랜잭션 안에서 조회한 Entity는 영속성 컨텍스트(1차 캐시)가 계속 추적하기 때문에, 필드 값만 바꾸면 별도 save() 호출 없이도 커밋 시점에 변경 사항이 자동으로 UPDATE됩니다(Dirty Checking).
다이어그램 렌더링 중…
UserService.javaJAVA
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 기본을 읽기 전용으로 설정
public class UserService {

    private final UserRepository userRepository;

    // 조회 — readOnly 그대로
    public UserResponse findById(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .orElseThrow(() -> new EntityNotFoundException("User: " + id));
    }

    // 쓰기 — readOnly 오버라이드
    @Transactional
    public UserResponse create(CreateUserRequest req) {
        if (userRepository.existsByEmail(req.getEmail())) {
            throw new DuplicateEmailException(req.getEmail());
        }
        User user = userRepository.save(
            User.builder()
                .name(req.getName())
                .email(req.getEmail())
                .status(UserStatus.ACTIVE)
                .build()
        );
        return UserResponse.from(user);
    }

    // 변경 감지 (Dirty Checking) — save() 불필요
    @Transactional
    public void activate(Long id) {
        User user = userRepository.findById(id).orElseThrow();
        user.activate(); // 트랜잭션 커밋 시 자동 UPDATE
    }
}

페이징 & 정렬

여기서는 페이징 & 정렬을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Pageable을 파라미터로 받으면 offset/limit 쿼리가 자동 생성됩니다. QueryDSL과 함께 사용할 때는 count 쿼리를 분리해 성능을 최적화합니다.
UserController.javaJAVA
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping
    public Page<UserResponse> list(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt,desc") String[] sort) {

        Pageable pageable = PageRequest.of(page, size,
            Sort.by(Arrays.stream(sort)
                .map(s -> s.split(","))
                .map(a -> a[1].equalsIgnoreCase("desc")
                    ? Sort.Order.desc(a[0])
                    : Sort.Order.asc(a[0]))
                .collect(Collectors.toList())));

        return userService.findAll(pageable);
    }
}

// QueryDSL 페이징 (count 쿼리 분리)
public Page<UserSummary> searchPaged(UserSearchCondition cond, Pageable pageable) {
    List<UserSummary> content = queryFactory
        .select(Projections.constructor(UserSummary.class, user.id, user.name, user.email))
        .from(user)
        .where(statusEq(cond.getStatus()))
        .orderBy(user.createdAt.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    JPAQuery<Long> countQuery = queryFactory
        .select(user.count())
        .from(user)
        .where(statusEq(cond.getStatus()));

    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
← 이전 가이드Spring Batch