2024. 3. 3. 10:32ㆍSpring Security
이 장에서는 다음을 다룹니다.
- 메소드가 매개변수 값으로 받는 것을 제한하기 위해 사전 필터링 사용하기
- 메소드가 반환하는 것을 제한하기 위해 사후 필터링 사용하기
- Spring Data와 필터링 통합하기
11장에서는 전역 메소드 보안을 사용하여 권한 부여 규칙을 적용하는 방법을 배웠습니다.
@PreAuthorize와 @PostAuthorize 어노테이션을 사용하는 예제를 다뤘습니다.
이 어노테이션들을 사용함으로써, 애플리케이션이 메소드 호출을 허용하거나 완전히 거부하는 접근 방식을 적용합니다. 메소드 호출을 금지하고 싶지 않지만, 메소드에 전달된 파라미터가 일정 규칙을 따르는지 확인하고 싶다고 가정해봅시다.
또는, 다른 시나리오에서, 누군가가 메소드를 호출한 후에, 메소드의 호출자가 반환된 값의 권한이 있는 부분만을 받게 하고 싶습니다. 우리는 이러한 기능을 필터링이라고 부르며, 두 가지 범주로 분류합니다:
- Prefiltering — 프레임워크가 메소드 호출 전에 파라미의 값을 필터링합니다.
- Postfiltering — 프레임워크가 메소드 호출 후 반환된 값을 필터링합니다.
필터링은 호출 권한 부여와 다르게 작동합니다(그림 12.1 참조). 필터링을 사용할 때, 프레임워크는 호출을 실행하며 매개변수나 반환된 값이 정의한 권한 규칙을 따르지 않더라도 예외를 발생시키지 않습니다. 대신, 지정한 조건을 따르지 않는 요소들을 필터링해냅니다.

그림 12.1 클라이언트는 권한 부여 규칙을 따르지 않는 값을 제공하는 엔드포인트를 호출합니다. 사전 승인을 사용하면 메서드가 전혀 호출되지 않으며 호출자는 예외를 받습니다. 사전 필터링을 사용하면 aspect가 메서드를 호출하지만 주어진 규칙을 따르는 값만 제공합니다.
시작부터 언급하는 것이 중요한데, 필터링은 컬렉션과 배열에만 적용할 수 있습니다. 메소드가 파라미터로 배열이나 객체의 컬렉션을 받는 경우에만 사전 필터링을 사용합니다. 프레임워크는 정의한 규칙에 따라 이 컬렉션이나 배열을 필터링합니다. 사후 필터링에 대해서도 마찬가지입니다: 이 접근 방식은 메소드가 컬렉션 또는 배열을 반환할 때만 적용할 수 있습니다. 프레임워크는 지정한 규칙을 기반으로 메소드가 반환하는 값을 필터링합니다.
12.1 Applying prefiltering for method authorization
이 섹션에서는 사전 필터링 뒤에 있는 메커니즘을 논의한 다음, 예제에서 사전 필터링을 구현합니다. 필터링을 사용하여 프레임워크에 누군가 메소드를 호출할 때 메소드 파라미터를 통해 전송된 값들을 검증하도록 지시할 수 있습니다. 프레임워크는 주어진 기준과 일치하지 않는 값을 필터링하고, 기준과 일치하는 값들로만 메소드를 호출합니다. 이 기능을 사전 필터링이라고 합니다(그림 12.2 참조).

그림 12.2 사전 필터링을 사용하면, aspect가 보호된 메소드로의 호출을 가로챕니다. aspect는 호출자가 파라미터로 제공하는 값을 필터링하고, 정의한 규칙을 따르는 값들만 메소드로 전송합니다.
실제 예제에서 사전 필터링이 잘 적용되는 요구사항을 찾을 수 있습니다. 왜냐하면, 이것은 메소드가 구현하는 비즈니스 로직으로부터 권한 부여 규칙을 분리하기 때문입니다. 예를 들어, 인증된 사용자가 소유한 특정 세부 사항만을 처리하는 사용 사례를 구현한다고 가정해보겠습니다. 이 사용 사례는 여러 곳에서 호출될 수 있습니다. 그러나, 책임은 누가 사용 사례를 호출하든 관계없이 인증된 사용자의 세부 사항만 처리될 수 있다고 항상 명시합니다. 사용 사례의 호출자가 권한 부여 규칙을 올바르게 적용하도록 하는 대신, 사용 사례가 자체 권한 부여 규칙을 적용하게 합니다. 물론, 이것을 메소드 내부에서 할 수도 있습니다. 하지만, 권한 부여 로직을 비즈니스 로직에서 분리하는 것은 코드의 유지보수성을 향상시키고, 다른 사람이 이해하고 읽기 쉽게 만듭니다.
11장에서 논의한 호출 권한 부여의 경우와 마찬가지로, Spring Security도 애스펙트를 사용하여 필터링을 구현합니다. 애스펙트는 특정 메소드 호출을 가로채고, 다른 지시사항을 추가할 수 있습니다. 사전 필터링의 경우, 애스펙트는 @PreFilter 어노테이션이 적용된 메소드를 가로채고, 파라미터로 제공된 컬렉션의 값을 정의한 기준에 따라 필터링합니다(그림 12.3 참조).

