PAHIDC — 클라우드 운영 시스템
서버와 네트워크 서비스를 신청하고, 자원·청구·구성원을 워크스페이스별로 관리할 수 있는 클라우드 운영 플랫폼입니다.
프로젝트 소개
PAHIDC는 서버와 네트워크 서비스를 신청하고, 이용 중인 자원과 청구 내역을 관리할 수 있는 클라우드·데이터센터 운영 플랫폼입니다. 클라우드 서버와 관리형 데이터베이스부터 서버 임대, 코로케이션, 네트워크 서비스까지 다룹니다.
고객은 포털에서 서비스 상태와 청구서를 확인하고, 함께 일하는 구성원을 추가하거나 역할을 배정할 수 있습니다. 운영자는 관리자 콘솔에서 고객사별 자원과 계약, 청구, 지원 요청을 처리합니다.
서비스와 구성원은 워크스페이스 단위로 관리합니다. 하나의 계정으로 여러 워크스페이스에 참여할 수 있고, 같은 사람도 소속마다 다른 역할을 맡을 수 있습니다. 이 프로젝트에서는 워크스페이스와 권한을 처리하는 백엔드 개발을 담당했습니다.

관리자 콘솔의 워크스페이스 목록입니다. 화면은 로컬 Docker 환경에서 테스트 계정과 시연 데이터로 촬영했습니다.
시스템 구성
고객 포털과 관리자 콘솔은 Next.js로 구성되어 있습니다. 두 화면 모두 Spring Boot API를 통해 데이터를 조회하거나 변경하고, 데이터는 PostgreSQL에 저장합니다.
| 영역 | 기술 |
|---|---|
| 프론트엔드 | Next.js 15, React 19, TypeScript, Tailwind CSS 4 |
| 백엔드 | Kotlin 1.9, Java 17, Spring Boot 3.2 |
| 인증·권한 | Spring Security, JWT, 워크스페이스별 역할 |
| 데이터 | PostgreSQL, Spring Data JPA, Flyway |
| 테스트 | JUnit 5, MockK, AssertJ |
| 배포 | Docker, Jenkins, Kubernetes, ArgoCD |
고객 포털 / 관리자 콘솔
|
Spring Boot API
/ \
자원·계정·권한 청구·결제
\ /
PostgreSQL
백엔드는 하나의 Spring Boot 애플리케이션으로 실행됩니다. com.pahidc.api에는 인증, 워크스페이스, 권한, 서비스 관리와 고객 지원 기능이 있고, 청구·결제 코드는 com.pahidc.billing에 모여 있습니다.
청구 모듈 내부는 presentation, application, domain, infrastructure로 나뉩니다. 도메인에서 청구서와 결제 상태를 관리하고, 유스케이스가 저장소와 결제 게이트웨이를 호출하는 구조입니다.
워크스페이스 데이터 모델
워크스페이스마다 구성원과 역할이 다르기 때문에, 로그인 계정과 소속, 역할 배정을 별도로 저장합니다. 예를 들어 같은 사용자가 A 회사에서는 서버를 관리하고, B 회사에서는 조회만 할 수 있습니다.
| 테이블 | 저장하는 정보 |
|---|---|
users | 로그인 계정 |
tenants | 워크스페이스 |
tenant_users | 사용자 소속, 소유자 여부, 가입·탈퇴 시각 |
roles | 워크스페이스에 정의된 역할 |
user_roles | 사용자·역할·워크스페이스의 배정 관계 |
role_permissions | 역할별 작업 권한 |
user_permission_overrides | 사용자별 권한 예외 |
tenant_default_permissions | 워크스페이스 기본 권한 |
현재 소속은 tenant_users에서 left_at IS NULL인 행으로 확인합니다. 한 워크스페이스 안에서 여러 역할을 받을 수도 있습니다. 권한을 조회할 때는 다른 소속의 역할이 섞이지 않도록 사용자 ID와 워크스페이스 ID를 함께 전달합니다.
userRoleMappingRepository
.findByUserIdAndTenantId(userId, tenantId)
저장 단계에서도 워크스페이스가 일치하는지 확인합니다. user_roles의 (role_id, tenant_id)가 roles의 (id, tenant_id)를 참조하도록 복합 외래키를 두어, 다른 워크스페이스의 역할은 배정할 수 없게 했습니다. 조회에는 (user_id, tenant_id) 복합 인덱스를 사용합니다.
FOREIGN KEY (role_id, tenant_id)
REFERENCES roles (id, tenant_id)
ON DELETE CASCADE
특정 구성원에게만 적용할 권한은 개인 예외로 저장합니다. service_id가 있으면 해당 서비스에만, NULL이면 워크스페이스 전체에 적용됩니다. 전역 예외가 중복 저장되지 않도록 NULL 여부에 따라 유니크 인덱스를 나눠 두었습니다.
-- 워크스페이스 전역 예외
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_override_global
ON user_permission_overrides
(user_id, tenant_id, permission)
WHERE service_id IS NULL;
-- 서비스별 예외
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_override_service
ON user_permission_overrides
(user_id, tenant_id, permission, service_id)
WHERE service_id IS NOT NULL;
관련 스키마는 V41__workspace_scoped_roles.sql에서 확인할 수 있습니다. 기존 역할 배정에 워크스페이스를 채우고, 소속 테이블에 있던 역할을 옮긴 뒤, 유효한 소속이 없는 배정을 정리하는 과정도 포함되어 있습니다.
인증과 요청의 워크스페이스
각 요청의 워크스페이스는 JWT에 담긴 tenantId로 구분합니다. JwtAuthFilter가 사용자의 현재 소속을 확인한 뒤, 요청의 인증 객체인 CustomUserDetails에 currentTenantId를 전달합니다.
JWT의 tenantId
-> 유효 소속 조회
-> CustomUserDetails.currentTenantId
-> 컨트롤러와 권한 서비스
사용자 테이블에도 tenant_id가 있지만, 이 값은 마지막으로 선택한 워크스페이스를 기억하기 위한 값입니다. 다음 로그인에는 기본값으로 사용하고, 현재 요청을 처리할 때는 인증 컨텍스트를 읽습니다. 그래서 다른 세션에서 워크스페이스를 바꿔도 이미 발급된 토큰의 작업 대상은 그대로 유지됩니다.
워크스페이스를 전환하면 대상 소속과 워크스페이스 상태를 확인하고 새 JWT를 발급합니다. 역할을 다시 배정할 필요는 없습니다. 전환한 곳에 저장된 역할을 조회해 그 워크스페이스의 권한을 적용합니다.
탈퇴 후 남아 있는 토큰
워크스페이스에서 나가더라도 이전 토큰은 만료 전까지 남아 있을 수 있습니다. 소속 확인에 실패하면 인증 필터가 currentTenantId를 null로 처리해, 나간 워크스페이스의 권한을 사용할 수 없도록 합니다.
계정 인증까지 함께 끊지는 않습니다. 다른 워크스페이스에 속해 있는 사용자는 소속 목록을 열고, 계속 이용할 공간으로 전환할 수 있습니다.
직원 계정은 고객 소속과 별도로 처리합니다. PermissionService에서 SystemRole.ADMIN을 먼저 확인하므로, 워크스페이스에 속하지 않은 직원도 관리자 업무를 수행할 수 있습니다.
권한 계산
권한은 허용(ALLOW), 거부(DENY), 상속(INHERIT)으로 구분합니다. 사용자의 소속을 확인한 뒤 아래 순서로 설정을 읽습니다.
- 개인 예외: 서비스별 설정을 먼저 보고, 없거나
INHERIT이면 워크스페이스 전역 설정을 확인합니다. - 역할 권한: 현재 워크스페이스에서 받은 역할들을 합산합니다.
- 워크스페이스 기본 권한: 앞 단계에서 결과가 정해지지 않았을 때 적용합니다.
- 기본 거부: 적용할 설정이 없으면
DENY를 반환합니다.
역할 하나 안에서는 서비스별 설정을 전역 설정보다 먼저 적용합니다. 여러 역할을 합칠 때는 하나라도 ALLOW가 있으면 허용합니다. 허용 없이 DENY만 있으면 거부하고, 어느 역할에서도 결정되지 않았을 때만 워크스페이스 기본값으로 넘어갑니다.
| 설정 | 결과 |
|---|---|
운영자 ALLOW + 조회자 DENY | 허용 |
조회자 DENY만 존재 | 거부 |
개인 DENY + 역할 ALLOW | 거부 |
| 개인 예외·역할에서 미결정 | 워크스페이스 기본값 적용 |
운영자와 조회자를 함께 배정받아도 운영자가 허용한 작업은 그대로 할 수 있습니다. 다만 특정 사용자를 제한하는 개인 예외는 역할보다 먼저 적용됩니다. 역할의 표시 순서인 position을 바꿔도 허용·거부 결과는 달라지지 않습니다.
유효 권한 응답
화면에 표시되는 권한과 실제 API의 판단이 같도록, hasPermission()과 getUserEffectivePermissions()는 같은 evaluate() 함수를 사용합니다. 응답에는 권한 코드와 허용·거부 결과뿐 아니라, 어떤 설정에서 나온 결과인지도 함께 담습니다.
data class EffectivePermission(
val permission: String,
val value: PermissionValue,
val source: PermissionSource
)
source를 보면 개인 예외, 역할, 워크스페이스 기본값 중 무엇이 적용됐는지 알 수 있습니다. 역할도 없고 적용할 설정도 없으면 NO_ROLE을 반환합니다. 역할이 없더라도 명시적인 기본 권한이 있다면 그 설정을 먼저 적용합니다.
권한의 이름과 분류는 공통 카탈로그에서 가져옵니다. 프론트엔드는 코드로 작업 가능 여부를 확인하고, 고객에게는 업무명과 판정 출처를 보여줍니다.

