JPA 첫 걸음마

Agida·2025년 9월 7일

JPA

목록 보기
1/8
post-thumbnail

JPA 첫 걸음마

JPA 동작 원리 이해하기

EntityManagerFactory는 애플리케이션 전체에서 하나만 생성해서 공유하지만, EntityManager`는 스레드 간 공유하면 안 된다. 사용 후에는 반드시 닫아야 한다.

또한 JPA의 모든 데이터 변경 작업은 트랜잭션 안에서 실행되어야 한다. 이는 데이터 무결성을 보장하기 위한 필수 규칙이다.

CRUD 기본 연산

JPA를 이용한 기본적인 CRUD 연산은 생각보다 단순하다.

// 저장
Member member = new Member();
member.setId(1L);
member.setName("김개발");
em.persist(member);

// 조회
Member findMember = em.find(Member.class, 1L);

// 수정 - 별도의 update 메소드 없이 객체 값만 변경
findMember.setName("박개발");

// 삭제
em.remove(findMember);

수정 연산이 특히 인상적이었다. 별도의 update() 메소드 호출 없이 객체의 값만 변경하면 JPA가 자동으로 변경을 감지해서 UPDATE SQL을 생성한다.

JPQL - 객체지향 쿼리 언어

단순한 조회는 find() 메소드로 충분하지만, 복잡한 조건의 검색에는 JPQL이 필요하다. JPQL의 핵심은 테이블이 아닌 엔티티 객체를 대상으로 쿼리를 작성한다는 점이다.

List<Member> result = em.createQuery(
    "SELECT m FROM Member m WHERE m.name LIKE '%김%'", 
    Member.class
).getResultList();

SQL과 문법이 유사하지만, 테이블명 대신 엔티티 클래스명을, 컬럼명 대신 필드명을 사용한다. 이것이 바로 객체지향 쿼리의 핵심이다.

영속성 컨텍스트 (Persistence Context)

영속성 컨텍스트는 JPA의 핵심 개념으로, 엔티티를 영구 저장하는 환경을 의미한다. 엔티티 매니저를 통해 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관하고 관리한다.

엔티티 매니저 팩토리와 엔티티 매니저

  • 엔티티 매니저 팩토리: 애플리케이션 전체에서 공유되는 객체로, 엔티티 매니저를 생성하는 팩토리
  • 엔티티 매니저: 영속성 컨텍스트에 접근하고 엔티티를 관리하는 관리자
  • 영속성 컨텍스트: 논리적인 개념으로, 엔티티를 영구 저장하는 환경
// 엔티티 매니저 팩토리 생성 (애플리케이션 전체에서 하나)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook");

// 엔티티 매니저 생성 (요청마다 생성, 스레드 간 공유 금지)
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

try {
    tx.begin(); // 트랜잭션 시작
    // 비즈니스 로직 수행
    tx.commit(); // 트랜잭션 커밋
} catch (Exception e) {
    tx.rollback(); // 트랜잭션 롤백
} finally {
    em.close(); // 엔티티 매니저 종료
}

emf.close(); // 엔티티 매니저 팩토리 종료

엔티티의 생명주기

엔티티는 4가지 상태를 가진다:

1. 비영속 (new/transient)

영속성 컨텍스트와 전혀 관계가 없는 새로운 상태

// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId(1L);
member.setName("김개발");

2. 영속 (managed)

영속성 컨텍스트에 관리되는 상태

// 객체를 저장한 상태 (영속)
em.persist(member);

// 또는 조회한 상태도 영속 상태
Member findMember = em.find(Member.class, 1L);

3. 준영속 (detached)

영속성 컨텍스트에 저장되었다가 분리된 상태

// 엔티티를 영속성 컨텍스트에서 분리 (준영속)
em.detach(member);

// 영속성 컨텍스트를 완전히 초기화
em.clear();

// 영속성 컨텍스트를 종료
em.close();

4. 삭제 (removed)

삭제된 상태

// 엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제
em.remove(member);

영속성 컨텍스트의 이점

1. 1차 캐시

영속성 컨텍스트는 내부에 1차 캐시를 가지고 있어, 동일한 트랜잭션 내에서 같은 엔티티를 조회할 때 데이터베이스를 거치지 않고 1차 캐시에서 조회한다.

Member member = new Member();
member.setId(1L);
member.setName("김개발");

// 1차 캐시에 저장됨
em.persist(member);

// 1차 캐시에서 조회 (SELECT SQL이 실행되지 않음)
Member findMember1 = em.find(Member.class, 1L);
Member findMember2 = em.find(Member.class, 1L);

2. 동일성 보장

영속성 컨텍스트는 성능상 이점과 엔티티의 동일성을 보장한다.

Member a = em.find(Member.class, 1L);
Member b = em.find(Member.class, 1L);

System.out.println(a == b); // 동일성 비교 true

3. 트랜잭션을 지원하는 쓰기 지연 (Transactional Write-behind)

엔티티 매니저는 트랜잭션을 커밋하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 내부 쿼리 저장소에 INSERT SQL을 모아둔다.

transaction.begin(); // 트랜잭션 시작

em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.

// 커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다.
transaction.commit(); // 트랜잭션 커밋

4. 변경 감지 (Dirty Checking)

JPA는 엔티티의 변경사항을 자동으로 감지하여 UPDATE SQL을 생성한다.

transaction.begin(); // 트랜잭션 시작

// 영속 엔티티 조회
Member memberA = em.find(Member.class, 1L);

// 영속 엔티티 데이터 수정
memberA.setName("박개발");
memberA.setAge(30);

// em.update(member) 이런 코드가 있어야 하지 않을까?

transaction.commit(); // 트랜잭션 커밋 - 자동으로 UPDATE SQL 실행

변경 감지 동작 원리:
1. 트랜잭션을 커밋하면 엔티티 매니저 내부에서 먼저 flush() 호출
2. 엔티티와 스냅샷을 비교
3. 변경된 엔티티를 찾음
4. 수정된 엔티티에 대해 UPDATE SQL 생성
5. UPDATE SQL을 쓰기 지연 SQL 저장소에 보냄
6. 쓰기 지연 저장소의 SQL을 데이터베이스에 전송
7. 데이터베이스 트랜잭션을 커밋

5. 지연 로딩 (Lazy Loading)

실제 객체 대신 프록시 객체를 로딩해두고, 해당 객체를 실제 사용할 때 로딩한다.

플러시 (Flush)

플러시는 영속성 컨텍스트의 변경내용을 데이터베이스에 반영한다. 플러시가 실행되면:

  1. 변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교
  2. 수정된 엔티티를 찾아서 UPDATE SQL을 만들어 쓰기 지연 SQL 저장소에 등록
  3. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송

플러시하는 방법:

// 직접 호출
em.flush();

// 트랜잭션 커밋 시 자동 호출
transaction.commit();

// JPQL 쿼리 실행 시 자동 호출
em.createQuery("SELECT m FROM Member m", Member.class).getResultList();

플러시 모드 옵션:

// 커밋이나 쿼리를 실행할 때 플러시 (기본값)
em.setFlushMode(FlushModeType.AUTO);

// 커밋할 때만 플러시
em.setFlushMode(FlushModeType.COMMIT);

중요한 점:

  • 플러시가 호출되어도 영속성 컨텍스트가 비워지지는 않는다
  • 영속성 컨텍스트의 변경내용을 데이터베이스에 동기화하는 것이 플러시의 역할
  • 트랜잭션이라는 작업 단위가 중요하다 - 커밋 직전에만 동기화하면 된다

준영속 상태

영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 것을 준영속 상태라 한다. 준영속 상태에서는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.

준영속 상태로 만드는 방법:

// 특정 엔티티만 준영속 상태로 전환
em.detach(entity);

// 영속성 컨텍스트를 완전히 초기화
em.clear();

// 영속성 컨텍스트를 종료
em.close();

준영속 상태의 특징:

  • 거의 비영속 상태에 가깝다
  • 식별자 값을 가지고 있다
  • 지연 로딩을 할 수 없다

엔티티 매핑의 기초

엔티티 매핑은 객체와 관계형 데이터베이스의 테이블을 연결하는 핵심 기술이다. JPA에서 엔티티 매핑은 크게 4가지 영역으로 나뉜다.

매핑 어노테이션 분류

  • 객체와 테이블 매핑: @Entity, @Table
  • 필드와 컬럼 매핑: @Column
  • 기본 키 매핑: @Id
  • 연관관계 매핑: @ManyToOne, @JoinColumn

객체와 테이블 매핑 - @Entity

@Entity 어노테이션은 JPA의 핵심으로, 이 클래스가 데이터베이스 테이블과 매핑될 엔티티임을 선언한다.

@Entity
public class Member {
    @Id
    private Long id;
    private String name;
    
    // 기본 생성자 필수
    public Member() {}
    
    // Getter, Setter...
}

@Entity 사용 시 주의사항

  1. 기본 생성자는 필수 - JPA가 엔티티를 생성할 때 리플렉션을 사용하기 때문
  2. final, enum, interface, inner 클래스에는 사용 불가
  3. 저장할 필드에 final 사용 금지
@Entity(name = "Member") // 엔티티 이름 지정 (기본값: 클래스명)
public class Member {
    // ...
}

@Table - 테이블 세부 매핑

@Entity
@Table(name = "MEMBER_TBL",
       catalog = "USER_DB",
       schema = "USER_SCHEMA",
       uniqueConstraints = {
           @UniqueConstraint(name = "NAME_AGE_UNIQUE", 
                           columnNames = {"NAME", "AGE"})
       })
public class Member {
    // ...
}

데이터베이스 스키마 자동 생성

개발 단계에서 JPA는 엔티티 정보를 바탕으로 데이터베이스 테이블을 자동으로 생성할 수 있다.

hibernate.hbm2ddl.auto 옵션

  • create: 기존 테이블 삭제 후 다시 생성
  • create-drop: 애플리케이션 종료시 테이블 삭제
  • update: 변경 사항만 반영 (운영 환경 금지!)
  • validate: 엔티티와 테이블 매핑만 검증
  • none: 기능 사용하지 않음
# 개발 초기
hibernate.hbm2ddl.auto=create

# 개발 중기
hibernate.hbm2ddl.auto=update

# 운영 환경
hibernate.hbm2ddl.auto=validate

운영 환경에서는 절대 create, create-drop, update 사용 금지!

필드와 컬럼 매핑

@Column - 세밀한 컬럼 제어

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "member_name", 
            nullable = false,
            length = 50)
    private String username;
    
    @Column(precision = 10, scale = 2)
    private BigDecimal salary;
    
    @Column(columnDefinition = "varchar(100) default 'EMPTY'")
    private String description;
}

@Enumerated - Enum 타입 매핑

public enum RoleType {
    USER, ADMIN
}

@Entity
public class Member {
    @Enumerated(EnumType.STRING) // 반드시 STRING 사용!
    private RoleType roleType;
}

주의: ORDINAL은 절대 사용하지 말 것! Enum 순서가 바뀌면 데이터가 꼬인다.

@Temporal - 날짜 타입 매핑

@Entity
public class Member {
    @Temporal(TemporalType.DATE)
    private Date createdDate;  // 날짜만
    
    @Temporal(TemporalType.TIME)
    private Date createdTime;  // 시간만
    
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdTimestamp;  // 날짜와 시간
    
    // Java 8 이후는 어노테이션 생략 가능
    private LocalDate localDate;
    private LocalDateTime localDateTime;
}

@Lob - 큰 데이터 타입

@Entity
public class Member {
    @Lob
    private String longText;  // CLOB
    
    @Lob
    private byte[] binaryData;  // BLOB
}

@Transient - 매핑 제외

@Entity
public class Member {
    @Transient
    private String temp; // 데이터베이스에 저장되지 않음
}

기본 키 매핑

기본 키 매핑 전략

1. 직접 할당

@Entity
public class Member {
    @Id
    private Long id; // 개발자가 직접 할당
}

2. 자동 생성 - IDENTITY

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // MySQL AUTO_INCREMENT
}

특징: persist() 호출 즉시 INSERT SQL 실행

3. 자동 생성 - SEQUENCE

@Entity
@SequenceGenerator(
    name = "MEMBER_SEQ_GENERATOR",
    sequenceName = "MEMBER_SEQ",
    initialValue = 1,
    allocationSize = 50) // 성능 최적화
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
                   generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
}

4. 자동 생성 - TABLE

@Entity
@TableGenerator(
    name = "MEMBER_SEQ_GENERATOR",
    table = "MY_SEQUENCES",
    pkColumnValue = "MEMBER_SEQ")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE,
                   generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
}

권장하는 식별자 전략

Long형 + 대체키 + 키 생성 전략 조합 사용을 권장한다.

  • 자연키(주민등록번호, 전화번호)는 변경 가능성이 있어 적절하지 않음
  • 비즈니스와 무관한 대체키 사용
  • AUTO_INCREMENT나 SEQUENCE 활용
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // 권장하는 방식
    
    private String businessKey; // 비즈니스 식별자는 별도 필드로
}

실무 매핑 예제

@Entity
@Table(name = "MEMBER")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "name", nullable = false, length = 10)
    private String username;
    
    private Integer age;
    
    @Enumerated(EnumType.STRING)
    private RoleType roleType;
    
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdDate;
    
    @Temporal(TemporalType.TIMESTAMP)
    private Date lastModifiedDate;
    
    @Lob
    private String description;
    
    // 기본 생성자
    public Member() {}
    
    // Getter, Setter...
}

enum RoleType {
    USER, ADMIN
}

이렇게 엔티티 매핑을 통해 객체지향적인 도메인 모델과 관계형 데이터베이스를 효과적으로 연결할 수 있다.

연관관계 매핑 - 객체의 참조와 테이블의 외래키

객체지향 프로그래밍과 관계형 데이터베이스는 연관관계를 맺는 방식이 근본적으로 다르다.

객체 vs 테이블의 연관관계 차이점

테이블의 연관관계

  • 외래키 하나로 양방향 조회 가능
  • 테이블은 외래키 하나만 있으면 양쪽 테이블을 조인할 수 있다
SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

-- 또는 반대로도 가능
SELECT *
FROM TEAM T  
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

객체의 연관관계

  • 참조(주소)를 사용해서 연관관계를 맺는다
  • 참조에 접근하는 방향이 있으므로 단방향이다
  • 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다

단방향 연관관계

회원과 팀의 관계를 생각해보자. 회원은 하나의 팀에만 소속될 수 있다(다대일 관계).

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    // 연관관계 편의 메소드
    public void changeTeam(Team team) {
        this.team = team;
    }
}

@Entity
public class Team {
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;
    
    // Getter, Setter...
}

핵심 어노테이션

  • @ManyToOne: 다대일 관계 매핑
  • @JoinColumn(name = "TEAM_ID"): 외래키 매핑, 생략 시 '필드명_참조테이블기본키' 형식

조회와 저장

// 팀 저장
Team team = new Team();
team.setName("팀A");
em.persist(team);

// 회원 저장
Member member = new Member();
member.setUsername("김개발");
member.changeTeam(team); // 연관관계 설정
em.persist(member);

// 조회 - 객체 그래프 탐색
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();
System.out.println("팀 이름: " + findTeam.getName());

양방향 연관관계와 연관관계의 주인

양방향 연관관계는 단방향 연관관계가 2개 있는 것이다. 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개다.

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")  
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID") // 연관관계의 주인
    private Team team;
    
    public void changeTeam(Team team) {
        this.team = team;
    }
}

@Entity
public class Team {
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team") // mappedBy로 연관관계의 주인이 아님을 표시
    private List<Member> members = new ArrayList<>();
    
    // Getter, Setter...
}

연관관계의 주인 (Owner)

양방향 매핑 규칙:

  • 객체의 두 관계중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래키를 관리(등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용 안함
  • 주인이 아니면 mappedBy 속성으로 주인 지정

누구를 주인으로?

  • 외래키가 있는 곳을 주인으로 정하자
  • 여기서는 Member.team이 연관관계의 주인

양방향 연관관계의 주의점과 해결책

1. 연관관계의 주인에 값을 입력하지 않는 실수

// 잘못된 예 - 연관관계의 주인이 아닌 곳에만 값 설정
Team team = new Team();
team.setName("팀A");
em.persist(team);

Member member = new Member();
member.setUsername("김개발");
em.persist(member);

team.getMembers().add(member); // 역방향(주인 아님)만 연관관계 설정

// 결과: MEMBER 테이블의 TEAM_ID가 null
// 올바른 예 - 연관관계의 주인에 값 설정
Member member = new Member();
member.setUsername("김개발");
member.changeTeam(team); // 연관관계의 주인에 값 설정
em.persist(member);

2. 순수 객체 상태를 고려한 양방향 매핑

@Entity
public class Member {
    // ...
    
    public void changeTeam(Team team) {
        // 기존 팀과의 관계 제거
        if (this.team != null) {
            this.team.getMembers().remove(this);
        }
        
        this.team = team;
        
        // 새로운 팀에 멤버 추가
        if (team != null) {
            team.getMembers().add(this);
        }
    }
}

또는 팀에서도 편의 메소드 제공:

@Entity
public class Team {
    // ...
    
    public void addMember(Member member) {
        member.changeTeam(this);
        this.members.add(member);
    }
}

3. 무한 루프 주의

toString(), lombok, JSON 생성 라이브러리에서 무한 루프 조심:

// 위험한 코드
@Entity
public class Member {
    // ...
    
    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", team=" + team + // team.toString()도 member를 호출하면 무한루프
                '}';
    }
}

해결책: toString()에서는 연관관계 필드 제외하거나, 한쪽만 출력

정리

  1. 단방향 매핑만으로도 이미 연관관계 매핑은 완료
  2. 양방향 매핑은 반대 방향으로 조회 기능이 추가된 것뿐
  3. JPQL에서 역방향으로 탐색할 일이 많음
  4. 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨 (테이블에 영향 없음)

양방향 매핑 정리

  • 연관관계의 주인을 정하는 기준: 외래키의 위치
  • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
  • 연관관계의 주인은 단순히 외래키를 누가 관리하냐의 문제일 뿐

마무리

JPA는 단순히 SQL을 대체하는 도구가 아니라, 객체지향과 관계형 데이터베이스 사이의 패러다임 불일치를 해결하는 브릿지 역할을 한다는 점이다.

영속성 컨텍스트의 1차 캐시, 변경 감지, 지연 로딩 등의 기능들은 모두 개발자가 객체지향적 사고에 집중할 수 있도록 도와준다. 더 이상 SQL과 객체 사이의 변환 작업에 시간을 쏟지 않고, 비즈니스 로직 구현에 더 많은 에너지를 투자할 수 있게 되었다.

profile
백엔드

0개의 댓글