Topic (오늘의 주제)
JPA 연관관계 매핑이 무엇인지, 왜 필요한지, 그리고 어떻게 사용하는지 이해한다.
객체지향 세계와 관계형 데이터베이스 세계의 패러다임 불일치를 해결하는 방법을 학습한다.
Why (왜 사용하는가? 왜 중요한가?)
- 객체지향 세계와 관계형 데이터베이스 세계는 관계를 표현하는 방식이 다릅니다. 객체는 참조를 통해 관계를 표현하지만, 데이터베이스는 외래 키를 통해 관계를 표현합니다. 이 패러다임 불일치를 해결하지 않으면 객체 간 협력이 불가능하고, 코드가 데이터베이스 설계에 종속되어 유지보수가 복잡해집니다.
- JPA 연관관계 매핑은 객체 참조를 사용하여 자연스러운 객체 탐색을 가능하게 하고, JPA의 지연 로딩, 캐시 기능을 활용할 수 있게 합니다. 이를 통해 개발자는 SQL에 집중하지 않고 객체 그래프 탐색에 집중할 수 있어 생산성이 크게 향상됩니다.
Core Concept (핵심 개념 정리)
1. 연관관계가 필요한 이유
패러다임 불일치 문제
객체지향 세계와 관계형 데이터베이스 세계는 관계를 표현하는 방식이 다릅니다.
| 구분 | 객체 지향 세계 | 관계형 데이터베이스 세계 |
|---|---|---|
| 관계 표현 | 객체 참조 (필드) | 외래 키 (Foreign Key) |
| 탐색 방식 | 객체 그래프 탐색 | JOIN 연산 |
| 관계 설정 | 코드로 참조 직접 연결 | 외래 키 값 지정 |
| 연관 관리 | 컬렉션 & 참조 | PK, FK로 관리 |
문제 상황 예시
잘못된 설계 (외래 키 직접 관리)
@Entity
public class Board {
@Id @GeneratedValue
private Long boardId;
private String boardTitle;
private String boardWriter; // 작성자 ID만 관리 (객체 참조 없음)
}
문제점:
- 객체 간 협력 불가 → 무조건 별도의 조회 필요
- 코드가 데이터베이스 설계에 종속
- 유지보수 복잡
올바른 설계 (객체 참조 사용)
@Entity
public class Board {
@Id @GeneratedValue
private Long boardId;
private String boardTitle;
@ManyToOne
@JoinColumn(name = "board_writer")
private Member member; // 객체 참조
}
장점:
- 자연스러운 객체 탐색:
board.getMember().getUserName() - 객체 간 협력 관계 명확
- 유지보수 용이
- JPA의 지연 로딩, 캐시 기능 활용 가능
[!tip] 핵심 포인트
연관관계 매핑의 목적은 객체 참조를 통해 객체지향적으로 설계하면서도, 데이터베이스의 외래 키 관계를 자동으로 관리하는 것입니다.
2. 연관관계 매핑 3가지 핵심 요소
2.1 다중성 (Multiplicity)
두 엔티티가 어떤 관계를 맺고 있는지, 관계의 수를 명확히 정하는 것이 가장 중요합니다.
| 관계 종류 | 어노테이션 | 설명 | 프로젝트 예시 |
|---|---|---|---|
| 다대일 (N:1) | @ManyToOne |
여러 엔티티가 하나의 엔티티를 참조 | BOARD → MEMBER (작성자) |
| 일대다 (1:N) | @OneToMany |
하나의 엔티티가 여러 엔티티를 참조 | (현재 프로젝트 미사용) |
| 일대일 (1:1) | @OneToOne |
하나의 엔티티가 하나의 엔티티를 참조 | PROFILE → MEMBER |
| 다대다 (N:M) | @ManyToMany |
여러 엔티티가 여러 엔티티를 참조 | (중간 엔티티로 해결) |
2.2 방향성 (Direction)
현재 프로젝트는 모두 단방향 관계를 사용
| 방향성 | 특징 | 프로젝트 예시 |
|---|---|---|
| 단방향 | 한쪽에서만 참조 | Board → Member |
| 양방향 | 양쪽 모두 참조 | (현재 미사용) |
단방향의 장점:
- 설계 단순, 구현 쉬움
- 관계 방향 명확
- 무한 루프 위험 없음
양방향의 특징:
- 양쪽 모두 참조 가능
- 연관관계의 주인 설정 필요
@ToString.Exclude로 무한 루프 방지 필요
2.3 연관관계의 주인 (Owner)
현재 프로젝트는 모두 단방향이므로 주인 개념 불필요
양방향 관계 시:
- 주인: 외래 키가 있는 쪽 (
@JoinColumn사용) - 비주인:
mappedBy로 주인 지정 - 규칙: 주인에만 값 설정, 비주인은 읽기 전용
[!important] 핵심 원리
다중성을 정확히 파악하는 것이 가장 중요합니다. 관계의 수를 명확히 정하면 방향성과 주인 설정은 자연스럽게 결정됩니다.
3. 관계 유형별 매핑 방법
3.1 다대일 (N:1) - 가장 많이 사용 ⭐
프로젝트에서 사용 중:
Board → MemberReply → BoardReply → MemberNotice → Member
예시 코드:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_writer", referencedColumnName = "userId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
핵심 포인트:
- ✅
fetch = FetchType.LAZY필수 (성능 최적화) - ✅
referencedColumnName은 엔티티 필드명 사용 (camelCase) - ✅
@JoinColumn(name)은 DB 컬럼명 (snake_case) - ✅
nullable = false로 필수 관계 명시
왜 LAZY를 사용하나?
- EAGER는 N+1 문제 발생 가능
- 작성자 정보가 항상 필요한 것은 아님
- 필요할 때만 조회하는 것이 효율적
@JoinColumn의 referencedColumnName
❌ 잘못된 사용:
@JoinColumn(name = "board_writer", referencedColumnName = "user_id")
✅ 올바른 사용:
@JoinColumn(name = "board_writer", referencedColumnName = "userId")
이유:
referencedColumnName은 엔티티 필드명 (camelCase)name은 DB 컬럼명 (snake_case)CamelCaseToUnderscoresNamingStrategy가 자동 변환
3.2 일대일 (1:1) - 조건부 사용
프로젝트에서 사용 중:
Profile → Member
예시 코드:
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "userId", nullable = false, unique = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
외래 키 위치:
- 현재 프로젝트: 대상 테이블(PROFILE)에 외래 키
- 장점: 구조 변경에 유리
- 단점: 항상 즉시 로딩 발생 (프록시 한계)
대안 (주 테이블에 외래 키):
// Member 엔티티에 추가
@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
선택 기준:
- 주 테이블: 조회 편리, 매핑 간단
- 대상 테이블: 구조 변경에 유리
3.3 다대다 (N:M) - 중간 엔티티로 해결 ⚠️
프로젝트에서 사용 중:
Board ↔ Tag→BoardTag중간 엔티티
예시 코드:
@IdClass(BoardTag.BoardTagId.class)
public class BoardTag {
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_no", referencedColumnName = "boardId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id", referencedColumnName = "tagId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Tag tag;
}
왜 중간 엔티티를 사용하나?
@ManyToMany는 실무에서 거의 사용 안 함- 중간 테이블에 추가 컬럼 필요 시 대응 불가
- 복합키로 관리하여 유연성 확보
4. 현재 프로젝트의 연관관계 구조
프로젝트 엔티티 관계도
MEMBER (1)
├── BOARD (N) - 다대일 단방향
├── REPLY (N) - 다대일 단방향
├── NOTICE (N) - 다대일 단방향
└── PROFILE (1) - 일대일 단방향
BOARD (1)
├── REPLY (N) - 다대일 단방향
└── BOARD_TAG (N) - 다대일 (중간 엔티티)
TAG (1)
└── BOARD_TAG (N) - 다대일 (중간 엔티티)
실제 코드 구조
1. Board → Member (다대일 단방향)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_writer", referencedColumnName = "userId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
특징:
@ManyToOne: 다대일 관계fetch = FetchType.LAZY: 지연 로딩 (성능 최적화)@OnDelete(CASCADE): 회원 삭제 시 게시글도 삭제
2. Reply → Board, Member (다대일 단방향)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ref_bno", referencedColumnName = "boardId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reply_writer", referencedColumnName = "userId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
특징:
- 댓글은 게시글과 작성자 모두 참조
- 양쪽 모두 CASCADE 삭제 설정
3. Profile → Member (일대일 단방향)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "userId", nullable = false, unique = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
특징:
@OneToOne: 일대일 관계unique = true: 외래 키에 유니크 제약조건- 외래 키를 대상 테이블(PROFILE)에 둠
4. BoardTag (다대다 관계의 중간 엔티티)
@IdClass(BoardTag.BoardTagId.class)
public class BoardTag {
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_no", referencedColumnName = "boardId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id", referencedColumnName = "tagId", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Tag tag;
}
특징:
- 복합키 사용 (
@IdClass) - 다대다 관계를 중간 엔티티로 해결
- 실무 권장 패턴
5. 실무 권장 패턴
✅ 권장 사항
| 관계 유형 | 권장 여부 | 프로젝트 적용 |
|---|---|---|
| 다대일 (N:1) | ⭐⭐⭐ 적극 사용 | Board, Reply, Notice |
| 일대일 (1:1) | ⭐⭐ 조건부 사용 | Profile |
| 일대다 (1:N) | ⚠️ 비권장 | 미사용 |
| 다대다 (N:M) | ❌ 사용 금지 | BoardTag로 해결 |
5.1 다대일 (N:1) 중심 설계
현재 프로젝트의 핵심 패턴:
// 모든 다대일 관계에 적용
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "...", referencedColumnName = "...", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
장점:
- 성능 최적화 (LAZY 로딩)
- 외래 키 관리 명확
- 코드 간결
5.2 일대일 (1:1) 외래 키 위치 전략
현재 프로젝트: 대상 테이블에 외래 키
// Profile 엔티티
@OneToOne
@JoinColumn(name = "user_id", unique = true)
private Member member;
대안: 주 테이블에 외래 키 (권장)
// Member 엔티티에 추가
@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
선택 기준:
- 주 테이블: 조회 편리, 매핑 간단
- 대상 테이블: 구조 변경에 유리
5.3 다대다 (N:M) 중간 엔티티 필수
프로젝트 적용:
BoardTag엔티티로 다대다 관계 해결- 복합키(
@IdClass) 사용 - 추가 컬럼 확장 가능
6. 주의사항 및 실전 팁
6.1 Enum 타입 매핑
프로젝트 적용:
@Enumerated(EnumType.STRING)
@Builder.Default
@Column(nullable = false, columnDefinition = "CHAR(1) DEFAULT 'Y'")
private Status status = Status.Y;
public enum Status {
Y, N;
}
핵심:
- ✅
EnumType.STRING사용 (가독성, 유지보수) - ❌
EnumType.ORDINAL사용 금지 (순서 변경 시 문제)
6.2 CASCADE 삭제 설정
프로젝트 적용:
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
효과:
- 회원 삭제 시 관련 게시글, 댓글 자동 삭제
- DB 레벨에서 처리 (안전)
6.3 BaseTimeEntity 활용
프로젝트 구조:
@MappedSuperclass
public abstract class BaseTimeEntity {
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
사용:
Board,Reply,Notice가 상속- 공통 시간 필드 관리
6.4 양방향 관계 시 주의사항 (현재 미사용)
만약 양방향을 사용한다면:
// Board (주인)
@ManyToOne
@JoinColumn(name = "board_writer")
private Member member;
// Member (비주인)
@OneToMany(mappedBy = "member")
@ToString.Exclude // 무한 루프 방지
private List<Board> boards = new ArrayList<>();
주의:
- 주인에만 값 설정:
board.setMember(member) mappedBy는 비주인에만 사용@ToString.Exclude로 무한 루프 방지
Practical Tip (사용시 주의할 점 or 활용 예)
연관관계 설계 시 확인사항
- 다중성 정확히 파악했는가? (N:1, 1:1, N:M)
- 단방향/양방향 결정했는가?
- 주인 설정했는가? (양방향 시)
-
fetch = FetchType.LAZY설정했는가? -
referencedColumnName에 엔티티 필드명 사용했는가? - CASCADE 삭제 필요 시 설정했는가?
- Enum 타입은
EnumType.STRING사용했는가?
활용 예시
1. 지연 로딩 활용
// Board 조회 시 Member는 조회하지 않음
Board board = boardRepository.findById(1L).orElseThrow();
// Member 접근 시에만 쿼리 실행 (지연 로딩)
String writerName = board.getMember().getUserName();
2. N+1 문제 해결
// ❌ N+1 문제 발생
List<Board> boards = boardRepository.findAll();
boards.forEach(board -> board.getMember().getUserName()); // 각 Board마다 Member 조회
// ✅ Fetch Join으로 해결
@Query("SELECT b FROM Board b JOIN FETCH b.member")
List<Board> findAllWithMember();
3. CASCADE 삭제 활용
// 회원 삭제 시 관련 게시글, 댓글 자동 삭제
memberRepository.delete(member);
// @OnDelete(CASCADE)로 인해 자동 처리'Spring' 카테고리의 다른 글
| Spring_14) IoC 모르고 DI를 이해 못 한 채 @Autowired를 누르다 (0) | 2025.12.23 |
|---|---|
| Spring_13 ) Q: 어떻게 했어? A: 스프링이 해주던데? (0) | 2025.12.23 |
| Spring_11) API (Application Programming Interface)란 무엇일까? (0) | 2025.12.10 |
| Spring-10) Scheduler - 자동 작업 실행 메커니즘 (1) | 2025.11.21 |
| Spring_09) Spring Boot - View Resolver와 AJAX 비동기 통신 (0) | 2025.11.07 |