Giter Club home page Giter Club logo

dev-qna's Introduction

dev-qna's People

Contributors

yiseull avatar

Stargazers

 avatar  avatar

Watchers

 avatar

dev-qna's Issues

Redis을 이용한 투표 랭킹 시스템에서 동점자 처리 방법 고민

📍 상황

Redis의 Sorted Set을 사용하여 투표에 대한 랭킹 시스템을 구현했다. Sorted Set은 score가 높은 순으로 정렬되는 특징을 가지고 있고, 현재 랭킹에서는 진행 중인 투표 참여자 수를 score로 하고 있다.

score가 같을 때 동점자 처리를 해줘야 하는 상황에 부딪혔고, 효율적인 동점자 처리 방법에 대해 고민하게 되었다.

동점자 처리에 대한 요구사항

  • score가 동점일 때는 투표 마감 시각이 더 임박한 쪽(투표 마감 시각이 더 빠른 쪽)이 더 높은 랭킹을 가지도록 해야 한다.

📍 생각한 구현 방법

동점자 처리를 위해 세 가지 방법을 생각해 봤다.

  1. Application 단에서 처리
  2. value에 시간에 따른 정렬 값을 추가하여 처리
  3. score 값을 정수부(투표 참여자 수)와 소수부(투표 마감 시각에 따른 정렬 값)로 분리하여 처리

1. Application 단에서 동점자 처리 (기각)

첫 번째 방법은 Application 단에서 동점자 처리를 하는 것이다. 이 방법은 레디스에서 랭킹을 조회한 후 동점자에 대해 투표 마감 시각이 더 빠른 순으로 또 다시 정렬을 하는 것이다.

그러나 이 방법을 사용하면 Redis를 자동 정렬을 위해 사용한 의미가 흐려지게 된다. 또한 복잡한 재정렬 코드가 추가되면서 관리 측면에서 효율적이지 않다고 판단하였다.

2. value에 시간에 따른 정렬 값을 추가하여 처리

두 번째 방법은 value에 시간에 따른 정렬 값을 추가하여 처리하는 것이다. Redis의 Sorted Set은 score 값이 같을 때 value 값이 사전적으로 큰 순으로 정렬된다. 현재 value는 String 타입이 아닌 Object 타입이므로 JSON 형태로 직렬화해 Redis에 저장하고 있다. 이 경우, 동점자가 발생했을 때 정렬 순서는 JSON의 순서 그대로 id, item1Image, item2Image, participants 필드의 순서에 따라 결정된다.

이 문제를 해결하기 위해 value에 투표 마감 시각을 가지고 만든 정렬 값을 맨 앞 필드에 두면, 동점자일 경우 투표 마감 시각이 더 빠른 투표가 높은 순위를 가지도록 처리할 수 있다.

여기서 투표 마감 시각을 이용한 정렬 값은 투표 마감 시각을 숫자로 변환한 후 큰 값에서 빼준 값이다. 이렇게 하면 투표 마감 시각이 더 빠를수록 더 높은 순위를 가질 것입니다.

아래는 value로 사용하고 있는 레코드이고, 맨 앞 필드에 secondSortValue라는 투표 마감 시각을 이용한 정렬 값을 추가한 것이다.

