세상에 나쁜 코드는 없다

연관관계 매핑, 꼭 해야할까요? 본문

웹개발/백엔드

연관관계 매핑, 꼭 해야할까요?

Beomseok Seo 2024. 2. 9. 16:47

알림을 잘 보내보자

요구사항

현재 진행중인 프로젝트의 기능 중에는 특정 안건에 대해 투표를 받는 기능이 있습니다. 투표가 종료되는 순간 그 결과를 담은 알림을 모든 투표자들에게 보내야하며, 알림 정보는 DB에 저장되어야 합니다. 알림 테이블이 필요로 하는 정보는 아래와 같습니다.

문제점

일반적으로 JPA를 사용하여 DB에 정보를 저장하는 방법은 엔티티 객체를 만들고 영속화 메서드를 호출하는 것입니다.

Notification noti = new Notification();
em.persist(noti);
Notification noti = new Notification();
notificationRepository.save(noti);

하지만 위 방법으로 알림을 보내기에는 적절하지 않은 것 같습니다. 생성되는 알림의 개수는 곧 투표를 수행한 모든 사용자의 수와 동일하므로 너무 많은 엔티티가 생성되어야 하기 때문입니다.

// 투표자들에게 보낼 알림 생성
List<Notification> notis = createNotification(voters);

// 저장
notificationRepository.saveAll(notis);

위 코드의 경우 2가지 문제점이 발생합니다.

  1. 불필요하게 많은 메모리 사용

만약 특정 안건에 대해 투표한 사람이 N명이라면 Notification 객체 N개가 생성됩니다. 이는 순간적으로 많은 메모리를 사용합니다. 서비스의 규모가 커짐에 따라 하나의 안건에 대해 투표한 사람의 수도 늘어날 것이므로, 이러한 코드는 오랫동안 유지되지 못할 것입니다.

  1. 발생하는 쿼리의 개수

JpaRepository의 saveAll() 메서드는 각각의 엔티티 개수 만큼의 삽입 쿼리를 DB에 전송합니다. 너무 많은 삽입 쿼리는 수행시간도 많이 들고 DB에 부하를 주기 쉽습니다.

saveAll 호출 시 왜 엔티티 개수만큼의 쿼리가 발생하나?

사실 saveAll() 메서드가 항상 N개의 삽입 쿼리를 발생시키는 것은 아닙니다. 이는 ID의 생성 전략이 IDENTITY일 때에 Hibernate 쓰기 지연 정책과 결부되어 나타나는 결과입니다.

Hibernate는 엔티티들의 생성, 수정, 삭제와 같은 작업들을 영속성 컨텍스트의 1차 캐시에서 관리하다가, 트랜잭션이 종료되는 시점에 해당 내용들을 DB에 반영하는 쿼리를 수행합니다. 이를 통하여 불필요한 쿼리 발생을 최소화하며 네트워크 연결의 오버헤드를 줄일 수 있습니다.

하지만 IDENTITY 정책을 사용하면 엔티티 생성 과정에서 구조적인 문제가 발생합니다. 엔티티의 생성 작업을 수행하면 1차 캐시에 엔티티 객체가 저장되어야 하는데, 이 때 ID는 DBMS가 자동 증가 정수로 관리하므로 DB를 거치지 않으면 ID 값을 알 수 없어 온전한 엔티티 객체를 저장할 수 없기 때문입니다. 이러한 이유로 Hibernate는 IDENTITY 정책을 사용하는 경우 persist가 수행되는 시점에 삽입 쿼리를 발생시켜 생성된 자동 증가 정수를 엔티티에 할당하는 방법을 사용합니다.

위 방법은 JDBC API가 제공하는 삽입 시 생성된 키를 가져오는 방법으로 구현됩니다. 이 방법은 단일 삽입에서는 유효하나 벌크성 삽입에서는 생성된 키를 검색하는데에 제약이 많습니다. 따라서 saveAll() 메서드는 IDENTITY 정책을 사용하는 엔티티들을 관리할 때에 영속성 컨텍스트의 구조를 유지하기 위하여 각각의 엔티티들을 개별적으로 삽입하도록 만들어져 있습니다.

벌크성 삽입의 도입

위 문제를 해결하기 위해 다수의 레코드 삽입을 하나의 쿼리로 처리할 수 있도록 JdbcTemplate의 batchUpdate()를 사용하기로 했습니다. batchUpdate는 bulk inserting을 수행합니다.

@Override
	public void insertNotifications(String message, String uri, Set<Long> memberIds) {
		List<Object[]> parameters = new ArrayList<>();

		String sql = INSERT_NOTIFICATIONS_SQL
			.replace(":message", message)
			.replace(":uri", uri);

		for (Long memberId : memberIds) {
			parameters.add(new Object[] {memberId});
		}

		jdbcTemplate.batchUpdate(sql, parameters);

	}

하지만 이러한 방식은 또 다른 문제를 낳았습니다. 알림의 생성 쿼리가 하나로 나가기 때문에, 모종의 이유로 쿼리 수행에 실패한다면 전체 알림은 등록되지 않습니다. 쿼리의 형태를 볼 때 문제의 여지가 있는 부분은 memberId 와 관련된 부분입니다. 혹시나 메서드를 호출하는 측에서 존재하지 않는 memberId를 전달해주거나, 로직이 도는 사이에 해당 ID를 가진 사용자가 삭제되는 경우 참조무결성에 의해 쿼리는 실패할 것이며, 개별적으로 전송했을 때에는 성공적으로 저장되었을 케이스도 단일 쿼리로 수행되기 때문에 함께 실패할 것입니다. 이러한 상황은 알림 시스템의 결함률을 높이게 됩니다.

결함률을 줄이기 위한 방법 도입

