[ezcode] 추천 기능 구현 및 1차 캐시 동작 확인

NCOOKIE·2025년 6월 14일

ezcode

목록 보기
2/8

개요

  • MVP 기능으로 추천, 추천 취소 기능 구현해야 함
    • 추천/비추천 기능은 MVP 이후 고도화 때 구현할 예정
    • 추천 요청이 들어왔을 때 기존 추천 기록이 있다면 추천 취소(삭제), 없다면 추천(생성)
  • 추천(vote) 기능을 적용할 대상은 토론글(discussion), 댓글(reply)이 있다.
    • 이 둘은 테이블이 별도로 있긴 하지만 기능은 거의 동일함
    • 추후 해답(solution)과 그 댓글(comment)에도 같은 추천이 들어갈 예정
    • 때문에 기능을 따로따로 구현할 경우 추후 많은 코드 중복이 예상됨
      => 엔티티 분리 + 공통 추상 클래스로 코드 중복을 제거하는 방법을 사용하기로 함
  • 반정규화가 아니라 공통 추상 클래스로 묶는 방식을 선택한 이유
    • 지금은 기능이 거의 동일하긴 하지만, 나중에 비즈니스 규칙이 달라질 수 있음
    • 반정규화를 하게 되면 if (type == DISCUSSION)과 같이 분기 코드가 생겨서 코드 중복과 실수가 발생할 수 있음
    • 복합 인덱스의 크기가 커짐 : (vote_type, target_id, voter_id)

추천 기능 구현

아래 코드들은 리팩토링이 적용되기 이전의 코드임! 적용된 코드를 보고 싶다면 시리즈의 이후 글 참고

위의 개요에서 언급한 이유들로 추천 기능은 공통 추상 클래스와 제네릭 등을 활용해 구현하기로 했다. 당장은 클래스 수가 늘어난 것으로 보이겠지만, 추후 도메인 확장성과 유지보수성을 가져올 수 있다.

Entity

