0. 배경
작성된 게시글에 댓글을 작성하는 기능을 추가하였고, 알람 기능이 존재하지 않아 실시간으로 확인이 불가능하여 불편함을 겪는 사용자가 존재한다고 생각했습니다. 요구사항에 따라 알람 기능을 추가하면서 문제가 발생했습니다.
현재 제가 짠 로직은 댓글 작성 로직과 알람 발송 로직이 하나의 트랜잭션 안에서 처리되었습니다.
외부 API - FCM과, Alarm Entity저장 그리고 댓글 작성을 하나로 처리하였을 때 발생할 수 있는 문제는 FCM과 알람 저장 기능에 문제가 생겼을 때 댓글 저장도 롤백되어버리는 문제입니다.
비즈니스 중요도를 고려하면 사용자 편의를 위한 부가적인 알림 기능(서비스 로직)이 댓글 작성(도메인 로직)이라는 메인 기능에 영향을 미치는 것이 부자연스럽다고 판단해 트랜잭션 분리를 고려하게 되었습니다.
1. 한 트랜잭션에서 처리가 될 때 문제 발생 코드
먼저 CommentService 코드는 다음과 같습니다.
@Transactional
public CommentResponse createComment(final Long postId, final CommentCreateRequest request, final String mobileNumber) {
Post post = postRepository.getById(postId);
User user = userRepository.getByMobileNumber(mobileNumber);
Comment comment = commentRepository.save(request.toEntity(post, user));
if (!post.getUser().getId().equals(user.getId())) {
alarmService.createCommentAlarm(post.getId(), user.getId(), user.getNickname(), post.getUser(), comment.getId());
NotificationReceiveResponse notificationReceiveResponse =
notificationReceiveService.getNotificationReceive(post.getUser().getMobileNumber());
if (notificationReceiveResponse.isFeedNotification()) {
fcmService.sendNotificationWithComment(post.getUser(), user, request.getContent());
}
}
return CommentResponse.of(comment, post, user);
}
@Transactional
public AlarmResponse createCommentAlarm(Long postId, Long fromUser, String fromUserNickname, User toUser, Long commentId) {
Alarm alarm = Alarm.builder()
.isRead(false)
.alarmType(AlarmType.FEED)
.commentId(commentId)
.fromUser(fromUser)
.content(fromUserNickname + "님이 댓글을 남겼습니다.")
.postId(postId)
.user(toUser)
.build();
alarmRepository.save(alarm);
return AlarmResponse.of(alarm);
}
@Transactional
public void sendNotificationWithComment(final User postWriter, final User commentWriter, final String content) {
Notification notification = Notification.builder()
.setTitle(commentWriter.getNickname() + "님이 댓글을 남겼습니다.")
.setBody(content)
.build();
Message message = Message.builder()
.setToken(postWriter.getDeviceToken())
.setNotification(notification)
.build();
try {
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
throw new RuntimeException(e);
}
}
1. commentRepository.save()를 이용하여 댓글을 저장합니다.
2. 게시글 작성자와 댓글 작성자가 다르다면 Alarm을 저장합니다.
3. 게시글 작성자 app push 알람 동의가 되어있다면, fcm 서비스를 이용하여 앱 알람을 실시간으로 보내줍니다.
1.1 AlarmService 문제
createCommentAlarm 에서 예외가 발생했다고 가정하겠습니다.
createCommentAlarm 에서 예외가 발생하면 createComment 메소드도 정상 실행이 되지 않습니다.
해당 문제가 발생한 이유는 댓글 등록 트랜잭션과 알림 등록 트랜잭션이 분리되지 않았기 때문입니다. AlarmService의 createCommentAlarm() 메서드는 @Transactional 어노테이션을 가지고 있고, 전파 옵션은 디폴트인 propagation = Propagation.REQUIRED로 지정되어 있습니다.
따라서 AlarmService의 createCommentAlarm() 메서드는 CommentService의 createComment() 트랜잭션에 참여하게 되고, 두 트랜잭션은 하나의 물리 트랜잭션으로 묶이게 됩니다. 이때 동일한 물리 트랜잭션에는 같은 롤백 규칙이 적용되는데, 트랜잭션 롤백 규칙 기본값은 외부 트랜잭션에 참여한 내부 트랜잭션에서 RuntimeException이 발생하는 경우 rollback-only 마킹을 하는 것입니다. 따라서 CommentService의 createComment() 트랜잭션에서 예외가 발생하지 않았지만, 해당 트랜잭션이 완료되는 시점에 rollback-only 마킹으로 인해 최종적으로 물리 트랜잭션이 롤백 되는 것입니다.
1.2 FcmService 문제
위 AlarmService와 마찬가지로 하나의 물리 트랜잭션으로 묶이게 됩니다.
AlarmService같은 경우에 예외가 터질 확률이 높지 않다고 생각합니다. DB 근본적인 문제 발생이기 때문에 AlarmService에서 문제가 발생한다면 당연히 CommentService에도 똑같은 문제가 발생할 것이라고 예측할 수 있습니다.
그러나, FCM같은 경우에는 예측할 수 없는 예외가 존재합니다.
대표적으로 FCM 토큰 형식이 유효하지 않을 때 발생하는 예외가 존재합니다.
저희 서비스에서는 로그아웃 하였을 때 User의 토큰 값을 null로 수정하여 알람을 받지 않는 식으로 구현하였습니다.
로그인을 시도할 때, 기기에 맞게 deviceToken값을 업데이트 하고 있지만, 해당 유저가 로그인을 장시간 하지 않은 상태에서 댓글 알림을 받는다면 deviceToken 값이 올바르지 않은 상태였을 때 예외가 발생합니다.
따라서 예측할 수 없는 예외가 발생하여 댓글 작성에 문제가 발생합니다.
이러한 문제는 댓글뿐만 아니라 서비스 전체적인 기능에서 발생할 수 있는 예외입니다.
FCM은 외부 API이기 때문에 외부 통신에 문제가 발생하는 예외가 존재합니다.
이러한 경우 서비스 운영쪽에서 바로 해결할 수 없는 문제이기 때문에 심각한 서비스 장애로 이어집니다.
이러한 문제들을 해결하기 위해 어떠한 방법들이 존재하는지 어떤 방법으로 해결했는지 알아보겠습니다.
2. 기존 방식 문제점 - Comment 엔티티에 알람 발송 여부 컬럼 추가
2.1 기존 Comment Entity 코드
@Entity
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
private String content;
private boolean alarmStatus;
알람 발송 여부에 대한 컬럼이 존재했습니다.
댓글 작성과 알람은 실시간이 보장되지 않아도 된다고 판단하여 트랜잭션을 분리하지 않고 Comment에 알람 발송 여부 컬럼을 만들어 스프링 @Scheduled 기능을 활용하여 5~10초 마다 false 값들을 알람 전송하도록 구현했습니다.
2.2 오히려 복잡해지는 서비스 코드
CommentService에서 CommentRepository.save()를 사용할 때, 코드가 복잡하다는 것을 알 수 있습니다.
기존 댓글 작성 코드
@Transactional
public CommentResponse createComment(final Long postId, final CommentCreateRequest request, final String mobileNumber) {
log.info("createComment");
Post post = postRepository.getById(postId);
User user = userRepository.getByMobileNumber(mobileNumber);
Comment comment = commentRepository.save(request.toEntity(post, user));
return CommentResponse.of(comment, post, user);
}
복잡한 댓글 작성
@Transactional
public CommentResponse createComment(final Long postId, final CommentCreateRequest request, final String mobileNumber) {
log.info("createComment");
Post post = postRepository.getById(postId);
User user = userRepository.getByMobileNumber(mobileNumber);
boolean result = post.getUser().getId().equals(user.getId()); // 게시글 작성자와 댓글 작성자가 일치하면 true
Comment comment = commentRepository.save(request.toEntity(post, user, result));
return CommentResponse.of(comment, post, user);
}
게시글 작성자와 댓글 작성자를 확인하는 result 값이 필요했고, 저희가 운영하는 서비스에서 댓글 작성 하나만 알람이 발송되는 시스템이 아닙니다.
프로필 잠금 해제, 게시글 좋아요, 댓글 좋아요, 채팅 등 여러 알람이 존재하였고 하나 하나 다 코드를 작성하기에 오히려 더 불편하고 복잡하다는 것을 느꼈습니다.
2.3 불필요한 DB 접근 발생
알람과 댓글 작성에 대해서 실시간 API CALL이 중요하지 않다고 판단하였지만 댓글이 작성이 되었을 때 바로 알람 발송은 아니지만 어느정도 짧은 시간안에 알람이 생성되어야 한다고 생각했습니다.
따라서 스케쥴러를 활용하여 5초마다 알람을 발송하는 시스템을 구축했습니다.
@Scheduled(fixedDelay = 5_000L)
@Transactional
public void createAlarm() {
List<Comment> byAlarmStatusIsFalse = commentRepository.findALlByAlarmStatusIsFalse();
for (Comment comment : byAlarmStatusIsFalse) {
try {
AlarmResponse commentAlarm =
alarmService.createCommentAlarm(comment.getPost().getId(), comment.getUser().getId(), comment.getUser().getNickname(), comment.getPost().getUser(), comment.getId());
fcmService.sendNotificationWithComment(comment.getPost().getUser(), comment.getUser(), comment.getContent());
comment.updateAlarmStatus(true);
} catch (RuntimeException e) {
log.info("알람 발송 예외 발생");
}
}
}
5초마다 DB를 조회하고 업데이트를 합니다.
서비스를 이용하는 사람이 없을 때도 계속 DB에 접근을 하게 되고, 갑자기 사용자가 몰리면 한번에 처리해야 할 데이터가 많아집니다. 댓글 뿐만 아니라 좋아요, 프로필 잠금 해제 등 여러 스케쥴러를 구현하는 것이 오히려 더 복잡하고 자원 낭비라고 판단하였고 이렇게 구현하면 하나의 물리 트랜잭션을 이용하는 것이기 때문에 효율적인 방법은 아니라고 판단했습니다.
그럼 어떤 방법으로 개선하였는지 알아보겠습니다.
3. 트랜잭션 전파 옵션 (Propagation.REQUIRES_NEW) 활용
3.1 REQUIRES_NEW 정의
기존 트랜잭션에 참여하는 REQUIRES 대신에, REQUIRES_NEW를 적용하였습니다.
REQUIRES_NEW를 활용하면 항상 새로운 물리 트랙잭션을 생성합니다.
따라서 DB 커넥션도 별도로 사용하게 됩니다.
트랜잭션이 분리되기 때문에 rollbackOnly가 적용되지 않음을 알 수 있습니다.
CommentService에서 예외를 복구하고 정상적으로 리턴합니다.
Comment는 저장 되고, Alarm과 Fcm은 롤백 되는 것을 확인할 수 있습니다.
3.2 REQUIRES_NEW 적용 서비스 코드
AlarmService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public AlarmResponse createCommentAlarm(Long postId, Long fromUser, String fromUserNickname, User toUser, Long commentId) {
log.info("createCommentAlarm");
Alarm alarm = Alarm.builder()
.isRead(false)
.alarmType(AlarmType.FEED)
.commentId(commentId)
.fromUser(fromUser)
.content(fromUserNickname + "님이 댓글을 남겼습니다.")
.postId(postId)
.user(toUser)
.build();
try {
alarmRepository.save(alarm);
} catch (RuntimeException e) {
log.info("alarmService - createCommentAlarm - {}", e.getMessage());
}
return AlarmResponse.of(alarm);
}
FcmService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotificationWithComment(final User postWriter, final User commentWriter, final String content) {
if (existDeviceToken(postWriter)) return;
Notification notification = Notification.builder()
.setTitle(commentWriter.getNickname() + "님이 댓글을 남겼습니다.")
.setBody(content)
.build();
Message message = Message.builder()
.setToken(postWriter.getDeviceToken())
.setNotification(notification)
.build();
try {
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
System.out.println("예외 발생!@!@");
throw new RuntimeException(e);
}
}
3.3 Alarm과 Fcm에서 예외가 발생하지 않을 때 테스트 코드
@DisplayName("댓글을 등록한다. 본인이 작성한 게시글이 아니면 작성자에게 알람이 생성된다.")
@Test
void createCommentWithAlarm() {
// given
User writer = createUser("박지성", "01012345678");
User user = createUser("강혜원", "01011112222");
userRepository.saveAll(List.of(writer, user));
NotificationReceive notificationReceive = createNotificationReceive(writer, true, true, true);
notificationReceiveRepository.save(notificationReceive);
Post post = createPost("내용", writer);
postRepository.save(post);
CommentCreateRequest request = CommentCreateRequest.builder()
.content("댓글")
.build();
// when
CommentResponse commentResponse = commentService.createComment(post.getId(), request, user.getMobileNumber());
// then
assertThat(commentResponse.getCommentId()).isNotNull();
assertThat(commentResponse)
.extracting("postId", "userId", "content")
.containsExactlyInAnyOrder(post.getId(), user.getId(), request.getContent());
List<Alarm> alarms = alarmRepository.findAllByUserAndIsReadIsFalse(writer);
assertThat(alarms).hasSize(1)
.extracting("alarmType", "fromUser", "postId")
.containsExactlyInAnyOrder(
tuple(AlarmType.FEED, user.getId(), post.getId()));
verify(fcmService, times(1));
}
fcmService가 1번 호출 되었고, comment와 alarm 모두 정상적으로 저장되었음을 확인할 수 있습니다.
3.4 Alarm에 예외가 발생했을 때 테스트 코드
임의로 Alarm을 저장할 때 예외를 발생시켜보겠습니다.
예외가 발생하고 Comment와 Fcm은 각각 어떻게 되는지 확인해보겠습니다.
@DisplayName("댓글을 등록한다. 본인이 작성한 게시글이 아니면 작성자에게 알람이 생성된다.")
@Test
void createCommentWithAlarm() {
// given
User writer = createUser("박지성", "01012345678");
User user = createUser("강혜원", "01011112222");
userRepository.saveAll(List.of(writer, user));
NotificationReceive notificationReceive = createNotificationReceive(writer, true, true, true);
notificationReceiveRepository.save(notificationReceive);
Post post = createPost("내용", writer);
postRepository.save(post);
CommentCreateRequest request = CommentCreateRequest.builder()
.content("댓글")
.build();
// when
CommentResponse commentResponse = commentService.createComment(post.getId(), request, user.getMobileNumber());
// then
assertThat(commentResponse.getCommentId()).isNotNull();
assertThat(commentResponse)
.extracting("postId", "userId", "content")
.containsExactlyInAnyOrder(post.getId(), user.getId(), request.getContent());
List<Alarm> alarms = alarmRepository.findAllByUserAndIsReadIsFalse(writer);
assertThat(alarms).hasSize(0);
verify(fcmService, times(1));
}
정상적으로 작동함을 알 수 있습니다.
따러서 REQUIRES_NEW를 사용하면 알람과 FCM에 문제가 발생해도 Comment는 정상 작동합니다.
이렇게 트랜잭션 전파 옵션을 통하여 해결할 수 있지만, 단점이 존재합니다.
지금부터 트랜잭션 전파 옵션으로 해결하였을 때 발생할 수 있는 문제를 살펴보겠습니다.
3.5 REQUIRES_NEW의 단점
REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 DB 커넥션을 사용합니다.
사용자가 적은 서비스는 하나의 요청에 2개의 DB 커넥션을 사용한다고 해도 성능적인 측면에서 문제될 것이 없다고 생각합니다.
저희 서비스도 사용자가 없기 때문에 2개의 커넥션에 대한 문제가 발생하지 않았는데,
FCM이라는 외부 API를 사용하기 때문에 발생할 수 있는 문제를 미리 예측하였습니다.
DB 커넥션의 사이즈는 10개로 설정이 되어있는 상태입니다.
현재 FCM 서버에 문제가 발생하여 send가 되지 않고 로딩 중인 상황을 가정하였습니다.
try {
Thread.sleep(10000L);
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
log.info("FcmService - sendNotificationWithComment - {}", e.getMessage());
throw new RuntimeException(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
이런 경우에 댓글을 작성하면 커넥션을 2개를 점유하고 있는 상태가 됩니다.
여러 요청이 동시에 들어와 DB 커넥션을 전부 점유 중인 상태라면, 다른 API를 사용할 때 사용자가 기다려야 한다는 문제가 발생합니다.
성능이 중요한 곳에서는 주의가 필요합니다.
이런 단점 때문에 전파 옵션을 활용하면 안 된다! 가 아니라, 각각 장단점이 존재하기 때문에 현재 상황에 맞게 사용하면 됩니다.
3.6 단점 개선 방법
REQUIRES_NEW를 사용하지 않고 위와 같은 문제를 해결하기 위해서는 구조를 변경하는 방법이 있습니다.
CommentService에 @Transactional을 무작정 거는 패턴이 아닌, Service와 ImplementLayer로 나누어 구현하는 쪽에 Transactional을 사용하는 방법으로 구조를 변경할 수 있습니다.
현재 저희 프로젝트에서는 전자의 방식을 사용 중이여서 추후에 리팩토링을 통하여 더 나은 구조로 개선해보겠습니다.
4. @TransactionalEventListener 사용
이벤트 리스너를 사용한다면 트랜잭션 전파 옵션으로만 설정했던 것보다 장점이 존재합니다.
4.1 ApplicationEventPublisher를 통해 양방향 패키지 의존 삭제
CommentService가 AlarmService, FcmService를 의존하지 않고 ApplicationEventPublisher를 통해 이벤트를 발행할 수 있습니다.
의존 관계를 없앴을 경우 장점은 테스트 코드 작성에 매우 유리한 부분이라고 생각합니다.
createComment() (댓글 작성)라는 테스트를 짤 때 댓글 작성에 초첨을 맞출 수 없고 알림까지 테스트를 작성해야 되기 떄문에 핵심 기능 외에 다른 부가적인 기능 때문에 테스트의 집중도가 떨어집니다.
따라서 ApplicationEventPublisher를 활용한다면 댓글 알람에 대한 Mock가 필요 없고, EventPublisher에 대한 count만 체크해주면 되기 때문에 테스트 복잡도가 개선될 것이라고 생각합니다.
이러한 방식은 위에 트랜잭션 전파 옵션에 대한 코드와 다르지 않다고 생각합니다.
한 Service에서 모든 것을 처리하냐 아니면 이벤트를 발행하여 따른 패키지에서 처리를 하냐 차이입니다.
이벤트를 발행하여 처리하는 것도 똑같이 DB 커넥션을 2개 사용하기 때문에 더 효율적인 방법을 사용하려면 3번에서 말한 implementLayer를 구축하여 활용하는 것이 더 좋다고 생각합니다.
더 나아가 규모가 큰 서비스인 경우 kafka를 활용하여 댓글, 이벤트를 각각 별도의 시스템으로 구축하여 두 시스템이 의존성을 가지지 않고 동작할 수 있습니다.
추후에 메시지 브로커를 활용하여 개선해보겠습니다.
5. 결론
1. 규모가 작다면 어떤 방법을 써도 문제가 없다고 생각합니다.
2. 현재 주어진 상황에 맞게 적합한 방법을 선택하는 것이 좋습니다.
3. DB 커넥션에 대한 부담이 없으려면 Facade를 만들어서 각각 Transaction을 부여하고 DB 커넥션을 2개 사용하는 일이 없도록 만드는게 좋습니다.
'JPA' 카테고리의 다른 글
JPA: 페이징이 필요한 이유와 페이징 성능 개선하기 - No Offset 사용 (0) | 2024.06.05 |
---|---|
JDBC, JPA) 싱글벙글 JPA 프로젝트에서 이미지 업로드 쿼리를 최소화 해보자 (0) | 2023.08.08 |
Querydsl: OneToMany 관계에서 Projection DTO 값 List를 어떻게 갖고올까? (0) | 2023.04.01 |
JPA: 게시글 전체 조회 N + 1를 어떻게 처리할까? (0) | 2023.03.06 |