@Builder
public record VoteRankingInfo(
        double secondSortValue // 투표 마감 시각에 따른 정렬 값 추가
	Long id,
	String item1Image,
	String item2Image,
	int participants
) {

3. score 값을 정수부(투표 참여자 수)와 소수부(투표 마감 시각에 따른 정렬 값)로 분리하여 처리

세 번째 방법은 score 값을 정수부와 소수부로 분리하여 처리하는 것이다. 이 방법은 기존 score에 소수점을 추가하는 방법입니다. 이때 소수점은 투표 마감 시각에 따른 정렬 값인데, 이 값은 2번 방법과 동일하게 계산한다.

이렇게 하면 정수부가 같을 경우 (즉, 투표 참여자 수가 같을 경우) 소수부 (즉, 투표 마감 시각에 따른 정렬 값)에 따라 순위가 결정되므로 원하는 방향으로 동점자를 처리할 수 있을 것이다.


참고 자료

모킹할 때 테스트 검증에 필요하지 않은 값들은 Mockito ArgumentMatchers로 대체해도 될까?

📍 상황

서비스 테스트를 진행하면서, 검증이 필요하지 않은 값들을 어떻게 처리해줘야 할지 고민이 생겼다.

1. 반환 값으로 ArgumentMatchers를 사용해도 될까?

예를 들어, 투표 생성 기능을 테스트 할 때 로그인한 회원의 Id를 반환하는 메서드를 모킹하고 있다. 이 경우 회원 Id는 테스트에 큰 영향을 미치는 정보는 아니라고 생각한다. 왜냐하면 회원 Id가 어떤 값이든, 투표가 주어진 정보로 잘 생성되는지만 확인하면 되기 때문이다. 그래서 현재 로그인한 회원 Id를 반환하는 메서드의 반환 값으로 실제 값을 사용하는 것이 아니라, Mockito의 ArgumentMatchers인 anyLong()를 사용해도 되는지 고민이 되었다.

// given
 given(memberUtils.getCurrentMemberId())
				.willReturn(1L); // 1L -> anyLong()을 사용해도 될까?

2. 테스트 실행 부분에서 필요하지 않은 입력 값에 대해 ArgumentMatchers를 사용해도 될까?

GWT(Given-When-Then) 구조의 테스트에서 When 부분의 코드에서 중요하지 않은 입력값에 대해 ArgumentMatchers를 사용해도 되는지 고민이 되었다.

아래는 투표 참여 테스트에 대한 실행과 검증 부분이다. 이 테스트는 종료된 투표에 참여할 수 없다는 것을 검증하고 있다. 여기서 투표 아이템 Id는 중요한 정보가 아니라고 생각했다. 이때 아이템 Id인 1L을 anyLong()으로 변경해도 될까?

// when & then
assertThatThrownBy(() -> voteService.participateVote(voteId, 1L)) // // 1L -> anyLong()을 사용해도 될까?
				.isInstanceOf(BusinessException.class)
				.hasFieldOrPropertyWithValue("errorCode", ErrorCode.VOTE_CANNOT_PARTICIPATE);

📍 결론 - 모두 불가능

Java 공식 문서에 따르면, ArgumentMatchers는 검증 또는 스텁(stub) 메서드 외에는 사용할 수 없다고 한다. 또한 반환 값으로 사용할 수 없다. 반환 값이나 테스트 실행 부분의 입력 값으로 ArgumentMatchers를 사용하려고 하면 InvalidUseOfMatchersException 예외가 발생한다.

👉 ArgumentMatchers를 사용할 때는 목적대로 사용하자.

참고

포인트 지급 로직 어떻게 처리할까? 직접 메소드 호출 vs 이벤트

📍 상황

LIME 서비스 개발 중에, 특정 상황에서 사용자에게 포인트를 지급하는 요구사항이 있었다. 포인트는 댓글 작성, 댓글 채택, 리뷰 작성 세 가지 상황에서 지급된다.

현재 LIME 서비스에서는 이 로직을 AOP로 구현하고 있다. AOP는 주로 비지니스 로직과 그 외 공통 로직을 분리하는 프로그래밍 방식으로 사용되는데, 포인트 지급이라는 비지니스 로직을 AOP로 처리하는 것이 다소 어색해 보였다.

따라서, 이 기능을 '직접 메소드 호출' 또는 '이벤트 처리' 중 어느 방식으로 변경할지 고민하게 되었다.


📍 고려 사항

1. 관심사의 분리

포인트 지급이 비지니스 로직의 한 부분이라는 건 분명하다. 그렇다면 이 로직은 포인트 지급이 발생하는 로직(댓글 생성, 댓글 채택, 리뷰 생성)과 분리되어야 하는 관심사인가?

관심사를 분리하는 것이 맞는 것 같다. 예를 들어, 댓글 작성 로직을 고려해보면, 댓글 생성 시 포인트도 함께 지급된다. 이는 SOLID 원칙 중 하나인 '단일 책임 원칙(SRP)'을 위반하는 것이다. 만약, 지급 포인트가 3점에서 5점으로 변경될 경우, 댓글 생성 로직에도 영향을 미친다.

포인트 지급이 필요한 상황은 댓글과 리뷰 도메인에서 발생하지만, 실제 포인트 지급 처리는 회원 도메인에서 이루어진다. 만약 직접 메소드를 호출하면, 댓글과 리뷰 서비스는 회원 서비스에 대한 의존성을 주입 받아야 한다. 이로 인해 댓글/리뷰 서비스와 회원 서비스 간의 강한 의존성이 생겨난다. 또한, 이렇게 되면 댓글과 리뷰 서비스에서 각각 포인트 지급 로직을 중복해서 작성해야 하는 문제가 발생한다.

이벤트를 사용하면, 지급 포인트가 변경되더라도 댓글 생성 로직에 영향을 끼치지 않는다. 또한 도메인 간의 의존성을 분리함으로써 재사용성을 높일 수 있다. 이는 각 도메인의 책임을 명확히 분리하고, 유지 보수성을 높인다.

👉 이런 이유들로, 직접 메소드를 호출하는 것보다 이벤트를 사용하여 처리하는 방식을 선택하기로 결정했다.

2. 트랜잭션

포인트 지급이 발생하는 로직(댓글 생성, 댓글 채택, 리뷰 생성)과 포인트 지급 로직은 같은 트랜잭션에 포함되어야 하는가?

처음에는 각 로직이 다른 트랜잭션에 포함되어야 한다고 생각했다. 이유는 포인트 지급이 롤백될 경우, 댓글 생성 로직이 함께 롤백되지 않아야 한다는 생각에서였다.

그러나 우리 서비스를 고려해보면, 포인트는 사용자의 레벨을 표현하며, 이 레벨은 서비스에서 사용자의 전문성을 나타내는 중요한 수단이다. 따라서, 이 레벨을 높이는데 필요한 포인트는 서비스의 중요한 요소라고 볼 수 있다.

만약 사용자가 댓글을 작성했음에도 불구하고 포인트를 지급하지 않는다면, 이는 사용자에게 좋지 않은 경험을 제공하는 것이 될 것이다. 포인트 지급이 롤백되면, 댓글 생성 또한 함께 롤백되어야 하며, 사용자는 다시 댓글을 작성하도록 유도해야 한다.

👉 결국, 로직을 동일한 트랜잭션에서 처리할 것인지, 다른 트랜잭션에서 처리할 것인지 결정하는 것은 서비스를 고려하여 결정해야 한다.

비지니스 검증을 할 때 도메인과 서비스 어느 쪽에서 예외를 터뜨려야 할까?

상황 설명

투표 참여 기능을 개발 중인데, 예외 처리에 대한 고민이 생겼다. 기능의 규칙은 다음과 같다.
사용자는 오직 진행 중인 투표에만 참여할 수 있어야 한다. 사용자가 이미 종료된 투표에 참여하려고 시도한다면, 이때 예외를 발생시켜야 한다. 이런 상황에서, 예외 발생은 도메인과 서비스 중 어느 곳에서 처리해야 하는 것이 적절할까?

예시는 실제 로직보다 간소화했습니다.

1. 도메인에서 예외 발생

Vote

public void validateVoting() {
	final LocalDateTime now = LocalDateTime.now();
	if (this.endTime.isAfter(now)) {
		throw new BusinessException(ErrorCode.VOTE_CANNOT_PARTICIPATE);
	}
}

VoteService

public void participateVote(
	final Long voteId,
	final Long itemId,
	final Long memberId,
) {
	final Vote vote = voteReader.read(voteId);

	vote.validateVoting();

	voteManager.participate(vote, memberId, itemId);
}

2. 서비스에서 예외 발생

Vote

public boolean isVoting() {
	final LocalDateTime now = LocalDateTime.now();
	return this.endTime.isAfter(now);
}

VoteService

public void participateVote(
	final Long voteId,
	final Long itemId,
	final Long memberId,
) {
	final Vote vote = voteReader.read(voteId);

	if (!vote.isVoting()) {
		throw new BusinessException(ErrorCode.VOTE_CANNOT_PARTICIPATE);
	}

	voteManager.participate(vote, memberId, itemId);
}

AOP에는 트랜잭션이 작동하지 않는걸까? AOP 내부에서 변경 감지는 못하는걸까?

문제 상황

레벨 시스템에서 특정 API가 호출될 때 회원에게 포인트를 지급하는 기능을 개발 중이었다. 예를 들어 댓글이 채택되면 댓글의 작성자에게 포인트 20을 지급한다.

PayPoint 라는 어노테이션이 붙은 메서드들의 수행이 완료되면, 메서드의 반환값으로 회원 id를 받아서 AOP로 처리할 생각이었다. (PayPoint는 얼마의 포인트를 지급할건지 나타내기 위해 value값을 가진다.)

PointManager

@Aspect
@Component
@RequiredArgsConstructor
public class PointManager {

    private final MemberReader memberReader;
    
    @AfterReturning(value = "@annotation(PayPoint)", returning = "memberId")
    public void payPoint(
            final JoinPoint joinPoint,
            final Long memberId
    ) {
        final int point = getPoint(joinPoint);
        final Member member = memberReader.read(memberId);
        member.earnPoint(point);
    }

    private int getPoint(final JoinPoint joinPoint) {
        ...생략
        return payPoint.value();
    }
}

Member

public void earnPoint(final int point) {
	this.levelPoint += point;
}

나는 회원의 포인트가 증가하는 업데이트를 @transactional을 사용하여 변경감지로 처리해야겠다고 생각했다.
하지만 업데이트는 발생하지 않았다.

그래서 결국 memberRepository.save(member) 를 통해 업데이트를 처리했다.

궁금한 사항 👀

  • 왜 AOP에는 변경감지를 통한 업데이트가 처리되지 않을까?
  • 트랜잭션이 작동하긴 할까?
  • 변경감지는 되고 있을까? 변경감지는 되는데 flush 호출이 안된 것이 아닐까?

Querydsl에서 정렬 조건이 QClass의 List 필드라면 어떻게 해야할까?

상황

  • vote의 목록을 조회할 때 인기순으로 조회해야 한다. (인기순 == 투표자가 많음)
  • vote(투표)와 voter(투표자)는 1:N 관계이다.
image
  • 하지만 vote 엔티티에서는 voter를 리스트로 가지고 있어서 Querydsl의 정렬 조건으로 사용하면 밑에 사진과 같이 빨간줄이 뜬다.
image

인수 테스트에서 테스트 격리하기(테스트 데이터 초기화)

문제 상황

공지사항 등록 기능에 대한 인수 테스트를 수행 중입니다. 이 과정에서 서비스가 생성한 공지사항의 ID를 반환받아 테스트에서 해당 값을 확인합니다. 두 개의 테스트 모두 ID 값을 1L로 설정하고 진행했으나, 한 테스트는 성공한 반면 다른 하나는 ID 값이 2L로 나타나며 실패했습니다.

인수 테스트와 @Transactional 사용에 대한 오해

초기에는 구글링을 통해 인수 테스트에서 @Transactional을 사용하면 HTTP 클라이언트 요청과 서버 요청이 다른 스레드에서 처리되어 트랜잭션 롤백이 이루어지지 않는다는 정보를 얻었습니다.

그러나 스프링 공식 문서를 살펴본 결과, 별도의 포트를 지정하지 않았기 때문에 내 상황에는 해당되지 않는다고 판단했습니다. 실제로 테스트를 진행하며 데이터베이스를 확인해 본 결과, 롤백이 정상적으로 이루어졌습니다.

진짜 원인

문제의 진짜 원인은 ID의 자동 증가 값이 트랜잭션 롤백 시에도 롤백되지 않기 때문입니다.

해결 방법

1. TRUNCATE를 사용하여 테스트마다 테이블 초기화

MySQL을 사용하는 경우에는 TRUNCATE만 해줘도 AUTO_INCREMENT 값이 초기화됩니다.

하지만 H2 같은 경우에는 TRUNCATE를 한다고 해서 자동 증가 값이 초기화되지 않습니다. 따라서 아래 명령어를 사용해서 TRUNCATE하면서 자동 증가 값을 같이 초기화해줍니다.

TRUNCATE TABLE notices RESTART IDENTITY;
TRUNCATE TABLE files RESTART IDENTITY;

🚨 h2database 깃허브를 보면 아래 명령어를 사용하려면 MySQL 호환 모드로 해야 한다고 합니다.


참고자료

@Transactional vs save() 둘 중 어느 것을 사용해야 할까?

단건 업데이트를 할 경우 @transactional과 save() 둘 중 어느 것을 사용해야 할까?

업데이트를 해야할 사항이 하나인 경우에 대해서

public void modifyNickname(final Long memberId, final String nickname) {
	final Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFound(ErrorCode.MEMBER_NOT_FOUND));

	member.changeNickname(nickname);
}

