개발 고민

[개발일지_safeHome] 12. 등기부등본 분석 작업 관련 DB 설계 및 서비스 개발(임시)

woopii 2026. 1. 8. 01:52

 

분석 요청 API 동작 시 

비동기로 처리를 할건데

처리를 위한 job 을 관리하는 DB를 만들것이다.

 

1. analysis_jobs 테이블 설계

  • schema.sql
-- 분석 job 테이블
CREATE TABLE IF NOT EXISTS analysis_jobs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
    job_id VARCHAR(255) NOT NULL COMMENT '작업 ID(UUID)',
    file_name VARCHAR(255) NOT NULL COMMENT '파일명',
    file_size BIGINT NOT NULL COMMENT '파일 크기',
    status VARCHAR(50) NOT NULL COMMENT '상태(예: PENDING, IN_PROGRESS, COMPLETED, FAILED)',
    result TEXT NULL COMMENT '분석 결과 JSON',
    description TEXT NULL COMMENT '설명 (에러메시지 등)',
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제 여부',
    created_id BIGINT NOT NULL COMMENT '생성자 ID',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
    updated_id BIGINT NULL COMMENT '수정자 ID',
    updated_at TIMESTAMP NULL DEFAULT NULL COMMENT '수정 시간'
);

CREATE INDEX IF NOT EXISTS idx_analysis_jobs_job_id ON analysis_jobs(job_id);
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_status ON analysis_jobs(status);

 

 

2. 패키지 구성

deed
├── adapter
│   ├── inbound.web
│   │   ├── dto
│   │   │   ├── DeedRequest
│   │   │   └── DeedResponse
│   │   └── DeedInboundWebAdapter
│   └── outbound.persistence
│       ├── jpa
│       │   ├── AnalysisJobEntity
│       │   └── AnalysisJobRepository
│       ├── AnalysisJobentityMapper
│       └── AnalysisJobPersistenceAdapter
├── application
│   ├── port
│   │   ├── inbound
│   │   │   └── DeedUseCase
│   │   └── outbound
│   │       └── AnalysisJobPersistencePort
│   └── usecase
│       └── DeedUseCaseImpl
└── model
    └── AnalysisJob

3. 엔티티 생성

  • AnalysisJobEntity
package com.woopi.safehome.domain.deed.adapter.outbound.persistence.jpa

import com.woopi.safehome.global.enums.AnalysisJobStatus
import com.woopi.safehome.global.`object`.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table

@Entity
@Table(name = "analysis_jobs")
class AnalysisJobEntity(

    @Column(name = "job_id" )
    var jobId: String,

    @Column(name = "file_name" )
    var fileName: String,

    @Column(name = "file_size" )
    var fileSize: Long,

    @Column(name = "status" )
    @Enumerated(EnumType.STRING)
    var status: AnalysisJobStatus,

    @Column(name = "result" )
    var result: String? = null,

    @Column(name = "description" )
    var description: String? = null,

    ) : BaseEntity() {

}

 

 

4. repository 생성

  • AnalysisJobRepository
package com.woopi.safehome.domain.deed.adapter.outbound.persistence.jpa

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface AnalysisJobRepository : JpaRepository<AnalysisJobEntity, Long> {
    fun findByJobId(jobId: String): AnalysisJobEntity?
}

 

 

5. model 생성

  • AnalysisJob
    • 클래스 수를 줄이기 위해서 object 선언 후 안에 data class를 추가
    • Query, Create, Update 모두 조회와 관련된 모델
    • Data는 응답시의 모델(Info, Response 랑 고민했는데, Data가 나아보임...)
package com.woopi.safehome.domain.deed.model

import com.woopi.safehome.global.enums.AnalysisJobStatus

object AnalysisJob {

    data class Query(
        val id: Long,
        val status: AnalysisJobStatus
    )

    data class Create(
        val jobId: Long,
        val fileName: String,
        val fileSize: Long,
        val status: AnalysisJobStatus,
        val result: String? = null,
        val description: String? = null
    )

    data class Update(
        val id: Long,
        val status: AnalysisJobStatus,
        val result: String? = null,
        val description: String? = null
    )

    data class Data(
        val id: Long,
        val jobId: Long,
        val fileName: String,
        val fileSize: Long,
        val status: AnalysisJobStatus,
        val result: String? = null,
        val description: String? = null
    )

}

 

 

6. model - entity Mapper 생성

package com.woopi.safehome.domain.deed.adapter.outbound.persistence

import com.woopi.safehome.domain.deed.adapter.outbound.persistence.jpa.AnalysisJobEntity
import com.woopi.safehome.domain.deed.model.AnalysisJob

object AnalysisJobEntityMapper {

    fun toModel(entity: AnalysisJobEntity): AnalysisJob.Data {
        return AnalysisJob.Data(
            id = entity.id!!,
            jobId = entity.jobId,
            fileName = entity.fileName,
            fileSize = entity.fileSize,
            status = entity.status,
            result = entity.result,
            description = entity.description,
        )
    }

