배경
기존 타임피스 프로젝트의 소스코드에는 JPA의 연관관계 매핑(@OneToMany
, @ManyToOne
등)이 과도하게 사용되고 있었습니다. 이와 함께 외래키 제약조건도 활성화되어 있었죠. 다행히도 @ManyToMany
는 사용하지 않고 있었지만, 개발이 진행될수록 두 가지 측면에서 문제가 발생했습니다
1) 과도한 연관관계 매핑으로 인한 코드 결합도 증가와 2) FK 제약조건으로 인한 운영 제약입니다.
프로젝트가 성장하면서 이러한 문제점들이 실제 운영 및 개발에 걸림돌이 되기 시작했고, 근본적인 해결책으로 식별자 참조 방식으로의 전환을 결정했습니다.
JPA 연관관계 매핑과 FK 제약조건의 구분
먼저, JPA의 연관관계 매핑과 데이터베이스의 FK 제약조건은 별개의 개념이라는 걸 짚고 넘어가겠습니다.
- JPA 연관관계 매핑: 자바 객체 간의 참조 관계를 정의하는 것으로, 객체 그래프 탐색을 위한 객체지향적 방식입니다.
@ManyToOne
,@OneToMany
등의 어노테이션으로 구현됩니다. - FK 제약조건: 데이터베이스 레벨에서 테이블 간의 참조 무결성을 보장하는 제약사항입니다.
JPA에서는 연관관계 매핑을 사용하면서도 DB에 물리적인 FK 제약조건을 사용하지 않는 것이 가능합니다. 반대로 FK 제약조건은 사용하고 연관관계 매핑 없이 개발하는 것도 가능합니다. JPA를 처음 접하면 두 개념이 흔히 함께 사용되어 헷갈릴 수 있지만, 이 둘은 완전히 다른 문제입니다.
프로젝트의 문제 상황
우리 프로젝트에서는 두 가지 차원의 문제가 존재했습니다:
과도한 연관관계 매핑의 문제
- 양방향 매핑의 남용
- A→B를 알고, B→A는 몰라도 되는 상황에서도 A↔B를 양방향으로 연관 관계가 설정됨
- 관계가 변경될 경우 연관관계 매핑에 의존하고 있는 모든 코드를 찾아 수정해야 했음
- 도메인 객체 간 강한 결합
- 객체 그래프를 따라 참조하는 코드가 많아지면서 각 도메인 객체의 독립성 저하
- 특정 엔티티의 수정이 연관된 모든 엔티티에 영향을 미치는 구조
- N+1 문제 발생
- 지연 로딩(LAZY)을 사용하더라도 실수로 N+1 쿼리가 발생하기 쉬운 구조
- 성능 튜닝 시 fetch join이나 EntityGraph 등 추가적인 최적화 필요
FK 제약조건으로 인한 운영 이슈
- 데이터 관리의 어려움
- 개발/테스트/운영 상황에서 DB 데이터를 넣거나 삭제할 때 제약이 강하게 작용
- 특히 테스트 데이터를 생성하거나 삭제할 때 순서를 신경써야 하는 번거로움
- 스키마 변경의 제약
- 모델이 성숙하지 않은 초기 단계에서 FK 제약을 걸면 추후 모델 변경 시 마이그레이션 작업이 복잡해짐
- 성능 오버헤드
- FK 제약조건으로 인한 미세한 성능 저하
- 특히 대량의 데이터를 다룰 때 더 명확하게 드러남
식별자 참조 방식의 이점
연관관계 매핑과 FK 제약조건을, 식별자 참조 방식(+물리적인 FK 사용 x)으로 전환함으로써 얻을 수 있는 이점은 다음과 같습니다:
- 도메인 모델의 단순화: 엔티티 간 직접적 참조가 없어 각 엔티티의 책임과 역할이 명확해집니다.
- 유연한 데이터베이스 설계: 테이블 간 외래 키 제약 없이 작업할 수 있어 스키마 변경이 용이합니다.
- 성능 최적화: 필요할 때만 연관된 엔티티를 조회하므로 불필요한 조인이나 지연 로딩으로 인한 N+1 문제를 방지할 수 있습니다.
- 결합도 감소: 각 엔티티가 독립적으로 변경될 수 있어 유연한 코드 구조를 가질 수 있습니다.
- 운영 유연성: FK 제약 없이 데이터를 관리할 수 있어 테스트 데이터 구성이나 운영 중 데이터 관리가 편리합니다.
물론, 단점도 존재합니다.
매번 조인문을 작성해야 된다거나 데이터베이스 수준에서 무결성을 보장하지 못 한다는 점이 그렇습니다.
리팩토링 과정과 결과
변경 전 코드 구조
변경 전의 코드는 연관관계 매핑과 FK 제약이 모두 활성화된 상태였습니다. 예를 들어 Member 엔티티는 다음과 같았습니다:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "profile_image_url")
private String profileImageUrl;
@ElementCollection(fetch = FetchType.EAGER)
@Column(nullable = false)
private List<String> role;
@OneToMany(mappedBy = "member") // 양방향 연관관계 매핑
private List<MemberProject> memberProjects = new ArrayList<>();
// 생략...
}
Project 엔티티의 경우도 여러 연관관계를 가지고 있었습니다:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Project extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL) // 연관관계 매핑
private List<MemberProject> memberProjects = new ArrayList<>();
@OneToMany(mappedBy = "project") // 연관관계 매핑
private List<Invitation> invitations = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY) // 연관관계 매핑
@JoinColumn(name = "cover_id") // FK 제약 설정됨
private Cover cover;
// 생략...
}
변경 후 코드 구조
연관관계 매핑을 제거하고 FK 제약조건을 해제한 후, 식별자 참조 방식으로 변경했습니다:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 다른 엔티티에서 이 id를 참조
@Column(name = "profile_image_url")
private String profileImageUrl;
@ElementCollection(fetch = FetchType.EAGER)
@Column(nullable = false)
private List<String> role;
// 연관관계 매핑 제거
// 생략...
}
Project 엔티티에서도 마찬가지로 연관관계와 FK 제약을 제거했습니다:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Project extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 연관관계 매핑 모두 제거
@Column(name = "cover_id") // FK 제약 없이 단순 컬럼으로 변경
private Long coverId; // 식별자 참조 방식
// 생략...
}
리포지토리 계층에서는 필요에 따라 조인 쿼리를 작성하거나, 서비스 계층에서 여러 리포지토리를 조합하여 비즈니스 로직을 구현하는 방식으로 변경했습니다.
코드가 간결해졌다
연관관계 매핑을 제거한 후, 엔티티 클래스는 훨씬 더 간결해졌고 각 엔티티의 책임이 명확해졌습니다. 특히 서비스 코드에서 불필요한 양방향 매핑이 사라져 코드의 가독성이 크게 향상되었습니다. 아래는 그 예시 중 하나입니다.


