콘텐츠로 이동

JPA 개요 및 복합키

Java Persistence API(JPA)의 기본 개념과 복합키 매핑 방법을 설명합니다.


📋 JPA란?

**JPA (Java Persistence API)**는 자바 객체를 관계형 데이터베이스에 영속화하기 위한 표준 명세입니다.

flowchart LR
    subgraph Application["애플리케이션"]
        A[Java Object] --> B[JPA/Hibernate]
    end

    subgraph Database["데이터베이스"]
        C[(Table)]
    end

    B <-->|SQL| C

핵심 개념

개념 설명
Entity DB 테이블과 매핑되는 Java 클래스
EntityManager 엔티티의 생명주기를 관리
Persistence Context 엔티티를 관리하는 환경
JPQL 객체 지향 쿼리 언어

JPA 구현체

graph TD
    A[JPA 명세] --> B[Hibernate]
    A --> C[EclipseLink]
    A --> D[OpenJPA]

    B --> E[가장 많이 사용]

🔑 기본키 전략

단일 기본키

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}

기본키 생성 전략

전략 설명 사용처
IDENTITY DB에 위임 (AUTO_INCREMENT) MySQL, PostgreSQL
SEQUENCE 시퀀스 사용 Oracle, PostgreSQL
TABLE 키 생성 테이블 사용 모든 DB
AUTO 자동 선택 기본값

🔗 복합키 (Composite Key)

두 개 이상의 컬럼을 기본키로 사용하는 경우 복합키를 정의합니다.

복합키 구현 방법

flowchart TB
    A[복합키 구현] --> B["@EmbeddedId<br/>(권장)"]
    A --> C["@IdClass"]

    B --> D["별도 클래스에<br/>@Embeddable"]
    C --> E["엔티티에<br/>직접 @Id 2개"]

📝 @EmbeddedId 방식 (권장)

1. 복합키 클래스 정의

@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode  // 필수!
public class AlarmReadsId implements Serializable {

    @Column(name = "alarm_id")
    private Long alarmId;

    @Column(name = "user_id")
    private Long userId;
}

⚠️ 중요: 복합키 클래스는 반드시 Serializable 구현하고, equals(), hashCode() 재정의해야 합니다.

2. 엔티티 클래스 정의

@Entity
@NoArgsConstructor
@Table(name = "alarm_reads")
public class AlarmReads {

    @EmbeddedId
    private AlarmReadsId id;

    @MapsId("userId")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @MapsId("alarmId")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "alarm_id", nullable = false)
    private Alarm alarm;

    @CreationTimestamp
    @Column(name = "read_at")
    private Timestamp readAt;
}

3. 사용 예시

// 복합키 객체 생성
AlarmReadsId id = new AlarmReadsId(alarmId, userId);

// 엔티티 생성 및 저장
AlarmReads alarmRead = new AlarmReads();
alarmRead.setId(id);
alarmRead.setUser(user);
alarmRead.setAlarm(alarm);

alarmReadsRepository.save(alarmRead);

// 조회
Optional<AlarmReads> found = alarmReadsRepository.findById(id);

4. Repository 정의

public interface AlarmReadsRepository 
    extends JpaRepository<AlarmReads, AlarmReadsId> {

    // 사용자별 읽은 알람 조회
    List<AlarmReads> findByIdUserId(Long userId);

    // 특정 알람을 읽은 사용자 수
    long countByIdAlarmId(Long alarmId);
}

📝 @IdClass 방식

1. ID 클래스 정의

@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class AlarmReadsId implements Serializable {
    private Long alarm;  // 엔티티의 필드명과 일치해야 함
    private Long user;
}

2. 엔티티 정의

@Entity
@Table(name = "alarm_reads")
@IdClass(AlarmReadsId.class)
public class AlarmReads {

    @Id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "alarm_id")
    private Alarm alarm;

    @Id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @CreationTimestamp
    @Column(name = "read_at")
    private Timestamp readAt;
}

📊 방식 비교

특성 @EmbeddedId @IdClass
객체지향적 ✅ 더 객체지향적 ❌ 덜 객체지향적
JPQL 접근 e.id.alarmId e.alarmId
식별 관계 복잡할 수 있음 간단함
선호도 권장 레거시 코드

🔄 @MapsId 어노테이션

@MapsId는 외래키가 기본키의 일부일 때 사용합니다.

erDiagram
    USERS ||--o{ ALARM_READS : "1:N"
    ALARMS ||--o{ ALARM_READS : "1:N"

    USERS {
        bigint id PK
        varchar name
    }

    ALARMS {
        bigint id PK
        varchar message
    }

    ALARM_READS {
        bigint user_id PK,FK
        bigint alarm_id PK,FK
        timestamp read_at
    }
@MapsId("userId")  // AlarmReadsId.userId에 매핑
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

동작 방식: 1. @MapsId("userId")AlarmReadsId.userId 필드에 매핑 2. User 엔티티의 id 값이 자동으로 복합키에 설정됨 3. 별도로 alarmReadsId.setUserId() 호출 불필요


💡 실전 팁

1. N+1 문제 방지

// 페치 조인 사용
@Query("SELECT ar FROM AlarmReads ar " +
       "JOIN FETCH ar.alarm " +
       "WHERE ar.id.userId = :userId")
List<AlarmReads> findByUserIdWithAlarm(@Param("userId") Long userId);

2. 복합키로 벌크 삭제

@Modifying
@Query("DELETE FROM AlarmReads ar " +
       "WHERE ar.id.userId = :userId AND ar.id.alarmId IN :alarmIds")
void deleteByUserIdAndAlarmIds(
    @Param("userId") Long userId, 
    @Param("alarmIds") List<Long> alarmIds
);

3. 존재 여부 확인

// 효율적인 존재 확인
boolean exists = alarmReadsRepository.existsById(
    new AlarmReadsId(alarmId, userId)
);

⚠️ 주의사항

equals()와 hashCode() 필수 구현

@Embeddable
public class AlarmReadsId implements Serializable {

    private Long alarmId;
    private Long userId;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        AlarmReadsId that = (AlarmReadsId) o;
        return Objects.equals(alarmId, that.alarmId) && 
               Objects.equals(userId, that.userId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(alarmId, userId);
    }
}

프록시 이슈

// LAZY 로딩 시 프록시 비교 주의
// getId()로 비교하거나 Hibernate.initialize() 사용

🔗 관련 문서


📚 참고 자료