그림 12.3 사전 필터링을 사용함으로써, 우리는 권한 부여 책임을 비즈니스 구현으로부터 분리합니다. Spring Security에 의해 제공되는 aspect는 오직 권한 부여 규칙에만 신경 쓰고, 서비스 메소드는 그것이 구현하는 사용 사례의 비즈니스 로직에만 신경 씁니다.
11장에서 논의한 @PreAuthorize 및 @PostAuthorize 어노테이션과 유사하게, @PreFilter 어노테이션의 값으로 권한 부여 규칙을 설정합니다. SpEL 표현식으로 제공되는 이 규칙들에서, 메소드에 파라미터로 제공된 컬렉션 또는 배열 안의 어떤 엘리먼트를 참조하기 위해 filterObject를 사용합니다. 사전 필터링이 적용된 것을 보기 위해, 프로젝트에서 작업해 봅시다. 이 프로젝트의 이름은 ssia-ch12-ex1입니다. 제품을 사고파는 애플리케이션이 있고, 그 백엔드는 /sell 엔드포인트를 구현합니다. 사용자가 제품을 팔 때 애플리케이션의 프론트엔드가 이 엔드포인트를 호출합니다. 하지만, 로그인한 사용자는 자신이 소유한 제품만 팔 수 있습니다. 파라미터로 받은 제품을 팔기 위해 호출되는 서비스 메소드의 간단한 시나리오를 구현해 봅시다. 이 예제를 통해, 현재 로그인한 사용자가 소유한 제품만 메소드가 받도록 하는 것을 보장하기 위해 @PreFilter 어노테이션을 어떻게 적용하는지 배웁니다.
프로젝트를 생성하면, 구현을 테스트할 사용자 몇 명이 확실히 있도록 구성 클래스를 작성합니다. 구성 클래스의 간단한 정의는 리스트 12.1에 있습니다. ProjectConfig라고 부르는 구성 클래스는 UserDetailsService와 PasswordEncoder만을 선언하고, @EnableMethodSecurity로 어노테이션됩니다. 필터링 어노테이션을 위해서는 여전히 @EnableMethodSecurity 어노테이션을 사용하고 사전/사후 권한 부여 어노테이션을 활성화해야 합니다. 제공된 UserDetailsService는 테스트에 필요한 두 사용자, Nikolai와 Julien을 정의합니다.
Listing 12.1 Configuring users and enabling method security
package com.laurentiuspilca.ssia.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
@EnableMethodSecurity
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
var uds = new InMemoryUserDetailsManager();
var u1 = User.withUsername("nikolai")
.password("12345")
.authorities("read")
.build();
var u2 = User.withUsername("julien")
.password("12345")
.authorities("write")
.build();
uds.createUser(u1);
uds.createUser(u2);
return uds;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
Listing 12.2 The Product class definition
public class Product {
private String name;
private String owner; #A
public Product(String name, String owner) {
this.name = name;
this.owner = owner;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return Objects.equals(name, product.name) &&
Objects.equals(owner, product.owner);
}
@Override
public int hashCode() {
return Objects.hash(name, owner);
}
}
#A The attribute owner has the value of the username.
ProductService 클래스는 @PreFilter로 보호하는 서비스 메소드를 정의합니다. ProductService 클래스는 리스팅 12.3에서 찾을 수 있습니다. 그 리스팅에서, sellProducts() 메소드 전에, @PreFilter 어노테이션의 사용을 관찰할 수 있습니다. 어노테이션과 함께 사용된 Spring 표현 언어(SpEL)는 filterObject.owner == authentication.name으로, Product의 owner 속성이 로그인한 사용자의 사용자 이름과 동일한 값에만 허용합니다. SpEL 표현식에서 equals 연산자의 왼쪽에는 filterObject를 사용합니다. filterObject를 사용하여 파라미터인 리스트 내의 객체를 참조합니다. 제품 리스트를 가지고 있기 때문에, 우리 경우의 filterObject는 Product 타입입니다. 이러한 이유로, 우리는 제품의 owner 속성을 참조할 수 있습니다. 표현식에서 equals 연산자의 오른쪽에는 authentication 객체를 사용합니다. @PreFilter 및 @PostFilter 어노테이션에 대해, 우리는 인증 후 SecurityContext에서 사용 가능한 authentication 객체를 직접 참조할 수 있습니다(그림 12.4).

