[아이티센 부트캠프] JPA 3

이언덕·2026년 5월 1일

아이티센 부트캠프

목록 보기
80/115
post-thumbnail

응용예제 1. 전체 파일 구조 먼저 확인하기

이번 응용예제는 JPA 기본 개념을 실제 코드 흐름으로 확인하는 파트이다.
앞에서는 Entity, EntityManager, 영속성 컨텍스트, Transaction, JPQL, 연관관계, 지연 로딩, N+1, Fetch Join을 개념 중심으로 정리했다.
이제는 코드예제가 어떤 개념을 확인하는지 하나씩 연결해서 본다.


응용예제는 파일을 하나씩 따로 외우는 방식으로 보면 어렵다.
먼저 파일의 역할을 나누어야 한다.
어떤 파일은 Entity 구조를 보여 주고, 어떤 파일은 EntityManager 생성 흐름을 보여 주며, 어떤 파일은 저장, 조회, 삭제 같은 실제 실행 흐름을 보여 준다.


응용예제는 코드 파일을 실행 순서와 역할 기준으로 묶어야 흐름이 보인다.


응용예제를 읽기 전에 잡아야 할 기준

응용예제는 새 개념을 무작정 추가하는 파트가 아니다.
앞에서 배운 개념이 실제 코드에서 어떤 순서로 연결되는지 확인하는 파트이다.


그래서 코드를 볼 때는 먼저 이 파일이 어떤 역할인지부터 구분해야 한다.
이 파일이 Entity 구조를 보여 주는 파일인지, 직접 실행하는 파일인지, 데이터베이스 작업을 모아 둔 DAO 파일인지 먼저 봐야 한다.


이 기준이 잡히면 뒤에서 나오는 persist(), find(), JPQL, remove(), Fetch Join, DAO 호출 흐름이 어느 위치에서 실행되는지 이해하기 쉬워진다.


응용예제의 핵심은 코드 한 줄을 따로 외우는 것이 아니라, 각 파일이 어떤 개념을 확인하는 역할인지 연결해서 보는 것이다.


이번 응용예제에서 계속 확인할 기준

  • 이 파일이 실행용 파일인지 확인한다.
  • 이 파일이 테이블과 연결되는 Entity인지 확인한다.
  • 이 파일이 데이터베이스 작업을 모아 둔 DAO인지 확인한다.
  • 이 파일이 조회 결과를 담는 DTO인지 확인한다.
  • 이 파일이 설정 파일인지 확인한다.
  • 실행 결과가 어떤 코드 흐름에서 나온 것인지 확인한다.

이 기준으로 보면 파일이 많아도 흐름이 끊기지 않는다.
각 파일을 따로 외우는 것이 아니라, JPA 실행 과정 안에서 어떤 역할을 맡는지 연결해서 볼 수 있다.


파일은 역할별로 나누어 봐야 한다

파일은 크게 실행 파일, Entity 파일, DAO 파일, DTO 파일, 설정 파일로 나누어 볼 수 있다.
이 구분이 먼저 되어야 코드 흐름이 정리된다.

실행 파일

실행 파일은 직접 실행해서 결과를 확인하는 코드이다.
main() 메서드가 들어 있고, EntityManager를 만들거나 DAO 메서드를 호출해서 실제 결과를 출력한다.

  • HelloJPA1, HelloJPA2는 JPA 실행 객체 생성 흐름을 확인한다.
  • HelloJPA3부터 HelloJPA6까지는 Emp 조회, 연관 필드 접근, N+1, Fetch Join 흐름을 확인한다.
  • EntityTestApp 계열은 기본 저장, 조회, flush, rollback, 삭제 흐름을 확인한다.
  • MemberTeamTest 계열은 Member, Team, Locker의 연관관계 저장과 조회를 확인한다.
  • EmpApp 계열은 EmpDAO의 여러 조회 기능을 실행한다.
  • VisitorApp, MeetingApp은 콘솔 메뉴로 DAO 기반 프로그램 흐름을 확인한다.

실행 파일은 결과를 직접 만드는 파일이다.
그래서 나중에 결과 이미지나 영상을 붙일 때는 대부분 실행 파일을 기준으로 보면 된다.


Entity 파일

Entity 파일은 데이터베이스 테이블과 연결되는 객체 구조를 정의한다.
실행 파일에서 저장하거나 조회하는 대상이 바로 Entity이다.

  • EntityTest 계열은 기본 엔티티 매핑, 컬럼 설정, 날짜·시간 매핑을 보여 준다.
  • Emp, Dept, Locations는 사원, 부서, 지역의 연관관계를 보여 준다.
  • Member, Team, Locker는 회원, 팀, 락커의 다대일과 일대일 관계를 보여 준다.
  • Visitor, Meeting, Reply, Book은 실제 프로그램 구조에 가까운 엔티티 예제이다.

Entity 파일을 먼저 확인해야 실행 파일의 persist(), find(), JPQL이 어떤 테이블과 연결되는지 이해할 수 있다.


DAO 파일

DAO 파일은 데이터베이스 작업을 메서드 단위로 모아 둔 코드이다.
DAO는 Data Access Object의 줄임말이고, 데이터 접근 객체라는 뜻이다.

  • EmpDAO는 다양한 JPQL 조회 기능을 보여 준다.
  • VisitorDAO는 방명록 조회, 검색, 저장, 수정, 삭제를 처리한다.
  • MeetingDAO는 미팅 글 조회, 검색, 저장, 수정, 삭제와 댓글 조회·저장을 처리한다.

DAO를 사용하면 실행 앱이 JPA 코드를 전부 직접 들고 있지 않아도 된다.
데이터베이스 접근 코드를 DAO에 모아 두기 때문에 프로그램 구조가 더 분리된다.


DTO 파일과 설정 파일

DTO 파일은 조회 결과를 필요한 형태로 담기 위한 객체이다.
DTO는 Data Transfer Object의 줄임말이고, 데이터를 옮기기 위한 객체라는 뜻이다.

  • EmpFreqDTO는 Emp 전체 엔티티가 아니라 필요한 값만 묶어서 조회하는 흐름에서 사용된다.

설정 파일은 JPA 실행 설정을 담당한다.
실행 코드에서 "entitytest"나 "emptest" 같은 이름을 사용하면, 해당 설정 묶음을 기준으로 EntityManagerFactory가 만들어진다.


파일 역할을 먼저 나누면 실행 코드, 데이터 구조, 데이터베이스 작업 코드가 서로 어떻게 연결되는지 보인다.


전체 흐름은 실행 준비에서 DAO 구조까지 이어진다

응용예제를 읽을 때는 파일 이름 순서만 따라가면 흐름이 잘 보이지 않을 수 있다.
먼저 실행 준비 파일을 보고, 그다음 Entity 구조를 확인한 뒤, 실제 실행 파일을 보는 순서가 좋다.

첫 번째 흐름은 JPA 실행 준비이다

HelloJPA1, HelloJPA2는 EntityManagerFactory와 EntityManager가 어떻게 만들어지는지 확인한다.
실제 저장이나 조회 전에 JPA 실행 객체가 어떻게 준비되는지 보는 단계이다.


두 번째 흐름은 기본 Entity 매핑과 기본 데이터 작업이다

EntityTest 계열에서는 기본 Entity 매핑, 기본키 자동 생성, 컬럼 설정, 날짜·시간 매핑, 간단한 연관관계를 확인한다.


EntityTestApp 계열에서는 저장, 전체 조회, flush, rollback(), 삭제 요청과 commit() 여부를 확인한다.


세 번째 흐름은 Emp 기반 JPQL과 로딩 전략이다

Emp, Dept, Locations와 HelloJPA3부터 HelloJPA6까지의 조회 예제에서는 JPQL 조회, 연관 필드 접근, 지연 로딩, N+1, Fetch Join 흐름을 확인한다.


이 구간은 연관관계가 있는 엔티티를 조회할 때 실제 SQL 흐름이 어떻게 달라질 수 있는지 보는 구간이다.


네 번째 흐름은 DAO 기반 조회 심화이다

EmpDAO, EmpApp1, EmpApp2에서는 JPQL 조건 조회, like, DTO 조회, 페이징, 집계 함수를 DAO 메서드로 나누어 실행하는 구조를 확인한다.


여기서는 쿼리 하나만 보는 것이 아니라, 여러 조회 기능을 메서드로 분리해 사용하는 흐름을 본다.


다섯 번째 흐름은 Member 기반 연관관계 조회이다

Member, Team, Locker와 MemberTeamTest 계열에서는 다대일, 일대일 관계를 가진 엔티티를 저장하고 조회한다.


이 구간에서는 연관 필드 조건 조회, 단건 조회, null 관계, Native SQL과 JPQL 차이를 확인한다.


마지막 흐름은 실제 프로그램 구조에 가까운 DAO 예제이다

Visitor, Meeting, Reply, Book 예제에서는 실제 프로그램 구조에 가까운 DAO 기반 CRUD 흐름과 댓글의 다대일 관계를 확인한다.


CRUD는 생성, 조회, 수정, 삭제를 의미한다.
실제 프로그램에서 가장 기본이 되는 데이터 처리 흐름이다.


결과물은 코드 흐름과 함께 봐야 한다

응용예제 결과물은 대부분 콘솔 출력이나 SQL 로그로 확인한다.
일부 예제는 실행 결과가 짧지만, EmpApp, VisitorApp, MeetingApp처럼 결과가 길게 나오는 예제도 있다.


결과가 길게 나오면 전체를 한 번에 외우려고 하면 안 된다.
구분선, 입력 메뉴, 출력 메시지, SQL 로그를 기준으로 어느 코드가 실행된 결과인지 나누어 봐야 한다.

결과물이 길게 나오는 대표 예제

  • EntityTestApp3은 저장 요청, JPQL 조회, 추가 저장 요청, 다시 조회, 마지막 rollback() 흐름으로 결과가 길어진다.
  • EmpApp1, EmpApp2는 여러 DAO 메서드를 순서대로 호출하기 때문에 결과가 여러 구간으로 나뉜다.
  • VisitorApp, MeetingApp은 메뉴 번호에 따라 결과 흐름이 달라진다.

결과가 길어도 핵심은 출력 줄 수가 아니다.
어떤 코드가 실행되었고, 그 결과 어떤 콘솔 출력이나 SQL 로그가 나온 것인지 연결해서 봐야 한다.


응용예제 결과물은 출력 줄 수보다 어떤 코드 흐름이 어떤 결과를 만든 것인지 연결해서 보는 것이 중요하다.


핵심 정리

이번 응용예제 전체는 파일을 역할별로 나누어 읽어야 한다.
실행 파일만 보면 어떤 테이블과 연결되는지 알기 어렵고, Entity만 보면 실제로 어떤 작업을 하는지 알기 어렵다.
그래서 Entity 구조와 실행 흐름을 함께 봐야 한다.


정리하면 아래와 같다.

  • Entity는 데이터 구조와 테이블 매핑을 담당한다.
  • 실행 파일은 EntityManager를 사용해 저장, 조회, 삭제 흐름을 확인한다.
  • DAO는 데이터베이스 접근 코드를 메서드 단위로 분리한다.
  • DTO는 조회 결과를 필요한 형태로 담는다.
  • 콘솔 앱은 사용자 입력과 실행 흐름을 담당한다.
  • 설정 파일은 JPA 실행 설정 묶음을 제공한다.

응용예제는 파일명을 외우는 것이 아니라, 각 파일이 JPA 실행 흐름의 어느 단계를 맡는지 확인하는 방식으로 읽어야 한다.




응용예제 2. JPA 실행 객체 생성 확인하기 (HelloJPA1.java, HelloJPA2.java)

HelloJPA1.java와 HelloJPA2.java는 JPA 실행 흐름의 시작점을 확인하는 예제이다.
이 예제는 데이터를 저장하거나 조회하는 코드가 아니다.
JPA를 사용하기 위해 필요한 실행 객체가 어떤 순서로 만들어지는지 확인한다.


이전 기본개념에서 EntityManagerFactory는 EntityManager를 만드는 공장이고, EntityManager는 실제 Entity 작업을 처리하는 객체라고 정리했다.
이번 예제에서는 그 개념이 실제 코드에서 어떻게 등장하는지 확인한다.


이 예제의 핵심은 EntityManagerFactory를 먼저 만들고, 그다음 EntityManager를 만든다는 실행 준비 순서를 확인하는 것이다.


이전 기본개념과 연결하기

이전 기본개념에서 JPA 실행 흐름은 Persistence에서 시작한다고 정리했다.
Persistence.createEntityManagerFactory()를 호출하면 persistence.xml에 적힌 설정 묶음을 읽고 EntityManagerFactory가 만들어진다.


EntityManagerFactory는 이름 그대로 EntityManager를 만드는 공장 역할이다.
공장은 실제 작업을 직접 처리하는 객체라기보다, 작업 객체를 만들어 주는 기준 객체로 보면 된다.


EntityManager는 실제로 Entity를 저장, 조회, 수정, 삭제할 때 사용하는 객체이다.
앞으로 나오는 persist(), find(), remove(), createQuery() 같은 메서드는 대부분 EntityManager를 통해 사용한다.

기본개념에서 다시 잡아야 할 흐름

  • Persistence는 JPA 실행 설정을 읽는 시작점이다.
  • EntityManagerFactory는 EntityManager를 만드는 공장이다.
  • EntityManager는 실제 데이터 작업을 처리하는 객체이다.
  • EntityManagerFactory를 먼저 만들고, 그다음 EntityManager를 만든다.
  • 사용이 끝나면 close()로 자원을 정리한다.

이 흐름을 알고 보면 HelloJPA1.java와 HelloJPA2.java는 매우 단순하다.
두 파일은 저장이나 조회가 아니라, JPA 실행 준비 단계만 확인하는 예제이다.


예제의 목표

이 예제의 목표는 JPA 실행 객체 생성 순서를 확인하는 것이다.
HelloJPA1.java는 EntityManagerFactory만 만든다.
HelloJPA2.java는 EntityManagerFactory를 만든 뒤 EntityManager까지 만든다.


두 파일 모두 출력 결과로 객체의 실제 클래스명을 보여 준다.
코드에서 사용하는 타입은 JPA 표준 인터페이스이지만, 실제 실행 객체는 구현체가 만들어 준 객체이다.
여기서는 Hibernate가 구현체 역할을 한다.

이 예제에서 확인할 내용

  • "entitytest" 설정 묶음으로 EntityManagerFactory를 만든다.
  • EntityManagerFactory에서 EntityManager를 만든다.
  • getClass().getName()으로 실제 생성된 객체의 클래스명을 확인한다.
  • 사용이 끝난 객체는 close()로 정리한다.

표준 타입으로 코드를 작성하지만, 실제 실행 객체는 Hibernate 같은 구현체가 만들어 준다는 점을 확인해야 한다.


코드 흐름

HelloJPA1.java는 EntityManagerFactory 생성만 확인한다.
이 파일은 EntityManager를 만들지 않는다.
따라서 실제 데이터 작업도 하지 않는다.

// HelloJPA1.java
public class HelloJPA1 {
    public static void main(String[] args) {
        EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성

        System.out.println("EntityManagerFactory 객체 : " + factory.getClass().getName()); // 실제 구현 클래스명 출력

        factory.close(); // Factory 자원 정리
    }
}

Persistence.createEntityManagerFactory("entitytest")는 persistence.xml 안의 "entitytest" 설정 묶음을 읽는다.
그리고 그 설정을 기준으로 EntityManagerFactory를 만든다.


factory.getClass().getName()은 생성된 객체의 실제 클래스명을 문자열로 가져온다.
이 출력 결과를 통해 JPA 표준 타입 뒤에서 어떤 구현 객체가 만들어졌는지 확인할 수 있다.



HelloJPA2.java는 여기서 한 단계 더 나아간다.
EntityManagerFactory를 만든 뒤, factory.createEntityManager()로 EntityManager를 만든다.

// HelloJPA2.java
public class HelloJPA2 {
    public static void main(String[] args) {
        EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성

        System.out.println("EntityManagerFactory 객체 : " + factory.getClass().getName()); // Factory 구현 클래스명 출력

        EntityManager em = factory.createEntityManager(); // EntityManager 생성

        System.out.println("EntityManager 객체 : " + em.getClass().getName()); // EntityManager 구현 클래스명 출력

        em.close(); // EntityManager 자원 정리
        factory.close(); // Factory 자원 정리
    }
}

factory.createEntityManager()는 실제 작업 객체인 EntityManager를 만든다.
앞으로 Entity 저장, 조회, 수정, 삭제는 이 EntityManager를 통해 처리된다.


HelloJPA2.java에서도 아직 persist()나 find()는 나오지 않는다.
이 예제의 목적은 데이터 작업이 아니라, 데이터 작업을 하기 전 필요한 객체 생성 순서를 확인하는 것이다.


핵심 코드 해석

Persistence.createEntityManagerFactory

Persistence.createEntityManagerFactory("entitytest")는 JPA 실행의 시작점이다.
여기서 "entitytest"는 설정 묶음 이름이다.


이 이름은 아무 문자열이 아니다.
persistence.xml에 정의된 설정 이름과 연결된다.
그래서 "entitytest"라는 이름을 잘못 쓰면 JPA가 어떤 설정을 읽어야 하는지 찾지 못할 수 있다.


이 코드는 아래처럼 이해하면 된다.

  • persistence.xml에서 "entitytest" 설정을 찾는다.
  • 해당 설정을 기준으로 데이터베이스 연결과 JPA 구현체 설정을 준비한다.
  • EntityManagerFactory 객체를 만든다.

EntityManagerFactory는 무거운 객체로 이해하면 된다.
실습용 main()에서는 실행할 때 만들고 끝날 때 닫지만, 실제 애플리케이션에서는 보통 자주 만들고 닫는 객체로 쓰지 않는다.


factory.createEntityManager

factory.createEntityManager()는 EntityManager를 만든다.
EntityManager는 실제 작업 객체이다.


앞으로 나오는 저장, 조회, 수정, 삭제 흐름은 거의 모두 EntityManager와 연결된다.
예를 들어 em.persist(entity)는 저장 요청이고, em.find(Entity.class, id)는 기본키 조회이다.


이 예제에서는 아직 그런 작업을 하지 않는다.
하지만 HelloJPA2.java에서 EntityManager를 생성해 보기 때문에, 뒤 예제에서 em.persist()가 나와도 흐름이 끊기지 않는다.


getClass().getName

getClass().getName()은 객체의 실제 클래스명을 확인하는 코드이다.
초보자 입장에서는 이 부분이 조금 낯설 수 있다.


코드에서는 변수를 EntityManagerFactory 타입으로 선언한다.
하지만 실제 객체는 Hibernate가 만든 구현 클래스일 수 있다.
그래서 getClass().getName()을 출력하면 표준 타입 이름이 아니라 실제 구현 클래스명이 길게 출력될 수 있다.


이 코드로 확인하는 흐름은 아래와 같다.

  • 변수 타입은 EntityManagerFactory이다.
  • 실제 생성된 객체는 구현체의 클래스이다.
  • getClass().getName()으로 실제 클래스명을 확인한다.

인터페이스 타입으로 선언해도 실제 실행 객체는 구현체가 만든 클래스일 수 있다.


close와 연결 풀 정리 로그

close()는 사용한 자원을 정리하는 메서드이다.
EntityManager와 EntityManagerFactory는 데이터베이스 연결과 관련된 자원을 사용할 수 있다.
그래서 실습 코드에서는 사용이 끝난 뒤 닫아 주는 흐름을 잡는 것이 좋다.


닫는 순서도 중요하게 보면 좋다.
HelloJPA2.java에서는 EntityManager를 먼저 닫고, 그다음 EntityManagerFactory를 닫는다.
작업 객체를 먼저 정리하고, 공장 객체를 나중에 정리하는 흐름이다.


실행 결과 아래쪽에는 Cleaning up connection pool 로그가 보일 수 있다.
이 로그는 오류가 아니다.
EntityManagerFactory를 닫으면서 Hibernate가 데이터베이스 연결 자원을 정리한다는 의미이다.


마지막에 Process finished with exit code 0이 출력되면 프로그램이 정상 종료된 것이다.
즉, 빨간색 로그가 보인다고 해서 무조건 오류로 판단하면 안 된다.


정리하면 아래와 같다.

  • EntityManager는 실제 작업 객체이므로 사용 후 닫는다.
  • EntityManagerFactory는 공장 객체이므로 마지막에 닫는다.
  • Cleaning up connection pool은 연결 자원 정리 로그이다.
  • exit code 0은 프로그램이 정상 종료되었다는 의미이다.



결과물은 어떻게 나오는가

HelloJPA1.java를 실행하면 콘솔에 EntityManagerFactory 객체의 실제 클래스명이 출력된다.
제공한 첫 번째 실행 결과에서는 EntityManagerFactory 객체 : org.hibernate.internal.SessionFactoryImpl이 출력된다.
SessionFactoryImpl은 Hibernate가 만든 실제 구현 클래스이다.
즉, 코드에서는 EntityManagerFactory라는 표준 타입으로 받았지만, 내부 실행 객체는 Hibernate 구현 객체로 만들어졌다는 뜻이다.


HelloJPA2.java를 실행하면 두 종류의 클래스명이 출력된다.
제공한 두 번째 실행 결과에서는 EntityManagerFactory 객체 : org.hibernate.internal.SessionFactoryImpl과 EntityManager 객체 : org.hibernate.internal.SessionImpl이 함께 출력된다.
SessionImpl은 Hibernate가 만든 실제 EntityManager 구현 객체로 볼 수 있다.

왼쪽은 HelloJPA1.java 실행 결과이고, 오른쪽은 HelloJPA2.java 실행 결과이다.
왼쪽 결과에서는 EntityManagerFactory 구현 객체만 확인한다.
오른쪽 결과에서는 EntityManagerFactory가 먼저 생성되고, 그 공장에서 실제 작업 객체인 EntityManager가 생성된 것을 함께 확인한다.


출력 아래쪽의 Cleaning up connection pool 로그는 연결 자원 정리 로그이다.
마지막의 Process finished with exit code 0은 프로그램이 정상 종료되었다는 뜻이다.


결과가 길게 보여도 기준은 단순하다.
EntityManagerFactory가 먼저 만들어지고, 그다음 EntityManager가 만들어졌는지 확인하면 된다.

// 출력결과
// HelloJPA1.java
// EntityManagerFactory 객체 : org.hibernate.internal.SessionFactoryImpl
// Cleaning up connection pool 로그
// Process finished with exit code 0

 // HelloJPA2.java
// EntityManagerFactory 객체 : org.hibernate.internal.SessionFactoryImpl
// EntityManager 객체 : org.hibernate.internal.SessionImpl
// Cleaning up connection pool 로그
// Process finished with exit code 0

결과 화면에서 봐야 할 핵심은 클래스명 전체가 아니라 EntityManagerFactory와 EntityManager가 실제 구현 객체로 생성되었다는 점이다.


핵심 정리

HelloJPA1.java와 HelloJPA2.java는 JPA 실행 준비 흐름을 확인하는 예제이다.
이 단계에서는 아직 데이터 저장이나 조회를 하지 않는다.


핵심은 아래와 같다.

  • Persistence.createEntityManagerFactory("entitytest")로 설정 묶음을 읽는다.
  • EntityManagerFactory는 EntityManager를 만드는 공장이다.
  • factory.createEntityManager()로 실제 작업 객체인 EntityManager를 만든다.
  • getClass().getName()으로 실제 구현 클래스명을 확인할 수 있다.
  • Cleaning up connection pool은 연결 자원 정리 로그이다.
  • Process finished with exit code 0은 정상 종료를 의미한다.
  • 사용이 끝난 뒤에는 close()로 자원을 정리한다.

이 예제를 이해하면 뒤에서 나오는 모든 EntityManager 기반 저장, 조회, 수정, 삭제 코드의 시작점을 알 수 있다.




응용예제 3. 기본 Entity 매핑 구조 확인하기 (EntityTest1.java, EntityTest2.java, EntityTest3.java, EntityTest4.java, EntityTest5.java, MyMyTest.java)

EntityTest 계열 파일은 JPA에서 Entity가 어떻게 테이블과 연결되는지 확인하는 예제이다.
앞에서 Entity는 데이터베이스 테이블과 매핑되는 Java 클래스라고 정리했다.
이번 예제에서는 그 개념이 실제 코드에서 어떻게 작성되는지 확인한다.


실행 파일에서 persist()나 find()를 호출하더라도, 어떤 테이블에 저장되고 어떤 컬럼으로 조회되는지는 Entity 클래스의 매핑 정보가 결정한다.
따라서 저장이나 조회 예제를 보기 전에 Entity 구조를 먼저 읽어야 한다.


Entity 매핑 구조를 먼저 이해해야 뒤에서 나오는 저장, 조회, 수정, 삭제 코드가 어떤 테이블을 대상으로 동작하는지 알 수 있다.


이전 기본개념과 연결하기

이전 기본개념에서 Entity는 JPA가 관리하는 객체라고 정리했다.
@Entity가 붙은 클래스는 데이터베이스 테이블과 연결될 수 있고, @Id가 붙은 필드는 기본키 역할을 한다.
기본키는 각 데이터를 구분하는 값이다.


또 @Table, @Column, @GeneratedValue, @Lob, @Temporal, @ManyToOne 같은 어노테이션도 함께 봤다.
어노테이션은 코드에 붙이는 설정 표시이다.
JPA는 이 표시를 보고 어떤 테이블과 어떤 컬럼을 사용할지 판단한다.


이번 응용예제에서는 기본개념에서 배운 매핑 어노테이션이 실제 Entity 클래스 안에서 어떻게 쓰이는지 확인한다.

기본개념에서 다시 잡아야 할 흐름

  • @Entity는 JPA 관리 대상 클래스라는 뜻이다.
  • @Table은 연결할 테이블 이름을 지정한다.
  • @Id는 기본키 필드를 지정한다.
  • @GeneratedValue는 기본키 자동 생성 전략을 지정한다.
  • @Column은 컬럼 이름, 길이, null 허용 여부 같은 세부 조건을 지정한다.
  • @Lob은 큰 데이터 저장에 사용한다.
  • @Temporal은 오래된 날짜 타입에서 날짜, 시간, 날짜와 시간을 구분할 때 사용한다.
  • @ManyToOne은 여러 엔티티가 하나의 엔티티와 연결되는 관계를 나타낸다.

이 기준을 가지고 EntityTest1.java부터 MyMyTest.java까지 보면, 각 파일이 어떤 매핑 개념을 확인하는 예제인지 구분할 수 있다.


예제의 목표

이 예제의 목표는 여러 Entity 클래스의 매핑 구조를 비교해서 보는 것이다.
EntityTest1.java 하나만 보면 기본적인 Entity 구조만 보인다.
하지만 EntityTest2.java, EntityTest3.java, EntityTest4.java, EntityTest5.java, MyMyTest.java까지 함께 보면 기본형과 참조형, 컬럼 설정, 날짜·시간 매핑, 연관관계, 이름 변환 규칙까지 흐름이 넓어진다.

이 예제에서 확인할 내용

  • EntityTest1.java: 가장 기본적인 Entity 구조와 IDENTITY 기본키 자동 생성
  • EntityTest2.java: 기본형 int와 참조형 Integer 차이
  • EntityTest3.java: @Column 세부 설정과 @Lob
  • EntityTest4.java: 날짜와 시간 타입 매핑
  • EntityTest5.java: @ManyToOne을 이용한 간단한 연관관계
  • MyMyTest.java: 클래스명과 필드명이 실제 테이블명, 컬럼명으로 바뀌는 흐름

이 묶음은 실행 결과보다 Entity 클래스 자체의 매핑 정보를 읽는 것이 핵심이다.


EntityTest1은 기본 저장과 조회 예제의 기준 Entity이다

EntityTest1.java는 뒤에서 나오는 EntityTestApp1.java부터 EntityTestApp4.java까지에서 계속 사용된다.
즉, 저장, 조회, flush, rollback(), 삭제 예제의 기준이 되는 Entity이다.


코드 구조는 아래와 같다.

// EntityTest1.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@Entity // JPA가 관리할 Entity 클래스
@Table(name="entitytesttbl") // entitytesttbl 테이블과 매핑
public class EntityTest1 {
    @Id // 기본키 필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id;

    private String name; // 이름
    private int age; // 나이
    private LocalDateTime birthday; // 날짜와 시간
}

@Entity가 있으므로 이 클래스는 JPA가 관리하는 엔티티가 된다.
@Table(name="entitytesttbl")이 있으므로 실제 테이블 이름은 entitytesttbl로 지정된다.


id에는 @Id와 @GeneratedValue(strategy = GenerationType.IDENTITY)가 붙어 있다.
@Id는 기본키이고, IDENTITY 전략은 기본키 값을 데이터베이스가 자동으로 만들게 하는 방식이다.


name, age, birthday에는 별도의 @Column 설정이 없다.
이런 경우에는 기본 매핑 규칙에 따라 필드명이 컬럼명으로 사용될 수 있다.
birthday는 LocalDateTime 타입이므로 날짜와 시간을 함께 담을 수 있다.

EntityTest1에서 봐야 할 핵심

  • @Entity로 JPA 관리 대상이 된다.
  • @Table(name="entitytesttbl")로 테이블명을 직접 지정한다.
  • id는 기본키이다.
  • IDENTITY 전략으로 기본키를 데이터베이스가 자동 생성한다.
  • @ToString 덕분에 조회 결과를 출력할 때 객체 내용이 보기 좋게 출력될 수 있다.

뒤의 저장, 조회, 삭제 예제는 모두 EntityTest1의 매핑 정보를 기준으로 동작한다.

EntityTest1은 entitytesttbl 테이블과 매핑된다.
결과에서 id 컬럼에 auto_increment가 붙은 것을 확인할 수 있다.
이는 @GeneratedValue(strategy = GenerationType.IDENTITY) 설정이 실제 테이블 구조에 반영된 결과이다.


EntityTest2는 기본형과 참조형 차이를 확인한다

EntityTest2.java는 구조가 단순하지만, int와 Integer가 함께 나온다는 점이 중요하다.
둘 다 숫자를 담을 수 있지만 의미가 다르다.


코드 구조는 아래와 같다.

// EntityTest2.java
@Entity // JPA가 관리할 Entity 클래스
public class EntityTest2 {
    @Id // 기본키 필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id;

    private String name; // 이름
    private int number1; // 기본형 int
    private Integer number2; // 참조형 Integer
}

int는 기본형이다.
기본형은 값이 없다는 상태를 표현하기 어렵다.
값을 따로 넣지 않으면 기본값 0이 들어갈 수 있다.


Integer는 참조형이다.
참조형은 값이 없다는 의미로 null을 가질 수 있다.
null은 아직 어떤 객체나 값이 연결되지 않았다는 뜻이다.


이 차이는 데이터베이스의 NULL과 연결해서 생각해야 한다.
숫자 값이 반드시 있어야 하는 필드라면 int를 사용할 수 있다.
하지만 값이 없을 수 있는 숫자라면 Integer처럼 null을 표현할 수 있는 타입이 더 자연스러울 수 있다.

int와 Integer 비교

  • int: 기본형이고 값이 없으면 기본값 0이 들어갈 수 있다.
  • Integer: 참조형이고 null을 가질 수 있다.
  • 0은 실제 숫자 값이다.
  • null은 값이 없다는 뜻이다.

숫자 필드를 만들 때 0과 null은 같은 의미가 아니므로 기본형과 참조형 차이를 구분해야 한다.


EntityTest3은 Column 세부 설정과 Lob을 확인한다

EntityTest3.java는 @Column 설정을 자세히 확인하는 예제이다.
필드가 컬럼으로 매핑될 때 이름, 길이, null 허용 여부, 숫자 자리수, 컬럼 정의 같은 조건을 세밀하게 지정할 수 있다.


코드 구조는 아래와 같다.

// EntityTest3.java
@Entity // JPA가 관리할 Entity 클래스
@Getter // getter 자동 생성
@Setter // setter 자동 생성
public class EntityTest3 {
    @Id // 기본키 필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id;

    @Column(nullable = false, length=6) // null 불가, 길이 6
    private String myName;

    @Column(columnDefinition = "varchar(30) default '녹색'") // 컬럼 정의 직접 지정
    private String favoriteColor;

    @Column(name="age", nullable = false) // age 컬럼과 매핑, null 불가
    private int number1;

    @Column(name="score", precision = 6, scale = 2) // 전체 6자리, 소수 2자리
    private BigDecimal number2;

    @Lob // 큰 데이터 매핑
    private byte[] content1;

    @Lob // 큰 문자 데이터 매핑
    private char[] content2;

    @Lob // 긴 문자열 매핑
    private String content3;
}

@Column(nullable = false, length=6)은 myName 컬럼이 null을 허용하지 않고, 문자열 길이는 6으로 제한된다는 뜻이다.
nullable = false는 반드시 값이 있어야 한다는 의미이다.


@Column(columnDefinition = "varchar(30) default '녹색'")은 데이터베이스 컬럼 정의를 직접 작성한 것이다.
favoriteColor 컬럼을 varchar(30) 형태로 만들고, 데이터베이스 컬럼 정의에 기본값을 넣는 설정이다.


여기서 주의해야 한다.
이 설정은 테이블 컬럼 정의에 기본값을 넣는다는 의미이지, Java 객체의 favoriteColor 필드가 자동으로 "녹색"이 된다는 뜻은 아니다.
실제 저장할 때 기본값이 적용되는지는 insert 문장이 어떻게 만들어지는지, 해당 컬럼 값이 실제로 생략되는지에 따라 달라질 수 있다.


즉, 초보자 단계에서는 이렇게 이해하면 된다.
columnDefinition은 데이터베이스 컬럼 모양을 직접 정하는 설정이고, 객체 필드 기본값을 자동으로 넣어 주는 기능으로 단정하면 안 된다.


@Column(name="age", nullable = false)는 number1 필드를 데이터베이스에서는 age 컬럼으로 사용하겠다는 뜻이다.
필드명과 컬럼명이 반드시 같을 필요는 없다.


precision = 6, scale = 2는 숫자 자리수를 지정한다.
전체 자리수는 6이고, 소수점 아래는 2자리이다.
예를 들어 점수, 금액처럼 자리수 관리가 필요한 값에 사용할 수 있다.


@Lob은 큰 데이터를 저장할 때 사용한다.
byte[]는 바이트 데이터, char[]와 String은 문자 데이터와 연결해서 생각할 수 있다.

EntityTest3에서 봐야 할 핵심

  • nullable = false는 값이 반드시 있어야 한다는 뜻이다.
  • length는 문자열 길이를 제한한다.
  • name은 실제 컬럼명을 지정한다.
  • columnDefinition은 컬럼 정의를 직접 지정한다.
  • columnDefinition의 기본값 설정을 Java 객체 필드 기본값으로 바로 오해하면 안 된다.
  • precision과 scale은 숫자 자리수를 지정한다.
  • @Lob은 큰 데이터를 저장할 때 사용한다.

@Column은 단순히 컬럼 이름만 정하는 것이 아니라 컬럼의 제약 조건과 저장 형태를 세밀하게 조정하는 설정이다.

EntityTest3에서는 @Column, columnDefinition, precision, scale, @Lob 설정이 실제 컬럼 타입과 제약 조건으로 반영된 것을 확인할 수 있다.
myName은 길이 제한이 있는 varchar(6)으로 생성되고, score는 decimal(6,2)로 생성된다.
또 @Lob이 붙은 필드는 tinyblob, tinytext 계열 타입으로 만들어진 것을 확인할 수 있다.


EntityTest4는 날짜와 시간 매핑을 확인한다

EntityTest4.java는 날짜와 시간 타입을 다양하게 보여 준다.
날짜와 시간은 타입마다 표현하는 범위가 다르기 때문에 구분해서 봐야 한다.


코드 구조는 아래와 같다.

// EntityTest4.java
@Entity // JPA가 관리할 Entity 클래스
public class EntityTest4 {
    @Id // 기본키 필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id;

    @Temporal(TemporalType.TIME) // 시간만 저장
    private java.util.Date utilTime;

    @Temporal(TemporalType.DATE) // 날짜만 저장
    private java.util.Date utilDate;

    @Temporal(TemporalType.TIMESTAMP) // 날짜와 시간 저장
    private java.util.Date utilTimestamp;

    private java.util.Date utilPlainDate; // Temporal 없이 사용한 util Date
    private java.sql.Date sqlPlainDate; // SQL Date

    @Column(columnDefinition = "TIME") // DB TIME 컬럼 지정
    private LocalTime localTime1;

    private LocalTime localTime2; // 시간만 표현

    @Column(columnDefinition = "DATE") // DB DATE 컬럼 지정
    private LocalDate localDate1;

    private LocalDate localDate2; // 날짜만 표현

    @Column(columnDefinition = "TIMESTAMP") // DB TIMESTAMP 컬럼 지정
    private LocalDateTime localDateTime1;

    private LocalDateTime localDateTime2; // 날짜와 시간 표현
}

java.util.Date를 사용할 때는 @Temporal로 저장 범위를 지정할 수 있다.
TemporalType.TIME은 시간만 저장한다.
TemporalType.DATE는 날짜만 저장한다.
TemporalType.TIMESTAMP는 날짜와 시간을 함께 저장한다.


utilPlainDate는 @Temporal을 붙이지 않은 java.util.Date 필드이다.
이 필드는 @Temporal을 붙인 경우와 붙이지 않은 경우의 차이를 비교해 보기 위한 필드로 보면 된다.
오래된 날짜 타입을 사용할 때는 어떤 범위로 저장할지 명확히 지정하는 것이 중요하다.


java.sql.Date는 SQL의 날짜 표현과 연결되는 타입이다.
java.util.Date와 이름이 비슷하지만, 실제 의미와 사용 흐름은 다를 수 있으므로 구분해서 봐야 한다.


LocalTime, LocalDate, LocalDateTime은 이름에서 의미가 더 분명하다.
LocalTime은 시간만 표현한다.
LocalDate는 날짜만 표현한다.
LocalDateTime은 날짜와 시간을 함께 표현한다.


@Column(columnDefinition = "TIME"), @Column(columnDefinition = "DATE"), @Column(columnDefinition = "TIMESTAMP")는 데이터베이스 컬럼 타입을 직접 지정한 것이다.

날짜와 시간 타입 비교

  • LocalTime: 시간만 표현한다.
  • LocalDate: 날짜만 표현한다.
  • LocalDateTime: 날짜와 시간을 함께 표현한다.
  • TemporalType.TIME: java.util.Date에서 시간만 저장한다.
  • TemporalType.DATE: java.util.Date에서 날짜만 저장한다.
  • TemporalType.TIMESTAMP: java.util.Date에서 날짜와 시간을 함께 저장한다.
  • utilPlainDate: @Temporal을 붙이지 않은 경우를 비교하기 위한 필드로 볼 수 있다.

날짜와 시간 매핑은 어떤 값을 저장하려는지에 따라 타입과 컬럼 정의를 맞춰야 한다.

EntityTest4에서는 날짜와 시간 타입이 date, time, timestamp, datetime 계열 컬럼으로 매핑되는 흐름을 확인할 수 있다.
LocalDate는 날짜를 저장하는 date 계열로, LocalTime은 시간을 저장하는 time 계열로, LocalDateTime은 날짜와 시간을 함께 저장하는 timestamp 또는 datetime 계열로 연결된다.


EntityTest5는 간단한 ManyToOne 관계를 보여 준다

EntityTest5.java는 Team을 참조하는 간단한 연관관계 예제이다.
여기서는 @ManyToOne이 사용된다.


코드 구조는 아래와 같다.

// EntityTest5.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@Entity // JPA가 관리할 Entity 클래스
public class EntityTest5 {
    @Id // 기본키 필드
    int id;

    private String username; // 사용자 이름

    @ManyToOne // 여러 EntityTest5가 하나의 Team과 연결될 수 있음
    private Team a; // Team 참조

    public EntityTest5() { // 기본 생성자
    }

    public EntityTest5(String username, Team team) { // 이름과 팀을 받는 생성자
        this.username = username; // 사용자 이름 저장
        a = team; // Team 참조 저장
    }
}

@ManyToOne은 여러 엔티티가 하나의 엔티티와 연결될 수 있다는 뜻이다.
예를 들어 여러 사용자가 하나의 팀에 속할 수 있다.


필드 이름이 a라서 처음 보면 의미가 잘 보이지 않을 수 있다.
하지만 중요한 것은 필드 이름이 아니라, 이 필드의 타입이 Team이고 @ManyToOne이 붙어 있다는 점이다.
즉, EntityTest5는 Team을 참조한다.


또 하나 봐야 할 점은 id에 @GeneratedValue가 없다는 점이다.
@Id만 있으면 기본키 필드라는 뜻은 되지만, 기본키 값을 자동으로 생성한다는 뜻은 아니다.
따라서 이 구조에서는 저장할 때 id 값을 직접 넣어야 할 수 있다.
이 예제는 기본키 자동 생성보다 @ManyToOne 연관관계 모양을 확인하는 데 초점을 두고 보면 된다.


이 예제에는 @JoinColumn이 없다.
@JoinColumn은 외래키 컬럼명을 직접 지정할 때 사용하는 어노테이션이다.
따라서 EntityTest5에서는 외래키 컬럼명이 명시적으로 정해진 것이 아니라, JPA의 기본 규칙에 따라 만들어질 수 있다.


이 부분은 뒤에서 나오는 Member 예제와 비교하면 더 분명해진다.
Member 예제에서는 @JoinColumn(name = "TEAM_ID")처럼 외래키 컬럼명을 직접 지정한다.
반면 EntityTest5는 @ManyToOne만 두고 기본 규칙에 맡긴 형태이다.


이 예제는 연관관계의 기본 모양을 짧게 보여 주는 용도이다.
뒤에서 Member, Team, Locker 예제로 다대일과 일대일 관계를 더 자세히 확인한다.

EntityTest5에서 봐야 할 핵심

  • EntityTest5는 Team을 참조한다.
  • @ManyToOne은 여러 EntityTest5가 하나의 Team과 연결될 수 있다는 뜻이다.
  • id에는 @GeneratedValue가 없으므로 기본키 자동 생성 예제가 아니다.
  • 이 예제에는 @JoinColumn이 없으므로 외래키 컬럼명은 기본 규칙에 따라 정해질 수 있다.
  • 필드 이름보다 필드 타입과 어노테이션을 먼저 봐야 한다.

연관관계 매핑을 볼 때는 참조 대상 엔티티, 관계 어노테이션, 기본키 자동 생성 여부, 외래키 컬럼명을 직접 지정했는지 여부를 함께 확인해야 한다.

EntityTest5에서는 @GeneratedValue가 없어서 id에 auto_increment가 없는 것을 확인할 수 있다.
또 @JoinColumn이 없어도 기본 규칙으로 a_TEAM_ID 외래키가 생성된 것을 확인할 수 있다.
즉, 필드명 a와 참조 대상 기본키인 TEAM_ID가 조합되어 외래키 컬럼명이 만들어진 흐름으로 볼 수 있다.


MyMyTest는 이름 변환 규칙을 확인하기 좋다

MyMyTest.java는 구조가 매우 단순하다.
하지만 클래스 이름과 필드 이름이 실제 테이블명, 컬럼명으로 어떻게 바뀔 수 있는지 확인하기 좋다.


코드 구조는 아래와 같다.

// MyMyTest.java
@Entity // JPA가 관리할 Entity 클래스
public class MyMyTest {
    @Id // 기본키 필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id;

    private String myName; // 설정에 따라 컬럼명으로 연결됨
}

Java에서는 MyMyTest, myName처럼 단어가 이어질 때 중간 단어를 대문자로 시작하는 방식을 자주 사용한다.
이런 표기 방식을 camel case라고 한다.


데이터베이스에서는 my_my_test, my_name처럼 단어 사이에 밑줄을 넣는 방식을 자주 사용한다.
이런 표기 방식을 snake case라고 한다.


다만 camel case가 항상 snake case로 자동 변환되는 것은 아니다.
자동 변환 규칙은 프로젝트 설정에 따라 달라진다.
이번 실행 결과에서는 테이블명이 MyMyTest 그대로 생성되고, 컬럼명도 myName 그대로 생성되었다.


따라서 이름 변환은 항상 자동으로 일어난다고 단정하면 안 된다.
실제 테이블명과 컬럼명은 데이터베이스에서 직접 확인하는 것이 가장 정확하다.
명확하게 지정하고 싶다면 @Table이나 @Column을 사용해 이름을 직접 지정할 수 있다.

이름 변환에서 주의할 점

  • Java 코드는 보통 camel case를 사용한다.
  • 데이터베이스는 보통 snake case를 사용하는 경우가 많다.
  • camel case가 항상 snake case로 자동 변환되는 것은 아니다.
  • 현재 실행 결과에서는 MyMyTest, myName이 그대로 생성되었다.
  • 명확하게 지정하고 싶으면 @Table, @Column을 사용할 수 있다.

클래스명과 필드명이 실제 테이블명과 컬럼명으로 어떻게 바뀌는지는 설정과 매핑 어노테이션을 함께 확인해야 한다.

현재 설정에서는 MyMyTest 테이블명과 myName 컬럼명이 그대로 생성되었다.
따라서 camel case가 항상 snake case로 자동 변환된다고 단정하면 안 된다.
이름을 명확하게 고정하고 싶다면 @Table이나 @Column으로 직접 지정하는 방식이 더 안전하다.


핵심 정리

이번 묶음에서는 여러 Entity 매핑 구조를 확인했다.
각 파일은 단순히 클래스 하나를 보여 주는 것이 아니라, 서로 다른 매핑 개념을 확인하는 역할을 한다.


정리하면 아래와 같다.

  • EntityTest1은 기본 Entity 구조와 IDENTITY 전략을 보여 준다.
  • EntityTest2는 int와 Integer 차이를 보여 준다.
  • EntityTest3은 @Column 세부 설정과 @Lob을 보여 준다.
  • EntityTest4는 날짜와 시간 타입 매핑을 보여 준다.
  • EntityTest5는 @ManyToOne 연관관계를 보여 주고, @GeneratedValue와 @JoinColumn이 없을 때의 매핑 흐름을 확인하게 해 준다.
  • MyMyTest는 이름 변환 규칙을 확인하기 좋다.

Entity 매핑 구조를 읽을 수 있어야 이후 실행 파일에서 어떤 테이블과 컬럼이 사용되는지 자연스럽게 이해할 수 있다.




응용예제 4. Entity 여러 개 저장하고 commit 시점 확인하기 (EntityTestApp1.java, EntityTest1.java)

EntityTestApp1.java는 EntityTest1 객체를 여러 개 만들고 저장하는 예제이다.
앞에서 Entity 매핑 구조를 확인했다면, 이번에는 그 Entity 객체가 실제 저장 흐름에서 어떻게 사용되는지 확인한다.


이 예제의 핵심은 persist()와 commit()의 역할을 나누어 보는 것이다.
persist()는 저장할 Entity를 JPA가 관리하도록 등록하는 흐름이고, commit()은 Transaction을 최종 확정하는 흐름이다.


이 예제는 여러 Entity 객체를 저장하면서 persist(), Transaction, commit(), IDENTITY 전략의 실행 흐름을 함께 확인하는 예제이다.


이전 기본개념과 연결하기

이전 기본개념에서 데이터베이스에 값을 저장하려면 EntityManager와 Transaction이 필요하다고 정리했다.
EntityManager는 실제 Entity 작업을 처리하는 객체이고, Transaction은 데이터 변경 작업을 하나의 작업 단위로 묶는 기능이다.


저장, 수정, 삭제처럼 데이터베이스 상태를 바꾸는 작업은 보통 Transaction 안에서 처리한다.
작업을 시작할 때는 begin()을 호출하고, 작업을 확정할 때는 commit()을 호출한다.
중간에 문제가 있거나 확정하지 않으려면 rollback()으로 되돌릴 수 있다.


또 EntityTest1.java에서 id 필드에 @GeneratedValue(strategy = GenerationType.IDENTITY)가 붙어 있었다.
IDENTITY 전략은 기본키 값을 데이터베이스가 자동으로 만들어 주는 방식이다.
따라서 저장 흐름을 볼 때 기본키가 언제 만들어지고, insert 쿼리가 언제 보이는지도 함께 확인해야 한다.

기본개념에서 다시 잡아야 할 흐름

  • EntityManager는 Entity 저장, 조회, 삭제 같은 실제 작업을 처리한다.
  • Transaction은 데이터 변경 작업을 하나의 단위로 묶는다.
  • begin()은 Transaction 시작이다.
  • persist()는 Entity를 저장 대상으로 등록한다.
  • commit()은 Transaction 안의 작업을 최종 확정한다.
  • IDENTITY 전략은 기본키 값을 데이터베이스가 자동 생성한다.

저장 예제는 persist()만 보면 안 되고, Transaction 시작과 commit() 확정까지 함께 봐야 한다.


예제의 목표

이 예제의 목표는 EntityTest1 객체 5개를 만들어 데이터베이스에 저장하는 흐름을 확인하는 것이다.
단순히 데이터가 저장된다는 사실만 보는 예제가 아니다.
반복문으로 객체를 만들고, 값을 넣고, persist()로 저장 요청한 뒤, 마지막에 commit()으로 확정하는 순서를 보는 예제이다.


또 코드 중간에는 Scanner로 엔터 입력을 기다리는 부분이 있다.
이 부분은 실행 흐름을 잠시 멈춰서 commit() 전후 상태를 확인하기 위한 장치로 볼 수 있다.

이 예제에서 확인할 내용

  • EntityManagerFactory와 EntityManager를 생성한다.
  • Transaction을 시작한다.
  • 반복문으로 EntityTest1 객체를 5개 만든다.
  • 각 객체에 name, age, birthday 값을 저장한다.
  • persist()로 저장 요청한다.
  • 엔터 입력으로 중간 상태를 확인할 시간을 만든다.
  • commit()으로 저장 작업을 확정한다.

이 흐름을 잡아두면 뒤에서 나오는 flush, rollback(), remove() 예제도 훨씬 쉽게 이어진다.


코드 흐름

EntityTestApp1.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 Transaction을 시작하고, 반복문 안에서 EntityTest1 객체를 생성한다.


핵심 코드는 아래와 같다.

// EntityTestApp1.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성
EntityTest1 et; // 저장할 Entity 참조 변수

em.getTransaction().begin(); // Transaction 시작

for(int i=1; i < 6; i++) { // 1부터 5까지 반복
    et = new EntityTest1(); // Entity 객체 생성
    et.setName("둘리"+i); // 이름 저장
    et.setAge(10+i); // 나이 저장
    et.setBirthday(LocalDateTime.now()); // 현재 날짜와 시간 저장
    em.persist(et); // 저장 요청
}

System.out.println("엔터키....."); // 중간 확인 안내 출력
Scanner scan = new Scanner(System.in); // 입력 도구 생성
scan.nextLine(); // 엔터 입력 대기
scan.close(); // Scanner 닫기

em.getTransaction().commit(); // Transaction 확정
em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

반복문은 i가 1부터 5까지 변하면서 총 5번 실행된다.
그래서 EntityTest1 객체가 5개 만들어진다.


각 객체에는 이름, 나이, 현재 날짜와 시간이 들어간다.
이름은 둘리1, 둘리2, 둘리3, 둘리4, 둘리5 형태로 만들어진다.
나이는 11부터 15까지 들어간다.
birthday에는 실행 시점의 현재 날짜와 시간이 들어간다.


핵심 코드 해석

반복문으로 Entity 객체를 여러 개 만든다

이 예제에서는 객체를 하나만 저장하지 않는다.
반복문을 사용해서 EntityTest1 객체를 여러 개 만든다.

// EntityTestApp1.java
for(int i=1; i < 6; i++) { // 1부터 5까지 반복
    et = new EntityTest1(); // 새 Entity 객체 생성
    et.setName("둘리"+i); // 이름 저장
    et.setAge(10+i); // 나이 저장
    et.setBirthday(LocalDateTime.now()); // 현재 날짜와 시간 저장
    em.persist(et); // 저장 요청
}

new EntityTest1()은 새로운 Entity 객체를 만드는 코드이다.
반복문이 한 번 돌 때마다 새로운 객체가 생성된다.


setName(), setAge(), setBirthday()는 객체의 필드 값을 채우는 메서드이다.
이 값들이 나중에 데이터베이스 컬럼 값으로 저장될 수 있다.


여기서 중요한 점은 객체를 만들기만 해서는 데이터베이스에 저장되지 않는다는 것이다.
new EntityTest1()은 메모리에 객체를 만든 것이고, em.persist(et)를 호출해야 JPA 저장 흐름에 들어간다.


et 변수는 반복문 안에서 계속 같은 이름으로 사용된다.
하지만 반복이 돌 때마다 new EntityTest1()로 새 객체가 만들어진다.
따라서 변수 이름은 하나여도 실제로는 서로 다른 EntityTest1 객체 5개가 persist()로 저장 대상에 등록된다.


persist는 저장 대상으로 등록하는 흐름이다

em.persist(et)는 EntityTest1 객체를 저장 대상으로 등록한다.
이때 객체는 JPA가 관리하는 상태가 된다.

// EntityTestApp1.java
em.persist(et); // Entity를 저장 대상으로 등록

초보자가 가장 많이 헷갈리는 부분은 persist()를 “데이터베이스 저장 완료”라고 바로 이해하는 것이다.
이 단계에서는 저장 요청 또는 저장 대상 등록으로 이해하는 것이 더 안전하다.


일반적인 흐름에서는 persist()로 영속성 컨텍스트에 저장 대상이 등록되고, 이후 flush와 commit() 흐름을 거쳐 데이터베이스에 반영된다.


다만 이 예제의 EntityTest1은 IDENTITY 전략을 사용한다.
IDENTITY 전략은 데이터베이스가 기본키를 만들어 주는 방식이다.
그래서 JPA가 기본키 값을 알아야 하는 상황에서는 persist() 시점에 insert 쿼리가 먼저 실행될 수 있다.


따라서 콘솔에 insert 쿼리가 보였다고 해서 바로 최종 저장이 끝났다고 보면 안 된다.
현재 흐름에서는 아직 Transaction이 끝나지 않았기 때문에, 최종 저장 여부는 commit() 이후 데이터베이스에서 확인하는 것이 가장 안전하다.


정리하면 아래처럼 봐야 한다.

  • persist()는 Entity를 저장 대상으로 등록한다.
  • 일반적으로 최종 확정은 commit()에서 이루어진다.
  • IDENTITY 전략에서는 기본키 값을 얻기 위해 persist() 시점에 insert 쿼리가 보일 수 있다.
  • insert 쿼리가 보여도 최종 저장 여부는 commit() 이후에 확인하는 것이 안전하다.

persist()의 의미와 IDENTITY 전략 때문에 쿼리가 빨리 실행될 수 있는 흐름을 구분해야 한다.


Scanner는 commit 전 상태를 확인하기 위한 대기 코드이다

코드 중간에는 Scanner로 엔터 입력을 기다리는 부분이 있다.
이 코드는 사용자에게 값을 입력받아 저장하려는 목적이 아니다.
실행을 잠시 멈춰서 중간 상태를 확인하기 위한 장치이다.

// EntityTestApp1.java
System.out.println("엔터키....."); // 중간 확인 안내 출력
Scanner scan = new Scanner(System.in); // 입력 도구 생성
scan.nextLine(); // 엔터 입력 대기

이 시점은 persist()를 모두 호출한 뒤이고, commit()은 아직 실행되기 전이다.
따라서 엔터를 누르기 전후를 나누어 보면 persist() 이후와 commit() 이후의 흐름을 비교할 수 있다.


다만 앞에서 말한 것처럼 IDENTITY 전략에서는 persist() 시점에 insert 쿼리가 이미 보일 수 있다.
그렇더라도 최종적으로 작업을 확정하는 흐름은 commit()과 연결해서 봐야 한다.


이 예제에서는 Scanner가 데이터 저장의 핵심 기능은 아니다.
실행 흐름을 잠시 멈춰서 관찰하기 위한 코드로 보면 된다.


commit은 Transaction을 최종 확정한다

마지막에는 commit()이 실행된다.
commit()은 Transaction 안에서 진행한 데이터 변경 작업을 최종 확정하는 메서드이다.

// EntityTestApp1.java
em.getTransaction().commit(); // 저장 작업 확정

persist()가 저장 대상으로 등록하는 흐름이라면, commit()은 그 작업을 확정하는 흐름이다.
그래서 저장, 수정, 삭제 예제를 볼 때는 마지막에 commit()이 있는지 반드시 확인해야 한다.


이 예제에서는 commit()이 실행되므로 최종적으로 EntityTest1 객체 5개가 데이터베이스에 저장되는 흐름으로 이어진다.


데이터 변경 작업은 persist() 호출만 보는 것이 아니라, 마지막에 commit()으로 확정되는지까지 확인해야 한다.


결과물은 어떻게 나오는가

이 예제를 실행하면 콘솔에 먼저 엔터키.....가 출력된다.
프로그램은 이 지점에서 사용자가 엔터를 누를 때까지 기다린다.
엔터를 누르면 commit()이 실행되고 프로그램이 종료된다.


실행 중에는 Hibernate가 생성한 insert 쿼리가 보일 수 있다.
특히 EntityTest1의 기본키는 IDENTITY 전략이므로, 기본키 값을 얻기 위해 persist() 시점에 insert 쿼리가 실행되는 흐름을 볼 수 있다.


하지만 insert 쿼리가 보였다는 사실만으로 최종 저장까지 확정되었다고 보면 안 된다.
이 예제에서는 엔터 입력 후 commit()이 실행되므로, 최종 확인은 commit() 이후 entitytesttbl을 조회해서 확인하는 것이 안전하다.


EntityTestApp1.java를 실행하면 반복문 안에서 persist()가 여러 번 호출되고, 콘솔에 insert 쿼리가 출력될 수 있다.
마지막에는 엔터키.....에서 실행이 멈춘다.
이 시점은 persist() 호출 이후이지만 아직 commit() 전 상태이다.
엔터를 입력하면 commit()이 실행되어 저장 작업이 확정된다.


데이터베이스에서 entitytesttbl을 조회하면 둘리1부터 둘리5까지 저장된 데이터를 확인할 수 있다.
id 값은 데이터베이스가 자동으로 생성한 값이고, name, age, birthday는 코드에서 넣은 값이다.


entitytesttbl을 조회하면 둘리1부터 둘리5까지 저장된 데이터를 확인할 수 있다.
현재 화면에는 같은 예제를 여러 번 실행한 결과가 누적되어 25개 행이 보인다.
각 실행마다 EntityTest1 객체 5개가 저장되며, id는 IDENTITY 전략에 따라 데이터베이스가 자동 생성한다.

// 출력결과
// 엔터키.....
// 엔터 입력 후 commit 실행
// entitytesttbl 테이블에 둘리1~둘리5 데이터 저장

결과를 볼 때는 단순히 데이터가 들어갔는지만 보면 부족하다.
persist()가 반복문 안에서 여러 번 호출되고, 마지막에 commit()으로 작업이 확정된다는 흐름을 함께 봐야 한다.

결과물을 확인할 때 봐야 할 기준

  • 콘솔에서 엔터키.....가 출력되는지 확인한다.
  • 엔터 입력 전에는 commit() 전 상태임을 이해한다.
  • insert 쿼리가 언제 출력되는지 확인한다.
  • insert 쿼리 출력과 최종 저장 확정은 구분해서 본다.
  • 엔터 입력 후 commit()이 실행되는 흐름을 확인한다.
  • 데이터베이스에서 entitytesttbl에 데이터가 저장되었는지 확인한다.

이 예제의 결과물은 저장된 데이터 자체보다, 저장 요청과 최종 확정 시점을 나누어 보는 것이 중요하다.


핵심 정리

EntityTestApp1.java는 EntityTest1 객체를 여러 개 저장하면서 JPA 저장 흐름을 확인하는 예제이다.
반복문으로 객체를 만들고, 값을 넣고, persist()로 저장 대상으로 등록한 뒤, commit()으로 최종 확정한다.


정리하면 아래와 같다.

  • EntityTest1은 entitytesttbl 테이블과 매핑된다.
  • 반복문으로 EntityTest1 객체 5개를 만든다.
  • et 변수는 하나지만 반복마다 새 객체가 만들어진다.
  • 각 객체에 이름, 나이, 현재 날짜와 시간을 넣는다.
  • persist()는 저장 대상으로 등록하는 흐름이다.
  • IDENTITY 전략에서는 persist() 시점에 insert 쿼리가 보일 수 있다.
  • insert 쿼리 출력과 최종 commit() 확정은 구분해서 봐야 한다.
  • Scanner는 commit() 전 상태를 확인하기 위한 대기 코드로 볼 수 있다.
  • commit()은 Transaction 안의 저장 작업을 최종 확정한다.

이 예제를 이해하면 이후 flush, rollback(), remove() 예제에서 데이터 변경 작업이 언제 반영되고 언제 확정되는지 더 쉽게 이해할 수 있다.




응용예제 5. JPQL로 Entity 전체 조회하기 (EntityTestApp2.java, EntityTest1.java)

EntityTestApp2.java는 EntityTest1 데이터를 전체 조회하는 예제이다.
앞의 예제에서는 EntityTest1 객체를 새로 만들어 저장했다.
이번 예제에서는 이미 저장된 entitytesttbl 데이터를 JPQL로 조회해서 EntityTest1 객체 목록으로 가져온다.


이 예제의 핵심은 JPQL 조회 문장, TypedQuery, getResultList(), List, stream().forEach() 흐름을 연결해서 보는 것이다.
저장 예제와 달리 데이터를 변경하지 않으므로 Transaction을 직접 시작하지 않는다.


이 예제는 데이터베이스에 저장된 행을 Entity 객체 목록으로 조회하는 기본 JPQL 조회 흐름을 확인하는 예제이다.


이전 기본개념과 연결하기

이전 기본개념에서 JPQL은 테이블이 아니라 Entity를 기준으로 작성하는 조회 언어라고 정리했다.
즉, 실제 테이블명이 entitytesttbl이어도 JPQL에서는 EntityTest1이라는 엔티티 이름을 사용한다.


또 EntityManager는 조회 작업도 처리한다고 정리했다.
저장할 때는 persist()를 사용했고, 기본키로 한 건을 찾을 때는 find()를 사용할 수 있다.
여러 데이터를 조건이나 목록 형태로 조회할 때는 createQuery()를 사용한다.


이번 예제에서는 createQuery()로 JPQL을 만들고, getResultList()로 여러 개의 결과를 가져온다.
가져온 결과는 List<EntityTest1> 형태로 저장된다.

기본개념에서 다시 잡아야 할 흐름

  • JPQL은 데이터베이스 테이블명이 아니라 Entity 이름을 기준으로 작성한다.
  • select t from EntityTest1 t에서 EntityTest1은 엔티티 이름이다.
  • t는 쿼리 안에서 EntityTest1을 짧게 부르기 위한 별칭이다.
  • TypedQuery는 조회 결과 타입이 정해진 쿼리 객체이다.
  • getResultList()는 여러 건의 조회 결과를 List로 가져온다.
  • 조회만 하는 예제이므로 begin()과 commit()이 나오지 않는다.

조회 예제에서는 JPQL이 실제 테이블명이 아니라 엔티티 이름을 사용한다는 점을 가장 먼저 확인해야 한다.


예제의 목표

이 예제의 목표는 EntityTest1 데이터를 전체 조회하고 출력하는 흐름을 확인하는 것이다.
앞에서 EntityTestApp1.java로 둘리1부터 둘리5까지 저장했다면, 이번 예제는 그 데이터를 다시 읽어오는 흐름이다.


즉, EntityTestApp1.java가 저장 예제라면 EntityTestApp2.java는 조회 예제이다.
저장과 조회는 사용하는 메서드와 실행 흐름이 다르다.
저장은 persist()와 commit()이 중요하고, 조회는 JPQL, TypedQuery, getResultList()가 중요하다.

이 예제에서 확인할 내용

  • "entitytest" 설정으로 EntityManagerFactory를 만든다.
  • EntityManager를 만든다.
  • JPQL 문장으로 EntityTest1 전체를 조회한다.
  • 조회 결과를 List<EntityTest1>로 받는다.
  • stream().forEach()로 목록을 하나씩 출력한다.
  • 조회 예제이므로 Transaction을 시작하지 않는다.

이 흐름을 이해하면 뒤에서 나오는 조건 조회, 연관 필드 조회, DAO 조회 메서드 흐름도 더 쉽게 이어진다.


코드 흐름

EntityTestApp2.java는 EntityManagerFactory와 EntityManager를 만든 뒤, JPQL로 EntityTest1 전체 목록을 조회한다.
조회 결과는 List로 받고, 목록의 각 요소를 콘솔에 출력한다.


핵심 코드는 아래와 같다.

// EntityTestApp2.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

TypedQuery<EntityTest1> q = em.createQuery(
        "select t from EntityTest1 t", EntityTest1.class
); // EntityTest1 전체 조회 JPQL 생성

List<EntityTest1> list = q.getResultList(); // 조회 결과를 List로 받음

list.stream().forEach(e -> System.out.println(e)); // 목록을 하나씩 출력

factory.close(); // Factory 자원 정리

Persistence.createEntityManagerFactory("entitytest")는 "entitytest" 설정 묶음을 읽어 EntityManagerFactory를 만든다.
그다음 factory.createEntityManager()로 실제 조회 작업에 사용할 EntityManager를 만든다.


이 예제에서 가장 중요한 코드는 em.createQuery("select t from EntityTest1 t", EntityTest1.class)이다.
이 한 줄에서 JPQL 문장을 만들고, 결과 타입을 EntityTest1.class로 지정한다.


q.getResultList()는 조회 결과를 여러 건 가져온다.
반환 결과는 List<EntityTest1>이다.
이 목록 안에는 데이터베이스 행이 그대로 들어 있는 것이 아니라, 조회 결과로 만들어진 EntityTest1 객체들이 들어 있다.


제공된 예제 코드에서는 마지막에 factory.close()로 EntityManagerFactory를 정리한다.
다만 일반적인 자원 정리 흐름에서는 EntityManager도 사용 후 em.close()로 닫아 주는 것이 좋다.
이후 예제들을 볼 때는 작업 객체인 EntityManager와 공장 객체인 EntityManagerFactory를 모두 정리하는 흐름도 함께 기억하면 된다.


핵심 코드 해석

select t from EntityTest1 t는 Entity 기준 조회이다

이 예제의 JPQL 문장은 아래와 같다.

// EntityTestApp2.java
"select t from EntityTest1 t" // EntityTest1 전체 조회

select t는 조회 결과로 t 전체를 가져오겠다는 뜻이다.
여기서 t는 EntityTest1 엔티티의 별칭이다.


from EntityTest1 t는 EntityTest1 엔티티를 조회 대상으로 삼고, 그 엔티티를 쿼리 안에서 t라고 부르겠다는 뜻이다.


여기서 중요한 점은 entitytesttbl이라는 테이블명을 쓰지 않는다는 것이다.
EntityTest1.java에는 @Table(name="entitytesttbl")이 있었지만, JPQL에서는 테이블명이 아니라 엔티티 클래스 이름인 EntityTest1을 사용한다.

SQL과 JPQL의 차이

  • SQL: 실제 테이블명을 기준으로 작성한다.
  • JPQL: Entity 이름과 필드명을 기준으로 작성한다.
  • 실제 테이블명은 entitytesttbl이다.
  • JPQL 조회 대상은 EntityTest1이다.

JPQL에서 EntityTest1을 사용한다고 해서 테이블 이름이 EntityTest1이라는 뜻은 아니다.


TypedQuery는 결과 타입을 정해 둔 Query이다

TypedQuery<EntityTest1>은 조회 결과가 EntityTest1 타입이라는 뜻이다.
Typed는 타입이 정해져 있다는 의미로 이해하면 된다.

// EntityTestApp2.java
TypedQuery<EntityTest1> q = em.createQuery(
        "select t from EntityTest1 t", EntityTest1.class
); // 결과 타입이 EntityTest1인 Query

createQuery()의 두 번째 인자인 EntityTest1.class는 조회 결과 타입을 알려 준다.
그래서 결과를 받을 때 List<EntityTest1>로 자연스럽게 받을 수 있다.


만약 결과 타입을 정하지 않는 일반 Query를 사용하면, 나중에 결과를 꺼낼 때 타입을 직접 확인하거나 형변환해야 하는 상황이 생길 수 있다.
초보자 단계에서는 결과 타입이 명확한 조회라면 TypedQuery가 더 읽기 쉽다.

TypedQuery에서 봐야 할 핵심

  • 조회 결과 타입이 EntityTest1로 정해져 있다.
  • EntityTest1.class가 결과 타입 기준이 된다.
  • 결과를 List<EntityTest1>로 받을 수 있다.
  • 타입이 명확하므로 코드를 읽기 쉽다.



getResultList는 여러 건의 결과를 List로 가져온다

getResultList()는 조회 결과가 여러 건일 때 사용하는 메서드이다.
이 예제에서는 전체 데이터를 조회하므로 결과가 여러 개일 수 있다.

// EntityTestApp2.java
List<EntityTest1> list = q.getResultList(); // 여러 건의 결과 조회

List는 여러 값을 순서대로 담는 자료구조이다.
여기서는 조회된 EntityTest1 객체들이 List 안에 들어간다.


앞의 저장 예제에서 데이터를 여러 개 넣었다면, 이번 조회 결과도 여러 개가 될 수 있다.
따라서 getSingleResult()처럼 한 건만 가져오는 메서드가 아니라, getResultList()를 사용한다.

getResultList와 getSingleResult 차이

  • getResultList(): 결과가 여러 건일 때 사용한다.
  • getSingleResult(): 결과가 한 건이라고 예상할 때 사용한다.
  • 전체 조회는 보통 여러 건이므로 getResultList()가 자연스럽다.

전체 조회처럼 결과가 여러 개일 수 있는 경우에는 getResultList()를 사용해야 한다.


stream().forEach는 목록을 하나씩 출력한다

조회 결과는 List<EntityTest1>이다.
목록 안에는 여러 개의 EntityTest1 객체가 들어 있다.
이 객체들을 콘솔에 하나씩 출력하기 위해 stream().forEach()를 사용한다.

// EntityTestApp2.java
list.stream().forEach(e -> System.out.println(e)); // 목록 요소를 하나씩 출력

stream()은 목록 데이터를 하나씩 처리하기 위한 흐름을 만든다.
forEach()는 그 흐름 안의 요소를 하나씩 꺼내 실행한다.
e -> System.out.println(e)는 목록에서 꺼낸 요소 e를 출력하겠다는 뜻이다.


이 코드는 일반 반복문으로 쓰면 아래 흐름과 비슷하다.

// EntityTestApp2ForLoop.java
for (EntityTest1 e : list) { // 목록 요소를 하나씩 꺼냄
    System.out.println(e); // 객체 출력
}

두 코드 모두 list 안의 EntityTest1 객체를 하나씩 출력한다.
차이는 표현 방식이다.
초보자 단계에서는 stream().forEach()를 “목록을 하나씩 돌면서 출력한다” 정도로 이해하면 된다.


System.out.println(e)를 실행하면 EntityTest1 객체가 문자열로 출력된다.
EntityTest1.java에는 @ToString이 붙어 있으므로 객체의 필드 값이 보기 좋은 형태로 출력될 수 있다.


조회만 하므로 Transaction을 시작하지 않는다

EntityTestApp2.java에는 em.getTransaction().begin()이 없다.
또 commit()도 없다.


이유는 이 예제가 데이터를 변경하지 않고 조회만 하기 때문이다.
조회는 데이터베이스 상태를 바꾸지 않는다.
그래서 이 코드에서는 Transaction을 직접 시작하지 않고 JPQL 조회만 수행한다.


앞의 저장 예제와 비교하면 차이가 분명하다.

// SaveFlow.java
em.getTransaction().begin(); // 저장 작업 시작
em.persist(entity); // 저장 요청
em.getTransaction().commit(); // 저장 확정
// ReadFlow.java
TypedQuery<EntityTest1> q = em.createQuery(
        "select t from EntityTest1 t", EntityTest1.class
); // 조회 Query 생성
List<EntityTest1> list = q.getResultList(); // 조회 실행

저장 흐름에는 Transaction 시작과 확정이 있다.
조회 흐름에는 JPQL을 만들고 결과를 가져오는 흐름이 중심이다.


저장 예제와 조회 예제를 비교할 때는 Transaction이 필요한 이유가 데이터 변경 여부와 연결된다는 점을 봐야 한다.


결과물은 어떻게 나오는가

이 예제를 실행하면 entitytesttbl에 저장된 데이터들이 콘솔에 한 줄씩 출력된다.
출력 형식은 EntityTest1 객체의 toString() 결과 형태로 나온다.


앞에서 EntityTestApp1.java를 여러 번 실행했다면 데이터가 누적되어 출력 줄이 많을 수 있다.
이것은 오류가 아니다.
EntityTestApp1.java를 실행할 때마다 EntityTest1 객체 5개가 저장되기 때문에, 조회 결과도 그만큼 늘어난다.


EntityTestApp2.java를 실행하면 JPQL의 select t from EntityTest1 t가 실행된다.
코드에서는 EntityTest1 엔티티를 기준으로 조회하지만, 실제 SQL 로그에서는 entitytesttbl 테이블을 대상으로 select가 실행되는 것을 확인할 수 있다.
조회 결과는 EntityTest1(id=..., name=..., age=..., birthday=...) 형태로 콘솔에 출력된다.
현재 화면에는 이전 저장 예제를 여러 번 실행한 데이터가 누적되어 여러 줄로 출력된다.


예상 출력 흐름은 아래와 같다.

// 출력결과
// Hibernate:
//     select ...
//     from entitytesttbl ...
// EntityTest1(id=1, name=둘리1, age=11, birthday=...)
// EntityTest1(id=2, name=둘리2, age=12, birthday=...)
// EntityTest1(id=3, name=둘리3, age=13, birthday=...)
// ...

결과에서 봐야 할 핵심은 출력 줄 수가 아니다.
JPQL로 전체 조회를 실행했고, 조회된 각 행이 EntityTest1 객체로 출력된다는 점이다.

결과물을 확인할 때 봐야 할 기준

  • select t from EntityTest1 t가 실행되는지 확인한다.
  • 실제 SQL 로그에서는 entitytesttbl이 조회되는지 확인한다.
  • 콘솔에 EntityTest1(...) 형태로 객체들이 출력되는지 확인한다.
  • id, name, age, birthday 값이 출력되는지 확인한다.
  • 이전 저장 예제를 여러 번 실행했다면 데이터가 누적되어 출력될 수 있음을 이해한다.
  • 조회 예제이므로 begin()과 commit()이 없다는 점을 확인한다.

이 예제의 결과물은 JPQL 조회 결과가 Entity 객체 목록으로 출력된다는 점을 확인하는 데 초점을 둔다.


핵심 정리

EntityTestApp2.java는 JPQL로 EntityTest1 전체 목록을 조회하는 예제이다.
앞의 저장 예제에서 데이터베이스에 넣은 데이터를 다시 Entity 객체 목록으로 읽어오는 흐름이다.


정리하면 아래와 같다.

  • JPQL은 테이블명이 아니라 Entity 이름을 기준으로 작성한다.
  • 실제 테이블명은 entitytesttbl이지만, JPQL에서는 EntityTest1을 사용한다.
  • TypedQuery<EntityTest1>은 결과 타입이 EntityTest1로 정해진 조회 객체이다.
  • getResultList()는 여러 건의 조회 결과를 List로 가져온다.
  • stream().forEach()는 목록을 하나씩 출력하는 흐름이다.
  • @ToString 덕분에 객체 필드 값이 콘솔에 보기 좋게 출력된다.
  • 제공된 코드에서는 factory.close()로 마무리하지만, 일반적으로는 EntityManager도 사용 후 닫아 주는 것이 좋다.
  • 조회만 하는 예제이므로 Transaction을 직접 시작하지 않는다.

이 예제를 이해하면 JPQL로 조회한 데이터가 단순 행이 아니라 Entity 객체 목록으로 다루어진다는 흐름을 잡을 수 있다.




응용예제 6. 조회 쿼리 실행과 rollback 흐름 확인하기 (EntityTestApp3.java, EntityTest1.java)

EntityTestApp3.java는 하나의 Transaction 안에서 저장 요청, 조회, 추가 저장 요청, 다시 조회, 마지막 rollback()까지 확인하는 예제이다.
앞에서는 persist()로 저장 대상을 등록하고 commit()으로 최종 확정하는 흐름을 봤다.
이번 예제에서는 commit()이 아니라 rollback()으로 끝날 때 어떤 관점으로 결과를 봐야 하는지 확인한다.


이 예제는 단순히 데이터를 저장하고 조회하는 코드가 아니다.
Transaction 안에서 조회 쿼리가 실행될 때 저장 요청된 데이터가 어떻게 보일 수 있는지, 그리고 마지막에 rollback()하면 최종 저장이 취소된다는 점을 함께 확인한다.


이 예제의 핵심은 중간 조회 결과에 데이터가 보이더라도, 마지막에 rollback()되면 최종 저장은 확정되지 않는다는 점이다.


이전 기본개념과 연결하기

이전 기본개념에서 Transaction은 데이터 변경 작업을 하나의 작업 단위로 묶는다고 정리했다.
begin()으로 작업 단위를 시작하고, commit()으로 확정하거나 rollback()으로 되돌린다.


또 flush는 영속성 컨텍스트의 변경 내용을 데이터베이스에 보내는 과정이라고 정리했다.
여기서 중요한 점은 flush와 commit()은 같은 개념이 아니라는 것이다.
flush는 변경 내용을 데이터베이스에 보내는 과정이고, commit()은 그 작업을 최종 확정하는 과정이다.


이번 예제에는 em.flush()가 직접 쓰이지 않는다.
하지만 JPQL 조회가 실행될 때, 현재 Transaction 안에서 저장 요청된 내용이 조회 결과와 맞도록 반영되는 흐름을 확인할 수 있다.
특히 기본 설정에서는 조회 쿼리를 실행하기 전에 필요한 flush가 일어날 수 있다.


다만 이 예제의 EntityTest1은 IDENTITY 전략을 사용한다.
IDENTITY 전략은 데이터베이스가 기본키 값을 만들어 주는 방식이다.
그래서 persist() 시점에 기본키를 얻기 위해 insert 쿼리가 먼저 실행될 수 있다.
또 JPQL 조회를 실행할 때도 조회 결과를 맞추기 위해 변경 내용이 반영될 수 있다.
중요한 것은 insert 쿼리 출력이나 중간 조회 결과가 최종 저장 확정을 의미하지는 않는다는 점이다.

기본개념에서 다시 잡아야 할 흐름

  • persist()는 Entity를 저장 대상으로 등록한다.
  • IDENTITY 전략에서는 persist() 시점에 insert 쿼리가 보일 수 있다.
  • JPQL 조회는 Entity 이름과 필드명을 기준으로 실행된다.
  • 조회 쿼리 실행 전에는 영속성 컨텍스트의 변경 내용이 반영될 수 있다.
  • flush는 데이터베이스에 SQL을 보내는 흐름이다.
  • commit()은 변경 작업을 최종 확정한다.
  • rollback()은 현재 Transaction 안의 변경 작업을 되돌린다.

insert 쿼리가 보이거나 중간 조회 결과에 데이터가 보여도, 마지막에 commit()되지 않으면 최종 저장으로 확정된 것이 아니다.


예제의 목표

이 예제의 목표는 Transaction 안에서 저장 요청과 조회가 섞였을 때 결과를 어떻게 해석해야 하는지 확인하는 것이다.
처음에는 도우너11부터 도우너15까지 저장 요청한다.
그다음 엔터 입력 후 전체 조회를 실행한다.


이후에는 또치21부터 또치25까지 추가로 저장 요청한다.
다시 엔터 입력 후 전체 조회를 실행한다.
마지막에는 commit()이 아니라 rollback()을 실행한다.

이 예제에서 확인할 내용

  • 하나의 Transaction 안에서 여러 번 persist()를 호출한다.
  • 중간에 JPQL 전체 조회를 실행한다.
  • 첫 번째 조회에서는 기존 데이터와 도우너 데이터가 함께 보일 수 있다.
  • 두 번째 조회에서는 기존 데이터, 도우너 데이터, 또치 데이터가 함께 보일 수 있다.
  • 마지막에 rollback()을 실행한다.
  • 최종적으로는 이번 Transaction에서 저장 요청한 데이터가 확정되지 않는다.
  • rollback() 이후 자동 증가 번호가 건너뛴 것처럼 보일 수 있다.

이 예제는 결과가 길게 나올 수 있다.
그래서 출력 줄 하나하나를 외우기보다, 저장 요청 → 조회 → 추가 저장 요청 → 조회 → rollback() 순서로 읽어야 한다.


코드 흐름

EntityTestApp3.java는 Transaction을 시작한 뒤, 첫 번째 반복문에서 도우너 데이터를 저장 요청한다.
그다음 엔터 입력을 기다리고, JPQL로 전체 조회를 실행한다.


핵심 코드는 아래와 같다.

// EntityTestApp3.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성
EntityTest1 et; // 저장할 Entity 참조 변수

em.getTransaction().begin(); // Transaction 시작

for(int i=11; i < 16; i++) { // 11부터 15까지 반복
    et = new EntityTest1(); // 새 Entity 객체 생성
    et.setName("도우너"+i); // 이름 저장
    et.setAge(10+i); // 나이 저장
    et.setBirthday(LocalDateTime.now()); // 현재 날짜와 시간 저장
    em.persist(et); // 저장 요청
}

Scanner scan = new Scanner(System.in); // 입력 도구 생성
System.out.println("엔터키....."); // 중간 확인 안내 출력
scan.nextLine(); // 엔터 입력 대기

TypedQuery<EntityTest1> q = em.createQuery("select t from EntityTest1 t", EntityTest1.class); // 전체 조회 JPQL
List<EntityTest1> list = q.getResultList(); // 조회 실행
list.stream().forEach(System.out::println); // 조회 결과 출력

첫 번째 반복문은 총 5번 실행된다.
i가 11부터 15까지 변하므로 이름은 도우너11, 도우너12, 도우너13, 도우너14, 도우너15가 된다.
나이는 21부터 25까지 들어간다.


그다음 Scanner로 엔터 입력을 기다린다.
이 대기 코드는 중간 상태를 관찰하기 위한 장치이다.
엔터를 누르면 JPQL 전체 조회가 실행되고, 현재 조회 가능한 EntityTest1 목록이 출력된다.


이후 두 번째 반복문이 실행된다.
이번에는 또치21부터 또치25까지 저장 요청한다.

// EntityTestApp3.java
for(int i=21; i < 26; i++) { // 21부터 25까지 반복
    et = new EntityTest1(); // 새 Entity 객체 생성
    et.setName("또치"+i); // 이름 저장
    et.setAge(10+i); // 나이 저장
    et.setBirthday(LocalDateTime.now()); // 현재 날짜와 시간 저장
    em.persist(et); // 저장 요청
}

System.out.println("엔터키....."); // 중간 확인 안내 출력
scan.nextLine(); // 엔터 입력 대기

q = em.createQuery("select t from EntityTest1 t", EntityTest1.class); // 전체 조회 JPQL 다시 생성
list = q.getResultList(); // 다시 조회 실행
list.stream().forEach(System.out::println); // 조회 결과 출력

int a=1; // 별 의미 없는 변수
System.out.println("엔터키....."); // rollback 전 확인 안내 출력
scan.nextLine(); // 엔터 입력 대기

em.getTransaction().rollback(); // Transaction 되돌리기
em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기
scan.close(); // Scanner 닫기

두 번째 조회에서는 첫 번째 조회 때보다 결과가 더 많아질 수 있다.
두 번째 반복문에서 또치 데이터까지 저장 대상으로 등록했기 때문이다.


하지만 마지막에 rollback()이 실행된다.
따라서 중간 조회에서 데이터가 보였더라도, 이번 Transaction에서 새로 저장 요청한 도우너와 또치 데이터는 최종 확정되지 않는다.


핵심 코드 해석

하나의 Transaction 안에서 여러 번 저장 요청한다

이 예제는 begin()을 한 번만 호출한다.
그 뒤에 도우너 저장 요청, 첫 번째 조회, 또치 저장 요청, 두 번째 조회, rollback()이 모두 이어진다.

// EntityTestApp3.java
em.getTransaction().begin(); // 하나의 Transaction 시작

em.persist(et); // 도우너 저장 요청 흐름

List<EntityTest1> list = q.getResultList(); // 중간 조회

em.persist(et); // 또치 저장 요청 흐름

list = q.getResultList(); // 다시 중간 조회

em.getTransaction().rollback(); // 전체 작업 되돌리기

여기서 중요한 점은 Transaction이 중간에 끝나지 않는다는 것이다.
중간에 조회가 있어도 commit()이 실행된 것은 아니다.
마지막에 rollback()이 실행되기 전까지는 같은 작업 단위 안에 있다.


하나의 Transaction 안에서 조회 결과가 보인다고 해서 변경 작업이 최종 확정된 것은 아니다.


JPQL 조회 전에는 변경 내용이 반영될 수 있다

첫 번째 반복문에서 도우너 데이터를 persist()한 뒤, 바로 JPQL 전체 조회를 실행한다.
이때 조회 결과에 도우너 데이터가 보일 수 있다.

// EntityTestApp3.java
TypedQuery<EntityTest1> q = em.createQuery(
        "select t from EntityTest1 t", EntityTest1.class
); // EntityTest1 전체 조회

List<EntityTest1> list = q.getResultList(); // 조회 실행

이 흐름은 JPA가 조회 결과를 현재 영속성 컨텍스트의 변경 내용과 맞추기 위해 처리하는 과정과 연결된다.
기본 설정에서는 JPQL 조회가 실행되기 전에 필요한 변경 내용이 데이터베이스에 전달될 수 있다.


다만 이 예제는 IDENTITY 전략도 함께 사용한다.
그래서 일부 insert 쿼리는 조회 시점이 아니라 persist() 시점에 이미 보일 수 있다.
즉, 이 예제에서 쿼리가 보이는 이유를 하나로만 단정하면 안 된다.
IDENTITY 전략 때문에 먼저 insert가 실행될 수도 있고, 조회 실행 전 변경 내용 반영 흐름도 함께 볼 수 있다.


하지만 어떤 시점에 insert 쿼리가 보였든 핵심은 같다.
조회를 위해 SQL이 실행되거나 데이터가 조회 결과에 보이더라도, 마지막에 rollback()하면 이번 Transaction의 변경 작업은 되돌아간다.

조회 결과가 보이는 것과 최종 저장은 다르다

  • 중간 조회 결과에 도우너 데이터가 보일 수 있다.
  • 두 번째 조회 결과에 또치 데이터까지 보일 수 있다.
  • insert 쿼리가 persist() 시점에 먼저 보일 수도 있다.
  • 하지만 마지막이 rollback()이면 최종 저장은 확정되지 않는다.
  • 최종 확인은 rollback() 이후 데이터베이스를 다시 조회해서 판단해야 한다.

중간 조회에서 보이는 데이터와 최종 저장된 데이터는 같은 의미가 아니다.


rollback은 현재 Transaction의 변경 작업을 되돌린다

rollback()은 현재 Transaction 안에서 진행한 변경 작업을 되돌린다.
이 예제에서는 commit()이 아니라 rollback()이 호출된다.

// EntityTestApp3.java
em.getTransaction().rollback(); // 저장 요청한 작업 되돌리기

이 코드가 실행되면 현재 Transaction 안에서 저장 요청한 내용은 최종 확정되지 않는다.
즉, 도우너11부터 도우너15, 또치21부터 또치25는 중간 조회에서는 보일 수 있지만 최종 저장 데이터로 남지 않을 수 있다.


이 부분이 EntityTestApp1.java와 가장 큰 차이이다.
EntityTestApp1.java는 마지막에 commit()을 호출했다.
그래서 저장 작업이 확정되었다.


반면 EntityTestApp3.java는 마지막에 rollback()을 호출한다.
그래서 이번 실행에서 새로 저장 요청한 작업은 되돌아간다.


여기서 하나 더 주의할 점이 있다.
rollback() 이후에는 도우너, 또치 데이터가 최종 저장되지 않는다.
다만 IDENTITY 전략의 자동 증가 번호는 이미 증가한 뒤 되돌아가지 않을 수 있다.
그래서 나중에 다시 데이터를 저장하면 id가 중간에 건너뛴 것처럼 보일 수 있다.
이것은 데이터가 남아 있다는 뜻이 아니라, 자동 증가 번호가 이미 사용된 결과로 보면 된다.


rollback()은 데이터 변경을 되돌리지만, 데이터베이스의 자동 증가 번호까지 항상 이전 상태로 되돌린다고 보면 안 된다.


int a=1은 실행 흐름에 큰 의미가 없다

코드 중간에는 아래 문장이 있다.

// EntityTestApp3.java
int a=1; // 별 의미 없는 변수

이 변수는 저장, 조회, rollback() 흐름에 영향을 주는 핵심 코드가 아니다.
주석에도 별 의미 없다고 되어 있다.


따라서 이 예제를 볼 때 int a=1에 집중할 필요는 없다.
중요한 것은 그 아래의 엔터 대기와 rollback() 흐름이다.


초보자는 코드에 있는 모든 줄이 같은 중요도를 가진다고 생각할 수 있다.
하지만 예제를 읽을 때는 핵심 흐름과 보조 코드를 구분해야 한다.


이 예제에서 핵심은 int a=1이 아니라, 마지막에 rollback()으로 Transaction을 되돌린다는 점이다.


결과물은 어떻게 나오는가

이 예제를 실행하면 콘솔 출력이 길게 나올 수 있다.
먼저 도우너11부터 도우너15까지 저장 요청한 뒤, 엔터 입력을 기다린다.
엔터를 누르면 첫 번째 전체 조회 결과가 출력된다.


그다음 또치21부터 또치25까지 추가 저장 요청한 뒤 다시 엔터 입력을 기다린다.
엔터를 누르면 두 번째 전체 조회 결과가 출력된다.
두 번째 결과는 첫 번째 결과보다 더 많아 보일 수 있다.


마지막 엔터를 누르면 rollback()이 실행된다.
따라서 중간 조회 결과에 도우너와 또치가 보였더라도, 최종적으로 데이터베이스에 남는지는 rollback() 이후 다시 조회해서 확인해야 한다.

왼쪽은 첫 번째 JPQL 조회 결과이다.
도우너11부터 도우너15까지 저장 요청한 뒤 조회했기 때문에 중간 조회 결과에 도우너 데이터가 보인다.


오른쪽은 두 번째 JPQL 조회 결과이다.
또치21부터 또치25까지 추가로 저장 요청한 뒤 다시 조회했기 때문에 또치 데이터까지 함께 보인다.
하지만 아직 마지막 rollback() 전 중간 조회 결과이므로, 이 데이터가 최종 저장되었다고 보면 안 된다.


rollback() 이후 도우너 또는 또치로 시작하는 데이터를 다시 조회하면 Empty set이 출력된다.
이는 중간 조회 결과에는 도우너, 또치 데이터가 보였지만, 마지막 rollback()으로 이번 Transaction의 저장 요청이 최종 취소되었다는 뜻이다.

// 출력결과
// 엔터키.....
// 첫 번째 조회 결과 출력
// 도우너11~도우너15가 조회 결과에 보일 수 있음

// 엔터키.....
// 두 번째 조회 결과 출력
// 또치21~또치25가 추가로 조회 결과에 보일 수 있음

// 엔터키.....
// rollback 실행
// rollback 이후 도우너, 또치 조건 조회 결과는 Empty set

결과를 볼 때는 “중간 조회에서 보였다”와 “최종 저장되었다”를 분리해서 봐야 한다.
이 예제는 그 차이를 확인하기 위해 만들어진 흐름이다.


또 rollback() 이후 다음 저장 예제를 실행하면 id 값이 연속되지 않고 건너뛴 것처럼 보일 수 있다.
이것은 중간 데이터가 실제로 남아 있어서가 아니라, IDENTITY 전략의 자동 증가 번호가 이미 사용되었기 때문일 수 있다.

결과물을 확인할 때 봐야 할 기준

  • 첫 번째 엔터 전에는 도우너 저장 요청 이후 상태이다.
  • 첫 번째 조회 결과에 도우너 데이터가 보이는지 확인한다.
  • 두 번째 엔터 전에는 또치 저장 요청 이후 상태이다.
  • 두 번째 조회 결과에 또치 데이터까지 보이는지 확인한다.
  • 마지막에는 rollback()이 실행된다.
  • rollback() 이후 데이터베이스에 도우너, 또치가 최종 저장되어 있는지 다시 확인한다.
  • 이후 저장 시 id가 건너뛰어 보이면 자동 증가 번호 사용 여부를 함께 생각한다.

이 예제의 결과물은 중간 조회 결과보다 마지막 rollback() 이후 데이터베이스 상태를 함께 봐야 정확하다.


핵심 정리

EntityTestApp3.java는 저장 요청과 조회가 같은 Transaction 안에서 섞일 때 결과를 어떻게 해석해야 하는지 보여 주는 예제이다.
중간 조회 결과에 데이터가 보일 수 있지만, 마지막이 rollback()이면 최종 저장은 확정되지 않는다.


정리하면 아래와 같다.

  • 도우너11부터 도우너15까지 먼저 저장 요청한다.
  • 첫 번째 JPQL 조회로 현재 조회 가능한 목록을 출력한다.
  • 또치21부터 또치25까지 추가 저장 요청한다.
  • 두 번째 JPQL 조회로 다시 목록을 출력한다.
  • IDENTITY 전략 때문에 persist() 시점에 insert 쿼리가 보일 수 있다.
  • 조회 결과에 데이터가 보여도 최종 저장을 의미하지는 않는다.
  • 마지막에 rollback()이 실행되므로 이번 Transaction의 변경 작업은 되돌아간다.
  • rollback() 이후에도 자동 증가 번호는 되돌아가지 않을 수 있다.
  • 최종 데이터베이스 상태는 rollback() 이후 다시 조회해서 확인해야 한다.

이 예제를 이해하면 flush, JPQL 조회, rollback(), IDENTITY 자동 증가 흐름을 서로 구분할 수 있다.




응용예제 7. Entity 삭제 요청과 commit 주석 처리 확인하기 (EntityTestApp4.java, EntityTest1.java)

EntityTestApp4.java는 EntityTest1 데이터를 조회한 뒤 삭제 요청하는 예제이다.
앞에서는 persist()로 저장 대상을 등록하고, rollback()으로 변경 작업을 되돌리는 흐름을 확인했다.
이번에는 find()로 삭제할 Entity를 먼저 찾고, remove()로 삭제 요청하는 흐름을 확인한다.


이 예제에서 가장 중요한 점은 삭제 요청과 최종 삭제 확정을 구분하는 것이다.
코드에는 em.remove(finalData)가 있지만, 마지막 commit()은 주석 처리되어 있다.
따라서 삭제 요청이 있더라도 최종 반영 여부는 commit() 실행 여부와 함께 봐야 한다.


이 예제의 핵심은 remove()가 삭제 요청이고, 실제 삭제 확정은 Transaction의 commit()과 연결된다는 점을 확인하는 것이다.


이전 기본개념과 연결하기

이전 기본개념에서 EntityManager는 Entity를 저장, 조회, 삭제할 때 사용하는 객체라고 정리했다.
저장할 때는 persist()를 사용했고, 기본키로 한 건을 찾을 때는 find()를 사용했다.
삭제할 때는 remove()를 사용한다.


하지만 remove()만 보고 바로 데이터베이스에서 삭제가 끝났다고 이해하면 안 된다.
저장, 수정, 삭제처럼 데이터베이스 상태를 바꾸는 작업은 Transaction 안에서 처리되고, 마지막에 commit()으로 확정되어야 한다.


이번 예제는 remove()를 호출하지만 commit()이 주석 처리되어 있다.
그래서 삭제 요청이 있었더라도 최종 삭제가 확정되지 않을 수 있다는 점을 확인해야 한다.

기본개념에서 다시 잡아야 할 흐름

  • find()는 기본키로 Entity 한 건을 조회한다.
  • remove()는 조회된 Entity를 삭제 대상으로 등록한다.
  • 삭제도 데이터 변경 작업이므로 Transaction 안에서 처리한다.
  • commit()이 실행되어야 삭제 작업이 최종 확정된다.
  • commit()이 주석 처리되어 있으면 삭제 요청이 최종 반영되지 않을 수 있다.

삭제 예제는 remove() 호출 여부뿐 아니라 마지막에 commit()이 실행되는지까지 반드시 확인해야 한다.


예제의 목표

이 예제의 목표는 Entity 삭제 흐름을 확인하는 것이다.
먼저 find()로 삭제할 EntityTest1 객체를 찾는다.
그다음 remove()로 삭제 요청을 한다.
이후 JPQL 전체 조회로 현재 조회 결과를 출력한다.


다만 이 코드에서는 commit()이 주석 처리되어 있다.
즉, 삭제 요청을 했지만 최종 확정 단계는 막혀 있는 상태이다.
이 구조를 통해 remove()와 commit()의 차이를 확인할 수 있다.

이 예제에서 확인할 내용

  • Transaction을 시작한다.
  • find()로 삭제할 EntityTest1을 찾는다.
  • remove()로 삭제 요청한다.
  • JPQL로 전체 데이터를 다시 조회한다.
  • 엔터 입력으로 중간 상태를 확인한다.
  • commit()이 주석 처리되어 있음을 확인한다.
  • 삭제 요청과 최종 삭제 확정은 다르다는 점을 확인한다.

이 예제는 정상 삭제 결과를 확인하는 예제라기보다, 삭제 요청과 commit() 여부를 비교해서 보는 예제에 가깝다.


코드 흐름

EntityTestApp4.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 Transaction을 시작하고, 삭제할 데이터를 find()로 조회한다.


핵심 코드는 아래와 같다.

// EntityTestApp4.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성
EntityTest1 et; // Entity 참조 변수

em.getTransaction().begin(); // Transaction 시작

System.out.println("엔티티 삭제"); // 삭제 예제 안내 출력
EntityTest1 finalData = em.find(EntityTest1.class, 0); // 두 번째 아규먼트에 삭제할 기본키 값 설정
em.remove(finalData); // 조회된 Entity 삭제 요청

TypedQuery<EntityTest1> q = em.createQuery("select t from EntityTest1 t", EntityTest1.class); // 전체 조회 JPQL
List<EntityTest1> list = q.getResultList(); // 조회 결과를 List로 받음
list.stream().forEach(System.out::println); // 조회 결과 출력

System.out.println("엔터키....."); // 중간 확인 안내 출력
Scanner scan = new Scanner(System.in); // 입력 도구 생성
scan.nextLine(); // 엔터 입력 대기

// em.getTransaction().commit(); // commit이 주석 처리되어 있음
em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기
scan.close(); // Scanner 닫기

코드 흐름은 삭제 대상 조회 → 삭제 요청 → 전체 조회 → 엔터 대기 → 종료 순서로 이어진다.
하지만 commit()이 실제로 실행되지 않는다.
//로 주석 처리되어 있기 때문이다.


삭제 테스트를 할 때는 find(EntityTest1.class, 0)의 두 번째 인자는 삭제할 기본키 값을 넣는 자리이다.
원본 코드에서는 0이 들어 있으므로, 이 값이 삭제 대상 조회 조건으로 사용된다.


따라서 삭제 요청이 있더라도 최종 삭제 결과를 확인하려면 commit() 주석 처리 여부를 반드시 함께 봐야 한다.


핵심 코드 해석

find는 삭제할 Entity를 먼저 찾는 코드이다

삭제하려면 먼저 어떤 데이터를 삭제할지 알아야 한다.
JPA에서는 기본키를 알고 있을 때 find()로 해당 Entity를 조회할 수 있다.

// EntityTestApp4.java
EntityTest1 finalData = em.find(EntityTest1.class, 0); // 기본키 0번 Entity 조회

첫 번째 인자인 EntityTest1.class는 조회할 Entity 타입이다.
두 번째 인자인 0은 조회할 기본키 값이다.


즉, 이 코드는 EntityTest1 중에서 기본키가 0인 데이터를 찾으려는 코드이다.
원본 코드에서는 0이 삭제 대상 기본키 값으로 들어가 있다.


find()는 해당 기본키의 데이터가 있으면 Entity 객체를 반환하지만, 없으면 null을 반환할 수 있다.
null은 조회 결과가 없다는 뜻이다.

find에서 주의할 점

  • find(EntityTest1.class, 0)은 기본키가 0인 데이터를 찾는다.
  • 실제 데이터에 해당 id가 없으면 결과는 null일 수 있다.
  • 코드 주석의 “두 번째 아규먼트 설정”은 삭제할 기본키 값을 넣는 자리라는 의미로 볼 수 있다.
  • 이 값이 remove()로 넘길 삭제 대상 조회 기준이 된다.

삭제 예제를 실행할 때는 find()의 두 번째 인자에 실제 존재하는 기본키 값을 넣어야 한다.


remove는 조회된 Entity를 삭제 대상으로 등록한다

remove()는 Entity를 삭제 대상으로 등록하는 메서드이다.

// EntityTestApp4.java
em.remove(finalData); // 조회된 Entity 삭제 요청

여기서 중요한 점은 remove()에 넘기는 값이 Entity 객체여야 한다는 것이다.
삭제할 데이터의 id 숫자만 넘기는 것이 아니다.
먼저 find()로 Entity 객체를 조회한 뒤, 그 객체를 remove()에 넘긴다.


만약 finalData가 null이면 문제가 생길 수 있다.
즉, 조회 결과가 없는데 remove(null)을 호출하면 정상 삭제 흐름이 아니라 오류 흐름으로 이어질 수 있다.


따라서 이 예제를 실행할 때는 삭제할 데이터가 실제로 있는지 먼저 확인하는 것이 좋다.
원본 코드에서는 find(EntityTest1.class, 0)으로 삭제 대상을 조회한다.

remove에서 봐야 할 핵심

  • remove()는 삭제할 Entity 객체를 인자로 받는다.
  • 삭제할 id 값을 직접 넘기는 메서드가 아니다.
  • find()로 먼저 삭제 대상 객체를 가져와야 한다.
  • find() 결과가 null이면 remove() 흐름이 정상적으로 진행되지 않을 수 있다.

remove()는 삭제할 객체를 넘겨야 하므로, 삭제 전에 find()로 실제 Entity를 조회하는 흐름이 필요하다.


JPQL 조회는 삭제 요청 후 현재 목록을 확인하기 위한 코드이다

remove()를 호출한 뒤에는 JPQL로 전체 목록을 다시 조회한다.
이 코드는 삭제 요청 이후 현재 조회 결과가 어떻게 보이는지 확인하기 위한 흐름이다.

// EntityTestApp4.java
TypedQuery<EntityTest1> q = em.createQuery(
        "select t from EntityTest1 t", EntityTest1.class
); // 전체 조회 JPQL

List<EntityTest1> list = q.getResultList(); // 조회 실행
list.stream().forEach(System.out::println); // 결과 출력

select t from EntityTest1 t는 EntityTest1 전체 조회 JPQL이다.
앞 예제에서 봤듯이 JPQL은 실제 테이블명이 아니라 Entity 이름을 기준으로 작성한다.


삭제 요청 후 조회를 실행하면 현재 Transaction 안에서 삭제 대상으로 등록된 객체가 조회 결과에서 보이지 않을 수 있다.
하지만 이것을 최종 삭제 확정으로 바로 해석하면 안 된다.
마지막에 commit()이 실행되는지 확인해야 한다.


삭제 요청 후 조회 결과에서 데이터가 안 보이더라도, 최종 삭제 확정은 commit() 실행 여부로 판단해야 한다.


commit이 주석 처리되어 있다

이 예제에서 가장 중요한 부분은 마지막의 commit()이다.
정확히는 commit()이 실행되지 않는다는 점이다.

// EntityTestApp4.java
// em.getTransaction().commit(); // commit이 주석 처리되어 있음

앞에 //가 붙어 있으므로 이 줄은 실행되지 않는다.
Java에서 //는 한 줄 주석이다.
주석 처리된 코드는 프로그램 실행에 포함되지 않는다.


즉, 이 예제는 Transaction을 시작하고 삭제 요청까지 했지만, 마지막에 확정하지 않는다.
따라서 최종 데이터베이스 상태에서는 삭제가 반영되지 않을 수 있다.


여기서 앞 예제들과 비교하면 흐름이 분명해진다.

  • EntityTestApp1.java: 마지막에 commit()이 있어서 저장이 확정된다.
  • EntityTestApp3.java: 마지막에 rollback()이 있어서 저장 요청이 취소된다.
  • EntityTestApp4.java: commit()이 주석 처리되어 있어서 삭제 요청이 확정되지 않는다.

데이터 변경 예제에서는 마지막 줄이 commit()인지, rollback()인지, 주석 처리인지가 결과를 결정한다.


결과물은 어떻게 나오는가

이 예제를 실행하면 먼저 엔티티 삭제가 출력된다.
그다음 find()로 삭제 대상을 찾고, remove()로 삭제 요청을 한다.
이후 JPQL 전체 조회 결과가 콘솔에 출력된다.


단, 삭제 테스트에서는 find()의 두 번째 인자에 실제 존재하는 id 값을 넣어야 한다.
존재하지 않는 id를 넣으면 find() 결과가 null이 될 수 있고, remove() 흐름이 정상적으로 진행되지 않을 수 있다.


또 마지막에 commit()이 주석 처리되어 있으므로, 삭제 요청이 최종 반영되지 않을 수 있다.
삭제 후 목록에서 데이터가 빠진 것처럼 보이더라도, 프로그램 종료 후 데이터베이스를 다시 조회하면 그대로 남아 있을 수 있다.


EntityTestApp4.java를 실행하면 먼저 find()가 실행되면서 삭제할 대상을 기본키로 조회한다.
콘솔의 where et1_0.id=?는 find(EntityTest1.class, id)가 실제로는 기본키 조건 조회로 실행된다는 것을 보여 준다.
이후 코드에서는 remove()로 삭제 요청을 하지만, 마지막 commit()은 주석 처리되어 있다.


프로그램 실행 후 entitytesttbl을 다시 조회해도 기존 데이터가 그대로 남아 있다.
remove()로 삭제 요청은 했지만 commit()이 실행되지 않았기 때문에 최종 삭제가 확정되지 않은 것이다.

// 출력결과
// 엔티티 삭제
// find()가 기본키 조건으로 삭제 대상을 조회함
// remove()로 삭제 요청
// commit은 주석 처리되어 실행되지 않음
// 프로그램 종료 후 DB를 다시 조회하면 기존 데이터가 그대로 남아 있음

결과를 볼 때는 remove() 호출 여부만 보면 안 된다.
삭제 대상이 실제로 조회되었는지, remove()가 정상 실행되었는지, 마지막에 commit()이 실행되었는지까지 함께 봐야 한다.

결과물을 확인할 때 봐야 할 기준

  • find()의 두 번째 인자에 실제 존재하는 id를 넣었는지 확인한다.
  • 콘솔에서 where et1_0.id=? 형태의 기본키 조건 조회가 실행되는지 확인한다.
  • remove()는 삭제 요청일 뿐이라는 점을 확인한다.
  • commit()이 주석 처리되어 있으므로 최종 삭제 확정은 되지 않는다는 점을 확인한다.
  • 프로그램 종료 후 데이터베이스를 다시 조회했을 때 삭제 대상이 남아 있는지 확인한다.

이 예제의 결과물은 삭제 요청이 보이는지보다 commit()이 주석 처리되어 최종 삭제가 확정되지 않는다는 점을 확인하는 데 초점을 둔다.


핵심 정리

EntityTestApp4.java는 Entity 삭제 요청 흐름을 확인하는 예제이다.
find()로 삭제할 객체를 찾고, remove()로 삭제 대상으로 등록한다.
하지만 마지막 commit()이 주석 처리되어 있으므로 최종 삭제 확정은 일어나지 않을 수 있다.


정리하면 아래와 같다.

  • 삭제하려면 먼저 find()로 삭제 대상 Entity를 조회한다.
  • find()의 두 번째 인자는 기본키 값이다.
  • 실제 존재하는 id로 맞춰야 정상 테스트가 가능하다.
  • remove()는 삭제할 Entity 객체를 인자로 받는다.
  • remove()는 삭제 요청이지 최종 삭제 확정이 아니다.
  • 삭제도 데이터 변경 작업이므로 commit()이 필요하다.
  • 이 예제에서는 commit()이 주석 처리되어 있으므로 삭제가 최종 확정되지 않는다.

삭제 흐름에서는 find()로 삭제 대상을 찾고, remove()로 삭제 요청한 뒤, commit()으로 확정되는 전체 흐름을 함께 봐야 한다.




응용예제 8. Emp, Dept, Locations 연관관계 Entity 구조 확인하기 (Emp.java, Dept.java, Locations.java)

Emp.java, Dept.java, Locations.java는 사원, 부서, 지역 정보를 JPA 엔티티로 연결한 예제이다.
앞에서는 EntityTest 계열로 기본 매핑, 컬럼 설정, 날짜·시간 타입, 간단한 @ManyToOne 구조를 확인했다.
이번에는 실제 업무 데이터에 가까운 Emp, Dept, Locations 구조로 연관관계 매핑을 확인한다.


이 예제의 핵심은 Emp가 Dept를 참조하고, Dept가 Locations를 참조하는 흐름을 읽는 것이다.
테이블 기준으로 보면 사원 테이블에는 부서 번호가 있고, 부서 테이블에는 지역 코드가 있다.
객체 기준으로 보면 Emp 객체 안에 Dept 객체가 들어가고, Dept 객체 안에 Locations 객체가 들어간다.


이 예제는 외래키 값을 숫자나 문자열로만 들고 있는 구조가 아니라, 외래키로 연결된 대상을 객체로 참조하는 구조를 확인하는 예제이다.


이전 기본개념과 연결하기

이전 기본개념에서 @ManyToOne은 여러 엔티티가 하나의 엔티티와 연결되는 관계라고 정리했다.
예를 들어 여러 사원이 하나의 부서에 속할 수 있다.
이때 사원 입장에서는 부서와 다대일 관계가 된다.


또 @JoinColumn은 외래키 컬럼을 지정하는 어노테이션이라고 정리했다.
외래키는 한 테이블의 값이 다른 테이블의 기본키를 참조하는 컬럼이다.
테이블에서는 외래키 컬럼으로 연결하지만, 객체에서는 참조 필드로 연결한다.


이번 예제에서는 이 흐름이 두 번 이어진다.
Emp는 Dept를 참조하고, Dept는 Locations를 참조한다.
따라서 객체 흐름은 Emp → Dept → Locations 순서로 이어진다.

기본개념에서 다시 잡아야 할 흐름

  • @Entity는 JPA가 관리할 클래스라는 뜻이다.
  • @Id는 기본키 필드를 지정한다.
  • @Column은 컬럼의 길이, 타입 같은 세부 설정을 지정한다.
  • @ManyToOne은 현재 엔티티 여러 개가 대상 엔티티 하나와 연결될 수 있다는 뜻이다.
  • @JoinColumn은 외래키 컬럼명을 지정한다.
  • 테이블은 외래키 값으로 연결하고, 객체는 참조 필드로 연결한다.

연관관계 매핑을 볼 때는 테이블의 외래키 컬럼과 객체의 참조 필드가 어떻게 연결되는지 함께 봐야 한다.


예제의 목표

이 예제의 목표는 Emp, Dept, Locations 엔티티가 어떤 방향으로 연결되는지 확인하는 것이다.
단순히 필드 목록을 외우는 것이 아니라, 사원에서 부서로, 부서에서 지역으로 이동할 수 있는 객체 구조를 읽는 것이 중요하다.


Emp는 사원 정보를 담는다.
Dept는 부서 정보를 담는다.
Locations는 지역 정보를 담는다.
이 세 엔티티는 따로 떨어진 객체가 아니라, 외래키 관계를 통해 연결된다.

이 예제에서 확인할 내용

  • Emp의 기본키는 empno이다.
  • Emp는 deptno 외래키 컬럼을 통해 Dept를 참조한다.
  • Dept의 기본키는 deptno이다.
  • Dept는 loc_code 외래키 컬럼을 통해 Locations를 참조한다.
  • Locations의 기본키는 loc_code이다.
  • Emp → Dept → Locations 흐름으로 사원, 부서, 지역 정보를 객체로 따라갈 수 있다.

이 구조를 이해하면 뒤에서 나오는 Emp 조회, 연관 필드 접근, 로딩 전략, N+1, Fetch Join 흐름을 더 쉽게 이해할 수 있다.


Emp는 사원 정보와 부서 참조를 가진 Entity이다

Emp.java는 사원 정보를 표현하는 엔티티이다.
사원 번호, 이름, 직무, 관리자 번호, 입사일, 급여, 커미션 정보를 필드로 가진다.
그리고 부서 정보는 단순한 숫자가 아니라 Dept 객체로 참조한다.


코드 구조는 아래와 같다.

// Emp.java
@Entity // JPA가 관리할 Entity 클래스
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
public class Emp {
    @Id // 기본키 필드
    private int empno; // 사원 번호

    @Column(length = 14) // 문자열 길이 14
    private String ename; // 사원 이름

    @Column(length = 30) // 문자열 길이 30
    private String job; // 직무

    private Integer mgr; // 관리자 번호, null 가능
    private java.sql.Date hiredate; // 입사일
    private int sal; // 급여
    private Integer comm; // 커미션, null 가능

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
    @JoinColumn(name="deptno") // emp 테이블의 deptno 외래키와 연결
    private Dept deptno; // 부서 Entity 참조
}

empno는 Emp의 기본키이다.
기본키는 각 사원을 구분하는 값이다.


ename과 job에는 @Column(length = ...)가 붙어 있다.
이는 문자열 컬럼의 길이를 제한하는 설정이다.
ename은 길이 14, job은 길이 30으로 지정되어 있다.


mgr와 comm은 Integer 타입이다.
Integer는 참조형이므로 null을 가질 수 있다.
관리자가 없거나 커미션이 없는 사원을 표현하려면 0이 아니라 null이 필요할 수 있다.
그래서 int가 아니라 Integer를 사용한 것으로 이해할 수 있다.


가장 중요한 부분은 deptno 필드이다.
필드 이름은 deptno라서 부서 번호처럼 보이지만, 실제 타입은 Dept이다.
즉, 객체 입장에서는 부서 번호 숫자만 들고 있는 것이 아니라 부서 객체 자체를 참조한다.


@JoinColumn(name="deptno")는 현재 엔티티인 Emp 쪽에서 사용할 외래키 컬럼명을 지정한다.
테이블에는 deptno 컬럼이 있지만, 객체 코드에서는 그 컬럼 값으로 연결되는 Dept 객체를 필드에 담는다.

Emp에서 봐야 할 핵심

  • empno는 사원 엔티티의 기본키이다.
  • mgr, comm은 null을 표현할 수 있도록 Integer를 사용한다.
  • deptno 필드는 이름만 보면 번호처럼 보이지만 실제 타입은 Dept이다.
  • @JoinColumn(name="deptno")는 emp 테이블의 deptno 외래키 컬럼과 연결한다.
  • @ManyToOne은 여러 Emp가 하나의 Dept와 연결되는 다대일 관계를 뜻한다.

Emp에서 가장 중요한 점은 deptno 필드가 단순 번호가 아니라 Dept 객체 참조라는 점이다.

Emp 테이블 구조를 보면 EMPNO가 기본키이고, DEPTNO 컬럼이 존재한다.
화면에서는 컬럼명이 대문자로 보이지만, 코드의 @JoinColumn(name="deptno")와 연결되는 부서 외래키 컬럼으로 보면 된다.
객체에서는 이 DEPTNO 값을 직접 숫자로 다루기보다 Dept 타입의 deptno 필드로 부서 객체를 참조한다.


Dept는 부서 정보와 지역 참조를 가진 Entity이다

Dept.java는 부서 정보를 표현하는 엔티티이다.
부서 번호와 부서명을 가지고, 해당 부서가 어느 지역에 있는지는 Locations 객체로 참조한다.


코드 구조는 아래와 같다.

// Dept.java
@Entity // JPA가 관리할 Entity 클래스
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
public class Dept {
    @Id // 기본키 필드
    private int deptno; // 부서 번호

    @Column(length = 20) // 문자열 길이 20
    private String dname; // 부서명

    @ManyToOne // 여러 Dept가 하나의 Locations와 연결될 수 있음
    @JoinColumn(name="loc_code", referencedColumnName = "loc_code") // 외래키와 참조 대상 컬럼 지정
    private Locations loc_code; // 지역 Entity 참조
}

deptno는 Dept의 기본키이다.
각 부서를 구분하는 값이다.


dname은 부서명이다.
@Column(length = 20)이 붙어 있으므로 부서명 컬럼의 길이는 20으로 제한된다.


loc_code 필드는 지역 정보를 참조한다.
필드 이름은 loc_code라서 지역 코드 문자열처럼 보이지만, 실제 타입은 Locations이다.
즉, 부서 객체 안에는 지역 코드 값 하나만 있는 것이 아니라 지역 엔티티 객체를 참조하는 구조이다.


@JoinColumn(name="loc_code", referencedColumnName = "loc_code")는 두 가지를 알려 준다.
name="loc_code"는 현재 엔티티인 Dept 쪽 외래키 컬럼 이름이다.
referencedColumnName = "loc_code"는 참조 대상인 Locations 쪽 컬럼 이름이다.

JoinColumn에서 봐야 할 부분

  • name="loc_code"는 현재 엔티티 쪽 외래키 컬럼명이다.
  • referencedColumnName = "loc_code"는 참조 대상 엔티티 쪽 컬럼명이다.
  • Dept는 loc_code 외래키로 Locations를 참조한다.
  • 객체에서는 Dept.loc_code 필드를 통해 Locations 객체로 이동할 수 있다.

Dept의 loc_code도 단순 문자열 코드가 아니라 Locations 객체를 참조하는 필드로 매핑되어 있다.

Dept 테이블 구조를 보면 DEPTNO가 기본키이고, loc_code 컬럼이 존재한다.
이 loc_code 컬럼은 Locations와 연결되는 외래키 역할을 한다.
코드에서는 @JoinColumn(name="loc_code", referencedColumnName = "loc_code")로 이 관계를 지정하고, 객체에서는 Locations 타입의 loc_code 필드로 지역 객체를 참조한다.


Locations는 지역 정보를 가진 기준 Entity이다

Locations.java는 지역 정보를 표현하는 엔티티이다.
지역 코드와 도시명을 가진다.
이 엔티티는 Dept에서 참조하는 대상이 된다.


코드 구조는 아래와 같다.

// Locations.java
@Entity // JPA가 관리할 Entity 클래스
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
public class Locations {
    @Id // 기본키 필드
    @Column(columnDefinition = "char(2)") // char(2) 컬럼으로 지정
    private String loc_code; // 지역 코드

    @Column(length = 20) // 문자열 길이 20
    private String city; // 도시명
}

loc_code는 Locations의 기본키이다.
지역을 구분하는 코드이다.


@Column(columnDefinition = "char(2)")는 컬럼 정의를 직접 지정한 것이다.
loc_code는 길이가 고정된 char(2) 컬럼으로 생성되도록 설정되어 있다.


city는 도시명이다.
@Column(length = 20)이 붙어 있으므로 도시명 컬럼 길이는 20으로 제한된다.


Locations는 다른 엔티티를 참조하지 않는다.
하지만 Dept가 Locations를 참조하므로, 전체 구조에서는 지역 정보를 제공하는 기준 엔티티 역할을 한다.

Locations에서 봐야 할 핵심

  • loc_code는 지역 엔티티의 기본키이다.
  • loc_code는 char(2) 형태로 매핑된다.
  • city는 도시명을 저장한다.
  • Dept가 Locations를 참조한다.

Locations는 직접 다른 객체를 참조하지 않지만, Dept가 참조하는 대상 엔티티로 사용된다.

Locations 테이블 구조를 보면 LOC_CODE가 기본키이고, CITY가 도시명을 저장하는 컬럼이다.
Dept는 loc_code를 통해 Locations를 참조하므로, 전체 객체 흐름은 Emp → Dept → Locations로 이어진다.


세 Entity는 Emp에서 Locations까지 이어지는 구조이다

이 세 엔티티는 각각 따로 존재하지만, 실제 조회 흐름에서는 이어져서 사용된다.
Emp에서 시작하면 Dept로 이동할 수 있고, Dept에서 다시 Locations로 이동할 수 있다.


객체 흐름은 아래와 같다.

// EmpDeptLocationsFlow.java
Emp emp = ...; // 사원 Entity
Dept dept = emp.getDeptno(); // 사원이 속한 부서 Entity
Locations loc = dept.getLoc_code(); // 부서가 속한 지역 Entity
String city = loc.getCity(); // 도시명 조회

테이블 기준으로 보면 emp.deptno가 dept.deptno를 참조한다.
그리고 dept.loc_code가 locations.loc_code를 참조한다.
객체 기준으로 보면 Emp가 Dept 객체를 참조하고, Dept가 Locations 객체를 참조한다.


즉, 같은 관계를 테이블과 객체가 서로 다른 방식으로 표현한다.
테이블은 외래키 컬럼으로 연결하고, 객체는 참조 필드로 연결한다.


이름은 deptno나 loc_code처럼 번호와 코드로 보일 수 있다.
하지만 필드 타입이 각각 Dept, Locations이면 실제로는 단순 값이 아니라 연결된 엔티티 객체를 참조하는 구조이다.

테이블 흐름과 객체 흐름 비교

  • 테이블 흐름: emp.deptno → dept.deptno, dept.loc_code → locations.loc_code
  • 객체 흐름: Emp.deptno → Dept, Dept.loc_code → Locations
  • 테이블은 값으로 연결한다.
  • 객체는 객체 참조로 연결한다.

JPA 연관관계 매핑은 테이블의 외래키 관계를 객체 참조 구조로 바꿔서 다룰 수 있게 해 준다.


로딩 전략은 이 구조 설명에서 고정해서 다루지 않는다

이 파트의 목적은 Emp, Dept, Locations가 어떤 방향으로 연결되는지 이해하는 것이다.
따라서 여기서는 특정 로딩 전략을 기준으로 고정해서 설명하지 않는다.


중요한 것은 Emp가 Dept를 참조하고, Dept가 Locations를 참조한다는 구조이다.
이 구조가 있어야 뒤에서 Emp를 조회할 때 부서와 지역 정보가 어떤 흐름으로 연결되는지 이해할 수 있다.

여기서 기억할 정도

  • Emp는 Dept를 @ManyToOne으로 참조한다.
  • Dept는 Locations를 @ManyToOne으로 참조한다.
  • 로딩 전략은 연관 엔티티를 언제 가져올지 정하는 설정이다.
  • 이 파트에서는 로딩 전략보다 엔티티 사이의 참조 구조를 먼저 이해한다.

이번 파트에서는 로딩 전략 자체보다 Emp, Dept, Locations의 연관관계 구조를 먼저 이해하는 것이 중요하다.


테이블 구조는 어떤 기준으로 확인하면 좋은가

이 묶음은 직접 실행 결과보다 엔티티 구조와 테이블 관계를 확인하는 것이 중요하다.
따라서 콘솔 출력보다 Emp, Dept, Locations 테이블 구조와 외래키로 사용되는 컬럼을 확인하는 화면이 더 적절하다.


확인할 때는 Emp 테이블의 deptno 컬럼, Dept 테이블의 loc_code 컬럼, Locations 테이블의 loc_code 기본키를 보면 된다.
이 세 부분을 코드의 @JoinColumn 설정과 함께 보면 Emp → Dept → Locations 흐름이 성립하는 이유를 이해할 수 있다.


확인 기준은 아래와 같다.

  • Emp 테이블에 deptno 외래키 컬럼이 있는지 확인한다.
  • Dept 테이블에 loc_code 외래키 컬럼이 있는지 확인한다.
  • Locations 테이블에 loc_code 기본키가 있는지 확인한다.
  • Emp의 deptno가 Dept 참조 필드로 매핑되는지 확인한다.
  • Dept의 loc_code가 Locations 참조 필드로 매핑되는지 확인한다.

이 테이블 구조 확인은 뒤에서 Emp를 조회했을 때 부서와 지역 정보가 어떻게 따라오는지 이해하기 위한 준비 단계로 보면 된다.


핵심 정리

이번 묶음에서는 Emp, Dept, Locations 엔티티의 연관관계 구조를 확인했다.
세 엔티티는 사원, 부서, 지역 정보를 표현하고, 외래키 관계를 객체 참조로 매핑한다.


정리하면 아래와 같다.

  • Emp는 사원 정보를 표현한다.
  • Emp는 @ManyToOne과 @JoinColumn(name="deptno")로 Dept를 참조한다.
  • Dept는 부서 정보를 표현한다.
  • Dept는 @ManyToOne과 @JoinColumn(name="loc_code", referencedColumnName = "loc_code")로 Locations를 참조한다.
  • Locations는 지역 정보를 표현한다.
  • 객체 흐름은 Emp → Dept → Locations로 이어진다.
  • 테이블 흐름은 외래키 컬럼으로 이어진다.
  • 로딩 전략 실행 차이는 뒤의 조회 예제에서 확인한다.

이 구조를 이해해야 이후 Emp 조회 예제에서 왜 부서와 지역 조회 쿼리가 함께 나타나는지 이해할 수 있다.




응용예제 9. Emp 전체 조회와 연관 객체 미사용 흐름 확인하기 (HelloJPA3.java, Emp.java, Dept.java, Locations.java)

HelloJPA3.java는 Emp 엔티티를 JPQL로 전체 조회하는 예제이다.
앞에서는 Emp, Dept, Locations 엔티티가 Emp → Dept → Locations 방향으로 연결된 구조를 확인했다.
이번에는 Emp를 조회하더라도 연관 객체를 직접 사용하지 않으면 조회 흐름이 어떻게 단순하게 유지되는지 확인한다.


이 예제의 출력 코드는 elem.getEname()만 사용한다.
즉, Emp는 조회하지만 연관 객체인 Dept의 필드는 직접 사용하지 않는다.


이 예제의 핵심은 Emp 전체 조회와 사원 이름 출력 흐름을 확인하고, 연관 객체를 사용하지 않는 코드에서는 부서 정보가 출력되지 않는다는 점을 보는 것이다.


이전 기본개념과 연결하기

이전 기본개념에서 JPQL은 실제 테이블명이 아니라 Entity 이름을 기준으로 작성한다고 정리했다.
따라서 사원 테이블을 조회하더라도 JPQL에서는 테이블명 대신 Emp 엔티티 이름을 사용한다.


또 이전 예제에서 Emp는 Dept를 참조하고, Dept는 Locations를 참조한다고 정리했다.
테이블은 외래키 컬럼으로 연결되지만, 객체는 참조 필드로 연결된다.
즉, Emp 객체 안에는 Dept 객체 참조가 있다.


다만 연관관계가 있다고 해서 항상 연관 객체의 필드까지 사용하는 것은 아니다.
이번 예제는 Emp 전체를 조회한 뒤 사원 이름만 출력한다.
그래서 조회 결과에서 봐야 할 중심은 부서 정보가 아니라 사원 이름 목록이다.

기본개념에서 다시 잡아야 할 흐름

  • JPQL은 테이블명이 아니라 Entity 이름을 기준으로 작성한다.
  • Emp는 Dept를 @ManyToOne으로 참조한다.
  • Dept는 Locations를 @ManyToOne으로 참조한다.
  • 이번 출력 코드는 Emp 자신의 필드인 ename만 사용한다.
  • 연관 객체인 Dept의 필드는 이 예제에서 직접 출력하지 않는다.

연관관계가 있다는 사실과 코드에서 연관 객체를 실제로 사용한다는 사실은 구분해야 한다.


예제의 목표

이 예제의 목표는 Emp 전체 조회 결과를 List<Emp>로 받아 사원 이름만 출력하는 흐름을 확인하는 것이다.
Emp가 Dept를 참조하고 있어도, 반복문 안에서 getEname()만 호출하면 출력 결과는 사원 이름 중심으로 나온다.

이 예제에서 확인할 내용

  • "entitytest" 설정으로 EntityManagerFactory를 만든다.
  • EntityManager를 생성한다.
  • JPQL로 Emp 전체를 조회한다.
  • 조회 결과를 List<Emp>로 받는다.
  • 반복문에서 elem.getEname()으로 사원 이름만 출력한다.
  • 마지막에 조회된 데이터 개수를 출력한다.

이 흐름을 이해하면 뒤에서 연관 필드를 실제로 사용하는 예제와 비교하기 쉬워진다.


코드 흐름

HelloJPA3.java는 EntityManagerFactory와 EntityManager를 만든 뒤, JPQL로 Emp 전체 목록을 조회한다.
그다음 조회 결과를 반복하면서 사원 이름만 출력한다.


핵심 코드는 아래와 같다.

// HelloJPA3.java
public class HelloJPA3 {
    public static void main(String[] args) {
        EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
        EntityManager em = factory.createEntityManager(); // EntityManager 생성

        Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회 JPQL 생성
        List<Emp> empList = q.getResultList(); // 조회 결과를 List로 받음

        for (Emp elem : empList) { // 조회된 Emp를 하나씩 꺼냄
            System.out.println(elem.getEname()); // 사원 이름만 출력
        }

        System.out.println("데이터 갯수 : " + empList.size()); // 조회된 데이터 개수 출력

        em.close(); // EntityManager 자원 정리
        factory.close(); // Factory 자원 정리
    }
}

select t from Emp t는 Emp 엔티티 전체를 조회하는 JPQL이다.
여기서 Emp는 테이블명이 아니라 엔티티 이름이다.
t는 쿼리 안에서 Emp를 짧게 부르기 위한 별칭이다.


조회 결과는 empList에 저장된다.
그다음 반복문에서 elem.getEname()만 호출한다.
즉, 사원 이름만 사용하고 elem.getDeptno()처럼 부서 객체를 직접 꺼내지 않는다.


이 코드는 Emp 전체를 조회한 뒤 elem.getEname()으로 사원 이름만 출력하는 흐름이다.
부서 객체를 직접 사용하지 않으므로 출력 결과는 사원 이름과 데이터 개수 중심으로 확인한다.


핵심 코드 해석

select t from Emp t는 Emp 엔티티 전체 조회이다

이 예제의 JPQL은 Emp 전체 조회 문장이다.
실제 데이터베이스 테이블명이나 컬럼명을 직접 쓰지 않고, 엔티티 이름과 별칭을 사용한다.

// HelloJPA3.java
Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회

select t는 조회 결과로 t 전체를 가져오겠다는 뜻이다.
from Emp t는 Emp 엔티티를 조회 대상으로 삼고, 그 엔티티를 쿼리 안에서 t라고 부르겠다는 뜻이다.


JPQL의 Emp는 Java 엔티티 이름이다.
실제 실행 시에는 Hibernate가 이 JPQL을 데이터베이스가 이해할 수 있는 SQL로 바꿔 실행한다.


elem.getEname만 출력하면 사원 이름만 사용한다

조회 결과를 출력하는 코드는 아래와 같다.

// HelloJPA3.java
for (Emp elem : empList) { // Emp 객체를 하나씩 꺼냄
    System.out.println(elem.getEname()); // 사원 이름만 출력
}

이 반복문은 empList 안의 Emp 객체를 하나씩 꺼낸다.
그리고 각 객체에서 getEname()만 호출한다.
getEname()은 사원 이름을 가져오는 메서드이다.


여기서 중요한 점은 getDeptno()를 호출하지 않는다는 것이다.
deptno는 Emp가 참조하는 Dept 객체이다.
현재 출력 코드는 부서 객체를 사용하지 않고 사원 이름만 출력한다.


같은 Emp 조회라도 반복문 안에서 어떤 필드를 사용하는지에 따라 확인해야 할 결과가 달라진다.


결과물은 어떻게 나오는가

이 예제를 실행하면 Emp 전체 조회 SQL이 실행된다.
그리고 콘솔에는 사원 이름이 한 줄씩 출력된다.
예를 들어 SMITH, ALLEN, WARD, JONES 같은 값이 출력된다.
마지막에는 데이터 갯수 : 14가 출력된다.


실행 결과를 보면 Emp 전체 조회 SQL이 실행되고, 콘솔에는 사원 이름만 출력된다.
이 예제에서는 부서명이나 지역명을 출력하지 않는다.
코드에서 elem.getEname()만 호출하기 때문이다.

// 출력결과
// Hibernate:
//     select ...
//     from Emp ...
// SMITH
// ALLEN
// WARD
// JONES
// ...
// 데이터 갯수 : 14

결과물을 확인할 때 봐야 할 기준

  • select t from Emp t에 해당하는 Emp 조회가 실행되는지 확인한다.
  • 콘솔에 사원 이름이 한 줄씩 출력되는지 확인한다.
  • 출력 코드가 elem.getEname()만 사용한다는 점을 확인한다.
  • 마지막에 데이터 갯수 : 14가 출력되는지 확인한다.

이 예제의 결과물은 Emp 전체 조회 후 사원 이름만 출력되는 흐름을 확인하는 데 초점을 둔다.


핵심 정리

HelloJPA3.java는 JPQL로 Emp 전체 목록을 조회하는 예제이다.
단순 목록 조회처럼 보이지만, Emp가 Dept를 참조한다는 구조를 알고 있어야 뒤의 연관 필드 접근 예제를 이해하기 쉽다.


정리하면 아래와 같다.

  • JPQL은 테이블명이 아니라 Entity 이름을 기준으로 작성한다.
  • select t from Emp t는 Emp 전체 조회이다.
  • 조회 결과는 List<Emp>로 받는다.
  • 현재 출력 코드는 elem.getEname()으로 사원 이름만 출력한다.
  • 부서 객체를 직접 사용하는 코드는 이 예제에 없다.
  • 이 흐름은 뒤에서 연관 필드를 실제로 사용하는 예제와 비교하는 기준이 된다.

Emp 전체 조회 예제는 연관관계를 사용하기 전, 먼저 기본 조회와 사원 이름 출력 흐름을 확인하는 출발점이다.




응용예제 10. 지연 로딩 프록시 확인하기 (HelloJPA3_1.java, Emp.java, Dept.java)

HelloJPA3_1.java는 Emp를 전체 조회한 뒤, 각 사원이 참조하는 Dept 객체가 어떤 클래스 형태로 준비되어 있는지 확인하는 예제이다.
앞의 HelloJPA3.java에서는 Emp를 조회하고 elem.getEname()만 출력했다.
이번 예제에서는 elem.getDeptno().getClass().getName()을 출력해서 Emp가 참조하는 Dept 객체의 실제 클래스명을 확인한다.


이 코드는 부서명을 출력하는 코드가 아니다.
Dept 참조 객체가 어떤 형태로 준비되어 있는지 관찰하는 코드이다.


이 예제의 핵심은 연관 객체가 실제 엔티티 객체로 바로 준비되는지, 아니면 프록시 객체로 준비되는지 확인하는 것이다.


이전 기본개념과 연결하기

앞 개념에서 JPA는 객체와 테이블을 매핑하고, 연관관계는 외래키 값을 객체 참조로 다룰 수 있게 해 준다고 정리했다.
Emp는 Dept를 참조하고, Dept는 다시 Locations를 참조할 수 있다.
객체 흐름으로 보면 Emp → Dept → Locations로 이어질 수 있다.


다만 이번 예제에서 직접 확인하는 대상은 Locations가 아니다.
이번 예제에서 직접 확인하는 대상은 Emp가 참조하는 Dept 객체의 클래스명이다.
즉, Emp에서 Dept 참조로 이동했을 때 그 참조 객체가 실제 Dept인지, 프록시인지 확인한다.


프록시는 실제 객체를 대신해서 서 있는 대리 객체이다.
실제 데이터 조회를 미루거나 필요한 순간에 이어서 처리할 수 있도록 준비된 객체로 이해하면 된다.

기본개념에서 다시 잡아야 할 흐름

  • Emp는 Dept를 @ManyToOne으로 참조한다.
  • elem.getDeptno()는 Emp에서 Dept 참조로 이동하는 코드이다.
  • 프록시는 실제 객체를 대신하는 대리 객체이다.
  • getClass().getName()은 객체의 실제 클래스명을 확인한다.
  • 프록시 객체라면 클래스명에 HibernateProxy 같은 프록시 관련 이름이 보일 수 있다.

지연 로딩을 이해하려면 연관 객체 참조가 있다는 사실과 실제 연관 데이터가 조회되었다는 사실을 구분해야 한다.


코드 흐름

HelloJPA3_1.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 JPQL로 Emp 전체 목록을 조회한다.
조회 결과를 반복하면서 사원 이름과 부서 객체의 클래스명을 출력한다.


핵심 코드는 아래와 같다.

// HelloJPA3_1.java
public class HelloJPA3_1 {
    public static void main(String[] args) {
        EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
        EntityManager em = factory.createEntityManager(); // EntityManager 생성

        Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회 JPQL 생성
        List<Emp> empList = q.getResultList(); // Emp 목록 조회

        for (Emp elem : empList) { // 조회된 Emp를 하나씩 꺼냄
            System.out.print(elem.getEname() + " : "); // 사원 이름 출력

            System.out.println(
                    elem.getDeptno() != null
                            ? elem.getDeptno().getClass().getName() // 부서 객체의 실제 클래스명 출력
                            : "힝 ㅠㅠ~~" // 부서가 없을 때 출력
            );
        }

        System.out.println("데이터 갯수 : " + empList.size()); // 조회된 Emp 개수 출력

        em.close(); // EntityManager 자원 정리
        factory.close(); // EntityManagerFactory 자원 정리
    }
}

select t from Emp t는 Emp 엔티티 전체를 조회하는 JPQL이다.
조회 결과는 List<Emp> 형태로 받아서 for 반복문으로 하나씩 출력한다.
각 줄은 사원 이름과 부서 객체의 클래스명이 함께 출력되는 구조이다.


elem.getDeptno() != null 조건이 들어간 이유는 부서가 없는 사원이 있을 수 있기 때문이다.
부서가 없는 사원은 deptno가 null일 수 있다.
이 상태에서 elem.getDeptno().getClass()를 바로 호출하면 오류가 발생할 수 있다.
그래서 먼저 null인지 확인한다.


연관 객체가 없을 수 있는 경우에는 객체 참조를 따라가기 전에 반드시 null 여부를 먼저 확인해야 한다.


핵심 코드 해석

elem.getDeptno는 Emp에서 Dept 참조로 이동하는 코드이다

Emp 엔티티에는 Dept를 참조하는 deptno 필드가 있다.
필드 이름은 deptno라서 숫자처럼 보이지만, 실제 타입은 Dept이다.

// Emp.java
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
@JoinColumn(name="deptno") // emp 테이블의 deptno 외래키와 연결
private Dept deptno; // Dept Entity 참조

@JoinColumn(name="deptno")는 Emp 테이블의 deptno 외래키 컬럼을 사용한다는 뜻이다.
하지만 객체 코드에서는 외래키 숫자만 다루지 않는다.
Emp 객체 안에서 Dept 객체를 참조하는 구조로 다룬다.


그래서 elem.getDeptno()는 사원의 부서 번호를 단순히 꺼내는 코드가 아니다.
Emp 객체에서 연관된 Dept 객체 참조로 이동하는 코드이다.


elem.getDeptno()는 외래키 값을 직접 꺼내는 것이 아니라, Emp에서 연관된 Dept 객체 참조로 이동하는 코드이다.


getClass().getName은 객체의 실제 클래스명을 확인한다

이 예제의 핵심 코드는 아래 부분이다.

// HelloJPA3_1.java
elem.getDeptno().getClass().getName() // Dept 참조 객체의 실제 클래스명 확인

이 코드는 세 단계로 나누어 읽으면 쉽다.
elem.getDeptno()는 Emp가 참조하는 Dept 객체를 가져온다.
getClass()는 그 객체가 실제로 어떤 클래스의 객체인지 확인한다.
getName()은 그 클래스 이름을 문자열로 가져온다.


출력된 클래스명에 프록시 관련 이름이 포함되어 있다면, 실제 엔티티 대신 프록시 객체가 준비된 흐름으로 볼 수 있다.
여기서 중요한 점은 클래스명 전체를 외우는 것이 아니다.
출력된 클래스명이 프록시인지 실제 엔티티인지 구분하는 것이다.


getClass().getName()은 연관 객체가 실제 엔티티인지 프록시인지 확인하기 위한 관찰용 코드이다.


결과물은 어떻게 나오는가

이 예제를 실행하면 Emp 전체 조회가 먼저 실행된다.
그다음 콘솔에는 사원 이름과 부서 참조 객체의 클래스명이 함께 출력된다.


실행 결과에서는 사원명 오른쪽에 jpaexam2.model.entity.Dept$HibernateProxy... 형태의 클래스명이 출력될 수 있다.
이는 Emp가 참조하는 Dept가 실제 엔티티 객체 대신 프록시 객체 형태로 준비될 수 있다는 것을 보여 준다.


ADAMS는 "힝 ㅠㅠ~~"로 출력된다.
이는 ADAMS의 deptno가 null이기 때문이다.
즉, 참조할 Dept 객체가 없으므로 getClass().getName()을 호출하지 않고 삼항 연산자의 거짓 결과가 출력된 것이다.

// 출력결과
// SMITH : jpaexam2.model.entity.Dept$HibernateProxy...
// ALLEN : jpaexam2.model.entity.Dept$HibernateProxy...
// ...
// ADAMS : 힝 ㅠㅠ~~
// 데이터 갯수 : 14

결과물을 확인할 때 봐야 할 기준

  • Emp 전체 조회 SQL이 실행되는지 확인한다.
  • 사원명 오른쪽에 Dept$HibernateProxy... 같은 프록시 클래스명이 보이는지 확인한다.
  • ADAMS처럼 deptno가 null인 데이터는 "힝 ㅠㅠ~~"가 출력될 수 있음을 확인한다.

이 예제의 결과물은 연관 객체가 프록시 형태로 준비될 수 있음을 확인하는 데 초점을 둔다.


핵심 정리

HelloJPA3_1.java는 Emp 전체 조회 후 연관 객체인 Dept가 어떤 클래스 형태로 준비되어 있는지 확인하는 예제이다.
앞의 HelloJPA3.java가 사원 이름만 출력했다면, 이번 예제는 Emp에서 Dept 참조로 이동해 객체의 실제 클래스명을 확인한다.


정리하면 아래와 같다.

  • select t from Emp t는 Emp 전체 조회 JPQL이다.
  • 조회 결과는 List<Emp>로 받는다.
  • 반복문에서 사원 이름과 부서 객체 클래스명을 출력한다.
  • elem.getDeptno()는 외래키 값을 꺼내는 것이 아니라 Dept 객체 참조로 이동하는 코드이다.
  • getClass().getName()은 객체의 실제 클래스명을 확인한다.
  • getClass().getName()은 부서명 같은 실제 필드 값을 읽는 코드가 아니라 객체 형태를 확인하는 코드이다.
  • 프록시 클래스명이 보이면 연관 객체가 대리 객체 형태로 준비된 흐름으로 볼 수 있다.
  • deptno가 null인 ADAMS는 클래스명을 출력하지 않고 "힝 ㅠㅠ~~"를 출력한다.

이 예제를 이해하면 지연 로딩이 실제 객체 대신 프록시를 사용해 연관 데이터 조회를 미룰 수 있다는 흐름을 코드 결과로 확인할 수 있다.




응용예제 11. 연관 Entity 필드 접근과 N+1 흐름 확인하기 (HelloJPA3_2.java, HelloJPA4.java, HelloJPA5.java, Emp.java, Dept.java)

HelloJPA3_2.java, HelloJPA4.java, HelloJPA5.java는 Emp에서 연관된 Dept로 이동해 실제 부서 정보를 사용하는 예제이다.
앞의 HelloJPA3_1.java에서는 getClass().getName()으로 Dept 참조 객체가 실제 엔티티인지 프록시인지 확인했다.
이번에는 클래스명 확인에서 끝나지 않고, getDname()이나 toString()을 통해 연관 엔티티의 실제 필드를 사용할 수 있는 흐름을 확인한다.


이 예제들은 Emp 목록을 조회한 뒤 반복문에서 Dept 필드를 실제로 사용할 때 추가 조회가 발생할 수 있음을 보여 준다.
이때 추가 조회가 반복되면 N+1 문제 흐름과 연결된다.


이 예제의 핵심은 Emp 목록 조회 후 반복문에서 연관 엔티티 필드를 실제로 사용할 때 추가 조회가 발생할 수 있고, 이 흐름이 N+1 문제와 연결된다는 점이다.


이전 기본개념과 연결하기

앞 개념에서 Emp는 Dept를 @ManyToOne으로 참조한다고 정리했다.
테이블 기준으로 보면 Emp 테이블의 deptno 외래키가 Dept 테이블의 기본키와 연결된다.
객체 기준으로 보면 Emp 객체 안에 Dept 객체 참조가 들어 있다.


그래서 Emp에서 부서 정보를 사용하려면 elem.getDeptno()로 Dept 참조에 접근할 수 있다.
그리고 elem.getDeptno().getDname()처럼 한 단계 더 들어가면 Dept의 부서명을 사용할 수 있다.


앞 예제에서 사용한 getClass().getName()은 객체의 클래스 형태를 확인하는 코드였다.
부서명 같은 실제 필드 값을 읽는 코드는 아니었다.
반면 이번 예제의 getDname()은 Dept의 실제 필드 값을 읽는 코드이다.
이 차이를 구분해야 한다.

기본개념에서 다시 잡아야 할 흐름

  • Emp는 Dept를 객체 참조로 가진다.
  • elem.getDeptno()는 Emp에서 Dept 참조로 이동하는 코드이다.
  • elem.getDeptno().getDname()은 Dept의 부서명 필드를 실제로 사용하는 코드이다.
  • getClass().getName()은 객체 형태 확인이고, getDname()은 실제 필드 값 사용이다.
  • 반복문 안에서 연관 필드를 계속 사용하면 추가 조회가 반복될 수 있다.
  • 이 흐름이 N+1 문제와 연결된다.
  • System.out.println(elem)도 내부적으로 toString()을 호출하므로, toString() 설정에 따라 연관 필드 접근이 발생할 수 있다.

연관관계 성능 문제는 연관관계 매핑 자체보다, 목록 조회 후 반복문에서 연관 필드를 실제로 사용하는 순간에 잘 드러난다.


공통 코드 흐름

HelloJPA3_2.java, HelloJPA4.java, HelloJPA5.java는 모두 먼저 Emp 전체 목록을 조회한다.
조회 문장은 같은 흐름을 가진다.
차이는 반복문 안에서 Emp를 어떻게 사용하는지이다.


기본 조회 흐름은 아래와 같다.

// EmpListBasicFlow.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회 JPQL 생성
List<Emp> empList = q.getResultList(); // Emp 목록 조회

for (Emp elem : empList) { // 조회된 Emp를 하나씩 꺼냄
    // 예제별로 출력 방식이 달라짐
}

System.out.println("데이터 갯수 : " + empList.size()); // 조회된 Emp 개수 출력

em.close(); // EntityManager 자원 정리
factory.close(); // EntityManagerFactory 자원 정리

여기까지는 Emp 목록을 조회하는 공통 흐름이다.
select t from Emp t는 Emp 엔티티 전체를 조회하는 JPQL이다.


이제 중요한 부분은 반복문 안이다.
반복문 안에서 사원 이름만 출력하면 Emp 자신의 필드만 사용한다.
하지만 elem.getDeptno().getDname()처럼 연관된 Dept의 필드를 사용하면 Dept 조회가 추가로 발생할 수 있다.


같은 Emp 목록 조회라도 반복문 안에서 어떤 필드를 사용하느냐에 따라 실제 SQL 흐름이 달라질 수 있다.


HelloJPA3_2는 부서명을 직접 출력한다

HelloJPA3_2.java는 Emp 목록을 조회한 뒤 각 사원의 이름과 부서명을 출력한다.
앞의 HelloJPA3_1.java가 getClass().getName()으로 객체 형태를 확인했다면, 이번 파일은 getDname()으로 실제 부서명을 사용한다.


핵심 코드는 아래와 같다.

// HelloJPA3_2.java
Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회
List<Emp> empList = q.getResultList(); // 결과 목록 조회

for (Emp elem : empList) { // 사원 목록 반복
    System.out.print(elem.getEname() + " : "); // 사원 이름 출력

    if (elem.getDeptno() != null) { // 부서 참조가 있으면
        System.out.println(elem.getDeptno().getDname()); // 부서명 출력
    } else {
        System.out.println("부서미정"); // 부서 참조가 없으면 출력
    }
}

System.out.println("데이터 갯수 : " + empList.size()); // 데이터 개수 출력

elem.getDeptno().getDname()은 두 단계로 읽어야 한다.
먼저 elem.getDeptno()로 Emp에서 Dept 참조로 이동한다.
그다음 getDname()으로 Dept의 부서명 필드를 읽는다.


이 코드는 Dept 객체의 실제 필드 값을 사용한다.
따라서 Dept가 아직 준비되지 않았다면 이 시점에 부서 조회가 발생할 수 있다.


HelloJPA3_2.java 실행 결과이다.
SMITH : RESEARCH, ALLEN : SALES처럼 사원명 오른쪽에 부서명이 출력된다.
ADAMS : 부서미정은 deptno가 null이라서 참조할 Dept 객체가 없을 때 실행되는 분기 결과이다.

HelloJPA3_2에서 봐야 할 핵심

  • Emp 목록을 먼저 조회한다.
  • 반복문 안에서 사원 이름을 출력한다.
  • getDeptno()로 Dept 참조에 접근한다.
  • getDname()으로 Dept의 실제 부서명 필드를 읽는다.
  • ADAMS는 deptno가 null이라서 "부서미정"이 출력된다.

getClass().getName()은 객체 형태 확인이고, getDname()은 연관 엔티티의 실제 필드 값을 읽는 코드이다.


HelloJPA4는 시간 간격을 두고 연관 필드 접근 시점을 보여 준다

HelloJPA4.java는 직원명을 먼저 출력하고, Thread.sleep(1000)으로 1초 기다린 뒤 부서명을 출력한다.
이 구조는 직원 정보 출력과 부서 정보 출력 사이의 흐름을 눈으로 구분하기 쉽게 만든다.


핵심 코드는 아래와 같다.

// HelloJPA4.java
Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회
List<Emp> empList = q.getResultList(); // 결과 목록 조회

for (Emp elem : empList) { // 사원 목록 반복
    System.out.println("직원명 : " + elem.getEname()); // 직원명 먼저 출력

    Thread.sleep(1000); // 1초 대기

    if (elem.getDeptno() != null) { // 부서 참조가 있으면
        System.out.println("    부서명 : " + elem.getDeptno().getDname()); // 부서명 출력
    } else {
        System.out.println("    부서명 : 없음"); // 부서 참조가 없으면 출력
    }
}

System.out.println("데이터 갯수 : " + empList.size()); // 데이터 개수 출력

Thread.sleep(1000)은 로딩 전략을 설정하는 코드가 아니다.
Thread는 실행 흐름을 의미하고, sleep()은 잠시 멈추는 메서드이다.
여기서는 1초 동안 출력 흐름을 멈춰서 직원명 출력과 부서명 출력의 시점을 구분하기 쉽게 만든다.


즉, HelloJPA4.java의 핵심은 시간 지연 자체가 아니다.
직원명 출력과 부서명 출력 사이를 나누어 보면서, 연관 필드에 접근하는 시점을 확인하는 것이다.


위 결과는 HelloJPA4.java 실행 흐름이다.
Emp의 직원명을 먼저 출력한 뒤, 부서명을 출력하려고 Dept의 dname 필드에 접근한다.
Thread.sleep(1000)은 직원명 출력과 부서명 출력 사이에 시간 간격을 만들어서, 언제 연관 필드에 접근하는지 눈으로 보기 쉽게 만든 코드이다.


deptno가 null인 사원에게는 "부서명 : 없음"이 출력된다.
이는 else 구문에서 부서 참조가 없을 때 출력하도록 작성한 분기 결과이다.

HelloJPA4에서 봐야 할 핵심

  • Thread.sleep(1000)은 지연 로딩 문법이 아니다.
  • 출력 사이에 시간 간격을 만들기 위한 코드이다.
  • 직원명은 Emp 자신의 필드이다.
  • 부서명은 연관된 Dept의 필드이다.
  • 부서명에 접근하는 순간 연관 필드가 실제로 사용된다.
  • ADAMS는 deptno가 null이므로 "부서명 : 없음"이 출력된다.

HelloJPA4.java는 시간 지연 자체가 핵심이 아니라, 직원명 출력 후 부서명 필드에 접근하는 시점을 관찰하게 해 주는 예제이다.


HelloJPA5는 toString 호출로 연관 필드가 사용될 수 있음을 보여 준다

HelloJPA5.java는 각 Emp 객체를 그대로 출력한다.
즉, 반복문 안에서 System.out.println(elem)을 호출한다.


겉으로 보기에는 Emp 객체 하나를 출력하는 코드처럼 보인다.
하지만 System.out.println(elem)은 내부적으로 elem.toString()을 호출한다.
Emp 클래스에 @ToString이 붙어 있다면, 출력 문자열을 만들 때 여러 필드가 함께 사용될 수 있다.


핵심 코드는 아래와 같다.

// HelloJPA5.java
Query q = em.createQuery("select t from Emp t", Emp.class); // Emp 전체 조회
List<Emp> empList = q.getResultList(); // 결과 목록 조회

for (Emp elem : empList) { // 사원 목록 반복
    System.out.println(elem); // toString() 호출로 객체 정보 출력
}

System.out.println("데이터 갯수 : " + empList.size()); // 데이터 개수 출력

이 코드에는 getDeptno().getDname()이 직접 보이지 않는다.
하지만 toString() 결과에 deptno 필드가 포함되면 연관 객체 정보가 출력 문자열을 만드는 과정에서 사용될 수 있다.


즉, 직접 연관 필드 접근 코드를 작성하지 않아도, 객체 출력 과정에서 연관 필드가 사용될 수 있다.


HelloJPA5.java 실행 결과이다.
System.out.println(elem)으로 Emp 객체를 출력했지만, 내부적으로는 toString()이 호출된다.
현재 toString() 결과에는 deptno=Dept(...)가 포함되어 있으므로, 객체 출력만으로도 연관 객체 정보가 함께 출력된다.
ADAMS는 부서 참조가 없기 때문에 deptno=null로 출력된다.

HelloJPA5에서 봐야 할 핵심

  • System.out.println(elem)은 내부적으로 toString()을 호출한다.
  • @ToString이 있으면 객체의 여러 필드가 출력 문자열에 포함될 수 있다.
  • toString() 안에서 연관 필드가 사용되면 연관 조회가 발생할 수 있다.
  • 실행 결과에서 deptno=Dept(...)와 loc_code=Locations(...)가 함께 출력된다.
  • ADAMS는 부서 참조가 없으므로 deptno=null로 출력된다.

객체 출력도 내부적으로 toString()을 호출하므로, toString() 안에서 연관 필드를 사용하면 로딩이 발생할 수 있다.


반복문과 연관 필드 접근은 N+1 문제와 연결된다

HelloJPA3_2.java, HelloJPA4.java, HelloJPA5.java는 모두 Emp 목록 조회 후 반복문 안에서 연관 엔티티를 사용할 수 있는 구조이다.
이 구조는 N+1 문제를 이해하는 데 중요하다.


기본 흐름은 아래와 같다.

  • Emp 목록을 한 번 조회한다.
  • 조회된 사원 수만큼 반복문을 돈다.
  • 반복문 안에서 Dept 정보를 사용한다.
  • 연관 엔티티가 아직 준비되지 않았다면 추가 조회가 발생할 수 있다.

이때 처음 Emp 목록 조회가 1번이다.
그다음 반복문 안에서 연관된 Dept 조회가 추가로 발생할 수 있다.
이처럼 목록 조회 1번 이후 연관 데이터 조회가 여러 번 이어지는 흐름이 N+1 문제와 연결된다.


다만 여기서 주의해야 한다.
추가 조회가 사원 수만큼 무조건 발생한다고 단정하면 안 된다.
여러 사원이 같은 부서를 참조할 수 있고, 이미 영속성 컨텍스트에 조회된 Dept는 다시 조회하지 않을 수 있기 때문이다.
그래서 실제 추가 조회 횟수는 참조되는 부서의 종류와 영속성 컨텍스트 상태에 따라 달라질 수 있다.


출력 형태를 정리하면 아래와 같다.

// 출력결과
// HelloJPA3_2.java
// SMITH : RESEARCH
// ALLEN : SALES
// WARD : SALES
// ...
// ADAMS : 부서미정
// 데이터 갯수 : 14

// HelloJPA4.java
// 직원명 : SMITH
//     부서명 : RESEARCH
// 직원명 : ALLEN
//     부서명 : SALES
// ...
// 직원명 : ADAMS
//     부서명 : 없음
// 데이터 갯수 : 14

// HelloJPA5.java
// Emp(empno=7369, ename=SMITH, ..., deptno=Dept(... loc_code=Locations(...)))
// ...
// Emp(empno=7876, ename=ADAMS, ..., deptno=null)
// 데이터 갯수 : 14

결과를 볼 때는 단순히 출력 문자열만 보면 부족하다.
출력 문자열 오른쪽에 부서명이 나오거나, Emp 객체 안에 Dept 정보가 함께 출력된다면 연관 엔티티 필드가 사용된 것이다.
이때 SQL 로그에서 Dept 조회가 언제 발생하는지도 함께 확인해야 한다.

N+1 흐름에서 봐야 할 핵심

  • 처음 목록 조회가 1번 실행된다.
  • 반복문 안에서 연관 필드를 사용한다.
  • 연관 엔티티가 아직 준비되지 않았다면 추가 조회가 발생할 수 있다.
  • 추가 조회 횟수는 참조키의 유니크 값과 영속성 컨텍스트 상태에 따라 달라질 수 있다.
  • SQL 로그를 보면서 실제 조회 횟수를 확인해야 한다.

N+1 문제는 연관관계를 매핑했다는 사실만으로 생기는 것이 아니라, 목록 조회 후 연관 데이터를 반복해서 사용하는 흐름에서 잘 드러난다.


핵심 정리

HelloJPA3_2.java, HelloJPA4.java, HelloJPA5.java는 모두 Emp 목록 조회 후 연관된 Dept 필드를 실제로 사용하는 예제이다.


정리하면 아래와 같다.

  • HelloJPA3_2.java는 getDeptno().getDname()으로 부서명을 직접 출력한다.
  • HelloJPA4.java는 직원명 출력 후 시간 간격을 두고 부서명을 출력한다.
  • Thread.sleep(1000)은 출력 흐름을 보기 쉽게 만드는 대기 코드이다.
  • HelloJPA5.java는 System.out.println(elem)으로 toString() 호출 흐름을 확인한다.
  • toString()에 연관 필드가 포함되면 객체 출력 과정에서도 연관 필드 접근이 발생할 수 있다.
  • deptno가 null인 사원은 부서미정, 부서명 : 없음, deptno=null처럼 분기되어 출력된다.
  • 목록 조회 후 반복문 안에서 연관 필드를 사용하면 추가 조회가 발생할 수 있다.
  • 이 흐름이 N+1 문제와 연결된다.

이 묶음을 이해하면 연관 객체를 실제로 사용하는 순간부터 SQL 흐름이 달라질 수 있다는 점을 확인할 수 있다.




응용예제 12. Fetch Join으로 연관 데이터를 함께 조회하기 (HelloJPA6.java, Emp.java, Dept.java, Locations.java)

HelloJPA6.java는 Fetch Join을 사용해서 Emp, Dept, Locations를 함께 조회하는 예제이다.
앞 예제에서는 Emp 목록을 조회한 뒤 반복문 안에서 Dept의 부서명을 사용했다.
그 과정에서 FetchType.LAZY 상태라면 부서명을 실제로 사용하는 시점에 추가 조회가 발생할 수 있었다.


이번 예제는 필요한 연관 데이터를 처음 조회할 때 함께 가져오도록 JPQL에 명시한다.
즉, Emp를 조회하면서 Dept와 Locations까지 함께 준비하려는 흐름이다.


Fetch Join은 연관 엔티티를 실제로 함께 사용할 것이 확실할 때 처음 조회 쿼리에서 함께 가져오는 방법이다.


이전 기본개념과 연결하기

앞에서 Emp, Dept, Locations는 서로 이어진 엔티티라고 정리했다.
Emp는 Dept를 참조하고, Dept는 Locations를 참조한다.
객체 흐름으로 보면 Emp → Dept → Locations이다.


앞 예제에서는 이 흐름을 반복문 안에서 사용했다.
Emp 목록을 먼저 조회하고, 각 Emp에서 getDeptno()로 Dept에 접근했다.
그리고 getDname()으로 Dept의 부서명을 읽었다.


이때 FetchType.LAZY 상태라면 연관 엔티티를 처음부터 바로 가져오지 않는다.
그래서 반복문 안에서 Dept의 실제 필드를 사용할 때 추가 조회가 발생할 수 있다.
이 흐름이 반복되면 N+1 문제와 연결된다.


이번 예제의 Fetch Join은 이 문제를 줄이기 위한 방법이다.
처음부터 Emp만 조회하지 않고, 앞으로 사용할 Dept와 Locations까지 함께 가져오도록 JPQL에 적는다.

기본개념에서 다시 잡아야 할 흐름

  • Emp는 Dept를 참조한다.
  • Dept는 Locations를 참조한다.
  • FetchType.LAZY 상태에서는 연관 엔티티를 실제로 사용할 때 추가 조회가 발생할 수 있다.
  • 반복문 안에서 연관 필드를 계속 사용하면 추가 조회가 반복될 수 있다.
  • Fetch Join은 처음 조회할 때 필요한 연관 엔티티를 함께 가져오는 방법이다.
  • 일반 join과 fetch join은 목적이 다르다.
  • fetch join은 연관 엔티티를 함께 조회하는 데 초점이 있다.

Fetch Join은 N+1 흐름을 줄이기 위해 필요한 연관 데이터를 처음 조회 시점에 함께 가져오도록 만드는 조회 방식이다.


예제의 목표

이 예제의 목표는 Fetch Join을 사용했을 때 Emp, Dept, Locations가 한 조회 흐름에서 함께 준비되는지 확인하는 것이다.
앞 예제처럼 반복문 안에서 연관 필드를 사용할 때마다 조회가 추가되는 흐름과 비교해서 봐야 한다.


HelloJPA6.java는 select t from Emp t처럼 Emp만 조회하지 않는다.
left join fetch t.deptno로 Dept를 함께 가져오고, left join fetch t.deptno.loc_code로 Locations까지 함께 가져온다.


즉, 이번 예제는 Emp 목록을 조회하면서 사원, 부서, 지역 정보를 함께 사용할 준비를 하는 코드이다.

이 예제에서 확인할 내용

  • JPQL 문자열에 left join fetch가 들어간다.
  • t.deptno를 fetch join해서 Emp와 Dept를 함께 조회한다.
  • t.deptno.loc_code를 fetch join해서 Dept와 Locations까지 함께 조회한다.
  • left join을 사용하므로 부서가 없는 Emp도 결과에서 빠지지 않을 수 있다.
  • 조회 결과의 중심은 Emp이다.
  • 조회 결과는 List<Emp>로 받는다.
  • 반복문에서 System.out.println(elem)을 실행한다.
  • toString() 결과에 Dept, Locations 정보가 함께 출력될 수 있다.
  • SQL 로그에서 join이 어떻게 만들어졌는지 확인한다.

이번 예제는 출력 문자열보다 JPQL의 fetch join이 실제 SQL에서 연관 테이블을 함께 조회하는 흐름으로 바뀌는지 확인하는 데 목적이 있다.


코드 흐름

HelloJPA6.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 Fetch Join이 포함된 JPQL 문자열을 만들고, 그 쿼리를 실행한다.
조회된 Emp 목록은 반복문에서 그대로 출력한다.


핵심 코드는 아래와 같다.

// HelloJPA6.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

String fetchJoinJpql = "select  t " +
                       "from Emp t left join fetch t.deptno " +
                       "left join fetch t.deptno.loc_code"; // Emp, Dept, Locations 함께 조회

Query q = em.createQuery(fetchJoinJpql, Emp.class); // Emp 타입 기준으로 Fetch Join JPQL 실행 준비
List<Emp> empList = q.getResultList(); // Emp 목록 조회

for (Emp elem : empList) { // 사원 목록 반복
    System.out.println(elem); // Emp 객체 출력
}

System.out.println("데이터 갯수 : " + empList.size()); // 데이터 개수 출력

em.close(); // EntityManager 자원 정리
factory.close(); // EntityManagerFactory 자원 정리

이 코드에서 가장 중요한 부분은 fetchJoinJpql 문자열이다.
JPQL 안에 left join fetch가 두 번 들어간다.
첫 번째는 Emp와 Dept를 함께 조회하기 위한 것이고, 두 번째는 Dept와 Locations를 함께 조회하기 위한 것이다.


조회 결과는 여전히 Emp 목록이다.
즉, select t의 기준은 Emp이다.
다만 Emp만 따로 가져오는 것이 아니라, Emp가 참조하는 연관 엔티티까지 함께 준비한다.


여기서 변수 타입은 Query로 받았지만, createQuery(fetchJoinJpql, Emp.class)처럼 Emp.class를 함께 넘겼다.
이 말은 조회 결과를 Emp 타입 기준으로 다루겠다는 뜻이다.
TypedQuery<Emp>로 받으면 타입 의미가 더 분명하게 보이지만, 이 예제에서는 Query 변수로 받아도 결과 흐름은 List<Emp> 중심으로 이해하면 된다.


Fetch Join을 사용해도 조회 결과의 중심은 select에 적은 엔티티이고, 함께 조회되는 연관 엔티티는 그 엔티티 안에 채워지는 구조로 이해하면 된다.


핵심 코드 해석

select t는 Emp를 조회 결과의 중심으로 둔다

HelloJPA6.java의 JPQL은 아래처럼 시작한다.

// HelloJPA6.java
String fetchJoinJpql = "select  t " +
                       "from Emp t left join fetch t.deptno " +
                       "left join fetch t.deptno.loc_code"; // Fetch Join JPQL

select t는 t가 가리키는 Emp를 조회 결과로 받겠다는 뜻이다.
from Emp t는 Emp 엔티티를 조회 대상으로 두고, 쿼리 안에서 t라는 별칭으로 부르겠다는 뜻이다.


그래서 결과 타입은 Emp이다.
코드에서도 결과를 List<Emp>로 받는다.

// HelloJPA6.java
Query q = em.createQuery(fetchJoinJpql, Emp.class); // Emp 타입 기준으로 조회
List<Emp> empList = q.getResultList(); // Emp 목록으로 받음

여기서 Dept와 Locations를 함께 조회하더라도 결과 목록 자체가 List<Dept>나 List<Locations>로 바뀌는 것은 아니다.
조회의 중심은 여전히 Emp이다.


Emp.class를 넘겼다는 것은 조회 결과를 Emp 타입으로 기대한다는 뜻이다.
그래서 초보자 기준에서는 “조회 결과의 중심은 Emp이고, Dept와 Locations는 각 Emp 안에 함께 채워지는 연관 객체”라고 이해하면 된다.


select t가 Emp를 가리키므로 결과 목록은 Emp 목록이고, Dept와 Locations는 각 Emp 안의 연관 객체로 함께 채워진다.


left join fetch t.deptno는 Emp와 Dept를 함께 조회한다

left join fetch t.deptno는 Emp를 조회하면서 Emp가 참조하는 Dept도 함께 조회하겠다는 뜻이다.


여기서 t는 Emp이다.
t.deptno는 Emp 안에 있는 Dept 참조 필드이다.
따라서 left join fetch t.deptno는 Emp → Dept 관계를 따라가면서 Dept를 함께 가져오는 코드이다.

// HelloJPA6.java
"from Emp t left join fetch t.deptno " // Emp와 Dept를 함께 조회

fetch가 붙어 있기 때문에 단순히 조건 연결만 하는 조인이 아니다.
연관 엔티티인 Dept를 실제 조회 결과와 함께 준비하는 것이 목적이다.


또 left join을 사용했기 때문에 부서가 없는 사원도 결과에서 빠지지 않을 수 있다.
예를 들어 ADAMS처럼 deptno가 null인 사원이 있어도 Emp 자체는 조회 결과에 포함될 수 있다.


left join fetch t.deptno.loc_code는 Locations까지 함께 조회한다

두 번째 fetch join은 아래 부분이다.

// HelloJPA6.java
"left join fetch t.deptno.loc_code" // Dept와 Locations까지 함께 조회

이 경로는 길어 보이지만, 나누어 읽으면 어렵지 않다.
t는 Emp이다.
t.deptno는 Emp가 참조하는 Dept이다.
t.deptno.loc_code는 그 Dept가 참조하는 Locations이다.


따라서 이 코드는 Emp → Dept → Locations 흐름을 한 번에 따라간다.
Emp를 조회하면서 Dept를 함께 가져오고, 그 Dept가 참조하는 Locations까지 함께 가져오려는 구조이다.


이렇게 작성하면 System.out.println(elem)으로 Emp를 출력할 때 Dept와 Locations 정보가 함께 보일 수 있다.
이때 toString() 설정에 따라 출력 문자열이 길어질 수 있다.


left join fetch t.deptno.loc_code는 사원에서 부서를 거쳐 지역까지 이어지는 연관 객체 흐름을 처음 조회 쿼리에서 함께 준비하는 코드이다.


일반 Join과 Fetch Join은 목적이 다르다

join과 fetch join은 모양이 비슷해서 헷갈리기 쉽다.
하지만 목적이 다르다.


일반 join은 주로 조건을 걸거나 연결된 엔티티의 값을 기준으로 조회 범위를 정할 때 사용한다.
예를 들어 부서명이 특정 값인 사원만 찾고 싶다면 join을 조건 조회 목적으로 사용할 수 있다.


반면 fetch join은 연관 엔티티를 실제로 함께 가져오는 것이 목적이다.
즉, 조회 결과의 중심 엔티티를 가져오면서, 나중에 사용할 연관 엔티티도 함께 채워 둔다.


아래 두 흐름을 비교하면 차이가 보인다.

// NormalJoinFlow.java
Query q = em.createQuery(
        "select t from Emp t join t.deptno d where d.dname = :dname",
        Emp.class
); // 부서명을 조건으로 Emp 조회

일반 join은 여기서 d.dname 조건을 걸기 위해 사용된다.
연관 엔티티를 항상 함께 채워 두는 것이 핵심 목적은 아니다.

// FetchJoinFlow.java
Query q = em.createQuery(
        "select t from Emp t left join fetch t.deptno",
        Emp.class
); // Emp를 조회하면서 Dept도 함께 조회

fetch join은 Emp를 조회하면서 Dept를 함께 가져오는 것이 목적이다.
이후 반복문 안에서 Dept를 사용할 때 추가 조회를 줄이는 데 도움이 될 수 있다.

일반 Join과 Fetch Join 비교

  • 일반 join: 조건이나 연결을 위해 사용한다.
  • 일반 join: 연관 엔티티를 함께 채워 두는 것이 항상 목적은 아니다.
  • fetch join: 연관 엔티티를 함께 조회하기 위해 사용한다.
  • fetch join: 목록 조회 후 연관 필드를 사용할 때 추가 조회를 줄이는 데 도움이 된다.

이 예제처럼 조회한 Emp 객체 안에서 Dept까지 바로 사용하려면, 단순 join보다 연관 엔티티를 함께 채우는 fetch join이 더 적합하다.


Fetch Join 결과는 길게 나와도 보는 기준이 있다

HelloJPA6.java의 결과는 길게 나올 수 있다.
System.out.println(elem)으로 Emp 전체를 출력하고, Emp의 toString() 안에 Dept, Locations 정보가 함께 보일 수 있기 때문이다.


하지만 결과가 길어도 보는 기준은 명확하다.
첫 번째는 SQL 로그에서 Emp, Dept, Locations가 함께 조인되는지이다.
두 번째는 출력 결과에서 Emp 데이터가 출력되는지이다.
세 번째는 Dept와 Locations 정보가 함께 출력되는지이다.


먼저 SQL 로그를 보면 Emp를 기준으로 Dept와 Locations가 left join으로 함께 조회된다.
JPQL에서는 left join fetch t.deptno와 left join fetch t.deptno.loc_code를 사용했고, 실제 SQL에서는 이 연관 흐름이 테이블 조인으로 바뀐다.


HelloJPA6.java의 Fetch Join SQL 로그이다.
JPQL에서는 left join fetch t.deptno와 left join fetch t.deptno.loc_code를 사용했다.
실제 SQL에서는 Emp를 기준으로 Dept가 left join되고, 이어서 Locations가 다시 left join된다.
즉, Emp → Dept → Locations 연관 흐름이 한 조회 쿼리 안에서 함께 처리되는 것을 확인할 수 있다.


출력 결과에서는 Emp 객체 안에 Dept 정보가 함께 보인다.
그리고 Dept 안에는 다시 Locations 정보가 포함되어 보인다.


HelloJPA6.java 실행 결과이다.
출력 결과를 보면 Emp 객체 안에 deptno=Dept(...)가 함께 출력되고, 그 안에 loc_code=Locations(...)도 포함되어 있다.
이는 Fetch Join으로 Emp, Dept, Locations가 함께 준비되었음을 보여 준다.
ADAMS는 부서 참조가 없기 때문에 deptno=null로 출력되지만, left join을 사용했기 때문에 결과 목록에서 빠지지 않는다.


출력 형태는 아래처럼 볼 수 있다.

// 출력결과
// Emp(empno=7369, ename=SMITH, ..., deptno=Dept(... loc_code=Locations(...)))
// Emp(empno=7499, ename=ALLEN, ..., deptno=Dept(... loc_code=Locations(...)))
// ...
// Emp(empno=7876, ename=ADAMS, ..., deptno=null)
// 데이터 갯수 : 14

ADAMS처럼 deptno가 null인 사원은 left join 덕분에 조회 결과에서 제외되지 않는다.
대신 출력 결과에서 deptno=null로 보인다.


결과를 볼 때는 출력 문자열만 보면 부족하다.
반드시 SQL 로그도 함께 봐야 한다.
fetch join이 적용되면 Emp 조회 쿼리 안에서 Dept, Locations가 함께 조인되는 흐름을 확인할 수 있다.


HelloJPA6.java의 결과는 길어도 Emp, Dept, Locations가 한 조회 흐름에서 함께 준비되는지 확인하면 된다.


앞 예제와 HelloJPA6의 차이

앞 예제들은 먼저 Emp 목록을 조회한 뒤, 반복문에서 Dept 정보를 사용했다.
이때 연관 엔티티가 아직 준비되지 않았다면 추가 조회가 발생할 수 있었다.


반면 HelloJPA6.java는 조회 쿼리 자체에서 연관 엔티티를 함께 가져오라고 명시한다.
그래서 결과를 출력할 때 이미 연관 데이터가 준비되어 있을 수 있다.


차이를 코드 기준으로 보면 아래와 같다.

// NormalRelationAccessFlow.java
Query q = em.createQuery("select t from Emp t", Emp.class); // Emp만 조회
List<Emp> empList = q.getResultList(); // Emp 목록 조회

for (Emp elem : empList) { // 사원 목록 반복
    if (elem.getDeptno() != null) { // 부서 참조가 있으면
        System.out.println(elem.getDeptno().getDname()); // 연관 필드 사용 시 추가 조회 가능
    }
}

이 흐름은 Emp를 먼저 조회한다.
그다음 반복문 안에서 Dept의 부서명을 사용한다.
FetchType.LAZY 상태에서 Dept가 아직 준비되지 않았다면 이때 추가 조회가 발생할 수 있다.

// FetchJoinRelationAccessFlow.java
Query q = em.createQuery(
        "select t from Emp t left join fetch t.deptno left join fetch t.deptno.loc_code",
        Emp.class
); // Emp, Dept, Locations 함께 조회

List<Emp> empList = q.getResultList(); // 함께 조회된 결과 사용

이 흐름은 처음 조회할 때부터 Dept와 Locations까지 함께 가져오도록 지정한다.
그래서 반복문에서 연관 필드를 사용할 때 추가 조회가 반복되는 상황을 줄일 수 있다.

앞 예제와의 차이

  • 앞 예제: Emp를 먼저 조회하고, 반복문에서 Dept 필드를 사용한다.
  • 앞 예제: FetchType.LAZY 상태에서는 연관 필드 사용 시점에 추가 조회가 발생할 수 있다.
  • HelloJPA6: fetch join으로 Dept, Locations를 처음부터 함께 조회한다.
  • HelloJPA6: 반복문에서 연관 필드를 사용할 때 추가 조회가 반복되는 상황을 줄일 수 있다.

Fetch Join은 연관 필드를 나중에 반복해서 조회하지 않도록, 필요한 연관 엔티티를 처음부터 함께 조회하는 방식이다.


Fetch Join은 필요한 곳에만 사용해야 한다

Fetch Join은 N+1 문제를 줄이는 데 효과적이다.
하지만 모든 조회에 무조건 붙이면 좋은 것은 아니다.


연관 데이터를 함께 가져온다는 말은 한 번에 가져오는 데이터 양이 늘어날 수 있다는 뜻이다.
사원 이름만 필요한 화면인데 부서와 지역 정보까지 항상 함께 가져오면 오히려 불필요한 조회가 될 수 있다.


그래서 fetch join은 연관 데이터가 실제로 필요한 조회에서 사용해야 한다.
예를 들어 사원 목록 화면에서 부서명과 지역명도 함께 보여 줘야 한다면 fetch join을 사용할 수 있다.
하지만 사원 번호와 이름만 보여 주는 화면이라면 부서와 지역까지 가져올 필요가 없을 수 있다.


좋은 기준은 아래와 같다.

  • 연관 데이터가 화면이나 로직에서 바로 필요하면 fetch join을 고려한다.
  • 연관 데이터가 필요하지 않으면 기본 조회를 가볍게 유지한다.
  • 목록 조회 후 반복문에서 연관 데이터를 계속 사용한다면 N+1 여부를 확인한다.
  • 모든 조회에 무조건 fetch join을 붙이지 않는다.

Fetch Join은 성능 문제를 줄이는 도구이지만, 필요한 연관 데이터가 명확할 때 사용하는 것이 좋다.


핵심 정리

HelloJPA6.java는 Fetch Join으로 Emp, Dept, Locations를 함께 조회하는 예제이다.
앞 예제에서 반복문 안의 연관 필드 접근으로 추가 조회가 발생할 수 있는 흐름을 봤다면, 이번 예제는 처음 조회 쿼리에서 필요한 연관 엔티티를 함께 가져오는 흐름을 확인한다.


정리하면 아래와 같다.

  • Fetch Join은 연관 엔티티를 함께 조회하는 JPQL 기능이다.
  • select t from Emp t에서 조회 결과의 중심은 Emp이다.
  • left join fetch t.deptno는 Emp를 조회하면서 Dept도 함께 조회한다.
  • left join fetch t.deptno.loc_code는 Dept가 참조하는 Locations까지 함께 조회한다.
  • t는 Emp, t.deptno는 Dept, t.deptno.loc_code는 Locations로 나누어 읽으면 된다.
  • left join을 사용하면 연관 데이터가 없어도 주 엔티티가 결과에서 빠지지 않을 수 있다.
  • 일반 join은 조건이나 연결 목적이 강하다.
  • fetch join은 연관 엔티티를 함께 조회하는 목적이 강하다.
  • Fetch Join을 사용하면 반복문에서 연관 필드를 사용할 때 추가 조회가 반복되는 상황을 줄일 수 있다.
  • 모든 조회에 무조건 Fetch Join을 붙이면 안 된다.
  • 연관 데이터가 실제로 필요한 조회에서만 Fetch Join을 사용하는 것이 좋다.

기본은 필요한 데이터만 가볍게 조회하고, 연관 데이터를 반복해서 사용할 조회에서는 Fetch Join으로 함께 가져오는 흐름을 고려해야 한다.




응용예제 13. EmpDAO로 JPQL 조회 기능 구조 잡기 (EmpDAO.java, EmpFreqDTO.java)

EmpDAO.java는 Emp 데이터를 조회하는 기능을 메서드 단위로 모아 둔 클래스이다.
앞 예제들에서는 실행 파일 안에서 직접 JPQL을 작성하고 바로 실행했다.
이번 예제에서는 조회 기능을 DAO 메서드로 분리해서 관리한다.


DAO는 Data Access Object의 줄임말이다.
데이터 접근 객체라는 뜻이며, 데이터베이스 조회나 저장 같은 작업을 한곳에 모아 두는 역할을 한다.
즉, 실행 파일이 직접 모든 JPQL을 작성하지 않고, EmpDAO의 메서드를 호출해서 필요한 조회 기능을 사용할 수 있게 만든다.


이 예제의 핵심은 EmpDAO의 모든 메서드를 외우는 것이 아니라, 조회 목적에 따라 JPQL, 반환 타입, 메서드 역할이 어떻게 나뉘는지 구조로 이해하는 것이다.


이전 기본개념과 연결하기

앞에서 JPQL은 테이블이 아니라 Entity를 기준으로 조회한다고 정리했다.
예를 들어 select t from Emp t는 실제 테이블 이름을 직접 쓰는 것이 아니라, Emp 엔티티를 조회 대상으로 사용하는 문장이다.


또 앞에서 EntityManager는 Entity를 저장, 조회, 수정, 삭제할 때 사용하는 핵심 객체라고 정리했다.
EmpDAO.java도 내부에서 EntityManager를 사용한다.
다만 실행 파일처럼 한 번만 조회하고 끝나는 것이 아니라, 여러 조회 기능을 메서드로 나누어 제공한다.


이번 예제에서 중요한 점은 JPQL 문법이 여러 형태로 등장한다는 것이다.
전체 개수 조회에는 count()를 사용하고, 기본키 조회에는 find()를 사용한다.
조건 조회에는 where, =, >=, and, like를 사용한다.
필요한 값만 담을 때는 select new로 DTO를 만들고, 일부 목록만 가져올 때는 setFirstResult()와 setMaxResults()를 사용한다.


즉, EmpDAO.java는 새로운 개념 하나만 보는 예제가 아니다.
앞에서 배운 조회 문법을 실제 조회 기능별 메서드로 정리한 예제이다.

기본개념에서 다시 잡아야 할 흐름

  • DAO는 데이터베이스 접근 코드를 모아 둔 객체이다.
  • EmpDAO는 Emp 엔티티 조회 기능을 메서드로 나눈다.
  • EntityManager는 실제 조회 작업을 실행한다.
  • JPQL은 테이블이 아니라 Entity와 필드를 기준으로 작성한다.
  • 메서드마다 조회 목적이 다르다.
  • 조회 목적이 다르면 JPQL 문법과 반환 타입도 달라진다.
  • EmpDAO를 볼 때는 메서드 이름, JPQL, 반환 타입을 함께 봐야 한다.

EmpDAO는 긴 코드 파일로 보기보다, 조회 기능을 목적별로 나눈 지도처럼 읽어야 한다.


예제의 목표

이 예제의 목표는 EmpDAO.java 안에 들어 있는 여러 조회 메서드의 역할을 구분하는 것이다.
각 메서드를 깊게 실행 결과까지 분석하는 것이 아니라, 다음 예제에서 EmpApp1.java, EmpApp2.java가 어떤 메서드를 호출하는지 이해할 수 있도록 기반 구조를 잡는 것이 중요하다.


어떤 메서드는 전체 데이터 개수를 반환한다.
어떤 메서드는 기본키로 사원 이름을 조회한다.
어떤 메서드는 직무, 급여, 이름 일부 검색처럼 조건을 사용한다.
또 어떤 메서드는 DTO, 페이징, 집계 함수처럼 조회 결과를 다른 형태로 받는다.


즉, EmpDAO.java는 단순히 코드가 긴 파일이 아니다.
JPQL 조회 기능을 실제 프로그램에서 재사용할 수 있는 메서드로 나눈 파일이다.

이 예제에서 확인할 내용

  • EmpDAO가 EntityManagerFactory와 EntityManager를 어떻게 준비하는지 확인한다.
  • 전체 개수 조회 메서드가 어떤 역할인지 확인한다.
  • 기본키 조회와 조건 조회의 차이를 확인한다.
  • where, like, and 같은 조건 문법을 메서드 역할과 연결한다.
  • select new로 DTO를 만드는 흐름을 확인한다.
  • setFirstResult()와 setMaxResults()로 페이징을 처리하는 흐름을 확인한다.
  • sum(), max(), min() 같은 집계 결과를 Object[]로 받는 흐름을 확인한다.
  • 다음 예제에서 EmpApp1.java, EmpApp2.java가 이 메서드들을 어떻게 호출하는지 이어서 볼 준비를 한다.

13번은 실행 결과를 보는 파트가 아니라, 14번 실행 결과를 이해하기 위한 EmpDAO 메서드 구조를 먼저 잡는 파트이다.


EmpDAO는 EntityManager를 내부에서 사용한다

EmpDAO.java는 내부에서 EntityManagerFactory와 EntityManager를 생성한다.
그리고 여러 조회 메서드가 같은 em을 사용한다.


핵심 구조는 아래와 같다.

// EmpDAO.java
public class EmpDAO {
    EntityManagerFactory factory = Persistence.createEntityManagerFactory("emptest"); // emptest 설정으로 Factory 생성
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    public void close() { // 자원 정리 메서드
        em.close(); // EntityManager 닫기
        factory.close(); // EntityManagerFactory 닫기
    }
}

여기서는 "entitytest"가 아니라 "emptest" 설정 묶음을 사용한다.
즉, 앞의 HelloJPA 실행 예제와 다른 설정 묶음으로 실행될 수 있다.


close()는 사용이 끝난 뒤 EntityManager와 EntityManagerFactory를 닫는 메서드이다.
다음 예제의 EmpApp1.java, EmpApp2.java에서는 마지막에 dao.close()를 호출해 사용한 자원을 정리한다.


이 구조를 먼저 봐야 뒤의 조회 메서드들이 이해된다.
각 메서드는 새로 EntityManager를 만드는 것이 아니라, EmpDAO 안에 준비된 em을 사용해서 조회를 실행한다.


EmpDAO는 조회 기능을 메서드로 나누고, 그 메서드들은 내부의 EntityManager를 사용해서 실제 JPQL을 실행한다.


EmpDAO 메서드는 조회 목적별로 먼저 나누어 읽는다

EmpDAO.java는 메서드가 많기 때문에 위에서 아래로 무작정 읽으면 헷갈릴 수 있다.
그래서 먼저 조회 목적별로 나누어 보는 것이 좋다.


목적별로 정리하면 아래와 같다.

  • 전체 개수 조회: getAllDataNum()
  • 기본키 조회: getEmpName()
  • 정확한 조건 조회: findByJob()
  • 숫자 비교 조건 조회: findByGreaterThanSal()
  • 일부 문자열 검색: findByPartEname()
  • 여러 조건 조회: findByEnameAndJob1(), findByEnameAndJob2()
  • DTO 조회: findByEmpFreqDTO()
  • 페이징 조회: listPart()
  • 집계 함수 조회: getGroupFunc()

각 메서드는 모두 Emp를 기준으로 조회하지만, 조회 목적과 반환 타입이 다르다.
그래서 메서드를 볼 때는 메서드 이름만 보지 말고 JPQL, setParameter(), 반환 타입을 함께 확인해야 한다.

메서드를 읽는 기준

  • 메서드 이름은 조회 목적을 알려 준다.
  • JPQL은 어떤 조건으로 조회하는지 알려 준다.
  • 반환 타입은 결과를 어떤 형태로 받을지 알려 준다.
  • getSingleResult()는 결과가 하나일 때 사용한다.
  • getResultList()는 결과가 여러 개일 수 있을 때 사용한다.

EmpDAO에서 가장 먼저 봐야 할 것은 코드 줄 수가 아니라, 메서드별 조회 목적과 반환 타입이다.


전체 개수 조회와 기본키 조회

전체 개수 조회와 기본키 조회는 가장 기본적인 조회 흐름이다.
전체 개수는 count()를 사용하고, 기본키로 한 건을 찾을 때는 find()를 사용할 수 있다.

전체 데이터 개수 조회

getAllDataNum()은 Emp 데이터 개수를 구하는 메서드이다.
count()는 개수를 세는 집계 함수이다.
집계 함수는 여러 데이터에서 하나의 결과값을 계산하는 함수라고 이해하면 된다.

// EmpDAO.java
public Long getAllDataNum() { // 전체 데이터 개수 조회
    TypedQuery<Long> q = em.createQuery(
            "select count(t.empno) from Emp t", Long.class
    ); // count 결과는 Long

    return q.getSingleResult(); // 단일 결과 반환
}

select count(t.empno) from Emp t는 Emp 엔티티의 empno 값을 기준으로 데이터 개수를 센다.
결과는 목록이 아니라 하나의 숫자이다.
그래서 getResultList()가 아니라 getSingleResult()를 사용한다.


count() 결과는 정수처럼 보이지만, JPQL에서는 보통 Long으로 받는다.
따라서 반환 타입도 Long이다.

기본키로 사원 이름 조회

getEmpName()은 사원 번호를 받아 해당 사원의 이름을 반환한다.
여기서는 JPQL을 직접 작성하지 않고 find()를 사용한다.

// EmpDAO.java
public String getEmpName(Integer numOfEmp) { // 사원 번호로 이름 조회
    Emp e = em.find(Emp.class, numOfEmp); // 기본키로 Emp 조회

    if (e != null) { // 조회 결과가 있으면
        return e.getEname(); // 사원 이름 반환
    } else {
        return "없음"; // 조회 결과가 없으면 없음 반환
    }
}

find(Emp.class, numOfEmp)는 Emp 엔티티에서 기본키 값이 numOfEmp인 데이터를 찾는다.
기본키는 한 데이터를 식별하는 값이다.
따라서 기본키로 조회하면 결과가 있더라도 보통 하나의 엔티티만 나온다.


조회 결과가 없으면 e가 null이 될 수 있다.
그래서 e.getEname()을 바로 호출하지 않고, 먼저 e != null인지 확인한다.

이 구간에서 봐야 할 기준

  • count()는 전체 개수를 구한다.
  • 전체 개수는 하나의 결과이므로 getSingleResult()를 사용한다.
  • find()는 기본키로 엔티티 하나를 조회한다.
  • find() 결과가 없으면 null이 될 수 있다.
  • null 여부를 확인한 뒤 필드를 사용해야 한다.

개수 조회는 count(), 기본키 조회는 find()처럼 조회 목적에 맞는 도구를 선택해야 한다.


조건 조회는 JPQL과 파라미터 바인딩을 사용한다

조건 조회는 특정 기준에 맞는 데이터만 찾는 조회이다.
EmpDAO에서는 직무가 같은 사원, 급여가 기준 이상인 사원, 이름과 직무가 모두 같은 사원 등을 조건으로 조회한다.


이때 중요한 문법이 파라미터 바인딩이다.
파라미터는 쿼리 안에서 나중에 값을 넣을 자리이다.
바인딩은 그 자리에 실제 값을 연결하는 것이다.
즉, 파라미터 바인딩은 JPQL 안의 빈자리에 메서드로 받은 값을 연결하는 과정이다.

직무와 급여 조건 조회

findByJob()은 직무가 특정 값과 같은 사원을 조회한다.
findByGreaterThanSal()은 급여가 기준값 이상인 사원을 조회한다.

// EmpDAO.java
public List<Emp> findByJob(String job) { // 직무 조건 조회
    TypedQuery<Emp> q = em.createQuery(
            "SELECT t FROM Emp t WHERE t.job = :job", Emp.class
    ); // job 필드 조건

    q.setParameter("job", job); // job 파라미터에 값 연결

    return q.getResultList(); // 결과 목록 반환
}

public List<Emp> findByGreaterThanSal(int sal) { // 급여 조건 조회
    TypedQuery<Emp> q = em.createQuery(
            "SELECT t FROM Emp t WHERE t.sal >= :sal", Emp.class
    ); // sal 이상 조건

    q.setParameter("sal", sal); // sal 파라미터에 기준값 연결

    return q.getResultList(); // 결과 목록 반환
}

:job과 :sal은 값이 들어갈 자리이다.
setParameter("job", job)은 :job 자리에 메서드로 받은 job 값을 연결한다.
setParameter("sal", sal)은 :sal 자리에 메서드로 받은 sal 값을 연결한다.


결과는 여러 명일 수 있으므로 List<Emp>로 받는다.
그리고 여러 결과를 받을 수 있기 때문에 getResultList()를 사용한다.

일부 문자열 검색

findByPartEname()은 사원 이름에 특정 문자열이 포함된 데이터를 찾는다.
이때 like 조건을 사용한다.

// EmpDAO.java
public List<Emp> findByPartEname(String partEname) { // 이름 일부 검색
    TypedQuery<Emp> q = em.createQuery(
            "SELECT t FROM Emp t WHERE t.ename like :pe", Emp.class
    ); // like 조건

    q.setParameter("pe", "%" + partEname + "%"); // 앞뒤에 어떤 글자가 와도 됨

    return q.getResultList(); // 결과 목록 반환
}

like는 정확히 같은 값이 아니라, 특정 글자가 포함된 값을 찾을 때 사용한다.
"%"+partEname+"%"는 앞뒤에 어떤 글자가 와도 된다는 뜻이다.


정확히 같은 값을 찾을 때는 =를 사용한다.
일부 글자가 포함된 값을 찾을 때는 like와 %를 사용한다.

여러 조건 조회

findByEnameAndJob1()과 findByEnameAndJob2()는 이름과 직무를 모두 만족하는 사원을 조회한다.
and는 두 조건을 모두 만족해야 한다는 뜻이다.

// EmpDAO.java
public List<Emp> findByEnameAndJob1(String ename, String job) { // 이름과 직무 조건 조회
    TypedQuery<Emp> q = em.createQuery(
            "SELECT t FROM Emp t WHERE t.ename = :ename and t.job = :job", Emp.class
    ); // 이름 기반 파라미터 사용

    q.setParameter("ename", ename); // ename 파라미터 연결
    q.setParameter("job", job); // job 파라미터 연결

    return q.getResultList(); // 결과 목록 반환
}

public Emp findByEnameAndJob2(String ename, String job) { // 이름과 직무로 단건 조회
    TypedQuery<Emp> q = em.createQuery(
            "SELECT t FROM Emp t WHERE t.ename = ?1 and t.job = ?2", Emp.class
    ); // 위치 기반 파라미터 사용

    q.setParameter(1, ename); // 첫 번째 파라미터에 ename 연결
    q.setParameter(2, job); // 두 번째 파라미터에 job 연결

    return q.getSingleResult(); // 단일 결과 반환
}

:ename, :job처럼 이름으로 값을 연결하는 방식을 이름 기반 파라미터라고 한다.
?1, ?2처럼 순서로 값을 연결하는 방식을 위치 기반 파라미터라고 한다.


두 방식 모두 가능하다.
다만 처음 배울 때는 이름 기반 파라미터가 읽기 쉽다.
위치 기반은 순서를 잘못 맞추면 다른 값이 잘못 연결될 수 있다.

조건 조회에서 봐야 할 기준

  • where는 조건을 지정한다.
  • :job, :sal처럼 이름이 붙은 값 자리를 이름 기반 파라미터라고 한다.
  • ?1, ?2처럼 순서로 값을 넣는 방식을 위치 기반 파라미터라고 한다.
  • setParameter()는 파라미터 자리에 실제 값을 연결한다.
  • 정확한 값 비교는 =, 숫자 비교는 >=, 일부 문자열 검색은 like를 사용한다.
  • 조건이 여러 개이면 and로 연결할 수 있다.

조건 조회에서는 JPQL의 조건식과 setParameter()에 연결하는 값이 서로 정확히 맞아야 한다.


DTO 조회는 select new로 처리한다

findByEmpFreqDTO()는 Emp 엔티티 전체가 아니라 EmpFreqDTO 객체로 결과를 받는다.
이때 select new 문법을 사용한다.


DTO는 Data Transfer Object의 줄임말이다.
데이터 전달 객체라는 뜻이다.
엔티티 전체가 아니라 화면이나 기능에 필요한 값만 따로 담아 전달할 때 사용한다.


핵심 코드는 아래와 같다.

// EmpDAO.java
public List<EmpFreqDTO> findByEmpFreqDTO() { // DTO 조회
    TypedQuery<EmpFreqDTO> q = em.createQuery(
            "SELECT new EmpFreqDTO(t.empno, t.ename, t.hiredate, t.sal, t.deptno) FROM Emp t",
            EmpFreqDTO.class
    ); // DTO 생성자 호출 형태의 JPQL

    return q.getResultList(); // DTO 목록 반환
}

select new EmpFreqDTO(...)는 조회 결과를 바로 EmpFreqDTO 객체로 만들어 받겠다는 뜻이다.
이때 EmpFreqDTO에는 해당 인자들을 받을 수 있는 생성자가 있어야 한다.


실제 코드에서 EmpFreqDTO 앞에 패키지 경로가 붙어 있다면, select new는 그 전체 클래스명을 기준으로 객체를 만든다.
즉, 실행 환경과 패키지 구조에 따라 SELECT new 패키지명.EmpFreqDTO(...) 형태로 작성될 수 있다.


EmpFreqDTO.java에는 @AllArgsConstructor가 있다.
@AllArgsConstructor는 모든 필드를 인자로 받는 생성자를 자동으로 만들어 주는 Lombok 어노테이션이다.
그래서 empno, ename, hiredate, sal, deptno를 받는 생성자가 만들어질 수 있다.

// EmpFreqDTO.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
@AllArgsConstructor // 모든 필드를 받는 생성자 자동 생성
public class EmpFreqDTO {
    private int empno; // 사원 번호
    private String ename; // 사원 이름
    private java.sql.Date hiredate; // 입사일
    private int sal; // 급여
    private Dept deptno; // 부서
}

여기서 중요한 점은 select new의 인자 순서와 DTO 생성자의 인자 순서가 맞아야 한다는 것이다.
JPQL에서 t.empno, t.ename, t.hiredate, t.sal, t.deptno 순서로 값을 넘기면, EmpFreqDTO 생성자도 그 순서대로 받을 수 있어야 한다.

DTO 조회에서 봐야 할 기준

  • DTO는 필요한 값만 담아 전달하는 객체이다.
  • select new는 조회 결과로 DTO 객체를 바로 만든다.
  • DTO에는 해당 값을 받을 생성자가 있어야 한다.
  • 패키지 구조에 따라 select new에 전체 클래스명이 필요할 수 있다.
  • select new의 인자 순서와 생성자 인자 순서가 맞아야 한다.

select new는 엔티티 전체가 아니라 필요한 값을 담은 DTO 객체를 바로 만들 때 사용하는 조회 방식이다.


페이징과 집계 함수는 결과 형태가 다르다

listPart()는 사원 목록을 급여 내림차순으로 정렬한 뒤 일부만 가져온다.
getGroupFunc()는 급여 총액, 최대 급여, 최소 급여를 한 번에 조회한다.
두 메서드는 모두 조회 기능이지만 결과 형태가 다르다.

페이징 조회

페이징은 전체 데이터 중 일부 구간만 가져오는 방식이다.
setFirstResult()와 setMaxResults()를 사용한다.

// EmpDAO.java
public List<Emp> listPart(int start, int num) { // 일부 목록 조회
    TypedQuery<Emp> q = em.createQuery(
            "SELECT t FROM Emp t ORDER BY t.sal DESC", Emp.class
    ); // 급여 내림차순 정렬

    q.setFirstResult(start); // 조회 시작 위치
    q.setMaxResults(num); // 조회할 데이터 수

    return q.getResultList(); // 결과 목록 반환
}

ORDER BY t.sal DESC는 급여를 기준으로 내림차순 정렬한다는 뜻이다.
DESC는 큰 값에서 작은 값으로 정렬하는 방식이다.


setFirstResult(start)는 몇 번째 결과부터 가져올지 정한다.
start가 0이면 첫 번째 결과부터 가져온다.
start가 3이면 앞의 3개를 건너뛰고 그다음 결과부터 가져온다.


setMaxResults(num)은 몇 개를 가져올지 정한다.
예를 들어 num이 3이면 최대 3개만 가져온다.

집계 함수 조회

getGroupFunc()는 급여 총액, 최대 급여, 최소 급여를 한 번에 조회한다.
여러 집계 결과를 함께 조회하므로 결과를 Object[]로 받는다.

// EmpDAO.java
public Object[] getGroupFunc() { // 집계 함수 조회
    Query query = em.createQuery(
            "SELECT sum(t.sal), max(t.sal), min(t.sal) FROM Emp t"
    ); // 급여 합계, 최대, 최소 조회

    Object[] result = (Object[]) query.getSingleResult(); // 단일 행 결과를 Object 배열로 받음

    return result; // 결과 반환
}

sum(t.sal)은 급여 합계이다.
max(t.sal)은 가장 큰 급여이다.
min(t.sal)은 가장 작은 급여이다.


조회 결과는 한 행으로 나오지만, 그 안에 값이 여러 개 있다.
그래서 Object[]로 받은 뒤 result[0], result[1], result[2]처럼 꺼내 사용할 수 있다.


이때 result[0]은 sum(t.sal) 결과이고, result[1]은 max(t.sal) 결과이며, result[2]는 min(t.sal) 결과이다.
배열은 순서로 값을 꺼내기 때문에, select에 적은 순서와 배열 인덱스를 함께 봐야 한다.

페이징과 집계 함수에서 봐야 할 기준

  • 페이징은 정렬 기준, 시작 위치, 조회 개수를 함께 봐야 한다.
  • setFirstResult()는 조회 시작 위치를 정한다.
  • setMaxResults()는 가져올 데이터 수를 정한다.
  • 집계 함수는 여러 데이터를 계산해 하나의 결과를 만든다.
  • 여러 집계 값을 한 행으로 조회하면 Object[]로 받을 수 있다.
  • Object[]는 select에 적은 순서대로 값이 들어간다.

페이징은 목록 일부를 가져오는 조회이고, 집계 함수는 여러 데이터를 계산한 결과를 가져오는 조회이다.


EmpDAO와 다음 EmpApp 예제는 함께 봐야 한다

EmpDAO.java만 보면 메서드가 많아 복잡해 보일 수 있다.
하지만 다음 예제의 EmpApp1.java, EmpApp2.java에서는 이 메서드들을 순서대로 호출해서 결과를 확인한다.


그래서 13번에서는 메서드의 역할을 먼저 잡고, 14번에서는 실제 실행 결과를 구간별로 읽으면 된다.
예를 들어 getAllDataNum()이 어떤 JPQL을 실행하는지 13번에서 확인했다면, 14번에서는 그 메서드를 호출했을 때 콘솔에 어떤 결과가 나오는지 확인하면 된다.


이렇게 나누어 보면 EmpDAO와 EmpApp의 역할도 분명해진다.
EmpDAO는 조회 기능을 제공하는 클래스이고, EmpApp은 그 조회 기능을 호출해서 결과를 확인하는 실행 클래스이다.

EmpDAO와 EmpApp의 역할 차이

  • EmpDAO: 조회 기능을 메서드로 제공한다.
  • EmpDAO: 내부에서 EntityManager와 JPQL을 사용한다.
  • EmpApp: EmpDAO 메서드를 호출한다.
  • EmpApp: 호출 결과를 콘솔에 출력하며 확인한다.

13번에서는 EmpDAO 메서드의 역할을 먼저 잡고, 14번에서는 EmpApp 실행 결과로 그 역할이 어떻게 드러나는지 확인하면 된다.


핵심 정리

EmpDAO는 JPQL 조회 기능을 한곳에 모아 둔 예제이다.
이 파일을 보면 기본 개념에서 배운 조회 문법이 실제 메서드로 어떻게 분리되는지 확인할 수 있다.


정리하면 아래와 같다.

  • EmpDAO는 데이터베이스 조회 기능을 모아 둔 DAO 클래스이다.
  • EntityManager를 내부에서 사용해 실제 조회를 실행한다.
  • 기본키 조회는 find()를 사용한다.
  • 전체 개수는 count()로 조회한다.
  • 정확한 조건은 =를 사용한다.
  • 일부 문자열 검색은 like를 사용한다.
  • 숫자 비교는 >= 같은 비교 연산자를 사용한다.
  • 여러 조건은 and로 연결한다.
  • 파라미터 바인딩은 JPQL의 빈자리에 실제 값을 연결하는 과정이다.
  • 이름 기반 파라미터는 의미가 잘 보이고, 위치 기반 파라미터는 순서가 중요하다.
  • 필요한 값만 담으려면 select new로 DTO 조회를 할 수 있다.
  • select new는 패키지 구조에 따라 전체 클래스명이 필요할 수 있다.
  • 페이징은 setFirstResult()와 setMaxResults()를 사용한다.
  • 집계 결과 여러 개는 Object[]로 받을 수 있다.
  • 다음 예제에서는 EmpApp1.java, EmpApp2.java가 이 메서드들을 호출해 실제 결과를 보여 준다.

EmpDAO는 JPQL 문법을 실제 데이터 조회 기능으로 나누어 구현한 대표 응용예제이며, 다음 EmpApp 실행 예제를 이해하기 위한 기반 코드이다.




응용예제 14. EmpApp1과 EmpApp2로 DAO 실행 결과 확인하기 (EmpApp1.java, EmpApp2.java, EmpDAO.java)

EmpApp1.java와 EmpApp2.java는 EmpDAO의 메서드를 실제로 호출해서 결과를 출력하는 실행 예제이다.
앞의 EmpDAO.java가 조회 기능을 메서드로 나눈 클래스라면, 이번 예제의 EmpApp1.java와 EmpApp2.java는 그 메서드를 호출해서 결과를 확인하는 클래스이다.


두 파일은 거의 같은 조회 흐름을 가진다.
차이는 결과를 출력하는 방식이다.
EmpApp1.java는 주로 for 반복문을 사용하고, EmpApp2.java는 stream()을 사용한다.


결과가 매우 길게 나오기 때문에 모든 출력 줄을 이미지로 하나씩 나누어 볼 필요는 없다.
이 예제에서는 실행 흐름을 GIF로 확인하고, 본문에서는 각 출력 구간이 어떤 EmpDAO 메서드 호출 결과인지 연결해서 본다.


이 예제의 핵심은 EmpDAO 메서드를 호출했을 때 어떤 결과 구간이 만들어지는지 확인하고, for 반복문 출력과 stream() 출력 방식의 차이를 이해하는 것이다.


이전 기본개념과 연결하기

앞 예제에서 EmpDAO는 Emp 데이터를 조회하는 기능을 메서드 단위로 나누어 둔 클래스라고 정리했다.
getAllDataNum()은 전체 데이터 개수를 조회하고, getEmpName()은 기본키로 사원 이름을 조회한다.
findByJob(), findByPartEname(), findByGreaterThanSal()은 조건에 맞는 사원 목록을 조회한다.


이번 예제에서는 그 메서드들이 실제로 호출된다.
즉, EmpApp1.java와 EmpApp2.java는 JPQL을 직접 작성하는 파일이 아니라, EmpDAO가 제공하는 메서드를 호출하는 실행 파일이다.


이 흐름을 구분해야 한다.
EmpDAO는 데이터베이스 조회 기능을 제공하는 쪽이고, EmpApp은 그 기능을 호출해서 결과를 출력하는 쪽이다.

기본개념에서 다시 잡아야 할 흐름

  • EmpDAO는 조회 기능을 메서드로 제공한다.
  • EmpApp1.java와 EmpApp2.java는 EmpDAO 메서드를 호출한다.
  • EmpDAO 내부에서는 EntityManager와 JPQL이 사용된다.
  • EmpApp에서는 조회 결과를 콘솔에 출력한다.
  • EmpApp1.java는 for 반복문 중심으로 출력한다.
  • EmpApp2.java는 stream() 중심으로 출력한다.
  • 결과가 길게 나오므로 구분선과 출력 메시지를 기준으로 조회 구간을 나누어 봐야 한다.

EmpApp을 볼 때는 직접 JPQL을 찾기보다, 어떤 EmpDAO 메서드를 호출하고 그 결과를 어떻게 출력하는지 봐야 한다.


예제의 목표

이 예제의 목표는 EmpDAO의 여러 조회 메서드가 실제 실행 결과로 어떻게 나타나는지 확인하는 것이다.
13번에서는 메서드의 역할과 JPQL 구조를 먼저 잡았다.
이번에는 그 메서드들을 호출했을 때 콘솔에 어떤 결과 구간이 만들어지는지 본다.


EmpApp1.java와 EmpApp2.java는 같은 조회 기능을 다르게 출력하는 예제라고 볼 수 있다.
EmpApp1.java는 전통적인 for 반복문으로 목록을 하나씩 출력한다.
EmpApp2.java는 stream().forEach()와 System.out::println 같은 방식으로 목록을 출력한다.


즉, 이 예제는 새로운 조회 문법을 배우는 것이 아니다.
DAO 메서드를 실행 파일에서 어떻게 호출하고, 긴 콘솔 결과를 어떤 기준으로 읽어야 하는지 확인하는 예제이다.

이 예제에서 확인할 내용

  • EmpDAO 객체를 생성한다.
  • EmpDAO의 조회 메서드를 순서대로 호출한다.
  • getAllDataNum()으로 전체 데이터 개수를 확인한다.
  • getEmpName()으로 특정 사번의 이름을 확인한다.
  • 조건 조회 메서드들이 어떤 목록을 출력하는지 확인한다.
  • DTO 조회 결과가 어떻게 출력되는지 확인한다.
  • 페이징 조회 결과가 정렬과 개수 기준에 맞게 나오는지 확인한다.
  • 집계 함수 결과가 어떤 순서로 출력되는지 확인한다.
  • EmpApp1.java와 EmpApp2.java의 출력 방식 차이를 확인한다.

이번 예제는 결과를 한 줄씩 외우는 것이 아니라, 출력 구간이 어떤 DAO 메서드 호출 결과인지 연결해서 보는 것이 중요하다.


EmpApp1은 for 반복문으로 결과를 출력한다

EmpApp1.java는 EmpDAO 객체를 만들고, 여러 조회 메서드를 순서대로 호출한다.
조회 결과가 목록이면 for 반복문으로 하나씩 꺼내 출력한다.


전체 코드를 모두 외울 필요는 없다.
아래처럼 어떤 DAO 메서드를 어떤 순서로 호출하는지 먼저 보면 된다.

// EmpApp1.java
EmpDAO dao = new EmpDAO(); // DAO 객체 생성

dao.getAllDataNum(); // 전체 데이터 개수 조회
dao.getEmpName(7499); // 사번으로 이름 조회
dao.findByJob("SALESMAN"); // 직무 조건 조회
dao.findByPartEname("T"); // 이름 일부 검색
dao.findByGreaterThanSal(2000); // 급여 조건 조회
dao.findByEnameAndJob1("MARTIN", "SALESMAN"); // 이름과 직무 조건 조회
dao.findByEnameAndJob2("MARTIN", "SALESMAN"); // 이름과 직무 조건 단건 조회
dao.findByEmpFreqDTO(); // DTO 조회
dao.listPart(0, 3); // 페이징 조회
dao.getGroupFunc(); // 집계 함수 조회

dao.close(); // DAO 자원 정리

실제 코드에서는 각 메서드 호출 결과를 출력 메시지와 함께 보여 준다.
목록 결과는 for 반복문으로 하나씩 꺼내 출력한다.


예를 들어 findByJob("SALESMAN")의 결과가 여러 명이면, for 반복문이 각 사원을 하나씩 꺼내 System.out.println(elem)으로 출력한다.
이때 elem은 현재 반복에서 꺼낸 Emp 객체 하나를 의미한다.

// EmpApp1.java
List<Emp> r1 = dao.findByJob("SALESMAN"); // 직무 조건 조회

for (Emp elem : r1) { // 목록에서 Emp 객체를 하나씩 꺼냄
    System.out.println(elem); // 현재 Emp 객체 출력
}

for 반복문은 목록에 들어 있는 데이터를 하나씩 꺼내 처리하는 기본 반복 방식이다.
그래서 초보자가 실행 순서를 따라가기 쉽다.

EmpApp1에서 봐야 할 핵심

  • EmpDAO dao = new EmpDAO()로 조회 기능을 사용할 준비를 한다.
  • dao.getAllDataNum()처럼 DAO 메서드를 호출한다.
  • 목록 결과는 List<Emp>로 받는다.
  • for 반복문으로 목록의 요소를 하나씩 출력한다.
  • 실행이 끝나면 dao.close()로 자원을 정리한다.

EmpApp1.java는 EmpDAO 메서드 호출 결과를 for 반복문으로 하나씩 출력하는 실행 예제이다.


EmpApp1 결과는 메서드 호출 순서대로 읽는다

EmpApp1.java는 여러 DAO 메서드를 한 번에 실행한다.
그래서 결과가 길게 나온다.
이때 전체 결과를 한 덩어리로 보면 헷갈리기 쉽다.


결과는 구분선이나 출력 메시지를 기준으로 나누어 읽어야 한다.
중요한 것은 모든 출력 줄을 외우는 것이 아니라, 각 구간이 어떤 메서드 호출 결과인지 연결하는 것이다.


EmpApp1.java 실행 결과이다.
출력 결과가 길기 때문에 한 줄씩 모두 외우지 않는다.
구분선과 출력 메시지를 기준으로 나누어 보고, 각 구간이 어떤 EmpDAO 메서드 호출 결과인지 연결해서 확인한다.


EmpApp1.java 결과는 아래 흐름으로 읽으면 된다.

  • getAllDataNum() 구간에서는 전체 데이터 개수가 출력된다.
  • getEmpName(7499) 구간에서는 사번 7499 직원의 이름이 출력된다.
  • findByJob("SALESMAN") 구간에서는 직무가 SALESMAN인 사원 목록이 출력된다.
  • findByPartEname("T") 구간에서는 이름에 T가 포함된 사원 목록이 출력된다.
  • findByGreaterThanSal(2000) 구간에서는 급여가 2000 이상인 사원 목록이 출력된다.
  • findByEnameAndJob1("MARTIN", "SALESMAN") 구간에서는 이름과 직무 조건을 모두 만족하는 결과가 출력된다.
  • findByEnameAndJob2("MARTIN", "SALESMAN") 구간에서는 위치 기반 파라미터 조건 조회 결과가 출력된다.
  • findByEmpFreqDTO() 구간에서는 DTO 형태의 결과가 출력된다.
  • listPart(0, 3) 구간에서는 정렬된 결과 중 일부 목록이 출력된다.
  • getGroupFunc() 구간에서는 급여 합계, 최대 급여, 최소 급여가 출력된다.

예를 들어 findByPartEname("T") 구간에 이름에 T가 포함된 사원들이 출력된다면, 그 결과는 like 조건 조회의 결과로 이해해야 한다.
getGroupFunc() 구간은 목록 출력이 아니라 집계 결과 출력이다.
따라서 sum, max, min 결과가 어떤 순서로 출력되는지 함께 봐야 한다.

EmpApp1 결과를 확인할 때 봐야 할 기준

  • 전체 개수 조회 결과가 먼저 출력되는지 확인한다.
  • 특정 사번의 이름이 출력되는지 확인한다.
  • 직무 조건 조회 결과가 목록으로 출력되는지 확인한다.
  • 이름 일부 검색 결과가 like 조건에 맞게 출력되는지 확인한다.
  • 급여 조건 조회 결과가 기준 급여 이상으로 출력되는지 확인한다.
  • DTO 조회 결과가 EmpFreqDTO 형태로 출력되는지 확인한다.
  • 페이징 조회 결과가 정렬과 개수 기준에 맞게 출력되는지 확인한다.
  • 집계 함수 결과가 합계, 최댓값, 최솟값 순서로 출력되는지 확인한다.

EmpApp1.java 결과는 출력 줄 수보다 각 구간이 어떤 DAO 메서드의 결과인지 연결해서 보는 것이 중요하다.


EmpApp2는 stream으로 결과를 출력한다

EmpApp2.java도 EmpDAO 메서드를 호출해서 조회 결과를 출력한다.
다만 EmpApp1.java와 달리 목록 출력에 stream()을 사용한다.


EmpApp2.java는 새로운 조회 기능을 추가한 파일이 아니다.
같은 EmpDAO 메서드 호출 결과를 for 반복문 대신 stream() 방식으로 출력하는 예제이다.


핵심 흐름은 아래와 같다.

// EmpApp2.java
EmpDAO dao = new EmpDAO(); // DAO 객체 생성

List<Emp> r1 = dao.findByJob("SALESMAN"); // 직무 조건 조회
r1.stream().forEach(elem -> System.out.println(elem)); // stream과 lambda로 결과 출력

List<Emp> r2 = dao.findByPartEname("T"); // 이름 일부 검색
r2.stream().forEach(System.out::println); // 메서드 참조로 결과 출력

dao.close(); // DAO 자원 정리

stream()은 컬렉션 데이터를 순서대로 처리할 수 있게 해 주는 흐름이다.
여기서는 List<Emp>에 들어 있는 사원들을 하나씩 출력하는 데 사용한다.


forEach()는 각 요소를 하나씩 처리하는 메서드이다.
elem -> System.out.println(elem)은 lambda 표현식이다.
lambda는 짧은 함수처럼 동작하는 표현식이라고 이해하면 된다.
여기서는 각 elem을 받아 출력한다.


System.out::println은 메서드 참조이다.
메서드 참조는 이미 존재하는 메서드를 간단히 가리키는 표현이다.
여기서는 각 요소를 System.out.println()으로 출력하겠다는 뜻이다.

stream 출력에서 봐야 할 핵심

  • stream()은 목록 데이터를 흐름처럼 처리한다.
  • forEach()는 각 요소를 하나씩 처리한다.
  • elem -> System.out.println(elem)은 각 요소를 출력하는 lambda 표현식이다.
  • System.out::println은 각 요소를 출력하는 메서드 참조이다.
  • 출력 결과 자체는 for 반복문으로 출력한 것과 비슷할 수 있다.

EmpApp2.java는 조회 기능이 달라진 예제가 아니라, 같은 조회 결과를 stream() 방식으로 출력하는 예제이다.


EmpApp2 결과도 DAO 메서드 호출 순서대로 읽는다

EmpApp2.java의 결과도 EmpApp1.java처럼 구간별로 읽어야 한다.
stream()을 사용한다고 해서 조회 결과의 의미가 달라지는 것은 아니다.


예를 들어 findByJob("SALESMAN")을 호출했다면 직무가 SALESMAN인 사원들이 출력된다.
findByPartEname("T")를 호출했다면 이름에 T가 포함된 사원들이 출력된다.
출력 방식이 stream()일 뿐, 조회 조건은 EmpDAO 메서드에 의해 결정된다.


EmpApp2.java 실행 결과이다.
조회 조건은 EmpDAO 메서드에 의해 결정되고, 출력 방식만 stream()과 forEach()로 바뀐다.
따라서 결과 해석 기준은 EmpApp1.java와 같다.
구분선과 출력 메시지를 기준으로 어떤 DAO 메서드 호출 결과인지 확인하면 된다.


EmpApp2.java 결과에서 중요한 것은 출력 문법이다.
stream().forEach()는 목록의 각 요소를 하나씩 처리한다.
System.out::println은 각 요소를 출력하는 메서드 참조이다.
따라서 결과가 출력되는 의미는 for 반복문과 크게 다르지 않고, 표현 방식이 달라진 것이다.

EmpApp2 결과를 확인할 때 봐야 할 기준

  • EmpApp1.java와 같은 조회 메서드가 호출되는지 확인한다.
  • 결과 목록이 stream().forEach()로 출력되는지 확인한다.
  • System.out::println이 각 요소를 출력하는 역할을 하는지 확인한다.
  • 출력 결과의 의미는 EmpDAO 메서드 기준으로 해석한다.

stream()을 사용해도 결과 해석 기준은 동일하며, 어떤 EmpDAO 메서드를 호출했는지가 가장 중요하다.


EmpApp1과 EmpApp2는 출력 방식이 다르다

EmpApp1.java와 EmpApp2.java는 완전히 다른 조회 예제가 아니다.
두 파일 모두 EmpDAO 메서드를 호출한다.
차이는 결과 목록을 어떻게 출력하느냐이다.


EmpApp1.java는 for 반복문을 사용한다.
for 반복문은 초보자가 읽기 쉽다.
목록에서 하나씩 꺼내서 출력한다는 흐름이 눈에 잘 보인다.


EmpApp2.java는 stream()을 사용한다.
stream()은 코드가 짧고, 목록 처리 흐름을 함수형 스타일로 표현할 수 있다.
하지만 처음 보면 lambda나 메서드 참조 문법이 낯설 수 있다.


둘을 비교하면 아래와 같다.

// ForLoopOutput.java
for (Emp elem : r1) { // 목록에서 하나씩 꺼냄
    System.out.println(elem); // 하나씩 출력
}
// StreamOutput.java
r1.stream().forEach(elem -> System.out.println(elem)); // stream으로 하나씩 출력

두 코드는 출력 목적이 같다.
둘 다 r1 목록의 사원 객체를 하나씩 출력한다.
차이는 반복을 표현하는 문법이다.

for 반복문과 stream 비교

  • for 반복문: 하나씩 꺼내는 흐름이 직접 보인다.
  • for 반복문: 초보자가 실행 순서를 따라가기 쉽다.
  • stream(): 목록 처리 코드를 짧게 표현할 수 있다.
  • stream(): lambda, 메서드 참조 문법을 함께 이해해야 한다.
  • 두 방식 모두 DAO 메서드의 조회 결과를 출력하는 역할을 한다.

EmpApp1과 EmpApp2의 차이는 조회 조건이 아니라, 조회 결과를 출력하는 방식이다.


긴 출력 결과는 메서드 호출 순서와 연결해서 읽는다

EmpApp1.java와 EmpApp2.java는 여러 조회 메서드를 순서대로 실행한다.
그래서 콘솔 결과가 길게 나온다.
결과가 길 때는 전체를 외우려고 하면 안 된다.


구분선, 출력 메시지, 메서드 호출 순서를 기준으로 나누어 읽어야 한다.
이렇게 보면 긴 결과도 어렵지 않다.


결과를 읽는 흐름은 아래와 같다.

  • 먼저 어떤 DAO 메서드가 호출되었는지 본다.
  • 그 메서드가 어떤 조회 조건을 가지는지 떠올린다.
  • 콘솔 출력이 그 조건과 맞는지 확인한다.
  • 목록 결과인지, 단일 결과인지, 집계 결과인지 구분한다.
  • 결과가 길면 구분선 기준으로 구간을 나누어 읽는다.

예를 들어 findByGreaterThanSal(2000)이 실행된 구간이라면, 급여가 2000 이상인 사원들이 출력되어야 한다.
listPart(0, 3)이 실행된 구간이라면, 정렬된 결과 중 처음 3개가 출력되는지 확인하면 된다.
getGroupFunc()가 실행된 구간이라면, 급여 합계, 최대 급여, 최소 급여가 어떤 순서로 출력되는지 확인하면 된다.


긴 실행 결과는 출력 내용을 통째로 외우지 말고, DAO 메서드 호출 순서와 결과 구간을 연결해서 읽어야 한다.


핵심 정리

EmpApp1.java와 EmpApp2.java는 EmpDAO 메서드를 실제로 호출해서 결과를 확인하는 실행 예제이다.
13번에서 EmpDAO 메서드의 역할을 정리했다면, 이번 예제에서는 그 메서드들이 실제 콘솔 결과로 어떻게 나타나는지 확인한다.


정리하면 아래와 같다.

  • EmpApp1.java와 EmpApp2.java는 EmpDAO 메서드를 호출하는 실행 클래스이다.
  • EmpDAO는 조회 기능을 제공하고, EmpApp은 그 기능을 호출해 결과를 출력한다.
  • EmpApp1.java는 주로 for 반복문으로 목록을 출력한다.
  • EmpApp2.java는 stream()과 forEach()로 목록을 출력한다.
  • lambda 표현식은 각 요소를 처리하는 짧은 함수처럼 볼 수 있다.
  • System.out::println은 각 요소를 출력하는 메서드 참조이다.
  • 두 파일은 조회 조건이 완전히 다른 것이 아니라 출력 방식이 다르다.
  • 결과가 길면 구분선과 출력 메시지를 기준으로 나누어 읽어야 한다.
  • 각 출력 구간이 어떤 EmpDAO 메서드 호출 결과인지 연결해서 봐야 한다.

EmpApp1과 EmpApp2를 이해하면 DAO 메서드가 실제 실행 클래스에서 어떻게 호출되고, 그 결과가 콘솔에 어떻게 출력되는지 연결해서 볼 수 있다.




응용예제 15. Team, Locker, Member Entity 관계 확인하기 (Team.java, Locker.java, Member.java)

Team.java, Locker.java, Member.java는 회원과 연결될 팀, 락커, 회원 정보를 표현하는 Entity이다.
앞 예제까지는 Emp, Dept, Locations를 중심으로 사원, 부서, 지역의 연관관계를 확인했다.
이번 예제부터는 Member, Team, Locker를 중심으로 새로운 연관관계 구조를 확인한다.


이 예제는 실행 결과를 보는 예제가 아니다.
뒤에서 나오는 MemberTeamTest 계열 예제를 이해하기 전에, 먼저 세 Entity가 어떤 테이블과 연결되고 서로 어떤 관계를 가지는지 확인하는 단계이다.


이 예제의 핵심은 Member가 Team과 Locker를 객체 참조로 가지고 있고, 데이터베이스에서는 TEAM_ID, LOCKER_ID 외래키로 연결된다는 점이다.


이전 기본개념과 연결하기

앞에서 JPA의 연관관계는 객체 참조와 외래키를 연결하는 방식이라고 정리했다.
객체 코드에서는 다른 Entity 객체를 필드로 가진다.
데이터베이스에서는 그 관계를 외래키 컬럼으로 저장한다.


예를 들어 회원이 팀에 속한다면 객체 코드에서는 Member 안에 Team 필드가 있을 수 있다.
데이터베이스에서는 membertbl 테이블 안에 TEAM_ID 외래키 컬럼이 생길 수 있다.
즉, 객체는 Team 객체를 참조하고, 테이블은 TEAM_ID 값으로 관계를 저장한다.


이번 예제에서는 관계가 두 개 나온다.
하나는 Member와 Team의 관계이고, 다른 하나는 Member와 Locker의 관계이다.
Member는 팀에 속할 수 있고, 락커 하나를 사용할 수 있다.

기본개념에서 다시 잡아야 할 흐름

  • Entity는 데이터베이스 테이블과 연결되는 Java 클래스이다.
  • Member는 회원 정보를 표현한다.
  • Team은 팀 정보를 표현한다.
  • Locker는 락커 정보를 표현한다.
  • Member는 Team을 참조한다.
  • Member는 Locker를 참조한다.
  • 객체에서는 team, locker 필드로 관계를 표현한다.
  • 데이터베이스에서는 TEAM_ID, LOCKER_ID 외래키 컬럼으로 관계를 표현한다.

연관관계 실행 예제를 보기 전에는 먼저 어떤 Entity가 어떤 객체를 참조하는지 확인해야 한다.


예제의 목표

이 예제의 목표는 Team, Locker, Member의 역할과 관계를 구분하는 것이다.
Team과 Locker는 회원이 참조할 대상이다.
Member는 회원 정보를 가지면서 Team과 Locker를 함께 참조한다.


뒤에서 MemberTeamTest1.java는 Team, Locker, Member 데이터를 저장한다.
그때 Member 객체를 만들면서 팀 객체와 락커 객체를 함께 전달한다.
이 흐름을 이해하려면 먼저 Member 생성자가 왜 Team, Locker 객체를 받는지 알아야 한다.


즉, 이번 예제는 저장 실행보다 먼저 보는 관계 구조 확인 단계이다.
코드에서 @ManyToOne, @OneToOne, @JoinColumn이 어떤 역할을 하는지 확인하면 뒤의 저장, 조회 예제가 훨씬 쉽게 이어진다.

이 예제에서 확인할 내용

  • Team은 팀 정보를 담는 Entity이다.
  • Locker는 락커 정보를 담는 Entity이다.
  • Member는 회원 정보를 담는 Entity이다.
  • Member는 Team과 다대일 관계를 가진다.
  • Member는 Locker와 일대일 관계를 가진다.
  • @ManyToOne은 여러 회원이 하나의 팀을 참조할 수 있음을 나타낸다.
  • @OneToOne은 회원 하나가 락커 하나와 연결될 수 있음을 나타낸다.
  • @JoinColumn은 관계를 저장할 외래키 컬럼을 지정한다.

15번은 실행 결과를 확인하는 파트가 아니라, 뒤의 MemberTeamTest 실행 예제를 이해하기 위한 Entity 관계 구조를 잡는 파트이다.


Team은 팀 정보를 담는 Entity이다

Team.java는 팀 정보를 표현하는 Entity이다.
팀 기본키와 팀 이름을 가진다.


핵심 구조는 아래와 같다.

// Team.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
@AllArgsConstructor // 모든 필드를 받는 생성자 자동 생성
@Entity // JPA 관리 대상 Entity
public class Team {
    @Id // 팀 기본키
    @Column(name = "TEAM_ID") // TEAM_ID 컬럼과 매핑
    private String id;

    private String name; // 팀 이름
}

@Entity는 이 클래스가 JPA의 관리 대상이라는 뜻이다.
즉, Team 객체는 데이터베이스 테이블과 연결될 수 있는 Entity이다.


@Id가 붙은 id는 팀을 구분하는 기본키이다.
기본키는 테이블에서 각 행을 구분하는 값이다.
@Column(name = "TEAM_ID")가 있으므로 id 필드는 데이터베이스의 TEAM_ID 컬럼과 연결된다.


Team의 기본키 타입은 String이다.
그래서 뒤에서 new Team("1팀", "아기공룡둘리")처럼 문자열을 기본키 값으로 직접 넣을 수 있다.

Team에서 봐야 할 기준

  • Team은 팀 정보를 표현하는 Entity이다.
  • id는 팀의 기본키이다.
  • id는 TEAM_ID 컬럼과 연결된다.
  • name은 팀 이름이다.
  • Team은 뒤에서 Member가 참조할 대상이다.

Team은 회원이 소속될 팀 정보를 담는 기준 Entity이다.


Locker는 락커 정보를 담는 Entity이다

Locker.java는 락커 정보를 표현하는 Entity이다.
락커 기본키와 락커 이름을 가진다.


핵심 구조는 아래와 같다.

// Locker.java
@Entity // JPA 관리 대상 Entity
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
public class Locker {
    @Id // 락커 기본키
    @Column(name = "LOCKER_ID") // LOCKER_ID 컬럼과 매핑
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private Long id;

    private String name; // 락커 이름

    public Locker(String name) { // 락커 이름을 받는 생성자
        this.name = name; // 이름 저장
    }
}

Locker도 @Entity가 붙어 있으므로 JPA 관리 대상이다.
id는 락커의 기본키이고, LOCKER_ID 컬럼과 연결된다.


Locker의 기본키는 Long 타입이고, @GeneratedValue(strategy = GenerationType.IDENTITY)가 붙어 있다.
IDENTITY 전략은 기본키 생성을 데이터베이스에 맡기는 방식이다.
즉, 락커를 저장하면 데이터베이스가 기본키 값을 자동으로 만들어 준다.


그래서 new Locker("A101")처럼 생성할 때는 락커 이름만 전달한다.
기본키 값은 직접 넣지 않는다.
저장 과정에서 데이터베이스가 LOCKER_ID 값을 생성한다.

Locker에서 봐야 할 기준

  • Locker는 락커 정보를 표현하는 Entity이다.
  • id는 락커의 기본키이다.
  • id는 LOCKER_ID 컬럼과 연결된다.
  • IDENTITY 전략으로 기본키가 자동 생성된다.
  • name은 락커 이름이다.
  • Locker는 뒤에서 Member가 참조할 대상이다.

Locker는 IDENTITY 전략을 사용하므로 저장 시 데이터베이스가 기본키를 자동 생성한다.


Member는 Team과 Locker를 함께 참조한다

Member.java는 회원 정보를 표현하는 Entity이다.
회원 이름을 가지고 있고, 팀과 락커를 객체 참조로 가진다.


핵심 구조는 아래와 같다.

// Member.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
@Entity // JPA 관리 대상 Entity
@Table(name = "membertbl") // membertbl 테이블과 매핑
public class Member {
    @Id // 회원 기본키
    @Column(name = "MEMBER_ID") // MEMBER_ID 컬럼과 매핑
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id;

    private String username; // 회원 이름

    @ManyToOne // 여러 회원이 하나의 팀과 연결
    @JoinColumn(name = "TEAM_ID") // TEAM_ID 외래키 컬럼과 연결
    private Team team;

    @OneToOne // 회원 하나가 락커 하나와 연결
    @JoinColumn(name = "LOCKER_ID") // LOCKER_ID 외래키 컬럼과 연결
    private Locker locker;

    public Member(String username, Team team, Locker locker) { // 회원 생성 시 관계 객체도 함께 받음
        this.username = username; // 회원 이름 저장
        this.team = team; // 팀 참조 저장
        this.locker = locker; // 락커 참조 저장
    }
}

@Table(name = "membertbl")은 Member 엔티티가 membertbl 테이블과 연결된다는 뜻이다.
즉, 클래스 이름은 Member이지만 실제 테이블명은 membertbl로 지정되어 있다.


id는 회원 기본키이고, MEMBER_ID 컬럼과 연결된다.
@GeneratedValue(strategy = GenerationType.IDENTITY)가 있으므로 회원 기본키도 데이터베이스가 자동 생성한다.


username은 회원 이름이다.
이 필드는 일반 값이다.
반면 team과 locker는 단순 값이 아니라 다른 Entity 객체를 참조하는 필드이다.


Member에서 가장 중요한 부분은 일반 필드인 username과 연관 필드인 team, locker를 구분하는 것이다.


Member와 Team은 다대일 관계이다

Member 안의 team 필드는 아래처럼 작성되어 있다.

// Member.java
@ManyToOne // 여러 회원이 하나의 팀과 연결
@JoinColumn(name = "TEAM_ID") // TEAM_ID 외래키 컬럼과 연결
private Team team;

@ManyToOne은 다대일 관계를 나타낸다.
다대일은 여러 개의 현재 엔티티가 하나의 대상 엔티티와 연결될 수 있다는 뜻이다.


여기서는 여러 명의 Member가 하나의 Team에 속할 수 있다.
예를 들어 여러 회원이 같은 1팀에 속할 수 있다.
그래서 Member → Team 관계는 다대일이다.


객체 코드에서는 team 필드가 Team 객체를 참조한다.
데이터베이스에서는 이 관계가 TEAM_ID 외래키 컬럼으로 저장된다.
@JoinColumn(name = "TEAM_ID")는 바로 그 외래키 컬럼명을 지정한다.

Member와 Team 관계에서 봐야 할 기준

  • Member는 Team을 객체로 참조한다.
  • 여러 Member가 하나의 Team을 참조할 수 있다.
  • 그래서 관계는 @ManyToOne이다.
  • 데이터베이스에는 TEAM_ID 외래키 컬럼으로 저장된다.

@ManyToOne은 객체 참조를 만들고, @JoinColumn(name = "TEAM_ID")은 그 참조를 저장할 외래키 컬럼을 지정한다.


Member와 Locker는 일대일 관계이다

Member 안의 locker 필드는 아래처럼 작성되어 있다.

// Member.java
@OneToOne // 회원 하나가 락커 하나와 연결
@JoinColumn(name = "LOCKER_ID") // LOCKER_ID 외래키 컬럼과 연결
private Locker locker;

@OneToOne은 일대일 관계를 나타낸다.
일대일은 현재 엔티티 하나가 대상 엔티티 하나와 연결되는 관계이다.


여기서는 회원 한 명이 락커 하나를 사용할 수 있는 구조로 본다.
그래서 Member → Locker 관계는 일대일이다.


객체 코드에서는 locker 필드가 Locker 객체를 참조한다.
데이터베이스에서는 이 관계가 LOCKER_ID 외래키 컬럼으로 저장된다.
@JoinColumn(name = "LOCKER_ID")는 락커와의 관계를 저장할 외래키 컬럼명을 지정한다.

Member와 Locker 관계에서 봐야 할 기준

  • Member는 Locker를 객체로 참조한다.
  • 회원 하나가 락커 하나와 연결될 수 있다.
  • 그래서 관계는 @OneToOne이다.
  • 데이터베이스에는 LOCKER_ID 외래키 컬럼으로 저장된다.

@OneToOne은 하나의 Entity가 다른 하나의 Entity와 연결되는 관계를 표현한다.


JoinColumn은 관계를 저장할 외래키 컬럼을 지정한다

@JoinColumn은 연관관계에서 사용할 외래키 컬럼을 지정한다.
컬럼이라는 말이 들어가지만, 일반 필드 매핑에 쓰는 @Column과 역할이 다르다.


@Column은 일반 값 필드와 테이블 컬럼을 연결한다.
예를 들어 username 같은 문자열 값 필드를 테이블 컬럼과 연결할 때 사용할 수 있다.


@JoinColumn은 다른 Entity와의 관계를 저장하는 외래키 컬럼을 지정한다.
예를 들어 회원이 팀을 참조할 때, 그 관계를 TEAM_ID 외래키 컬럼에 저장한다고 알려 준다.


비교하면 아래처럼 볼 수 있다.

// ColumnAndJoinColumnCompare.java
@Column(name = "MEMBER_ID") // 일반 값 필드와 컬럼 연결
private int id;

@ManyToOne // 다른 Entity와의 관계
@JoinColumn(name = "TEAM_ID") // 외래키 컬럼 지정
private Team team;

id는 회원의 기본키 값이다.
그래서 @Column으로 컬럼과 연결한다.


team은 단순 값이 아니라 Team 객체 참조이다.
그래서 관계 어노테이션인 @ManyToOne과 외래키 컬럼을 지정하는 @JoinColumn을 함께 사용한다.


초보자가 헷갈리기 쉬운 부분은 TEAM_ID 필드를 따로 만들어야 하는지이다.
객체 중심으로 설계할 때는 보통 외래키 값 자체보다 참조할 객체를 필드로 둔다.


즉, Member 안에 private int teamId;처럼 외래키 값만 따로 두는 것이 아니라, private Team team;처럼 참조할 Entity 객체를 둔다.
그러면 JPA가 team 참조와 TEAM_ID 외래키 컬럼을 연결한다.


객체 코드에서는 Team과 Locker를 참조하고, 데이터베이스에서는 TEAM_ID, LOCKER_ID 외래키 값으로 관계를 저장한다.


Member 관계 구조를 한 번에 정리하기

Member, Team, Locker의 관계는 아래처럼 정리할 수 있다.

  • Team: 팀 정보를 가진다.
  • Locker: 락커 정보를 가진다.
  • Member: 회원 정보를 가지며 Team과 Locker를 참조한다.
  • Member와 Team: 다대일 관계이다.
  • Member와 Locker: 일대일 관계이다.
  • Member 테이블에는 TEAM_ID, LOCKER_ID 외래키가 생긴다.

객체 흐름으로 보면 회원에서 팀으로 이동할 수 있다.
또 회원에서 락커로 이동할 수 있다.

// MemberRelationObjectFlow.java
Member member = entityManager.find(Member.class, 1); // 회원 조회

Team team = member.getTeam(); // 회원에서 팀으로 이동
Locker locker = member.getLocker(); // 회원에서 락커로 이동

이 흐름은 뒤에서 MemberTeamTest3.java의 dto.getTeam().getName(), dto.getLocker().getName()과 직접 연결된다.


Member를 조회한 뒤 getTeam()을 호출하면 회원이 참조하는 팀 객체로 이동할 수 있다.
getLocker()를 호출하면 회원이 참조하는 락커 객체로 이동할 수 있다.


다만 관계가 항상 존재한다고 단정하면 안 된다.
뒤 예제에서는 팀과 락커가 없는 회원도 저장한다.
그 경우 team과 locker가 null일 수 있다.
그래서 실제 조회 코드에서는 객체 참조를 따라가기 전에 null 여부를 확인해야 한다.


뒤의 MemberTeamTest 예제는 모두 Member가 Team과 Locker를 객체 참조로 가지고 있다는 구조를 바탕으로 실행된다.


핵심 정리

Team, Locker, Member는 뒤의 MemberTeamTest 계열 예제를 이해하기 위한 기본 Entity 구조이다.
Team과 Locker는 회원이 참조할 대상이고, Member는 이 두 Entity를 함께 참조한다.


정리하면 아래와 같다.

  • Team은 팀 정보를 표현하는 Entity이다.
  • Team의 기본키는 String 타입의 TEAM_ID이다.
  • Locker는 락커 정보를 표현하는 Entity이다.
  • Locker의 기본키는 IDENTITY 전략으로 자동 생성된다.
  • Member는 membertbl 테이블과 매핑된다.
  • Member의 기본키는 IDENTITY 전략으로 자동 생성된다.
  • Member는 Team과 다대일 관계를 가진다.
  • Member는 Locker와 일대일 관계를 가진다.
  • TEAM_ID는 Member와 Team의 관계를 저장하는 외래키이다.
  • LOCKER_ID는 Member와 Locker의 관계를 저장하는 외래키이다.
  • 객체 코드에서는 team, locker 필드로 관계를 따라간다.
  • 데이터베이스에서는 TEAM_ID, LOCKER_ID 외래키 컬럼으로 관계가 저장된다.

이 예제를 이해하면 뒤에서 MemberTeamTest 계열 파일을 볼 때 회원, 팀, 락커가 어떤 관계로 저장되고 조회되는지 흐름을 잡을 수 있다.




응용예제 16. Member, Team, Locker 데이터 저장 흐름 확인하기 (MemberTeamTest1.java, Member.java, Team.java, Locker.java)

MemberTeamTest1.java는 Team, Locker, Member 객체를 만들고 데이터베이스에 저장하는 예제이다.
앞 예제에서는 Member가 Team과 Locker를 객체 참조로 가진다는 구조를 확인했다.
이번 예제에서는 그 구조를 실제 객체 생성과 저장 흐름으로 확인한다.


이 예제에서 중요한 점은 Member를 저장할 때 단순히 회원 이름만 저장하는 것이 아니라, 회원이 참조하는 Team 객체와 Locker 객체도 함께 연결한다는 점이다.
객체 코드에서는 new Member("둘리", t1, list.get(0))처럼 관계 객체를 함께 넘긴다.
데이터베이스에서는 이 관계가 TEAM_ID, LOCKER_ID 외래키 값으로 저장된다.


이 예제의 핵심은 Team 3개, Locker 8개, Member 8명을 저장하면서 객체 참조가 외래키 값으로 기록되는 흐름을 확인하는 것이다.


이전 기본개념과 연결하기

앞 예제에서 Member는 Team과 다대일 관계를 가진다고 정리했다.
여러 회원이 하나의 팀에 속할 수 있기 때문에 Member 안에는 @ManyToOne으로 Team 참조가 들어간다.
그리고 이 관계는 데이터베이스에서 TEAM_ID 외래키 컬럼으로 저장된다.


또 Member는 Locker와 일대일 관계를 가진다고 정리했다.
회원 하나가 락커 하나와 연결되는 구조이기 때문에 Member 안에는 @OneToOne으로 Locker 참조가 들어간다.
그리고 이 관계는 데이터베이스에서 LOCKER_ID 외래키 컬럼으로 저장된다.


이번 예제는 그 관계를 실제로 저장한다.
먼저 팀 객체 3개를 만들고, 락커 객체 8개를 리스트에 담는다.
그다음 회원 객체 8명을 만들 때 각 회원에게 팀과 락커를 함께 연결한다.
마지막으로 persist()와 commit()을 통해 데이터베이스에 반영한다.

기본개념에서 다시 잡아야 할 흐름

  • Team은 회원이 속할 팀이다.
  • Locker는 회원이 사용할 락커이다.
  • Member는 회원이며, Team과 Locker를 참조한다.
  • 객체 생성 시 Member 생성자에 Team, Locker 객체를 함께 넘길 수 있다.
  • persist()는 객체를 JPA가 관리하는 상태로 만든다.
  • commit()이 실행되어야 데이터베이스에 실제로 반영된다.
  • 저장 결과에서는 Member 테이블에 TEAM_ID, LOCKER_ID 외래키가 들어간다.

연관관계 저장은 객체 참조를 먼저 연결하고, 그 참조가 데이터베이스 외래키 값으로 저장되는 흐름이다.


예제의 목표

이 예제의 목표는 Member, Team, Locker 객체를 저장할 때 관계가 어떻게 연결되는지 확인하는 것이다.
단순히 객체를 각각 따로 저장하는 것이 아니라, Member 객체 안에 Team과 Locker 참조를 넣어서 저장한다.


예를 들어 t1이라는 팀 객체와 list.get(0)이라는 락커 객체가 있다고 하자.
Member 객체를 만들 때 new Member("둘리", t1, list.get(0))처럼 작성하면, 회원 객체 안에 팀 참조와 락커 참조가 함께 들어간다.


이렇게 저장하면 객체 코드에서는 member.getTeam()으로 팀에 접근할 수 있고, member.getLocker()로 락커에 접근할 수 있다.
데이터베이스에서는 membertbl 테이블에 TEAM_ID, LOCKER_ID 값이 저장된다.

이 예제에서 확인할 내용

  • EntityManagerFactory와 EntityManager를 만든다.
  • Transaction을 시작한다.
  • Team 객체 3개를 만든다.
  • Locker 객체 8개를 List에 담는다.
  • Member 객체 8명을 만들 때 Team, Locker를 함께 넘긴다.
  • Team, Locker, Member를 persist()로 저장 대상으로 등록한다.
  • commit()으로 데이터베이스에 반영한다.
  • 저장 후 membertbl 테이블에 외래키 값이 들어가는 흐름을 확인한다.

이 예제는 저장 코드 자체보다, 객체 참조가 외래키 값으로 바뀌어 저장되는 흐름을 보는 예제이다.


코드 흐름

MemberTeamTest1.java는 먼저 JPA 실행에 필요한 객체를 만든다.
그다음 Transaction을 시작하고, 팀과 락커와 회원 객체를 생성한다.
마지막으로 객체를 저장하고 Transaction을 commit()한다.


핵심 코드는 아래와 같다.

// MemberTeamTest1.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // entitytest 설정으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성
em.getTransaction().begin(); // Transaction 시작

Team t1 = new Team("1팀", "아기공룡둘리"); // 1팀 생성
Team t2 = new Team("2팀", "겨울왕국"); // 2팀 생성
Team t3 = new Team("3팀", "짱구는못말려"); // 3팀 생성

em.persist(t1); // 1팀 저장
em.persist(t2); // 2팀 저장
em.persist(t3); // 3팀 저장

List<Locker> list = new ArrayList<Locker>(); // Locker 목록 생성
list.add(new Locker("A101")); // 락커 추가
list.add(new Locker("A102")); // 락커 추가
list.add(new Locker("A103")); // 락커 추가
list.add(new Locker("A104")); // 락커 추가
list.add(new Locker("B101")); // 락커 추가
list.add(new Locker("B102")); // 락커 추가
list.add(new Locker("B103")); // 락커 추가
list.add(new Locker("B104")); // 락커 추가

for (Locker e : list) { // Locker 목록 반복
    em.persist(e); // Locker 저장
}

Member m1 = new Member("둘리", t1, list.get(0)); // 1팀, A101 락커 연결
Member m2 = new Member("또치", t1, list.get(1)); // 1팀, A102 락커 연결
Member m3 = new Member("도우너", t1, list.get(2)); // 1팀, A103 락커 연결

Member m4 = new Member("올라프", t2, list.get(3)); // 2팀, A104 락커 연결
Member m5 = new Member("안나", t2, list.get(4)); // 2팀, B101 락커 연결

Member m6 = new Member("짱구", t3, list.get(5)); // 3팀, B102 락커 연결
Member m7 = new Member("흰둥이", t3, list.get(6)); // 3팀, B103 락커 연결
Member m8 = new Member("짱아", t3, list.get(7)); // 3팀, B104 락커 연결

em.persist(m1); // 회원 저장
em.persist(m2); // 회원 저장
em.persist(m3); // 회원 저장
em.persist(m4); // 회원 저장
em.persist(m5); // 회원 저장
em.persist(m6); // 회원 저장
em.persist(m7); // 회원 저장
em.persist(m8); // 회원 저장

System.out.println("team, locker 그리고 membertbl 테이블에 데이터 저장~~ "); // 저장 안내 출력
em.getTransaction().commit(); // 데이터베이스 반영
em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

이 코드에서는 "entitytest" 설정 묶음을 사용한다.
그리고 Team 3개, Locker 8개, Member 8명을 저장한다.


여기서 중요한 부분은 Member 객체를 만들 때 t1, t2, t3 같은 Team 객체와 list.get(0)부터 list.get(7)까지의 Locker 객체를 함께 넘긴다는 점이다.
TEAM_ID나 LOCKER_ID 값을 직접 넘기는 것이 아니다.
객체 코드에서는 관계를 외래키 숫자나 문자열이 아니라, 참조 객체로 표현한다.


Member 생성자에 외래키 값을 직접 넣는 것이 아니라, 이미 만든 Team 객체와 Locker 객체를 넣어 관계를 연결한다.


Transaction은 저장 작업을 하나의 작업 단위로 묶는다

JPA에서 저장, 수정, 삭제 같은 변경 작업은 Transaction 안에서 실행해야 한다.
Transaction은 하나의 작업 단위이다.
여러 저장 작업을 하나로 묶고, 마지막에 확정할 수 있게 해 준다.


핵심 코드는 아래와 같다.

// MemberTeamTest1.java
em.getTransaction().begin(); // Transaction 시작

em.persist(t1); // Team 저장
em.persist(list.get(0)); // Locker 저장 흐름
em.persist(m1); // Member 저장

em.getTransaction().commit(); // 변경 내용 확정

em.getTransaction().begin()은 Transaction을 시작한다.
이후에 실행되는 persist() 작업들은 Transaction 안에서 관리된다.


em.getTransaction().commit()은 Transaction 안에서 수행한 변경 내용을 데이터베이스에 반영한다.
persist()를 호출했다고 해서 바로 최종 반영이 끝나는 것으로 이해하면 안 된다.
최종 반영은 commit() 시점에 이루어진다.


이 예제에는 별도의 try-catch와 rollback() 흐름이 들어 있지 않다.
따라서 이 예제에서는 begin()으로 시작하고, 저장 작업을 수행한 뒤, 마지막에 commit()으로 확정하는 흐름만 확인하면 된다.


변경 작업에서는 persist()만 보는 것이 아니라, begin()부터 commit()까지의 Transaction 흐름을 함께 봐야 한다.


persist는 객체를 저장 대상으로 만든다

persist()는 엔티티 객체를 JPA가 관리하는 상태로 만드는 메서드이다.
이 상태를 영속 상태라고 한다.
영속 상태는 JPA가 객체를 관리하고, Transaction 커밋 시점에 데이터베이스와 맞춰 줄 수 있는 상태이다.


이 예제에서는 Team, Locker, Member를 모두 저장 대상으로 만든다.

// MemberTeamTest1.java
em.persist(t1); // Team 저장 대상으로 등록
em.persist(t2); // Team 저장 대상으로 등록
em.persist(t3); // Team 저장 대상으로 등록

for (Locker e : list) { // Locker 목록 반복
    em.persist(e); // Locker 저장 대상으로 등록
}

em.persist(m1); // Member 저장 대상으로 등록
em.persist(m2); // Member 저장 대상으로 등록
em.persist(m3); // Member 저장 대상으로 등록
em.persist(m4); // Member 저장 대상으로 등록
em.persist(m5); // Member 저장 대상으로 등록
em.persist(m6); // Member 저장 대상으로 등록
em.persist(m7); // Member 저장 대상으로 등록
em.persist(m8); // Member 저장 대상으로 등록

persist(t1)은 t1 객체를 저장 대상으로 등록한다.
persist(e)는 반복문에서 꺼낸 각 Locker 객체를 저장 대상으로 등록한다.
persist(m1)부터 persist(m8)까지는 각 Member 객체를 저장 대상으로 등록한다.


중요한 점은 Member가 Team과 Locker를 참조하고 있다는 것이다.
회원 객체를 저장할 때 m1 안에는 이미 t1, list.get(0) 참조가 들어 있다.
그래서 membertbl에는 회원 정보와 함께 TEAM_ID, LOCKER_ID 외래키 값이 저장될 수 있다.


다만 이 예제에서는 관계 대상인 Team, Locker도 직접 persist()한다.
즉, Member만 저장하는 것이 아니라, 참조 대상 객체도 먼저 저장 대상으로 등록한다.
이 흐름을 보면 저장 순서가 더 명확하게 보인다.


연관관계가 있는 객체를 저장할 때는 참조 대상 객체가 함께 저장되어 있어야 외래키 관계가 안전하게 만들어진다.


Member 생성자에서 관계가 연결된다

Member 생성자는 회원 이름, 팀 객체, 락커 객체를 받는다.
이 생성자에서 username, team, locker 필드가 채워진다.


핵심 구조는 아래와 같다.

// Member.java
public Member(String username, Team team, Locker locker) { // 회원 생성자
    super(); // 부모 생성자 호출
    this.username = username; // 회원 이름 저장
    this.team = team; // 팀 객체 참조 저장
    this.locker = locker; // 락커 객체 참조 저장
}

그리고 실행 코드에서는 아래처럼 사용한다.

// MemberTeamTest1.java
Team t1 = new Team("1팀", "아기공룡둘리"); // 팀 객체 생성
Locker locker = list.get(0); // 첫 번째 Locker 가져오기

Member m1 = new Member("둘리", t1, locker); // 회원과 팀, 락커 연결

m1은 회원 이름으로 "둘리"를 가진다.
그리고 team 필드에는 t1 객체가 들어간다.
locker 필드에는 list.get(0)으로 꺼낸 락커 객체가 들어간다.


이 상태에서 m1을 저장하면 JPA는 m1이 어떤 팀과 락커를 참조하는지 알 수 있다.
그 결과 데이터베이스에는 membertbl 행이 저장되면서 TEAM_ID, LOCKER_ID 외래키 값도 함께 들어갈 수 있다.


연관관계는 저장할 때 갑자기 생기는 것이 아니라, 객체를 만들 때 참조 필드에 관계 객체를 넣으면서 먼저 만들어진다.


같은 Team을 여러 Member가 참조할 수 있다

Member와 Team은 다대일 관계이다.
즉, 여러 회원이 하나의 팀을 참조할 수 있다.


예를 들어 실제 코드에서는 m1, m2, m3가 모두 t1을 참조한다.

// MemberTeamTest1.java
Team t1 = new Team("1팀", "아기공룡둘리"); // 팀 객체 생성

Member m1 = new Member("둘리", t1, list.get(0)); // m1이 t1 참조
Member m2 = new Member("또치", t1, list.get(1)); // m2도 t1 참조
Member m3 = new Member("도우너", t1, list.get(2)); // m3도 t1 참조

m1, m2, m3는 서로 다른 회원 객체이다.
하지만 모두 같은 t1을 참조한다.


객체 관점에서는 세 회원의 team 필드가 같은 Team 객체를 가리킨다.
데이터베이스 관점에서는 세 회원 행의 TEAM_ID 값이 같은 "1팀"으로 저장될 수 있다.


이것이 @ManyToOne 관계이다.
여러 Member가 하나의 Team과 연결될 수 있다.


다대일 관계에서는 여러 회원 행이 같은 TEAM_ID 외래키 값을 가질 수 있다.


모든 Member는 Locker를 하나씩 참조한다

이 예제에서는 Member 8명이 모두 Locker를 하나씩 참조한다.
즉, Member 생성자에 세 번째 인자로 null을 넘기는 흐름이 없다.

// MemberTeamTest1.java
Member m1 = new Member("둘리", t1, list.get(0)); // A101 락커 연결
Member m2 = new Member("또치", t1, list.get(1)); // A102 락커 연결
Member m3 = new Member("도우너", t1, list.get(2)); // A103 락커 연결
Member m4 = new Member("올라프", t2, list.get(3)); // A104 락커 연결
Member m5 = new Member("안나", t2, list.get(4)); // B101 락커 연결
Member m6 = new Member("짱구", t3, list.get(5)); // B102 락커 연결
Member m7 = new Member("흰둥이", t3, list.get(6)); // B103 락커 연결
Member m8 = new Member("짱아", t3, list.get(7)); // B104 락커 연결

Member와 Locker는 일대일 관계로 설정되어 있다.
실제 코드에서는 회원마다 서로 다른 락커 객체를 연결한다.


객체 관점에서는 각 회원의 locker 필드가 하나의 Locker 객체를 참조한다.
데이터베이스 관점에서는 각 회원 행의 LOCKER_ID 값이 들어간다.


이후 MemberTeamTest4.java에서는 Team과 Locker가 null인 회원을 따로 추가한다.
따라서 MemberTeamTest1.java와 MemberTeamTest4.java의 저장 조건을 섞어서 보면 안 된다.


MemberTeamTest1.java에서는 모든 회원이 Team과 Locker를 가진 상태로 저장된다.


다시 실행할 때는 기본키 중복을 주의한다

MemberTeamTest1.java는 Team의 기본키 값을 직접 지정해서 저장한다.
예를 들어 new Team("1팀", "아기공룡둘리")처럼 만들면 "1팀"이 TEAM_ID 기본키 값으로 저장된다.


이 상태에서 같은 예제를 다시 실행하면 이미 Team 테이블에 "1팀" 데이터가 있는데, 또 "1팀"을 저장하려고 할 수 있다.
그러면 기본키 중복 오류가 발생한다.


대표적인 오류 흐름은 아래와 같다.

// 오류 흐름
// Team 테이블에 TEAM_ID = "1팀" 데이터가 이미 있음
// 다시 MemberTeamTest1.java 실행
// 또 new Team("1팀", ...) 저장 시도
// Duplicate entry "1팀" for key "team.PRIMARY" 오류 발생

이 오류는 코드 문법 문제가 아니다.
이미 저장된 데이터와 새로 저장하려는 기본키 값이 겹쳐서 생기는 문제이다.


다시 실행해서 결과를 확인하려면 기존 데이터를 지우고 실행하면 된다.
외래키 관계가 있으므로 보통 자식 테이블부터 지우는 방식으로 정리한다.

// 실행 전 데이터 정리 예시
delete from membertbl;
delete from locker;
delete from team;

데이터를 지운 뒤 다시 실행하면 같은 기본키 값을 다시 저장할 수 있다.
다만 실제 환경에서는 삭제 순서나 제약 조건 설정에 따라 방법이 달라질 수 있다.


기본키 값을 직접 지정하는 저장 예제는 같은 데이터를 다시 저장할 때 중복 오류가 날 수 있으므로, 재실행 전에 기존 데이터를 정리해야 한다.


저장 결과는 테이블의 외래키 값으로 확인한다

이 예제를 실행하면 데이터베이스 테이블에 저장 결과가 반영된다.
Team은 팀 테이블에 저장되고, Locker는 락커 테이블에 저장된다.
Member는 membertbl 테이블에 저장된다.


중요한 확인 대상은 membertbl이다.
membertbl에는 회원 기본 정보와 함께 TEAM_ID, LOCKER_ID가 저장된다.
이 두 컬럼을 보면 회원이 어떤 팀과 락커를 참조하는지 확인할 수 있다.


확인 기준은 아래와 같다.

  • TEAM_ID 값이 있으면 해당 회원이 특정 팀을 참조한다는 뜻이다.
  • 여러 회원의 TEAM_ID가 같으면 같은 팀을 참조한다는 뜻이다.
  • LOCKER_ID 값이 있으면 해당 회원이 특정 락커를 참조한다는 뜻이다.
  • 이 예제에서는 Member 8명 모두 락커를 연결해서 저장한다.

MemberTeamTest1.java 실행 후 저장 결과를 확인하는 화면이다.
Team, Locker, Member 객체를 저장하면 각각의 테이블에 데이터가 들어간다.
이때 가장 중요하게 봐야 할 테이블은 membertbl이다.
membertbl에는 회원 기본 정보와 함께 TEAM_ID, LOCKER_ID 외래키 값이 저장된다.


객체 코드에서는 Member가 Team, Locker 객체를 참조한다.
하지만 데이터베이스에는 객체가 그대로 들어가는 것이 아니라, 참조 관계가 TEAM_ID, LOCKER_ID 값으로 저장된다.
따라서 저장 결과를 볼 때는 Member 행마다 어떤 TEAM_ID, LOCKER_ID가 들어갔는지 확인해야 한다.


여러 회원이 같은 TEAM_ID를 가지고 있다면 같은 팀을 참조하는 것이다.
LOCKER_ID가 있으면 락커와 연결된 회원이다.
이 예제에서는 Member 8명을 모두 서로 다른 락커와 연결해서 저장한다.


연관관계 저장 결과는 객체 코드만 보는 것이 아니라, 테이블의 외래키 컬럼에 어떤 값이 들어갔는지 함께 확인해야 한다.


핵심 정리

MemberTeamTest1.java는 Team, Locker, Member 객체를 만들고 저장하는 예제이다.
앞에서 확인한 Member → Team, Member → Locker 관계가 실제 저장 과정에서 어떻게 외래키 값으로 반영되는지 확인할 수 있다.


정리하면 아래와 같다.

  • "entitytest" 설정 묶음으로 EntityManagerFactory를 만든다.
  • Team은 회원이 속할 팀이다.
  • Locker는 회원이 사용할 락커이다.
  • Member는 회원 정보와 함께 Team, Locker 참조를 가진다.
  • 실제 코드에서는 Team 3개, Locker 8개, Member 8명을 저장한다.
  • Member 생성자에 Team, Locker 객체를 넘기면 객체 관계가 연결된다.
  • persist()는 엔티티 객체를 저장 대상으로 만든다.
  • commit()이 실행되어야 변경 내용이 데이터베이스에 반영된다.
  • 여러 Member가 같은 Team을 참조할 수 있다.
  • 이 예제에서는 모든 Member가 Locker를 하나씩 참조한다.
  • 같은 기본키 데이터를 다시 저장하면 중복 오류가 날 수 있다.
  • 저장 결과는 membertbl의 TEAM_ID, LOCKER_ID 외래키 컬럼으로 확인한다.

이 예제를 이해하면 객체 참조로 만든 연관관계가 데이터베이스 외래키 값으로 저장되는 전체 흐름을 잡을 수 있다.




응용예제 17. Team의 name 값으로 회원 목록 조회하기 (MemberTeamTest2.java, Member.java, Team.java, Locker.java)

MemberTeamTest2.java는 사용자가 입력한 팀 이름을 기준으로 해당 팀에 속한 회원 목록을 조회하는 예제이다.
앞 예제에서는 MemberTeamTest1.java로 Team, Locker, Member 데이터를 저장했다.
이번 예제에서는 저장된 데이터에서 특정 Team의 name 값과 연결된 회원을 조회한다.


이 예제에서 중요한 점은 조회 조건이 Member 자신의 일반 필드가 아니라는 점이다.
회원 이름이나 회원 번호를 조건으로 조회하는 것이 아니라, Member가 참조하는 Team의 name 값을 조건으로 조회한다.
즉, 객체 관계를 따라가서 m.team.name을 조건으로 사용하는 예제이다.


이 예제의 핵심은 Member → Team 연관관계를 따라가서 Team의 name 값을 조회 조건으로 사용할 수 있다는 점이다.


이전 기본개념과 연결하기

앞 예제에서 Member는 Team을 참조한다고 정리했다.
객체 코드에서는 Member 안에 Team team 필드가 있고, 데이터베이스에서는 membertbl 테이블의 TEAM_ID 외래키로 관계가 저장된다.


MemberTeamTest1.java에서는 이 관계를 저장했다.
예를 들어 여러 회원이 같은 team1을 참조하도록 만들면, 데이터베이스에서는 여러 회원 행이 같은 TEAM_ID 값을 가지게 된다.


이번 예제는 저장된 관계를 조회에 사용한다.
Member를 기준으로 조회하되, 조건은 Member가 참조하는 Team의 name 값이다.
그래서 JPQL에서는 m.team.name처럼 객체 참조를 따라가는 표현이 등장한다.


여기서 1팀과 아기공룡둘리를 반드시 구분해야 한다.
앞 예제에서 new Team("1팀", "아기공룡둘리")처럼 생성했다면, 1팀은 Team의 id이고 아기공룡둘리는 Team의 name이다.
이번 조회 조건은 m.team.name이므로 입력값은 id인 1팀이 아니라 name인 아기공룡둘리와 비교된다.

기본개념에서 다시 잡아야 할 흐름

  • Member는 Team을 참조한다.
  • 여러 Member가 같은 Team을 참조할 수 있다.
  • 데이터베이스에서는 TEAM_ID 외래키로 관계가 저장된다.
  • Team의 id 값과 name 값은 다르다.
  • new Team("1팀", "아기공룡둘리")에서 1팀은 id이고, 아기공룡둘리는 name이다.
  • JPQL에서는 객체 참조를 따라가서 조건을 작성할 수 있다.
  • m.team.name은 회원이 참조하는 팀의 이름을 의미한다.
  • 조건에 맞는 회원이 여러 명일 수 있으므로 결과는 목록으로 받는다.

객체 관계는 저장할 때만 쓰는 것이 아니라, 조회 조건을 만들 때도 사용할 수 있다.


예제의 목표

이 예제의 목표는 입력한 팀 이름에 해당하는 회원 목록을 조회하는 것이다.
사용자가 팀 이름을 입력하면, MemberTeamTest2.java는 그 팀 이름과 연결된 회원들을 찾아 출력한다.


예를 들어 아기공룡둘리를 입력하면 Team의 name 값이 아기공룡둘리인 팀을 참조하는 회원들이 출력될 수 있다.
존재하지 않는 팀 이름을 입력하면 조회 결과가 비어 있을 수 있다.
이때는 팀원이 없다는 안내 메시지를 출력한다.


즉, 이 예제는 단순히 전체 회원을 조회하는 예제가 아니다.
사용자가 입력한 값이 JPQL 파라미터로 들어가고, 그 값이 m.team.name 조건과 비교되어 결과 목록이 만들어지는 흐름이다.

이 예제에서 확인할 내용

  • 사용자에게 팀 이름을 입력받는다.
  • 입력받은 팀 이름을 JPQL 파라미터로 연결한다.
  • m.team.name 조건으로 회원을 조회한다.
  • 조회 결과는 List<Member>로 받는다.
  • 결과 목록이 비어 있으면 안내 메시지를 출력한다.
  • 결과 목록이 있으면 회원 정보를 반복해서 출력한다.
  • Team.id가 아니라 Team.name 기준으로 조회한다는 점을 확인한다.

이 예제는 입력값, 파라미터 바인딩, 연관 필드 조건 조회, 결과 목록 처리가 함께 들어 있는 조회 예제이다.


코드 흐름

MemberTeamTest2.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 사용자에게 팀 이름을 입력받고, 입력값을 JPQL 조건에 연결한다.
조회 결과는 목록으로 받은 뒤, 결과가 있는지 없는지에 따라 출력 내용을 나눈다.


핵심 코드는 아래와 같다.

// MemberTeamTest2.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager entityManager = factory.createEntityManager(); // EntityManager 생성

Scanner scan = new Scanner(System.in); // 입력 도구 생성
System.out.print("팀명을 입력하세요 : "); // 입력 안내 출력
String teamName = scan.nextLine(); // 팀 이름 입력

String jpql = "select m from Member m where m.team.name = :tn"; // 팀 이름 조건으로 Member 조회

TypedQuery<Member> query = entityManager.createQuery(jpql, Member.class); // Member 목록 조회 쿼리 생성
query.setParameter("tn", teamName); // tn 파라미터에 입력한 팀 이름 연결

List<Member> memberList = query.getResultList(); // 조건에 맞는 회원 목록 조회

if (memberList.isEmpty()) { // 조회 결과가 없으면
    System.out.println(teamName + "에는 팀원이 없습니다."); // 안내 메시지 출력
} else {
    for (Member member : memberList) { // 조회 결과가 있으면 반복
        System.out.println(member); // 회원 정보 출력
    }
}

entityManager.close(); // EntityManager 닫기
factory.close(); // Factory 닫기
scan.close(); // 입력 도구 닫기

이 코드는 사용자가 입력한 팀 이름을 기준으로 회원 목록을 조회한다.
teamName에는 사용자가 입력한 문자열이 들어간다.
그리고 setParameter("tn", teamName)으로 JPQL의 :tn 자리에 그 값이 연결된다.


이후 getResultList()로 결과 목록을 가져온다.
결과가 없을 수도 있기 때문에 isEmpty()로 목록이 비어 있는지 확인한다.
결과가 있으면 for 반복문으로 회원 정보를 하나씩 출력한다.


입력값은 바로 문자열 비교에 쓰이는 것이 아니라, setParameter()를 통해 JPQL의 파라미터 자리에 안전하게 연결된다.


m.team.name은 연관 객체의 필드를 조건으로 사용한다

이 예제에서 가장 중요한 JPQL은 아래 문장이다.

// MemberTeamTest2.java
String jpql = "select m from Member m where m.team.name = :tn"; // 팀 이름 조건 조회

select m from Member m은 Member 엔티티를 조회하겠다는 뜻이다.
여기서 m은 Member를 가리키는 별칭이다.


where m.team.name = :tn은 조회 조건이다.
이 조건은 Member 자신의 username을 비교하는 것이 아니다.
Member가 참조하는 Team 객체로 이동한 뒤, 그 Team의 name 값을 비교한다.


나누어 읽으면 아래와 같다.

  • m은 Member이다.
  • m.team은 Member가 참조하는 Team이다.
  • m.team.name은 그 Team의 팀 이름이다.
  • :tn은 사용자가 입력한 팀 이름이 들어갈 파라미터이다.

그래서 이 조건은 “회원이 속한 팀의 이름이 입력한 팀 이름과 같은 회원을 조회하라”는 뜻이다.


예를 들어 사용자가 아기공룡둘리를 입력했다면, Team의 name이 아기공룡둘리인 팀을 참조하는 회원들이 조회될 수 있다.
반대로 1팀을 입력하면 결과가 없을 수 있다.
왜냐하면 이 코드에서는 Team의 id가 아니라 Team의 name과 비교하기 때문이다.


m.team.name은 Member에서 Team으로 이동한 뒤, 그 팀의 name 필드를 조회 조건으로 사용하는 표현이다.


Team의 id와 name을 헷갈리면 결과가 달라진다

앞 예제에서 Team 객체를 아래처럼 만들었다고 생각해 보자.

// MemberTeamTest1.java
Team team1 = new Team("1팀", "아기공룡둘리"); // id는 1팀, name은 아기공룡둘리
Team team2 = new Team("2팀", "겨울왕국"); // id는 2팀, name은 겨울왕국

이 코드에서 첫 번째 값은 Team의 id이다.
두 번째 값은 Team의 name이다.


따라서 team1의 id는 1팀이고, name은 아기공룡둘리이다.
team2의 id는 2팀이고, name은 겨울왕국이다.


이번 JPQL은 m.team.name = :tn이다.
즉, 입력값은 Team의 name과 비교된다.
그래서 아기공룡둘리 또는 겨울왕국처럼 name 값을 입력해야 조건과 맞는다.


만약 1팀, 2팀처럼 id 값을 기준으로 조회하고 싶다면 조건을 바꿔야 한다.
이때는 m.team.name이 아니라 m.team.id를 사용해야 한다.

// TeamIdConditionExample.java
String jpql = "select m from Member m where m.team.id = :tn"; // 팀 id 기준 조회

두 조건은 비슷해 보이지만 비교 대상이 다르다.
m.team.name은 팀 이름을 비교하고, m.team.id는 팀 기본키를 비교한다.

id와 name 구분 기준

  • m.team.id: Team의 기본키 값을 기준으로 조회한다.
  • m.team.name: Team의 이름 값을 기준으로 조회한다.
  • new Team("1팀", "아기공룡둘리")에서 1팀은 id이다.
  • new Team("1팀", "아기공룡둘리")에서 아기공룡둘리는 name이다.
  • 현재 예제는 m.team.name 기준이므로 아기공룡둘리 같은 name 값을 입력해야 한다.

입력값이 맞는데 결과가 없으면, 먼저 JPQL 조건이 id를 비교하는지 name을 비교하는지 확인해야 한다.


파라미터 바인딩으로 입력값을 조건에 연결한다

사용자가 입력한 팀 이름은 바로 JPQL 문자열에 붙이지 않는다.
대신 :tn이라는 파라미터 자리를 만들고, setParameter()로 값을 연결한다.


핵심 코드는 아래와 같다.

// MemberTeamTest2.java
String jpql = "select m from Member m where m.team.name = :tn"; // tn 파라미터 사용

TypedQuery<Member> query = entityManager.createQuery(jpql, Member.class); // 쿼리 생성
query.setParameter("tn", teamName); // 입력받은 팀 이름을 tn에 연결

:tn은 나중에 값을 넣을 자리이다.
setParameter("tn", teamName)은 그 자리에 teamName 값을 넣는다.


이 방식을 파라미터 바인딩이라고 한다.
파라미터 바인딩은 쿼리 안의 값 자리에 실제 값을 연결하는 과정이다.


이 방식은 코드가 읽기 쉽다.
또 입력값을 문자열로 직접 이어 붙이는 방식보다 안전하게 조건 값을 전달할 수 있다.

파라미터 바인딩에서 봐야 할 기준

  • :tn은 값이 들어갈 자리이다.
  • setParameter("tn", teamName)은 :tn에 입력값을 연결한다.
  • 파라미터 이름 tn은 JPQL과 setParameter()에서 같아야 한다.
  • 입력값은 m.team.name과 비교된다.

파라미터 이름이 JPQL의 :tn과 setParameter("tn", ...)에서 정확히 맞아야 한다.


getResultList는 결과 목록을 반환한다

이 예제는 팀 이름에 해당하는 회원이 여러 명일 수 있다.
그래서 결과를 하나의 Member로 받지 않고 List<Member>로 받는다.


핵심 코드는 아래와 같다.

// MemberTeamTest2.java
List<Member> memberList = query.getResultList(); // 조건에 맞는 회원 목록 조회

getResultList()는 조회 결과를 목록으로 반환한다.
결과가 여러 개일 수 있을 때 사용한다.


예를 들어 같은 팀에 여러 회원이 있으면 memberList 안에 여러 Member 객체가 들어간다.
반대로 조건에 맞는 회원이 없으면 빈 목록이 반환될 수 있다.


여기서 중요한 점은 결과가 없다고 해서 바로 오류로 처리되는 흐름이 아니라는 점이다.
getResultList()는 조건에 맞는 데이터가 없으면 빈 목록을 줄 수 있다.
그래서 isEmpty()로 결과가 비었는지 확인한 뒤 메시지를 출력한다.

// MemberTeamTest2.java
if (memberList.isEmpty()) { // 결과 목록이 비어 있으면
    System.out.println(teamName + "에는 팀원이 없습니다."); // 안내 메시지 출력
}

isEmpty()는 목록이 비어 있는지 확인하는 메서드이다.
조회 결과가 없을 때 아무것도 출력하지 않으면 사용자가 헷갈릴 수 있다.
그래서 안내 메시지를 따로 출력한다.


여러 결과 조회에서는 getResultList()를 사용하고, 결과가 없을 수 있으므로 빈 목록 처리까지 함께 해야 한다.


결과가 있으면 회원 목록을 반복해서 출력한다

조건에 맞는 회원이 있으면 for 반복문으로 목록을 하나씩 출력한다.


핵심 코드는 아래와 같다.

// MemberTeamTest2.java
for (Member member : memberList) { // 회원 목록 반복
    System.out.println(member); // 회원 정보 출력
}

memberList에는 조건에 맞는 Member 객체들이 들어 있다.
for 반복문은 그 목록에서 Member 객체를 하나씩 꺼낸다.
꺼낸 객체는 member 변수에 들어간다.


System.out.println(member)는 내부적으로 member.toString()을 호출한다.
Member 클래스에 @ToString이 붙어 있으면 회원 정보가 문자열로 출력된다.


이때 Member 안에 Team, Locker 참조가 포함되어 있다면 출력 결과가 길게 보일 수 있다.
결과가 길더라도 핵심은 입력한 팀 이름과 연결된 회원들이 출력되는지이다.

회원 목록 출력에서 봐야 할 기준

  • 조건에 맞는 회원이 여러 명이면 여러 줄이 출력된다.
  • System.out.println(member)는 toString() 결과를 출력한다.
  • 출력 결과에 Team, Locker 정보가 함께 보일 수 있다.
  • 결과가 길어도 입력한 팀 이름에 해당하는 회원인지 확인하면 된다.

회원 목록 출력 결과가 길게 보여도, 핵심은 입력한 팀 조건과 맞는 회원들이 조회되었는지 확인하는 것이다.


실행 결과는 입력값에 따라 달라진다

이 예제는 사용자가 어떤 팀 이름을 입력하느냐에 따라 결과가 달라진다.
이미 MemberTeamTest1.java로 데이터를 저장했다면, 저장된 Team.name과 맞는 값을 입력했을 때 회원 목록이 출력된다.


예상 결과 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 팀명을 입력하세요 : 아기공룡둘리
// Member(id=..., username=둘리, team=Team(id=1팀, name=아기공룡둘리), locker=Locker(...))
// Member(id=..., username=또치, team=Team(id=1팀, name=아기공룡둘리), locker=Locker(...))

존재하지 않는 팀 이름을 입력하면 아래처럼 안내 메시지가 출력될 수 있다.

// 출력결과
// 팀명을 입력하세요 : 없는팀
// 없는팀에는 팀원이 없습니다.

여기서 입력값은 반드시 JPQL 조건과 맞아야 한다.
현재 JPQL은 m.team.name을 비교하므로 팀의 name 값을 입력해야 한다.
따라서 아기공룡둘리, 겨울왕국 같은 값을 입력해야 조건에 맞는다.


만약 1팀, 2팀 같은 팀의 id 값을 입력하고 싶다면, 현재 코드에서는 결과가 나오지 않을 수 있다.
그 경우에는 조회 조건을 m.team.id로 바꿔야 한다.


MemberTeamTest2.java 실행 결과이다.
먼저 아기공룡둘리를 입력하면 Team.name 값이 일치하는 팀을 참조하는 회원 목록이 출력된다.
이 결과는 현재 JPQL 조건이 m.team.name = :tn이기 때문에 나온다.


반대로 1팀을 입력하면 팀원이 없다는 메시지가 출력된다.
1팀은 저장된 Team의 id 값이고, 현재 조회 조건에서 비교하는 값은 Team.name이기 때문이다.
따라서 이 예제에서는 입력값이 Team.id인지 Team.name인지 구분해서 확인해야 한다.


결과가 다르게 나올 때는 입력값이 Team.id인지 Team.name인지, 그리고 JPQL 조건이 어느 필드를 비교하는지 먼저 확인해야 한다.


MemberTeamTest1과 MemberTeamTest2의 차이

MemberTeamTest1.java와 MemberTeamTest2.java는 서로 이어지는 예제이다.
하지만 역할은 다르다.


MemberTeamTest1.java는 데이터를 저장한다.
Team, Locker, Member 객체를 만들고, Member가 Team, Locker를 참조하도록 연결한다.
그 결과가 데이터베이스 테이블에 저장된다.


MemberTeamTest2.java는 저장된 데이터를 조회한다.
특히 Member가 참조하는 Team의 name 값을 조건으로 사용해서 회원 목록을 찾는다.


차이를 정리하면 아래와 같다.

  • MemberTeamTest1.java: 팀, 락커, 회원 데이터를 저장한다.
  • MemberTeamTest1.java: 객체 참조가 외래키 값으로 저장되는지 확인한다.
  • MemberTeamTest2.java: 저장된 회원 데이터를 조회한다.
  • MemberTeamTest2.java: Member가 참조하는 Team의 name 값을 조건으로 사용한다.

저장은 관계를 만드는 과정이고, 조회는 만들어진 관계를 조건으로 활용하는 과정이다.


핵심 정리

MemberTeamTest2.java는 팀 이름을 입력받아 해당 팀에 속한 회원 목록을 조회하는 예제이다.
Member와 Team의 연관관계를 저장한 뒤, 그 관계를 조회 조건으로 사용하는 흐름을 보여 준다.


정리하면 아래와 같다.

  • MemberTeamTest2.java는 팀 이름을 입력받는다.
  • 입력값은 setParameter()로 JPQL 파라미터에 연결된다.
  • select m from Member m은 Member를 조회한다는 뜻이다.
  • m.team.name은 Member가 참조하는 Team의 name 필드이다.
  • where m.team.name = :tn은 입력한 팀 이름과 같은 팀을 참조하는 회원을 찾는 조건이다.
  • 1팀, 2팀은 Team.id 값이고, 아기공룡둘리, 겨울왕국은 Team.name 값이다.
  • 현재 예제는 Team.name 기준 조회이므로 아기공룡둘리 같은 이름 값을 입력해야 한다.
  • 결과가 여러 명일 수 있으므로 getResultList()를 사용한다.
  • 결과가 없을 수 있으므로 isEmpty()로 빈 목록을 확인한다.
  • 결과가 있으면 for 반복문으로 회원 목록을 출력한다.
  • 입력값이 Team의 name과 맞지 않으면 결과가 없을 수 있다.

이 예제는 객체 연관관계가 저장뿐 아니라 조회 조건에서도 사용될 수 있음을 보여 준다.




응용예제 18. 회원 단건 조회 후 팀과 락커 정보 출력하기 (MemberTeamTest3.java, Member.java, Team.java, Locker.java)

MemberTeamTest3.java는 회원명을 입력받아 해당 회원 한 명을 조회하고, 그 회원이 속한 팀명과 사용하는 락커명을 출력하는 예제이다.
앞 예제에서는 Team.name을 기준으로 여러 Member를 조회했다.
이번 예제에서는 반대로 Member.username을 기준으로 회원 한 명을 조회한다.


이 예제에서 중요한 점은 조회 결과가 Member 객체 하나라는 것이다.
조회된 Member 객체에서 getTeam().getName()으로 팀 이름을 꺼내고, getLocker().getName()으로 락커 이름을 꺼낸다.
즉, Member → Team, Member → Locker 객체 참조를 따라가서 필요한 값을 출력한다.


이 예제의 핵심은 회원 한 명을 조회한 뒤, 조회된 Member 객체에서 연관된 Team과 Locker 정보를 객체 참조로 꺼내는 흐름이다.


이전 기본개념과 연결하기

앞 예제에서 Member는 Team과 Locker를 참조한다고 정리했다.
객체 코드에서는 Member 안에 Team team, Locker locker 필드가 있다.
데이터베이스에서는 membertbl 테이블의 TEAM_ID, LOCKER_ID 외래키로 관계가 저장된다.


MemberTeamTest2.java에서는 Member가 참조하는 Team의 name 값을 조회 조건으로 사용했다.
즉, m.team.name처럼 Member에서 Team으로 이동한 뒤 팀 이름을 비교했다.


이번 예제에서는 조회 조건이 더 단순하다.
Member 자신의 필드인 username을 조건으로 사용한다.
하지만 출력할 때는 조회된 Member에서 다시 Team과 Locker로 이동한다.


그래서 이 예제는 조건 조회와 연관 객체 탐색이 함께 들어 있다.
조회 조건은 m.username = :mn이고, 출력 값은 dto.getTeam().getName(), dto.getLocker().getName()으로 꺼낸다.

기본개념에서 다시 잡아야 할 흐름

  • Member는 회원 정보를 가진다.
  • Member는 Team을 참조한다.
  • Member는 Locker를 참조한다.
  • username은 Member 자신의 일반 필드이다.
  • team과 locker는 다른 Entity를 가리키는 연관 필드이다.
  • 회원명으로 조회할 때는 m.username을 조건으로 사용한다.
  • 팀명과 락커명은 조회된 Member 객체에서 참조를 따라가서 꺼낸다.

조회 조건이 Member 자신의 필드여도, 출력할 값은 연관 객체를 따라가서 가져올 수 있다.


예제의 목표

이 예제의 목표는 입력한 회원명에 해당하는 회원 한 명을 조회하고, 그 회원의 소속 팀과 락커 정보를 함께 출력하는 것이다.


예를 들어 둘리를 입력하면 둘리라는 회원을 조회한다.
조회된 Member 객체에서 회원 이름, 팀 이름, 락커 이름을 꺼내서 한 문장으로 출력한다.


존재하지 않는 회원명을 입력하면 조회 결과가 없다.
이때는 NoResultException을 처리해서 정보가 없다는 메시지를 출력한다.

이 예제에서 확인할 내용

  • 사용자에게 회원명을 입력받는다.
  • 입력받은 회원명을 JPQL 파라미터로 연결한다.
  • m.username 조건으로 회원 한 명을 조회한다.
  • 결과가 한 명이라고 예상하므로 getSingleResult()를 사용한다.
  • 조회 결과가 없으면 NoResultException이 발생할 수 있다.
  • 조회된 Member에서 Team과 Locker 정보를 꺼낸다.
  • getTeam().getName()으로 팀명을 출력한다.
  • getLocker().getName()으로 락커명을 출력한다.

이 예제는 단건 조회, 예외 처리, 연관 객체 탐색을 한 번에 확인하는 예제이다.


코드 흐름

MemberTeamTest3.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 사용자에게 회원명을 입력받고, 입력값을 JPQL 조건에 연결한다.
조회 결과가 있으면 회원명, 팀명, 락커명을 출력하고, 결과가 없으면 안내 메시지를 출력한다.


핵심 코드는 아래와 같다.

// MemberTeamTest3.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

Scanner scan = new Scanner(System.in); // 입력 도구 생성
System.out.print("회원명을 입력하세요 : "); // 입력 안내 출력
String inputName = scan.nextLine(); // 회원명 입력
scan.close(); // 입력 도구 닫기

String jpql = "select m from Member m where m.username = :mn"; // 회원명 조건 JPQL
TypedQuery<Member> q = em.createQuery(jpql, Member.class); // Member 단건 조회 쿼리 생성
q.setParameter("mn", inputName); // mn 파라미터에 입력값 연결

try {
    Member dto = q.getSingleResult(); // 결과 한 건 조회

    System.out.printf(
            "%s님은 %s팀 소속이고 %s 락커를 사용중입니다.%n",
            dto.getUsername(), // 회원명
            dto.getTeam().getName(), // 팀명
            dto.getLocker().getName() // 락커명
    );
} catch (NoResultException e) {
    System.out.printf("%s님은 정보가 없습니다.%n", inputName); // 결과가 없을 때
}

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

이 코드는 회원명으로 Member 하나를 조회한다.
inputName에는 사용자가 입력한 회원명이 들어간다.
그리고 setParameter("mn", inputName)으로 JPQL의 :mn 자리에 입력값을 연결한다.


조회 결과가 있으면 getSingleResult()로 Member 객체 하나를 받는다.
그다음 dto.getUsername(), dto.getTeam().getName(), dto.getLocker().getName()을 사용해 출력 문장을 만든다.


MemberTeamTest3.java는 Member를 조회한 뒤, 조회된 객체에서 연관 객체의 값을 꺼내 출력한다.


m.username은 Member 자신의 필드를 조건으로 사용한다

이 예제의 JPQL은 아래와 같다.

// MemberTeamTest3.java
String jpql = "select m from Member m where m.username = :mn"; // 회원명 조건 조회

select m from Member m은 Member 엔티티를 조회한다는 뜻이다.
여기서 m은 Member를 가리키는 별칭이다.


where m.username = :mn은 조회 조건이다.
m.username은 Member 자신의 회원명 필드이다.
즉, 이번 예제는 팀 이름이나 락커 이름을 조건으로 조회하는 것이 아니라, 회원 이름을 조건으로 조회한다.


나누어 읽으면 아래와 같다.

  • m은 Member이다.
  • m.username은 Member의 회원명이다.
  • :mn은 사용자가 입력한 회원명이 들어갈 파라미터이다.

그래서 이 조건은 “회원 이름이 입력값과 같은 Member를 조회하라”는 뜻이다.


앞 예제의 m.team.name과 비교하면 차이가 분명하다.
m.team.name은 Member에서 Team으로 이동한 뒤 팀 이름을 조건으로 사용한다.
반면 m.username은 Member 자신의 필드를 바로 조건으로 사용한다.


m.username은 연관 객체로 이동하지 않고, Member 자신의 필드를 조건으로 사용하는 표현이다.


getSingleResult는 결과 한 건을 기대할 때 사용한다

이 예제에서는 getSingleResult()를 사용한다.
getSingleResult()는 조회 결과가 한 건이라고 기대할 때 사용하는 메서드이다.


핵심 코드는 아래와 같다.

// MemberTeamTest3.java
Member dto = q.getSingleResult(); // 결과 한 건 조회

회원명으로 한 명을 찾는 흐름이므로 결과를 List<Member>가 아니라 Member 객체 하나로 받는다.
그래서 getResultList()가 아니라 getSingleResult()를 사용한다.


하지만 getSingleResult()는 조심해서 써야 한다.
조건에 맞는 결과가 없으면 NoResultException이 발생할 수 있다.
조건에 맞는 결과가 여러 건이면 다른 예외가 발생할 수도 있다.


이 예제에서는 결과가 없는 경우를 처리하기 위해 try-catch를 사용한다.

// MemberTeamTest3.java
try {
    Member dto = q.getSingleResult(); // 결과 한 건 조회
    System.out.println(dto); // 조회 결과 출력
} catch (NoResultException e) {
    System.out.printf("%s님은 정보가 없습니다.%n", inputName); // 결과 없음 처리
}

try 안에는 정상 실행 코드를 넣는다.
catch 안에는 예외가 발생했을 때 실행할 코드를 넣는다.


여기서는 회원이 조회되면 정상 출력하고, 회원이 없으면 정보가 없다는 메시지를 출력한다.

getSingleResult에서 봐야 할 기준

  • 결과 한 건을 기대할 때 getSingleResult()를 사용한다.
  • 결과가 여러 건일 수 있으면 getResultList()가 더 적합하다.
  • 결과가 없으면 NoResultException이 발생할 수 있다.
  • 결과 없음 상황을 처리하려면 try-catch가 필요하다.

getSingleResult()는 결과 한 건을 기대하는 메서드이므로, 결과가 없을 때의 예외 처리까지 함께 봐야 한다.


getTeam과 getLocker로 연관 객체의 값을 꺼낸다

조회 결과는 Member 객체이다.
하지만 출력에는 팀명과 락커명도 필요하다.
이때 Member가 가진 객체 참조를 사용한다.


핵심 출력 코드는 아래와 같다.

// MemberTeamTest3.java
System.out.printf(
        "%s님은 %s팀 소속이고 %s 락커를 사용중입니다.%n",
        dto.getUsername(), // Member 자신의 username
        dto.getTeam().getName(), // Team의 name
        dto.getLocker().getName() // Locker의 name
);

dto.getUsername()은 Member 자신의 필드 값을 가져온다.
dto.getTeam().getName()은 Member에서 Team으로 이동한 뒤 팀 이름을 가져온다.
dto.getLocker().getName()은 Member에서 Locker로 이동한 뒤 락커 이름을 가져온다.


이 흐름은 테이블의 외래키 값을 직접 꺼내는 방식이 아니다.
객체 참조를 따라가서 필요한 값을 가져오는 방식이다.


객체 이동 흐름은 아래처럼 볼 수 있다.

// MemberRelationAccessFlow.java
Member dto = q.getSingleResult(); // 회원 조회

String username = dto.getUsername(); // 회원 이름
String teamName = dto.getTeam().getName(); // 회원이 참조하는 팀 이름
String lockerName = dto.getLocker().getName(); // 회원이 참조하는 락커 이름

dto.getTeam()의 결과는 Team 객체이다.
그 객체에서 다시 getName()을 호출하면 팀 이름을 얻을 수 있다.


dto.getLocker()의 결과는 Locker 객체이다.
그 객체에서 다시 getName()을 호출하면 락커 이름을 얻을 수 있다.


연관관계가 매핑되어 있으면 외래키 값을 직접 다루지 않고 객체 참조로 연결된 데이터를 사용할 수 있다.


Locker가 null이면 바로 getName을 호출할 수 없다

앞 예제에서 락커가 없는 회원도 저장할 수 있다고 정리했다.
예를 들어 new Member("도우너", team2, null)처럼 저장했다면, 해당 회원의 locker 필드는 null이다.


이 상태에서 아래 코드가 실행되면 문제가 생길 수 있다.

// NullLockerProblem.java
dto.getLocker().getName(); // locker가 null이면 오류 발생 가능

dto.getLocker()가 null이면 그 뒤에 .getName()을 호출할 수 없다.
null은 참조할 객체가 없다는 뜻이기 때문이다.
이 상태에서 메서드를 호출하면 NullPointerException이 발생할 수 있다.


따라서 이 예제의 출력 코드는 락커가 있는 회원을 조회할 때는 정상 동작한다.
하지만 락커가 없는 회원을 입력하면 별도의 null 처리가 필요할 수 있다.


안전하게 작성하려면 아래처럼 먼저 확인할 수 있다.

// NullLockerSafeExample.java
String lockerName;

if (dto.getLocker() != null) { // 락커가 있으면
    lockerName = dto.getLocker().getName(); // 락커 이름 사용
} else {
    lockerName = "사용중인 락커 없음"; // 락커가 없을 때 문구
}

이렇게 하면 락커가 없는 회원도 오류 없이 처리할 수 있다.


다만 현재 예제의 핵심은 null 처리 자체보다, 연관 객체를 따라가서 값을 꺼내는 흐름을 확인하는 것이다.
null 관계 처리는 뒤의 예제에서 더 중요하게 다룰 수 있다.


연관 객체가 없을 수 있는 필드는 바로 메서드를 이어서 호출하지 말고, 먼저 null 여부를 확인해야 한다.


실행 결과는 존재하는 회원과 없는 회원으로 나누어 확인한다

이 예제는 입력한 회원명이 실제 데이터에 있는지에 따라 결과가 달라진다.
이미 MemberTeamTest1.java로 데이터를 저장했다면, 저장된 회원 이름을 입력했을 때 회원 정보가 출력된다.


예상 결과 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 회원명을 입력하세요 : 둘리
// 둘리님은 아기공룡둘리팀 소속이고 A101 락커를 사용중입니다.

존재하지 않는 회원명을 입력하면 아래처럼 안내 메시지가 출력될 수 있다.

// 출력결과
// 회원명을 입력하세요 : 마이콜
// 마이콜님은 정보가 없습니다.

존재하는 회원을 입력했을 때는 getSingleResult()가 Member 객체를 반환한다.
존재하지 않는 회원을 입력했을 때는 NoResultException이 발생하고, catch 구문에서 안내 메시지를 출력한다.


MemberTeamTest3.java 실행 결과이다.
먼저 둘리를 입력하면 Member.username이 일치하는 회원 한 명이 조회된다.
조회된 Member 객체에서 getTeam().getName()으로 팀명을 꺼내고, getLocker().getName()으로 락커명을 꺼내 함께 출력한다.


반대로 마이콜을 입력하면 조건에 맞는 회원이 없기 때문에 정보가 없다는 메시지가 출력된다.
이 흐름은 getSingleResult()에서 결과가 없을 때 NoResultException을 처리한 결과이다.


결과가 있을 때는 연관 객체의 값이 출력되고, 결과가 없을 때는 NoResultException 처리 결과가 출력된다.


MemberTeamTest2와 MemberTeamTest3의 차이

MemberTeamTest2.java와 MemberTeamTest3.java는 모두 저장된 Member 데이터를 조회한다.
하지만 조회 기준과 결과 형태가 다르다.


MemberTeamTest2.java는 팀 이름으로 회원 목록을 조회한다.
조건은 m.team.name = :tn이고, 결과는 여러 명일 수 있으므로 List<Member>로 받는다.


MemberTeamTest3.java는 회원 이름으로 한 명을 조회한다.
조건은 m.username = :mn이고, 결과는 한 명이라고 기대하므로 getSingleResult()를 사용한다.


차이를 정리하면 아래와 같다.

  • MemberTeamTest2.java: Team.name으로 회원 목록을 조회한다.
  • MemberTeamTest2.java: 결과가 여러 명일 수 있으므로 getResultList()를 사용한다.
  • MemberTeamTest3.java: Member.username으로 회원 한 명을 조회한다.
  • MemberTeamTest3.java: 결과 한 건을 기대하므로 getSingleResult()를 사용한다.
  • MemberTeamTest2.java: 결과가 없으면 빈 목록을 처리한다.
  • MemberTeamTest3.java: 결과가 없으면 NoResultException을 처리한다.

목록 조회와 단건 조회는 결과를 받는 메서드와 결과 없음 처리 방식이 다르다.


핵심 정리

MemberTeamTest3.java는 회원명을 입력받아 회원 한 명을 조회하고, 그 회원의 팀명과 락커명을 출력하는 예제이다.
조회 조건은 Member 자신의 username이고, 출력할 팀명과 락커명은 연관 객체 참조를 따라가서 가져온다.


정리하면 아래와 같다.

  • MemberTeamTest3.java는 회원명을 입력받는다.
  • 입력값은 setParameter()로 JPQL 파라미터에 연결된다.
  • select m from Member m은 Member 엔티티 전체를 조회한다는 뜻이다.
  • where m.username = :mn은 회원명이 입력값과 같은 회원을 찾는 조건이다.
  • 결과 한 건을 기대하므로 getSingleResult()를 사용한다.
  • 결과가 없으면 NoResultException이 발생할 수 있다.
  • 조회된 Member에서 getUsername()으로 회원명을 꺼낸다.
  • 조회된 Member에서 getTeam().getName()으로 팀명을 꺼낸다.
  • 조회된 Member에서 getLocker().getName()으로 락커명을 꺼낸다.
  • locker가 null이면 getLocker().getName() 호출 시 오류가 발생할 수 있다.
  • 목록 조회와 단건 조회는 결과 처리 방식이 다르다.

이 예제는 단건으로 조회한 Member 객체에서 연관 객체를 따라가 필요한 값을 출력하는 흐름을 보여 준다.




응용예제 19. Entity 전체가 아니라 필요한 필드만 조회하기 (MemberTeamTest3_1.java, Member.java, Team.java, Locker.java)

MemberTeamTest3_1.java는 Member 엔티티 전체를 조회하지 않고, 출력에 필요한 필드만 직접 조회하는 예제이다.
앞 예제인 MemberTeamTest3.java에서는 select m from Member m으로 Member 객체 전체를 조회했다.
이번 예제에서는 m.username, m.team.name, m.locker.name만 선택해서 조회한다.


이 예제에서 중요한 점은 조회 결과가 Member 객체가 아니라는 점이다.
여러 필드를 직접 선택하면 결과 한 줄에 여러 값이 들어 있고, 그 결과를 Object[] 배열로 받는다.


이 예제의 핵심은 엔티티 전체 조회와 필요한 필드만 조회하는 방식의 차이를 이해하고, 여러 필드 조회 결과를 Object[]로 다루는 흐름을 잡는 것이다.


이전 기본개념과 연결하기

앞 예제에서 MemberTeamTest3.java는 회원명을 입력받아 Member 객체 하나를 조회했다.
그다음 조회된 Member 객체에서 getTeam().getName(), getLocker().getName()을 호출해 팀명과 락커명을 꺼냈다.


즉, 앞 예제의 흐름은 먼저 Member 엔티티 전체를 가져오는 방식이었다.
회원 객체를 가져온 뒤, 그 객체 안의 연관 필드를 따라가 필요한 값을 꺼냈다.


이번 예제는 방식이 다르다.
처음부터 Member 객체 전체를 가져오지 않고, 출력에 필요한 값만 select 절에 적는다.
회원명, 팀명, 락커명만 필요하다면 m.username, m.team.name, m.locker.name만 조회할 수 있다.


이 차이를 이해해야 한다.
select m은 엔티티 전체 조회이다.
select m.username, m.team.name, m.locker.name은 필요한 필드만 조회하는 방식이다.
결과를 받는 타입도 달라진다.

기본개념에서 다시 잡아야 할 흐름

  • select m은 Member 엔티티 전체를 조회한다.
  • select m.username은 Member의 회원명 필드만 조회한다.
  • m.team.name은 Member가 참조하는 Team의 name 값을 조회한다.
  • m.locker.name은 Member가 참조하는 Locker의 name 값을 조회한다.
  • 여러 필드를 한 번에 선택하면 결과 한 줄에 값이 여러 개 들어간다.
  • 여러 값을 한 줄로 받으면 Object[]로 다룰 수 있다.
  • Object[]는 select에 적은 순서대로 값이 들어간다.

엔티티 전체를 조회하는 것과 필요한 필드만 조회하는 것은 결과 타입부터 다르다.


예제의 목표

이 예제의 목표는 회원명을 입력받아 회원명, 팀명, 락커명만 조회하는 것이다.
결과 문장은 앞 예제와 비슷하게 보일 수 있다.
하지만 내부 조회 방식은 다르다.


앞 예제는 Member 객체 전체를 조회한 뒤 객체 참조를 따라갔다.
이번 예제는 처음부터 출력에 필요한 세 값만 조회한다.
그래서 결과를 Member 타입으로 받을 수 없고, Object[] 배열로 받아야 한다.


즉, 이 예제는 출력 결과만 보는 예제가 아니다.
같은 문장을 출력하더라도 조회 방식이 어떻게 달라지는지 확인하는 예제이다.

이 예제에서 확인할 내용

  • 사용자에게 회원명을 입력받는다.
  • 입력값을 JPQL 파라미터로 연결한다.
  • select m.username, m.team.name, m.locker.name으로 필요한 필드만 조회한다.
  • 조회 결과는 Member가 아니라 Object[]로 받는다.
  • result[0], result[1], result[2] 순서로 값을 꺼낸다.
  • 결과가 없으면 NoResultException을 처리한다.
  • locker가 null인 회원은 회원이 존재해도 결과가 없을 수 있음을 확인한다.
  • 앞 예제의 엔티티 전체 조회와 이번 예제의 필드 조회 차이를 비교한다.

이번 예제는 필요한 값만 선택해서 조회할 때 결과를 어떻게 받아야 하는지 확인하는 예제이다.


코드 흐름

MemberTeamTest3_1.java도 사용자에게 회원명을 입력받는다.
다만 JPQL의 select 절이 앞 예제와 다르다.
앞 예제는 Member 전체를 조회했지만, 이번 예제는 필요한 필드 세 개만 조회한다.


핵심 코드는 아래와 같다.

// MemberTeamTest3_1.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

Scanner scan = new Scanner(System.in); // 입력 도구 생성
System.out.print("회원명을 입력하세요 : "); // 입력 안내 출력
String inputName = scan.nextLine(); // 회원명 입력
scan.close(); // 입력 도구 닫기

String jpql = "select m.username, m.team.name, m.locker.name from Member m where m.username = :mn"; // 필요한 필드만 조회

Query q = em.createQuery(jpql); // 여러 필드 결과이므로 일반 Query 사용
q.setParameter("mn", inputName); // mn 파라미터에 입력값 연결

try {
    Object[] result = (Object[]) q.getSingleResult(); // 한 행의 여러 값을 Object 배열로 받음

    System.out.printf(
            "%s님은 %s팀 소속이고 %s 락커를 사용중입니다.%n",
            result[0], // 회원명
            result[1], // 팀명
            result[2] // 락커명
    );
} catch (NoResultException e) {
    System.out.printf("%s님은 정보가 없습니다.%n", inputName); // 결과가 없을 때
}

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

여기서는 TypedQuery<Member>가 아니라 일반 Query를 사용한다.
조회 결과가 Member 객체 하나가 아니기 때문이다.


select 절에서 세 개의 값을 선택했으므로 결과 한 줄에는 세 값이 들어 있다.
그래서 getSingleResult()의 결과를 Object[]로 형변환해서 받는다.


여러 필드를 직접 선택하면 조회 결과가 엔티티 객체가 아니라 값들의 묶음이 되므로 Object[]로 받아야 한다.


select 절에 여러 필드를 쓰면 Object 배열로 받는다

이 예제에서 가장 중요한 JPQL은 아래 문장이다.

// MemberTeamTest3_1.java
String jpql = "select m.username, m.team.name, m.locker.name from Member m where m.username = :mn"; // 여러 필드 조회

select m.username은 회원명을 조회한다.
m.team.name은 회원이 참조하는 팀의 이름을 조회한다.
m.locker.name은 회원이 참조하는 락커의 이름을 조회한다.


이렇게 여러 값을 선택하면 결과 한 줄에 값이 여러 개 들어 있다.
이때 결과를 Member 객체로 받을 수 없다.
Member 전체를 조회한 것이 아니라, username, team.name, locker.name이라는 값 세 개를 조회했기 때문이다.


그래서 아래처럼 Object[]로 받는다.

// MemberTeamTest3_1.java
Object[] result = (Object[]) q.getSingleResult(); // 한 행의 여러 값을 배열로 받음

System.out.println(result[0]); // 회원명
System.out.println(result[1]); // 팀명
System.out.println(result[2]); // 락커명

result[0], result[1], result[2]는 select 절에 적은 순서대로 들어간다.
첫 번째로 적은 m.username은 result[0]이다.
두 번째로 적은 m.team.name은 result[1]이다.
세 번째로 적은 m.locker.name은 result[2]이다.

Object 배열에서 봐야 할 기준

  • 여러 필드를 선택하면 결과 한 줄에 여러 값이 들어간다.
  • 여러 값을 한 줄로 받으면 Object[]를 사용할 수 있다.
  • 배열 인덱스는 0부터 시작한다.
  • result[0]은 첫 번째 선택값이다.
  • result[1]은 두 번째 선택값이다.
  • result[2]는 세 번째 선택값이다.
  • select 순서와 배열 인덱스를 정확히 맞춰야 한다.

여러 필드 조회에서는 select 절의 순서와 Object[] 인덱스 순서가 그대로 연결된다.


Entity 전체 조회와 필드 조회는 결과 형태가 다르다

앞 예제와 이번 예제의 가장 큰 차이는 조회 결과의 형태이다.
앞 예제는 Member 엔티티 전체를 조회했다.
이번 예제는 필요한 필드만 조회한다.


먼저 앞 예제의 흐름은 아래와 같다.

// MemberTeamTest3.java
String jpql = "select m from Member m where m.username = :mn"; // Member 전체 조회

TypedQuery<Member> q = em.createQuery(jpql, Member.class); // Member 타입 조회
Member dto = q.getSingleResult(); // Member 객체로 받음

String teamName = dto.getTeam().getName(); // 객체 참조로 팀명 접근
String lockerName = dto.getLocker().getName(); // 객체 참조로 락커명 접근

이 방식은 Member 객체 전체를 가져온다.
그래서 dto.getUsername(), dto.getTeam(), dto.getLocker()처럼 객체 메서드로 값을 꺼낼 수 있다.


반면 이번 예제의 흐름은 아래와 같다.

// MemberTeamTest3_1.java
String jpql = "select m.username, m.team.name, m.locker.name from Member m where m.username = :mn"; // 필드만 조회

Query q = em.createQuery(jpql); // 여러 필드 조회
Object[] result = (Object[]) q.getSingleResult(); // Object 배열로 받음

String username = (String) result[0]; // 회원명
String teamName = (String) result[1]; // 팀명
String lockerName = (String) result[2]; // 락커명

이 방식은 처음부터 필요한 값만 조회한다.
그래서 결과를 Member 객체로 다루지 않는다.
대신 배열 인덱스로 각 값을 꺼낸다.

두 방식의 차이

  • 엔티티 전체 조회: select m을 사용한다.
  • 엔티티 전체 조회: 결과를 Member 객체로 받는다.
  • 엔티티 전체 조회: 객체 참조를 따라가 값을 꺼낸다.
  • 필드 조회: select m.username, m.team.name, m.locker.name처럼 필요한 필드만 선택한다.
  • 필드 조회: 결과를 Object[]로 받는다.
  • 필드 조회: 배열 인덱스로 값을 꺼낸다.

출력 문장이 비슷해도 내부 조회 방식이 엔티티 전체 조회인지 필드 조회인지 반드시 구분해야 한다.


필요한 값만 조회하면 가볍지만 인덱스 관리가 필요하다

필드 조회의 장점은 필요한 값만 가져올 수 있다는 점이다.
예를 들어 화면에 회원명, 팀명, 락커명만 필요하다면 Member 객체 전체를 가져오지 않고 세 값만 조회할 수 있다.


하지만 단점도 있다.
결과를 Object[]로 받으면 어떤 값이 몇 번째 인덱스에 들어 있는지 직접 맞춰야 한다.
select 순서를 바꾸면 배열 인덱스의 의미도 바뀐다.


예를 들어 아래처럼 select 순서를 바꾸면 결과 인덱스도 달라진다.

// ObjectArrayOrderExample.java
String jpql = "select m.team.name, m.username, m.locker.name from Member m where m.username = :mn"; // 순서 변경

Object[] result = (Object[]) q.getSingleResult(); // 결과 배열

System.out.println(result[0]); // 팀명
System.out.println(result[1]); // 회원명
System.out.println(result[2]); // 락커명

이 코드에서는 result[0]이 회원명이 아니라 팀명이다.
왜냐하면 select 절에서 m.team.name을 첫 번째로 적었기 때문이다.


그래서 Object[]를 사용할 때는 반드시 select 순서와 출력 순서를 함께 봐야 한다.
순서를 잘못 맞추면 출력 문장은 정상처럼 보여도 값의 의미가 뒤섞일 수 있다.


Object[] 조회는 필요한 값만 가져올 수 있다는 장점이 있지만, 인덱스 순서를 직접 관리해야 한다는 단점이 있다.


null 연관관계가 있으면 결과 없음의 의미가 달라질 수 있다

앞 예제에서 락커가 없는 회원도 저장할 수 있다고 정리했다.
예를 들어 도우너처럼 locker가 null인 회원이 있을 수 있다.


이번 예제의 JPQL은 m.locker.name을 직접 조회한다.
이 말은 Member에서 Locker로 이동한 뒤, 그 Locker의 name 값을 조회한다는 뜻이다.


그런데 회원의 locker가 null이면 m.locker.name으로 이동할 대상이 없다.
이 경우 Member 데이터 자체는 존재해도, 현재 필드 조회 결과에서는 빠질 수 있다.


예를 들어 도우너라는 회원이 실제로 저장되어 있더라도 locker가 null이라면 문제가 생길 수 있다.
이때 getSingleResult()에서 결과를 찾지 못하면 catch 구문이 실행되어 "도우너님은 정보가 없습니다."처럼 출력될 수 있다.


하지만 이 메시지를 단순히 “도우너 회원이 존재하지 않는다”는 뜻으로만 보면 안 된다.
회원 데이터는 존재하지만, 현재 select 절에서 m.locker.name까지 직접 조회하고 있기 때문에 연관된 Locker가 없는 데이터가 결과에서 제외된 상황일 수 있다.


이 점이 앞 예제와 다르다.
MemberTeamTest3.java처럼 select m으로 Member 전체를 조회하면 먼저 회원 객체를 가져온 뒤 locker가 null인지 직접 확인할 수 있다.
하지만 MemberTeamTest3_1.java는 처음부터 m.locker.name 값을 조회하려고 하므로, Locker가 없는 회원은 조회 결과 해석이 달라질 수 있다.


따라서 이 예제는 락커가 있는 회원인 둘리 같은 데이터로 확인하는 것이 안전하다.
락커가 없는 회원까지 안전하게 처리하려면 left join을 사용하거나, 엔티티 전체 조회 후 null 여부를 확인하는 방식이 필요할 수 있다.


이 단계에서는 아래 정도로 이해하면 된다.

  • m.locker.name은 락커가 있는 회원에게 자연스럽게 사용할 수 있다.
  • locker가 null이면 m.locker.name으로 값을 꺼낼 수 없다.
  • 회원이 존재해도 연관된 Locker가 없으면 결과가 없다고 처리될 수 있다.
  • 이때 정보 없음 메시지를 회원 자체가 없다는 뜻으로만 해석하면 안 된다.
  • 연관관계가 없을 수 있는 데이터는 조회 방식과 null 처리 방식을 함께 고려해야 한다.

연관 필드를 직접 선택하는 조회에서는 회원 자체의 존재 여부와 연관 객체의 존재 여부를 구분해서 해석해야 한다.


실행 결과는 앞 예제와 비슷하지만 조회 방식이 다르다

실행 결과 문장은 MemberTeamTest3.java와 거의 비슷하게 나올 수 있다.
하지만 내부 조회 방식은 다르다.


예상 결과 흐름은 아래와 같다.

// 출력결과
// 회원명을 입력하세요 : 둘리
// 둘리님은 아기공룡둘리팀 소속이고 A101 락커를 사용중입니다.

출력 문장만 보면 앞 예제와 같아 보일 수 있다.
하지만 앞 예제는 Member 객체를 조회한 뒤 객체 참조를 따라갔다.
이번 예제는 처음부터 필요한 필드 세 개만 조회했고, 그 결과를 Object[]로 받아 출력했다.


존재하지 않는 회원명을 입력하면 아래처럼 안내 메시지가 출력될 수 있다.

// 출력결과
// 회원명을 입력하세요 : 마이콜
// 마이콜님은 정보가 없습니다.

결과가 없을 때는 앞 예제와 마찬가지로 NoResultException을 처리한다.
다만 이번 예제에서는 마이콜처럼 회원이 아예 없는 경우와 도우너처럼 회원은 있지만 locker가 없는 경우를 구분해서 생각해야 한다.


MemberTeamTest3_1.java 실행 결과이다.
먼저 둘리를 입력하면 필요한 필드인 회원명, 팀명, 락커명이 조회되어 출력된다.
출력 문장은 앞 예제와 비슷하지만, 이번 예제는 Member 엔티티 전체를 조회한 것이 아니라 m.username, m.team.name, m.locker.name 세 값만 조회한 결과이다.


반대로 마이콜을 입력하면 조건에 맞는 결과가 없기 때문에 정보가 없다는 메시지가 출력된다.
이 흐름은 getSingleResult()에서 결과가 없을 때 NoResultException을 처리한 결과이다.


같은 출력 결과라도 Entity 전체 조회인지, 필요한 필드만 조회한 것인지 구분해야 한다.


MemberTeamTest3와 MemberTeamTest3_1의 차이

MemberTeamTest3.java와 MemberTeamTest3_1.java는 출력 결과가 비슷하다.
하지만 조회 대상과 결과 타입이 다르다.


MemberTeamTest3.java는 Member 엔티티 전체를 조회한다.
조회된 객체에서 getTeam()과 getLocker()를 호출해 연관 객체의 값을 꺼낸다.


MemberTeamTest3_1.java는 필요한 필드만 조회한다.
m.username, m.team.name, m.locker.name 값을 바로 조회하고, 결과를 Object[]로 받는다.


차이를 정리하면 아래와 같다.

  • MemberTeamTest3.java: select m으로 Member 전체를 조회한다.
  • MemberTeamTest3.java: 결과 타입은 Member이다.
  • MemberTeamTest3.java: 조회 후 객체 참조를 따라가 값을 꺼낸다.
  • MemberTeamTest3_1.java: 필요한 필드만 select 절에 적는다.
  • MemberTeamTest3_1.java: 결과 타입은 Object[]이다.
  • MemberTeamTest3_1.java: 배열 인덱스로 회원명, 팀명, 락커명을 꺼낸다.
  • MemberTeamTest3_1.java: 연관 객체가 null이면 결과 해석이 달라질 수 있다.

MemberTeamTest3_1.java는 필요한 값만 조회하는 방식이므로 결과 타입과 값 꺼내는 방식이 앞 예제와 달라진다.


핵심 정리

MemberTeamTest3_1.java는 Member 엔티티 전체를 조회하지 않고, 필요한 필드만 선택해서 조회하는 예제이다.
회원명, 팀명, 락커명만 필요하기 때문에 select m.username, m.team.name, m.locker.name을 사용한다.


정리하면 아래와 같다.

  • MemberTeamTest3_1.java는 회원명을 입력받는다.
  • 입력값은 setParameter()로 JPQL 파라미터에 연결된다.
  • select m.username, m.team.name, m.locker.name은 필요한 필드 세 개만 조회한다.
  • 조회 결과는 Member가 아니라 Object[]이다.
  • result[0]은 회원명이다.
  • result[1]은 팀명이다.
  • result[2]는 락커명이다.
  • Object[]는 select 절에 적은 순서대로 값이 들어간다.
  • 결과가 없으면 NoResultException을 처리한다.
  • 출력 결과가 앞 예제와 비슷해도 내부 조회 방식은 다르다.
  • locker가 null인 회원은 회원이 존재해도 결과가 없다고 처리될 수 있다.
  • 정보 없음 메시지는 회원 자체가 없다는 뜻인지, 연관 객체가 없어 조회 결과에서 빠진 것인지 구분해서 봐야 한다.

필드 조회는 필요한 값만 가져올 수 있지만, 결과를 Object[]로 받기 때문에 select 순서와 배열 인덱스를 정확히 맞춰야 한다.




응용예제 20. 연관관계가 null인 Member 저장하고 전체 조회하기 (MemberTeamTest4.java, MemberTeamTest4_1.java, Member.java)

MemberTeamTest4.java와 MemberTeamTest4_1.java는 연관관계가 없는 회원을 저장하고 전체 회원 목록에서 그 결과를 확인하는 예제이다.
앞 예제들에서는 회원이 Team과 Locker를 참조하는 흐름을 계속 확인했다.
이번 예제에서는 반대로 팀도 없고 락커도 없는 회원을 저장한다.


이 예제에서 중요한 점은 연관관계 필드가 항상 객체를 참조하는 것은 아니라는 점이다.
Member의 team, locker 필드에는 Team, Locker 객체가 들어갈 수도 있지만, 아무 관계가 없으면 null이 들어갈 수도 있다.


이 예제의 핵심은 연관관계 필드도 상황에 따라 null일 수 있고, 데이터베이스에서는 외래키 컬럼이 비어 있는 값으로 저장될 수 있다는 점이다.


이전 기본개념과 연결하기

앞에서 Member는 Team과 다대일 관계를 가진다고 정리했다.
객체 코드에서는 Member 안의 team 필드가 Team 객체를 참조하고, 데이터베이스에서는 TEAM_ID 외래키 컬럼으로 관계가 저장된다.


또 Member는 Locker와 일대일 관계를 가진다고 정리했다.
객체 코드에서는 Member 안의 locker 필드가 Locker 객체를 참조하고, 데이터베이스에서는 LOCKER_ID 외래키 컬럼으로 관계가 저장된다.


하지만 모든 회원이 반드시 팀과 락커를 가져야 하는 것은 아니다.
업무 규칙에 따라 아직 팀이 정해지지 않은 회원이 있을 수 있고, 락커를 사용하지 않는 회원도 있을 수 있다.
이때 객체에서는 team과 locker에 null을 넣어 관계가 없음을 표현할 수 있다.


이 흐름은 앞의 MemberTeamTest3_1.java에서 다룬 null 연관관계 문제와도 이어진다.
연관 필드를 바로 따라가서 값을 조회하거나 출력하려면, 그 연관 객체가 실제로 존재하는지 먼저 생각해야 한다.

기본개념에서 다시 잡아야 할 흐름

  • Member는 Team을 참조할 수 있다.
  • Member는 Locker를 참조할 수 있다.
  • team과 locker는 연관관계 필드이다.
  • 연관관계 필드에는 객체가 들어갈 수도 있고 null이 들어갈 수도 있다.
  • team이 null이면 회원이 소속 팀을 가지지 않는다는 뜻이다.
  • locker가 null이면 회원이 락커를 사용하지 않는다는 뜻이다.
  • 데이터베이스에서는 TEAM_ID, LOCKER_ID가 비어 있는 값으로 저장될 수 있다.

연관관계는 항상 존재한다고 단정하면 안 되고, 관계가 없는 상태도 하나의 데이터 상태로 이해해야 한다.


예제의 목표

이 예제의 목표는 팀과 락커가 없는 회원을 저장하고, 전체 조회 결과에서 team=null, locker=null로 확인하는 것이다.


MemberTeamTest4.java는 new Member("토토로", null, null)처럼 회원 객체를 만든다.
첫 번째 null은 팀이 없다는 뜻이고, 두 번째 null은 락커가 없다는 뜻이다.


그다음 MemberTeamTest4_1.java는 전체 회원 목록을 조회한다.
이때 앞에서 저장한 토토로, 듀크 같은 회원이 함께 출력되고, 그 회원들의 team, locker가 null로 보일 수 있다.


즉, 이 예제는 저장과 조회가 함께 이어지는 구조이다.
먼저 null 관계를 가진 회원을 저장하고, 다음 실행 파일에서 전체 조회로 그 결과를 확인한다.

이 예제에서 확인할 내용

  • MemberTeamTest4.java에서 팀과 락커가 없는 회원을 만든다.
  • Member 생성자에 null, null을 전달한다.
  • persist()로 회원을 저장한다.
  • commit()으로 데이터베이스에 반영한다.
  • MemberTeamTest4_1.java에서 전체 회원 목록을 조회한다.
  • 조회 결과에서 team=null, locker=null이 출력되는지 확인한다.
  • 데이터베이스에서는 TEAM_ID, LOCKER_ID가 비어 있는 값으로 저장될 수 있음을 이해한다.

이 예제는 연관관계가 없는 회원을 저장하고, 그 상태가 객체 출력과 테이블 외래키 값에 어떻게 나타나는지 확인하는 예제이다.


MemberTeamTest4는 team과 locker가 null인 회원을 저장한다

MemberTeamTest4.java는 Team과 Locker 없이 회원 두 명을 저장한다.
즉, 회원 객체는 만들지만 팀 객체와 락커 객체는 연결하지 않는다.


핵심 코드는 아래와 같다.

// MemberTeamTest4.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

em.getTransaction().begin(); // 트랜잭션 시작

Member m1 = new Member("토토로", null, null); // 팀과 락커가 없는 회원
Member m2 = new Member("듀크", null, null); // 팀과 락커가 없는 회원

em.persist(m1); // 토토로 저장 대상으로 등록
em.persist(m2); // 듀크 저장 대상으로 등록

System.out.println("Member에 추가 데이터 저장~~ "); // 저장 안내 출력

em.getTransaction().commit(); // 저장 확정

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

new Member("토토로", null, null)에서 "토토로"는 회원 이름이다.
첫 번째 null은 team 필드에 들어갈 값이다.
즉, 이 회원은 참조하는 Team이 없다.


두 번째 null은 locker 필드에 들어갈 값이다.
즉, 이 회원은 참조하는 Locker가 없다.


persist(m1)과 persist(m2)는 두 회원 객체를 저장 대상으로 만든다.
그리고 commit()이 실행되면 데이터베이스에 반영된다.


Member 생성자에 null을 넘기면 해당 연관관계가 없는 회원 객체를 만들 수 있다.


null 관계는 데이터베이스 외래키 컬럼에 비어 있는 값으로 저장될 수 있다

객체 코드에서 team과 locker에 null을 넣으면, 데이터베이스에서는 외래키 컬럼이 비어 있는 값으로 저장될 수 있다.


Member의 관계 필드는 아래처럼 외래키 컬럼과 연결되어 있다.

// Member.java
@ManyToOne // 여러 회원이 하나의 팀과 연결될 수 있음
@JoinColumn(name = "TEAM_ID") // TEAM_ID 외래키 컬럼
private Team team;

@OneToOne // 회원 하나가 락커 하나와 연결될 수 있음
@JoinColumn(name = "LOCKER_ID") // LOCKER_ID 외래키 컬럼
private Locker locker;

team에 Team 객체가 들어 있으면 TEAM_ID에 해당 팀의 기본키 값이 저장된다.
하지만 team이 null이면 연결할 팀이 없으므로 TEAM_ID가 비어 있는 값으로 저장될 수 있다.


locker도 마찬가지이다.
locker에 Locker 객체가 들어 있으면 LOCKER_ID에 락커 기본키 값이 저장된다.
하지만 locker가 null이면 연결할 락커가 없으므로 LOCKER_ID가 비어 있는 값으로 저장될 수 있다.


단, 실제로 NULL 값이 저장되려면 테이블의 외래키 컬럼이 NULL을 허용해야 한다.
만약 TEAM_ID, LOCKER_ID 컬럼이 반드시 값이 있어야 하도록 설정되어 있다면 저장 과정에서 오류가 날 수 있다.

null 관계 저장에서 봐야 할 기준

  • 객체의 team이 null이면 참조하는 팀이 없다.
  • 객체의 locker가 null이면 참조하는 락커가 없다.
  • 테이블에서는 TEAM_ID, LOCKER_ID가 비어 있는 값으로 저장될 수 있다.
  • 외래키 컬럼이 NULL을 허용해야 정상 저장된다.

객체의 연관 필드가 null이면 데이터베이스의 외래키 컬럼도 비어 있는 관계 상태를 나타낼 수 있다.


MemberTeamTest4_1은 Member 전체를 조회한다

MemberTeamTest4_1.java는 전체 회원 목록을 조회한다.
앞에서 저장한 팀과 락커가 없는 회원까지 함께 확인할 수 있다.


핵심 코드는 아래와 같다.

// MemberTeamTest4_1.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

Query query = em.createQuery("select m from Member m", Member.class); // 전체 Member 조회
List<Member> list = query.getResultList(); // 회원 목록 조회

for (Member member : list) { // 회원 목록 반복
    System.out.println(member); // 회원 정보 출력
}

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

select m from Member m은 Member 엔티티 전체를 조회한다는 뜻이다.
결과는 여러 명일 수 있으므로 getResultList()를 사용한다.


조회된 회원 목록은 for 반복문으로 하나씩 출력한다.
System.out.println(member)는 내부적으로 member.toString()을 호출한다.
그래서 Member의 toString() 결과에 team, locker 필드가 포함되어 있으면 해당 값도 함께 보일 수 있다.


앞에서 저장한 토토로, 듀크는 팀과 락커를 연결하지 않았기 때문에 출력 결과에서 team=null, locker=null로 보일 수 있다.


전체 조회 결과에서 team=null, locker=null이 보이면 해당 회원에게 연관관계가 설정되지 않았다는 뜻이다.


toString 출력에서 null 관계를 확인할 수 있다

MemberTeamTest4_1.java는 회원 객체를 그대로 출력한다.
그래서 출력 결과는 Member의 toString() 형식에 따라 보인다.


예상 출력 흐름은 아래와 같다.

// 출력결과
// Member(id=..., username=둘리, team=Team(...), locker=Locker(...))
// Member(id=..., username=또치, team=Team(...), locker=Locker(...))
// ...
// Member(id=..., username=토토로, team=null, locker=null)
// Member(id=..., username=듀크, team=null, locker=null)

기존에 저장한 둘리, 또치 같은 회원은 Team, Locker가 연결되어 있을 수 있다.
그래서 team=Team(...), locker=Locker(...)처럼 출력될 수 있다.


반면 토토로, 듀크는 new Member("토토로", null, null), new Member("듀크", null, null)로 저장했다.
그래서 출력 결과에서 team=null, locker=null로 보일 수 있다.


이 결과는 오류가 아니다.
관계가 없도록 저장했기 때문에 객체 출력에서도 관계가 없다고 보이는 것이다.

출력 결과에서 봐야 할 기준

  • team=Team(...)이면 팀 객체를 참조한다는 뜻이다.
  • locker=Locker(...)이면 락커 객체를 참조한다는 뜻이다.
  • team=null이면 팀 관계가 없다는 뜻이다.
  • locker=null이면 락커 관계가 없다는 뜻이다.

null 출력은 실패가 아니라, 해당 연관관계가 설정되지 않은 상태를 보여 주는 결과일 수 있다.


null 관계는 뒤의 조회와 삭제 예제로 이어진다

팀과 락커가 없는 회원을 저장해 두면, 뒤의 예제에서 이 데이터를 다시 활용할 수 있다.
특히 team이 null인 회원을 찾거나 삭제하는 예제로 이어진다.


예를 들어 팀이 없는 회원을 찾으려면 m.team is null 같은 조건을 사용할 수 있다.
is null은 값이 없는 데이터를 찾을 때 사용하는 조건이다.

// NullRelationConditionExample.java
String jpql = "select m from Member m where m.team is null"; // 팀이 없는 회원 조회

이 조건은 team 연관관계가 설정되지 않은 회원을 찾는다.
즉, 앞에서 저장한 토토로, 듀크 같은 회원이 조회 대상이 될 수 있다.


그래서 MemberTeamTest4.java는 단순히 null 데이터를 저장하고 끝나는 예제가 아니다.
뒤에서 is null 조건 조회와 삭제 흐름을 확인하기 위한 준비 데이터 역할도 한다.


team=null인 회원을 저장해 두면, 뒤에서 is null 조건으로 해당 회원을 조회하거나 삭제하는 흐름을 확인할 수 있다.


실행 결과는 null 관계가 포함된 회원 목록으로 나온다

MemberTeamTest4.java를 실행하면 저장 완료 메시지가 나온다.
그다음 MemberTeamTest4_1.java를 실행하면 전체 회원 목록이 출력된다.


예상 결과 흐름은 아래와 같다.

// 출력결과
// Member에 추가 데이터 저장~~
// 출력결과
// Member(id=..., username=둘리, team=Team(...), locker=Locker(...))
// ...
// Member(id=..., username=토토로, team=null, locker=null)
// Member(id=..., username=듀크, team=null, locker=null)

실제 id 값은 데이터베이스 상태에 따라 달라진다.
중요한 것은 토토로, 듀크의 team, locker가 null로 보일 수 있다는 점이다.


MemberTeamTest4.java 실행 결과이다.
토토로, 듀크 회원을 저장하면서 membertbl에 insert가 실행된다.
이때 두 회원은 Team과 Locker를 연결하지 않고 생성했기 때문에, 객체 기준으로 team=null, locker=null 상태로 저장된다.


MemberTeamTest4_1.java 실행 결과이다.
전체 회원 목록을 조회하면 기존 회원들은 team=Team(...), locker=Locker(...)처럼 연관 객체가 함께 출력된다.
반면 토토로, 듀크는 team=null, locker=null로 출력된다.
이는 두 회원이 팀과 락커를 참조하지 않는 상태로 저장되었음을 보여 준다.


출력 중간에 Team, Locker를 추가 조회하는 SQL이 보이는 이유는 Member를 출력할 때 toString() 결과 안에서 연관 객체 정보까지 확인하기 때문이다.
여기서 핵심은 추가 조회 자체가 아니라, 토토로, 듀크의 연관관계가 null로 확인된다는 점이다.


이 결과에서 봐야 할 핵심은 팀과 락커 없이 저장한 회원이 전체 조회 결과에서 null 관계로 확인된다는 점이다.


핵심 정리

MemberTeamTest4.java와 MemberTeamTest4_1.java는 팀과 락커가 없는 회원을 저장하고 전체 조회 결과로 확인하는 예제이다.
연관관계가 항상 존재하는 것이 아니라, 필요에 따라 null일 수 있음을 보여 준다.


정리하면 아래와 같다.

  • MemberTeamTest4.java는 팀과 락커가 없는 회원을 저장한다.
  • new Member("토토로", null, null)에서 첫 번째 null은 팀이 없다는 뜻이다.
  • new Member("토토로", null, null)에서 두 번째 null은 락커가 없다는 뜻이다.
  • persist()로 회원을 저장 대상으로 만든다.
  • commit()으로 데이터베이스에 반영한다.
  • 객체의 team이 null이면 TEAM_ID가 비어 있는 값으로 저장될 수 있다.
  • 객체의 locker가 null이면 LOCKER_ID가 비어 있는 값으로 저장될 수 있다.
  • MemberTeamTest4_1.java는 전체 회원을 조회한다.
  • 전체 조회 결과에서 토토로, 듀크가 team=null, locker=null로 보일 수 있다.
  • 이 데이터는 뒤에서 is null 조건 조회와 삭제 예제의 준비 데이터가 된다.

연관관계가 없는 데이터도 JPA에서 다룰 수 있으며, 이때 객체에서는 null, 테이블에서는 비어 있는 외래키 값으로 관계 없음이 표현된다.




응용예제 21. 멤버명을 입력받아 팀명만 조회하기 (MemberTeamTest5.java, Member.java, Team.java)

MemberTeamTest5.java는 사용자가 입력한 멤버명을 기준으로 해당 멤버의 팀명만 조회하는 예제이다.
앞 예제에서는 Member 전체를 조회하거나, 필요한 여러 필드를 Object[]로 조회하는 흐름을 확인했다.
이번 예제에서는 Member 전체도 아니고 여러 필드도 아니라, Member가 참조하는 Team의 name 값 하나만 조회한다.


이 예제에서 중요한 점은 조회 결과 타입이 Member가 아니라 String이라는 점이다.
JPQL에서 select m.team.name처럼 팀명 하나만 선택하기 때문에, 결과도 팀명 문자열 하나로 받는다.


이 예제의 핵심은 Member에서 Team으로 연관관계를 따라간 뒤, Team의 name 값 하나만 조회하는 흐름을 이해하는 것이다.


이전 기본개념과 연결하기

앞에서 Member는 Team을 참조할 수 있다고 정리했다.
객체 코드에서는 Member 안에 Team team 필드가 있고, 데이터베이스에서는 membertbl 테이블의 TEAM_ID 외래키로 관계가 저장된다.


앞 예제들에서는 이 연관관계를 여러 방식으로 사용했다.
MemberTeamTest2.java에서는 m.team.name을 조건으로 사용해서 특정 팀에 속한 회원 목록을 조회했다.
MemberTeamTest3.java에서는 Member 객체 전체를 조회한 뒤 dto.getTeam().getName()으로 팀명을 꺼냈다.
MemberTeamTest3_1.java에서는 m.username, m.team.name, m.locker.name처럼 필요한 필드 여러 개를 직접 조회했다.


이번 예제는 그보다 더 좁게 본다.
입력한 멤버명에 해당하는 Member를 찾고, 그 멤버가 속한 팀 이름 하나만 조회한다.
그래서 select 절에는 m.team.name만 작성한다.

기본개념에서 다시 잡아야 할 흐름

  • Member는 Team을 참조한다.
  • m.username은 Member 자신의 회원명 필드이다.
  • m.team은 Member가 참조하는 Team 객체이다.
  • m.team.name은 그 Team 객체의 팀명이다.
  • select m.team.name은 팀명 값 하나만 조회한다.
  • 조회 결과가 팀명 하나이므로 결과 타입은 String이다.
  • 결과가 없으면 NoResultException이 발생할 수 있다.

이번 예제는 Member 객체를 가져오는 예제가 아니라, 연관 객체의 필드 값 하나만 가져오는 예제이다.


예제의 목표

이 예제의 목표는 멤버명을 입력받아 그 멤버가 속한 팀명을 출력하는 것이다.
예를 들어 둘리를 입력하면 둘리라는 회원을 찾고, 그 회원이 참조하는 Team의 name 값을 출력한다.


조회 조건은 m.username = :un이다.
즉, 입력값은 Member의 username과 비교된다.
하지만 출력되는 값은 Member의 username이 아니라 m.team.name이다.


따라서 이 예제는 조회 조건과 조회 결과가 서로 다르다.
조건은 회원명이고, 결과는 팀명이다.

이 예제에서 확인할 내용

  • 사용자에게 멤버명을 입력받는다.
  • 입력받은 값을 JPQL 파라미터에 연결한다.
  • m.username = :un 조건으로 해당 멤버를 찾는다.
  • select m.team.name으로 팀명 하나만 조회한다.
  • 조회 결과를 TypedQuery<String>으로 받는다.
  • 결과가 있으면 팀명을 출력한다.
  • 결과가 없으면 NoResultException을 처리한다.

이 예제는 “회원명을 조건으로 사용하고, 팀명을 결과로 받는다”는 구조를 정확히 구분해야 한다.


코드 흐름

MemberTeamTest5.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 사용자에게 멤버명을 입력받고, 입력값을 JPQL의 파라미터에 연결한다.
조회 결과가 있으면 팀명을 출력하고, 결과가 없으면 팀을 찾을 수 없다는 메시지를 출력한다.


핵심 코드는 아래와 같다.

// MemberTeamTest5.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

Scanner scan = new Scanner(System.in); // 입력 도구 생성
System.out.print("멤버명을 입력하세요 : "); // 입력 안내 출력
String inputName = scan.nextLine(); // 멤버명 입력
scan.close(); // 입력 도구 닫기

String jpql = "select m.team.name from Member m where m.username = :un"; // 회원명으로 팀명 조회

TypedQuery<String> q = em.createQuery(jpql, String.class); // 팀명 하나를 String으로 조회
q.setParameter("un", inputName); // un 파라미터에 입력값 연결

String teamName; // 조회한 팀명을 담을 변수

try {
    teamName = q.getSingleResult(); // 팀명 한 건 조회
    System.out.printf("%s님의 팀명은 %s입니다...\n", inputName, teamName); // 조회 성공 출력
} catch (NoResultException e) {
    System.out.printf("%s님의 팀을 찾을 수 없네요..ㅜㅜ \n", inputName); // 조회 결과 없음 출력
    e.printStackTrace(); // 예외 내용 출력
}

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

이 코드에서 inputName은 사용자가 입력한 멤버명이다.
setParameter("un", inputName)은 JPQL 안의 :un 자리에 입력한 멤버명을 연결한다.


조회 결과는 String 타입의 teamName으로 받는다.
왜냐하면 select m.team.name은 Team 객체 전체를 조회하는 것이 아니라, 팀명 값 하나만 조회하기 때문이다.


select 절에서 무엇을 조회하느냐에 따라 createQuery()의 결과 타입도 달라진다.


select m.team.name은 팀명 하나만 조회한다

이 예제에서 가장 중요한 JPQL은 아래 문장이다.

// MemberTeamTest5.java
String jpql = "select m.team.name from Member m where m.username = :un"; // 팀명 하나만 조회

from Member m은 Member 엔티티를 조회 대상으로 사용한다는 뜻이다.
여기서 m은 Member를 가리키는 별칭이다.


where m.username = :un은 조건이다.
Member의 username 값이 입력값과 같은 데이터를 찾는다.


select m.team.name은 조회 결과이다.
조건에 맞는 Member를 찾은 뒤, 그 Member가 참조하는 Team으로 이동하고, 그 Team의 name 값을 가져온다.


나누어 읽으면 아래와 같다.

  • m은 Member이다.
  • m.username은 Member의 회원명이다.
  • m.team은 Member가 참조하는 Team이다.
  • m.team.name은 그 Team의 팀명이다.
  • :un은 사용자가 입력한 멤버명이 들어갈 자리이다.

즉, 이 문장은 “입력한 멤버명을 가진 회원을 찾고, 그 회원의 팀명을 조회하라”는 뜻이다.


m.team.name은 Member에서 Team으로 이동한 뒤, 팀 이름 값만 꺼내는 표현이다.


결과 타입은 TypedQuery<String>이다

앞 예제에서 select m from Member m을 사용했을 때는 결과를 Member 객체로 받았다.
하지만 이번 예제는 select m.team.name을 사용한다.
이 결과는 팀명 문자열 하나이다.


그래서 쿼리 타입도 아래처럼 작성한다.

// MemberTeamTest5.java
TypedQuery<String> q = em.createQuery(jpql, String.class); // String 결과를 받는 TypedQuery

TypedQuery<String>은 조회 결과가 String이라고 미리 정해 둔 쿼리이다.
여기서는 팀명 하나가 문자열로 나오기 때문에 String.class를 사용한다.


만약 select m from Member m이었다면 TypedQuery<Member>가 맞다.
하지만 지금은 Member 객체 전체가 아니라 팀명 하나만 조회하므로 TypedQuery<String>이 맞다.

결과 타입 비교

  • select m: Member 객체 전체를 조회하므로 TypedQuery<Member>를 사용한다.
  • select m.team: Team 객체를 조회하므로 TypedQuery<Team>을 사용할 수 있다.
  • select m.team.name: 팀명 문자열을 조회하므로 TypedQuery<String>을 사용한다.

JPQL의 select 대상과 TypedQuery의 타입은 서로 맞아야 한다.


파라미터 바인딩으로 입력한 멤버명을 조건에 연결한다

사용자가 입력한 멤버명은 JPQL 문자열에 직접 붙이지 않는다.
대신 :un이라는 파라미터 자리를 만들고, setParameter()로 입력값을 연결한다.


핵심 코드는 아래와 같다.

// MemberTeamTest5.java
q.setParameter("un", inputName); // un 파라미터에 입력한 멤버명 연결

:un은 값이 들어갈 자리이다.
setParameter("un", inputName)은 :un 자리에 사용자가 입력한 멤버명을 넣는다.


파라미터 이름은 반드시 맞아야 한다.
JPQL에서 :un을 사용했다면 setParameter("un", ...)처럼 같은 이름을 사용해야 한다.

파라미터 바인딩에서 봐야 할 기준

  • :un은 값이 들어갈 자리이다.
  • setParameter("un", inputName)은 :un에 입력값을 연결한다.
  • un이라는 이름은 JPQL과 setParameter()에서 같아야 한다.
  • 입력값은 m.username과 비교된다.

파라미터 이름이 다르면 JPQL이 어떤 값으로 조건을 비교해야 하는지 알 수 없으므로 오류가 날 수 있다.


getSingleResult는 팀명 한 건을 기대할 때 사용한다

이 예제에서는 getSingleResult()를 사용한다.
입력한 멤버명에 해당하는 팀명 하나를 기대하기 때문이다.


핵심 코드는 아래와 같다.

// MemberTeamTest5.java
teamName = q.getSingleResult(); // 팀명 한 건 조회

getSingleResult()는 결과가 한 건이라고 기대할 때 사용하는 메서드이다.
여기서는 결과가 Member 객체 한 건이 아니라, 팀명 문자열 한 건이다.


따라서 teamName 변수의 타입은 String이다.
조회에 성공하면 teamName에는 해당 멤버의 팀명이 들어간다.


하지만 조건에 맞는 결과가 없으면 NoResultException이 발생할 수 있다.
그래서 이 예제는 try-catch로 결과 없음 상황을 처리한다.

// MemberTeamTest5.java
try {
    teamName = q.getSingleResult(); // 정상 조회
    System.out.printf("%s님의 팀명은 %s입니다...\n", inputName, teamName); // 성공 메시지
} catch (NoResultException e) {
    System.out.printf("%s님의 팀을 찾을 수 없네요..ㅜㅜ \n", inputName); // 실패 메시지
}

try 안에는 정상 조회 흐름이 들어간다.
catch 안에는 조회 결과가 없을 때의 처리 흐름이 들어간다.


getSingleResult()는 결과 한 건을 기대하는 메서드이므로, 결과가 없을 때의 예외 처리까지 함께 봐야 한다.


팀이 없는 멤버도 결과가 없다고 처리될 수 있다

이 예제에서 조심해야 할 부분은 결과 없음의 의미이다.
NoResultException이 발생했다고 해서 항상 “회원 자체가 없다”는 뜻은 아니다.


현재 JPQL은 select m.team.name이다.
즉, 회원을 찾는 것뿐 아니라 그 회원의 팀명까지 가져오려고 한다.


만약 입력한 멤버가 실제로 존재하더라도 team이 null이면 m.team.name으로 이동할 팀 객체가 없다.
이 경우 조회 결과가 없다고 처리될 수 있다.


예를 들어 토토로가 new Member("토토로", null, null)로 저장되어 있다면, 회원 데이터는 존재한다.
하지만 팀이 없기 때문에 m.team.name 값을 가져올 수 없다.
이때 토토로님의 팀을 찾을 수 없네요..ㅜㅜ 같은 메시지가 나올 수 있다.


따라서 이 예제의 실패 메시지는 두 가지 가능성을 가진다.

  • 입력한 멤버명이 실제로 존재하지 않을 수 있다.
  • 입력한 멤버는 존재하지만 team 연관관계가 없을 수 있다.

이 차이를 분명히 이해해야 한다.
회원 존재 여부를 정확히 확인하려면 select m from Member m where m.username = :un처럼 먼저 Member 전체를 조회한 뒤, team이 null인지 확인하는 방식이 더 안전할 수 있다.


select m.team.name에서 결과가 없다는 것은 회원이 없다는 뜻일 수도 있고, 회원은 있지만 팀 관계가 없다는 뜻일 수도 있다.


실행 결과는 팀명이 있는 멤버와 없는 멤버로 나누어 확인한다

이 예제는 입력한 멤버명에 따라 결과가 달라진다.
팀이 연결된 멤버를 입력하면 팀명이 출력된다.
팀이 없거나 존재하지 않는 멤버를 입력하면 팀을 찾을 수 없다는 메시지가 출력된다.


예상 결과 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 멤버명을 입력하세요 : 둘리
// 둘리님의 팀명은 아기공룡둘리입니다...

팀이 없거나 존재하지 않는 멤버를 입력하면 아래처럼 출력될 수 있다.

// 출력결과
// 멤버명을 입력하세요 : 토토로
// 토토로님의 팀을 찾을 수 없네요..ㅜㅜ

여기서 토토로가 실제로 저장되어 있다면 이 결과는 회원이 없다는 뜻이 아니다.
팀 연관관계가 없어서 m.team.name 값을 가져오지 못한 결과로 볼 수 있다.


MemberTeamTest5.java 실행 결과이다.
먼저 둘리를 입력하면 Member.username이 일치하는 회원을 찾고, 그 회원이 참조하는 Team의 name 값만 조회해서 팀명을 출력한다.
이때 조회 결과는 Member 객체가 아니라 팀명 문자열 하나이므로 TypedQuery<String>으로 받는다.


반대로 토토로를 입력하면 팀을 찾을 수 없다는 메시지가 출력된다.
토토로는 회원 데이터가 존재하더라도 team이 null이면 m.team.name 값을 가져올 수 없기 때문이다.
따라서 이 결과는 단순히 회원이 없다는 뜻이 아니라, 회원은 있어도 팀 연관관계가 없을 수 있다는 뜻으로 해석해야 한다.


아래에 예외 로그가 함께 출력되는 이유는 catch 구문 안에서 e.printStackTrace()를 호출했기 때문이다.
즉, 안내 메시지와 함께 예외 내용을 콘솔에 출력하도록 코드가 작성되어 있기 때문이다.


실행 결과를 볼 때는 “멤버가 없는 것”과 “멤버는 있지만 팀이 없는 것”을 구분해서 해석해야 한다.


MemberTeamTest3과 MemberTeamTest5의 차이

MemberTeamTest3.java와 MemberTeamTest5.java는 모두 멤버명을 입력받는다.
하지만 조회 방식과 결과 타입이 다르다.


MemberTeamTest3.java는 select m from Member m으로 Member 객체 전체를 조회한다.
조회된 Member 객체에서 getTeam().getName()과 getLocker().getName()으로 연관 객체의 값을 꺼낸다.


MemberTeamTest5.java는 select m.team.name으로 팀명 하나만 조회한다.
따라서 Member 객체를 받지 않고 String 타입으로 팀명만 받는다.


차이를 정리하면 아래와 같다.

  • MemberTeamTest3.java: Member 전체를 조회한다.
  • MemberTeamTest3.java: 결과 타입은 Member이다.
  • MemberTeamTest3.java: 조회 후 객체 참조를 따라가 팀명과 락커명을 꺼낸다.
  • MemberTeamTest5.java: 팀명 하나만 직접 조회한다.
  • MemberTeamTest5.java: 결과 타입은 String이다.
  • MemberTeamTest5.java: TypedQuery<String>을 사용한다.
  • MemberTeamTest5.java: 팀이 없는 멤버는 결과가 없다고 처리될 수 있다.

Member 전체를 조회할지, 팀명 값 하나만 조회할지에 따라 결과 타입과 예외 해석이 달라진다.


핵심 정리

MemberTeamTest5.java는 멤버명을 입력받아 해당 멤버의 팀명만 조회하는 예제이다.
Member 전체를 조회하지 않고 m.team.name 값 하나만 조회하기 때문에 결과 타입은 String이다.


정리하면 아래와 같다.

  • MemberTeamTest5.java는 멤버명을 입력받는다.
  • 입력값은 m.username과 비교된다.
  • select m.team.name은 멤버가 참조하는 팀의 이름만 조회한다.
  • 결과 타입은 String이다.
  • 그래서 TypedQuery<String>을 사용한다.
  • getSingleResult()로 팀명 한 건을 조회한다.
  • 결과가 없으면 NoResultException이 발생할 수 있다.
  • 결과 없음은 회원 자체가 없다는 뜻일 수도 있다.
  • 결과 없음은 회원은 있지만 team이 null이라는 뜻일 수도 있다.
  • e.printStackTrace()가 있으면 결과 없음 상황에서 예외 로그도 함께 출력될 수 있다.
  • 회원 존재 여부와 팀 존재 여부를 구분하려면 Member 전체 조회 후 team을 확인하는 방식이 더 명확할 수 있다.

이 예제는 연관 객체 전체가 아니라 연관 객체의 필드 값 하나만 직접 조회할 때 결과 타입이 어떻게 달라지는지 보여 준다.




응용예제 22. team이 null인 Member 조회하고 삭제하기 (MemberTeamTest6.java, Member.java)

MemberTeamTest6.java는 team 연관관계가 없는 회원을 조회하고 삭제하는 예제이다.
앞 예제에서는 MemberTeamTest4.java에서 토토로, 듀크처럼 Team과 Locker를 연결하지 않은 회원을 저장했다.
이번 예제에서는 team이 null인 회원을 찾아 삭제한다.


이 예제에서 중요한 점은 null도 조회 조건으로 사용할 수 있다는 점이다.
일반 값 비교처럼 =를 사용하는 것이 아니라, 값이 없는 상태를 찾을 때는 is null을 사용한다.


이 예제의 핵심은 연관관계가 없는 데이터를 m.team is null 조건으로 조회하고, 조회된 엔티티를 remove()로 삭제하는 흐름을 이해하는 것이다.


이전 기본개념과 연결하기

앞 예제에서 Member는 Team과 Locker를 참조할 수 있다고 정리했다.
하지만 모든 회원이 반드시 팀과 락커를 가지는 것은 아니었다.
new Member("토토로", null, null)처럼 생성하면 team과 locker가 없는 회원을 저장할 수 있었다.


객체 코드에서는 team=null, locker=null로 보이고, 데이터베이스에서는 TEAM_ID, LOCKER_ID 외래키 컬럼이 비어 있는 값으로 저장될 수 있다.
즉, 연관관계가 없다는 상태도 하나의 데이터 상태이다.


이번 예제는 그 상태를 조회 조건으로 사용한다.
팀이 없는 회원만 찾기 위해 m.team is null을 사용한다.
그리고 조회된 회원 객체를 remove()로 삭제한다.

기본개념에서 다시 잡아야 할 흐름

  • team은 Member가 참조하는 Team 연관 필드이다.
  • team=null이면 소속 팀이 없다는 뜻이다.
  • 값이 없는 데이터를 찾을 때는 is null을 사용한다.
  • m.team is null은 팀이 없는 회원을 찾는 조건이다.
  • 삭제는 조회된 엔티티 객체를 대상으로 remove()를 호출한다.
  • 삭제 작업도 데이터 변경이므로 트랜잭션 안에서 실행해야 한다.
  • commit()이 실행되어야 삭제 결과가 데이터베이스에 반영된다.

연관관계가 없는 데이터를 다룰 때는 null 상태를 조회 조건과 삭제 기준으로 사용할 수 있다.


예제의 목표

이 예제의 목표는 팀이 없는 회원을 찾아 삭제하는 것이다.
20번에서 저장한 토토로, 듀크는 team=null, locker=null 상태였다.
이번 예제에서는 team이 null인 회원을 조회한다.


다만 m.team is null은 이름이 토토로, 듀크인 회원만 찾는 조건이 아니다.
이 조건은 팀 연관관계가 없는 모든 Member를 조회한다.
따라서 현재 데이터에서 팀이 없는 회원이 토토로, 듀크뿐이면 두 회원이 삭제 대상이 된다.


조회 결과가 있으면 전체 개수를 먼저 출력한다.
그다음 stream().forEach()로 조회된 회원을 하나씩 처리하면서 remove()를 호출한다.
마지막으로 트랜잭션을 commit()하면 삭제 내용이 데이터베이스에 반영된다.

이 예제에서 확인할 내용

  • 트랜잭션을 시작한다.
  • m.team is null 조건으로 팀이 없는 회원을 조회한다.
  • 조회 결과를 List<Member>로 받는다.
  • 팀이 없는 회원 수를 출력한다.
  • 조회된 회원이 있으면 stream().forEach()로 하나씩 삭제한다.
  • remove()로 조회된 회원을 삭제한다.
  • commit()으로 삭제를 확정한다.
  • 삭제 후 다시 조회하면 해당 회원들이 사라질 수 있음을 이해한다.

이 예제는 null 조건으로 대상을 찾고, 찾은 엔티티를 삭제하는 흐름을 확인하는 예제이다.


코드 흐름

MemberTeamTest6.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 트랜잭션을 시작하고, team이 null인 회원을 조회한다.
조회된 회원 수를 출력한 뒤, 조회 결과가 있으면 remove()로 삭제한다.


핵심 코드는 아래와 같다.

// MemberTeamTest6.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

em.getTransaction().begin(); // 트랜잭션 시작

String jpql = "select m from Member m where m.team is null"; // 팀이 없는 회원 조회

TypedQuery<Member> q = em.createQuery(jpql, Member.class); // Member 타입 쿼리 생성
List<Member> list = q.getResultList(); // 조회 결과 목록으로 받기

System.out.println("팀이 설정되지 않은 회원 명수 : " + list.size() + "명!!"); // 조회된 회원 수 출력

if (list.size() > 0) { // 삭제 대상이 있으면
    list.stream().forEach(x -> { // 조회된 회원을 하나씩 처리
        em.remove(x); // 현재 회원 삭제
        System.out.println("삭제함"); // 삭제 처리 메시지 출력
    });
}

System.out.println("데이터 삭제~~ "); // 삭제 흐름 안내 출력

em.getTransaction().commit(); // 삭제 확정

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

이 코드는 team이 없는 회원을 찾아 삭제한다.
select m from Member m where m.team is null은 Member 중에서 team 참조가 없는 데이터만 조회한다.


조회 결과는 여러 명일 수 있으므로 getResultList()로 받는다.
그리고 list.size()로 삭제 대상이 몇 명인지 먼저 출력한다.
삭제 대상이 있으면 stream().forEach()가 목록의 회원을 하나씩 꺼내고, 각 회원에 대해 em.remove(x)를 실행한다.


삭제할 대상을 먼저 조회하고, 조회된 엔티티 객체를 remove()에 전달해야 한다.


is null은 값이 없는 데이터를 찾는 조건이다

null은 값이 없다는 뜻이다.
일반 값처럼 = null로 비교하지 않는다.
값이 없는 상태를 조회할 때는 is null을 사용한다.


이번 예제의 핵심 조건은 아래와 같다.

// MemberTeamTest6.java
String jpql = "select m from Member m where m.team is null"; // team이 없는 회원 조회

m은 Member를 가리킨다.
m.team은 Member가 참조하는 Team이다.
m.team is null은 Member가 참조하는 Team이 없다는 뜻이다.


즉, 이 조건은 “팀이 연결되지 않은 회원을 조회하라”는 의미이다.
앞에서 저장한 토토로, 듀크처럼 new Member("토토로", null, null)로 만든 회원이 조회 대상이 될 수 있다.


하지만 이 조건은 특정 이름을 기준으로 찾는 조건이 아니다.
username이 토토로인지 듀크인지 비교하지 않는다.
오직 team 연관관계가 없는지를 기준으로 조회한다.

is null 조건에서 봐야 할 기준

  • null은 값이 없는 상태이다.
  • = null이 아니라 is null을 사용한다.
  • m.team is null은 팀이 없는 회원을 찾는 조건이다.
  • 연관 필드도 null 조건으로 조회할 수 있다.
  • 이 조건은 팀이 없는 모든 회원을 조회한다.

null 여부를 조건으로 조회할 때는 =가 아니라 is null을 사용해야 한다.


getResultList로 삭제 대상 목록을 받는다

m.team is null 조건에 맞는 회원은 한 명일 수도 있고, 여러 명일 수도 있다.
그래서 이 예제는 getSingleResult()를 사용하지 않는다.
여러 결과를 받을 수 있는 getResultList()를 사용한다.


핵심 코드는 아래와 같다.

// MemberTeamTest6.java
List<Member> list = q.getResultList(); // 팀이 없는 회원 목록 조회

System.out.println("팀이 설정되지 않은 회원 명수 : " + list.size() + "명!!"); // 조회 개수 출력

getResultList()는 조건에 맞는 결과를 목록으로 반환한다.
조건에 맞는 회원이 없다면 빈 목록이 반환될 수 있다.


list.size()는 목록에 들어 있는 데이터 개수를 반환한다.
따라서 이 출력은 팀이 없는 회원이 몇 명인지 확인하는 역할을 한다.


예를 들어 토토로, 듀크만 팀이 없다면 2명으로 출력될 수 있다.
반대로 이미 삭제해서 대상이 없다면 0명으로 출력될 수 있다.


삭제하기 전에 list.size()로 삭제 대상이 몇 명인지 확인하면 현재 데이터 상태를 먼저 파악할 수 있다.


remove는 조회된 엔티티를 삭제한다

remove()는 엔티티를 삭제할 때 사용하는 메서드이다.
이 예제에서는 team이 null인 회원을 먼저 조회한 뒤, 그 회원 객체를 remove()에 넘긴다.


핵심 코드는 아래와 같다.

// MemberTeamTest6.java
if (list.size() > 0) { // 삭제 대상이 있으면
    list.stream().forEach(x -> { // 조회된 회원을 하나씩 꺼냄
        em.remove(x); // 현재 회원 삭제
        System.out.println("삭제함"); // 삭제 처리 확인
    });
}

list에는 m.team is null 조건에 맞는 Member 객체들이 들어 있다.
stream().forEach()는 그 객체들을 하나씩 처리한다.


x는 현재 처리 중인 Member 객체 하나이다.
em.remove(x)는 현재 회원을 삭제 대상으로 만든다.
이때 x는 방금 조회된 엔티티이므로 JPA가 관리하는 상태이다.
관리되는 엔티티를 삭제하는 흐름이므로 remove()를 사용할 수 있다.


System.out.println("삭제함")은 실제 삭제 대상마다 한 번씩 출력된다.
따라서 삭제 대상이 두 명이면 삭제함도 두 번 출력될 수 있다.


remove()는 삭제할 엔티티 객체를 대상으로 호출하며, 실제 삭제 반영은 트랜잭션 commit() 시점에 이루어진다.


삭제 작업은 트랜잭션 안에서 실행해야 한다

조회만 하는 작업은 트랜잭션이 없어도 실행될 수 있다.
하지만 삭제는 데이터베이스 내용을 바꾸는 작업이다.
그래서 트랜잭션 안에서 처리해야 한다.


이번 예제도 아래 흐름으로 진행된다.

// MemberTeamTest6.java
em.getTransaction().begin(); // 트랜잭션 시작

// 삭제 대상 조회
// remove()로 삭제

em.getTransaction().commit(); // 삭제 반영

begin()은 트랜잭션을 시작한다.
이후 실행되는 삭제 작업은 이 트랜잭션 안에서 관리된다.


commit()은 삭제 내용을 데이터베이스에 반영한다.
remove()를 호출했다고 해서 곧바로 최종 삭제가 끝난 것으로 보면 안 된다.
삭제 확정은 commit() 시점에 이루어진다.


만약 삭제 중 오류가 발생할 수 있는 코드라면 try-catch와 rollback()으로 되돌리는 흐름을 넣을 수도 있다.
이 예제에서는 핵심 흐름을 보기 위해 삭제 대상 조회, 삭제, 커밋 흐름에 집중한다.


삭제처럼 데이터를 변경하는 작업은 begin()과 commit() 사이에서 실행해야 한다.


실행 결과는 삭제 대상 수와 delete SQL로 확인한다

이 예제를 실행하면 먼저 team이 null인 회원 수가 출력된다.
그다음 삭제 대상이 있으면 삭제함 메시지가 출력되고, Hibernate 로그에서 delete 문이 실행되는 것을 확인할 수 있다.


예상 결과 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 팀이 설정되지 않은 회원 명수 : 2명!!
// 삭제함
// 삭제함
// 데이터 삭제~~
// SQL 로그 예시
// delete from membertbl where MEMBER_ID=?
// delete from membertbl where MEMBER_ID=?

출력 결과에서 회원 명수가 2명으로 보이면 팀이 없는 회원 두 명이 조회된 것이다.
삭제함이 두 번 출력되면 두 회원에 대해 remove()가 실행된 것이다.
delete 로그가 보이면 해당 회원들이 삭제 대상으로 처리된 것이다.


이미 MemberTeamTest6.java를 실행해 삭제한 뒤 다시 실행하면 조회 결과가 0명일 수 있다.
이때 delete SQL이 나오지 않는 것은 오류가 아니다.
삭제할 대상이 이미 사라졌기 때문이다.
다시 확인하려면 MemberTeamTest4.java로 토토로, 듀크를 다시 저장한 뒤 실행해야 한다.


MemberTeamTest6.java 실행 전 MemberTeamTest4_1.java로 전체 회원 목록을 조회한 결과이다.
이 시점에는 토토로, 듀크가 team=null, locker=null 상태로 존재한다.
즉, 두 회원은 팀과 락커 연관관계가 없는 삭제 대상 데이터이다.


MemberTeamTest6.java 실행 결과이다.
m.team is null 조건으로 팀이 설정되지 않은 회원을 조회하면 삭제 대상 수가 2명으로 출력된다.
그다음 stream().forEach() 안에서 remove()가 실행되면서 삭제함 메시지가 두 번 출력된다.


마지막에 delete from membertbl SQL이 두 번 실행되는 것을 보면, 조회된 두 회원이 실제 삭제 대상으로 처리되었음을 확인할 수 있다.


MemberTeamTest6.java 실행 후 MemberTeamTest4_1.java를 다시 실행한 결과이다.
전체 회원 목록을 다시 조회하면 앞에서 삭제된 토토로, 듀크가 더 이상 출력되지 않는다.
이 결과로 remove()와 commit()을 통해 삭제가 데이터베이스에 반영되었음을 확인할 수 있다.


실행 결과에서는 삭제 대상 수가 몇 명인지, remove()가 몇 번 실행되는지, 그리고 delete SQL이 실행되는지를 함께 확인해야 한다.


삭제 후에는 전체 조회 결과가 달라질 수 있다

MemberTeamTest6.java를 실행해 team이 null인 회원을 삭제하면, 이후 전체 조회 결과가 달라진다.


예를 들어 MemberTeamTest4_1.java를 다시 실행하면, 이전에 보였던 토토로, 듀크가 더 이상 보이지 않을 수 있다.
삭제가 commit()으로 반영되었기 때문이다.


이 흐름은 데이터 변경 작업을 확인할 때 중요하다.
삭제 전에는 전체 조회에서 team=null, locker=null 회원이 보인다.
삭제 후에는 같은 전체 조회를 실행해도 그 회원들이 사라질 수 있다.


확인 흐름은 아래처럼 볼 수 있다.

  • MemberTeamTest4_1.java 실행: 토토로, 듀크가 보인다.
  • MemberTeamTest6.java 실행: 토토로, 듀크를 삭제한다.
  • 다시 MemberTeamTest4_1.java 실행: 토토로, 듀크가 사라졌는지 확인한다.

이렇게 보면 삭제 예제의 결과를 더 정확하게 확인할 수 있다.
단순히 delete 로그만 보는 것보다, 삭제 전후 전체 조회 결과를 비교하면 데이터가 실제로 사라졌는지 이해하기 쉽다.


삭제 결과는 delete 로그뿐 아니라, 삭제 후 다시 조회했을 때 대상 데이터가 사라졌는지까지 확인하면 더 명확하다.


재실행할 때는 데이터 상태를 먼저 확인해야 한다

MemberTeamTest6.java는 삭제 예제이다.
따라서 한 번 실행하면 조건에 맞는 데이터가 실제로 삭제된다.


그래서 같은 예제를 다시 실행했을 때 처음과 같은 결과가 나오지 않을 수 있다.
처음 실행에서는 팀이 없는 회원 수가 2명처럼 출력되고 delete SQL이 실행될 수 있다.
하지만 두 번째 실행에서는 이미 삭제된 상태라서 조회 결과가 0명일 수 있다.


이것은 코드가 잘못된 것이 아니다.
데이터베이스 상태가 바뀌었기 때문에 결과가 달라진 것이다.


다시 같은 결과를 보고 싶다면 아래 흐름으로 진행하면 된다.

  • MemberTeamTest4.java를 실행해서 토토로, 듀크를 다시 저장한다.
  • MemberTeamTest4_1.java로 team=null, locker=null 상태를 확인한다.
  • MemberTeamTest6.java를 실행해서 다시 삭제 흐름을 확인한다.

삭제 예제는 실행할 때마다 데이터 상태가 바뀌므로, 재실행 전에는 삭제할 대상 데이터가 실제로 있는지 먼저 확인해야 한다.


앞 예제와의 연결 흐름

MemberTeamTest4.java, MemberTeamTest4_1.java, MemberTeamTest6.java는 하나의 흐름으로 이어진다.


먼저 MemberTeamTest4.java에서 팀과 락커가 없는 회원을 저장한다.
그다음 MemberTeamTest4_1.java에서 전체 회원 목록을 조회해 team=null, locker=null 상태를 확인한다.
마지막으로 MemberTeamTest6.java에서 m.team is null 조건으로 해당 회원들을 찾아 삭제한다.


흐름을 정리하면 아래와 같다.

  • MemberTeamTest4.java: 토토로, 듀크를 team=null, locker=null 상태로 저장한다.
  • MemberTeamTest4_1.java: 전체 회원 목록에서 토토로, 듀크의 null 관계를 확인한다.
  • MemberTeamTest6.java: m.team is null 조건으로 팀이 없는 회원을 조회한다.
  • MemberTeamTest6.java: 조회된 회원을 remove()로 삭제한다.

단, MemberTeamTest6.java는 이름으로 토토로, 듀크만 고르는 예제가 아니다.
팀이 없는 모든 회원을 삭제 대상으로 삼는다.
따라서 실제 데이터에 팀이 없는 다른 회원이 있다면 그 회원도 함께 조회되고 삭제될 수 있다.


22번은 20번에서 만든 null 관계 데이터를 조건 조회와 삭제에 활용하는 예제이다.


핵심 정리

MemberTeamTest6.java는 team이 null인 회원을 조회하고 삭제하는 예제이다.
앞 예제에서 만든 토토로, 듀크 같은 데이터를 대상으로 is null 조건과 remove() 동작을 확인한다.


정리하면 아래와 같다.

  • m.team is null은 팀이 없는 회원을 찾는 조건이다.
  • m.team is null은 토토로, 듀크라는 이름만 찾는 조건이 아니다.
  • 데이터에 팀이 없는 다른 회원이 있으면 그 회원도 삭제 대상이 될 수 있다.
  • null 값을 비교할 때는 = null이 아니라 is null을 사용한다.
  • 조회 결과는 여러 명일 수 있으므로 getResultList()를 사용한다.
  • list.size()로 삭제 대상 수를 확인한다.
  • 조회된 회원은 stream().forEach()로 하나씩 처리한다.
  • em.remove(x)는 현재 회원을 삭제 대상으로 만든다.
  • 삭제는 데이터 변경 작업이므로 트랜잭션 안에서 실행한다.
  • commit()이 실행되어야 삭제가 데이터베이스에 반영된다.
  • 실행 결과는 삭제 대상 수, 삭제함 메시지, delete SQL로 확인할 수 있다.
  • 이미 삭제한 뒤 다시 실행하면 결과가 0명일 수 있다.
  • 삭제 후 전체 조회를 다시 실행하면 대상 회원이 사라졌는지 확인할 수 있다.

is null 조건으로 연관관계가 없는 데이터를 찾고, 조회된 엔티티를 remove()로 삭제하는 흐름을 이해하면 JPA에서 조건 삭제 흐름을 더 쉽게 볼 수 있다.




응용예제 23. find, Native SQL, JPQL 조회 방식 비교하기 (MemberTeamTest7.java, Member.java)

MemberTeamTest7.java는 하나의 파일 안에서 여러 조회 방식을 비교하는 예제이다.
앞 예제들에서는 JPQL을 사용해 Member 전체를 조회하거나, 특정 필드만 조회하거나, 조건에 맞는 데이터를 삭제했다.
이번 예제에서는 find(), Native SQL, JPQL을 한 번에 실행하면서 조회 방식의 차이를 확인한다.


이 예제에서 중요한 점은 모두 Member 데이터를 조회하지만, 조회하는 방법과 결과 타입이 다르다는 점이다.
find()는 기본키로 Entity 하나를 조회한다.
Native SQL은 실제 테이블 이름과 컬럼명을 사용해 조회한다.
JPQL은 테이블이 아니라 Entity와 필드 이름을 기준으로 조회한다.


이 예제의 핵심은 같은 Member 데이터를 조회하더라도 find(), Native SQL, JPQL은 작성 방식과 결과 처리 방식이 다르다는 점을 구분하는 것이다.


이전 기본개념과 연결하기

앞 예제에서 Member는 데이터베이스의 membertbl 테이블과 매핑된 Entity라고 정리했다.
객체 코드에서는 Member라는 클래스 이름을 사용하지만, 실제 데이터베이스 테이블은 membertbl이다.


이 차이는 조회 방식을 비교할 때 중요하다.
JPQL은 데이터베이스 테이블명을 직접 쓰지 않고 Entity 이름인 Member를 사용한다.
반대로 Native SQL은 데이터베이스에 직접 보내는 SQL이기 때문에 실제 테이블명인 membertbl을 사용한다.


또 앞에서는 getResultList()로 여러 결과를 목록으로 받는 방식도 확인했다.
이번 예제에서도 전체 조회는 여러 결과가 나올 수 있으므로 List<Member> 또는 List<String>으로 받는다.

기본개념에서 다시 잡아야 할 흐름

  • Member는 JPA가 관리하는 Entity이다.
  • membertbl은 실제 데이터베이스 테이블명이다.
  • find()는 기본키로 Entity 하나를 조회한다.
  • Native SQL은 실제 테이블명과 컬럼명을 사용한다.
  • JPQL은 Entity 이름과 필드 이름을 사용한다.
  • 전체 조회 결과는 여러 개일 수 있으므로 List로 받는다.

JPQL은 객체 중심 조회이고, Native SQL은 데이터베이스 테이블 중심 조회이다.


예제의 목표

이 예제의 목표는 Member 데이터를 여러 방식으로 조회하면서 차이를 비교하는 것이다.
첫 번째는 em.find(Member.class, 2)로 기본키가 2인 회원을 조회한다.
두 번째는 Native SQL로 membertbl 전체를 조회하고, 결과를 Member 객체로 받는다.
세 번째는 Native SQL로 username 컬럼만 조회하고, 결과를 문자열 목록으로 받는다.
네 번째는 JPQL로 Member 전체를 조회한다.


즉, 이번 예제는 단순히 결과를 출력하는 예제가 아니다.
조회 방식마다 어떤 이름을 쓰는지, 결과 타입이 어떻게 달라지는지, 어떤 상황에서 사용할 수 있는지 비교하는 예제이다.

이 예제에서 확인할 내용

  • find()로 기본키 기준 단건 조회를 한다.
  • find()에서 사용하는 기본키 값은 실제 데이터베이스에 존재해야 한다.
  • createNativeQuery()로 실제 SQL을 실행한다.
  • select * from membertbl 결과를 Member 객체로 매핑한다.
  • select username from membertbl 결과를 String 목록으로 받는다.
  • createQuery()로 JPQL을 실행한다.
  • select m from Member m 결과를 Member 목록으로 받는다.

이번 예제는 조회 결과보다 조회 방식의 차이를 비교하는 것이 더 중요하다.


코드 흐름

MemberTeamTest7.java는 먼저 EntityManagerFactory와 EntityManager를 만든다.
그다음 네 가지 조회를 순서대로 실행한다.
각 조회 결과 사이에는 "=".repeat(50)을 출력해서 콘솔에서 결과 구간을 구분한다.


핵심 코드는 아래와 같다.

// MemberTeamTest7.java
EntityManagerFactory factory = Persistence.createEntityManagerFactory("entitytest"); // 설정 묶음으로 Factory 생성
EntityManager em = factory.createEntityManager(); // EntityManager 생성

Member m = em.find(Member.class, 2); // 기본키가 2인 Member 조회
System.out.println(m); // 조회 결과 출력
System.out.println("=".repeat(50)); // 구분선 출력

Query q = em.createNativeQuery("select * from membertbl order by username", Member.class); // Native SQL로 전체 조회
List<Member> list = q.getResultList(); // Member 목록으로 결과 받기

if (list.size() != 0) { // 조회 결과가 있으면
    list.stream().forEach(System.out::println); // 결과를 한 줄씩 출력
}

System.out.println("=".repeat(50)); // 구분선 출력

q = em.createNativeQuery("select username from membertbl order by username"); // Native SQL로 username만 조회
List<String> namelist = q.getResultList(); // 이름 목록으로 결과 받기

if (namelist.size() != 0) { // 조회 결과가 있으면
    namelist.stream().forEach(System.out::println); // 이름을 한 줄씩 출력
}

System.out.println("=".repeat(50)); // 구분선 출력

TypedQuery<Member> tq = em.createQuery("select m from Member m", Member.class); // JPQL로 Member 전체 조회
List<Member> empList = tq.getResultList(); // Member 목록으로 결과 받기

for (Member elem : empList) { // 조회 결과 반복
    System.out.println(elem); // Member 출력
}

em.close(); // EntityManager 닫기
factory.close(); // Factory 닫기

이 코드에서 entitytest를 사용하는 이유는 Member, Team, Locker가 persistence.xml의 entitytest 설정에 등록되어 있기 때문이다.
Member 계열 예제는 entitytest 기준으로 실행해야 한다.


이 예제는 조회 방식이 여러 개이므로, 각 코드 구간이 어떤 방식의 조회인지 먼저 나누어 읽어야 한다.


find는 기본키로 Entity 하나를 조회한다

첫 번째 조회는 find()이다.
find()는 Entity 클래스와 기본키 값을 전달해서 데이터 하나를 조회한다.


핵심 코드는 아래와 같다.

// MemberTeamTest7.java
Member m = em.find(Member.class, 2); // 기본키가 2인 Member 조회
System.out.println(m); // 조회 결과 출력

Member.class는 조회할 Entity 타입이다.
2는 기본키 값이다.
즉, 이 코드는 기본키가 2인 Member를 찾아오라는 뜻이다.


find()는 테이블명이나 JPQL 문자열을 직접 작성하지 않는다.
JPA가 Member 엔티티의 매핑 정보를 보고, 기본키가 2인 데이터를 조회한다.


다만 주석에 id는 알아서 입력이라고 되어 있는 이유를 생각해야 한다.
데이터베이스 상태에 따라 실제 존재하는 Member의 기본키 값은 달라질 수 있다.
따라서 실행할 때는 현재 테이블에 존재하는 MEMBER_ID 값을 넣어야 조회 결과가 나온다.


만약 해당 기본키 값이 존재하지 않으면 find()의 결과는 null이 될 수 있다.
이때 콘솔에 null이 출력되는 것은 find()가 실패해서 예외를 던진 것이 아니라, 해당 기본키를 가진 Member가 없어서 조회 결과가 없다는 뜻이다.
따라서 실행 전에 현재 membertbl에 존재하는 MEMBER_ID 값을 확인하고 넣어야 한다.

find에서 봐야 할 기준

  • find()는 기본키로 한 건을 조회한다.
  • 첫 번째 인자는 조회할 Entity 클래스이다.
  • 두 번째 인자는 기본키 값이다.
  • 기본키 값이 실제로 존재해야 결과가 나온다.
  • 기본키 값이 없으면 결과가 null일 수 있다.
  • 결과 타입은 조회한 Entity 타입이다.

find()는 조건문을 직접 작성하지 않고, 기본키 값으로 Entity 하나를 바로 조회할 때 사용한다.


find 결과가 null일 수 있는 상황

find(Member.class, 2)에서 2는 예시 기본키 값이다.
현재 데이터베이스에 MEMBER_ID가 2인 회원이 있으면 Member 객체가 출력된다.
하지만 해당 값이 없으면 null이 출력될 수 있다.


이 상황은 코드 문법 오류가 아니다.
find()는 기본키 조회 메서드이므로, 해당 기본키가 없으면 가져올 엔티티가 없는 것이다.


예를 들어 아래처럼 볼 수 있다.

// FindNullExample.java
Member m = em.find(Member.class, 9999); // 존재하지 않는 기본키라고 가정
System.out.println(m); // null 출력 가능

이 결과를 보고 JPA 설정이 잘못됐다고 바로 판단하면 안 된다.
먼저 현재 membertbl에 해당 기본키 값이 실제로 존재하는지 확인해야 한다.

기본키 확인 기준

  • find()는 username이 아니라 기본키로 조회한다.
  • Member의 기본키는 MEMBER_ID이다.
  • find(Member.class, 2)는 MEMBER_ID = 2인 회원을 찾는다.
  • 해당 MEMBER_ID가 없으면 결과는 null일 수 있다.

find() 결과가 null이면 먼저 코드보다 현재 테이블에 해당 기본키 값이 존재하는지 확인해야 한다.


Native SQL은 실제 테이블명과 컬럼명을 사용한다

두 번째 조회는 Native SQL이다.
Native SQL은 데이터베이스에서 직접 사용하는 SQL 문법을 그대로 작성하는 방식이다.


핵심 코드는 아래와 같다.

// MemberTeamTest7.java
Query q = em.createNativeQuery("select * from membertbl order by username", Member.class); // 실제 SQL 실행
List<Member> list = q.getResultList(); // Member 목록으로 결과 받기

여기서는 Member가 아니라 membertbl을 사용한다.
membertbl은 실제 데이터베이스 테이블명이다.
그래서 Native SQL에서는 select * from membertbl처럼 작성한다.


뒤에 Member.class를 함께 전달했다.
이 말은 조회 결과를 Member 엔티티 객체로 매핑하겠다는 뜻이다.
select *로 membertbl의 컬럼들을 조회하고, 그 결과를 Member 객체로 만들어 받는다.


결과는 여러 명일 수 있으므로 getResultList()를 사용한다.
반환 결과는 List<Member>이다.


Native SQL에서는 Entity 이름이 아니라 실제 데이터베이스 테이블명인 membertbl을 사용한다.


select * 결과를 Member 객체로 받을 수 있다

createNativeQuery()에 Member.class를 함께 넘기면 조회 결과를 Member 객체로 받을 수 있다.


핵심 코드는 아래와 같다.

// MemberTeamTest7.java
Query q = em.createNativeQuery("select * from membertbl order by username", Member.class); // 결과를 Member로 매핑
List<Member> list = q.getResultList(); // Member 목록

if (list.size() != 0) { // 결과가 있으면
    list.stream().forEach(System.out::println); // Member 객체 출력
}

select * from membertbl은 테이블의 모든 컬럼을 조회한다.
이 컬럼들이 Member 엔티티의 매핑 정보와 맞으면, JPA는 결과를 Member 객체로 만들 수 있다.


System.out::println은 각 Member 객체를 출력한다.
Member에 toString()이 있으면 회원 정보가 문자열로 출력된다.


이때 출력 결과에는 team, locker 같은 연관 객체 정보도 보일 수 있다.
왜냐하면 Member 객체를 출력하는 과정에서 toString()이 연관 필드까지 포함할 수 있기 때문이다.

Native SQL 전체 조회에서 봐야 할 기준

  • select * from membertbl은 실제 테이블 전체 컬럼을 조회한다.
  • Member.class를 함께 전달하면 결과를 Member 객체로 받을 수 있다.
  • 결과가 여러 개이므로 List<Member>로 받는다.
  • 출력은 stream().forEach(System.out::println)으로 처리한다.

Native SQL도 결과 매핑 타입을 지정하면 엔티티 객체 목록으로 받을 수 있다.


특정 컬럼 하나만 조회하면 결과 타입도 달라진다

세 번째 조회도 Native SQL이다.
하지만 이번에는 전체 컬럼이 아니라 username 컬럼 하나만 조회한다.


핵심 코드는 아래와 같다.

// MemberTeamTest7.java
q = em.createNativeQuery("select username from membertbl order by username"); // username 컬럼만 조회
List<String> namelist = q.getResultList(); // 이름 목록으로 결과 받기

if (namelist.size() != 0) { // 결과가 있으면
    namelist.stream().forEach(System.out::println); // 이름만 출력
}

이번 SQL은 select username from membertbl이다.
즉, Member 전체를 조회하는 것이 아니라 username 값만 조회한다.


그래서 결과를 Member 객체로 받을 수 없다.
결과는 이름 문자열들이므로 List<String>으로 받는다.


이 흐름은 앞의 MemberTeamTest5.java와 비슷하다.
앞에서는 select m.team.name으로 팀명 문자열 하나를 조회했다.
이번에는 실제 SQL로 select username을 실행해서 회원명 문자열 목록을 조회한다.

컬럼 하나만 조회할 때 봐야 할 기준

  • select username은 회원 이름 값만 조회한다.
  • Member 전체를 조회하는 것이 아니다.
  • 결과는 문자열 목록이다.
  • 그래서 List<String>으로 받을 수 있다.

조회하는 대상이 엔티티 전체인지, 특정 값 하나인지에 따라 결과 타입이 달라진다.


JPQL은 Entity 이름을 기준으로 조회한다

마지막 조회는 JPQL이다.
JPQL은 데이터베이스 테이블명이 아니라 Entity 이름을 기준으로 작성한다.


핵심 코드는 아래와 같다.

// MemberTeamTest7.java
TypedQuery<Member> tq = em.createQuery("select m from Member m", Member.class); // JPQL로 Member 조회
List<Member> empList = tq.getResultList(); // Member 목록 조회

for (Member elem : empList) { // 결과 반복
    System.out.println(elem); // Member 출력
}

select m from Member m에서 Member는 테이블명이 아니라 Entity 이름이다.
실제 테이블명이 membertbl이어도 JPQL에서는 Member라고 쓴다.


TypedQuery<Member>를 사용한 이유는 조회 결과가 Member 객체 목록이기 때문이다.
getResultList()는 여러 Member 객체를 목록으로 반환한다.


이 구간은 두 번째 Native SQL 전체 조회와 결과가 비슷해 보일 수 있다.
하지만 작성 방식이 다르다.
Native SQL은 select * from membertbl이고, JPQL은 select m from Member m이다.


JPQL은 테이블 중심이 아니라 Entity 중심으로 조회문을 작성한다.


Native SQL과 JPQL의 차이

Native SQL과 JPQL은 둘 다 데이터를 조회할 수 있다.
하지만 기준이 다르다.


Native SQL은 데이터베이스 중심이다.
실제 테이블명과 컬럼명을 사용한다.
그래서 membertbl, username 같은 데이터베이스 이름이 그대로 나온다.


JPQL은 객체 중심이다.
Entity 이름과 필드 이름을 사용한다.
그래서 Member, m.username, m.team처럼 객체 코드에서 사용하는 이름을 기준으로 작성한다.


비교하면 아래와 같다.

// NativeSqlAndJpqlCompare.java
em.createNativeQuery("select * from membertbl order by username", Member.class); // 실제 테이블명 사용

em.createQuery("select m from Member m", Member.class); // Entity 이름 사용

첫 번째는 실제 SQL이다.
membertbl이라는 테이블명을 사용한다.


두 번째는 JPQL이다.
Member라는 Entity 이름을 사용한다.

차이 정리

  • Native SQL: 실제 테이블명과 컬럼명을 사용한다.
  • Native SQL: 데이터베이스에 가까운 조회 방식이다.
  • JPQL: Entity 이름과 필드 이름을 사용한다.
  • JPQL: 객체 코드에 가까운 조회 방식이다.
  • 같은 데이터를 조회해도 작성 방식이 다르다.

Native SQL은 데이터베이스 이름을 쓰고, JPQL은 Entity 이름을 쓴다는 차이를 반드시 구분해야 한다.


출력 구분선은 조회 결과를 나누기 위한 장치이다

이 예제에는 아래 코드가 여러 번 나온다.

// MemberTeamTest7.java
System.out.println("=".repeat(50)); // 구분선 출력

"=".repeat(50)은 "=" 문자를 50번 반복한 문자열을 만든다.
즉, 콘솔에 긴 구분선을 출력한다.


이 구분선은 데이터 처리에 영향을 주는 핵심 문법은 아니다.
여러 조회 결과가 한 화면에 이어서 출력되면 어디까지가 어떤 조회 결과인지 헷갈릴 수 있다.
그래서 출력 구간을 나누기 위해 사용한다.


이 예제에서는 find() 결과, Native SQL 전체 조회 결과, Native SQL 컬럼 조회 결과, JPQL 전체 조회 결과를 구분하는 역할을 한다.


구분선은 조회 로직이 아니라, 여러 결과를 콘솔에서 보기 쉽게 나누는 출력용 코드이다.


실행 결과는 조회 방식별로 나누어 확인한다

이 예제를 실행하면 여러 조회 결과가 순서대로 출력된다.
구분선을 기준으로 결과를 나누어 보면 이해하기 쉽다.


예상 흐름은 아래처럼 볼 수 있다.

// 출력결과
// Member(id=2, username=..., team=..., locker=...)
// ==================================================
// Member(id=..., username=..., team=..., locker=...)
// Member(id=..., username=..., team=..., locker=...)
// ...
// ==================================================
// 둘리
// 또치
// ...
// ==================================================
// Member(id=..., username=..., team=..., locker=...)
// Member(id=..., username=..., team=..., locker=...)

첫 번째 구간은 find() 결과이다.
기본키로 조회한 Member 한 명이 출력된다.
만약 기본키 값이 존재하지 않으면 이 구간은 null로 출력될 수 있다.


두 번째 구간은 Native SQL로 membertbl 전체를 조회한 결과이다.
Member 객체 목록이 출력된다.


세 번째 구간은 Native SQL로 username 컬럼만 조회한 결과이다.
회원 이름만 한 줄씩 출력된다.


네 번째 구간은 JPQL로 Member 전체를 조회한 결과이다.
다시 Member 객체 목록이 출력된다.


MemberTeamTest7.java 실행 결과이다.
첫 번째 구간에서는 find(Member.class, 2)로 기본키가 2인 Member 한 명을 조회한다.
이때 현재 membertbl에 해당 기본키 값이 존재하면 Member 객체가 출력되고, 존재하지 않으면 null이 출력될 수 있다.


두 번째 구간에서는 Native SQL인 select * from membertbl order by username으로 실제 테이블을 조회하고, 결과를 Member 객체 목록으로 받는다.
세 번째 구간에서는 select username from membertbl order by username으로 username 컬럼만 조회해서 이름 목록만 출력한다.


마지막 구간에서는 JPQL인 select m from Member m으로 Member 엔티티 전체를 조회한다.
구분선 "=".repeat(50)을 기준으로 보면 find(), Native SQL, 컬럼 단독 조회, JPQL 조회 결과를 나누어 확인할 수 있다.


실행 결과는 한 덩어리로 보지 말고, 구분선을 기준으로 어떤 조회 방식의 결과인지 나누어 읽어야 한다.


핵심 정리

MemberTeamTest7.java는 Member 데이터를 여러 방식으로 조회하는 예제이다.
find(), Native SQL, JPQL이 한 파일 안에 함께 나오므로 각각의 기준을 비교해야 한다.


정리하면 아래와 같다.

  • find(Member.class, id)는 기본키로 Member 한 명을 조회한다.
  • id 값은 현재 데이터베이스에 실제로 존재하는 값이어야 한다.
  • id 값이 존재하지 않으면 find() 결과는 null일 수 있다.
  • createNativeQuery("select * from membertbl", Member.class)는 실제 테이블을 조회하고 결과를 Member로 매핑한다.
  • createNativeQuery("select username from membertbl")는 이름 컬럼만 조회한다.
  • 이름 컬럼만 조회하면 결과는 List<String>으로 받을 수 있다.
  • createQuery("select m from Member m", Member.class)는 JPQL로 Member 전체를 조회한다.
  • Native SQL은 실제 테이블명과 컬럼명을 사용한다.
  • JPQL은 Entity 이름과 필드 이름을 사용한다.
  • "=".repeat(50)은 결과 구간을 나누기 위한 출력용 코드이다.

같은 데이터를 조회해도 find(), Native SQL, JPQL은 기준과 결과 타입이 다르므로 코드에서 어떤 조회 방식을 쓰는지 먼저 구분해야 한다.




응용예제 24. Visitor Entity와 Controller, DAO 기반 CRUD 확인하기 (Visitor.java, VisitorController.java, VisitorDAO.java, VisitorApp.java)

Visitor 예제는 방명록 데이터를 JPA로 저장, 조회, 검색, 수정, 삭제하는 흐름을 보여 주는 예제이다.
앞 예제들에서는 실행 파일 안에서 EntityManager를 직접 만들고 바로 조회하거나 삭제했다.
이번 예제에서는 역할을 더 나누어 VisitorApp, VisitorController, VisitorDAO, Visitor가 각각 다른 책임을 맡는다.


이 구조를 보면 실제 프로그램에서 사용자 입력, 기능 처리, 데이터베이스 작업이 어떻게 나뉘는지 이해하기 쉽다.
VisitorApp.java는 메뉴 입력을 받는다.
VisitorController.java는 메뉴에서 선택된 기능을 처리하기 위해 Visitor 객체를 만들거나 출력 흐름을 담당한다.
VisitorDAO.java는 실제 데이터베이스 조회, 저장, 수정, 삭제 작업을 담당한다.


이 예제의 핵심은 실행 앱이 데이터베이스 작업을 직접 처리하지 않고, Controller와 DAO를 거쳐 CRUD 흐름을 분리한다는 점이다.


이전 기본개념과 연결하기

앞에서는 EntityManager를 직접 사용해서 persist(), find(), remove(), JPQL, Native SQL 같은 흐름을 확인했다.
이 방식은 실습 흐름을 이해하기에는 좋지만, 기능이 많아지면 실행 파일이 너무 복잡해질 수 있다.


실제 프로그램에서는 사용자 입력을 처리하는 코드, 기능 흐름을 조정하는 코드, 데이터베이스 작업을 처리하는 코드를 나누는 편이 좋다.
그래야 메뉴 흐름은 메뉴 흐름대로 읽히고, 데이터 저장과 조회는 데이터 작업 코드 안에서 따로 확인할 수 있다.


이 예제에서는 그 역할이 아래처럼 나뉜다.

  • VisitorApp.java는 콘솔 메뉴와 사용자 입력을 담당한다.
  • VisitorController.java는 사용자가 선택한 기능을 실행하기 위해 DAO를 호출한다.
  • VisitorDAO.java는 실제 JPA 데이터베이스 작업을 담당한다.
  • Visitor.java는 방명록 한 건의 데이터를 담는 Entity이다.

이 예제는 단순한 DAO 예제가 아니라 App → Controller → DAO → Entity 흐름을 확인하는 예제이다.


예제의 목표

이 예제의 목표는 방명록 프로그램에서 CRUD가 어떤 파일을 거쳐 실행되는지 확인하는 것이다.
CRUD는 생성, 조회, 수정, 삭제를 의미한다.
방명록 글을 저장하고, 목록을 보고, 검색하고, 수정하고, 삭제하는 기능이 모두 이 흐름에 포함된다.


이 예제는 파일 하나만 보면 흐름이 잘 보이지 않는다.
VisitorApp.java만 보면 메뉴 번호 입력만 보인다.
VisitorController.java만 보면 DAO 호출과 출력 처리가 보인다.
VisitorDAO.java만 보면 실제 JPA 코드가 보인다.
Visitor.java만 보면 데이터 구조만 보인다.


따라서 네 파일을 역할 기준으로 연결해서 봐야 한다.

이 예제에서 확인할 내용

  • Visitor가 방명록 한 건을 표현하는 Entity인지 확인한다.
  • VisitorApp이 메뉴 입력을 받고 VisitorController 메서드를 호출하는지 확인한다.
  • VisitorController가 VisitorDAO를 가지고 기능별 메서드를 실행하는지 확인한다.
  • VisitorDAO가 EntityManager를 만들고 실제 JPQL, find(), persist(), remove()를 실행하는지 확인한다.
  • 저장, 수정, 삭제가 Transaction 안에서 처리되는지 확인한다.
  • 수정은 조회한 영속 객체의 값을 바꾸고 commit() 시점에 반영되는지 확인한다.

이 흐름을 이해하면 뒤의 Meeting 예제도 더 쉽게 이어진다.
Meeting 예제도 앱, 컨트롤러, DAO, 엔티티가 나뉘어 동작하기 때문이다.


Visitor는 방명록 한 건을 표현하는 Entity이다

Visitor.java는 방명록 한 건의 데이터를 담는 Entity이다.
id, name, writeDate, memo 필드를 가진다.


코드 구조는 아래와 같다.

// Visitor.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@Entity // JPA가 관리할 Entity 클래스
public class Visitor {
    @Id // 기본키 필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id; // 방명록 글 번호

    private String name; // 작성자 이름

    @CreationTimestamp // 저장 시점의 날짜 자동 입력
    private java.sql.Date writeDate; // 작성일

    private String memo; // 방명록 내용
}

@Entity가 붙어 있으므로 Visitor는 JPA가 관리하는 엔티티이다.
id에는 @Id와 @GeneratedValue(strategy = GenerationType.IDENTITY)가 붙어 있다.
따라서 id는 기본키이고, 데이터베이스가 값을 자동으로 생성할 수 있다.


name은 작성자 이름이다.
memo는 방명록 글 내용이다.
writeDate에는 @CreationTimestamp가 붙어 있다.
@CreationTimestamp는 데이터가 처음 저장되는 시점의 날짜를 자동으로 넣는 역할을 한다.


즉, 사용자가 직접 입력하는 값은 작성자 이름과 방명록 내용이다.
글 번호와 작성일은 저장 흐름에서 자동으로 채워지는 값으로 볼 수 있다.

Visitor에서 봐야 할 핵심

  • Visitor는 방명록 한 건을 표현한다.
  • id는 기본키이다.
  • IDENTITY 전략으로 id가 자동 생성될 수 있다.
  • name은 작성자 이름이다.
  • memo는 방명록 내용이다.
  • writeDate는 @CreationTimestamp로 저장 시점에 자동 입력될 수 있다.

Visitor는 사용자가 입력한 글 내용을 객체로 담고, JPA가 데이터베이스 테이블과 연결할 수 있게 만든 클래스이다.


VisitorApp은 메뉴 입력을 담당한다

VisitorApp.java는 콘솔에서 메뉴를 보여 주고 사용자의 번호 입력을 받는다.
직접 EntityManager를 만들지 않는다.
직접 JPQL을 작성하지도 않는다.


대신 VisitorController 객체를 만들고, 메뉴 번호에 따라 컨트롤러 메서드를 호출한다.


핵심 구조는 아래와 같다.

// VisitorApp.java
Scanner scan = new Scanner(System.in); // 사용자 입력 도구 생성
VisitorController vc = new VisitorController(); // Controller 생성

while (true) { // 메뉴 반복 실행
    System.out.println("1. 방명록 리스트 보기"); // 전체 조회 메뉴
    System.out.println("2. 방명록 검색"); // 검색 메뉴
    System.out.println("3. 방명록 글 저장"); // 저장 메뉴
    System.out.println("4. 방명록 글 수정"); // 수정 메뉴
    System.out.println("5. 방명록 글 삭제"); // 삭제 메뉴
    System.out.println("6. 종료"); // 종료 메뉴

    int num = scan.nextInt(); // 메뉴 번호 입력
    scan.nextLine(); // 줄바꿈 처리

    if (num == 1) {
        vc.listVisitor(); // 전체 목록 조회 요청
    } else if (num == 2) {
        String keyword = scan.nextLine(); // 검색어 입력
        vc.searchVisitor(keyword); // 검색 요청
    } else if (num == 3) {
        String name = scan.nextLine(); // 작성자 입력
        String memo = scan.nextLine(); // 방명록 내용 입력
        vc.insertVisitor(name, memo); // 저장 요청
    } else if (num == 4) {
        int id = scan.nextInt(); // 수정할 글 번호 입력
        scan.nextLine(); // 줄바꿈 처리
        if (vc.oneVisitor(id, false)) { // 수정 대상 존재 여부 확인
            String name = scan.nextLine(); // 새 작성자 입력
            String memo = scan.nextLine(); // 새 내용 입력
            vc.updateVisitor(id, name, memo); // 수정 요청
        }
    } else if (num == 5) {
        int id = scan.nextInt(); // 삭제할 글 번호 입력
        vc.deleteVisitor(id); // 삭제 요청
    } else {
        vc.close(); // DAO 자원 정리
        break; // 반복 종료
    }
}

VisitorApp은 메뉴 흐름을 담당한다.
사용자가 어떤 번호를 입력했는지 판단하고, 필요한 값을 추가로 입력받은 뒤 VisitorController에 넘긴다.


여기서 중요한 점은 VisitorApp이 VisitorDAO를 직접 호출하지 않는다는 것이다.
VisitorApp은 VisitorController를 호출한다.
그리고 VisitorController가 내부에서 VisitorDAO를 호출한다.

VisitorApp에서 봐야 할 핵심

  • Scanner로 메뉴 번호와 입력값을 받는다.
  • while (true)로 메뉴를 반복 실행한다.
  • 1번은 전체 목록 조회이다.
  • 2번은 검색이다.
  • 3번은 저장이다.
  • 4번은 수정이다.
  • 5번은 삭제이다.
  • 6번은 종료이다.
  • 실제 기능 실행은 VisitorController 메서드 호출로 이어진다.

VisitorApp은 데이터베이스 작업을 직접 하지 않고, 사용자 입력을 받아 VisitorController로 넘기는 역할을 한다.


VisitorController는 App과 DAO 사이에서 기능 흐름을 조정한다

VisitorController.java는 VisitorApp과 VisitorDAO 사이에 있는 중간 역할이다.
사용자의 메뉴 입력으로 넘어온 값을 받아 Visitor 객체를 만들거나, DAO에서 받은 결과를 출력한다.


코드 구조를 보면 VisitorController 안에는 VisitorDAO 필드가 있다.
생성자에서 VisitorDAO 객체를 만든다.

// VisitorController.java
public class VisitorController {
    private VisitorDAO dao; // DAO 참조 변수

    public VisitorController() {
        dao = new VisitorDAO(); // DAO 객체 생성
    }
}

이 구조 때문에 VisitorController는 VisitorDAO의 메서드를 호출할 수 있다.
즉, VisitorApp은 컨트롤러를 호출하고, 컨트롤러는 DAO를 호출한다.


흐름은 아래처럼 이어진다.

// VisitorFlow.java
VisitorApp // 메뉴 입력 담당

VisitorController // 기능 처리와 출력 담당

VisitorDAO // 실제 JPA 데이터 작업 담당

Visitor // 방명록 데이터 Entity

이 구조를 잡아야 예제가 정확히 보인다.
기존처럼 VisitorApp → VisitorDAO로 바로 설명하면 중간에서 VisitorController가 하는 일이 빠진다.


VisitorController는 메뉴 입력과 데이터베이스 작업 사이에서 기능 흐름을 연결하는 중간 계층이다.


VisitorController는 조회 결과를 출력하고 저장할 객체를 만든다

VisitorController의 메서드는 크게 조회 출력, 저장 요청, 수정 요청, 삭제 요청으로 나누어 볼 수 있다.

전체 목록 출력

listVisitor()는 dao.listAll()로 전체 목록을 가져온 뒤, 각 Visitor 객체의 값을 출력한다.

// VisitorController.java
public void listVisitor() {
    List<Visitor> list = dao.listAll(); // 전체 목록 조회

    for (Visitor vo : list) { // 조회 결과 반복
        System.out.print(vo.getId() + "\t"); // 글 번호 출력
        System.out.print(vo.getName() + "\t"); // 작성자 출력
        System.out.print(vo.getWriteDate() + "\t"); // 작성일 출력
        System.out.println(vo.getMemo()); // 글 내용 출력
    }
}

DAO는 데이터를 가져오는 역할을 하고, Controller는 가져온 데이터를 화면에 보기 좋게 출력하는 역할을 한다.
콘솔 프로그램이므로 여기서 말하는 화면은 콘솔 출력이다.


단건 존재 여부 확인

oneVisitor()는 글 번호로 방명록 한 건을 조회한다.
수정할 때는 먼저 해당 번호의 글이 있는지 확인해야 한다.
그래서 VisitorApp의 수정 메뉴에서 이 메서드를 사용한다.

// VisitorController.java
public boolean oneVisitor(int id, boolean output) {
    Visitor vo = dao.one(id); // 기본키로 단건 조회
    boolean result = false; // 존재 여부 기본값

    if (vo != null) { // 조회 결과가 있으면
        result = true; // 존재한다고 표시

        if (output) { // 출력 옵션이 true이면
            System.out.print(vo.getId() + "\t"); // 글 번호 출력
            System.out.print(vo.getName() + "\t"); // 작성자 출력
            System.out.print(vo.getWriteDate() + "\t"); // 작성일 출력
            System.out.println(vo.getMemo()); // 글 내용 출력
        }
    }

    return result; // 존재 여부 반환
}

output은 조회 결과를 바로 출력할지 정하는 값이다.
수정 메뉴에서는 존재 여부만 확인하면 되기 때문에 false로 호출한다.


이 메서드의 핵심은 boolean 반환값이다.
방명록 글이 있으면 true, 없으면 false를 반환한다.
그래서 수정 메뉴는 이 값을 보고 수정 입력을 받을지 말지 결정한다.


검색 출력

searchVisitor()는 검색어를 받아 memo에 검색어가 포함된 방명록 글을 출력한다.

// VisitorController.java
public void searchVisitor(String keyword) {
    List<Visitor> list = dao.search(keyword); // 검색 결과 조회

    if (list.size() == 0) { // 검색 결과가 없으면
        System.out.println(keyword + "를 포함한 글은 없네요~~"); // 없음 출력
    } else {
        for (Visitor vo : list) { // 검색 결과 반복
            System.out.print(vo.getId() + "\t"); // 글 번호 출력
            System.out.print(vo.getName() + "\t"); // 작성자 출력
            System.out.print(vo.getWriteDate() + "\t"); // 작성일 출력
            System.out.println(vo.getMemo()); // 글 내용 출력
        }
    }
}

검색 결과가 없을 수도 있다.
그래서 list.size() == 0으로 결과 개수를 확인한 뒤 메시지를 다르게 출력한다.


저장 요청

insertVisitor()는 작성자 이름과 글 내용을 받아 Visitor 객체를 만든다.
그다음 dao.insert(vo)를 호출해서 저장을 요청한다.

// VisitorController.java
public void insertVisitor(String name, String memo) {
    Visitor vo = new Visitor(); // 새 Visitor 객체 생성
    vo.setName(name); // 작성자 저장
    vo.setMemo(memo); // 글 내용 저장

    boolean result = dao.insert(vo); // 저장 요청

    if (result) { // 저장 성공이면
        System.out.println(name + "님의 글이 성공적으로 저장했어요^^"); // 성공 메시지
    } else {
        System.out.println(name + "님의 글 저장 작업을 실패했어요ㅜㅜ"); // 실패 메시지
    }
}

여기서 VisitorController는 직접 persist()를 호출하지 않는다.
저장할 Visitor 객체만 만들고, 실제 저장은 VisitorDAO의 insert()에 맡긴다.


Controller는 저장할 객체를 준비하고, 실제 데이터베이스 저장은 DAO가 처리한다.


수정 요청

updateVisitor()는 수정할 글 번호, 작성자, 내용을 받아 Visitor 객체에 담는다.
그다음 dao.update(vo)를 호출한다.

// VisitorController.java
public void updateVisitor(int id, String name, String memo) {
    Visitor vo = new Visitor(); // 수정 값을 담을 객체 생성
    vo.setId(id); // 수정 대상 번호 저장
    vo.setName(name); // 새 작성자 저장
    vo.setMemo(memo); // 새 글 내용 저장

    boolean result = dao.update(vo); // 수정 요청

    if (result) { // 수정 성공이면
        System.out.println(name + "님의 글이 성공적으로 수정했어요^^"); // 성공 메시지
    } else {
        System.out.println(name + "님의 글 수정 작업을 실패했어요ㅜㅜ"); // 실패 메시지
    }
}

이 객체는 새로 저장할 객체라기보다, 수정할 값들을 묶어서 DAO로 넘기는 객체로 보면 된다.
실제 수정은 DAO에서 기존 데이터를 조회한 뒤, 그 객체의 값을 바꾸는 방식으로 처리된다.


삭제 요청

deleteVisitor()는 삭제할 글 번호를 받아 dao.delete(id)를 호출한다.

// VisitorController.java
public void deleteVisitor(int id) {
    boolean result = dao.delete(id); // 삭제 요청

    if (result) { // 삭제 성공이면
        System.out.println(id + "번 글을 성공적으로 삭제했어요^^"); // 성공 메시지
    } else {
        System.out.println(id + "번 글이 존재하지 않아요ㅜㅜ"); // 실패 메시지
    }
}

삭제할 때도 Controller는 remove()를 직접 호출하지 않는다.
삭제할 번호만 DAO에 넘긴다.
실제 삭제 대상 조회와 삭제 확정은 DAO에서 처리된다.

VisitorController에서 봐야 할 핵심

  • VisitorController는 내부에 VisitorDAO를 가진다.
  • 전체 조회와 검색 결과를 콘솔에 출력한다.
  • 저장할 때는 Visitor 객체를 만들고 dao.insert()를 호출한다.
  • 수정할 때는 수정할 값을 Visitor 객체에 담고 dao.update()를 호출한다.
  • 삭제할 때는 글 번호를 dao.delete()로 넘긴다.
  • 성공 여부에 따라 다른 메시지를 출력한다.

VisitorController가 있어야 VisitorApp의 메뉴 입력과 VisitorDAO의 데이터 작업이 자연스럽게 연결된다.


VisitorDAO는 실제 JPA 데이터 작업을 담당한다

VisitorDAO.java는 실제 데이터베이스 작업을 처리한다.
EntityManagerFactory를 가지고 있고, 각 메서드 안에서 EntityManager를 만들어 조회, 저장, 수정, 삭제를 실행한다.


기본 구조는 아래와 같다.

// VisitorDAO.java
public class VisitorDAO {
    private EntityManagerFactory factory; // EntityManagerFactory 보관

    public VisitorDAO() {
        factory = Persistence.createEntityManagerFactory("emptest"); // emptest 설정으로 Factory 생성
    }

    public void close() {
        if (factory != null) { // Factory가 있으면
            factory.close(); // Factory 자원 정리
        }
    }
}

여기서는 "emptest" 설정 묶음을 사용한다.
이 설정을 기준으로 EntityManagerFactory가 만들어지고, 각 메서드에서 EntityManager를 생성한다.


VisitorDAO의 메서드는 작업이 끝나면 em.close()로 EntityManager를 닫는다.
프로그램 종료 시에는 close()에서 EntityManagerFactory를 닫는다.


VisitorDAO는 JPA 코드가 실제로 들어 있는 위치이며, 데이터베이스 접근 책임을 맡는다.


listAll은 방명록 전체 목록을 조회한다

listAll()은 방명록 전체 데이터를 조회하는 메서드이다.
JPQL의 select t from Visitor t를 사용한다.

// VisitorDAO.java
public List<Visitor> listAll() {
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    TypedQuery<Visitor> q = em.createQuery(
            "select t from Visitor t", Visitor.class
    ); // Visitor 전체 조회 JPQL 생성

    List<Visitor> list = q.getResultList(); // 여러 건 조회

    em.close(); // EntityManager 닫기
    return list; // 조회 결과 반환
}

Visitor는 테이블명이 아니라 엔티티 이름이다.
JPQL은 실제 테이블명이 아니라 엔티티 이름을 기준으로 작성한다.


getResultList()는 여러 건의 결과를 List로 가져온다.
방명록 전체 조회는 결과가 여러 개일 수 있으므로 getResultList()가 사용된다.


one은 기본키로 방명록 한 건을 조회한다

one()은 글 번호 id를 기준으로 방명록 한 건을 조회한다.
기본키 조회이므로 find()를 사용한다.

// VisitorDAO.java
public Visitor one(int id) {
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    Visitor vo = em.find(Visitor.class, id); // 기본키로 Visitor 한 건 조회

    em.close(); // EntityManager 닫기
    return vo; // 조회 결과 반환
}

find(Visitor.class, id)는 Visitor 엔티티 중 기본키가 id인 데이터를 찾는다.
조회 결과가 있으면 Visitor 객체를 반환한다.
조회 결과가 없으면 null이 될 수 있다.


이 메서드는 수정 전에 글이 존재하는지 확인할 때도 사용된다.


search는 memo에 검색어가 포함된 글을 찾는다

search()는 검색어를 받아 방명록 내용에 해당 검색어가 포함된 글을 찾는다.
여기서는 like 조건을 사용한다.

// VisitorDAO.java
public List<Visitor> search(String keyword) {
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    TypedQuery<Visitor> q = em.createQuery(
            "select t from Visitor t WHERE t.memo like :keyword",
            Visitor.class
    ); // memo 포함 검색 JPQL 생성

    q.setParameter("keyword", "%" + keyword + "%"); // 검색어 앞뒤에 % 붙이기

    List<Visitor> list = q.getResultList(); // 검색 결과 조회

    em.close(); // EntityManager 닫기
    return list; // 검색 결과 반환
}

:keyword는 JPQL 안에서 값을 나중에 넣을 자리이다.
setParameter()는 그 자리에 실제 값을 연결한다.


"%" + keyword + "%"는 검색어 앞뒤에 %를 붙이는 코드이다.
%는 앞뒤에 어떤 글자가 있어도 된다는 의미로 사용된다.
따라서 검색어가 memo 중간에 들어 있어도 조회될 수 있다.

search에서 봐야 할 핵심

  • like는 일부 문자열 검색에 사용한다.
  • :keyword는 이름 기반 파라미터이다.
  • setParameter()로 실제 검색어를 연결한다.
  • %검색어% 형태로 만들면 검색어가 포함된 데이터를 찾을 수 있다.

검색 기능은 like 조건과 파라미터 바인딩을 함께 사용해서 만든다.


insert는 새 방명록 글을 저장한다

insert()는 새 Visitor 객체를 데이터베이스에 저장한다.
저장은 데이터베이스 상태를 바꾸는 작업이므로 Transaction 안에서 처리한다.

// VisitorDAO.java
public boolean insert(Visitor vo) {
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작
        em.persist(vo); // 새 Visitor 저장 요청
        em.getTransaction().commit(); // 저장 확정
    } catch (Exception e) {
        result = false; // 예외 발생 시 실패 처리
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

persist(vo)는 새 Visitor 객체를 저장 대상으로 등록한다.
commit()이 실행되면 저장 작업이 확정된다.


Visitor의 id는 IDENTITY 전략이므로 데이터베이스가 자동으로 생성할 수 있다.
writeDate에는 @CreationTimestamp가 있으므로 저장 시점의 날짜가 자동으로 들어갈 수 있다.


저장은 persist() 호출뿐 아니라 Transaction의 commit()까지 함께 봐야 한다.


update는 기존 방명록 글을 수정한다

update()는 전달받은 Visitor 객체의 id를 기준으로 기존 데이터를 찾고, 조회된 객체의 값을 바꾼다.

// VisitorDAO.java
public boolean update(Visitor vo) {
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작

        Visitor oldVo = em.find(Visitor.class, vo.getId()); // 수정 대상 조회
        System.out.println(oldVo.getName()); // 기존 작성자 출력
        oldVo.setName(vo.getName()); // 새 작성자 반영
        oldVo.setMemo(vo.getMemo()); // 새 글 내용 반영

        em.getTransaction().commit(); // 변경 감지 후 수정 확정
    } catch (Exception e) {
        result = false; // 예외 발생 시 실패 처리
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

수정에서 중요한 점은 persist()가 다시 호출되지 않는다는 것이다.
먼저 find()로 기존 Visitor를 조회한다.
이렇게 조회된 객체는 영속 상태이다.
영속 상태는 JPA가 관리하는 상태라는 뜻이다.


그 상태에서 oldVo.setName(), oldVo.setMemo()로 값을 바꾸면 commit() 시점에 변경 내용이 반영될 수 있다.
이 흐름을 변경 감지라고 한다.
변경 감지는 영속 상태의 객체 값이 바뀐 것을 JPA가 감지해서 데이터베이스에 반영하는 기능이다.


단, find() 결과가 없으면 oldVo가 null이 될 수 있다.
이 경우 oldVo.getName()이나 oldVo.setName()을 호출하는 과정에서 예외가 발생할 수 있고, catch에서 실패 처리된다.

update에서 봐야 할 핵심

  • 수정 대상은 find()로 먼저 조회한다.
  • 조회된 영속 객체의 값을 바꾼다.
  • persist()를 다시 호출하지 않는다.
  • commit() 시점에 변경 감지로 수정 내용이 반영될 수 있다.
  • 수정 대상이 없으면 실패 처리될 수 있다.

수정은 새 객체를 저장하는 흐름이 아니라, 기존 영속 객체를 조회한 뒤 값을 바꾸는 흐름이다.


delete는 기존 방명록 글을 삭제한다

delete()는 삭제할 글 번호를 받아 기존 Visitor 객체를 조회한 뒤 삭제한다.
삭제도 데이터베이스 상태를 바꾸는 작업이므로 Transaction 안에서 처리한다.

// VisitorDAO.java
public boolean delete(int id) {
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작

        Visitor vo = em.find(Visitor.class, id); // 삭제 대상 조회
        em.remove(vo); // 삭제 요청

        em.getTransaction().commit(); // 삭제 확정
    } catch (Exception e) {
        result = false; // 예외 발생 시 실패 처리
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

삭제할 때도 먼저 find()가 필요하다.
remove()는 삭제할 Entity 객체를 인자로 받기 때문이다.
기본키 숫자만 바로 넘기는 것이 아니다.


find() 결과가 없으면 vo가 null이 될 수 있다.
이 경우 remove() 흐름이 정상적으로 진행되지 않고 실패 처리될 수 있다.

delete에서 봐야 할 핵심

  • 삭제 대상은 find()로 먼저 조회한다.
  • remove()는 삭제할 Entity 객체를 받는다.
  • remove()는 삭제 요청이다.
  • commit()이 실행되어야 삭제가 확정된다.
  • 삭제 대상이 없으면 실패 처리될 수 있다.

삭제는 find()로 대상을 찾고, remove()로 삭제 요청한 뒤, commit()으로 확정하는 흐름이다.


전체 실행 흐름은 App에서 Controller를 거쳐 DAO로 내려간다

이 예제를 정확히 이해하려면 기능 하나가 실행될 때 어느 파일을 거치는지 봐야 한다.
예를 들어 방명록 저장 기능은 아래 순서로 실행된다.

// VisitorInsertFlow.java
VisitorApp // 3번 메뉴 선택, 작성자와 글 내용 입력

VisitorController.insertVisitor(name, memo) // Visitor 객체 생성

VisitorDAO.insert(vo) // Transaction 시작, persist, commit

Visitor // 저장할 방명록 Entity

저장 흐름에서 VisitorApp은 입력만 받는다.
VisitorController는 입력값으로 Visitor 객체를 만든다.
VisitorDAO는 그 객체를 데이터베이스에 저장한다.


수정 흐름도 같은 기준으로 볼 수 있다.

// VisitorUpdateFlow.java
VisitorApp // 4번 메뉴 선택, 수정할 id 입력

VisitorController.oneVisitor(id, false) // 수정 대상 존재 여부 확인

VisitorApp // 새 작성자와 새 글 내용 입력

VisitorController.updateVisitor(id, name, memo) // 수정 값 전달

VisitorDAO.update(vo) // 기존 Entity 조회 후 값 변경, commit

수정은 저장보다 한 단계가 더 있다.
먼저 수정할 글이 있는지 확인한다.
글이 있으면 새 값을 입력받고, 그 값을 DAO로 넘겨 기존 데이터를 수정한다.


삭제 흐름은 아래와 같다.

// VisitorDeleteFlow.java
VisitorApp // 5번 메뉴 선택, 삭제할 id 입력

VisitorController.deleteVisitor(id) // 삭제 요청 전달

VisitorDAO.delete(id) // 삭제 대상 조회, remove, commit

이 흐름을 보면 VisitorController의 역할이 분명해진다.
VisitorController는 단순히 파일 이름만 있는 것이 아니라, 앱과 DAO 사이에서 기능 실행 흐름을 이어 주는 코드이다.


정확한 실행 흐름은 VisitorApp → VisitorController → VisitorDAO → Visitor 순서로 이해해야 한다.


결과물은 어떻게 나오는가

VisitorApp.java는 메뉴 기반 프로그램이다.
실행하면 먼저 메뉴가 출력되고, 사용자가 입력한 번호에 따라 결과가 달라진다.


이번 결과물은 1번부터 5번 메뉴까지 각각 나누어 확인한다.
1번은 전체 목록 조회, 2번은 검색, 3번은 저장, 4번은 수정, 5번은 삭제 흐름이다.


VisitorApp.java에서 1번 메뉴를 실행한 결과이다.
1번은 방명록 전체 목록을 조회하는 기능이다.
콘솔 메뉴에서 번호를 입력하면 VisitorController의 listVisitor()가 호출되고, listVisitor()는 VisitorDAO의 listAll()로 전체 목록을 가져온 뒤 출력한다.


VisitorApp.java에서 2번 메뉴를 실행한 결과이다.
2번은 검색어를 입력받아 방명록 글 내용에서 해당 검색어가 포함된 데이터를 찾는 기능이다.
이 흐름은 VisitorController의 searchVisitor()를 거쳐 VisitorDAO의 search()에서 memo like :keyword 조건으로 검색하는 방식과 연결된다.


VisitorApp.java에서 3번 메뉴를 실행한 결과이다.
3번은 작성자와 방명록 글을 입력받아 새 방명록 데이터를 저장하는 기능이다.
VisitorController의 insertVisitor()는 입력값으로 Visitor 객체를 만들고, VisitorDAO의 insert()가 persist()와 commit()으로 저장을 확정한다.


VisitorApp.java에서 4번 메뉴를 실행한 결과이다.
4번은 기존 방명록 글을 수정하는 기능이다.
먼저 VisitorController의 oneVisitor()로 수정할 글이 있는지 확인한다.
글이 있으면 새 작성자와 새 내용을 입력받고, VisitorController의 updateVisitor()를 거쳐 VisitorDAO의 update()에서 기존 영속 객체 값을 변경한다.


VisitorApp.java에서 5번 메뉴를 실행한 결과이다.
5번은 기존 방명록 글을 삭제하는 기능이다.
VisitorController의 deleteVisitor()가 삭제할 글 번호를 VisitorDAO의 delete()로 넘기고, delete()는 삭제 대상을 조회한 뒤 remove()와 commit()으로 삭제를 확정한다.


이 결과를 통해 VisitorApp의 메뉴 입력이 VisitorController를 거쳐 방명록 조회, 검색, 저장, 수정, 삭제 기능으로 이어지고, 실제 데이터 작업은 VisitorDAO의 CRUD 메서드에서 처리된다는 점을 확인할 수 있다.


핵심 정리

Visitor 예제는 방명록 데이터를 JPA로 처리하는 Controller, DAO 기반 CRUD 구조이다.
Visitor는 방명록 한 건을 표현하는 Entity이다.
VisitorApp은 사용자의 메뉴 입력을 받는다.
VisitorController는 메뉴 입력으로 선택된 기능을 처리하고 DAO를 호출한다.
VisitorDAO는 실제 데이터베이스 작업을 담당한다.


정리하면 아래와 같다.

  • Visitor는 방명록 데이터를 담는 Entity이다.
  • id는 기본키이고, IDENTITY 전략으로 자동 생성될 수 있다.
  • writeDate는 @CreationTimestamp로 저장 시점에 자동 입력될 수 있다.
  • VisitorApp은 메뉴 번호와 입력값을 받는다.
  • VisitorApp은 VisitorDAO를 직접 호출하지 않고 VisitorController를 호출한다.
  • VisitorController는 내부에 VisitorDAO를 가진다.
  • VisitorController는 조회 결과를 출력하고, 저장·수정에 필요한 Visitor 객체를 만든다.
  • VisitorController는 성공 여부에 따라 사용자에게 메시지를 출력한다.
  • VisitorDAO는 방명록 전체 조회, 단건 조회, 검색, 저장, 수정, 삭제를 담당한다.
  • listAll()은 JPQL로 전체 목록을 조회한다.
  • one()은 find()로 기본키 기준 단건 조회를 한다.
  • search()는 like 조건으로 memo 포함 검색을 한다.
  • insert()는 persist()로 새 방명록 글을 저장한다.
  • update()는 조회한 영속 객체의 값을 바꾸고 변경 감지로 수정한다.
  • update()에서 수정 대상 id가 없으면 실패 처리될 수 있다.
  • delete()는 삭제할 객체를 조회한 뒤 remove()로 삭제한다.
  • delete()에서 삭제 대상 id가 없으면 실패 처리될 수 있다.
  • 저장, 수정, 삭제는 Transaction 안에서 처리한다.

Visitor 예제는 App, Controller, DAO, Entity가 나뉘어 실제 방명록 CRUD 흐름을 구성하는 구조를 보여 준다.




응용예제 25. Meeting Entity와 DAO 기반 CRUD 확인하기 (Meeting.java, MeetingDAO.java, MeetingController.java, MeetingApp.java)

Meeting 예제는 미팅 글을 저장, 조회, 검색, 수정, 삭제하는 흐름을 보여 주는 예제이다.
구조는 앞의 Visitor 예제와 비슷하다.
다만 Meeting은 날짜와 시간을 함께 다루는 LocalDateTime 필드를 가진다는 차이가 있다.


MeetingApp.java는 사용자의 메뉴 입력을 받는다.
MeetingController.java는 메뉴 입력과 DAO 호출 사이의 중간 흐름을 담당한다.
MeetingDAO.java는 실제 데이터베이스 작업을 담당한다.


그리고 Meeting 글은 뒤에서 댓글인 Reply가 연결될 기준 데이터가 된다.
즉, 이번 예제는 미팅 글 자체의 CRUD를 확인하면서, 다음 댓글 관계 예제로 이어지는 기준 데이터를 만드는 흐름이기도 하다.


이 예제의 핵심은 날짜와 시간 정보가 포함된 Meeting 데이터를 DAO 기반 CRUD 구조로 처리하는 흐름을 이해하는 것이다.


이전 기본개념과 연결하기

앞의 Visitor 예제에서는 방명록 데이터를 Entity, DAO, 실행 앱으로 나누어 처리했다.
Visitor는 방명록 한 건을 표현했고, VisitorDAO는 조회, 검색, 저장, 수정, 삭제 작업을 담당했다.


Meeting 예제도 큰 구조는 비슷하다.
Meeting은 미팅 글 한 건을 표현하는 Entity이다.
MeetingDAO는 미팅 글에 대한 데이터베이스 작업을 담당한다.
MeetingApp은 콘솔 메뉴를 통해 사용자가 기능을 선택하게 한다.


다만 이번 예제에서 추가로 봐야 할 부분은 날짜와 시간이다.
Visitor의 작성일은 저장 시점에 자동으로 들어가는 흐름이었다.
반면 Meeting은 사용자가 미팅 일시를 입력하고, 그 값을 LocalDateTime으로 다루는 흐름과 연결된다.

기본개념에서 다시 잡아야 할 흐름

  • Meeting은 미팅 글 한 건을 표현하는 Entity이다.
  • MeetingDAO는 미팅 글의 조회, 검색, 저장, 수정, 삭제를 담당한다.
  • MeetingController는 앱과 DAO 사이에서 기능 호출 흐름을 연결한다.
  • MeetingApp은 메뉴 입력을 받아 기능 실행 흐름을 시작한다.
  • meetingDate는 날짜와 시간을 함께 표현하는 LocalDateTime 타입이다.
  • LocalDateTime은 데이터베이스의 TIMESTAMP 계열 컬럼과 연결될 수 있다.
  • 저장, 수정, 삭제는 데이터베이스 상태를 바꾸므로 Transaction 안에서 처리한다.
  • 뒤의 Reply 예제에서 댓글은 특정 Meeting 글에 연결된다.

Meeting은 단순 글 데이터가 아니라, 날짜·시간 필드와 댓글 연결의 기준이 되는 엔티티이다.


예제의 목표

이 예제의 목표는 미팅 글 데이터를 JPA로 처리하는 구조를 확인하는 것이다.
미팅 글 한 건은 Meeting 객체로 표현한다.
실제 데이터베이스 작업은 MeetingDAO에 모아 둔다.
사용자는 MeetingApp의 메뉴를 통해 미팅 글 작성, 삭제, 검색, 수정 기능을 실행한다.


즉, 이번 예제는 Meeting 하나만 보는 예제가 아니다.
Entity가 어떤 데이터를 담는지, DAO가 어떤 작업을 맡는지, 실행 앱이 어떤 메뉴 흐름을 제공하는지를 함께 확인하는 예제이다.


특히 Meeting은 LocalDateTime을 사용한다.
따라서 문자열로 입력한 날짜와 시간이 어떻게 미팅 일시로 저장되는지 흐름을 함께 봐야 한다.

이 예제에서 확인할 내용

  • Meeting이 미팅 글 데이터를 담는 Entity인지 확인한다.
  • meetingDate가 LocalDateTime 타입인지 확인한다.
  • TIMESTAMP 컬럼과 연결되는 흐름을 확인한다.
  • MeetingDAO가 전체 조회, 단건 조회, 저장, 삭제, 수정, 이름 검색을 담당하는지 확인한다.
  • MeetingApp의 메뉴가 미팅 글 기능 호출로 이어지는지 확인한다.
  • MeetingApp의 댓글 관련 메뉴는 다음 Reply 예제와 연결된다는 점을 구분한다.

이번 예제는 Meeting 데이터의 구조와 DAO 기반 CRUD 흐름을 함께 보는 것이 중요하다.


Meeting은 미팅 글 데이터를 담는 Entity이다

Meeting.java는 미팅 글 하나를 표현하는 Entity이다.
글 번호, 친구 이름, 미팅 목적, 미팅 일시를 필드로 가진다.


핵심 구조는 아래와 같다.

// Meeting.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@Entity // JPA 관리 대상 Entity
public class Meeting {
    @Id // 미팅 글 기본키
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id; // 글 번호

    private String name; // 미팅할 친구 이름
    private String title; // 미팅 목적

    @Column(columnDefinition = "TIMESTAMP") // TIMESTAMP 컬럼 지정
    private LocalDateTime meetingDate; // 미팅 일시
}

@Entity는 이 클래스가 JPA 관리 대상이라는 뜻이다.
즉, Meeting 객체는 데이터베이스 테이블과 연결될 수 있다.


id는 미팅 글을 구분하는 기본키이다.
@GeneratedValue(strategy = GenerationType.IDENTITY)가 붙어 있으므로 기본키 값은 데이터베이스가 자동으로 만들 수 있다.
따라서 새 미팅 글을 저장할 때 사용자가 id를 직접 입력하지 않아도 된다.


meetingDate는 LocalDateTime 타입이다.
LocalDateTime은 날짜와 시간을 함께 표현하는 타입이다.
예를 들어 2026-04-30T14:30처럼 날짜와 시간이 한 값 안에 들어간다.


@Column(columnDefinition = "TIMESTAMP")는 이 필드가 데이터베이스에서 TIMESTAMP 계열 컬럼으로 만들어지도록 지정하는 설정이다.


Meeting은 날짜와 시간이 함께 필요한 데이터를 LocalDateTime과 TIMESTAMP로 매핑하는 예제이다.


MeetingDAO는 미팅 글 CRUD 작업을 담당한다

MeetingDAO.java는 미팅 글을 조회, 저장, 검색, 수정, 삭제하는 기능을 제공한다.
CRUD는 Create, Read, Update, Delete의 줄임말이다.
데이터를 생성하고, 읽고, 수정하고, 삭제하는 기본 작업을 의미한다.


MeetingDAO는 생성자에서 EntityManagerFactory를 만든다.
각 메서드는 필요할 때 EntityManager를 만들고, 작업이 끝나면 닫는다.


핵심 구조는 아래와 같다.

// MeetingDAO.java
public class MeetingDAO {
    private EntityManagerFactory factory; // EntityManagerFactory 보관

    public MeetingDAO() { // DAO 생성자
        super(); // 부모 생성자 호출
        factory = Persistence.createEntityManagerFactory("emptest"); // emptest 설정으로 Factory 생성
    }

    public void close() { // Factory 정리
        if (factory != null) { // Factory가 있으면
            factory.close(); // Factory 닫기
        }
    }
}

여기서는 "emptest" 설정 묶음을 사용한다.
앞의 Member, Team, Locker 예제에서 사용한 "entitytest"와 다르다.
Meeting은 emptest 설정에 포함된 예제 흐름으로 보면 된다.


DAO 안에 EntityManagerFactory가 있고, 각 데이터 작업 메서드에서 EntityManager를 만들어 사용한다.
이 구조는 데이터 접근 코드를 한곳에 모으기 위한 방식이다.


MeetingDAO는 미팅 글 데이터 작업을 모아 둔 클래스이고, 실행 앱은 이 기능들을 메뉴 흐름으로 사용한다.


listM과 oneM은 미팅 글 조회 기능이다

listM()은 미팅 글 전체 목록을 조회한다.
oneM()은 기본키 id로 미팅 글 한 건을 조회한다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public List<Meeting> listM() { // 미팅 글 전체 조회
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    TypedQuery<Meeting> q = em.createQuery(
            "select t from Meeting t", Meeting.class
    ); // Meeting 전체 조회 JPQL

    List<Meeting> list = q.getResultList(); // 목록 조회

    em.close(); // EntityManager 닫기
    return list; // 결과 반환
}

public Meeting oneM(int id) { // 미팅 글 한 건 조회
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    Meeting vo = em.find(Meeting.class, id); // 기본키로 Meeting 조회

    em.close(); // EntityManager 닫기
    return vo; // 조회 결과 반환
}

listM()은 여러 건을 조회하므로 getResultList()를 사용한다.
조회 결과는 List<Meeting>으로 받는다.


oneM()은 기본키를 알고 있을 때 사용한다.
find(Meeting.class, id)는 Meeting 엔티티에서 기본키가 id인 데이터 한 건을 찾는다.


다만 id가 실제로 존재하지 않으면 oneM()의 결과가 null일 수 있다.
따라서 단건 조회 결과를 사용하는 쪽에서는 조회 결과가 있는지 확인하는 흐름이 필요할 수 있다.


전체 조회는 JPQL과 getResultList()를 사용하고, 기본키 단건 조회는 find()를 사용한다.


insertM은 새 미팅 글을 저장한다

insertM()은 새 미팅 글을 저장하는 메서드이다.
저장은 데이터베이스 상태를 바꾸는 작업이므로 Transaction 안에서 처리해야 한다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public boolean insertM(Meeting vo) { // 미팅 글 저장
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작
        em.persist(vo); // 저장 요청
        em.getTransaction().commit(); // 저장 확정
    } catch (Exception e) {
        result = false; // 실패 처리
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

persist(vo)는 Meeting 엔티티를 저장 대상으로 등록한다.
commit()이 실행되면 저장 작업이 확정된다.


Meeting은 IDENTITY 전략을 사용하므로 기본키 id는 데이터베이스가 자동으로 만들 수 있다.
따라서 저장할 때 사용자가 입력해야 하는 핵심 값은 친구 이름, 미팅 목적, 미팅 일시이다.


미팅 글 저장은 Meeting 객체를 만들고, persist()로 저장 요청한 뒤, commit()으로 확정하는 흐름이다.


deleteM은 기본키로 찾은 미팅 글을 삭제한다

deleteM()은 미팅 글을 삭제하는 메서드이다.
삭제도 데이터베이스 상태를 바꾸는 작업이므로 Transaction 안에서 처리해야 한다.


핵심 흐름은 아래와 같다.

// MeetingDAO.java
public boolean deleteM(int id) { // 미팅 글 삭제
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작

        Meeting vo = em.find(Meeting.class, id); // 삭제할 미팅 글 조회
        em.remove(vo); // 삭제 요청

        em.getTransaction().commit(); // 삭제 확정
    } catch (Exception e) {
        result = false; // 실패 처리
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

삭제는 먼저 삭제할 엔티티를 조회하는 것부터 시작한다.
find(Meeting.class, id)로 삭제할 미팅 글을 찾고, 그 결과를 remove(vo)에 전달한다.


삭제할 id가 실제로 존재하지 않으면 vo가 null일 수 있다.
그 상태에서 바로 remove(vo)를 호출하면 문제가 생길 수 있다.
현재 구조에서는 try-catch로 실패 처리될 수 있지만, 실제 프로그램에서는 삭제 대상이 있는지 먼저 확인하는 흐름이 더 안전하다.


삭제는 삭제할 엔티티를 먼저 조회하고, 조회된 엔티티를 remove()로 삭제 대상으로 만든 뒤 commit()으로 확정한다.


updateM은 조회한 영속 객체의 값을 바꿔 수정한다

updateM()은 기존 미팅 글을 수정하는 메서드이다.
JPA에서는 보통 update()라는 메서드를 따로 호출하지 않는다.
먼저 기존 엔티티를 조회한 뒤, 조회된 객체의 값을 바꾸면 commit() 시점에 변경 감지가 일어날 수 있다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public boolean updateM(Meeting vo) { // 미팅 글 수정
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작

        Meeting oldVo = em.find(Meeting.class, vo.getId()); // 기존 미팅 글 조회
        oldVo.setName(vo.getName()); // 이름 변경
        oldVo.setTitle(vo.getTitle()); // 제목 변경
        oldVo.setMeetingDate(vo.getMeetingDate()); // 미팅 일시 변경

        em.getTransaction().commit(); // 변경 감지 후 수정 확정
    } catch (Exception e) {
        result = false; // 실패 처리
        e.printStackTrace(); // 예외 로그 출력
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

oldVo는 find()로 조회된 영속 상태의 Meeting 객체이다.
영속 상태는 JPA가 관리하는 상태를 의미한다.


이 상태에서 oldVo.setName(...), oldVo.setTitle(...), oldVo.setMeetingDate(...)처럼 값을 바꾸면 JPA가 변경 내용을 감지할 수 있다.
그리고 commit() 시점에 수정 SQL이 실행될 수 있다.
이 흐름을 변경 감지라고 한다.


다만 수정할 id가 실제로 존재하지 않으면 oldVo가 null일 수 있다.
그 상태에서 oldVo.setName(), oldVo.setTitle(), oldVo.setMeetingDate()를 호출하면 문제가 생길 수 있다.
현재 코드는 try-catch로 감싸져 있으므로 이런 상황에서는 result=false로 실패 처리될 수 있다.


MeetingDAO의 수정도 영속 상태 엔티티의 값을 바꾸고 commit()하는 변경 감지 흐름으로 처리된다.


searchM은 이름 조건으로 미팅 글을 검색한다

searchM()은 이름이 일치하는 미팅 글을 조회한다.
앞의 Visitor 검색에서는 like를 사용했지만, 여기서는 = 조건을 사용한다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public List<Meeting> searchM(String name) { // 이름으로 미팅 글 검색
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    TypedQuery<Meeting> q = em.createQuery(
            "select t from Meeting t WHERE t.name = :name", Meeting.class
    ); // 이름 일치 조건 JPQL

    q.setParameter("name", name); // name 파라미터에 검색값 연결

    List<Meeting> list = q.getResultList(); // 결과 목록 조회

    em.close(); // EntityManager 닫기
    return list; // 검색 결과 반환
}

:name은 파라미터 자리이다.
setParameter("name", name)으로 입력받은 이름을 연결한다.


이 쿼리는 이름이 정확히 일치하는 미팅 글만 찾는다.
일부 문자열 검색이 아니라 정확한 이름 검색이다.
예를 들어 저장된 이름이 둘리라면 검색어도 둘리처럼 정확히 맞아야 결과가 나올 수 있다.

Visitor 검색과 Meeting 검색의 차이

  • Visitor 검색은 memo like :keyword 구조였다.
  • Visitor 검색은 글 내용에 검색어가 포함되면 조회될 수 있다.
  • Meeting 검색은 name = :name 구조이다.
  • Meeting 검색은 이름이 정확히 일치해야 조회된다.

searchM()은 포함 검색이 아니라 이름이 정확히 같은 미팅 글을 찾는 검색이다.


LocalDateTime 입력 형식을 맞춰야 한다

MeetingApp.java에서는 미팅 일시를 입력받는다.
이때 입력값은 LocalDateTime으로 변환될 수 있는 형태여야 한다.


입력 예시는 아래처럼 보면 된다.

// 입력예시
// 2026-04-30T14:30

2026-04-30은 날짜이다.
14:30은 시간이다.
가운데의 T는 날짜와 시간을 구분하는 문자이다.


형식으로 쓰면 yyyy-MM-ddTHH:mm 형태이다.
여기서 MM은 월이고, dd는 일이다.
HH는 24시간 기준 시간이고, mm은 분이다.


만약 2026-04-30 14:30처럼 공백으로 입력하거나, 2026/04/30처럼 날짜 구분 형식이 다르면 문자열을 LocalDateTime으로 바꾸는 과정에서 오류가 날 수 있다.
따라서 미팅 일시는 안내된 형식에 맞춰 입력해야 한다.


LocalDateTime으로 바꿀 날짜·시간 문자열은 2026-04-30T14:30처럼 날짜와 시간 사이에 T를 넣어야 한다.


MeetingApp은 메뉴로 미팅 글 기능과 댓글 기능을 실행한다

MeetingApp.java는 콘솔 메뉴를 통해 미팅 글 기능과 댓글 기능을 실행한다.
사용자가 번호를 입력하면 기능 호출 흐름이 시작된다.


메뉴 구조는 아래처럼 볼 수 있다.

// MeetingApp.java
// 1. 미팅글 작성
// 2. 미팅글 삭제
// 3. 이름으로 검색
// 4. 미팅글 수정
// 5. 댓글 작성
// 6. 댓글 읽기
// 7. 댓글 전체 읽기
// 8. 종료

1번부터 4번까지는 미팅 글 자체의 CRUD와 관련된다.
1번은 작성, 2번은 삭제, 3번은 이름 검색, 4번은 수정이다.


5번부터 7번은 댓글과 관련된다.
5번은 댓글 작성, 6번은 특정 미팅 글의 댓글 읽기, 7번은 댓글 전체 읽기 흐름이다.
댓글은 다음 Reply 예제에서 더 자세히 다룰 내용이다.
따라서 이번 25번에서는 미팅 글 CRUD를 중심으로 보고, 5번부터 7번은 다음 예제로 이어지는 결과 흐름으로만 확인한다.


또 메뉴 번호 입력은 숫자로 해야 한다.
nextInt()로 메뉴 번호를 읽는 구조라면, 숫자가 아닌 값을 입력했을 때 InputMismatchException이 발생할 수 있다.


25번에서는 1번부터 4번까지는 Meeting 글 자체의 CRUD 흐름으로 보고, 5번부터 7번까지는 다음 Reply 예제로 이어지는 댓글 흐름으로 구분해서 본다.


실행 결과는 1번부터 7번까지 메뉴 흐름으로 확인한다

MeetingApp.java는 메뉴형 프로그램이다.
따라서 실행 결과는 사용자가 어떤 번호를 입력하느냐에 따라 달라진다.


이번 예제의 실행 결과는 1번부터 7번까지 메뉴 흐름을 기준으로 확인한다.
1번부터 4번까지는 미팅 글 작성, 삭제, 검색, 수정 흐름이다.
5번부터 7번까지는 댓글 작성, 댓글 읽기, 댓글 전체 읽기 흐름이며, 다음 Reply 예제와 연결된다.


MeetingApp.java에서 1번 미팅글 작성 메뉴를 실행한 결과이다.
친구 이름, 미팅 목적, 미팅 일시를 입력하면 새 Meeting 데이터가 저장된다.
이때 미팅 일시는 2026-04-30T14:30처럼 날짜와 시간 사이에 T를 넣어 입력해야 한다.


MeetingApp.java에서 2번 미팅글 삭제 메뉴를 실행한 결과이다.
삭제할 미팅 글 번호를 입력하면 해당 Meeting 데이터를 찾아 삭제한다.
삭제 작업은 MeetingDAO의 deleteM()에서 삭제할 엔티티를 조회한 뒤 remove()로 삭제 대상으로 만들고, commit()으로 반영하는 흐름과 연결된다.


MeetingApp.java에서 3번 이름으로 검색 메뉴를 실행한 결과이다.
검색할 이름을 입력하면 Meeting.name 값이 입력값과 정확히 일치하는 미팅 글을 조회한다.
이 흐름은 MeetingDAO의 searchM()에서 name = :name 조건으로 검색하는 방식과 연결된다.


MeetingApp.java에서 4번 미팅글 수정 메뉴를 실행한 결과이다.
수정할 글 번호와 새 값을 입력하면 기존 Meeting 객체의 값이 변경된다.
수정은 영속 상태 객체의 값을 바꾸고, commit() 시점에 변경 감지로 데이터베이스에 반영되는 흐름이다.


MeetingApp.java에서 5번 댓글 작성과 6번 댓글 읽기 메뉴를 실행한 결과이다.
5번은 특정 미팅 글에 댓글을 작성하는 흐름이고, 6번은 특정 미팅 글에 연결된 댓글을 조회하는 흐름이다.
이 부분은 Meeting 글이 댓글인 Reply와 연결되는 다음 예제로 이어진다.


MeetingApp.java에서 7번 댓글 전체 읽기 메뉴를 실행한 결과이다.
특정 미팅 글 하나만 보는 것이 아니라, 저장된 댓글 전체를 조회하는 흐름이다.
이 결과는 다음 Reply 예제에서 댓글 엔티티와 미팅 글의 관계를 설명할 때 다시 연결해서 보면 된다.


이 결과를 통해 MeetingApp의 1번부터 4번은 미팅 글 CRUD 흐름이고, 5번부터 7번은 다음 Reply 예제로 이어지는 댓글 처리 흐름임을 확인할 수 있다.


핵심 정리

Meeting 예제는 미팅 글 데이터를 JPA로 처리하는 DAO 기반 CRUD 구조이다.
Meeting은 미팅 글 한 건을 표현하는 Entity이고, MeetingDAO는 실제 데이터베이스 작업을 담당한다.
MeetingApp은 사용자의 메뉴 입력을 받아 기능 실행 흐름을 시작한다.


정리하면 아래와 같다.

  • Meeting은 미팅 글 데이터를 담는 Entity이다.
  • id는 기본키이고, IDENTITY 전략으로 자동 생성될 수 있다.
  • meetingDate는 날짜와 시간을 함께 표현하는 LocalDateTime 타입이다.
  • meetingDate는 TIMESTAMP 컬럼과 연결될 수 있다.
  • MeetingDAO는 미팅 글 전체 조회, 단건 조회, 검색, 저장, 수정, 삭제를 담당한다.
  • listM()은 JPQL로 전체 목록을 조회한다.
  • oneM()은 find()로 기본키 기준 단건 조회를 한다.
  • insertM()은 persist()로 새 미팅 글을 저장한다.
  • deleteM()은 삭제할 객체를 조회한 뒤 remove()로 삭제한다.
  • updateM()은 조회한 영속 객체의 값을 바꾸고 변경 감지로 수정한다.
  • searchM()은 name = :name 조건으로 이름이 정확히 일치하는 미팅 글을 검색한다.
  • 저장, 수정, 삭제는 Transaction 안에서 처리한다.
  • MeetingApp의 1번부터 4번은 미팅 글 CRUD 메뉴이다.
  • MeetingApp의 5번부터 7번은 다음 Reply 예제로 이어지는 댓글 메뉴이다.

Meeting 예제는 날짜·시간 데이터를 포함한 게시글형 Entity를 DAO 기반으로 처리하고, 다음 댓글 관계 예제로 이어지는 기준 흐름을 만든다.




응용예제 26. Reply와 Meeting의 다대일 관계 확인하기 (Reply.java, Meeting.java, MeetingDAO.java, MeetingController.java, MeetingApp.java)

Reply는 댓글을 표현하는 Entity이고, Meeting 글에 연결된다.
미팅 글 하나에는 여러 댓글이 달릴 수 있다.
댓글 입장에서는 하나의 미팅 글을 참조하므로 Reply와 Meeting은 다대일 관계가 된다.


이 예제는 단순히 댓글을 저장하는 예제가 아니다.
댓글이 어떤 미팅 글에 속하는지 JPA 연관관계로 연결하고, 특정 미팅 글에 달린 댓글만 조회하는 흐름을 확인하는 예제이다.


이 예제의 핵심은 Reply가 Meeting을 참조하고, 데이터베이스에서는 refid 외래키로 댓글과 미팅 글이 연결된다는 점이다.


이전 기본개념과 연결하기

앞 예제에서는 Meeting 글 자체의 작성, 삭제, 검색, 수정 흐름을 확인했다.
Meeting은 미팅 글 한 건을 표현하는 Entity였고, MeetingDAO는 미팅 글 데이터를 처리했다.


이번 예제에서는 그 Meeting 글에 댓글을 연결한다.
댓글은 혼자 의미가 완성되는 데이터가 아니다.
어떤 미팅 글에 달린 댓글인지 알아야 한다.


그래서 Reply는 Meeting을 참조한다.
객체 코드에서는 Reply 안에 Meeting refid 필드가 있고, 데이터베이스에서는 댓글 테이블의 refid 외래키가 미팅 글의 기본키와 연결된다.

기본개념에서 다시 잡아야 할 흐름

  • Meeting은 미팅 글 한 건을 표현한다.
  • Reply는 댓글 한 건을 표현한다.
  • 미팅 글 하나에는 여러 댓글이 달릴 수 있다.
  • 댓글 하나는 하나의 미팅 글에 속한다.
  • 그래서 Reply에서 Meeting으로 가는 관계는 다대일이다.
  • 객체에서는 Reply가 Meeting 객체를 참조한다.
  • 데이터베이스에서는 refid 외래키로 연결된다.

댓글 데이터는 댓글 내용만 보는 것이 아니라, 어떤 부모 글에 연결되는지까지 함께 봐야 한다.


예제의 목표

이 예제의 목표는 Reply와 Meeting의 관계를 이해하는 것이다.
댓글을 저장할 때는 작성자와 내용만 저장하는 것이 아니라, 어떤 미팅 글에 달린 댓글인지도 함께 저장해야 한다.


또 댓글을 조회할 때도 전체 댓글을 조회할 수 있고, 특정 미팅 글에 달린 댓글만 조회할 수도 있다.
이때 특정 미팅 글의 댓글을 찾는 조건이 t.refid.id = :refid이다.


즉, 이번 예제는 댓글 저장, 특정 글 댓글 조회, 전체 댓글 조회를 통해 다대일 관계를 확인하는 예제이다.

이 예제에서 확인할 내용

  • Reply가 댓글 데이터를 담는 Entity인지 확인한다.
  • Reply가 Meeting을 @ManyToOne으로 참조하는지 확인한다.
  • @JoinColumn(name = "refid")가 외래키 컬럼과 연결되는지 확인한다.
  • 특정 미팅 글의 댓글을 t.refid.id = :refid 조건으로 조회하는지 확인한다.
  • 댓글 저장 시 어떤 미팅 글에 속한 댓글인지 함께 연결되어야 함을 이해한다.
  • 전체 댓글 조회와 특정 글 댓글 조회의 차이를 구분한다.

이번 예제는 댓글 Entity 자체보다 댓글이 Meeting 글과 어떻게 연결되는지를 보는 것이 중요하다.


Reply는 Meeting을 참조하는 댓글 Entity이다

Reply.java는 댓글 데이터를 표현하는 Entity이다.
댓글 번호, 댓글 작성자, 댓글 내용, 댓글이 속한 미팅 글을 필드로 가진다.


핵심 구조는 아래와 같다.

// Reply.java
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
@Entity // JPA 관리 대상 Entity
@AllArgsConstructor // 모든 필드를 받는 생성자 자동 생성
@NoArgsConstructor // 기본 생성자 자동 생성
public class Reply {
    @Id // 댓글 기본키
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id; // 댓글 번호

    private String name; // 댓글 작성자
    private String content; // 댓글 내용

    @ManyToOne(optional = false) // 여러 댓글이 하나의 Meeting에 연결됨
    @JoinColumn(name = "refid") // refid 외래키 컬럼과 연결
    private Meeting refid; // 댓글이 속한 미팅 글
}

@Entity는 Reply가 JPA 관리 대상이라는 뜻이다.
즉, Reply 객체는 댓글 테이블과 연결될 수 있다.


id는 댓글 한 건을 구분하는 기본키이다.
@GeneratedValue(strategy = GenerationType.IDENTITY)가 붙어 있으므로 기본키 값은 데이터베이스가 자동으로 만들 수 있다.


name은 댓글 작성자이고, content는 댓글 내용이다.
여기까지는 일반 댓글 데이터이다.


가장 중요한 필드는 refid이다.
이 필드는 숫자처럼 보이는 이름을 가지고 있지만, 실제 타입은 Meeting이다.
즉, 댓글이 속한 미팅 글 객체를 참조한다.


Reply의 refid는 단순 숫자 필드가 아니라, Meeting 엔티티를 참조하는 연관관계 필드이다.


ManyToOne은 여러 댓글이 하나의 미팅 글에 연결된다는 뜻이다

Reply에서 가장 중요한 매핑은 아래 부분이다.

// Reply.java
@ManyToOne(optional = false) // 여러 댓글이 하나의 Meeting에 연결됨
@JoinColumn(name = "refid") // refid 외래키 컬럼 사용
private Meeting refid; // 댓글이 속한 미팅 글

@ManyToOne은 다대일 관계를 의미한다.
여기서 다는 Reply이고, 일은 Meeting이다.
즉, 여러 댓글이 하나의 미팅 글에 연결될 수 있다는 뜻이다.


예를 들어 미팅 글 1번에 댓글이 세 개 달릴 수 있다.
그 댓글 세 개는 모두 같은 Meeting 글을 참조한다.
댓글 입장에서는 자신이 속한 미팅 글이 하나이다.


optional = false는 이 관계가 필수라는 뜻이다.
즉, 댓글은 연결된 미팅 글 없이 존재하기 어렵다는 구조이다.
댓글을 저장하려면 어떤 Meeting 글에 달린 댓글인지 함께 정해야 한다.


@JoinColumn(name = "refid")는 댓글 테이블에서 외래키로 사용할 컬럼 이름을 지정한다.
데이터베이스에서는 refid 컬럼이 Meeting의 기본키를 가리키는 역할을 한다.

관계를 나누어 보면

  • Reply 여러 개가 하나의 Meeting에 연결될 수 있다.
  • 댓글 하나는 특정 Meeting 하나를 참조한다.
  • 객체에서는 Reply.refid가 Meeting 객체를 가진다.
  • 데이터베이스에서는 refid 외래키가 Meeting의 기본키와 연결된다.

객체에서는 Meeting 객체 참조로 연결되고, 테이블에서는 refid 외래키 값으로 연결된다.


listReplyByMettingId는 특정 미팅 글의 댓글을 조회한다

MeetingDAO.java에는 특정 미팅 글 번호에 해당하는 댓글 목록을 조회하는 메서드가 있다.
메서드 이름에는 Metting이라고 되어 있지만, 의미는 미팅 글 번호로 댓글을 찾는 기능이다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public List<Reply> listReplyByMettingId(int refid) { // 특정 미팅 글의 댓글 조회
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    TypedQuery<Reply> q = em.createQuery(
            "select t from Reply t WHERE t.refid.id = :refid", Reply.class
    ); // Reply가 참조하는 Meeting의 id 조건

    q.setParameter("refid", refid); // refid 파라미터에 미팅 글 번호 연결

    List<Reply> list = q.getResultList(); // 댓글 목록 조회

    em.close(); // EntityManager 닫기
    return list; // 결과 반환
}

이 메서드는 특정 미팅 글에 달린 댓글만 조회한다.
refid 매개변수로 미팅 글 번호를 받는다.


select t from Reply t는 Reply 엔티티를 조회한다는 뜻이다.
여기서 t는 Reply를 가리키는 별칭이다.


WHERE t.refid.id = :refid가 핵심 조건이다.
t.refid는 댓글이 참조하는 Meeting 객체이다.
t.refid.id는 그 Meeting 객체의 기본키이다.


따라서 이 조건은 댓글 자신의 번호를 비교하는 것이 아니다.
댓글이 연결된 미팅 글의 번호를 비교한다.


t.refid.id는 Reply에서 Meeting으로 객체 참조를 따라간 뒤, Meeting의 기본키를 조건으로 사용하는 표현이다.


t.refid.id는 댓글이 연결된 미팅 글 번호를 의미한다

초보자가 가장 헷갈리기 쉬운 부분은 refid라는 이름이다.
refid라는 이름만 보면 숫자 값처럼 보일 수 있다.
하지만 Reply 클래스에서 refid의 타입은 Meeting이다.


다시 구조를 보면 아래와 같다.

// Reply.java
private Meeting refid; // 댓글이 속한 미팅 글

그래서 t.refid는 숫자가 아니라 Meeting 객체이다.
그리고 t.refid.id는 그 Meeting 객체의 id 값이다.


즉, 아래 표현은 이렇게 읽어야 한다.

// ReplyConditionRead.java
t.refid.id // 댓글이 참조하는 Meeting의 id

이 조건을 사용하면 특정 미팅 글에 달린 댓글만 찾을 수 있다.
예를 들어 refid에 3을 넣으면, Meeting.id가 3인 글에 연결된 댓글 목록을 찾는다.

refid 해석 기준

  • Reply.id는 댓글 자신의 기본키이다.
  • Reply.refid는 댓글이 참조하는 Meeting 객체이다.
  • Reply.refid.id는 댓글이 참조하는 Meeting의 기본키이다.
  • t.refid.id = :refid는 특정 미팅 글에 달린 댓글을 찾는 조건이다.

refid라는 이름 때문에 숫자 필드처럼 보이지만, 객체 코드에서는 Meeting 객체 참조라는 점을 놓치면 안 된다.


insertReply는 댓글을 저장한다

insertReply()는 댓글 엔티티를 저장한다.
댓글도 데이터베이스에 새 행을 추가하는 작업이므로 Transaction 안에서 처리된다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public boolean insertReply(Reply vo) { // 댓글 저장
    boolean result = true; // 성공 여부 기본값
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    try {
        em.getTransaction().begin(); // Transaction 시작

        System.out.println(vo); // 저장할 댓글 정보 출력
        em.persist(vo); // 댓글 저장 요청

        em.getTransaction().commit(); // 저장 확정
    } catch (Exception e) {
        result = false; // 실패 처리
        e.printStackTrace(); // 예외 로그 출력
    }

    em.close(); // EntityManager 닫기
    return result; // 성공 여부 반환
}

댓글 저장은 persist(vo)로 진행된다.
vo는 저장할 Reply 객체이다.


하지만 댓글은 작성자와 내용만 있어서는 충분하지 않다.
Reply에는 Meeting refid가 필수로 들어가야 한다.
즉, 댓글을 만들 때 어떤 미팅 글에 달린 댓글인지 함께 설정되어 있어야 한다.


MeetingApp.java에서는 댓글 작성 메뉴에서 댓글을 작성할 미팅 글 번호를 입력받는다.
그 번호를 기준으로 Meeting 글을 연결한 뒤 댓글을 저장하는 흐름으로 이해하면 된다.


댓글 저장의 핵심은 Reply 객체에 작성자, 내용뿐 아니라 연결할 Meeting 객체도 들어 있어야 한다는 점이다.


listR은 전체 댓글을 조회한다

MeetingDAO.java에는 전체 댓글을 조회하는 listR()도 있다.
특정 미팅 글의 댓글만 보는 것이 아니라, 저장된 모든 댓글을 조회하는 기능이다.


핵심 코드는 아래와 같다.

// MeetingDAO.java
public List<Reply> listR() { // 전체 댓글 조회
    EntityManager em = factory.createEntityManager(); // EntityManager 생성

    TypedQuery<Reply> q = em.createQuery(
            "select t from Reply t", Reply.class
    ); // Reply 전체 조회 JPQL

    List<Reply> list = q.getResultList(); // 댓글 목록 조회

    em.close(); // EntityManager 닫기
    return list; // 결과 반환
}

select t from Reply t는 모든 Reply를 조회한다는 뜻이다.
조건이 없기 때문에 특정 미팅 글의 댓글만 조회하지 않는다.


listReplyByMettingId()와 비교하면 차이가 분명하다.
listReplyByMettingId()는 WHERE t.refid.id = :refid 조건이 있다.
그래서 특정 미팅 글의 댓글만 조회한다.


반면 listR()은 조건이 없다.
그래서 전체 댓글을 조회한다.

두 조회 메서드의 차이

  • listReplyByMettingId()는 특정 미팅 글의 댓글만 조회한다.
  • listReplyByMettingId()는 t.refid.id = :refid 조건을 사용한다.
  • listR()은 전체 댓글을 조회한다.
  • listR()은 별도의 조건이 없다.

댓글 조회는 특정 미팅 글 기준 조회와 전체 댓글 조회를 구분해야 한다.


MeetingApp의 댓글 메뉴와 연결된다

MeetingApp.java에는 댓글 관련 메뉴가 있다.
5번은 댓글 작성, 6번은 특정 미팅 글의 댓글 읽기, 7번은 댓글 전체 읽기이다.


핵심 흐름은 아래와 같다.

// MeetingApp.java
if (num == 5) { // 댓글 작성 메뉴
    System.out.print("댓글 작성할 미팅글 번호 : "); // 미팅 글 번호 입력 안내
    int refid = Integer.parseInt(scan.nextLine()); // 미팅 글 번호 입력

    System.out.print("댓글 작성자 이름 : "); // 댓글 작성자 입력 안내
    String name = scan.nextLine(); // 작성자 입력

    System.out.print("댓글 내용 : "); // 댓글 내용 입력 안내
    String content = scan.nextLine(); // 내용 입력

    con.replyCreate(refid, name, content); // 댓글 작성 요청
} else if (num == 6) { // 특정 미팅 글 댓글 읽기
    System.out.print("읽고자 하는 댓글의 미팅글 번호 : "); // 미팅 글 번호 입력 안내
    int refid = Integer.parseInt(scan.nextLine()); // 미팅 글 번호 입력

    con.replyRead(refid); // 특정 미팅 글 댓글 읽기
} else if (num == 7) { // 댓글 전체 읽기
    con.replyAllRead(); // 전체 댓글 읽기
}

5번 댓글 작성에서는 미팅 글 번호, 댓글 작성자, 댓글 내용을 입력받는다.
여기서 미팅 글 번호가 댓글의 부모 글을 정하는 기준이 된다.


6번 댓글 읽기에서는 미팅 글 번호를 입력받는다.
이 번호를 기준으로 특정 미팅 글에 달린 댓글만 조회한다.


7번 댓글 전체 읽기는 특정 미팅 글 번호를 입력받지 않는다.
저장된 전체 댓글을 조회한다.


5번과 6번은 미팅 글 번호가 필요하고, 7번은 전체 댓글 조회이므로 미팅 글 번호가 필요하지 않다.


댓글 저장은 Meeting 글이 먼저 있어야 자연스럽다

댓글은 어떤 미팅 글에 달린 데이터이다.
따라서 댓글을 저장하려면 먼저 댓글을 달 미팅 글이 있어야 한다.


예를 들어 미팅 글 번호 3번에 댓글을 달려면, 먼저 Meeting.id가 3인 데이터가 존재해야 한다.
그다음 댓글 작성 메뉴에서 3을 입력해야 댓글이 그 미팅 글과 연결될 수 있다.


만약 존재하지 않는 미팅 글 번호를 입력하면 댓글을 연결할 대상이 없어서 저장 흐름에서 문제가 생길 수 있다.
Reply의 @ManyToOne(optional = false)도 댓글이 반드시 미팅 글에 연결되어야 한다는 구조를 보여 준다.

댓글 작성 전 확인할 내용

  • 미팅 글이 먼저 저장되어 있어야 한다.
  • 댓글 작성 시 입력하는 번호는 Meeting 글 번호이다.
  • 이 번호는 Reply 자신의 댓글 번호가 아니다.
  • 존재하지 않는 미팅 글 번호를 입력하면 저장이 실패할 수 있다.

댓글 작성 메뉴에서 입력하는 refid는 댓글 번호가 아니라 댓글이 달릴 미팅 글 번호이다.


실행 결과는 미팅 글 번호 기준으로 댓글이 연결되는지 확인한다

이 예제의 결과는 MeetingApp.java의 댓글 메뉴를 통해 확인한다.
댓글은 미팅 글이 먼저 있어야 자연스럽게 테스트할 수 있다.
따라서 먼저 미팅 글을 확인하고, 해당 미팅 글 번호를 기준으로 댓글을 작성한 뒤, 같은 번호로 댓글을 조회하는 흐름으로 보면 된다.


예상 결과 흐름은 아래와 같다.

// 출력결과 흐름
// 5 입력: 댓글 작성할 미팅 글 번호 입력 후 댓글 저장
// 6 입력: 같은 미팅 글 번호를 입력해 해당 글의 댓글 조회
// 7 입력: 전체 댓글 조회

결과를 볼 때는 댓글 내용만 보면 부족하다.
댓글이 어떤 미팅 글 번호와 연결되었는지를 함께 봐야 한다.


MeetingApp.java에서 5번 댓글 작성과 6번 댓글 읽기 메뉴를 실행한 결과이다.
5번에서는 댓글을 작성할 미팅 글 번호, 댓글 작성자, 댓글 내용을 입력한다.
이때 입력한 미팅 글 번호가 Reply의 refid 관계와 연결된다.


그다음 6번에서 같은 미팅 글 번호를 입력하면, 해당 미팅 글에 달린 댓글만 조회된다.
이 흐름은 MeetingDAO의 listReplyByMettingId()에서 t.refid.id = :refid 조건으로 조회하는 방식과 연결된다.


MeetingApp.java에서 7번 댓글 전체 읽기 메뉴를 실행한 결과이다.
특정 미팅 글 하나만 기준으로 보는 것이 아니라, 저장된 전체 댓글을 조회한다.
이 흐름은 MeetingDAO의 listR()에서 select t from Reply t로 전체 댓글을 조회하는 방식과 연결된다.


실행 결과에서는 댓글 작성 시 입력한 미팅 글 번호와 댓글 조회 시 입력한 미팅 글 번호가 같은지 확인해야 한다.


핵심 정리

Reply 예제는 댓글이 Meeting 글에 연결되는 다대일 관계를 확인하는 예제이다.
댓글은 작성자와 내용만 가진 독립 데이터처럼 보일 수 있지만, 실제로는 어떤 미팅 글에 달린 댓글인지 함께 저장되어야 한다.


정리하면 아래와 같다.

  • Reply는 댓글 데이터를 담는 Entity이다.
  • Reply에는 댓글 작성자 name과 댓글 내용 content가 있다.
  • Reply는 Meeting을 @ManyToOne으로 참조한다.
  • 미팅 글 하나에는 여러 댓글이 달릴 수 있다.
  • 댓글 하나는 하나의 미팅 글에 속한다.
  • @JoinColumn(name = "refid")는 댓글 테이블의 refid 외래키 컬럼을 의미한다.
  • Reply.refid는 숫자가 아니라 Meeting 객체 참조이다.
  • t.refid.id = :refid는 댓글이 연결된 미팅 글 번호를 기준으로 댓글을 조회하는 조건이다.
  • insertReply()는 댓글을 저장한다.
  • 댓글 저장 시 연결할 Meeting이 함께 설정되어야 한다.
  • listReplyByMettingId()는 특정 미팅 글의 댓글만 조회한다.
  • listR()은 전체 댓글을 조회한다.
  • MeetingApp의 5번은 댓글 작성, 6번은 특정 글 댓글 읽기, 7번은 전체 댓글 읽기이다.

Reply 예제의 핵심은 댓글 데이터가 독립적으로만 존재하는 것이 아니라, Meeting 엔티티를 참조하는 다대일 관계로 저장된다는 점이다.




응용예제 27. Book Entity는 단순 Entity 매핑 예제로 짧게 확인하기 (Book.java)

Book.java는 실행 앱과 직접 연결된 예제는 아니지만, 단순한 Entity 매핑 구조를 확인하기 좋은 파일이다.
id, title, price, kind처럼 책 정보를 표현하는 기본 필드만 가지고 있다.


앞의 Visitor, Meeting, Reply처럼 메뉴 실행 흐름이나 연관관계를 함께 확인하는 예제는 아니다.
따라서 이 파일은 Entity 기본 구조와 IDENTITY 기본키 자동 생성 전략을 확인하는 보조 예제로 보면 된다.


이 예제의 핵심은 연관관계 없이 기본키와 일반 필드만 가진 단순 Entity 구조를 확인하는 것이다.


이전 기본개념과 연결하기

앞의 Visitor, Meeting, Reply 예제에서는 실제 프로그램 구조에 가까운 흐름을 확인했다.
Visitor는 방명록 데이터를 처리했고, Meeting은 미팅 글 데이터를 처리했으며, Reply는 Meeting 글에 연결되는 댓글 데이터를 처리했다.


이 예제들은 DAO, Controller, 실행 앱, 연관관계까지 함께 봐야 했다.
그래서 파일 간 연결 흐름이 길었다.


반면 Book.java는 구조가 훨씬 단순하다.
다른 Entity를 참조하지 않고, 책 한 권의 기본 정보만 필드로 가진다.
그래서 복잡한 흐름보다 Entity의 기본 매핑 구조를 다시 확인하는 데 적합하다.

기본개념에서 다시 잡아야 할 흐름

  • Book은 책 한 권의 정보를 표현하는 Entity이다.
  • @Entity가 붙으면 JPA가 관리할 수 있는 클래스가 된다.
  • id는 Book의 기본키이다.
  • @GeneratedValue(strategy = GenerationType.IDENTITY)는 기본키 값을 데이터베이스가 자동 생성하게 한다.
  • title, price, kind는 일반 필드이다.
  • Book은 다른 Entity와 연관관계를 가지지 않는다.

Book은 복잡한 관계보다 Entity의 가장 기본적인 형태를 확인하는 예제이다.


예제의 목표

이 예제의 목표는 Book 클래스가 어떤 방식으로 데이터베이스 테이블과 연결될 수 있는지 확인하는 것이다.
실행 파일을 통해 저장하거나 조회하는 흐름을 보는 것이 아니라, Entity 클래스 자체의 매핑 구조를 읽는 데 초점을 둔다.


Book은 책 번호, 책 제목, 가격, 종류를 가진다.
기본키는 id이고, 책의 일반 정보는 title, price, kind 필드에 저장된다.


연관관계가 없기 때문에 @ManyToOne, @OneToOne, @OneToMany 같은 어노테이션은 나오지 않는다.
즉, 이 예제는 다른 테이블과 연결하는 흐름이 아니라, 한 테이블 안에서 기본키와 일반 컬럼이 어떻게 구성되는지 확인하는 예제이다.

이 예제에서 확인할 내용

  • Book에 @Entity가 붙어 있는지 확인한다.
  • id에 @Id가 붙어 기본키로 사용되는지 확인한다.
  • id가 IDENTITY 전략으로 자동 생성되는지 확인한다.
  • title, price, kind가 일반 필드인지 확인한다.
  • 다른 Entity를 참조하는 연관관계 필드가 없는지 확인한다.

이 예제는 실행 결과보다 Entity 클래스의 기본 매핑 구조를 읽는 것이 중요하다.


Book은 책 정보를 담는 단순 Entity이다

Book.java는 책 한 권의 정보를 표현하는 Entity이다.
책 번호, 제목, 가격, 종류를 필드로 가진다.


핵심 구조는 아래와 같다.

// Book.java
@Entity // JPA 관리 대상 Entity
@Getter // getter 자동 생성
@Setter // setter 자동 생성
@ToString // 객체 출력 문자열 자동 생성
public class Book {
    @Id // 책 기본키
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 기본키 자동 생성
    private int id; // 책 번호

    private String title; // 책 제목
    private int price; // 책 가격
    private String kind; // 책 종류
}

@Entity는 이 클래스가 JPA 관리 대상이라는 뜻이다.
즉, Book 객체는 데이터베이스 테이블과 연결될 수 있다.


@Id는 기본키 필드를 지정한다.
기본키는 테이블에서 각 행을 구분하는 값이다.
여기서는 id가 책 한 권을 구분하는 기준이다.


@GeneratedValue(strategy = GenerationType.IDENTITY)는 기본키 자동 생성 전략이다.
IDENTITY 전략은 기본키 값을 데이터베이스가 자동으로 생성하는 방식이다.
따라서 새 책 데이터를 저장할 때 id 값을 직접 넣지 않아도 될 수 있다.


Book의 id는 기본키이고, IDENTITY 전략으로 데이터베이스에서 자동 생성될 수 있다.


title, price, kind는 일반 필드이다

Book에서 title, price, kind는 일반 필드이다.
기본키도 아니고, 다른 Entity와 연결되는 연관관계 필드도 아니다.


핵심 필드는 아래와 같다.

// Book.java
private String title; // 책 제목
private int price; // 책 가격
private String kind; // 책 종류

title은 책 제목을 저장한다.
문자열 값이므로 타입은 String이다.


price는 책 가격을 저장한다.
숫자 값이므로 타입은 int이다.


kind는 책 종류를 저장한다.
예를 들어 책의 분류나 유형을 담는 값으로 볼 수 있다.
타입은 String이다.


이 필드들에는 별도의 @Column 설정이 없다.
따라서 기본 매핑 규칙에 따라 필드 이름과 같은 컬럼으로 연결될 수 있다.
다만 실제 테이블명과 컬럼명은 프로젝트의 이름 변환 설정이나 데이터베이스 상태에 따라 달라질 수 있다.

일반 필드에서 봐야 할 기준

  • title은 책 제목이다.
  • price는 책 가격이다.
  • kind는 책 종류이다.
  • 별도의 연관관계 어노테이션이 없다.
  • 일반 필드는 기본 매핑 규칙에 따라 컬럼으로 연결될 수 있다.

일반 필드는 객체의 값을 담는 기본 속성이며, 연관관계 필드처럼 다른 Entity를 참조하지 않는다.


Book은 연관관계 없이 기본 매핑만 확인한다

Book에는 @ManyToOne, @OneToOne, @OneToMany 같은 연관관계 어노테이션이 없다.
즉, 다른 Entity를 참조하지 않는다.


앞에서 본 Reply는 Meeting을 참조했다.
그래서 @ManyToOne과 @JoinColumn을 함께 확인해야 했다.
또 Member는 Team, Locker를 참조했기 때문에 객체 관계와 외래키 흐름을 함께 봐야 했다.


반면 Book은 그런 관계가 없다.
책 한 권의 정보가 한 객체 안에 단순하게 들어 있다.
그래서 기본키와 일반 필드만 확인하면 구조를 이해할 수 있다.

연관관계가 없는 Entity의 특징

  • 다른 Entity 타입 필드가 없다.
  • 외래키 역할을 하는 연관 필드가 없다.
  • @JoinColumn이 없다.
  • @ManyToOne, @OneToOne 같은 관계 어노테이션이 없다.
  • 기본키와 일반 필드 중심으로 구조를 읽으면 된다.

연관관계가 없는 Entity는 다른 객체로 이동하는 흐름이 없으므로, 기본키와 일반 필드 매핑을 중심으로 읽으면 된다.


Book과 Reply 구조를 비교하면 차이가 분명하다

Book과 Reply를 비교하면 단순 Entity와 연관관계가 있는 Entity의 차이를 쉽게 볼 수 있다.


Book은 책 자체의 정보만 가진다.
다른 객체를 참조하지 않는다.

// Book.java
private String title; // 책 제목
private int price; // 책 가격
private String kind; // 책 종류

반면 Reply는 댓글 내용만 가지는 것이 아니라, 어떤 미팅 글에 달린 댓글인지도 함께 가진다.

// Reply.java
@ManyToOne(optional = false) // 여러 댓글이 하나의 Meeting에 연결됨
@JoinColumn(name = "refid") // refid 외래키 컬럼과 연결
private Meeting refid; // 댓글이 속한 미팅 글

Book은 일반 필드만 보면 된다.
하지만 Reply는 Meeting을 참조하므로 객체 관계와 외래키까지 같이 봐야 한다.


이 차이를 알면 파일을 볼 때 먼저 무엇을 확인해야 하는지 정리된다.
연관관계가 없는 Entity는 기본 필드를 먼저 보고, 연관관계가 있는 Entity는 어떤 객체를 참조하는지 먼저 확인해야 한다.


Book은 단순 필드 중심의 Entity이고, Reply는 다른 Entity를 참조하는 관계 중심의 Entity이다.


실행 앱은 제공되지 않았으므로 매핑 구조만 확인한다

현재 Book은 실행 앱과 직접 연결된 결과 흐름을 확인하는 예제가 아니다.
따라서 저장, 조회, 수정, 삭제 결과까지 억지로 확장해서 설명하지 않는다.


제공된 코드 기준으로는 Book.java의 매핑 구조를 확인하는 정도가 가장 안전하다.
즉, 이 예제는 콘솔 결과를 확인하는 예제라기보다, Entity 클래스의 기본 형태를 정리하는 예제이다.


만약 나중에 Book을 저장하는 실행 파일이 추가된다면, persist()로 저장하고 find()나 JPQL로 조회하는 흐름까지 연결할 수 있다.
하지만 현재 단계에서는 실행 흐름이 아니라 매핑 구조를 확인하는 데 집중한다.


제공된 실행 앱이 없을 때는 결과를 억지로 만들지 말고, 현재 코드가 보여 주는 매핑 구조까지만 정리해야 한다.


핵심 정리

Book.java는 책 정보를 담는 단순 Entity이다.
복잡한 실행 흐름이나 연관관계 없이 기본키와 일반 필드 매핑만 확인하면 된다.


정리하면 아래와 같다.

  • Book은 책 한 권의 정보를 표현한다.
  • @Entity가 있으므로 JPA 관리 대상이다.
  • id는 기본키이다.
  • id는 IDENTITY 전략으로 자동 생성될 수 있다.
  • title은 책 제목이다.
  • price는 책 가격이다.
  • kind는 책 종류이다.
  • Book에는 다른 Entity를 참조하는 연관관계 필드가 없다.
  • Book에는 @ManyToOne, @OneToOne, @JoinColumn이 없다.
  • 현재 제공된 코드 기준으로는 실행 결과보다 매핑 구조를 확인하는 예제로 보면 된다.

Book 예제는 복잡한 관계 없이 Entity 기본 매핑을 다시 확인하는 짧은 보조 예제이다.

0개의 댓글