G
Gyudy(정규원)Author

2025년 12월 16일

데이터 소유권 기반 인가 제어

안녕하세요. 볼트업에서 백엔드 개발을 맡고 있는 Gyudy입니다 😊

올해 4월에 볼트업에 입사하여 8개월 동안 꾸준하게 BMS 프로젝트를 진행하면서 백엔드 개발자로서, 신입 사원으로서 많이 배우며 성장하고 있습니다.
이번 포스트에서는 프로젝트 진행 과정에서 겪었던 데이터 인가에 대한 문제점들과 이를 해결하기 위해 했던 고민들을 소개해드리겠습니다.


1. 문제 상황: "내가 만든 데이터만 수정/조회해야 한다"

BMS는 사내 업무 툴이지만, 볼트업과 계약하는 여러 다른 회사들도 사용하는 서비스입니다. 이 때문에 사용자별로 접근 가능한 메뉴에 대한 권한 제어를 Role 기반으로 수행하고 있습니다. 하지만 Role 기반 권한 제어는 "A라는 사용자가 수정 기능을 실행할 수 있는가?"를 체크하는 데는 유용했지만, "A라는 사용자가 B 데이터를 수정할 자격이 있는가?" 를 판단하기에는 부족했습니다.

예를 들어, 데이터 수정 권한을 가진 A 회사의 직원이 B 회사 직원이 생성한 현장실사 데이터를 수정하려고 시도한다면, 시스템은 AccessDenied 예외를 반환해야 합니다. 하지만 기존 서비스 로직은 데이터 소유권 체크 없이 권한 체크만 수행했기 때문에, 권한을 가진 토큰을 가지고 있으면 다른 데이터의 key 값을 알아내 악의적인 수정하는 타사 데이터 침범이 발생할 위험이 있었습니다.

특히, BMS에서는 도메인에 따라서 데이터 소유권의 조건이 달랐기 때문에 조금 더 복잡한 인가 로직이 필요하였습니다.

  • - 현장실사: 자신이 속한 회사의 조직원이 생성한 데이터면 조회 및 수정이 가능해야 합니다.
  • - 거래처: 같은 회사일지라도 자신이 생성한 데이터만 조회 및 수정이 가능해야 합니다.

이러한 부적절한 인가 취약점을 해결하기 위해 몇가지 접근 방식을 고민했습니다.


2. 해결 방안에 대한 고민의 과정

시도 1: 모든 서비스 메서드에 check() 로직 넣기

가장 직관적인 방법은 데이터 소유권 검증이 필요한 모든 메서드 상단에 검증 로직을 넣는 것입니다.

kotlin
fun updateCompany(id: Long, user: UserPrincipal) {
    val company = companySearchService.searchById(id)

    // 검증 로직 직접 주입
    if (company.createdBy != user.id) throw AccessDeniedException()

    // 비즈니스 로직
    ...
}

하지만 이 방식은 치명적인 단점이 있었습니다.

  • 1. 중복 코드: 수십 개의 서비스 메서드마다 똑같은 if 문을 복사-붙여넣기 해야 합니다.
  • 2. 가독성 저하: 핵심 비즈니스 로직과 인 로직이 섞여 코드가 지저분해집니다.


시도 2: 공통 검증 로직 사용하기

중복 코드가 발생하는 문제를 해결하기 위해서 도메인 객체를 조회한 이후에 enum 값을 통해 검증 타입을 명시하여 검증 타입에 맞는 공통의 검증 로직을 사용하고자 하였습니다.

kotlin
fun updateCompany(id: Long, user: UserPrincipal) {
    val company = companySearchService.searchById(id)

    // 공통으로 사용하는 검증 로직 호출
    authenticateValidator.validateByUser(company.createdBy, user.id)

    // 비즈니스 로직
    ...
}

이 방식으로 중복 코드 문제는 어느 정도 해결되었지만, 비즈니스 로직과 보안 로직이 섞여서 Orchestrator 계층이 복잡해지는 문제가 여전히 존재했습니다.