1. @transactional 사용

JPA의 변경 감지를 활용한다. 메서드가 끝난 후에 update 쿼리를 날리기 위해서 메서드에@Transactional을 붙여준다.

@Transactional
public void modifyNickname(final Long memberId, final String nickname) {
	final Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFound(ErrorCode.MEMBER_NOT_FOUND));

	member.changeNickname(nickname);
}

2. save() 사용

위의 메서드는 여러 update가 수행되지도 않고 하나의 update만 일어나기 때문에 트랜잭션의 범위를 최소화하기 위해서 save() 메서드를 호출한다. save() 메서드 내부에는 @Transactional이 붙어있다.

public void modifyNickname(final Long memberId, final String nickname) {
	final Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFound(ErrorCode.MEMBER_NOT_FOUND));

	member.changeNickname(nickname);
        memberRepository.save(member)
}

도메인에서 null 체크 -> @NotNull vs @Column(nullable=false)

도메인에서의 null 검증 어떻게 하는 것이 좋을까? @NotNull vs @column(nullable=false)

1️⃣ 처음에 갖고 있던 생각

도메인은 최소한의 의존성만 가져가고 가능한 순수하게 유지되어야 한다. 따라서 도메인의 값이 초기화 될 때 null 검증 로직을 직접 작성하여 애플리케이션 단에서 한 번 검증하고 @Column(nullable=false)를 추가해 DB 단에서도 null을 검증할 수 있도록 추가하는 것이 좋다.

만약 @NotNull을 사용하게 된다면 Bean validation 의존성을 추가해야 할텐데 null 검증 때문에 validation 의존성을 추가하는 것은 도메인의 외부 의존성만 늘리는 것이라고 생각한다.

2️⃣ 두 번째 생각

기존에는 도메인을 최대한 외부에서 격리시켜서 순수하게 가져가야 한다는 생각이었다. 그런데 @NotNull을 사용하자는 입장에 설득(?) 당해버렸다.

@NotNull을 엔티티에 붙이면 Application과 DB 단에서 모두 null 검증을 할 수 있다고 한다. 애플리케이션에서 null 검증을 해주는 건 아는데 어떻게 DB까지...? 알고보니 jpa 속성 중 ddl-auto=create로 되어있으면 테이블 생성 시 @NotNull이 붙어있는 컬럼에 not null이 붙어서 생성된다. 이번에 찾아보면서 처음 알게 된 사실이다!🤩

무조건 @NotNull을 사용하자는 입장은 아니고 만약 DTO 검증이나 다른 이유로 Bean validation 의존성이 이미 추가되어있는 상태라면, @NotNull을 활용하자는 것이다.

나를 설득시킨 참고자료들

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.