티스토리 뷰

반응형

스프링 시큐리티 완전 처음부터 16편 (Kotlin)

실무 케이스 스터디 — “이 보안 구조, 실제 서비스에 적용하면 이렇게 흘러간다”


15편에서 우리는 태도 이야기를 했다.
16편은 다시 현실로 내려온다.

이번 글은 가상의 예제가 아니다.
실제 서비스에서 정말 자주 나오는 요구사항을 하나 잡고,
지금까지 만든 시큐리티 구조가 어떻게 자연스럽게 녹아드는지를 보여주는 글이다.

“아, 그래서 이 구조였구나”
이 감각을 남기는 게 목표다.


0️⃣ 가정하는 서비스 시나리오

🎯 서비스 설정

  • B2B SaaS (회사별 계약 관리 서비스)
  • 고객사(테넌트) 여러 개
  • 사용자 역할
    • ROLE_USER : 계약 조회
    • ROLE_ADMIN : 계약 생성/삭제
  • 프론트엔드
    • React SPA
  • 인증 방식
    • 자체 로그인 + JWT
  • 멀티 테넌트 필수

👉 딱, 우리가 지금까지 다룬 조건이다.


1️⃣ 요구사항을 먼저 문장으로 써보자 (이게 제일 중요)

실무에서는 항상 요구사항 문장화부터 한다.

  1. 사용자는 로그인해야만 계약을 볼 수 있다
  2. 사용자는 자기 회사 계약만 볼 수 있다
  3. 관리자는 자기 회사 계약을 생성/삭제할 수 있다
  4. 다른 회사 계약은 어떤 경우에도 보이면 안 된다

이 문장이
그대로 코드 구조가 된다.


2️⃣ 인증은 어디서 끝내는가? (다시 한 번)

✔ 선택

  • 로그인 시점에만 인증
  • 이후 요청은 JWT 검증
POST /api/login
→ JWT 발급

👉 이후 모든 요청은

Authorization: Bearer {JWT}

이 한 줄로 끝난다.


3️⃣ JWT Payload 설계 (케이스 스터디 버전)

이 서비스에서 반드시 필요한 정보만 넣는다.

{
  "sub": "alice",
  "role": "ADMIN",
  "tenantId": 42,
  "exp": 1710000000
}
  • username → 누군지
  • role → 뭘 할 수 있는지
  • tenantId → 어느 회사인지

👉 이 3개가 보안의 기준축이다.


4️⃣ SecurityConfig — 요구사항을 그대로 옮기면 이렇게 된다

반응형
.authorizeHttpRequests {
    it.requestMatchers("/api/login").permitAll()
    it.requestMatchers(HttpMethod.GET, "/contracts/**")
        .hasAnyRole("USER", "ADMIN")
    it.requestMatchers(HttpMethod.POST, "/contracts/**")
        .hasRole("ADMIN")
    it.anyRequest().authenticated()
}

요구사항 문장과 1:1 매칭된다.

  • “로그인 필요” → authenticated()
  • “조회는 USER 이상” → hasAnyRole
  • “생성/삭제는 ADMIN” → hasRole("ADMIN")

👉 시큐리티 설정은 번역 작업이다.


5️⃣ 서비스 레벨 — 진짜 보안은 여기서 완성된다

컨트롤러는 얇게 두고,
사고가 나면 안 되는 지점은 Service에서 막는다.

📄 ContractService.kt

@Service
class ContractService(
    private val contractRepository: ContractRepository
) {

    fun findMyContracts(): List<Contract> {
        val tenantId = currentTenantId()
        return contractRepository.findByTenantId(tenantId)
    }

    @PreAuthorize("hasRole('ADMIN')")
    fun createContract(request: CreateContractRequest) {
        val tenantId = currentTenantId()
        contractRepository.save(
            Contract(
                tenantId = tenantId,
                name = request.name
            )
        )
    }
}

여기서 중요한 포인트는 딱 두 개다.

  1. tenantId는 무조건 SecurityContext에서 꺼낸다
  2. 권한은 Service에서 한 번 더 막는다

👉 이 두 줄이
“실수해도 사고 안 나는 구조”를 만든다.


6️⃣ 사고 시나리오를 가정해보면 구조의 가치가 보인다

❌ 컨트롤러에서 tenantId를 파라미터로 받는 경우

GET /contracts?tenantId=99

👉 사고 확정


⭕ SecurityContext에서 tenantId를 꺼내는 경우

val tenantId = currentTenantId()
  • 요청 파라미터 무시
  • JWT 기준으로만 판단

👉 사고 구조적으로 불가능


7️⃣ 이 구조가 팀에 주는 진짜 이점

✔ 신규 개발자 투입

  • “tenantId 어디서 나와요?”
  • → “SecurityContext에서만 가져와요”

✔ 코드 리뷰

  • findAll() 보이면 바로 경고
  • tenant 조건 없는 쿼리는 자동으로 의심 대상

✔ 요구사항 변경

“관리자는 다른 회사 계약도 볼 수 있게 해주세요”

→ tenantId 기준만 바꾸면 된다
→ 구조 전체 수정 ❌


8️⃣ 이 케이스를 면접에서 말한다면 이렇게 말해라

“B2B SaaS라서
멀티 테넌트 보안이 핵심이었습니다.

인증 단계에서 tenant 정보를 JWT에 포함시키고,
모든 데이터 접근은
SecurityContext의 tenantId를 기준으로 제한했습니다.

그래서 실수로 다른 회사 데이터를 조회하는 경로 자체를
구조적으로 차단했습니다.”

👉 이 설명은 실제 경험처럼 들린다.


9️⃣ 이 편에서 꼭 가져가야 할 메시지

  • 보안 구조는 요구사항을 그대로 코드로 옮기는 작업
  • JWT / ROLE / tenant 는 도구
  • 진짜 핵심은
    👉 “어디서 판단하고, 어디서 막을 것인가”

 

스프링시큐리티, springsecurity, 코틀린, kotlin, jwt, 멀티테넌트, 보안설계, 백엔드개발, 실무케이스, 개발블로그

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함
반응형