최종 결정: AOP + 전략 패턴

"기존 비즈니스 로직을 건드리지 않으면서", 앞단에 권한 체크 로직을 추가하는 것이 목표였기 때문에 최종적으로 AOP와 전략 패턴을 활용하여 문제를 해결하였습니다.

흐름: Request -> Aspect(ID 추출 -> DB 조회 -> 검증) -> Orchestrator
흐름도


3. 구현 내용

Step 1. 핵심 인터페이스 정의

먼저, 소유권을 가진 모든 도메인 모델이 공통으로 구현해야 할 인터페이스를 정의했습니다. 검증에 해당하는 ID 비교 로직은 동일하게 사용할 수 있지만, 도메인별로 어떤 ID를 검증에 사용해야 하는지가 달랐기 때문에 공통 인터페이스를 정의했습니다.

kotlin
// 소유권 식별자를 반환하는 인터페이스
interface ResourceOwnership {
    fun getOwnershipId(): Long
}

이를 권한 검증이 필요한 도메인 구현체에서 구현하도록 하여, 각 도메인 특성에 맞는 검증용 ID를 반환하도록 했습니다.

[ConstructionInspection.kt] - 회사 ID 반환

kotlin
data class ConstructionInspection(
    val id: Long,
    val createdCompanyId: Long, // 생성한 회사 ID
    // ...
) : ResourceOwnership {
    override fun getOwnershipId(): Long = createdCompanyId
}

[Company.kt] - 생성자 ID 반환

kotlin
data class Company(
    val id: Long,
    val createdBy: Long, // 생성한 유저 ID
    // ...
) : ResourceOwnership {
    override fun getOwnershipId(): Long = createdBy
}


Step 2. 전략 패턴으로 검증 로직 추상화

검증 기준이 "사용자 본인"이냐 "소속 회사"냐에 따라 다르므로, 이를 Enum 전략 패턴으로 분리했습니다.

kotlin
enum class DataPermissionCheckType {
    USER {
        override fun validate(resource: ResourceOwnership, user: UserInfo) {
            if (resource.getOwnershipId() != user.userId) throw AccessDeniedException()
        }
    },
    COMPANY {
        override fun validate(resource: ResourceOwnership, user: UserInfo) {
            if (resource.getOwnershipId() != user.companyId) throw AccessDeniedException()
        }
    };

    abstract fun validate(resource: ResourceOwnership, user: UserInfo)
}

별도의 클래스 형태의 Validator 인터페이스와 구현체를 만들어서 로직으로 분리하는 방법도 고민하였지만, 검증 로직이 간단했기 때문에 Enum을 활용하여 깔끔하게 구현했습니다.


Step 3. 도메인 조회를 위한 DomainFinder 인터페이스 정의

Aspect에서 검증할 객체를 조회하기 위해, 기존 SearchService들이 구현할 공통 인터페이스를 정의합니다.

kotlin
// 리소스를 ID로 조회할 수 있는 '검색기' 인터페이스
interface DomainFinder<T : ResourceOwnership> {
    fun searchById(id: Long): T
}

기존의 ConstructionInspectionSearchService는 이 인터페이스를 구현하기만 하면 됩니다.

kotlin
@Service
class ConstructionInspectionSearchService(
    private val repository: ConstructionInspectionRepository,
) : DomainFinder<ConstructionInspection> {

    // DomainFinder 구현
    override fun searchById(id: Long): ConstructionInspection =
        repository.findById(id) ?: throw NotFoundException()
}


Step 4. Annotation & Aspect 구현

이제 개발자가 "이 메서드는 검사가 필요해!"라고 깃발을 꽂을 수 있는 애너테이션과, 이를 가로채서 처리할 Aspect를 만듭니다.