그림 12.4 filterObject를 사용한 사전 필터링 시, 호출자가 파라미터로 제공하는 리스트 내의 객체들을 참조합니다. authentication 객체는 보안 컨텍스트에서 인증 과정 후에 저장된 객체입니다.
서비스 메소드는 메소드가 전달받은 것과 정확히 같은 리스트를 반환합니다. 이 방법으로, 우리는 HTTP 응답 본문에 반환된 리스트를 확인함으로써 프레임워크가 우리가 예상한 대로 리스트를 필터링했는지 테스트하고 검증할 수 있습니다.
Listing 12.3 Using the @PreFilter annotation in the ProductService class
@Service
public class ProductService {
@PreFilter("filterObject.owner == authentication.name") #A
public List<Product> sellProducts(List<Product> products) {
// sell products and return the sold products list
return products; #B
}
}
#A The list given as a parameter allows only products owned by the authenticated user.
#B Returns the products for test purposes
테스트를 쉽게 하기 위해, 보호된 서비스 메소드를 호출하는 엔드포인트를 정의합니다. 리스트 12.4는 ProductController라는 컨트롤러 클래스에서 이 엔드포인트를 정의합니다. 여기서, 엔드포인트 호출을 짧게 만들기 위해, 리스트를 생성하고 이를 직접 서비스 메소드에 파라미터로 제공합니다. 실제 시나리오에서는 이 리스트가 클라이언트에 의해 요청 body에서 제공되어야 합니다. 또한, 변화를 제안하는 작업에 @GetMapping을 사용하는 것은 비표준이라는 점을 알 수 있지만, 이 예제에서 CSRF 보호를 다루는 것을 피하기 위해 이렇게 한다는 것을 알아두세요. CSRF 보호에 대해서는 9장에서 배웠습니다.
Listing 12.4 The controller class implementing the endpoint we use for tests
@RestController
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/sell")
public List<Product> sellProduct() {
List<Product> products = new ArrayList<>();
products.add(new Product("beer", "nikolai"));
products.add(new Product("candy", "nikolai"));
products.add(new Product("chocolate", "julien"));
return productService.sellProducts(products);
}
}
애플리케이션을 시작하고 /sell 엔드포인트를 호출했을 때 무슨 일이 발생하는지 살펴봅시다. 서비스 메소드에 파라미터로 제공한 리스트에서 세 개의 제품을 관찰하세요. 제품 중 두 개를 사용자 Nikolai에게 할당하고 나머지 하나를 사용자 Julien에게 할당합니다. 엔드포인트를 호출하고 사용자 Nikolai로 인증할 때, 응답에서 그녀와 연관된 두 제품만 보기를 기대합니다. 엔드포인트를 호출하고 Julien으로 인증할 때, 응답에서 Julien과 연관된 하나의 제품만 찾아야 합니다. 다음 코드 스니펫에서는 테스트 호출과 그 결과를 찾을 수 있습니다. /sell 엔드포인트를 호출하고 사용자 Nikolai로 인증하기 위해 이 명령어를 사용하세요:
※ 테스트 부분 생략
주의해야 할 점은 aspect가 주어진 컬렉션을 변경한다는 사실입니다. 우리 경우에서, 새로운 List 인스턴스를 리턴할 것으로 기대하지 마세요. 실제로, 주어진 기준과 일치하지 않는 요소를 애스펙트가 제거한 동일한 인스턴스입니다. 이는 고려해야 할 중요한 점입니다. 항상 제공하는 컬렉션 인스턴스가 불변이 아닌지 확인해야 합니다. 처리하기 위해 불변 컬렉션을 제공하는 것은 필터링 애스펙트가 컬렉션의 내용을 변경할 수 없기 때문에 실행 시 예외를 초래합니다(그림 12.5).