    fun toEntity(createModel: AnalysisJob.Create): AnalysisJobEntity {
        return AnalysisJobEntity(
            jobId = createModel.jobId,
            fileName = createModel.fileName,
            fileSize = createModel.fileSize,
            status = createModel.status,
            result = createModel.result,
            description = createModel.description,
        )
    }

}

 

 

7. persistencePort, persistenceAdapter 생성

  • PersistencePort
package com.woopi.safehome.domain.deed.application.port.outbound

import com.woopi.safehome.domain.deed.model.AnalysisJob

interface AnalysisJobPersistencePort {

    /**
     * 분석 작업 저장
     */
    fun save(analysisJobCreate: AnalysisJob.Create): AnalysisJob.Data

}

 

  • PersistenceAdapter
package com.woopi.safehome.domain.deed.adapter.outbound.persistence

import com.woopi.safehome.domain.deed.adapter.outbound.persistence.jpa.AnalysisJobRepository
import com.woopi.safehome.domain.deed.application.port.outbound.AnalysisJobPersistencePort
import com.woopi.safehome.domain.deed.model.AnalysisJob
import org.springframework.stereotype.Component

@Component
class AnalysisJobPersistenceAdapter(
    private val analysisJobRepository: AnalysisJobRepository
) : AnalysisJobPersistencePort {

    override fun save(analysisJobCreate: AnalysisJob.Create): AnalysisJob.Data {
        return analysisJobRepository.save(
            AnalysisJobEntityMapper.toEntity(analysisJobCreate)
        ).let { AnalysisJobEntityMapper.toModel(it) }
    }

}

 

 

8. UseCase, UseCaseImple 생성

package com.woopi.safehome.domain.deed.application.port.inbound

import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedRequest
import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedResponse

interface DeedUseCase {

    /**
     * 분석 작업
     */
    fun analyzeDeed(request: DeedRequest.Analyze): DeedResponse

}

 

package com.woopi.safehome.domain.deed.application.usecase

import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedDtoMapper
import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedRequest
import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedResponse
import com.woopi.safehome.domain.deed.application.port.inbound.DeedUseCase
import com.woopi.safehome.domain.deed.application.port.outbound.AnalysisJobPersistencePort
import com.woopi.safehome.domain.deed.model.AnalysisJob
import com.woopi.safehome.global.enums.AnalysisJobStatus
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID

@Transactional(readOnly = true)
@Service
class DeedUseCaseImpl (
    private val analysisJobPersistencePort: AnalysisJobPersistencePort
): DeedUseCase {

    private val logger = LoggerFactory.getLogger(javaClass)

    override fun analyzeDeed(request: DeedRequest.Analyze): DeedResponse {

        val file = request.file

        logger.info("📤 분석 요청 - fileName: ${file.originalFilename}, size: ${file.size}")

        // 파일 검증
        require(!file.isEmpty) { "파일이 비어있습니다" }
        require(file.contentType == "application/pdf") { "PDF 파일만 가능합니다" }
        require(file.size <= 50 * 1024 * 1024) { "파일은 50MB 이하여야 합니다" }

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

        val job = AnalysisJob.Create(
            jobId = jobId,
            fileName = file.originalFilename ?: "unknown.pdf",
            fileSize = file.size,
            status = AnalysisJobStatus.PENDING,
        )

        val savedJob = analysisJobPersistencePort.save(job)

        logger.info("✅ 분석 완료 - jobId: $jobId")

        return DeedDtoMapper.toResponse(savedJob)
    }

}

 

9. Dto Mapper생성

package com.woopi.safehome.domain.deed.adapter.inbound.web.dto

import com.woopi.safehome.domain.deed.model.AnalysisJob

object DeedDtoMapper {

    fun toResponse(model: AnalysisJob.Data): DeedResponse {
        return DeedResponse(
            id = 0L,
            jobId = model.jobId,
            description = model.description,
        )
    }

}

 

10. WebAdapter 생성

package com.woopi.safehome.domain.deed.adapter.inbound.web

import com.woopi.safehome.domain._sample.adapter.inbound.web.dto.SampleRequest
import com.woopi.safehome.domain._sample.adapter.inbound.web.dto.SampleResponse
import com.woopi.safehome.domain._sample.application.port.inbound.SampleUseCase
import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedRequest
import com.woopi.safehome.domain.deed.adapter.inbound.web.dto.DeedResponse
import com.woopi.safehome.domain.deed.application.port.inbound.DeedUseCase
import com.woopi.safehome.global.response.ApiResponse
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@Tag(name = "등기부등본 API", description = "등기부등본 API")
@RestController
@RequestMapping("/api/deed")
class DeedInboundWebAdapter (
    private val deedUseCase: DeedUseCase
) {

    @Operation(summary = "등기부등본 분석", description = "등기부등본 분석")
    @PostMapping
    fun analyzeDeed (request: DeedRequest.Analyze): ApiResponse<DeedResponse> {
        return ApiResponse.success(deedUseCase.analyzeDeed(request))
    }

}