
이번 포스팅 및 다음 포스팅에서는 유저의 사업자 검수 요청과 관련된
StudyWithMe
프로젝트에서의ApplicationEventPublisher
사용ApplicationEventPublisher
사용과 관련된 테스트 코드에서@Transactional
를 제거
과정에 관해 설명하려고 한다.
ApplicationEventPublihser
우선, ApplicationEventPublisher
가 뭘까?
ApplicationEventPublisher
- Spring의
ApplicationContext
가 상속하는 인터페이스 중 하나 - 디자인 패턴중 하나인 옵저버 패턴(Observer Pattern)의 구현체
- 옵저버 패턴
- 객체의 상태 변화를 관찰하는 옵저버들의 목록을 객체에 등록하고 상태 변화가 있을때마다
메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴
- 객체의 상태 변화를 관찰하는 옵저버들의 목록을 객체에 등록하고 상태 변화가 있을때마다
- 옵저버 패턴
이벤트를 발행하는 Publisher
와 이를 감시하는 Observer(또는 Subscriber)
사이의
결합도를 낮추면서 이벤트를 Observer
에게 전달하고 싶을 때 사용한다.
즉, 간단하게 설명하자면 Pub/Sub
구조처럼 특정 이벤트에 관해 적절한 로직의 처리가 가능하다.
사업자 검수 요청 요구사항
StudyWithMe
에서 ApplicationEventPublisher
를 쓰게 된 계기는 다음과 같다.
- 회의를 통해 나온 유저의 사업자 검수 요청을 처리 방식은 아래 2가지 방식이다.
- 유저의 사업자 검수 요청이 공공데이터 API 포탈로 인증되었다면 바로 유저 권한을
ROOM_ADMIN
으로 승격- 이후, 유저의 스터디 룸 생성 요청을 테이블로 따로 관리해 직접 검수
- 유저의 사업자 검수 요청시 공공데이터 API 포탈로 인증되었다면 사업자 검수 요청 테이블에 저장하고 검수 후 권한 승격
- 1번과 달리, 2번의 경우에는 스터디 룸 생성 요청을 직접 검수하지 않음
- 유저의 사업자 검수 요청이 공공데이터 API 포탈로 인증되었다면 바로 유저 권한을
유저가 익명의 사업자 정보를 통해 StudyWithMe
서비스 접근이 가능하기 때문에 어떤 경우든 직접 검수를 해야한다.
정리하자면,
1번 검수의 중요 관점은 사업자 권한 자동 승격 후 "스터디 룸 생성"에 두는 것이고,
2번 검수의 중요 관점은 "사업자 권한 승격"에 두고 스터디 룸 생성은 신경쓰지 않는 것이다.
위를 토대로 회의를 나눈 결과, 2번을 선택하기로 했다.
2번 선택의 이유
- 1번처럼 자동으로 사용자 권한을 승격시킨 뒤, 스터디 룸 생성시 검증하는 것은 "권한" 사용에 대한 의미가 옅어짐.
- 1번처럼 스터디 룸 생성마다의 테이블 저장은 많은 스터디 룸 업장을 가진 유저의 경우 직접 검수의 양이 많아짐.
- 2번의 경우 직접 검수 과정에서 확실하게 검수한다면 해당 유저가 생성하는 스터디 룸도 믿을 수 있기에 검증할 필요가 없음.
물론, "처음에는 어떻게 검수되었지만 추후에 어떤 불법적인 일을 할지 모른다."라고 계속해서 이어나간다면 할말은 없다.
(하지만, 그렇게 전부 따지면 세상에 존재하는 모든 서비스의 절반은 사라져야 할 것이다.)
위의 이유들로 우리는 2번을 선택하기로 했다. 아래는 2번의 흐름도를 글로 표현한 것이다.
2번의 흐름도
- 유저의 사업자 검수 요청시 공공데이터 API 포탈에 인증된 사업자라면
요청 DB
에 저장된다. - 익명의 사업자로 검수 요청을 할 수도 있기 때문에 직접 검수를 진행한다.
- 검수 후 올바른 사업자로 판단 내린다면
ROOM_ADMIN
으로 권한을 승격한다.
아니, 그래서 ApplicationEventPublisher
는 왜 쓰는건데?
우리는 이제 서비스를 만들어가는 과정이며, 검수만을 담당할 사람도 없다. 또한, 초기 사용자 유치가 굉장히 중요하다.
즉, 검수 요청이 들어오면 바로 검수 후 승인하므로 초기 사용자를 사로잡아야 한다.
그렇기에 검수 요청이 들어왔다는 사실을 우리가 재빠르게 알아야 한다는 판단이 섰고,
이에 디스코드 알림을 보내야 겠다는 생각이 들게 되었다.
그래서, 디스코드 알림을 보내는 작업시에 ApplicationEventPublisher
를 활용한다.
왜..?
분산 시스템에서 데이터를 전달하는 효율적인 방법 - 1
이번 포스팅에서는 NHN 유튜브의 분산 시스템에서 데이터를 전달하는 효율적인 방법 강의를 보고 정리한 내용이다. 포스팅에서 다룰 내용데이터 전달 보장 방법론RDB를 사용하는 애플리케이션
hdbstn3055.tistory.com
위의 포스팅의 작성 내용처럼 아래의 상황에서 ApplicationEventPublisher
와 같이 적절히 처리하지 않으면, 문제가 생긴다.
상황
- 공공데이터 API 포탈로부터 인증된 사용자는 사업자 검수 요청 DB에 저장하려고 한다.
- 커밋 전에 사업자 검수 요청에 관한 디스코드 알림 요청을 전송한다.
- 이때! 모종의 이유로 사업자의 검수 요청을 DB에 저장하는데 실패했다면?
- DB에는 데이터가 없는데 부적절한 디스코드 알림 요청이 전송된다.
위의 상황을 보면 DB에는 저장이 되지 않았는데 디스코드에서는 사업자 검수 요청이 왔다는 연락을 받으므로,
좀 이상한 상황이 발생한다.
그래서! ApplicationEventPublisher
를 활용해 COMMIT
이후에만 디스코드 알림이 전송되도록 한다.
어우.. 사용 이유를 설명하기 위해 여기까지 왔다..
이제는 코드와 함께 알아보자.
Events
Events
는 ApplicationEventPublisher
를 사용하기 위해 공통으로 사용하는 클래스이다.
public class Events {
private static ApplicationEventPublisher publisher;
public static void register(ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void send(Object event){
if(publisher != null){
publisher.publishEvent(event);
}
}
}
UserCommandService
@Service
@RequiredArgsConstructor
public class UserCommandService {
private final RedisService redisService;
private final EmailService emailService;
private final BusinessStatusService businessStatusService;
private final StudyRoomCommandService studyRoomCommandService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserCredentialRepository userCredentialRepository;
private final BusinessVerificationRequestRepository businessVerificationRequestRepository;
private final StudyRoomRepository studyRoomRepository;
...
@Transactional
public void validateBusinessStatus(UpgradeRoomAdminRequest request, UUID userId) {
if (!businessStatusService.validateBusinessStatus(request)) {
throw new GlobalException(ErrorCode.NOT_VALID, "국세청에 등록되지 않은 사업자등록번호입니다.");
} else {
User user = userRepository.findById(userId)
.orElseThrow(() -> new GlobalException(ErrorCode.NOT_FOUND, "User Not Found"));
if(businessVerificationRequestRepository
.existsByBusinessNumber(request.getBusinessNumber())
) {
throw new GlobalException(ErrorCode.NOT_VALID, "해당 사업자 번호는 요청되어 있습니다.");
}
BusinessVerificationRequest businessVerificationRequest = BusinessVerificationRequest.of(user, request);
businessVerificationRequestRepository.save(businessVerificationRequest);
Events.send(BusinessVerificationRequestEvent.from(businessVerificationRequest));
}
}
...
}
- validateBusinessStatus
- 공공데이터 포탈에 등록되지 않은 사용자라면 거부
- 공공데이터 포탈에 등록된 사용자라면
- 요청
userId
가 존재하는지를 확인한다. - 해당 사업자 번호가 이미 등록되어 있는지 확인한다.
- 그렇지 않다면 사업자 검수 요청을 DB에 저장한다.
- 그 후,
Events.send()
로 관련 이벤트를 전달한다.
- 요청
위의 방식으로 로직을 구성했다.
그러면 해당 Events
는 어떻게 받아서 처리할까?
BusinessVerificationEventHandler
@Slf4j
@Service
@RequiredArgsConstructor
public class BusinessVerificationEventHandler {
private final DiscordNotificationService discordNotificationService;
@TransactionalEventListener(classes = BusinessVerificationRequestEvent.class, phase = TransactionPhase.AFTER_COMMIT)
public void businessVerificationRequestEventAfterCommitHandler(BusinessVerificationRequestEvent event) {
discordNotificationService.sendBusinessVerificationNotification(event).thenAccept(success -> {
if(!success){
log.error("디스코드 알림 전송 오류, time: {}", LocalDateTime.now(ZoneId.of("Asia/Seoul")));
} else {
log.info("디스코드 알림 전송 성공, time: {}", LocalDateTime.now(ZoneId.of("Asia/Seoul")));
}
});
}
}
@TransactionalEventListener
AFTER_COMMIT
시점에 위의UserCommandService
에서Events.send()
로 전송한BusinessVerificationRequestEvent
를 전달받아 디스코드 알림 요청 전송CompletableFuture
객체를 받아 성공/실패에 맞게 로그 작성
위의 코드 및 설명을 보면 알 수 있는 것처럼 COMMIT
이 일어난 이후에 해당 이벤트를 받아들여 처리한다.
즉, 예외 혹은 오류가 발생하지 않아 COMMIT
이 되었을 때만 해당 이벤트를 받아 처리한다.
DiscordNotificationService
@Slf4j
@Service
@RequiredArgsConstructor
public class DiscordNotificationService {
@Value("${discord.webhook.business-verification-request.url}")
private String webhookUrl;
private final RestClient restClient;
@Async("asyncExecutor")
public CompletableFuture<Boolean> sendBusinessVerificationNotification(BusinessVerificationRequestEvent event) {
try{
ResponseEntity<Void> response = restClient.post()
.uri(webhookUrl)
.contentType(MediaType.APPLICATION_JSON)
.body(buildDiscordNotification(event))
.retrieve()
.toEntity(Void.class);
if(!response.getStatusCode().isSameCodeAs(HttpStatus.NO_CONTENT)){
throw new GlobalException(ErrorCode.EXTERNAL_API_ERROR, "디스코드 알림 전송 오류");
}
return CompletableFuture.completedFuture(true);
} catch (Exception e){
log.error(e.getMessage());
return CompletableFuture.completedFuture(false);
}
}
...
}
- sendBusinessVerificationNotification()
- 비동기로 실행되며 성공/실패 시
CompletableFuture
를 반환 - 응답 상태코드가
NO_CONTENT
가 아니라면 예외반환
- 비동기로 실행되며 성공/실패 시
디스코드 알림

디스코드 알림도 성공적으로 전달됨이 확인된다.
추후에 Spring Scheduler
를 사용해 PENDING
상태의 요청이 있는지에 관해
1시간에 한번씩 디스코드 알림을 전달할 계획도 있다.
자, 지금까지의 내용을 정리해보자.
정리
- 유저의 사업자 검증이라는 요구사항 존재
- 유저의 사업자 검증 요청 요구사항을 위해 직접 검수의 시점에 관한 고민
- 현재 서비스 단계 고려 및 초기 유저 확립을 위해 빠른 검수 요청 처리가 필요
- 디스코드 알림을 통한 빠른 검수 요청 처리 기대
- 예상치 못한 DB
COMMIT
실패로 인한 디스코드 알림과의 데이터 불일치 상황을 위해ApplicationEventPublisher
활용
라고 정리할 수 있다.
그럼 사업자 검수 요청을 어떻게 처리하는데?(참고)
사업자 검수 요청 테이블은 StudyWithMe
프로젝트 인원이 접근해서 ACCEPTED
, REJECTED
를 수행해야 한다.
그렇기에 설정한 ADMIN
권한의 유저만 접근해야 한다.
UserCommandController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserCommandController {
private final UserCommandService userCommandService;
...
@PreAuthorize("hasRole('ADMIN')")
@PatchMapping("/v1/user/business/verification/requests/approval")
public ApiResponse<Void> updateInspectionStatusApproval(
@Valid @RequestBody UpdateInspectionStatusRequest request
) {
userCommandService.updateInspectionStatusApproval(request.getBusinessVerificationRequestIds());
return ApiResponse.ok(null);
}
@PreAuthorize("hasRole('ADMIN')")
@PatchMapping("/v1/user/business/verification/requests/rejection")
public ApiResponse<Void> updateInspectionStatusRejection(
@Valid @RequestBody UpdateInspectionStatusRequest request
) {
userCommandService.updateInspectionStatusRejection(request.getBusinessVerificationRequestIds());
return ApiResponse.ok(null);
}
}
@PreAuthorize("hasRole('ADMIN')")
ADMIN
권한을 갖는 유저가 해당Controller
메소드를 수행할 수 있게 설정했다.- 즉, 어드민 계정을 갖는 유저가 승인/거절 작업을 할 수 있다.
BusinessVerificationRequestRepository
public interface BusinessVerificationRequestRepository extends JpaRepository<BusinessVerificationRequest, Long> {
boolean existsByBusinessNumber(String businessNumber);
@Query("select bvr from BusinessVerificationRequest bvr where bvr.inspectionStatus in ?1")
Page<BusinessVerificationRequest> findPagedVerificationRequestWithStatus(List<InspectionStatus> status, Pageable pageable);
@Modifying
@Query("update BusinessVerificationRequest bvr set bvr.inspectionStatus = 'APPROVED' where bvr.id in ?1")
void updateInspectionStatusApproval(List<Long> businessVerificationRequestIds);
@Modifying
@Query("update BusinessVerificationRequest bvr set bvr.inspectionStatus = 'REJECTED' where bvr.id in ?1")
void updateInspectionStatusRejection(List<Long> businessVerificationRequestIds);
long countByIdIn(List<Long> ids);
@Modifying
@Query("delete from BusinessVerificationRequest bvr where bvr.user.id = ?1")
void deleteAllByUserId(UUID userId);
}
- 각각의 API에 대해
APPROVED
혹은REJECTED
로 지정한다.- 추후에 하나의 메소드만을 사용하고 인자로 승인/거부를 나누는 것도 좋을 것 같다.
조회도 마찬가지로,
UserQueryController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserQueryController {
private final UserQueryService userQueryService;
...
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/v1/user/business/verification/requests")
public ApiResponse<PageResponse<GetBusinessVerificationRequestResponse>> getBusinessVerificationRequests(
@RequestParam(
value = "pageNo",
required = false,
defaultValue = "0"
) int pageNo,
@RequestParam(
value = "status",
required = false,
defaultValue = "PENDING, APPROVED, REJECTED"
) List<InspectionStatus> status
) {
PageResponse<GetBusinessVerificationRequestResponse> response
= userQueryService.getBusinessVerificationRequests(status, pageNo);
return ApiResponse.ok(response);
}
...
}
@PreAuthorize("hasRole('ADMIN')")
를 사용한다.defaultValue = "PENDING, APPROVED, REJECTED"
를 사용해서 상태에 따라 데이터를 조회한다.- 페이지 별 20개씩
사업자 검수 요청 및 다양한 어드민 처리를 위해 백오피스로 새롭게 프로젝트를 만드는 방법도 있겠지만,
현재 단계에서는 백오피스까지는 투머치(?)라는 생각이 들었다.
마무리하며
앞서, 정리한 것처럼 이번 포스팅의 주 내용은 아래와 같다.
정리
- 유저의 사업자 검증이라는 요구사항 존재
- 유저의 사업자 검증 요청 요구사항을 위해 직접 검수의 시점에 관한 고민
- 현재 서비스 단계 고려 및 초기 유저 확립을 위해 빠른 검수 요청 처리가 필요
- 디스코드 알림을 통한 빠른 검수 요청 처리 기대
- 예상치 못한 DB
COMMIT
실패로 인한 디스코드 알림과의 데이터 불일치 상황을 위해ApplicationEventPublisher
활용
더 요약하자면, 유저 사업자 검증이라는 요구사항에 맞춰 고민한 내용과 요구사항을 해결해가는 과정에서의 ApplicationEventPublisher
의 활용에 관한 포스팅이다.
하지만! 위의 비즈니스 로직의 테스트 코드에서 ApplicationEventPublisher
과 연관된 오류들을 만나며,
기존 테스트 코드를 대폭 수정했다.
관련해서는 다음 포스팅으로 다루도록 한다.
'프로젝트 > StudyWithMe' 카테고리의 다른 글
[StudyWithMe] 스터디 윗 미 프로젝트에서 n8n 사용해보기 (0) | 2025.02.03 |
---|---|
[StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 2 (0) | 2025.01.24 |
[StudyWithMe] Async Thread Pool과 CompletableFuture (0) | 2025.01.12 |
[StudyWithMe] 스터디 윗 미 dev 무중단 배포 (0) | 2024.12.24 |
[StudyWithMe] 영속성 컨텍스트와 관련된 퀴즈 풀어보실 분? (0) | 2024.12.23 |