1. 객체지향 관점의 단방향 (User -> Profile)
"User가 Profile을 소유한다."
가장 객체지향적인, 그리고 가장 단순한 접근 방식. User가 Profile을 알지만, Profile은 User의 존재를 모른다.
- DB 스키마: users 테이블에 profile_id (FK)가 생긴다.
- 특징: mappedBy가 없습니다. 코드가 단순해진다.
// User.java (관계의 '주인')
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 1. User 저장/삭제 시 Profile도 함께 처리
// 2. User와의 연결이 끊어지면(null) Profile 레코드도 DB에서 삭제
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "profile_id") // 'users' 테이블에 생성될 FK 컬럼
private Profile profile;
// == 비즈니스 로직 ==
public void createProfile(String bio) {
this.profile = new Profile(bio);
}
public void updateProfile(String newBio) {
if (this.profile != null) {
this.profile.update(newBio);
}
}
}
// Profile.java (아주 단순한 POJO)
@Entity
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
// User에 대한 참조가 전혀 없음!
protected Profile() {} // JPA용
public Profile(String bio) { this.bio = bio; }
public void update(String bio) { this.bio = bio; }
}
트레이드 오프 (Trade-off)
- 이득 (Pros):
- 압도적인 단순함: 코드가 정말 깨끗하다. mappedBy도 없고, 순환 참조도 없다.
- 직관적인 로직: user.getProfile() 사용이 가능. 모든 로직은 User를 통해서만 시작된다. (DDD의 '애그리거트 루트'와 유사)
- JPA 기능 활용: cascade와 orphanRemoval = true 옵션으로 Profile의 생명주기를 User에 완벽히 위임할 수 있다.
- 비용 (Cons):
- DB 설계의 어색함: DBA가 본다면 의아해할 수 있다. users 테이블에 profile_id가 생기는데, Profile이 나중에 생길 수 있으므로 이 FK는 NULL을 허용해야 한다.
- 역방향 조회 불가능: Profile 객체만으로는 User를 찾을 수 없다. (하지만 ProfileRepository를 통해 User로 조회는 가능.)
2. DB 설계 관점의 단방향 (Profile -> User)
"Profile이 User에게 종속된다."
DBA가 가장 선호하는, 데이터베이스 정규화 원칙에 가장 충실한 설계. Profile은 User 없이는 존재할 수 없으므로, Profile이 User의 id를 FK로 갖는다.
- DB 스키마: profiles 테이블에 user_id (FK)가 생긴다.
- 특징: user_id에 NOT NULL, UNIQUE 제약조건을 걸어 1:1 관계를 DB단에서 보장할 수 있다.
// Profile.java (관계의 '주인')
@Entity
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Profile이 User를 참조 (FK는 여기에)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;
// ...
}
// User.java (Profile을 전혀 모름)
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Profile에 대한 참조가 없음
}
트레이드 오프 (Trade-off)
- 이득 (Pros):
- 완벽한 DB 정합성: DB 스키마가 매우 깔끔하고, Profile이 User에 종속됨을 명확히 표현할 수 있다. (NOT NULL, UNIQUE)
- 비용 (Cons):
- 개발자 경험(DX) 최악: ORM을 사용하는 의미가 퇴색됩니다. user.getProfile()을 호출할 수 없습니다.
- 불편한 로직: User의 Profile을 찾으려면 User 객체를 ProfileRepository에 넘겨 profileRepository.findByUser(user) 같은 쿼리를 매번 실행해야 합니다. 객체 그래프 탐색이 불가능합니다.
- 결론: ORM을 쓴다면 절대 피해야 할 설계.
3. 두 마리 토끼? 양방향 관계 [ User <-> Profile ex) @MapsId ]
"서로가 서로를 참조하며, DB 정합성도 완벽하게!"
2번(DB 설계)의 장점과 1번(객체지향)의 장점을 모두 취하려는 시도. Profile이 User의 id를 FK로 갖되, 자신의 기본 키(PK)로도 사용한다. (@MapsId 사용) 그리고 User는 Profile을 mappedBy로 참조하여 user.getProfile()이 가능하게 한다.
- DB 스키마: profiles 테이블의 PK(profile_id)가 users 테이블의 PK를 참조하는 FK가 됩니다.
- 특징: DB단에서 1:1 관계가 완벽하게 보장되며, 객체에서도 양방향 탐색이 가능합니다.
// User.java (주인이 아님, 'mappedBy')
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 나는 주인이 아니다.
// Profile 엔티티의 'user' 필드에 의해 관리된다.
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true)
private Profile profile;
// == 연관관계 편의 메서드 ==
// (양방향일 때 가장 실수하기 쉬운 부분)
public void setProfile(Profile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
}
// Profile.java (관계의 '주인', @MapsId 사용)
@Entity
public class Profile {
@Id // User의 ID와 동일한 값을 PK로 사용
private Long id;
private String bio;
// 이 'id' 필드를 'user'의 PK와 매핑
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "user_id") // 'profiles' 테이블의 PK 컬럼명
private User user;
// ...
// setUser는 User 클래스의 편의 메서드에서 호출
protected void setUser(User user) {
this.user = user;
if (user != null) {
this.id = user.getId();
}
}
}
트레이드 오프 (Trade-off)
- 이득 (Pros):
- 완벽한 DB 정합성: @MapsId로 인해 User와 Profile의 생명주기가 PK 레벨에서 완벽히 일치.
- 양방향 탐색: user.getProfile()과 profile.getUser()가 모두 가능.
- 비용 (Cons):
- 치명적인 복잡성 (유지보수 비용): "완벽함"을 위해 치러야 할 비용이 너무 크다.
- mappedBy: 관계의 주인을 명확히 이해해야 한다.
- 연관관계 편의 메서드: user.setProfile(profile)을 할 때, profile.setUser(user)도 양쪽에서 세팅해줘야 한다. 이걸 잊으면 객체 상태가 꼬이고 심각한 버그 발생가능.
- 무한 루프: toString()이나 JSON 직렬화 시 user -> profile -> user ... 무한 루프에 빠진다. (@JsonIgnore, @ToString.Exclude 등 추가 작업 필수)
- 테스트 복잡: 테스트 코드 작성이 훨씬 복잡해진다.
결론
신입 개발자일수록 "완벽한 설계"에 집착하는 경향이 있다. 하지만 현업의 훌륭한 코드는 '완벽한' 코드가 아니라 '단순하고, 읽기 쉽고, 변경하기 쉬운' 코드이다.
'1번: 객체지향 관점의 단방향 (User → Profile)'으로 시작하는게 좋다. 이 설계는 mappedBy의 저주와 연관관계 편의 메서드의 함정, 그리고 무한 루프의 위험에서 벗어날 수 있다.
cascade, orphanRemoval를 적극 활용해야한다. DB 정합성이 조금 어색한 것(FK가 NULL 허용)은 JPA의 이 옵션들이 "애플리케이션 레벨에서 비즈니스 무결성"을 완벽하게 보장해 준다.
YAGNI (You Ain't Gonna Need It) 원칙을 기억해야한다. profile.getUser() 같은 역방향 조회가 정말로 필요한 순간은 거의 오지 않는다. 99%의 비즈니스는 User에서 시작합니다. 그 기능이 필요하지도 않은데, 3번(@MapsId)을 선택해서 엄청난 유지보수 비용을 지불할 필요는 없다.
복잡한 완벽함보다, 단순하고 실용적인 설계를 선택하는 것이 중요하다
'프로젝트 > 회고' 카테고리의 다른 글
| FK(Foreign Key)는 필요한가 (0) | 2025.11.08 |
|---|