그림 12.5 애스펙트는 파라미터로 주어진 컬렉션을 가로채고 변경합니다. 애스펙트가 변경할 수 있도록 가변 인스턴스의 컬렉션을 제공해야 합니다.
리스팅 12.5는 이 섹션의 앞부분에서 작업한 동일한 프로젝트를 제시하지만, 이 상황에서 무슨 일이 일어나는지 테스트하기 위해 List.of() 메소드에 의해 리턴된 불변 인스턴스로 List 정의를 변경했습니다.
Listing 12.5 Using an immutable collection
@RestController
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/sell")
public List<Product> sellProduct() {
List<Product> products = List.of( #A
new Product("beer", "nikolai"),
new Product("candy", "nikolai"),
new Product("chocolate", "julien"));
return productService.sellProducts(products);
}
}
#A List.of() returns an immutable instance of the list.
이 예제를 ssia-ch12-ex2 폴더의 프로젝트로 분리하여 여러분도 직접 테스트할 수 있도록 했습니다. 애플리케이션을 실행하고 /sell 엔드포인트를 호출하면, 다음 코드 스니펫이 제시하는 것처럼 콘솔 로그에 예외와 함께 HTTP 응답 상태 500 내부 서버 오류가 발생합니다.
{
"timestamp": "2024-03-06T08:06:44.757+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/sell"
}
※ 테스트는 생략합니다
애플리케이션 콘솔에서는 다음 코드 스니펫에서 제시된 것과 유사한 예외를 찾을 수 있습니다.
java.lang.UnsupportedOperationException: null
at
java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:73)
~[na:na]
...
12.2 Applying postfiltering for method authorization
이 섹션에서는 사후 필터링을 구현합니다. 다음과 같은 시나리오가 있다고 가정해 보겠습니다. Angular로 구현된 프론트엔드와 Spring 기반 백엔드를 가진 애플리케이션이 일부 제품을 관리합니다. 사용자는 제품을 소유하며, 자신의 제품에 대한 세부 정보만을 얻을 수 있습니다. 자신의 제품의 세부 정보를 얻기 위해, 프론트엔드는 백엔드에 의해 노출된 엔드포인트를 호출합니다(그림 12.6).

그림 12.6 사후 필터링 시나리오. 클라이언트는 프론트엔드에 표시할 데이터를 검색하기 위해 엔드포인트를 호출합니다. 사후 필터링 구현은 클라이언트가 현재 인증된 사용자가 소유한 데이터만을 얻도록 보장합니다.
백엔드에 있는 서비스 클래스에서 개발자는 제품의 세부 정보를 검색하는 List<Product> findProducts() 메소드를 작성했습니다. 클라이언트 애플리케이션은 이러한 세부 정보를 프론트엔드에 표시합니다. 개발자는 이 메소드를 호출하는 사람이 자신이 소유한 제품만을 받고 다른 사람이 소유한 제품을 받지 않도록 어떻게 보장할 수 있을까요? 애플리케이션의 비즈니스 규칙과 권한 규칙을 분리하여 이 기능을 구현하는 방법 중 하나가 사후 필터링입니다. 이 섹션에서는 사후 필터링이 어떻게 작동하는지 논의하고 애플리케이션에서의 구현을 보여줍니다.
사전 필터링과 유사하게, 사후 필터링도 애스펙트에 의존합니다. 이 애스펙트는 메소드 호출을 허용하지만, 메소드가 리턴하면, 애스펙트는 반환된 값이, 정의한 규칙을 따르는지 확인합니다. 사전 필터링의 경우처럼, 사후 필터링은 메소드에 의해 반환된 컬렉션이나 배열을 변경합니다. 반환된 컬렉션 내부의 요소들이 따라야 할 기준을 제공합니다. 사후 필터 애스펙트는 규칙을 따르지 않는 요소들을 반환된 컬렉션이나 배열에서 필터링합니다.
사후 필터링을 적용하려면, @PostFilter 어노테이션을 사용해야 합니다. @PostFilter 어노테이션은 11장과 이 장에서 사용한 다른 모든 사전/사후 어노테이션과 유사하게 작동합니다. 어노테이션의 값으로 권한 규칙을 SpEL 표현식으로 제공하며, 그 규칙은 그림 12.7에서 보여주듯이 필터링 애스펙트가 사용하는 규칙입니다. 또한, 사전 필터링과 유사하게, 사후 필터링은 배열과 컬렉션에서만 작동합니다. 반환 타입이 배열이나 컬렉션인 메소드에만 @PostFilter 어노테이션을 적용해야 합니다.

그림 12.7 사후 필터링. 애스펙트가 보호된 메소드에 의해 리턴된 컬렉션을 가로채고, 제공한 규칙을 따르지 않는 값들을 필터링합니다. 사후 권한 부여와 달리, 반환된 값이 권한 부여 규칙을 따르지 않을 때 사후 필터링은 호출자에게 예외를 던지지 않습니다.
사후 필터링을 예제에 적용해 봅시다. 이 예제를 위해 ssia-ch12-ex3라는 프로젝트를 만들었습니다. 일관성을 유지하기 위해, 이 장의 이전 예제와 동일한 사용자를 유지하여 구성 클래스가 변경되지 않도록 했습니다. 편의를 위해, 다음 리스팅에서 제시된 구성을 반복합니다.
Listing 12.6 The configuration class
생략...
ProductService 클래스에서, 이제 제품 목록을 리턴하는 메소드를 구현합니다. 실제 상황에서는 애플리케이션이 데이터베이스 또는 다른 데이터 소스에서 제품을 읽어온다고 가정합니다. 우리의 예제를 짧게 유지하고 논의하는 측면에 집중할 수 있도록 하기 위해, 리스트 12.7에 제시된 것처럼 간단한 컬렉션을 사용합니다. 제품 목록을 반환하는 findProducts() 메소드에 @PostFilter 어노테이션을 붙입니다. 어노테이션의 값으로 추가한 조건, filterObject.owner == authentication.name은 인증된 사용자와 동일한 소유자를 가진 제품만 반환되도록 허용합니다(그림 12.8). equals 연산자의 왼쪽에서는 반환된 컬렉션 내의 요소를 참조하기 위해 filterObject를 사용합니다. 연산자의 오른쪽에서는 SecurityContext에 저장된 Authentication 객체를 참조하기 위해 authentication을 사용합니다.

