#13 - JPA 실무 가이드 2부 - FK, PK 관리
중년개발자
@loxo
12일 전
JPA 실무 가이드 2부: 연관관계와 복합키의 모든 것
Spring Boot 4 & PostgreSQL 환경에서의 실용주의(Pragmatic) 고급 매핑
"왜 객체를 넣으면 ID가 저장될까요? 원리를 파헤칩니다."
1. 연관관계의 핵심: "객체는 외래키(FK)를 모른다"
데이터베이스(DB) 세상에서는 **"외래키(FK)"**를 사용해 테이블을 연결합니다.
하지만 객체 세상에서는 **"참조(Reference)"**를 사용해 연결합니다.
- DB:
SELECT * FROM orders WHERE member_id = 1 - Java:
order.getMember()
JPA의 역할은 이 패러다임의 불일치를 해결하는 것입니다.
우리가 order.setMember(member)라고 하면,
JPA가 알아서 INSERT INTO orders (member_id) VALUES (1)로 통역해 줍니다.
2. @ManyToOne 단방향: 실무 최강의 조합
가장 많이 쓰는 패턴은 **"다대일(N:1) 단방향"**입니다.
주문(Order) 입장에서 생각해보세요. "나는 회원(Member)을 알고 싶어"
@Entity
public class Order {
// DB의 'member_id' 컬럼과 매핑됩니다.
// fetch = LAZY: "회원 정보는 진짜 필요할 때만 가져와!" (성능 최적화 필수)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}- 왜
LAZY가 필수인가요?EAGER(즉시 로딩)로 하면Order하나 조회할 때마다Member도 무조건 같이 조회합니다.- 만약 주문 목록 100개를 조회한다면? -> 쿼리가 101방(1 + 100) 나갑니다. (이것이 전설의 N+1 문제)
LAZY는 **"가짜 객체(Proxy)"**만 넣어두고, 실제member.getName()을 호출할 때 쿼리를 날립니다.
2-1. 요즘 트렌드: @ManyToOne vs 그냥 ID만 쓰기 (Long memberId)
QueryDSL이 있으니 굳이 연관관계를 맺지 않고 ID만 가져도 되지 않을까?
최근 실무(MSA 전환 과도기)에서 많이 고민하는 주제입니다.
| 특징 | Pattern A: @ManyToOne (정석) | Pattern B: Long memberId (ID 참조) |
|---|---|---|
| 장점 | 데이터 무결성(FK 제약), 객체 그래프 탐색(order.getMember().getName()) | 결합도 낮음, MSA 전환 용이, 복잡한 연관관계 없음 |
| 단점 | 잘못 쓰면 N+1 문제, 결합도 높음 | 객체 탐색 불가, 조인시 직접 ON절 작성 필요 |
| 추천 | 같은 도메인(패키지) 내의 관계 | 다른 도메인(MSA 분리 예정) 간의 관계 |
💡 가이드:
- 회원-주문 처럼 강한 결합이면
@ManyToOne을 쓰세요. (FK 도움 및 JPA 이점 활용) - 주문-배송로그 처럼 느슨하거나, 다른 서비스로 쪼개질 것 같으면
Long memberId만 가지세요. - QueryDSL: 어차피 조인(
join(order.member, member))은 둘 다 문법만 다를 뿐 똑같이 잘 됩니다.
3. INSERT 시점의 고민: "객체를 다 조회해야 하나요?"
Order를 저장하려면 Member가 필요합니다.
그런데 우리는 **회원 ID(PK)**만 알고 있는 경우가 많습니다. (화면에서 넘어온 memberId)
✅ 정석 방법: 조회 후 할당
가장 안전하고 일반적인 방법입니다. 이미 DB에 있는 친구를 데려와서 연결해 주는 것이죠.
@Transactional
public Long createOrder(Long memberId) {
// 1. "누구니?" -> DB에서 찾아옴 (SELECT 쿼리 발생)
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException());
// 2. "너랑 짝꿍이야" -> 객체 연결
Order order = Order.builder()
.member(member)
.build();
// 3. "저장!" -> JPA가 member의 ID를 꺼내서 FK로 저장함
orderRepository.save(order);
// 💡 INSERT는 Dirty Checking 대상이 아닙니다!
// 처음 저장할 때는 반드시 repository.save()를 호출해야 ID가 생성됩니다. (IDENTITY 전략)
}✅ 최적화 방법 (팁): 프록시(Proxy) 활용
"어차피 ID만 있으면 되는데, 굳이 SELECT 쿼리를 날려야 해?"
이럴 때 쓰는 것이 getReferenceById입니다.
@Transactional
public Long createOrderFast(Long memberId) {
// 1. "이름표만 줘" -> 가짜 객체(Proxy) 생성 (SELECT 안 함!)
// 이 객체는 ID값만 가지고 있고, 나머지는 비어있습니다.
Member memberProxy = memberRepository.getReferenceById(memberId);
// 2. 객체 연결 (ID는 있으니까 문제 없음)
Order order = Order.builder()
.member(memberProxy)
.build();
// 3. 저장! (DB에는 ID만 잘 들어감)
orderRepository.save(order);
}주의: 만약 없는
memberId였다면? ->save하는 시점에 FK 오류가 터집니다. (데이터 정합성은 DB가 지켜줌)
4. 복합키(Composite Key): "주민번호 + 이름이 PK라고요?"
보통은 id(Long) 하나만 PK로 쓰지만, 레거시 DB나 특정 상황에서는 두 개 이상의 컬럼을 묶어서 PK로 써야 할 때가 있습니다. (예: 주문번호 + 상품번호)
JPA에서는 이를 위해 별도의 **"식별자 클래스"**를 만들어야 합니다. (@IdClass)
✅ 실전 코드 (OrderItem: 주문번호 + 순번)
1. 식별자 클래스 (DTO 처럼 생겼지만 PK입니다)
@Getter
@NoArgsConstructor
@EqualsAndHashCode // ⚠️ 필수! (JPA가 이걸로 식별함)
public class OrderItemKey implements Serializable {
private Long orderId;
private Long itemSeq;
}2. 엔티티 적용 (@IdClass)
@Entity
@Getter
@IdClass(OrderItemKey.class) // "나의 PK는 저 클래스야"
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id // 복합키 1
@Column(name = "order_id")
private Long orderId;
@Id // 복합키 2
@Column(name = "item_seq")
private Long itemSeq;
private int quantity;
// ...
}3. 조회 방법 (Repository)
// 식별자 객체를 만들어서 찾아야 함
OrderItemKey key = new OrderItemKey(100L, 1L);
OrderItem item = repository.findById(key).orElseThrow(...);실무 팁: 복합키는 설정도 복잡하고 쿼리하기도 귀찮습니다. 가능하면 그냥
Long id(대리키) 하나만 쓰는 게 정신 건강에 좋습니다. 어쩔 수 없을 때만 쓰세요!
💡 Why? (복합키 대신 대리키를 쓰는 이유)
- API 설계의 단순함:
GET /items/{id}처럼 깔끔하게 호출할 수 있습니다. 복합키라면GET /items?orderId=1&itemSeq=2처럼 지저분해집니다. - 비즈니스 변경에 유연: 나중에 비즈니스 키(주문번호 등)가 변경되어도 PK(
Long id)는 불변이므로 DB 구조를 뜯어고칠 필요가 없습니다. - FK 매핑의 편리함: 다른 테이블에서
OrderItem을 참조할 때, 복합키라면 FK 컬럼이 2개가 생겨서 관리가 매우 복잡해집니다.
결론: 새로운 테이블이라면 무조건 Long id (IDENTITY)를 쓰세요. 복합키는 레거시 DB 연동 때만 씁니다.