kotlin
// Annotation
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckDataPermission(
    val finder: KClass<out DomainFinder<*>>, // 누가 조회할 것인가?
    val type: DataPermissionCheckType,       // 어떤 기준으로 검사할 것인가?
)

// Aspect
@Aspect
@Component
class DataPermissionAspect(private val applicationContext: ApplicationContext) {

    @Around("@annotation(annotation)")
    fun checkPermission(joinPoint: ProceedingJoinPoint, annotation: CheckDataPermission): Any? {
        // 1. 사용자 정보 획득
        val user = SecurityContextHolder.getContext().authentication.principal as UserInfo

        // 2. ID 파라미터 추출 (@PermissionId가 붙은 Long 타입 찾기)
        val id = findPermissionId(joinPoint)

        // 3. Finder Bean으로 리소스 조회
        val finder = applicationContext.getBean(annotation.finder.java)
        val resource = finder.searchById(id)

        // 4. 검증 수행
        annotation.type.validate(resource, user)

        return joinPoint.proceed()
    }
}


4. 적용 결과

이를 통해 Orchestrator 코드는 인가 제어 로직과 분리할 수 있었습니다. 아래와 같이 소유권 인가 제어를 적용할 Orchestrator 메서드에 @CheckDataPermission 애너테이션을 달아주고, 검사 대상 도메인의 ID 필드에 @PermissionId 애너테이션을 달아주면 공통으로 적용할 수 있습니다.

[ConstructionInspectionOrchestrator.kt] - 회사 소유권 확인

kotlin
@CheckDataPermission( 
    finder = ConstructionInspectionSearchService::class, // 이 서비스로 조회해서 
    type = DataPermissionCheckType.COMPANY,             // 회사 ID가 같은지 체크하라 
)
fun approve( @PermissionId inspectionId: Long, // 검사 대상 ID ) {
    // 순수 비즈니스 로직
    constructionInspectionUpdateService.approve(inspectionId)
}

[CompanyOrchestrator.kt] - 유저 소유권 확인

kotlin
@CheckDataPermission( 
    finder = CompanySearchService::class, // 이 서비스로 조회해서 
    type = DataPermissionCheckType.USER,  // 유저 ID가 같은지 체크하라 
)
fun approve( @PermissionId companyId: Long, // 검사 대상 ID ) {
    // 순수 비즈니스 로직
    companyUpdateService.approve(inspectionId)
}


5. 회고

코드를 구현하면서 현재 방식의 단점도 몇 가지 보였습니다.

AOP 적용을 위해 Aspect에서 applicationContext.getBean을 통해 빈을 조회하고 리플렉션을 사용하는 과정에서 발생하는 복잡도와 비용이 조금 마음에 걸렸습니다. 하지만 리플렉션에 드는 비용은 실제 DB IO 비용에 비하면 매우 적기 때문에, 비즈니스 로직의 가독성과 유지보수성을 챙기는 것이 훨씬 유의미한 결정이라고 판단했습니다.


6. 마치며

볼트업은 충전 사업자로서도 빠르게 성장하고 있지만, 서비스 전체에는 유저 앱과 충전기뿐만 아니라 충전기 관리 시스템, 공사업체 툴, 비즈니스 관리 시스템 등 많은 툴들이 함께 돌아가며 더 좋은 서비스를 제공하기 위해 노력하고 있습니다.

이 과정에서 안전한 서비스를 제공하기 위해 성능, 보안 등 다양한 요소를 고려하며 개발자도, 시스템도, 회사도 모두 발전하고 있습니다. 앞으로도 저희는 서비스의 성장에 발맞춰 더 도전적이고 흥미로운 기술 문제들을 해결해 나갈 예정이니 많은 관심을 부탁드립니다. 긴 글 읽어주셔서 감사합니다. 🙇🏻‍♂️

G

Gyudy(정규원)

Tech Innovation Tribe
이 글 공유하기

지금 바로 볼트업에 지원해 보세요

볼트업 채용공고 바로가기