그림 12.8 권한 부여에 사용된 SpEL 표현식에서, 우리는 리턴된 컬렉션 내의 객체들을 참조하기 위해 filterObject를 사용하고, 시큐리티 컨텍스트에서 Authentication 인스턴스를 참조하기 위해 authentication을 사용합니다.
Listing 12.7 The ProductService class
@Service
public class ProductService {
@PostFilter #A
("filterObject.owner == authentication.principal.username")
public List<Product> findProducts() {
List<Product> products = new ArrayList<>();
products.add(new Product("beer", "nikolai"));
products.add(new Product("candy", "nikolai"));
products.add(new Product("chocolate", "julien"));
return products;
}
}
#A findProducts 메소드에 의해 리턴된 컬렉션의 element들에 대한 필터링 조건을 추가합니다
우리는 위 메소드를 엔드포인트를 통해 접근 가능하게 만들기 위해 컨트롤러 클래스를 정의합니다. 다음 리스팅은 컨트롤러 클래스를 제시합니다.
Listing 12.8 The ProductController class
@RestController
public class ProductController {
private final ProductService productService;
// Omitted constructor
@GetMapping("/find")
public List<Product> findProducts() {
return productService.findProducts();
}
}
애플리케이션을 실행하고 /find 엔드포인트를 호출함으로써 그 동작을 테스트할 시간입니다. 우리는 HTTP 응답 본문에서 인증된 사용자가 소유한 제품만 보길 기대합니다. 다음 코드 스니펫은 우리 사용자인 Nikolai와 Julien 각각으로 엔드포인트를 호출했을 때의 결과를 보여줍니다. /find 엔드포인트를 호출하고 사용자 Julien으로 인증하기 위해 이 cURL 명령어를 사용하세요:
curl -u julien:12345 http://localhost:8080/find
The response body is
[
{"name":"chocolate","owner":"julien"}
]
12.3 Using filtering in Spring Data repositories
이 섹션에서는 Spring Data 리포지토리를 사용하여 적용되는 필터링에 대해 논의합니다. 우리가 종종 데이터베이스를 사용하여 애플리케이션의 데이터를 유지하기 때문에 이 접근 방식을 이해하는 것이 중요합니다. SQL이든 NoSQL이든 데이터베이스에 연결하기 위한 고수준 레이어로 Spring Data를 사용하는 Spring Boot 애플리케이션을 구현하는 것은 상당히 일반적입니다. Spring Data를 사용할 때 리포지토리 레벨에서 필터링을 적용하기 위한 두 가지 접근 방식에 대해 논의하고 이를 예시와 함께 구현합니다.
우리가 취하는 첫 번째 접근 방식은 이 장에서 지금까지 배운 것입니다: @PreFilter 및 @PostFilter 어노테이션을 사용하는 것입니다. 우리가 논의하는 두 번째 접근 방식은 쿼리에 직접 권한 규칙을 통합하는 것입니다. 이 섹션에서 배우게 될 것처럼, Spring Data 리포지토리에서 필터링을 적용하는 방식을 선택할 때 주의가 필요합니다. 언급했듯이, 우리에게는 두 가지 옵션이 있습니다:
- Using @PreFilter and @PostFilter annotations
- Directly applying filtering within queries
리포지토리의 경우 @PreFilter 어노테이션을 사용하는 것은 애플리케이션의 다른 어느 레이어에 이 어노테이션을 적용하는 것과 같습니다. 하지만 후처리 필터링에 대해서는 상황이 달라집니다. 리포지토리 메소드에 @PostFilter를 사용하는 것은 기술적으로 잘 작동하지만, 성능 관점에서 볼 때 거의 좋은 선택이 아닙니다.
회사 문서를 관리하는 애플리케이션을 가지고 있다고 가정해 보겠습니다. 개발자는 사용자가 로그인한 후 웹 페이지에 모든 문서가 나열되는 기능을 구현해야 합니다. 개발자는 Spring Data 리포지토리의 findAll() 메소드를 사용하기로 결정하고, 현재 로그인한 사용자가 소유한 문서만 반환하도록 Spring Security가 문서를 필터링하게 하기 위해 @PostFilter로 어노테이트합니다. 이 접근 방식은 분명히 잘못되었습니다. 왜냐하면 이 방식은 애플리케이션이 데이터베이스에서 모든 레코드를 검색한 다음에 스스로 레코드를 필터링하도록 허용하기 때문입니다. 만약 우리가 많은 수의 문서를 가지고 있다면, 페이징 없이 findAll()을 호출하는 것은 직접적으로 OutOfMemoryError로 이어질 수 있습니다. 문서의 수가 힙을 채울 정도로 크지 않더라도, 필요한 것만 데이터베이스에서 처음부터 검색하는 것보다 애플리케이션에서 레코드를 필터링하는 것이 성능 면에서 덜 효율적입니다(그림 12.9 참조).