추천 엔티티들의 겹치는 컬럼을 묶어줄 수 있는 BaseVote 추상 클래스를 만들었다. 나중에 다른 추천 기능을 만들고 싶다면 BaseVote를 상속 받는 ReplyVote, SolutionVote 등을 생성하면 된다.

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseVote {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	protected Long id;

	@ManyToOne
	@JoinColumn(name = "voter_id", nullable = false)
	protected User voter;

	@CreatedDate
	@Column(name = "created_at", nullable = false, updatable = false)
	protected LocalDateTime createdAt;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class DiscussionVote extends BaseVote {

	@ManyToOne
	@JoinColumn(name = "discussion_id", nullable = false)
	private Discussion discussion;

	@Builder
	public DiscussionVote(User voter, Discussion discussion) {
		this.voter = voter;
		this.discussion = discussion;
	}
}

Port

도메인 서비스와 인프라 계층 사이의 연결다리 역할을 해주는 포트 클래스다. (우리 프로젝트에서는 편의상 repository라는 키워드를 사용 중이다.)

제네릭을 활용해 BaseVote를 상속받는 엔티티 객체로만 사용할 수 있도록 했다.

public interface BaseVoteRepository<T extends BaseVote> {

	T save(T voteEntity);

	Optional<T> findByVoterIdAndTargetId(Long voterId, Long targetId);

	void delete(T voteEntity);

}

공통이 아닌 독립된 비즈니스 로직을 가지게 된다면 여기에 필요한 기능을 선언하면 된다.

public interface DiscussionVoteRepository extends BaseVoteRepository<DiscussionVote> {
}

Domain Service

애플리케이션 서비스로부터 호출되어 포트의 메서드를 호출한다.

@RequiredArgsConstructor
public abstract class BaseVoteDomainService<T extends BaseVote, R extends BaseVoteRepository<T>> {

	protected final R repository;

	public T createVoteEntity(T entity) {

		return repository.save(entity);
	}

	public Optional<T> getVoteEntity(Long voterId, Long targetId) {

		return repository.findByVoterIdAndTargetId(voterId, targetId);
	}

	public boolean getVoteStatus(Long voterId, Long targetId) {

		return repository.existsByVoterIdAndTargetId(voterId, targetId);
	}

	public void removeVoteEntity(T entity) {

		repository.delete(entity);
	}
}
@Service
public class DiscussionVoteDomainService extends BaseVoteDomainService<DiscussionVote, DiscussionVoteRepository> {

	public DiscussionVoteDomainService(DiscussionVoteRepository repository) {
		super(repository);
	}

}

Application Service (UseCase)

핵심 로직을 수행한다. 추천 기록 저장 여부에 따라 추천 데이터 생성 또는 삭제를 한다.

요청된 추천 도메인에 대해 검증이 필요한데 검증 내용은 도메인에 따라 다르므로 이를 각각의 서브클래스에 구현했다. 따라서 컨트롤러에서는 validateAndToggleVote 메서드를 호출하고, 해당 메서드에 @Transactional 어노테이션을 붙여줬다.

@RequiredArgsConstructor
public abstract class BaseVoteService<T extends BaseVote, D extends BaseVoteDomainService<T, ?>> {

	protected final D domainService;

	protected Optional<T> toggleVote(User voter, Long targetId) {

		Optional<T> existing = domainService.getVoteEntity(voter.getId(), targetId);
		if (existing.isPresent()) {
			domainService.removeVoteEntity(existing.get());
			return Optional.empty();
		} else {
			T voteEntity = buildVoteEntity(voter, targetId);
			return Optional.of(domainService.createVoteEntity(voteEntity));
		}
	}

	@Transactional(readOnly = true)
	public VoteResponse getVoteStatus(Long userId, Long targetId) {

		boolean voteStatus = domainService.getVoteStatus(userId, targetId);
		return new VoteResponse(voteStatus);
	}

	protected abstract T buildVoteEntity(User voter, Long targetId);

}
@Service
public class DiscussionVoteService extends BaseVoteService<DiscussionVote, DiscussionVoteDomainService> {

	private final UserDomainService userDomainService;
	private final DiscussionDomainService discussionDomainService;

	public DiscussionVoteService(
		DiscussionVoteDomainService domainService,
		UserDomainService userDomainService,
		DiscussionDomainService discussionDomainService
	) {
		super(domainService);
		this.userDomainService = userDomainService;
		this.discussionDomainService = discussionDomainService;
	}

	@Transactional
	public VoteResponse validateAndToggleVote(Long problemId, Long discussionId, Long userId) {

		User voter = userDomainService.getUserById(userId);

		// validate
		Discussion discussion = discussionDomainService.getDiscussionById(discussionId);
		discussionDomainService.validateProblemMatches(discussion, problemId);

		Optional<DiscussionVote> discussionVote = toggleVote(voter, discussionId);
		return new VoteResponse(discussionVote.isPresent());
	}
    
	@Override
	protected DiscussionVote buildVoteEntity(User voter, Long targetId) {
		Discussion discussion = discussionDomainService.getDiscussionById(targetId);

		return DiscussionVote.builder()
			.voter(voter)
			.discussion(discussion)
			.build();
	}
}

1차 캐시 관련 트러블슈팅

예상과 다른 동작

추천 기능이 정상적으로 동작하는 것은 확인했다.

그러나 코드 리뷰를 하던 중 구조 상 같은 코드가 validateAndToggleVotebuildVoteEntity 메서드에서 중복으로 발생한 것을 확인할 수 있었다.

Discussion discussion = discussionDomainService.getDiscussionById(discussionId);

서브 클래스의 validateAndToggleVote -> 추상 클래스의 toggleVote -> 서브 클래스의 buildVoteEntity라는 호출 흐름을 따라가서 불가피하게 같은 코드가 중복된 것이다.

나는 여기서 getDiscussionById 여러 번 호출되어도 JPA의 1차 캐시가 있으니까 실제 쿼리는 한 번만 날라갈 것이라고 생각했다. 서로 다른 메서드에 위치해 있긴 하지만 같은 트랜잭션으로 묶여 있기 때문이다.

그러나...

Hibernate: 
    select
        d1_0.id,
        d1_0.content,
        d1_0.created_at,
        d1_0.is_deleted,
        d1_0.language_id,
        d1_0.modified_at,
        d1_0.problem_id,
        d1_0.user_id 
    from
        discussion d1_0 
    where
        d1_0.id=? 
        and d1_0.is_deleted=0
2025-06-04T12:37:38.811+09:00 DEBUG 29700 --- [dongwons] [nio-8080-exec-3] o.h.stat.internal.StatisticsImpl         : HHH000117: HQL: SELECT d FROM Discussion d WHERE d.id = :discussionId AND d.deleted = false, time: 0ms, rows: 1
Hibernate: 
    select
        dv1_0.id,
        dv1_0.created_at,
        dv1_0.discussion_id,
        dv1_0.voter_id 
    from
        discussion_vote dv1_0 
    left join
        users v1_0 
            on v1_0.id=dv1_0.voter_id 
    left join
        discussion d1_0 
            on d1_0.id=dv1_0.discussion_id 
    where
        v1_0.id=? 
        and d1_0.id=?
2025-06-04T12:37:44.289+09:00 DEBUG 29700 --- [dongwons] [nio-8080-exec-3] o.h.stat.internal.StatisticsImpl         : HHH000117: HQL: [CRITERIA] select dv1_0.id,dv1_0.created_at,dv1_0.discussion_id,dv1_0.voter_id from discussion_vote dv1_0 left join users v1_0 on v1_0.id=dv1_0.voter_id left join discussion d1_0 on d1_0.id=dv1_0.discussion_id where v1_0.id=? and d1_0.id=?, time: 0ms, rows: 0
Hibernate: 
    select
        d1_0.id,
        d1_0.content,
        d1_0.created_at,
        d1_0.is_deleted,
        d1_0.language_id,
        d1_0.modified_at,
        d1_0.problem_id,
        d1_0.user_id 
    from
        discussion d1_0 
    where
        d1_0.id=? 
        and d1_0.is_deleted=0
2025-06-04T12:37:46.251+09:00 DEBUG 29700 --- [dongwons] [nio-8080-exec-3] o.h.stat.internal.StatisticsImpl         : HHH000117: HQL: SELECT d FROM Discussion d WHERE d.id = :discussionId AND d.deleted = false, time: 0ms, rows: 1

이렇게 같은 쿼리가 두 번 날라가는 것을 확인할 수 있었다.

원인

jpa repository에 선언한 메서드는 다음과 같다. soft delete 된 데이터는 제외하고 조회하기 위해서 아래와 같이 작성했다.

@Where 등의 어노테이션을 사용할 수도 있겠지만 예상한 것과 다른 쿼리가 발생할 수 있고, 관리자 페이지 등에서는 soft delete된 데이터도 함께 조회해야 할 수도 있기 때문에 이런 구현 방식을 취했다.

@Query("""
	SELECT d
	FROM Discussion d
	WHERE d.id = :discussionId
	AND d.deleted = false
	""")
Optional<Discussion> findByDiscussionId(Long discussionId);

어쨌든간에, 이 문제의 원인부터 말하자면 JPA의 1차 캐시 최적화는 식별자 기반 조회에만 적용되기 때문이다. 그러니까 findById(...) 혹은 entityManager.find(...) 형태로 조회해야 한다는 것이다.

이에 대해 명확히 언급하고 있는 공식 문서를 찾고 싶었으나, 내 검색 실력이 부족한 것인지 찾을 수 없었다. 대신 관련된 링크와 내용을 인용하겠다.

https://stackoverflow.com/questions/53041653/when-does-a-query-hit-jpa-1st-level-cache-and-when-the-query-does-bypass-the-cac

I want to be very clear: the only plausible way for you to hit the first level cache using public API in JPA is by using the entityManager.find(class, id) - that is it! Nothing else.

That is because most of the time you're writing the JPQL/Criteria API queries that are far more complex than just find something by its id. For that specific case - just use entityManager.find - but in case of more complex tasks.

In such cases, there is just no practical reason to hit the cache at all - we would still need to hit the database anyway since we cannot guarantee that those N entities in the cache are the only that match the supplied criteria, we simply do not know. But even then, searching data in the cache linearly not by its ID is a very inefficient approach.

https://vladmihalcea.com/jpa-hibernate-first-level-cache/

However, while it prevents multiple find calls from fetching the same entity from the database, it cannot prevent a JPQL or SQL from loading the latest entity snapshot from the database, only to discard it upon assembling the query result set.

간단하게 요약하자면, ID가 아닌 다른 속성으로 조회하거나 WHERE 등으로 조건을 지정하게 되면 조회 때마다 결과가 달라질 수 있다. 즉, 조회된 데이터가 유일한 엔티티인지 보장할 수 없기 때문에 1차 캐시를 사용하지 않고 매번 쿼리가 발생하게 되는 것이다.

해결

jpa repository에서는 findById()를 호출하되, 해당 메서드를 호출하는 어댑터 코드에서 soft delete 여부를 검사하도록 했다.

헥사고날의 port, adpater 개념을 도입했기 때문에 비즈니스 로직을 가지고 있는 애플리케이션, 도메인 서비스 단의 코드는 수정하지 않았다.

@Override
public Optional<Discussion> findById(Long discussionId) {

	return discussionJpaRepository.findById(discussionId)
		.filter(d -> !d.isDeleted());
}
profile
일단 해보자

0개의 댓글