운영 상의 유연함을 얻었다
FK 제약조건을 제거함으로써 데이터 관리의 유연성을 확보했습니다. 기존에는 테스트 환경을 위해 데이터를 임의로 넣거나, 운영 상 이유로 데이터를 수정해야 할 경우 등 FK 제약으로 인한 불편함이 많았습니다.
모델이 성숙하지 않은 상태에서 FK 제약을 걸고 개발을 했을 때, 추후 모델 변경 시 마이그레이션 작업이 복잡해지는 문제도 해결되었습니다.



부가적으로 성능적으로도 약간의 효과를 얻었다
FK 제약조건을 제거함으로써 미세한 성능 향상을 누릴 수 있었다.
트러블슈팅: 알림 발송 실패
연관관계를 끊으면서 뜻밖의 위치에서 문제가 발생했는데, 그 중 하나가 알림 발송 실패였습니다.
문제 상황
이전까지 SSE를 통해 알림을 잘 보내고 있었고 이번 변경을 수행하면서 데이터 포맷도 변경하지 않았습니다. 그런데 갑자기 알림 발송이 실패하기 시작했습니다.

NotificationResponse
를 역직렬화할 수 없다는 오류가 발생했습니다.
원인 분석
연관관계를 끊어주는 과정에서 엔티티 간 참조 대신 식별자 참조 방식을 활용하기 위해 프로젝션을 통한 조회 방식을 도입했습니다. 이를 위해 NotificationResponse
클래스에 새로운 생성자를 추가했습니다.