그림 12.9 나쁜 디자인의 구조. 리포지토리 레벨에서 필터링을 적용해야 할 때, 필요한 데이터만 먼저 검색하는 것이 더 낫습니다. 그렇지 않으면, 애플리케이션은 심각한 메모리 및 성능 문제에 직면할 수 있습니다.
서비스 레벨에서는, 앱에서 레코드들을 필터링하는 것 외에는 다른 옵션이 없습니다. 그러나 로그인한 사용자가 소유한 레코드만 검색해야 한다는 것을 리포지토리 레벨에서 알고 있는 경우 데이터베이스에서 필요한 문서만 추출하는 쿼리를 구현해야 합니다.
참고: 데이터베이스, 웹 서비스, Input 스트림 또는 그 외 다른 것이든 데이터 소스에서 데이터를 검색하는 모든 상황에서, 애플리케이션이 필요한 데이터만 검색하도록 하십시오. 가능한 한 애플리케이션 내에서 데이터를 필터링할 필요성을 피하십시오.
Spring Data 리포지토리 메소드에 @PostFilter 어노테이션을 사용하는 애플리케이션을 먼저 작업해 보고, 그다음에는 쿼리에 직접 조건을 작성하는 두 번째 접근 방식으로 변경해 봅시다. 이 방법을 통해 우리는 두 가지 접근 방식을 모두 실험해 보고 비교할 기회를 가질 수 있습니다.
이 챕터의 이전 예시와 동일한 설정 클래스를 사용하는 ssia-ch12-ex4라는 새 프로젝트를 생성했습니다. 이전 예시와 마찬가지로, 우리는 제품을 관리하는 애플리케이션을 작성하지만, 이번에는 데이터베이스의 테이블에서 제품 세부 정보를 검색합니다. 우리의 예시를 위해, 제품에 대한 검색 기능을 구현합니다(그림 12.10). 문자열을 받아들여 그 문자열을 이름에 포함하고 있는 제품 목록을 반환하는 엔드포인트를 작성하지만, 인증된 사용자와 연관된 제품만 반환하도록 해야 합니다.

