Engineering Note

JPA 일대다 List 컬렉션 사이즈 조회, 동작 방식 본문

SW Engineering

JPA 일대다 List 컬렉션 사이즈 조회, 동작 방식

Software Engineer Kim 2025. 12. 26. 09:50

 

결론
JPA본질 객체지향 => 객체지향적으로 이해를 하면 메서드라는 것 자체가 객체 인스턴스의 행위고 인스턴스는 반드시 특정되는거라서 근데 그 특정 인스턴스가 Entity는 PK로 식별된다.



객체지향의 본질

 
 
java
event.getParticipants().size();

이 코드에서:

1. event는 이미 특정 인스턴스

 
 
java
Event event = entityManager.find(Event.class, 1L);
// event는 "ID가 1인 Event" 라는 특정 객체

event.getParticipants(); 
// "이 특정 event(id=1)의 participants를 달라"는 의미

객체지향적으로 보면:

  • event는 특정 인스턴스
  • getParticipants()는 이 인스턴스의 행위
  • 따라서 "이 event의 participants"가 당연한 것

 

2. Entity는 PK로 식별

일반 자바 객체:

 
 
java
Person person1 = new Person("홍길동");
Person person2 = new Person("홍길동");
// person1 != person2 (다른 인스턴스, 메모리 주소로 식별)

Entity 객체:

 
 
java
Event event1 = new Event(1L, "컨퍼런스");
Event event2 = new Event(1L, "컨퍼런스");
// PK가 같으면 같은 Entity! (영속성 컨텍스트에서 동일성 보장)

3. 연관관계도 객체 관점

 
 
java
class Event {
    private Long id;
    private List<EventParticipant> participants;
    
    public List<EventParticipant> getParticipants() {
        // "내(this Event)의 participants"
        return this.participants;
    }
}

객체지향 관점:

  • this.participants는 "이 Event 인스턴스에 속한" participants
  • JPA는 이걸 DB로 매핑할 때 PK를 사용

DB 매핑:

 
 
sql
-- "이 Event(this)의 participants"를 DB로 표현
SELECT * FROM participants 
WHERE event_id = ? -- this.id (특정 인스턴스의 PK)

 

 

---

Event event = eventRepository.findById(1L).orElseThrow();
int count = event.getParticipants().size();
```

**JPA 동작 순서**:
```
1. SELECT * FROM event_participant WHERE event_id = 1
   ↓
2. 모든 row를 EventParticipant 객체로 변환
   ↓
3. List<EventParticipant>에 담음 (메모리)
   ↓
4. Java List.size() 호출
   ↓
5. 결과 반환


event.getParticipants().size(); 이 자바코드가 카운트 쿼리를 수행하는 줄 알았다.

=> 내가 몰랐던 사실 나는 쿼리가 SELECT COUNT(*) FROM event_participant; 

문제 : 전체 데이터 로딩

 
 
java
participants.size();  
// SELECT * FROM event_participant WHERE event_id = ?
// 참여자 1000명이면 1000개 row 전부 메모리에!

비효율적:

  • 카운트만 필요한데 전체 데이터 로딩
  • SELECT COUNT(*) 쿼리가 훨씬 효율적

 

 

 

 

Before

 

public void vaidateAvialable() {
        // 1. 상태 체크
        if(this.status != EventStatus.ACTIVE) {
            throw new IllegalStateException("진행 중인 이벤트가 아닙니다.");
        }

        // 2. 기간 체크
        LocalDateTime now = LocalDateTime.now();
        if(now.isBefore(this.startDate)) {
            throw new IllegalStateException("이벤트가 아직 시작되지 않았습니다.");
        }

        if(now.isAfter(this.endDate)) {
            throw new IllegalStateException("이벤트가 종료 되었습니다.")
        }

        // 3. 선착순 인원 체크
        if(this.participants.size() >= maxParticipants) {
            this.status = EventStatus.SOLD_OUT;
            throw new IllegalStateException("선착순이 마감되었습니다.");
        }
}

 

 

 

수정 코드

 

 

/**
 * 이벤트 기간 및 상태 검증
 * - 선착순 인원 체크는 Service에서!
 */