기존에는 퍼블릭 생성자가 하나뿐이었기 때문에 ObjectMapper가 그것을 사용했으나, 이제는 퍼블릭 생성자가 두 개가 되면서 ObjectMapper가 어떤 생성자를 사용해 역직렬화해야 할지 결정하지 못했던 것입니다.

해결책
@JsonCreator
어노테이션을 통해 역직렬화할 때 사용할 생성자를 명시적으로 지정했습니다:
@JsonCreator
@Builder(access = AccessLevel.PRIVATE)
public NotificationResponse(Long notificationId, String message, ProjectInfo project, MemberInfo receiver,
MemberInfo sender, Notification.NotificationType type, Boolean isChecked,
LocalDateTime createdAt) {
this.notificationId = notificationId;
this.message = message;
this.project = project;
this.receiver = receiver;
this.sender = sender;
this.type = type;
this.isChecked = isChecked;
this.createdAt = createdAt;
}
이렇게 해결한 후 알림이 다시 정상적으로 발송되었습니다.

결론
JPA를 사용할 때 연관관계 매핑과 FK 제약조건은 별개의 개념임을 인식하고, 각 프로젝트의 상황에 맞게 적절히 조합하는 것이 중요합니다. 이번 리팩토링을 통해 몇 가지 중요한 교훈을 얻었습니다:
- 연관관계 매핑 vs 식별자 참조:
- 연관관계 매핑은 객체 그래프 탐색이 빈번하게 필요한 경우에 유용
- 식별자 참조는 도메인 모델의 독립성을 유지하고 결합도를 낮추는 데 효과적
- 영속 관계를 설정할 때 생명주기가 완전히 동일한 경우에만 연관관계 매핑을 사용하는 것이 바람직
- FK 제약조건의 선택적 사용:
- 데이터 무결성이 절대적으로 중요한 경우 FK 제약이 필요할 수 있음
- 하지만 개발 초기 단계나 빠른 변화가 예상되는 모델에서는 FK 제약을 느슨하게 가져가는 것이 유리할 수 있음
- FK 제약 없이도 애플리케이션 로직으로 참조 무결성을 유지할 수 있는 방법을 고려
- 트레이드오프 이해:
- 모든 설계 결정에는 장단점이 있으며, 프로젝트의 특성과 단계에 맞는 선택이 중요
- 연관관계와 제약조건을 줄이면 유연성은 높아지지만 애플리케이션에서 더 많은 책임을 가져야 함
- 변경의 영향 범위 예측:
- 데이터 모델이나 객체 참조 방식의 변경은 예상치 못한 부분(예: 알림 시스템)에 영향을 줄 수 있음
- 변경 전 영향 범위를 철저히 분석하고, 변경 후 충분한 테스트가 필요
이번 리팩토링을 통해 우리 프로젝트는 연관관계 매핑의 복잡성과 FK 제약조건의 운영 부담에서 벗어나, 보다 유연하고 독립적인 도메인 모델을 구축할 수 있게 되었습니다. 또한, 이번 작업은 앞으로 있을 기능 변경/추가, 리팩토링에 유연하게 대응할 수 있는 첫걸음이라는 점에서 의미가 큰데요. 도메인 모델 간의 결합도를 강제적으로나마 낮춰 변경사항에 유연하게 대처할 수 있는 시스템으로 한 발짝 더 다가갈 수 있었습니다.
다만, (이미 귀아프게 들으셨겠지만)모든 결정에는 트레이드오프가 따릅니다. 연관관계 매핑과 FK 제약 조건을 무차별적으로 사용하지 않는 것은 똑똑한 방법은 아닐 것입니다. 사용사례에 따라 적절한 판단을 통해 적용하시길 바랍니다.
긴 글 읽어주셔서 감사드립니다.
참고 자료:
https://github.com/github/gh-ost/issues/331#issuecomment-266027731
https://www.youtube.com/watch?v=6q0-IT5J0nI
댓글