/**
* 눈길을 보내는 api입니다
* member에게 recommend status의 눈길을 SENT로 수정하며
* receiver에게 status가 RECEIVED인 눈길을 생성합니다
*
* @param nungilId 눈길 id
*/
@Transactional
public void sendNungil(Member member, Long nungilId){
.....
//이미 눈길을 보냈을 시 중단
.....
//사용자의 눈길 상태를 SENT, 만료일을 일주일 뒤로 설정
.....
//눈길 받는 사용자 눈길 객체 생성 및 저장
.....
// 눈길 받은 사용자에게 알림 전송
String phoneNumber = receiver.getPhoneNumber();
String url = BASE_URL + "/receiveddetailpage/" + newNungil.getId();
String text = "[눈길] 새로운 눈길이 도착했어요. 얼른 확인해보세요!\n" + url;
coolSMS.send(phoneNumber, text);
}
현재 로직에서는 눈길(매칭 신청)을 보내는 기능( member는 recommend status 눈길을 SENT로 수정하며 receiver에게 status가 RECEIVED인 눈길을 생성)과 눈길을 받는(매칭 신청을 받는) 사용자에게 SMS를 보내는 기능을 하나의 transaction으로 구현했다.
이러한 구현의 문제점은 외부 api인 sms 기능의 장애가 있을 시 핵심 기능인 매칭 자체가 롤백된다는 점이다.
이는 매칭 수락에서도 같은 문제가 발생한다.
단일책임원칙(SRP)을 위반한다고 볼 수 있다.
소요시간
매칭 신청(sendNungil) : 1480ms(서버) 1591ms(클라이언트)
매칭 수락(matchNungil) : 1923ms(서버) 2400ms(클라이언트)
/**
* 인증번호를 생성하고, 주어진 회사 이메일로 발송하는 메서드이다.
* @param email 회사 이메일
*/
public void sendAuthEmail(String email, Member member) {
// 사용이 불가능한 회사 도메인인지 확인한다.
...
// 이미 가입된 이메일인지 확인한다.
...
// code: 알파벳 대문자와 숫자로 구성된 랜덤 문자열의 인증번호
...
// redis에 이메일을 키로 하여 인증번호를 저장한다.
...
// 이메일을 발송한다.
String subject = "[눈길] 회사 인증 메일입니다.";
String filename = "company-authentication.html";
emailSender.send(EmailMessage.create(email, subject, filename).addContext("code", code));
}
이메일 인증에서는 이메일만을 발송하기에 SMS 외부호출 로직과는 다르게 문제가 발생하지 않는다.
하지만 메일 발송은 후에 디벨롭하면서 여러 도메인에서 공통적으로 사용할 수 있기에 공통 도메인에 대한 의존성을 제거하여 재사용성을 높이는 편이 좋다
리팩토링
CoolSMS 객체를 사용하지 않음으로써 의존성을 분리하여 두 클래스를 느슨하게 결합시켰다.
매칭 신청과 수락 로직이 외부 api 호출에 영향을 받지 않게 된다.
메세지 구독 모듈을 추가 또는 삭제할 때 다른 모듈에 영향을 주지 않은 채 수정 가능하다, 즉 유지보수성과 확장성이 좋아진다
후에 msa 구조로 서버를 분리할 시에 효과적일 수 있다
클래스가 독립적이므로 재사용성을 높일 수 있다.
테스트가 용이해진다
matchNungil
@Transactional
public void matchNungil(Long nungilId){
//사용자의 눈길을 MATCHED 상태로 변경
.....
//수취자의 눈길 조회 후 MATCHED 상태로 변경
.....
// 서로에 대한 Acquaintance 객체 생성 및 저장
.....
// 매칭된 사용자 간에 겹치는 시간, 마커를 조회하여 저장
.....
// 매칭된 사용자들을 채팅방에 초대
.....
// 눈길 보낸 사용자에게 알림 전송
// String phoneNumber = sender.getPhoneNumber();
// String url = BASE_URL + "/finishmatch/" + sentNungil.getId();
// String text = "[눈길] 축하해요! 서로의 눈길이 닿았어요. 채팅방을 통해 두 분의 첫만남 약속을 잡아보세요.\n" + url;
this.sendMatchSMS(sender, sentNungil);
// coolSMS.send(phoneNumber, text);
}
public void sendMatchSMS(Member sender, Nungil sentNungil){
publisher.publishEvent(new NungilMatchedEvent(sender, sentNungil));
}
sendNungil
@Transactional
public void sendNungil(Member member, Long nungilId){
Nungil nungil = nungilRepository.findById(nungilId)
.orElseThrow(()->new GeneralException(NungilErrorResult.NUNGIL_NOT_FOUND));
Member receiver = nungil.getReceiver();
Acquaintance memberAcquaintance = getAcquaintance(member, receiver);
Acquaintance receiverAcquaintance = getAcquaintance(receiver, member);
if(!nungil.getStatus().equals(NungilStatus.RECOMMENDED)){
throw new GeneralException(NungilErrorResult.NUNGIL_WRONG_STATUS);
}
//이미 눈길을 보냈을 시 중단
List<Nungil> receiverNungilList = nungilRepository.findAllByMemberAndReceiverAndStatus(receiver, member, NungilStatus.RECEIVED);
if(receiverNungilList.size() > 0){
return ;
}
//사용자의 눈길 상태를 SENT, 만료일을 일주일 뒤로 설정
nungil.setStatus(NungilStatus.SENT);
nungil.setExpiredAt7DaysAfter();
memberAcquaintance.update(NungilStatus.SENT);
//눈길 받는 사용자 눈길 객체 생성 및 저장
Nungil newNungil = Nungil.create(receiver, member, NungilStatus.RECEIVED);
newNungil.setExpiredAt7DaysAfter();
receiverAcquaintance.update(NungilStatus.RECEIVED);
acquaintanceRepository.save(receiverAcquaintance);
nungilRepository.save(newNungil);
// 눈길 받은 사용자에게 알림 전송
// String phoneNumber = receiver.getPhoneNumber();
// String url = BASE_URL + "/receiveddetailpage/" + newNungil.getId();
// String text = "[눈길] 새로운 눈길이 도착했어요. 얼른 확인해보세요!\n" + url;
//
// coolSMS.send(phoneNumber, text);
this.sendNungilSMS(receiver, newNungil);
}
public void sendNungilSMS(Member sender, Nungil sentNungil){
publisher.publishEvent(new NungilSentEvent(sender, sentNungil));
}
NungilMatchedEvent
@Getter
@Validated
@RequiredArgsConstructor
public class NungilMatchedEvent {
@NotNull
private final Member sender;
@NotNull
private final Nungil sentNungil;
}
NungilSentEvent
@Getter
@Validated
@RequiredArgsConstructor
public class NungilSentEvent {
@NotNull
private final Member sender;
@NotNull
private final Nungil sentNungil;
}
NungilMatchedEventListener
@Component
@RequiredArgsConstructor
public class NungilEventListener {
private final CoolSMS coolSMS;
private static final String BASE_URL = "https://nungil.com";
@Async
@EventListener
public void matchNungilListen(NungilMatchedEvent nungilMatchedEvent){
// 눈길 보낸 사용자에게 알림 전송
String phoneNumber = nungilMatchedEvent.getSender().getPhoneNumber();
String url = BASE_URL + "/finishmatch/" + nungilMatchedEvent.getSentNungil().getId();
String text = "[눈길] 축하해요! 서로의 눈길이 닿았어요. 채팅방을 통해 두 분의 첫만남 약속을 잡아보세요.\n" + url;
coolSMS.send(phoneNumber, text);
}
@Async
@EventListener
public void sentNungilListen(NungilSentEvent nungilSentEvent){
// 눈길 보낸 사용자에게 알림 전송
String phoneNumber = nungilSentEvent.getSender().getPhoneNumber();
String url = BASE_URL + "/receiveddetailpage/" + nungilSentEvent.getSentNungil().getId();
String text = "[눈길] 새로운 눈길이 도착했어요. 얼른 확인해보세요!\n" + url;
coolSMS.send(phoneNumber, text);
}
}
@Async 를 사용하여 비동기로 구현함으로써 소요시간을 줄이고자하였다.
이벤트 발행을 발행하기 전/후가 더 이상 하나의 트랜잭션으로 묶일 수 없게 되지만 현재 로직에서는 순차적 실행이 불필요하므로 신경 쓰지 않았다
현재 비동기 처리하기 위해 @Async를 이용하였다.
@Async 는 일부 메시지를 비동기 처리하기 위한 어노테이션이며 다수의 applicationListener객체를 관리하고 객체에 이벤트를 전달하기 위한 인터페이스는 ApplicationEventMulticaster 이다
리뷰 이후 수정 사항
NungilEventListener 의 각 메소드가 디미티의 법칙을 지키지 못하고 있다는 리뷰를 받았다.
matchNungilListen과 sentNungilListen 각 event 객체에 대한 정보를 너무 많이 알고 있어 객체지향스럽지 못하다는 의견을 받아 각 event 클래스에 메서드를 만들어 디미터의 법칙을 준수하고자 수정했다.
public class NungilSentEvent {
@NotNull
private final Member sender;
@NotNull
private final Nungil sentNungil;
public String getSenderPhoneNumber(){
return sender.getPhoneNumber();
}
}
public class NungilMatchedEvent {
@NotNull
private final Member sender;
@NotNull
private final Nungil sentNungil;
public String getMatcherPhoneNumber(){
return sender.getPhoneNumber();
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 적용
NungilService의 sendNungilSMS 메서드의 트랜잭션이 성공적으로 커밋되어야만 해당 listner의 메서드를 실행시킨다. 이렇게 함으로써 sendNungilSMS 메서드가 정상적인 처리에 실패해서 롤백이 발생할 경우 외부 API는 호출되는 것을 방지할 수 있다.
@Component
@RequiredArgsConstructor
public class NungilEventListener {
private final CoolSMS coolSMS;
private static final String BASE_URL = "https://nungil.com";
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void matchNungilListen(NungilMatchedEvent nungilMatchedEvent){
// 눈길 보낸 사용자에게 알림 전송
String phoneNumber = nungilMatchedEvent.getMatcherPhoneNumber();
String url = BASE_URL + "/finishmatch/" + nungilMatchedEvent.getSentNungil().getId();
String text = "[눈길] 축하해요! 서로의 눈길이 닿았어요. 채팅방을 통해 두 분의 첫만남 약속을 잡아보세요.\n" + url;
coolSMS.send(phoneNumber, text);
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sentNungilListen(NungilSentEvent nungilSentEvent){
// 눈길 보낸 사용자에게 알림 전송
String phoneNumber = nungilSentEvent.getSenderPhoneNumber();
String url = BASE_URL + "/receiveddetailpage/" + nungilSentEvent.getSentNungil().getId();
String text = "[눈길] 새로운 눈길이 도착했어요. 얼른 확인해보세요!\n" + url;
coolSMS.send(phoneNumber, text);
}
}
결론
소요시간 단축
매칭 신청(sendNungil) : 1071(서버) 1097ms(클라이언트)
약 31% 개선, 클라이언트 기준 494ms
매칭 수락(matchNungil) : 1299ms(서버) 1482ms(클라이언트)
약 39% 개선, 클라이언트 기준 918ms
구조 개선
CoolSMS 객체를 사용하지 않음으로써 의존성을 분리하여 두 클래스를 느슨하게 결합시켰다.
매칭 신청과 수락 로직이 외부 api 호출에 영향을 받지 않게 된다.
메세지 구독 모듈을 추가 또는 삭제할 때 다른 모듈에 영향을 주지 않은 채 수정 가능하다, 즉 유지보수성과 확장성이 좋아진다
🔥 Related Issues
💜 작업 내용
✅ PR Point
reference
[Spring Event + Async + AOP 적용해보기](https://supawer0728.github.io/2018/03/24/spring-event/)
[[Spring] 스프링에서 이벤트의 발행과 구독 방법과 주의사항, 이벤트 사용의 장/단점과 사용 예시](https://mangkyu.tistory.com/292)
요구사항
기존 코드
sendNungil
) : 1480ms(서버) 1591ms(클라이언트)matchNungil
) : 1923ms(서버) 2400ms(클라이언트)리팩토링
CoolSMS
객체를 사용하지 않음으로써 의존성을 분리하여 두 클래스를 느슨하게 결합시켰다.matchNungil
sendNungil
NungilMatchedEvent
NungilSentEvent
NungilMatchedEventListener
@Async
를 사용하여 비동기로 구현함으로써 소요시간을 줄이고자하였다.@Async
를 이용하였다.@Async
는 일부 메시지를 비동기 처리하기 위한 어노테이션이며 다수의 applicationListener객체를 관리하고 객체에 이벤트를 전달하기 위한 인터페이스는ApplicationEventMulticaster
이다리뷰 이후 수정 사항
NungilEventListener 의 각 메소드가 디미티의 법칙을 지키지 못하고 있다는 리뷰를 받았다.
matchNungilListen
과sentNungilListen
각 event 객체에 대한 정보를 너무 많이 알고 있어 객체지향스럽지 못하다는 의견을 받아 각 event 클래스에 메서드를 만들어 디미터의 법칙을 준수하고자 수정했다.@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 적용
NungilService
의sendNungilSMS
메서드의 트랜잭션이 성공적으로 커밋되어야만 해당 listner의 메서드를 실행시킨다. 이렇게 함으로써sendNungilSMS
메서드가 정상적인 처리에 실패해서 롤백이 발생할 경우 외부 API는 호출되는 것을 방지할 수 있다.결론
sendNungil
) : 1071(서버) 1097ms(클라이언트)matchNungil
) : 1299ms(서버) 1482ms(클라이언트)CoolSMS
객체를 사용하지 않음으로써 의존성을 분리하여 두 클래스를 느슨하게 결합시켰다.