public void validateEventPeriodAndStatus() {
    // 1. 상태 체크
    if (this.status != EventStatus.ACTIVE) {
        throw new IllegalStateException("진행 중인 이벤트가 아닙니다.");
    }
    
    // 2. 기간 체크
    LocalDateTime now = LocalDateTime.now();
    if (now.isBefore(this.startDate)) {
        throw new IllegalStateException("이벤트가 아직 시작되지 않았습니다.");
    }
    
    if (now.isAfter(this.endDate)) {
        throw new IllegalStateException("이벤트가 종료되었습니다.");
    }
}

 

 

@Transactional
public Long createOrder(Long eventId, Long memberId) {
    // 1. 조회
    Member member = memberRepository.findById(memberId)
        .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다."));
    
    Event event = eventRepository.findById(eventId)
        .orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다."));
    
    // 2. 이벤트 검증 (도메인)
    event.validateEventPeriodAndStatus();
    
    // 3. 중복 주문 체크
    if (participantRepository.existsByEventIdAndMemberId(eventId, memberId)) {
        throw new IllegalStateException("이미 주문한 이벤트입니다.");
    }
    
    // 4. 선착순 인원 체크 (COUNT 쿼리)
    long participantCount = participantRepository.countByEventId(eventId);
    if (participantCount >= event.getMaxParticipants()) {
        throw new IllegalStateException(
            "선착순 마감되었습니다. (마감 인원: " + event.getMaxParticipants() + "명)"
        );
    }
    
    // 5. 주문 생성 (Item 재고 차감)
    Order order = Order.createEventOrder(member, event);
    orderRepository.save(order);
    
    // 6. 참여자 기록
    EventParticipant participant = EventParticipant.create(event, member, order);
    participantRepository.save(participant);
    
    log.info("이벤트 주문 생성 완료. orderId={}, eventId={}, memberId={}", 
        order.getId(), eventId, memberId);
    
    return order.getId();
}



 

내가 몰랐던 개념

JPA의 **지연 로딩(Lazy Loading)**과 프록시 객체의 동작 원리를 이해하면 명확해집니다.

왜 WHERE 조건에 eventId가 포함되는가?

event.getParticipants()를 호출할 때, JPA는 다음과 같이 동작합니다:

1. 컨텍스트 인식

JPA는 participants 컬렉션이 어떤 Event 객체에 속해있는지 알고 있습니다. 이미 event 객체를 영속성 컨텍스트에서 관리하고 있고, 그 event의 ID를 알고 있죠.

2. 효율적인 쿼리 생성

전체 participants 테이블을 조회할 이유가 없습니다. 이미 특정 event의 participants만 필요하다는 것을 JPA가 알기 때문에:

 
 
sql
-- 비효율적 (당신의 예상)
SELECT * FROM participants;

-- 효율적 (실제 JPA의 동작)
SELECT * FROM participants WHERE event_id = ?;

3. 연관관계 매핑 정보 활용

 
 
java
@OneToMany(mappedBy = "event")
private List<EventParticipant> participants;

이 매핑은 JPA에게 다음을 알려줍니다:

  • EventParticipant 테이블에 event_id 외래키가 있다
  • 이 컬렉션은 특정 event에 속한 participants만 포함해야 한다

실제 동작 흐름

 
 
java
Event event = entityManager.find(Event.class, 1L); // event_id = 1
int count = event.getParticipants().size(); // 지연 로딩 트리거

내부 동작:

  1. getParticipants() 호출 → 프록시 컬렉션 반환
  2. size() 호출 → 실제 데이터 필요 → 지연 로딩 트리거
  3. JPA가 현재 event 객체의 ID(1L)를 확인
  4. SELECT * FROM participants WHERE event_id = 1 실행

만약 전체 조회를 원한다면?

전체 participants를 조회하려면 Repository를 통해 직접 조회해야 합니다:

 
 
java
// 전체 조회
List<EventParticipant> allParticipants = participantRepository.findAll();

// 특정 event의 participants (연관관계 활용)
int count = event.getParticipants().size(); // WHERE event_id = ?

핵심: JPA는 객체의 연관관계 매핑 정보를 활용해서, 필요한 데이터만 효율적으로 가져오도록 설계되어 있습니다. event.getParticipants()는 "이 event에 속한" participants를 의미하므로, WHERE 조건이 자동으로 추가되는 것이 당연한 동작입니다.

 

 