구성원 상세의 역할 배정과 유효 권한.
워크스페이스 API
워크스페이스 API의 기본 경로는 /api/workspaces입니다.
| 메서드·경로 | 동작 |
|---|---|
GET / | 유효 소속 중 활성·미삭제 워크스페이스 조회 |
POST / | 워크스페이스 생성 후 JWT 반환 |
POST /{id}/switch | 대상 워크스페이스로 전환 후 JWT 반환 |
DELETE /{id}/membership | 해당 워크스페이스에서 나가기 |
새 워크스페이스를 만들 때는 계정을 다시 만들지 않고 기존 계정에 소속을 추가합니다. 워크스페이스 생성, 소유자 소속 등록, 기본 역할 배정은 하나의 트랜잭션에서 처리합니다.
워크스페이스에서 나갈 때는 allowSelfLeave 설정과 마지막 소유자 여부를 확인합니다. 나갈 수 있는 상태라면 탈퇴 시각을 기록하고, 해당 워크스페이스의 역할 배정도 삭제합니다. 마지막 선택값이 탈퇴한 곳을 가리키면 남은 소속으로 갱신하며, 이 변경들도 같은 트랜잭션에서 처리합니다.
생성·전환·나가기는 API 키가 아닌, 사용자가 직접 로그인한 세션에서만 호출할 수 있습니다. 특히 생성과 전환은 새 로그인 JWT를 반환하는 경로여서, 제한된 API 키로 접근하지 못하도록 구분합니다. 컨트롤러의 requireHumanSession()에서 인증 객체가 ApiKeyAuthentication인지 확인합니다.
청구와 실시간 갱신
청구·결제 모듈
청구서 결제는 PayInvoiceUseCase에서 처리합니다. 먼저 청구서를 행 잠금으로 조회합니다. 고객의 결제 요청이라면 해당 워크스페이스의 청구서인지 확인한 뒤, 결제 가능한 상태인지 검사합니다.
크레딧을 사용할 때는 지갑도 잠금 조회한 뒤 차감합니다. 남은 결제 금액은 등록된 결제수단과 PaymentGateway를 통해 처리합니다. 결제가 성공하면 청구서와 결제 기록을 저장하고 InvoicePaid 이벤트를 발행합니다.
화면 갱신
티켓이나 인스턴스, 알림이 변경되면 LiveEventService가 SSE로 브라우저에 알려줍니다. 다른 백엔드 파드에 연결된 사용자에게도 전달할 수 있도록, 파드 사이에서는 PostgreSQL LISTEN/NOTIFY를 사용합니다.
화면이 변경 전 데이터를 다시 읽지 않도록 이벤트는 트랜잭션 커밋 이후에 보냅니다. 같은 트랜잭션에서 발생한 동일 이벤트는 한 번으로 합칩니다. 브라우저가 변경 주제인 topic을 받으면 관련 데이터를 다시 조회하며, 전달 대상은 관리자 여부와 사용자·워크스페이스 ID로 구분합니다.
배포 구성
Jenkins에서 프론트엔드와 백엔드의 Docker 이미지를 빌드해 레지스트리에 올립니다. 이후 배포 저장소의 Kubernetes 매니페스트에 새 이미지 태그를 반영하고 ArgoCD 동기화를 호출합니다.
Git 변경
-> Jenkins
-> Docker 이미지 빌드·등록
-> 배포 매니페스트 갱신
-> ArgoCD
-> Kubernetes
main은 운영 환경, dev는 개발 환경에 연결됩니다. 프론트엔드의 API 주소가 빌드 시점에 포함되므로, 커밋이 같아도 개발용과 운영용 이미지는 서로 다릅니다. 이미지 태그에 커밋 SHA와 환경을 함께 넣어 두 이미지를 구분합니다.
테스트와 실행 화면
역할 조합에 따른 권한 계산은 PermissionServiceRoleUnionTest에서 다룹니다. 다섯 단위 테스트는 역할을 합쳤을 때의 허용 여부, 배정 순서에 따른 결과, 조회자 단독 거부, 판정 출처, 개인 예외의 우선순위를 확인합니다. 저장소는 MockK로 대체하고 실제 권한 서비스를 호출합니다.
화면은 2026년 9월 11일 로컬 Docker 환경에서 촬영했습니다. 테스트 계정으로 관리자 로그인과 조회 API, 고객의 워크스페이스 생성·전환, 역할·유효 권한 화면을 확인한 기록을 함께 남겼습니다.