개발일지

[개발일지_safeHome] 16. 등기부등본 비동기 분석 프로세스_PDF 검증_1

woopii 2026. 2. 5. 01:12

프로세스

  • 사용자가 PDF 업로드
  • 분석은 오래 걸림 → 비동기 처리
  • 분석 진행 상태를 SSE로 실시간 전달
  • PDF가 잘못되었으면 즉시 실패 처리

이제 PDF 검증부분을 추가해야하는데, 아래과 같은 고민이 생김

“PDF 검증은 어디서 해야 하지?”
“비동기 프로세스는 유스케이스가 직접 호출해도 되나?”

 


전체 흐름

최종적으로 정리된 흐름은 아래와 같다.

[Controller]
   ↓
[DeedUseCase]
   - Job 생성
   - SSE 연결
   - 분석 실행 지시
   ↓
[AnalysisJobExecutorPort]
   ↓
[AnalysisAsyncProcessor (@Async)]
   - PDF 검증
   - 단계별 분석
   - SSE 알림

유스케이스는 ‘시작’까지만 책임지고,
실제 처리는 전부 비동기 프로세서로 위임
한다.


Job 생성 + SSE 연결 (UseCase)

유스케이스에서는 딱 3가지만 한다.

  1. Job 생성 & 저장
  2. SSE 연결 생성
  3. 분석 실행 지시
@Transactional
override fun analyzeDeed(request: DeedRequest.Analyze): SseEmitter {

    val jobId = UUID.randomUUID().toString()

    val job = AnalysisJob.Create(
        jobId = jobId,
        fileName = "test_pdf.pdf",
        fileSize = 1024L,
        status = JobStatus.PENDING,
    )

    analysisJobPersistencePort.save(job)

    val emitter = analysisSseNotifierPort.createEmitter(jobId)

    analysisSseNotifierPort.notifyStep(
        jobId,
        JobStatus.PENDING,
        null,
        "분석 작업이 시작되었습니다."
    )

    analysisJobExecutorPort.execute(jobId, request.file)

    return emitter
}
 

유스케이스는 비동기 처리 방식 자체를 모름
“분석 시작해라”는 의도만 전달


PDF 검증은 어디서 할지

다음과 같은 이유로 PDF 검증은 비동기 프로세스 안에서 처리함

  • 검증 실패도 분석 실패의 한 단계
  • 실패 상태를 SSE로 전달해야 함
  • 유스케이스는 이미 성공 응답(SSE 연결)을 반환한 상태

PdfValidationService + InvalidPdfException

Domain Service

class PdfValidationService {

    fun validate(file: MultipartFile) {
        if (file.isEmpty) {
            throw InvalidPdfException("PDF 파일이 비어 있습니다.")
        }

        if (!file.originalFilename!!.endsWith(".pdf")) {
            throw InvalidPdfException("PDF 파일이 아닙니다.")
        }
    }
}

Exception 위치

domain.deed.domain.service.exception.InvalidPdfException

기술 예외 아님
도메인 규칙 위반


비동기 프로세스에서의 실패 처리

@Async
fun process(jobId: String, file: MultipartFile) {

    analysisProgressPort.notifyStep(
        jobId,
        JobStatus.IN_PROGRESS,
        AnalysisStep.PDF_PARSING,
        "첨부된 파일을 분석중이에요"
    )

    try {
        pdfValidationService.validate(file)
    } catch (e: InvalidPdfException) {

        analysisProgressPort.notifyStep(
            jobId,
            JobStatus.FAILED,
            AnalysisStep.PDF_PARSING,
            e.message ?: "PDF 검증 실패"
        )
        return
    }

    ...
}
 
 

예외를 던지지 않는다
실패는 JobStatus.FAILED 이벤트로 표현
SSE 클라이언트는 즉시 실패 인지 가능

->  GlobalExceptionHandler는 관여하지 않음


Executor Port 분리

유스케이스가 @Async 클래스를 직접 호출하는 게 마음에 걸렸다.

그래서 도입한 게 이 포트다.

Port

interface AnalysisJobExecutorPort {
    fun execute(jobId: String, file: MultipartFile)
}

Adapter

@Component
class AnalysisJobAsyncExecutorAdapter(
    private val analysisAsyncProcessor: AnalysisAsyncProcessor
) : AnalysisJobExecutorPort {

    override fun execute(jobId: String, file: MultipartFile) {
        analysisAsyncProcessor.process(jobId, file)
    }
}
 
 

유스케이스는 실행 방식 모름 (업무 규칙에만 집중)
Async → MQ → Batch로 변경 가능 (Port 구현만 교체)
테스트에서 mocking 쉬움 (AnalysisJobExecutorPort를 mock 하면 비동기 스레드 없이도 유스케이스 테스트가 가능)


이 구조에서 얻은 것

좋은 점

  • 유스케이스가 깔끔
  • 비동기/동기 전환 쉬움 (Executer의 구현체만 바꾸면 됨)
  • 실패를 도메인 이벤트처럼 처리
  • SSE와 분석 로직 결합도 낮음

고려 사항

비동기 프로세스는
“기술 구현”이 아니라
“업무 흐름의 일부”로 다뤄야 한다


마무리

이번에 정리하면서 한 주요 고민점

  • 유스케이스는 의도만 표현
  • 실패/성공은 상태 변화
  • 예외는 흐름 제어용으로 쓰지 않음
  • 비동기 프로세스 실행 전략

다음으로 정리하면 좋을 주제는 아마 이거일 듯하다.

  • JobStatus / AnalysisStep 변경 책임
  • Job 상태를 DB에 언제 반영할 것인가
  • SSE 재연결 전략