서론
이때까지 개인적으로 해오던 프로젝트들은 개발에 충분한 시간이 주어지고, 도메인이 단순했습니다. 그래서 ORM 기술인 JPA를 사용할 때 단방향 매핑을 사용해서 모든 것을 해결할 수 있었습니다. 하지만 실제 업무를 진행할 때는 급하게 개발을 하다 보니 양방향 매핑을 자주 사용했습니다. 그리고 그 문제들은 점점 커져 유지보수에 어려움을 주고 있었기에, 성능에 영향을 주는 OneToOne 관계를 급선무로 하여 약 90% 정도 (OneToMany는 조금씩 진행 중) 끊어냈습니다. 이 과정에서 제가 겪었던 양방향 매핑의 문제점에 대해 공유하고자 글을 작성했습니다.
사용한 기술들 -> Java17, Spring Boot 3.2.7, MySQL 8.0.34, JPA, QueryDSL
1. 데이터 획득 경로 분산
양방향 연관관계를 맺었을 때 데이터 획득경로는 하나 더 늘어나게 됩니다. 예를들어 기존에는 DB에서만 데이터를 조회했지만, 양방향 연관관계를 맺는다면 엔티티 그래프를 탐색해서 데이터를 찾아올 수 있습니다. 프로젝트 초창기에는 해당 문제가 거의 드러나지 않습니다. 하지만 규모가 커졌을 때 데이터 획득 경로를 파악하기 어려워 유지보수에 어려움을 겪을 수 있습니다. 아래에서 코드와 함께 예시를 들겠습니다.
1.1 JPA Entity 예시
- 아래는 양방향 매핑을 모든 엔티티에 적용하여 만든 JPA 코드입니다.
- Member: MemberOrder = 1:1, MemberOrder:Product = 1:1 로 양방향 연관관계를 맺도록 작성했습니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private MemberOrder memberOrder;
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@JoinColumn(name = "product_id")
@OneToOne(fetch = FetchType.LAZY)
private Product Product;
@JoinColumn(name = "member_id")
@OneToOne(fetch = FetchType.LAZY)
private Member member;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private MemberOrder memberOrder;
}
1.2 Product 조회 (단순한 비즈니스 로직)
비즈니스 요구사항으로 memberId를 조건으로, Member가 주문한 MemberOrder의 Product를 조회한다고 가정해 보겠습니다. 그러면 다음과 같이 QueryDSL로 쿼리 메서드를 작성해 Product 엔티티를 획득할 수 있습니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TwoWayService {
private final ProductRepository productRepository;
public Product getProductByQuery(Long memberId) {
return productRepository.findByMemberId(memberId)
.orElseThrow(IllegalStateException::new);
}
}
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Optional<Product> findByMemberId(Long memberId) {
QProduct product = QProduct.product;
QMemberOrder memberOrder = QMemberOrder.memberOrder;
QMember member = QMember.member;
//여러 테이블을 조인하여 Product 엔티티 조회
return Optional.ofNullable(queryFactory
.selectFrom(product)
.innerJoin(product.memberOrder, memberOrder)
.innerJoin(memberOrder.member, member)
.where(member.id.eq(memberId))
.fetchOne()
);
}
}
1.3 Product 조회 (복잡한 비즈니스 로직)
지금은 memberId를 활용해 Product를 찾는 단순한 로직이지만, 다음과 같은 비즈니스 로직을 추가로 요구한다고 가정해 보겠습니다.
- Member의 방문 횟수를 카운트한다. 다만 30분 내에 다시 접속한 경우는 처리하지 카운트하지 않는다.
- MemberOrder의 배송 상태를 업데이트한다.
- Product를 조회한 시간을 업데이트 한다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TwoWayService {
private final MemberRepository memberRepository;
private final ProductRepository productRepository;
@Transactional
public Product getProductWithAdditionalLogic(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(IllegalStateException::new);
member.plusVisitedCount();
MemberOrder memberOrder = member.getMemberOrder();
memberOrder.updateDeliveryStatus();
Product product = memberOrder.getProduct();
product.updateRecentlyViewTime();
return product;
}
}
코드가 복잡해졌다고 가정했을 때, Prodcut를 Repository에서 조회하는 것이 아닌 엔티티 그래프를 탐색해 조회해 왔습니다. 이처럼 비즈니스 로직이 복잡하고 일정이 바쁜 경우 엔티티 그래프 탐색을 통해 엔티티 획득하는 유혹에 빠지기 쉽습니다. 엔티티 탐색의 유혹에 빠지게 되면 다음과 같은 문제가 결국 발생합니다.
- 슈퍼 엔티티 위주로 흘러가는 비즈니스 로직 (위 예시에서는 Member가 슈퍼 엔티티로 유력)
- 연관관계에 있는 엔티티 getter로 획득 시 조회 쿼리 발생으로, 쿼리 호출 시점 파악이 어려움 (2.2에 설명)
그렇기 때문에 엔티티를 처음 조회할 때부터 엔티티 그래프 탐색을 하지 못하게 하도록 정말 필요한 데이터만 DTO로 조회하는 것이 바람직합니다.
2. 쿼리 호출 시점 파악
앞서 엔티티 그래프 탐색을 사용했을 때 조회 쿼리 호출 시점을 파악하기 어렵다고 말씀드렸습니다. 조회 쿼리 말고도 양방향 매핑을 했을 때 JPA의 cascade, orphanRemoval 옵션을 사용하는 경우가 있는데, 이때 비즈니스 로직에 쿼리가 포함되지 않아 해당 기능 때문에 유지보수에 어려움을 겪을 수 있다고 생각합니다.
아래 예시 엔티티를 바탕으로 그래프 탐색을 사용했을 때 조회 쿼리 호출 시점을 파악하기 어려운 이유, 그리고 cascade와 orphanRemoval 사용으로 유지보수에 어떤 어려움을 겪을 수 있는지 보여드리겠습니다.
2.1 JPA 엔티티 세팅
- Member와 Team의 연관관계를 N:1로 매핑했습니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@JoinColumn(name = "team_id")
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<Member> members = new ArrayList<>();
}
2.2 쿼리 호출 시점 파악하기 어려운 이유? (조회)
JPA에서는 특정 엔티티를 조회할 때, fetchJoin이라는 기능을 활용해 연관관계에 있는 엔티티를 조인해서 가져올 수 있습니다. 만약 fetchJoin으로 엔티티를 미리 불러온 경우가 아니라면 연관관계에 있는 엔티티를 탐색할 때 해당 데이터를 조회하기 위해서, 추가 조회 쿼리를 호출합니다. 아래 코드가 예시에 해당됩니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TwoWayService {
private final MemberRepository memberRepository;
private final TeamRepository teamRepository;
public String getTeamNameByMemberId(Long memberId) {
// 쿼리 1회 호출
Member member = memberRepository.findById(memberId)
.orElseThrow(IllegalStateException::new);
// 패치조인 하지 않아서 추가 조회 쿼리 발생
Team team = member.getTeam();
return team.getName();
}
}
이번에도 이런 단순한 경우에는 즉각적으로 문제가 보이지 않을 수 있습니다. 하지만 데이터 획득하는 경로가 하나 더 늘어났기에, 복잡한 엔티티 그래프를 가지고 그래프 탐색을 하다 보면 이미 조회된 데이터인지 아닌지 구별하기 어려울 수 있습니다.
구별하기 어려운 이유에 대해 간단한 예시를 작성했습니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TwoWayService {
private final MemberRepository memberRepository;
private final OtherImportantService otherImportantService;
public TeamAndMemberDto getTeamNameByMemberId(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(IllegalStateException::new);
//team 조회 쿼리 발생
Team team = member.getTeam();
validateTeam(team);
TeamAndMemberDto dto = otherImportantService.getTeamAndMember(member);
return dto;
}
}
@Service
@RequiredArgsConstructor
public class OtherImportantService {
private final TeamRepository teamRepository;
public TeamAndMemberDto getTeamAndMember(Member member) {
otherImportantBusiness(member);
// 이전에 이미 쿼리가 호출됐는지 한번에 파악 어려움
Team team = member.getTeam();
return new TeamAndMemberDto(team, member);
}
}
- MemberRepository를 조회한 후 연관관계에 있는 Team을 조회합니다. 이때 fetchJoin을 하지 않았기 때문에 조회 쿼리가 발생합니다.
- 조회한 Team에 대해 검증 후 다른 비즈니스 서비스 메서드에 Member를 파라미터로 넘깁니다.
- Member를 파라미터로 받은 서비스에서도 추가적으로 Team에 대해 다뤄야 해서 Member의 연관관계에 있는 Team을 가져옵니다.
위 상황에서 JPA의 1차 캐시 기능 때문에 추가로 쿼리가 발생하지는 않지만, 이후 유지보수 시 연관관계에 있는 Team을 조회할 때 쿼리가 발생하는지 알기 위해서는 무조건 해당 메서드를 참조하고 있는 코드를 확인해야 하기 때문에 유지보수 비용이 늘어날 수 있습니다.
실제로 연관관계를 끊어내는 과정에서 코드를 보았을 때 이미 그래프 탐색을 통해 조회한 엔티티임에도 불구하고, 이어지는 비즈니스 로직에서 동일한 엔티티를 다시 쿼리로 조회하는 경우가 빈번하게 있었습니다.
2.3 양방향 관계에서 cascade, orphanRemoval (삭제)
양방향 연관관계를 사용할 때 추가로 고려해야 할 점은 cascade, orphanRemoval를 사용할 때 입니다. 만약 특정 데이터가 삭제될때 자동으로 해당 데이터를 외래키로 가지고 있는 데이터를 삭제해야 할때 cascade, orphanRemoval는 좋은 대안이 될 수 있지만 아래의 문제점들을 가질 수 있습니다.
- 데이터 개수만큼 삭제 쿼리가 발생하는 성능 문제
- 잘못된 데이터 관리로 인한 데이터 무결성 문제 발생 가능성
예시로 Team의 Id를 외래키로 사용하고 있는 Member 데이터를 4개 저장 후, 아래 코드를 돌리면 Member를 삭제하기 위한 쿼리가 4번 호출되는 것을 확인할 수 있습니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TwoWayService {
private final TeamRepository teamRepository;
@Transactional
public void deleteTeam(Long teamId) {
teamRepository.deleteById(teamId);
}
}
3. JPA 양방향 관리
세 번째 양방향 매핑의 단점은 데이터 관리를 양쪽으로 해야 합니다. 예시로 아래의 비즈니스 요구사항이 있다고 가정해 보겠습니다.
- Team의 기본키 ID를 외래키로 가지고 있는 Member는 Team을 언제든지 변경될 수 있다.
- Member가 Team을 변경한 경우, 현재 남아있는 Team의 Member size를 리턴한다.
겉으로 보기에는 간단해 보이나, 양방향 매핑을 사용한 경우 충분히 실수할 수 있는 상황이 발생할 수도 있습니다.
3.1 서비스 코드 작성
서비스 코드는 다음과 같이 작성되었습니다.
- memberId를 조건으로 Member 엔티티를 조회한 후, 엔티티 그래프 탐색으로 이전 Team 엔티티를 구합니다.
- teamId를 조건으로 변경할 Team 엔티티를 조회합니다.
- 조회한 Member의 Team을 변경하고, 이전 Team 엔티티에서 Member의 사이즈를 구해 반환합니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TwoWayService {
private final MemberRepository memberRepository;
private final TeamRepository teamRepository;
@Transactional
public Integer changeTeam(Long memberId, Long teamId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(IllegalStateException::new);
Team beforeTeam = member.getTeam();
Team afterTeam = teamRepository.findById(teamId)
.orElseThrow(IllegalStateException::new);
member.updateTeam(afterTeam);
return beforeTeam.getMembers().size();
}
}
3.2 테스트 코드로 검증
작성한 테스트를 실행해 보면 기대했던 값과 다른 것을 확인할 수 있습니다. 왜냐하면 이전 Team 엔티티의 Members에서 팀을 변경한 Member를 삭제시키지 않았기 때문입니다. 그렇기 때문에 양방향 매핑을 사용한다면 엔티티 양쪽에 대해 데이터를 관리해줘야 하는 수고로움이 있기 때문에 유지보수에 더 큰 비용이 들 수 있습니다.
class TwoWayServiceTest {
@Test
@DisplayName("멤버가 속한 팀을 변경한 후, 변경되기 전 팀의 멤버수는 기존보다 한명 작다")
void changeTeam() {
//given
Team beforeTeam = Team.from("이전 팀");
Team afterTeam = Team.from("이후 팀");
teamRepository.saveAll(List.of(beforeTeam, afterTeam));
Member member1 = Member.of("이름1", beforeTeam);
Member member2 = Member.of("이름2", beforeTeam);
Member changeTeamMember = Member.of("팀 바뀔 멤버", beforeTeam);
List<Member> members = List.of(member1, member2, changeTeamMember);
memberRepository.saveAll(members);
beforeTeam.getMembers().addAll(members);
//when
Integer memberSize = twoWayService.changeTeam(changeTeamMember.getId(), afterTeam.getId());
//then
//expected : 2, Actual : 3
assertThat(memberSize).isEqualTo(members.size() - 1);
}
4. 결론
양방향 매핑을 사용하면 유지보수 시 다음과 같은 문제가 있음을 확인했습니다.
- 엔티티 데이터 획득 경로가 늘어난다
- 엔티티 그래프 탐색이 더 빈번해져 쿼리 호출 시점을 파악하기 어려워진다
- 엔티티 데이터에 대해 양방향으로 관리를 해줘야 한다
대게 도메인을 풀어나갈 때 @ManyToOne, @OneToOne 단방향으로 충분히 문제들을 해결할 수 있었습니다. 모든 상황에 대해 정답은 없기 때문에 양방향 매핑 사용을 무조건 지양하라고 할 수는 없으나, 사용하기 전에 정말로 필요에 의해 사용되는지는 확인해 볼 필요가 있는 문제라고 생각됩니다.
그래서 저는 이런 문제들을 겪고 다음과 같은 점을 실천하고 있습니다.
- 양방향 매핑관계 사용 지양 (정말 밀접한 관계에 있는 경우만 사용)
- 엔티티를 조회하지 않고 DTO로 조회
- 엔티티 그래프 탐색을 방지하기 위해 메서드 파라미터에 엔티티가 아닌 필요한 데이터만 넘기기
이것으로 글을 마무리하겠습니다. 읽어주셔서 감사합니다.
'Spring' 카테고리의 다른 글
Flyway 도입 전 탐색해보기 (2) | 2024.12.07 |
---|---|
환경변수가 있었는데요? 없었습니다 (2) | 2024.11.09 |
@PathVariable 엔드포인트 매핑 원리 (1) | 2024.10.12 |
Redis, 그리고 Spring 에서 Redis 활용 (6) | 2024.09.07 |
ChainedTransactionManager, JtaTransactionManager (2) | 2024.07.17 |