그림 12.10 우리 시나리오에서, 우리는 owner를 기반으로 제품을 필터링하기 위해 @PostFilter를 사용하여 애플리케이션을 구현하기 시작합니다. 그 다음에는 쿼리에 조건을 직접 추가하여 구현을 변경합니다. 이 방법을 통해, 애플리케이션이 소스에서 필요한 레코드만 가져오도록 합니다.
우리는 데이터베이스에 연결하기 위해 Spring Data JPA를 사용합니다. 이를 위해, pom.xml 파일에 spring-boot-starter-data-jpa 의존성과 데이터베이스 관리 서버 기술에 따른 연결 드라이버를 추가해야 합니다. 다음 코드 스니펫은 pom.xml 파일에서 사용하는 의존성을 제공합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
In the application.properties file, we add the properties Spring Boot needs to create the data source. In the next code snippet, you find the properties I added to my application.properties file:
spring.datasource.url=jdbc:mysql://localhost/spring?useLegacyDatetimeCode=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=
spring.datasource.initialization-mode=always
데이터베이스에 우리 애플리케이션이 검색하는 제품 상세 정보를 저장하기 위한 테이블이 필요합니다. 우리는 테이블을 생성하기 위한 스크립트를 작성하는 schema.sql 파일과 테이블에 테스트 데이터를 삽입하기 위한 쿼리를 작성하는 data.sql 파일을 정의합니다. 이 두 파일(schema.sql 및 data.sql)을 Spring Boot 프로젝트의 resources 폴더에 위치시켜야 애플리케이션이 시작될 때 찾아서 실행될 수 있습니다. 다음 코드 스니펫은 schema.sql 파일에 작성해야 하는 테이블 생성을 위한 쿼리를 보여줍니다:
CREATE TABLE IF NOT EXISTS `spring`.`product` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NULL,
`owner` VARCHAR(45) NULL,
PRIMARY KEY (`id`));
data.sql 파일에서, 저는 다음 코드 스니펫이 제시하는 세 개의 INSERT 문을 작성합니다. 이 문장들은 나중에 애플리케이션의 동작을 증명하는 데 필요한 테스트 데이터를 생성합니다.
INSERT IGNORE INTO `spring`.`product` (`id`, `name`, `owner`) VALUES ('1',
'beer', 'nikolai');
INSERT IGNORE INTO `spring`.`product` (`id`, `name`, `owner`) VALUES ('2',
'candy', 'nikolai');
INSERT IGNORE INTO `spring`.`product` (`id`, `name`, `owner`) VALUES ('3',
'chocolate', 'julien');
참고, 이 책 전반에 걸쳐 다른 예제에서도 테이블 이름을 동일하게 사용했습니다. 이전 예제에서 동일한 이름의 테이블을 이미 가지고 있다면, 이 프로젝트를 시작하기 전에 그것들을 삭제하는 것이 좋습니다. 다른 방법으로는 다른 스키마를 사용하는 것입니다.
우리 애플리케이션에서 제품 테이블을 매핑하기 위해, 엔티티 클래스를 작성해야 합니다. 다음 리스팅은 Product 엔티티를 정의합니다.
Listing 12.9 The Product entity class
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
private String owner;
// Omitted getters and setters
}
Product 엔티티를 위해, 다음 리스팅에서 정의된 Spring Data 리포지토리 인터페이스도 작성합니다. 이번에는 리포지토리 인터페이스에 의해 선언된 메소드에 직접 @PostFilter 어노테이션을 사용한다는 점을 주목하세요.
Listing 12.10 The ProductRepository interface
public interface ProductRepository
extends JpaRepository<Product, Integer> {
@PostFilter #A
("filterObject.owner == authentication.name")
List<Product> findProductByNameContains(String text);
}
#A Uses the @PostFilter annotation for the method declared by the Spring Data repository
다음 리스팅은 테스트 동작을 위해 사용하는 엔드포인트를 구현하는 컨트롤러 클래스를 정의하는 방법을 보여줍니다.
Listing 12.11 The ProductController class
@RestController
public class ProductController {
private final ProductRepository productRepository;
// Omitted constructor
@GetMapping("/products/{text}")
public List<Product> findProductsContaining(
@PathVariable String text) {
return productRepository.findProductByNameContains(text);
}
}
애플리케이션을 시작한 후, /products/{text} 엔드포인트를 호출했을 때 무슨 일이 일어나는지 테스트할 수 있습니다. 사용자 Nikolai로 인증하면서 문자 c를 검색하면, HTTP 응답은 오직 제품 candy만 포함합니다. chocolate 역시 c를 포함하고 있음에도 불구하고, Julien이 소유하고 있기 때문에, chocolate은 응답에 나타나지 않습니다. 호출과 그 응답은 다음 코드 스니펫에서 찾을 수 있습니다. /products 엔드포인트를 호출하고 사용자 Nikolai로 인증하기 위해, 이 명령을 실행하세요:
curl -u nikolai:12345 http://localhost:8080/products/c
The response body is
[
{"id":2,"name":"candy","owner":"nikolai"}
]
이 섹션에서 이전에 논의한 바와 같이, 저장소에서 @PostFilter를 사용하는 것은 최선의 선택이 아닙니다. 우리가 필요로 하지 않는 데이터를 데이터베이스에서 선택하지 않도록 해야 합니다. 그렇다면, 선택 후 데이터를 필터링하는 대신에 필요한 데이터만 선택하도록 예제를 어떻게 변경할 수 있을까요? 우리는 리포지토리 클래스가 사용하는 쿼리에 직접 SpEL(Spring Expression Language) 표현식을 제공할 수 있습니다. 이를 달성하기 위해, 우리는 두 가지 간단한 단계를 따릅니다:
- 우리는 SecurityEvaluationContextExtension 타입의 객체를 Spring 컨텍스트에 추가합니다. 이는 설정 클래스에서 간단한 @Bean 메서드를 사용하여 수행할 수 있습니다.
- 우리는 selection에 적절한 clauses를 사용하여 리파지토리 클래스 내의 쿼리를 조정합니다.
우리 프로젝트에서 SecurityEvaluationContextExtension 빈을 컨텍스트에 추가하려면, 리스팅 12.12에 제시된 대로 설정 클래스를 변경해야 합니다. 책의 예제와 관련된 모든 코드를 유지하기 위해 여기서는 ssia-ch12-ex5라는 다른 프로젝트를 사용합니다.
Listing 12.12 Adding the SecurityEvaluationContextExtension to the context
@Configuration
@EnableMethodSecurity
public class ProjectConfig {
@Bean #A
public SecurityEvaluationContextExtension
securityEvaluationContextExtension() {
return new SecurityEvaluationContextExtension();
}
// Omitted declaration of the UserDetailsService and PasswordEncoder
}
#A Adds a SecurityEvaluationContextExtension to the Spring context
ProductRepository 인터페이스에서는 메소드 전에 쿼리를 추가하고, SpEL 표현식을 사용하여 적절한 조건으로 WHERE 절을 조정합니다. 다음 리스팅은 변경 내용을 보여줍니다.
Listing 12.13 Using SpEL in the query in the repository interface
public interface ProductRepository
extends JpaRepository<Product, Integer> {
#A
@Query("SELECT p FROM Product p WHERE p.name LIKE %:text% AND p.owner=?#{authentication.name}")
List<Product> findProductByNameContains(String text);
}
#A 레코드의 owner에 대한 권한 부여 제한 조건을 추가하기 위해 쿼리에서 SpEL을 사용합니다.
이제 애플리케이션을 시작하고 /products/{text} 엔드포인트를 호출하여 테스트할 수 있습니다. 우리는 @PostFilter를 사용한 경우와 동일한 동작이 예상됩니다. 그러나 이제는 해당 owner에 대한 레코드만 데이터베이스에서 검색되므로 기능이 더 빠르고 신뢰할 수 있습니다. 다음 코드 스니펫은 엔드포인트를 호출하는 방법을 보여줍니다. /products 엔드포인트를 호출하고 사용자 Nikolai로 인증하기 위해, 다음 명령을 사용합니다:
curl -u nikolai:12345 http://localhost:8080/products/c
The response body is
[
{"id":2,"name":"candy","owner":"nikolai"}
]
12.4 Summary
- 필터링은 프레임워크가 메서드의 파라미터 또는 메서드가 리턴하는 값의 유효성을 검증하고, 정의한 기준을 충족하지 않는 요소를 제외하는 권한 부여 접근 방식입니다. 권한 부여 접근 방식으로써, 필터링은 메서드의 입력 및 출력 값에 중점을 두고 있으며 메서드 실행 자체에 대한 검증은 수행하지 않습니다.
- 필터링을 사용하여 메서드가 인가된 값 이외의 다른 값을 처리하지 않도록 하고, 메서드의 호출자가 받아서는 안 되는 값들을 리턴하지 못하도록 보장합니다.
- 필터링을 사용할 때, 메서드에 대한 액세스를 제한하는 것이 아니라 메서드의 파라미터를 통해 전달되는 아규먼트나 메서드가 리턴하는 것을 제한합니다. 이 액세스 방식을 통해 메서드의 입력과 출력을 제어할 수 있습니다.
- 메서드의 파라미터를 통해 전달될 수 있는 아규먼트들을 제한하려면 @PreFilter 어노테이션을 사용합니다. @PreFilter 어노테이션은 메서드의 파라미터로 전달되는 아규먼트들의 조건을 받습니다. 프레임워크는 파라미터의 아규먼트로 주어진 컬렉션에서 주어진 규칙을 따르지 않는 모든 아규먼트들을 필터링합니다.
- @PreFilter 어노테이션을 사용하려면 메서드의 파라미터는 컬렉션이거나 배열이어야 합니다. 어노테이션의 SpEL 표현식에서 규칙을 정의할 때, 컬렉션 내의 엘리먼트 객체를 filterObject를 사용하여 참조합니다.
- 메서드가 리턴하는 값들을 제한하려면 @PostFilter 어노테이션을 사용합니다. @PostFilter 어노테이션을 사용할 때, 메서드의 리턴 타입은 반드시 컬렉션이나 배열이어야 합니다. 프레임워크는 @PostFilter 어노테이션의 속성 값으로 정의한 규칙에 따라 리턴된 컬렉션 내의 값들을 필터링합니다.
- @Spring Data Repository에서도 @PreFilter 및 @PostFilter 어노테이션을 사용할 수 있습니다. 그러나 Spring Data Repository 메서드에 @PostFilter를 사용하는 것은 거의 좋은 선택이 아닙니다. 성능 문제를 피하기 위해 결과를 필터링하는 것은 이 경우에는 데이터베이스 레벨에서 직접 수행되어야 합니다.
- Spring Security는 Spring Data와 쉽게 통합되며, 이를 사용하여 Spring Data Repository 메서드에 @PostFilter를 사용하는 것을 피할 수 있습니다.
'Spring Security' 카테고리의 다른 글
ch14 Implementing an OAuth 2 authorization server (0) | 2024.12.16 |
---|---|
ch13 What are OAuth 2 and OpenID Connect? (0) | 2024.03.03 |
ch11 Implement authorization at the method level (0) | 2024.03.03 |
ch10 Configuring Cross-Origin Resource Sharing(CORS) (0) | 2024.03.03 |
ch09 Configuring Cross-Site Request Forgery(CSRF) protection (0) | 2024.03.03 |