-> 영속성 컨텍스트는 Event Entity의 PK를 알고 있다.

 

 

영속성 컨텍스트의 구조

영속성 컨텍스트는 내부적으로 Map 구조로 되어 있습니다:

 
 
java
// 영속성 컨텍스트 내부 (개념적)
Map<EntityKey, Entity> persistenceContext = new HashMap<>();

// EntityKey 구조
class EntityKey {
    Class<?> entityClass;  // 예: Event.class
    Object id;             // 예: 1L (PK)
}

왜 PK가 필수인가?

1. 엔티티 식별

 
 
java
Event event = entityManager.find(Event.class, 1L);
// 내부적으로: persistenceContext.get(new EntityKey(Event.class, 1L))

영속성 컨텍스트에서 엔티티를 찾을 때:

  • 엔티티 타입 + PK로 유일하게 식별
  • PK가 없으면 같은 객체인지 구분 불가능

2. 연관관계에서의 활용

 
 
java
Event event = entityManager.find(Event.class, 1L);
// 영속성 컨텍스트: {(Event.class, 1L) -> Event객체}

event.getParticipants().size(); // 지연 로딩 발생

JPA의 내부 동작:

 
 
java
// 1. event 객체에서 PK 추출
Long eventId = event.getId(); // 1L

// 2. 이 정보로 쿼리 생성
SELECT * FROM participants WHERE event_id = 1;

3. 프록시 객체도 PK를 가짐

 
 
java
Event event = entityManager.getReference(Event.class, 1L);
// 아직 DB 조회 안 함, 프록시 객체

event.getParticipants(); 
// 프록시 객체도 ID(1L)는 알고 있음
// WHERE event_id = 1 쿼리 가능

실제 시나리오

 
 
java
@Entity
public class Event {
    @Id
    private Long id; // 이 값이 핵심!
    
    @OneToMany(mappedBy = "event")
    private List<EventParticipant> participants;
}

// 사용
Event event = entityManager.find(Event.class, 1L);
// 영속성 컨텍스트에 (Event.class, 1L) -> event 객체 저장됨

event.getParticipants().size();
// JPA: "이 event의 id는 1L이구나"
// SQL: WHERE event_id = 1

정리

  • 영속성 컨텍스트는 (엔티티 타입, PK) 를 키로 사용
  • 모든 영속 상태 엔티티는 반드시 PK 값을 가짐
  • 연관관계 조회 시 이 PK 값을 활용해서 WHERE 조건 생성
  • 그래서 event.getParticipants()가 "이 event(id=1)의 participants" 라는 것을 JPA가 정확히 알 수 있음

PK가 영속성 컨텍스트의 핵심이라는 당신의 이해가 정확합니다! 🎯

 

 

프록시 객체란?

프록시는 실제 엔티티를 상속받은 가짜 객체입니다. 겉모습은 같지만 내부는 텅 비어있죠.

 
 
java
Event event = entityManager.getReference(Event.class, 1L);
// 이때 반환되는 것은 Event가 아니라 Event$HibernateProxy 같은 것

프록시 객체의 구조

 
 
java
// 실제 Entity
@Entity
public class Event {
    @Id
    private Long id;
    private String name;
    private LocalDateTime startDate;
}

// JPA가 런타임에 생성하는 프록시 (개념적)
public class Event$HibernateProxy extends Event {
    private Long id;  // ⭐ PK만 가지고 있음!
    private EntityEntry entityEntry; // 영속성 컨텍스트 참조
    private boolean initialized = false; // 초기화 여부
    
    @Override
    public String getName() {
        if (!initialized) {
            // DB에서 실제 데이터 로딩
            loadRealEntity();
        }
        return super.getName();
    }
    
    @Override
    public Long getId() {
        return this.id; // ⭐ DB 조회 없이 바로 반환!
    }
}

왜 PK만 가지는가?

1. 프록시 생성 시점

 
 
java
Event event = entityManager.getReference(Event.class, 1L);

이 코드 실행 시:

  • DB 조회 안 함
  • PK(1L)만 받아서 프록시 생성
  • 나머지 필드는 비어있음 (null)
Comments