쿼리 실패의 타격을 줄이기 위해, 예외를 잡아 쿼리를 다시 보내게 만들었습니다.

@Override
public void insertNotifications(String message, String uri, Set<Long> memberIds) {
	...
	
	try {
		jdbcTemplate.batchUpdate(sql, parameters);
		
	} catch (DataIntegrityViolationException e) {
		//알림 등록 중 오류 발생, 개별 알림 등록 시도
		for (Object[] parameter : parameters) {
			try {
				jdbcTemplate.update(sql, parameter);
			} catch (DataIntegrityViolationException e2) {
				//문제가 있는 memberId
			}
		}
	}
}

쿼리가 실패했을 때에 각각의 알림을 개별적으로 등록하는 방식으로 전체 알림이 등록되지 않는 문제를 해결하였습니다. 이 경우 문제가 있는 memberId를 제외한 모든 알림은 저장될 것입니다.

이 방법은 알림 서비스의 결함률은 줄여주지만 실패했을 때에 N개의 쿼리가 발생하며 성능 저하의 원인이 될 수 있습니다. 이를 해결하기 위해서 이진 분할 방식을 채택할 수 있습니다. 이진 분할 방식은 bulk inserting에 실패했을 때 이를 두 부분으로 나누어 각각의 bulk inserting을 수행하는 재귀적인 해법입니다. 이진 분할 방식으로 배치의 크기를 절반으로 줄여가며 호출하면, 하나의 레코드에서 문제가 발생한다는 가정하에 N개의 쿼리를 1+2×log2N1 + 2 \times \log_2N 개로 줄일 수 있어 보입니다.

연관관계 매핑, 꼭 해야할까요?

위 해결법에 대한 조언을 듣기 위해 멘토링에 들고 갔는데, 예상 밖의 솔루션을 듣게 되었습니다.

연관관계 매핑, 왜 하셨나요?
public class Notification extends BaseTimeEntity {
	...

	@ManyToOne
	@JoinColumn(name = "member_id")
	private Member member;

	...
}

Notification과 Member는 다대일 관계를 가지고 있으므로 당연하게 DB 레벨에서 FK로 연결되어있어야 한다고 생각했습니다. 그리고 프로젝트는 아직 개발 단계이고 엔티티의 필드가 계속 변화하고 있기 때문에 Hibernate가 제공하는 ddl 자동 생성 기능을 사용하고 있었습니다. 이 기능을 통해 편리하게 FK를 설정하려면 다대일 관계를 매핑해주어야 했습니다. 이 부분에 대해서 다음과 같은 지적을 받았습니다.

  1. Notification으로부터 Member 데이터에 접근할 필요가 없어 보이는데 매핑이 되어있다.

현재 애플리케이션에서 Notification 테이블의 member_id는 오직 특정 회원의 알림을 조회하기 위한 필터링 용도로 사용됩니다. Notification 조회와 함께 Member의 필드에 접근할 로직이 발생하지 않는다면, 굳이 연관관계 매핑을 해놓을 이유는 없을 것입니다.

또한 이러한 구조는 MSA를 도입하게 될 때에도 문제가 될 수 있을 것입니다. 알림 시스템이 기존 시스템과 분리되어 독자적인 애플리케이션으로서 동작하게 될 때, Member 필드를 갖고 있고 이를 사용하게 되면 알림 시스템이 Member 도메인에 의존하게 되어 분리하기 어려울 것입니다.

  1. FK로 연관관계를 주는 것도 도메인 간 의존성을 늘린다.

애플리케이션 레벨 뿐만 아니라 DB 레벨에서도 FK의 설정은 DB 테이블간 의존성을 발생시켜 분리하기 어려운 구조가 될 것입니다. 상황에 따라서는 FK를 사용하지 않는 것도 방법이 될 수 있습니다.

Notification - Member FK 연결을 하지 않는다면

FK는 테이블 간의 논리적 구조를 보장할 수 있는 훌륭한 방법입니다. 하지만 FK를 설정하는 것은 제약 조건으로 인한 작업의 성능 저하, 삭제와 업데이트 시 발생하는 복잡성, 테이블 간 강한 의존성 발생 등의 문제를 낳습니다. 따라서 참조 무결성을 꼭 보장해야하는 것이 아니라면, FK를 사용하지 않는 것도 고려해 볼 수 있습니다.

현재 저희 애플리케이션의 기능을 생각해볼 때 알림 테이블의 참조 무결성이 깨지는 경우, 예를 들어 member_id에 해당하는 사용자가 없는 경우는 사실 큰 문제가 되지 않습니다. 모든 알림의 조회는 member_id에 의해 필터링 되어서 조회되지만, 알림으로부터 사용자 측을 조회하는 경우는 없기 때문입니다. 따라서 이 케이스는 FK의 연결을 하지 않는 방법의 좋은 예시가 될 수 있어 보입니다.

FK를 제거하고 난다면 위에서 고민했던 쿼리 실패에 대한 처리를 할 필요성 자체가 없어집니다. DB의 제약 조건을 제거한 것이 애플리케이션 레벨의 복잡도를 줄이는데에도 영향을 주었습니다.

하지만 이 경우 다른 지점에서 관리 포인트가 발생합니다. 기존에는 생성되어야 하는 알림이 생성되지 않는 경우를 관리해야했지만, 이제는 생성되지 않았어야 하는 알림이 생성되는 경우를 관리해야 합니다. 이러한 레코드들은 애플리케이션에 큰 영향을 주지는 않을 것이지만, 지속적으로 쌓인다면 불필요한 공간을 낭비하게 됩니다. 따라서 주기적으로 이러한 레코드들을 지워주는 역할을 하는 배치를 수행시키는 방법도 고안해볼 필요가 있을 것입니다.


Uploaded by N2T