이번 포스팅 및 다음 포스팅에서는 유저의 사업자 검수 요청과 관련된
- `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 포탈로 인증되었다면 바로 유저 권한을 `ROOM_ADMIN`으로 승격
유저가 익명의 사업자 정보를 통해 `StudyWithMe` 서비스 접근이 가능하기 때문에 어떤 경우든 직접 검수를 해야한다.
정리하자면,
1번 검수의 중요 관점은 사업자 권한 자동 승격 후 "스터디 룸 생성"에 두는 것이고,
2번 검수의 중요 관점은 "사업자 권한 승격"에 두고 스터디 룸 생성은 신경쓰지 않는 것이다.
위를 토대로 회의를 나눈 결과, 2번을 선택하기로 했다.
2번 선택의 이유
- 1번처럼 자동으로 사용자 권한을 승격시킨 뒤, 스터디 룸 생성시 검증하는 것은 "권한" 사용에 대한 의미가 옅어짐.
- 1번처럼 스터디 룸 생성마다의 테이블 저장은 많은 스터디 룸 업장을 가진 유저의 경우 직접 검수의 양이 많아짐.
- 2번의 경우 직접 검수 과정에서 확실하게 검수한다면 해당 유저가 생성하는 스터디 룸도 믿을 수 있기에 검증할 필요가 없음.
물론, "처음에는 어떻게 검수되었지만 추후에 어떤 불법적인 일을 할지 모른다."라고 계속해서 이어나간다면 할말은 없다.
(하지만, 그렇게 전부 따지면 세상에 존재하는 모든 서비스의 절반은 사라져야 할 것이다.)
위의 이유들로 우리는 2번을 선택하기로 했다. 아래는 2번의 흐름도를 글로 표현한 것이다.
2번의 흐름도
- 유저의 사업자 검수 요청시 공공데이터 API 포탈에 인증된 사업자라면 `요청 DB`에 저장된다.
- 익명의 사업자로 검수 요청을 할 수도 있기 때문에 직접 검수를 진행한다.
- 검수 후 올바른 사업자로 판단 내린다면 `ROOM_ADMIN`으로 권한을 승격한다.
아니, 그래서 `ApplicationEventPublisher`는 왜 쓰는건데?
우리는 이제 서비스를 만들어가는 과정이며, 검수만을 담당할 사람도 없다. 또한, 초기 사용자 유치가 굉장히 중요하다.
즉, 검수 요청이 들어오면 바로 검수 후 승인하므로 초기 사용자를 사로잡아야 한다.
그렇기에 검수 요청이 들어왔다는 사실을 우리가 재빠르게 알아야 한다는 판단이 섰고,
이에 디스코드 알림을 보내야 겠다는 생각이 들게 되었다.
그래서, 디스코드 알림을 보내는 작업시에 `ApplicationEventPublisher`를 활용한다.
왜..?
위의 포스팅의 작성 내용처럼 아래의 상황에서 `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] 유저의 사업자 검수 요청을 처리하며 - 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 |
[StudyWithMe] 프로젝트에서의 쿼리 고민 일기(계속 추가 예정) (0) | 2024.12.10 |