- 여기어때컴퍼니 (2024.06 ~ 재직 중)
- Java, Spring Boot
- MySQL, Spring Data JPA, Querydsl, Redis
개발하다 마주친 크고 작은 고민들 💭
Redis의 Sorted Set을 사용하여 투표에 대한 랭킹 시스템을 구현했다. Sorted Set은 score가 높은 순으로 정렬되는 특징을 가지고 있고, 현재 랭킹에서는 진행 중인 투표 참여자 수를 score로 하고 있다.
score가 같을 때 동점자 처리를 해줘야 하는 상황에 부딪혔고, 효율적인 동점자 처리 방법에 대해 고민하게 되었다.
동점자 처리를 위해 세 가지 방법을 생각해 봤다.
첫 번째 방법은 Application 단에서 동점자 처리를 하는 것이다. 이 방법은 레디스에서 랭킹을 조회한 후 동점자에 대해 투표 마감 시각이 더 빠른 순으로 또 다시 정렬을 하는 것이다.
그러나 이 방법을 사용하면 Redis를 자동 정렬을 위해 사용한 의미가 흐려지게 된다. 또한 복잡한 재정렬 코드가 추가되면서 관리 측면에서 효율적이지 않다고 판단하였다.
두 번째 방법은 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
) {
세 번째 방법은 score 값을 정수부와 소수부로 분리하여 처리하는 것이다. 이 방법은 기존 score에 소수점을 추가하는 방법입니다. 이때 소수점은 투표 마감 시각에 따른 정렬 값인데, 이 값은 2번 방법과 동일하게 계산한다.
이렇게 하면 정수부가 같을 경우 (즉, 투표 참여자 수가 같을 경우) 소수부 (즉, 투표 마감 시각에 따른 정렬 값)에 따라 순위가 결정되므로 원하는 방향으로 동점자를 처리할 수 있을 것이다.
서비스 테스트를 진행하면서, 검증이 필요하지 않은 값들을 어떻게 처리해줘야 할지 고민이 생겼다.
예를 들어, 투표 생성 기능을 테스트 할 때 로그인한 회원의 Id를 반환하는 메서드를 모킹하고 있다. 이 경우 회원 Id는 테스트에 큰 영향을 미치는 정보는 아니라고 생각한다. 왜냐하면 회원 Id가 어떤 값이든, 투표가 주어진 정보로 잘 생성되는지만 확인하면 되기 때문이다. 그래서 현재 로그인한 회원 Id를 반환하는 메서드의 반환 값으로 실제 값을 사용하는 것이 아니라, Mockito의 ArgumentMatchers인 anyLong()
를 사용해도 되는지 고민이 되었다.
// given
given(memberUtils.getCurrentMemberId())
.willReturn(1L); // 1L -> anyLong()을 사용해도 될까?
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를 사용할 때는 목적대로 사용하자.
LIME 서비스 개발 중에, 특정 상황에서 사용자에게 포인트를 지급하는 요구사항이 있었다. 포인트는 댓글 작성, 댓글 채택, 리뷰 작성 세 가지 상황에서 지급된다.
현재 LIME 서비스에서는 이 로직을 AOP로 구현하고 있다. AOP는 주로 비지니스 로직과 그 외 공통 로직을 분리하는 프로그래밍 방식으로 사용되는데, 포인트 지급이라는 비지니스 로직을 AOP로 처리하는 것이 다소 어색해 보였다.
따라서, 이 기능을 '직접 메소드 호출' 또는 '이벤트 처리' 중 어느 방식으로 변경할지 고민하게 되었다.
포인트 지급이 비지니스 로직의 한 부분이라는 건 분명하다. 그렇다면 이 로직은 포인트 지급이 발생하는 로직(댓글 생성, 댓글 채택, 리뷰 생성)과 분리되어야 하는 관심사인가?
관심사를 분리하는 것이 맞는 것 같다. 예를 들어, 댓글 작성 로직을 고려해보면, 댓글 생성 시 포인트도 함께 지급된다. 이는 SOLID 원칙 중 하나인 '단일 책임 원칙(SRP)'을 위반하는 것이다. 만약, 지급 포인트가 3점에서 5점으로 변경될 경우, 댓글 생성 로직에도 영향을 미친다.
포인트 지급이 필요한 상황은 댓글과 리뷰 도메인에서 발생하지만, 실제 포인트 지급 처리는 회원 도메인에서 이루어진다. 만약 직접 메소드를 호출하면, 댓글과 리뷰 서비스는 회원 서비스에 대한 의존성을 주입 받아야 한다. 이로 인해 댓글/리뷰 서비스와 회원 서비스 간의 강한 의존성이 생겨난다. 또한, 이렇게 되면 댓글과 리뷰 서비스에서 각각 포인트 지급 로직을 중복해서 작성해야 하는 문제가 발생한다.
이벤트를 사용하면, 지급 포인트가 변경되더라도 댓글 생성 로직에 영향을 끼치지 않는다. 또한 도메인 간의 의존성을 분리함으로써 재사용성을 높일 수 있다. 이는 각 도메인의 책임을 명확히 분리하고, 유지 보수성을 높인다.
👉 이런 이유들로, 직접 메소드를 호출하는 것보다 이벤트를 사용하여 처리하는 방식을 선택하기로 결정했다.
포인트 지급이 발생하는 로직(댓글 생성, 댓글 채택, 리뷰 생성)과 포인트 지급 로직은 같은 트랜잭션에 포함되어야 하는가?
처음에는 각 로직이 다른 트랜잭션에 포함되어야 한다고 생각했다. 이유는 포인트 지급이 롤백될 경우, 댓글 생성 로직이 함께 롤백되지 않아야 한다는 생각에서였다.
그러나 우리 서비스를 고려해보면, 포인트는 사용자의 레벨을 표현하며, 이 레벨은 서비스에서 사용자의 전문성을 나타내는 중요한 수단이다. 따라서, 이 레벨을 높이는데 필요한 포인트는 서비스의 중요한 요소라고 볼 수 있다.
만약 사용자가 댓글을 작성했음에도 불구하고 포인트를 지급하지 않는다면, 이는 사용자에게 좋지 않은 경험을 제공하는 것이 될 것이다. 포인트 지급이 롤백되면, 댓글 생성 또한 함께 롤백되어야 하며, 사용자는 다시 댓글을 작성하도록 유도해야 한다.
👉 결국, 로직을 동일한 트랜잭션에서 처리할 것인지, 다른 트랜잭션에서 처리할 것인지 결정하는 것은 서비스를 고려하여 결정해야 한다.
투표 참여 기능을 개발 중인데, 예외 처리에 대한 고민이 생겼다. 기능의 규칙은 다음과 같다.
사용자는 오직 진행 중인 투표에만 참여할 수 있어야 한다. 사용자가 이미 종료된 투표에 참여하려고 시도한다면, 이때 예외를 발생시켜야 한다. 이런 상황에서, 예외 발생은 도메인과 서비스 중 어느 곳에서 처리해야 하는 것이 적절할까?
예시는 실제 로직보다 간소화했습니다.
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);
}
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);
}
레벨 시스템에서 특정 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)
를 통해 업데이트를 처리했다.
공지사항 등록 기능에 대한 인수 테스트를 수행 중입니다. 이 과정에서 서비스가 생성한 공지사항의 ID를 반환받아 테스트에서 해당 값을 확인합니다. 두 개의 테스트 모두 ID 값을 1L로 설정하고 진행했으나, 한 테스트는 성공한 반면 다른 하나는 ID 값이 2L로 나타나며 실패했습니다.
@Transactional
사용에 대한 오해초기에는 구글링을 통해 인수 테스트에서 @Transactional
을 사용하면 HTTP 클라이언트 요청과 서버 요청이 다른 스레드에서 처리되어 트랜잭션 롤백이 이루어지지 않는다는 정보를 얻었습니다.
그러나 스프링 공식 문서를 살펴본 결과, 별도의 포트를 지정하지 않았기 때문에 내 상황에는 해당되지 않는다고 판단했습니다. 실제로 테스트를 진행하며 데이터베이스를 확인해 본 결과, 롤백이 정상적으로 이루어졌습니다.
문제의 진짜 원인은 ID의 자동 증가 값이 트랜잭션 롤백 시에도 롤백되지 않기 때문입니다.
MySQL을 사용하는 경우에는 TRUNCATE만 해줘도 AUTO_INCREMENT 값이 초기화됩니다.
하지만 H2 같은 경우에는 TRUNCATE를 한다고 해서 자동 증가 값이 초기화되지 않습니다. 따라서 아래 명령어를 사용해서 TRUNCATE하면서 자동 증가 값을 같이 초기화해줍니다.
TRUNCATE TABLE notices RESTART IDENTITY;
TRUNCATE TABLE files RESTART IDENTITY;
🚨 h2database 깃허브를 보면 아래 명령어를 사용하려면 MySQL 호환 모드로 해야 한다고 합니다.
업데이트를 해야할 사항이 하나인 경우에 대해서
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);
}
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);
}
위의 메서드는 여러 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 검증 로직을 직접 작성하여 애플리케이션 단에서 한 번 검증하고 @Column(nullable=false)
를 추가해 DB 단에서도 null을 검증할 수 있도록 추가하는 것이 좋다.
만약 @NotNull
을 사용하게 된다면 Bean validation 의존성을 추가해야 할텐데 null 검증 때문에 validation 의존성을 추가하는 것은 도메인의 외부 의존성만 늘리는 것이라고 생각한다.
기존에는 도메인을 최대한 외부에서 격리시켜서 순수하게 가져가야 한다는 생각이었다. 그런데 @NotNull
을 사용하자는 입장에 설득(?) 당해버렸다.
@NotNull
을 엔티티에 붙이면 Application과 DB 단에서 모두 null 검증을 할 수 있다고 한다. 애플리케이션에서 null 검증을 해주는 건 아는데 어떻게 DB까지...? 알고보니 jpa 속성 중 ddl-auto=create
로 되어있으면 테이블 생성 시 @NotNull
이 붙어있는 컬럼에 not null이 붙어서 생성된다. 이번에 찾아보면서 처음 알게 된 사실이다!🤩
무조건 @NotNull
을 사용하자는 입장은 아니고 만약 DTO 검증이나 다른 이유로 Bean validation 의존성이 이미 추가되어있는 상태라면, @NotNull
을 활용하자는 것이다.
나를 설득시킨 참고자료들
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.