Ver2.0 Spring Security Method Security로 세밀한 권한 제어 마스터하기 🔐

Spring Security Method Security로 세밀한 권한 제어 마스터하기 🔐
Java 개발자를 위한 완벽 가이드
안녕? 오늘은 Spring 애플리케이션에서 보안의 핵심이라고 할 수 있는 Spring Security의 Method Security에 대해 깊이 파헤쳐볼 거야! 🚀
웹 애플리케이션을 개발하다 보면 "이 기능은 관리자만 접근할 수 있어야 해", "이 데이터는 해당 사용자만 볼 수 있어야 해" 같은 요구사항을 자주 마주치게 돼. 이런 세밀한 권한 제어가 필요할 때 Spring Security의 Method Security는 정말 강력한 도구가 된다고!
재능넷 같은 재능 공유 플랫폼을 운영한다면 더욱 중요해. 왜냐하면 사용자별로 접근할 수 있는 콘텐츠와 기능이 세밀하게 나뉘어야 하니까! 이제 함께 Spring Security Method Security의 세계로 들어가볼까? 😎
📚 목차
- Spring Security Method Security 개요
- Method Security 설정하기
- @PreAuthorize와 @PostAuthorize 활용하기
- SpEL(Spring Expression Language)로 복잡한 권한 로직 구현
- @Secured와 JSR-250 애노테이션
- Method Security와 도메인 객체 보안(ACL)
- 실전 예제: 다양한 시나리오별 구현 방법
- 성능 최적화 전략
- 테스트 전략
- 보안 취약점 방지하기
1. Spring Security Method Security 개요 🌟
Spring Security는 웹 요청에 대한 보안(URL 기반)과 메소드 호출에 대한 보안(Method 기반)을 모두 제공해. URL 기반 보안은 이미 알고 있을 테니, 오늘은 메소드 수준의 보안에 집중해보자!
Method Security가 필요한 이유
URL 기반 보안만으로는 부족한 경우가 많아. 예를 들어:
- 같은 URL이라도 HTTP 메소드(GET, POST 등)에 따라 권한을 다르게 부여하고 싶을 때
- 메소드의 파라미터 값에 따라 접근 권한을 동적으로 결정해야 할 때
- 비즈니스 로직 내부에서 세밀한 보안 검사가 필요할 때
- 반환되는 데이터에 대해 필터링이 필요할 때
이런 상황에서 Method Security는 코드 레벨에서 보안을 적용할 수 있는 강력한 방법을 제공해. 특히 마이크로서비스 아키텍처에서는 API Gateway 외에도 각 서비스 내부에서 보안을 강화하는 데 필수적이지! 🛡️
Method Security의 핵심 기능
Spring Security의 Method Security는 다음과 같은 핵심 기능을 제공해:
- 사전 권한 검사(Pre-Authorization): 메소드 실행 전에 권한 검사
- 사후 권한 검사(Post-Authorization): 메소드 실행 후 반환 값에 대한 권한 검사
- 메소드 파라미터 보안: 메소드 인자에 대한 보안 검사
- 반환 값 필터링: 권한에 따라 반환 값 필터링
- SpEL 지원: 복잡한 보안 표현식 작성 가능
이제 Method Security가 왜 중요한지 감이 왔지? 다음으로 이걸 어떻게 설정하고 사용하는지 자세히 알아보자! 🔍
2. Method Security 설정하기 ⚙️
Method Security를 사용하려면 먼저 적절한 설정이 필요해. 설정 방법은 Spring Boot 버전과 선호하는 설정 방식(Java Config vs XML)에 따라 조금씩 다를 수 있어. 여기서는 최신 Spring Boot와 Java Config를 기준으로 설명할게!
기본 설정
먼저 Spring Security 의존성이 필요해:
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
// Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
그 다음, Method Security를 활성화하는 설정 클래스를 만들어야 해:
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity(
prePostEnabled = true, // @PreAuthorize, @PostAuthorize 활성화
securedEnabled = true, // @Secured 활성화
jsr250Enabled = true // @RolesAllowed 활성화
)
public class MethodSecurityConfig {
// 추가 설정이 필요하면 여기에 작성
}
@EnableMethodSecurity 애노테이션은 Spring Security 5.6부터 도입된 새로운 방식이야. 이전 버전에서는 @EnableGlobalMethodSecurity를 사용했지. 최신 버전을 사용하는 걸 권장해! 🆕
설정 옵션 이해하기
@EnableMethodSecurity 애노테이션의 주요 옵션을 살펴보자:
- prePostEnabled: @PreAuthorize와 @PostAuthorize 애노테이션을 활성화함. 가장 강력하고 유연한 방식으로, SpEL을 사용할 수 있어.
- securedEnabled: @Secured 애노테이션을 활성화함. 간단한 역할 기반 보안에 적합해.
- jsr250Enabled: Java 표준인 @RolesAllowed 애노테이션을 활성화함. 프레임워크에 독립적인 방식을 선호한다면 이 옵션이 좋아.
일반적으로는 prePostEnabled = true만 설정해도 충분한 경우가 많아. 하지만 기존 코드와의 호환성이나 팀의 선호도에 따라 다른 옵션도 활성화할 수 있지! 👍
SecurityContext 설정
Method Security가 제대로 작동하려면 SecurityContext에 현재 사용자 정보가 올바르게 설정되어 있어야 해. Spring Security는 웹 요청의 경우 자동으로 이를 처리하지만, 비동기 메소드나 스케줄된 작업에서는 추가 설정이 필요할 수 있어:
@Configuration
public class AsyncSecurityConfig extends AsyncConfigurerSupport {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("async-");
// SecurityContext를 비동기 스레드로 전파
executor.setTaskDecorator(task -> {
SecurityContext context = SecurityContextHolder.getContext();
return () -> {
try {
SecurityContextHolder.setContext(context);
task.run();
} finally {
SecurityContextHolder.clearContext();
}
};
});
executor.initialize();
return executor;
}
}
이렇게 하면 비동기 메소드에서도 Method Security가 정상적으로 작동해! 🧵
Method Security 동작 원리
Method Security는 Spring AOP(Aspect-Oriented Programming)를 기반으로 동작해. 간단히 말하면:
- 보안 애노테이션이 붙은 메소드가 호출되면
- Spring AOP가 이를 가로채서(인터셉트)
- 보안 표현식을 평가하고
- 권한이 있으면 원래 메소드를 실행하고, 없으면 예외를 발생시킴
이 과정은 프록시를 통해 이루어지기 때문에, 같은 클래스 내에서 메소드를 직접 호출할 때는 보안 검사가 작동하지 않아. 이 점을 꼭 기억해둬야 해! ⚠️
이제 Method Security를 설정하는 방법을 알았으니, 다음으로 가장 많이 사용되는 @PreAuthorize와 @PostAuthorize 애노테이션에 대해 자세히 알아보자! 🚀
3. @PreAuthorize와 @PostAuthorize 활용하기 🔍
Method Security의 핵심은 @PreAuthorize와 @PostAuthorize 애노테이션이야. 이 두 애노테이션은 SpEL(Spring Expression Language)을 사용해서 매우 유연하고 강력한 보안 규칙을 정의할 수 있게 해줘. 😎
@PreAuthorize 사용하기
@PreAuthorize는 메소드 실행 전에 권한을 검사해. 가장 많이 사용되는 애노테이션이지! 기본적인 사용법을 살펴보자:
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
// 모든 사용자 목록을 반환하는 로직
return userRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
public User getUserById(Long id) {
// 특정 ID의 사용자를 반환하는 로직
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
@PreAuthorize("authentication.principal.username == #username")
public User getUserByUsername(String username) {
// 특정 사용자명의 사용자를 반환하는 로직
return userRepository.findByUsername(username)
.orElseThrow(() -> new UserNotFoundException(username));
}
}
위 예제에서 볼 수 있듯이, @PreAuthorize는 다양한 표현식을 지원해:
- hasRole('ROLE_NAME'): 특정 역할을 가진 사용자만 접근 가능
- hasAuthority('AUTHORITY_NAME'): 특정 권한을 가진 사용자만 접근 가능
- isAuthenticated(): 인증된 사용자만 접근 가능
- isAnonymous(): 익명 사용자만 접근 가능
- permitAll(): 모든 사용자 접근 가능
- denyAll(): 모든 사용자 접근 불가능
- authentication: 현재 인증 객체에 접근 (예: authentication.principal)
- #파라미터명: 메소드 파라미터에 접근
특히 #파라미터명 문법을 사용하면 메소드 파라미터를 보안 표현식에서 참조할 수 있어서 매우 유용해! 🎯
@PostAuthorize 사용하기
@PostAuthorize는 메소드 실행 후에 권한을 검사해. 이는 메소드의 반환 값을 기반으로 보안 결정을 내릴 때 유용해:
@Service
public class DocumentService {
@PostAuthorize("returnObject.owner == authentication.principal.username")
public Document getDocumentById(Long id) {
// 문서를 반환하는 로직
return documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
}
@PostAuthorize("hasRole('ADMIN') or returnObject.department == authentication.principal.department")
public Report generateReport(String department) {
// 보고서 생성 로직
Report report = new Report();
report.setDepartment(department);
// ... 보고서 데이터 채우기
return report;
}
}
@PostAuthorize에서는 returnObject 키워드를 사용해 메소드의 반환 값에 접근할 수 있어. 이를 통해 반환된 객체의 속성을 검사하여 접근 권한을 결정할 수 있지! 🔄
하지만 @PostAuthorize는 메소드가 이미 실행된 후에 검사하기 때문에, 메소드 내에서 부작용(side effect)이 있는 경우 주의해야 해. 예를 들어, 데이터베이스를 수정하는 메소드라면 권한이 없어도 이미 변경이 일어난 후에 예외가 발생하게 돼. 😱
@PreFilter와 @PostFilter 사용하기
컬렉션 데이터를 필터링하고 싶을 때는 @PreFilter와 @PostFilter를 사용할 수 있어:
@Service
public class ProjectService {
@PostFilter("filterObject.owner == authentication.principal.username or hasRole('ADMIN')")
public List<Project> getAllProjects() {
// 모든 프로젝트를 반환하는 로직
return projectRepository.findAll();
}
@PreFilter("filterObject.owner == authentication.principal.username")
public List<Project> updateProjects(List<Project> projects) {
// 프로젝트 업데이트 로직
return projectRepository.saveAll(projects);
}
}
@PostFilter는 메소드 실행 후 반환된 컬렉션에서 조건에 맞는 항목만 필터링해서 반환해. @PreFilter는 메소드 실행 전 파라미터로 전달된 컬렉션을 필터링해. 두 경우 모두 filterObject 키워드로 컬렉션의 각 항목에 접근할 수 있어! 🧹
필터링은 강력하지만, 대용량 데이터에서는 성능 문제가 발생할 수 있어. 가능하면 데이터베이스 쿼리 단계에서 필터링하는 것이 더 효율적일 수 있다는 점을 기억해! 💡
실제 사용 예시: 재능넷 시나리오
재능넷과 같은 재능 공유 플랫폼에서 Method Security를 어떻게 활용할 수 있는지 예시를 들어볼게:
@Service
public class TalentService {
@PreAuthorize("isAuthenticated()")
public List<Talent> getAllPublicTalents() {
// 공개된 모든 재능 목록 반환
return talentRepository.findByIsPublicTrue();
}
@PreAuthorize("hasRole('SELLER')")
public Talent createTalent(Talent talent) {
// 현재 인증된 사용자를 판매자로 설정
talent.setSeller(getCurrentUsername());
return talentRepository.save(talent);
}
@PreAuthorize("hasRole('ADMIN') or #talent.seller == authentication.principal.username")
public Talent updateTalent(Talent talent) {
// 재능 정보 업데이트
return talentRepository.save(talent);
}
@PostAuthorize("returnObject.isPublic == true or returnObject.seller == authentication.principal.username or hasRole('ADMIN')")
public Talent getTalentById(Long id) {
// 특정 ID의 재능 조회
return talentRepository.findById(id)
.orElseThrow(() -> new TalentNotFoundException(id));
}
@PreAuthorize("hasRole('ADMIN') or @talentPermissionEvaluator.canDeleteTalent(authentication, #id)")
public void deleteTalent(Long id) {
// 재능 삭제
talentRepository.deleteById(id);
}
private String getCurrentUsername() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.getName();
}
}
이 예시에서는 다양한 Method Security 기능을 활용해 재능넷 플랫폼의 재능 관리 기능에 세밀한 권한 제어를 적용했어. 특히 마지막 예시에서는 커스텀 권한 평가자(PermissionEvaluator)를 사용하는 방법도 보여주고 있지! 🌟
다음 섹션에서는 더 복잡한 권한 로직을 구현하기 위한 SpEL의 고급 기능에 대해 알아보자! 🚀
4. SpEL(Spring Expression Language)로 복잡한 권한 로직 구현 🧩
Spring Security Method Security의 진정한 강점은 SpEL(Spring Expression Language)을 통해 복잡한 권한 로직을 표현할 수 있다는 거야. SpEL은 런타임에 객체 그래프를 탐색하고 조작할 수 있는 강력한 표현 언어야! 🔍
SpEL 기본 문법
SpEL의 기본 문법을 간단히 살펴보자:
- 리터럴 표현식: 'Hello', 42, true 등
- 속성 접근: user.username, project.owner 등
- 메소드 호출: user.getUsername(), project.isActive() 등
- 연산자: +, -, *, /, ==, !=, >, <, >=, <=, and, or, not 등
- 조건식: condition ? trueValue : falseValue
- 컬렉션 접근: list[0], map['key'] 등
- 컬렉션 투영: users.![username] (모든 사용자의 username 추출)
- 컬렉션 선택: users.?[age > 18] (18세 초과 사용자만 선택)
이런 문법을 활용해 Method Security에서 복잡한 권한 검사를 구현할 수 있어! 😎
SpEL에서 사용 가능한 보안 관련 객체와 메소드
Spring Security는 SpEL에서 사용할 수 있는 여러 보안 관련 객체와 메소드를 제공해:
- authentication: 현재 Authentication 객체
- principal: authentication.principal의 단축어
- hasRole('ROLE'): 특정 역할 보유 여부 확인
- hasAnyRole('ROLE1', 'ROLE2'): 나열된 역할 중 하나라도 보유 여부 확인
- hasAuthority('AUTH'): 특정 권한 보유 여부 확인
- hasAnyAuthority('AUTH1', 'AUTH2'): 나열된 권한 중 하나라도 보유 여부 확인
- permitAll: 항상 true 반환
- denyAll: 항상 false 반환
- isAnonymous(): 익명 사용자인지 확인
- isAuthenticated(): 인증된 사용자인지 확인
- isRememberMe(): Remember-me로 인증된 사용자인지 확인
- isFullyAuthenticated(): Remember-me가 아닌 완전히 인증된 사용자인지 확인
복잡한 권한 표현식 예제
이제 SpEL을 활용한 복잡한 권한 표현식 예제를 살펴보자:
@Service
public class AdvancedSecurityService {
// 사용자가 관리자이거나, 프로젝트의 소유자이거나, 프로젝트가 공개 상태인 경우 접근 허용
@PreAuthorize("hasRole('ADMIN') or #project.owner == authentication.principal.username or #project.isPublic == true")
public void processProject(Project project) {
// 프로젝트 처리 로직
}
// 사용자가 관리자이거나, 요청한 부서의 멤버인 경우 접근 허용
@PreAuthorize("hasRole('ADMIN') or authentication.principal.departments.contains(#department)")
public List<Employee> getEmployeesByDepartment(String department) {
// 부서별 직원 조회 로직
return employeeRepository.findByDepartment(department);
}
// 사용자가 관리자이거나, 주문의 고객인 경우에만 접근 허용
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isCustomerOf(authentication.principal.id, #orderId)")
public Order getOrderDetails(Long orderId) {
// 주문 상세 조회 로직
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
// 반환된 주문 목록에서 사용자가 접근 가능한 주문만 필터링
@PostFilter("hasRole('ADMIN') or filterObject.customerId == authentication.principal.id")
public List<Order> getAllOrders() {
// 모든 주문 조회 로직
return orderRepository.findAll();
}
// 관리자이거나 모든 프로젝트가 사용자 소유인 경우에만 접근 허용
@PreAuthorize("hasRole('ADMIN') or #projects.![owner].contains(authentication.principal.username)")
public void processProjects(List<Project> projects) {
// 여러 프로젝트 처리 로직
}
// 사용자의 역할에 따라 다른 데이터 접근 범위 설정
@PostAuthorize("hasRole('ADMIN') ? true : hasRole('MANAGER') ? returnObject.size() <= 100 : returnObject.size() <= 10")
public List<DataItem> getDataItems() {
// 데이터 조회 로직
return dataItemRepository.findAll();
}
}
위 예제에서 볼 수 있듯이, SpEL을 사용하면 매우 세밀하고 복잡한 권한 로직을 구현할 수 있어! 특히 컬렉션 투영(projection)이나 선택(selection) 기능은 복잡한 데이터 구조에서 권한을 검사할 때 매우 유용해. 🧠
커스텀 보안 표현식 만들기
권한 로직이 너무 복잡하거나 여러 곳에서 재사용해야 한다면, 커스텀 보안 표현식을 만드는 것이 좋아. 이를 위해 두 가지 방법을 사용할 수 있어:
1. @Bean 메소드 사용
@Configuration
public class SecurityExpressionConfig {
@Bean
public ProjectSecurityService projectSecurity() {
return new ProjectSecurityService();
}
}
public class ProjectSecurityService {
@Autowired
private ProjectRepository projectRepository;
public boolean isProjectOwner(Authentication authentication, Long projectId) {
Project project = projectRepository.findById(projectId).orElse(null);
if (project == null) {
return false;
}
String username = authentication.getName();
return project.getOwner().equals(username);
}
public boolean hasAccessToProject(Authentication authentication, Long projectId) {
Project project = projectRepository.findById(projectId).orElse(null);
if (project == null) {
return false;
}
// 관리자는 모든 프로젝트에 접근 가능
if (authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
return true;
}
String username = authentication.getName();
// 프로젝트 소유자이거나 팀원인 경우 접근 가능
return project.getOwner().equals(username) ||
project.getTeamMembers().contains(username);
}
}
이제 이 커스텀 보안 표현식을 다음과 같이 사용할 수 있어:
@PreAuthorize("@projectSecurity.isProjectOwner(authentication, #projectId)")
public void updateProject(Long projectId, ProjectDto projectDto) {
// 프로젝트 업데이트 로직
}
@PreAuthorize("@projectSecurity.hasAccessToProject(authentication, #projectId)")
public Project getProjectDetails(Long projectId) {
// 프로젝트 상세 조회 로직
return projectRepository.findById(projectId)
.orElseThrow(() -> new ProjectNotFoundException(projectId));
}
@Bean 이름 앞에 @를 붙여 SpEL에서 해당 빈을 참조할 수 있어! 이 방식은 복잡한 로직을 Java 코드로 구현할 수 있어서 가독성과 유지보수성이 좋아. 👍
2. PermissionEvaluator 구현
더 체계적인 방법으로는 PermissionEvaluator 인터페이스를 구현하는 방법이 있어:
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private ProjectRepository projectRepository;
@Autowired
private DocumentRepository documentRepository;
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
if (authentication == null || targetDomainObject == null || !(permission instanceof String)) {
return false;
}
String permissionString = (String) permission;
if (targetDomainObject instanceof Project) {
return hasProjectPermission(authentication, (Project) targetDomainObject, permissionString);
}
if (targetDomainObject instanceof Document) {
return hasDocumentPermission(authentication, (Document) targetDomainObject, permissionString);
}
return false;
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
if (authentication == null || targetId == null || targetType == null || !(permission instanceof String)) {
return false;
}
String permissionString = (String) permission;
if (targetType.equals("Project")) {
Project project = projectRepository.findById((Long) targetId).orElse(null);
if (project == null) {
return false;
}
return hasProjectPermission(authentication, project, permissionString);
}
if (targetType.equals("Document")) {
Document document = documentRepository.findById((Long) targetId).orElse(null);
if (document == null) {
return false;
}
return hasDocumentPermission(authentication, document, permissionString);
}
return false;
}
private boolean hasProjectPermission(Authentication authentication, Project project, String permission) {
String username = authentication.getName();
// 관리자는 모든 권한 가짐
if (authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
return true;
}
switch (permission) {
case "READ":
// 공개 프로젝트이거나 소유자이거나 팀원인 경우 읽기 가능
return project.isPublic() ||
project.getOwner().equals(username) ||
project.getTeamMembers().contains(username);
case "WRITE":
// 소유자이거나 편집 권한이 있는 팀원인 경우 쓰기 가능
return project.getOwner().equals(username) ||
(project.getTeamMembers().contains(username) &&
project.getEditableMembers().contains(username));
case "DELETE":
// 소유자만 삭제 가능
return project.getOwner().equals(username);
default:
return false;
}
}
private boolean hasDocumentPermission(Authentication authentication, Document document, String permission) {
// 문서에 대한 권한 로직 구현
// ...
return false;
}
}
그리고 이 PermissionEvaluator를 Spring Security 설정에 등록해:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig extends MethodSecurityExpressionHandler {
@Autowired
private CustomPermissionEvaluator permissionEvaluator;
@Bean
public MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(permissionEvaluator);
return expressionHandler;
}
}
이제 다음과 같이 hasPermission 표현식을 사용할 수 있어:
@PreAuthorize("hasPermission(#projectId, 'Project', 'READ')")
public Project getProject(Long projectId) {
// 프로젝트 조회 로직
return projectRepository.findById(projectId)
.orElseThrow(() -> new ProjectNotFoundException(projectId));
}
@PreAuthorize("hasPermission(#project, 'WRITE')")
public Project updateProject(Project project) {
// 프로젝트 업데이트 로직
return projectRepository.save(project);
}
이 방식은 도메인 객체 보안을 체계적으로 구현할 때 특히 유용해. 권한 로직이 중앙화되고 일관성 있게 관리될 수 있기 때문이지! 🏗️
다음 섹션에서는 @Secured와 JSR-250 애노테이션에 대해 알아보자! 🚀
5. @Secured와 JSR-250 애노테이션 🔒
@PreAuthorize와 @PostAuthorize가 가장 강력하고 유연한 방식이지만, 더 간단한 권한 검사가 필요한 경우에는 @Secured나 JSR-250 애노테이션을 사용할 수도 있어. 이 애노테이션들은 SpEL을 지원하지 않아서 기능이 제한적이지만, 간단하고 직관적이라는 장점이 있어! 🧩
@Secured 사용하기
@Secured는 Spring Security에서 제공하는 가장 기본적인 메소드 보안 애노테이션이야. 사용자의 역할(role)을 기반으로 접근을 제어할 수 있어:
@Service
public class SimpleSecurityService {
@Secured("ROLE_ADMIN")
public void adminOnlyMethod() {
// 관리자만 접근 가능한 메소드
}
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void managementMethod() {
// 관리자나 매니저만 접근 가능한 메소드
}
@Secured("ROLE_USER")
public void userMethod() {
// 일반 사용자만 접근 가능한 메소드
}
}
@Secured를 사용하려면 @EnableMethodSecurity 애노테이션에 securedEnabled = true 옵션을 설정해야 해:
@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class MethodSecurityConfig {
// 설정 내용
}
@Secured의 장점은 간단하고 직관적이라는 거야. 하지만 SpEL을 지원하지 않기 때문에 복잡한 권한 로직을 구현하기 어렵다는 단점이 있어. 또한 메소드 파라미터나 반환 값에 접근할 수 없어서 동적인 권한 검사가 제한적이야. 😕
JSR-250 애노테이션 사용하기
JSR-250은 Java EE 표준의 일부로, 보안 관련 애노테이션을 정의하고 있어. Spring Security는 이 표준을 지원하기 위해 @RolesAllowed, @PermitAll, @DenyAll 애노테이션을 제공해:
import javax.annotation.security.RolesAllowed;
import javax.annotation.security.PermitAll;
import javax.annotation.security.DenyAll;
@Service
public class StandardSecurityService {
@RolesAllowed("ADMIN")
public void adminOnlyMethod() {
// 관리자만 접근 가능한 메소드
}
@RolesAllowed({"ADMIN", "MANAGER"})
public void managementMethod() {
// 관리자나 매니저만 접근 가능한 메소드
}
@PermitAll
public void publicMethod() {
// 모든 사용자가 접근 가능한 메소드
}
@DenyAll
public void noAccessMethod() {
// 모든 사용자가 접근 불가능한 메소드
}
}
JSR-250 애노테이션을 사용하려면 @EnableMethodSecurity 애노테이션에 jsr250Enabled = true 옵션을 설정해야 해:
@Configuration
@EnableMethodSecurity(jsr250Enabled = true)
public class MethodSecurityConfig {
// 설정 내용
}
JSR-250 애노테이션의 장점은 Java EE 표준이라는 거야. 따라서 Spring 외의 다른 Java EE 호환 프레임워크에서도 동일하게 사용할 수 있어서 이식성이 좋아. 하지만 @Secured와 마찬가지로 SpEL을 지원하지 않아서 기능이 제한적이야. 📝
애노테이션 선택 가이드
여러 보안 애노테이션 중 어떤 것을 선택해야 할까? 다음 가이드라인이 도움이 될 거야:
- @PreAuthorize/@PostAuthorize: 가장 강력하고 유연한 옵션. 복잡한 권한 로직이 필요하거나 메소드 파라미터/반환 값에 접근해야 할 때 사용해.
- @Secured: 간단한 역할 기반 보안이 필요하고 Spring에 종속적이어도 괜찮을 때 사용해.
- @RolesAllowed: 간단한 역할 기반 보안이 필요하고 Java EE 표준 준수가 중요할 때 사용해.
대부분의 경우 @PreAuthorize/@PostAuthorize가 가장 좋은 선택이야. 하지만 기존 코드베이스와의 일관성이나 팀의 선호도에 따라 다른 애노테이션을 선택할 수도 있어! 🤔
여러 애노테이션 혼합 사용하기
같은 프로젝트 내에서 여러 종류의 보안 애노테이션을 혼합해서 사용할 수도 있어. 이 경우 각 애노테이션 타입에 맞는 설정을 모두 활성화해야 해:
@Configuration
@EnableMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true
)
public class MethodSecurityConfig {
// 설정 내용
}
하지만 같은 메소드에 여러 보안 애노테이션을 적용하는 것은 권장하지 않아. 애노테이션 간의 우선순위가 명확하지 않고, 의도하지 않은 동작이 발생할 수 있기 때문이야. 일관된 방식으로 하나의 애노테이션 타입만 사용하는 것이 좋아! ⚠️
애노테이션 상속
보안 애노테이션은 메소드 오버라이딩 시 상속되지 않아. 이 점을 주의해야 해:
public interface UserService {
@PreAuthorize("hasRole('ADMIN')")
void deleteUser(Long userId);
}
@Service
public class UserServiceImpl implements UserService {
@Override
// 여기에 @PreAuthorize 애노테이션이 없으면 보안 검사가 적용되지 않음!
public void deleteUser(Long userId) {
// 사용자 삭제 로직
}
}
인터페이스에 정의된 보안 애노테이션이 구현 클래스에 자동으로 적용되게 하려면, 프록시 모드를 설정해야 해:
@Configuration
@EnableMethodSecurity(
prePostEnabled = true,
proxyTargetClass = false
)
public class MethodSecurityConfig {
// 설정 내용
}
여기서 proxyTargetClass = false로 설정하면 JDK 동적 프록시를 사용하게 되고, 인터페이스에 정의된 애노테이션이 적용돼. 하지만 이 방식은 제한사항이 있으니 가능하면 구현 클래스에도 명시적으로 보안 애노테이션을 추가하는 것이 좋아! 🔄
다음 섹션에서는 Method Security와 도메인 객체 보안(ACL)에 대해 알아보자! 🚀
6. Method Security와 도메인 객체 보안(ACL) 🛡️
지금까지 살펴본 Method Security는 주로 사용자의 역할이나 권한에 기반한 보안 모델이었어. 하지만 실제 애플리케이션에서는 특정 객체에 대한 사용자별 권한을 관리해야 하는 경우가 많아. 이를 도메인 객체 보안 또는 ACL(Access Control List)이라고 해! 🔐
도메인 객체 보안이란?
도메인 객체 보안은 다음과 같은 상황에서 필요해:
- 사용자 A는 문서 1에 대한 읽기/쓰기 권한이 있지만, 문서 2에는 읽기 권한만 있음
- 사용자 B는 프로젝트 X의 관리자이지만, 프로젝트 Y에는 접근 권한이 없음
- 사용자 C는 자신이 생성한 리소스만 수정/삭제할 수 있음
이런 세밀한 권한 제어를 위해 Spring Security는 ACL 모듈을 제공해. 하지만 ACL 모듈은 설정이 복잡하고 성능 오버헤드가 있어서, 간단한 경우에는 커스텀 구현이 더 효율적일 수 있어! 🤔
Spring Security ACL 설정하기
Spring Security ACL을 사용하려면 먼저 의존성을 추가해야 해:
// Gradle
implementation 'org.springframework.security:spring-security-acl'
// Maven
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-acl</artifactId>
</dependency>
그리고 ACL 스키마를 데이터베이스에 생성해야 해. Spring Security는 기본 스키마를 제공하지만, 필요에 따라 커스터마이징할 수 있어:
-- 기본 ACL 스키마 (MySQL 기준)
CREATE TABLE acl_sid (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
principal BOOLEAN NOT NULL,
sid VARCHAR(100) NOT NULL,
UNIQUE KEY unique_acl_sid (sid, principal)
) ENGINE=InnoDB;
CREATE TABLE acl_class (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
class VARCHAR(100) NOT NULL,
UNIQUE KEY uk_acl_class (class)
) ENGINE=InnoDB;
CREATE TABLE acl_object_identity (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
object_id_class BIGINT UNSIGNED NOT NULL,
object_id_identity BIGINT NOT NULL,
parent_object BIGINT UNSIGNED,
owner_sid BIGINT UNSIGNED,
entries_inheriting BOOLEAN NOT NULL,
UNIQUE KEY uk_acl_object_identity (object_id_class, object_id_identity),
CONSTRAINT fk_acl_object_identity_parent FOREIGN KEY (parent_object) REFERENCES acl_object_identity (id),
CONSTRAINT fk_acl_object_identity_class FOREIGN KEY (object_id_class) REFERENCES acl_class (id),
CONSTRAINT fk_acl_object_identity_owner FOREIGN KEY (owner_sid) REFERENCES acl_sid (id)
) ENGINE=InnoDB;
CREATE TABLE acl_entry (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
acl_object_identity BIGINT UNSIGNED NOT NULL,
ace_order INTEGER NOT NULL,
sid BIGINT UNSIGNED NOT NULL,
mask INTEGER NOT NULL,
granting BOOLEAN NOT NULL,
audit_success BOOLEAN NOT NULL,
audit_failure BOOLEAN NOT NULL,
UNIQUE KEY unique_acl_entry (acl_object_identity, ace_order),
CONSTRAINT fk_acl_entry_object FOREIGN KEY (acl_object_identity) REFERENCES acl_object_identity (id),
CONSTRAINT fk_acl_entry_sid FOREIGN KEY (sid) REFERENCES acl_sid (id)
) ENGINE=InnoDB;
다음으로 ACL 서비스를 설정해야 해:
@Configuration
@EnableMethodSecurity
public class AclSecurityConfig extends GlobalMethodSecurityConfiguration {
@Autowired
private DataSource dataSource;
@Bean
public JdbcMutableAclService aclService() {
return new JdbcMutableAclService(dataSource, lookupStrategy(), aclCache());
}
@Bean
public AclAuthorizationStrategy aclAuthorizationStrategy() {
return new AclAuthorizationStrategyImpl(
new SimpleGrantedAuthority("ROLE_ADMIN")
);
}
@Bean
public PermissionGrantingStrategy permissionGrantingStrategy() {
return new DefaultPermissionGrantingStrategy(new ConsoleAuditLogger());
}
@Bean
public EhCacheBasedAclCache aclCache() {
return new EhCacheBasedAclCache(
aclEhCacheFactoryBean().getObject(),
permissionGrantingStrategy(),
aclAuthorizationStrategy()
);
}
@Bean
public EhCacheFactoryBean aclEhCacheFactoryBean() {
EhCacheFactoryBean factoryBean = new EhCacheFactoryBean();
factoryBean.setCacheName("aclCache");
factoryBean.setMaxEntriesLocalHeap(1000);
factoryBean.setMaxEntriesLocalDisk(10000);
factoryBean.setTimeToLiveSeconds(1200);
factoryBean.setTimeToIdleSeconds(1200);
return factoryBean;
}
@Bean
public LookupStrategy lookupStrategy() {
return new BasicLookupStrategy(
dataSource,
aclCache(),
aclAuthorizationStrategy(),
permissionGrantingStrategy()
);
}
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(new AclPermissionEvaluator(aclService()));
return expressionHandler;
}
}
이 설정은 꽤 복잡해 보이지만, 기본적으로 다음과 같은 구성 요소를 설정하는 거야:
- JdbcMutableAclService: 데이터베이스에서 ACL 정보를 관리하는 서비스
- AclCache: 성능 향상을 위한 ACL 캐시
- LookupStrategy: ACL 정보를 조회하는 전략
- PermissionGrantingStrategy: 권한 부여 결정을 내리는 전략
- AclPermissionEvaluator: hasPermission 표현식을 평가하는 평가자
권한 정의하기
ACL에서 사용할 권한을 정의해야 해. Spring Security ACL은 비트 마스크를 사용해 권한을 표현해:
public class CustomPermission extends BasePermission {
public static final Permission VIEW = new CustomPermission(1 << 0, 'V'); // 1
public static final Permission EDIT = new CustomPermission(1 << 1, 'E'); // 2
public static final Permission CREATE = new CustomPermission(1 << 2, 'C'); // 4
public static final Permission DELETE = new CustomPermission(1 << 3, 'D'); // 8
public static final Permission ADMIN = new CustomPermission(1 << 4, 'A'); // 16
protected CustomPermission(int mask, char code) {
super(mask, code);
}
}
또는 Spring Security가 제공하는 기본 권한을 사용할 수도 있어:
- BasePermission.READ: 읽기 권한 (1)
- BasePermission.WRITE: 쓰기 권한 (2)
- BasePermission.CREATE: 생성 권한 (4)
- BasePermission.DELETE: 삭제 권한 (8)
- BasePermission.ADMINISTRATION: 관리 권한 (16)
ACL 데이터 관리하기
ACL 데이터를 관리하기 위한 서비스를 구현해야 해:
@Service
public class AclManagementService {
@Autowired
private MutableAclService aclService;
@Autowired
private ObjectIdentityRetrievalStrategy identityRetrieval;
public void addPermission(Object domainObject, String username, Permission permission) {
ObjectIdentity oid = identityRetrieval.getObjectIdentity(domainObject);
Sid sid = new PrincipalSid(username);
MutableAcl acl;
try {
acl = (MutableAcl) aclService.readAclById(oid);
} catch (NotFoundException e) {
acl = aclService.createAcl(oid);
}
acl.insertAce(acl.getEntries().size(), permission, sid, true);
aclService.updateAcl(acl);
}
public void removePermission(Object domainObject, String username, Permission permission) {
ObjectIdentity oid = identityRetrieval.getObjectIdentity(domainObject);
Sid sid = new PrincipalSid(username);
MutableAcl acl = (MutableAcl) aclService.readAclById(oid);
List<AccessControlEntry> entries = acl.getEntries();
for (int i = 0; i < entries.size(); i++) {
AccessControlEntry entry = entries.get(i);
if (entry.getSid().equals(sid) && entry.getPermission().equals(permission)) {
acl.deleteAce(i);
break;
}
}
aclService.updateAcl(acl);
}
public void deleteAcl(Object domainObject, boolean deleteChildren) {
ObjectIdentity oid = identityRetrieval.getObjectIdentity(domainObject);
aclService.deleteAcl(oid, deleteChildren);
}
}
이 서비스를 사용해 객체에 대한 권한을 추가, 제거, 삭제할 수 있어! 🔧
ACL 사용하기
이제 Method Security에서 ACL을 사용할 수 있어:
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
@Autowired
private AclManagementService aclManagementService;
@Transactional
public Document createDocument(Document document) {
// 문서 저장
Document savedDocument = documentRepository.save(document);
// 생성자에게 모든 권한 부여
String username = SecurityContextHolder.getContext().getAuthentication().getName();
aclManagementService.addPermission(savedDocument, username, BasePermission.READ);
aclManagementService.addPermission(savedDocument, username, BasePermission.WRITE);
aclManagementService.addPermission(savedDocument, username, BasePermission.DELETE);
aclManagementService.addPermission(savedDocument, username, BasePermission.ADMINISTRATION);
return savedDocument;
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'READ')")
public Document getDocument(Long id) {
return documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
}
@PreAuthorize("hasPermission(#document, 'WRITE')")
public Document updateDocument(Document document) {
return documentRepository.save(document);
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'DELETE')")
public void deleteDocument(Long id) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
documentRepository.delete(document);
aclManagementService.deleteAcl(document, false);
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'ADMINISTRATION')")
public void shareDocument(Long id, String username, String permission) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
Permission perm;
switch (permission.toUpperCase()) {
case "READ":
perm = BasePermission.READ;
break;
case "WRITE":
perm = BasePermission.WRITE;
break;
case "DELETE":
perm = BasePermission.DELETE;
break;
case "ADMIN":
perm = BasePermission.ADMINISTRATION;
break;
default:
throw new IllegalArgumentException("Unknown permission: " + permission);
}
aclManagementService.addPermission(document, username, perm);
}
}
위 예제에서 hasPermission 표현식은 ACL 시스템을 통해 객체별 권한을 검사해. 이를 통해 매우 세밀한 권한 제어가 가능해져! 🎯
커스텀 도메인 객체 보안 구현하기
Spring Security ACL은 강력하지만 복잡하고 오버헤드가 있어. 간단한 경우에는 커스텀 구현이 더 효율적일 수 있어:
@Entity
public class Project {
@Id
@GeneratedValue
private Long id;
private String name;
private String owner;
@ElementCollection
private Set<String> viewers = new HashSet<>();
@ElementCollection
private Set<String> editors = new HashSet<>();
// getters, setters, etc.
}
@Service
public class ProjectPermissionService {
public boolean canView(Project project, String username) {
return project.getOwner().equals(username) ||
project.getViewers().contains(username) ||
project.getEditors().contains(username);
}
public boolean canEdit(Project project, String username) {
return project.getOwner().equals(username) ||
project.getEditors().contains(username);
}
public boolean isOwner(Project project, String username) {
return project.getOwner().equals(username);
}
}
@Service
public class ProjectService {
@Autowired
private ProjectRepository projectRepository;
@Autowired
private ProjectPermissionService permissionService;
@PreAuthorize("@projectPermissionService.canView(#project, authentication.name)")
public Project getProject(Project project) {
return project;
}
@PreAuthorize("@projectPermissionService.canEdit(#project, authentication.name)")
public Project updateProject(Project project) {
return projectRepository.save(project);
}
@PreAuthorize("@projectPermissionService.isOwner(#project, authentication.name)")
public void deleteProject(Project project) {
projectRepository.delete(project);
}
}
이 방식은 권한 정보를 엔티티에 직접 저장하고, 커스텀 서비스를 통해 권한을 검사해. ACL보다 간단하고 직관적이지만, 권한 모델이 복잡해지면 관리하기 어려울 수 있어. 🤔
어떤 방식을 선택할지는 애플리케이션의 요구사항과 복잡도에 따라 달라질 수 있어. 간단한 권한 모델이라면 커스텀 구현이 좋고, 복잡하고 유연한 권한 모델이 필요하다면 ACL이 더 적합할 수 있어! 🧩
다음 섹션에서는 실전 예제를 통해 다양한 시나리오별 구현 방법을 알아보자! 🚀
7. 실전 예제: 다양한 시나리오별 구현 방법 💼
이제 Method Security를 활용한 다양한 실전 시나리오를 살펴보자! 재능넷과 같은 플랫폼에서 발생할 수 있는 여러 상황을 예로 들어볼게. 🎭
시나리오 1: 사용자 관리 시스템
사용자 관리 시스템에서는 다음과 같은 권한 규칙이 필요할 수 있어:
- 관리자만 모든 사용자 정보를 볼 수 있음
- 일반 사용자는 자신의 정보만 볼 수 있음
- 사용자 삭제는 관리자만 가능
- 비밀번호 변경은 본인 또는 관리자만 가능
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public List<UserDto> getAllUsers() {
return userService.getAllUsers();
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public UserDto getUserById(@PathVariable Long id) {
return userService.getUserById(id);
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public UserDto createUser(@RequestBody UserDto userDto) {
return userService.createUser(userDto);
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public UserDto updateUser(@PathVariable Long id, @RequestBody UserDto userDto) {
return userService.updateUser(id, userDto);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
@PutMapping("/{id}/password")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public void changePassword(@PathVariable Long id, @RequestBody PasswordChangeRequest request) {
userService.changePassword(id, request.getOldPassword(), request.getNewPassword());
}
}
여기서 #id == authentication.principal.id 표현식은 요청 경로의 ID가 현재 인증된 사용자의 ID와 일치하는지 확인해. 이를 통해 사용자는 자신의 정보만 조회하고 수정할 수 있어! 🔒
시나리오 2: 재능 거래 플랫폼
재능넷과 같은 재능 거래 플랫폼에서는 다음과 같은 권한 규칙이 필요할 수 있어:
- 판매자는 자신의 재능만 관리할 수 있음
- 구매자는 구매한 재능에만 접근할 수 있음
- 리뷰는 실제 구매자만 작성할 수 있음
- 관리자는 모든 재능과 거래를 관리할 수 있음
@Service
public class TalentService {
@Autowired
private TalentRepository talentRepository;
@Autowired
private OrderRepository orderRepository;
@PreAuthorize("isAuthenticated()")
public List<Talent> getPublicTalents() {
return talentRepository.findByStatus("ACTIVE");
}
@PostAuthorize("returnObject.seller == authentication.principal.username or hasRole('ADMIN')")
public Talent getTalentById(Long id) {
return talentRepository.findById(id)
.orElseThrow(() -> new TalentNotFoundException(id));
}
@PreAuthorize("hasRole('SELLER')")
public Talent createTalent(Talent talent) {
talent.setSeller(SecurityContextHolder.getContext().getAuthentication().getName());
talent.setStatus("PENDING");
return talentRepository.save(talent);
}
@PreAuthorize("#talent.seller == authentication.principal.username or hasRole('ADMIN')")
public Talent updateTalent(Talent talent) {
return talentRepository.save(talent);
}
@PreAuthorize("@talentPermissionService.canPurchase(#talentId, authentication.principal.username)")
public Order purchaseTalent(Long talentId) {
// 재능 구매 로직
Talent talent = talentRepository.findById(talentId)
.orElseThrow(() -> new TalentNotFoundException(talentId));
Order order = new Order();
order.setTalent(talent);
order.setBuyer(SecurityContextHolder.getContext().getAuthentication().getName());
order.setStatus("PAID");
return orderRepository.save(order);
}
}
@Service
public class ReviewService {
@Autowired
private ReviewRepository reviewRepository;
@Autowired
private OrderRepository orderRepository;
@PreAuthorize("@orderPermissionService.hasPurchased(#review.talentId, authentication.principal.username)")
public Review createReview(Review review) {
review.setReviewer(SecurityContextHolder.getContext().getAuthentication().getName());
return reviewRepository.save(review);
}
@PreAuthorize("#review.reviewer == authentication.principal.username or hasRole('ADMIN')")
public Review updateReview(Review review) {
return reviewRepository.save(review);
}
@PreAuthorize("#review.reviewer == authentication.principal.username or hasRole('ADMIN')")
public void deleteReview(Review review) {
reviewRepository.delete(review);
}
}
@Service
public class OrderPermissionService {
@Autowired
private OrderRepository orderRepository;
public boolean hasPurchased(Long talentId, String username) {
return orderRepository.existsByTalentIdAndBuyerAndStatus(talentId, username, "COMPLETED");
}
}
@Service
public class TalentPermissionService {
@Autowired
private TalentRepository talentRepository;
public boolean canPurchase(Long talentId, String username) {
Talent talent = talentRepository.findById(talentId).orElse(null);
if (talent == null) {
return false;
}
// 자신의 재능은 구매할 수 없음
if (talent.getSeller().equals(username)) {
return false;
}
// 활성 상태의 재능만 구매 가능
return "ACTIVE".equals(talent.getStatus());
}
}
이 예제에서는 커스텀 권한 서비스를 활용해 복잡한 비즈니스 규칙을 구현했어. 특히 리뷰 작성 권한 검사에서는 실제 구매 여부를 확인하는 로직이 포함돼 있어! 🛒
시나리오 3: 협업 문서 시스템
여러 사용자가 문서를 공유하고 협업하는 시스템에서는 다음과 같은 권한 규칙이 필요할 수 있어:
- 문서 소유자는 모든 권한을 가짐
- 편집자는 문서를 읽고 수정할 수 있지만 삭제는 불가
- 뷰어는 문서를 읽을 수만 있음
- 문서 공유 권한은 소유자만 가짐
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
@Autowired
private MutableAclService aclService;
@Transactional
public Document createDocument(Document document) {
// 문서 생성자를 소유자로 설정
document.setOwner(SecurityContextHolder.getContext().getAuthentication().getName());
Document savedDocument = documentRepository.save(document);
// ACL 설정
ObjectIdentity oid = new ObjectIdentityImpl(Document.class, savedDocument.getId());
MutableAcl acl = aclService.createAcl(oid);
// 소유자에게 모든 권한 부여
Sid owner = new PrincipalSid(document.getOwner());
acl.setOwner(owner);
acl.insertAce(acl.getEntries().size(), BasePermission.READ, owner, true);
acl.insertAce(acl.getEntries().size(), BasePermission.WRITE, owner, true);
acl.insertAce(acl.getEntries().size(), BasePermission.DELETE, owner, true);
acl.insertAce(acl.getEntries().size(), BasePermission.ADMINISTRATION, owner, true);
aclService.updateAcl(acl);
return savedDocument;
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'READ')")
public Document getDocument(Long id) {
return documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'WRITE')")
public Document updateDocument(Long id, Document document) {
Document existingDocument = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
// 내용 업데이트 로직
existingDocument.setTitle(document.getTitle());
existingDocument.setContent(document.getContent());
return documentRepository.save(existingDocument);
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'DELETE')")
public void deleteDocument(Long id) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
// 문서 삭제
documentRepository.delete(document);
// ACL 삭제
ObjectIdentity oid = new ObjectIdentityImpl(Document.class, id);
aclService.deleteAcl(oid, false);
}
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'ADMINISTRATION')")
public void shareDocument(Long id, String username, String permission) {
// 권한 확인
Permission perm;
switch (permission.toUpperCase()) {
case "READ":
perm = BasePermission.READ;
break;
case "WRITE":
perm = BasePermission.WRITE;
break;
case "DELETE":
perm = BasePermission.DELETE;
break;
case "ADMIN":
perm = BasePermission.ADMINISTRATION;
break;
default:
throw new IllegalArgumentException("Unknown permission: " + permission);
}
// ACL 업데이트
ObjectIdentity oid = new ObjectIdentityImpl(Document.class, id);
MutableAcl acl = (MutableAcl) aclService.readAclById(oid);
Sid sid = new PrincipalSid(username);
acl.insertAce(acl.getEntries().size(), perm, sid, true);
aclService.updateAcl(acl);
}
}
이 예제에서는 Spring Security ACL을 활용해 문서별로 세밀한 권한을 관리하고 있어. ACL을 통해 각 사용자에게 문서별로 다른 권한을 부여할 수 있지! 📄
시나리오 4: 다단계 승인 워크플로우
결재나 승인이 필요한 워크플로우 시스템에서는 다음과 같은 권한 규칙이 필요할 수 있어:
- 요청자는 자신의 요청을 생성하고 취소할 수 있음
- 승인자는 자신에게 할당된 요청만 승인/거부할 수 있음
- 관리자는 모든 요청을 관리할 수 있음
- 요청 상태에 따라 가능한 작업이 달라짐
@Service
public class ApprovalService {
@Autowired
private ApprovalRepository approvalRepository;
@Autowired
private ApprovalWorkflowService workflowService;
@PreAuthorize("isAuthenticated()")
public Approval createApproval(Approval approval) {
// 요청자 설정
approval.setRequester(SecurityContextHolder.getContext().getAuthentication().getName());
approval.setStatus("PENDING");
// 워크플로우에 따라 승인자 할당
String approver = workflowService.getNextApprover(approval);
approval.setCurrentApprover(approver);
return approvalRepository.save(approval);
}
@PreAuthorize("hasRole('ADMIN') or #approval.requester == authentication.principal.username")
public Approval cancelApproval(Approval approval) {
// 취소 가능한 상태인지 확인
if (!approval.getStatus().equals("PENDING")) {
throw new IllegalStateException("Cannot cancel approval in status: " + approval.getStatus());
}
approval.setStatus("CANCELED");
return approvalRepository.save(approval);
}
@PreAuthorize("hasRole('ADMIN') or #approval.currentApprover == authentication.principal.username")
public Approval approveRequest(Approval approval) {
// 승인 가능한 상태인지 확인
if (!approval.getStatus().equals("PENDING")) {
throw new IllegalStateException("Cannot approve request in status: " + approval.getStatus());
}
// 다음 승인자 확인
String nextApprover = workflowService.getNextApprover(approval);
if (nextApprover != null) {
// 다음 승인 단계로 진행
approval.setCurrentApprover(nextApprover);
} else {
// 최종 승인 완료
approval.setStatus("APPROVED");
approval.setCurrentApprover(null);
}
return approvalRepository.save(approval);
}
@PreAuthorize("hasRole('ADMIN') or #approval.currentApprover == authentication.principal.username")
public Approval rejectRequest(Approval approval, String reason) {
// 거부 가능한 상태인지 확인
if (!approval.getStatus().equals("PENDING")) {
throw new IllegalStateException("Cannot reject request in status: " + approval.getStatus());
}
approval.setStatus("REJECTED");
approval.setRejectionReason(reason);
approval.setCurrentApprover(null);
return approvalRepository.save(approval);
}
@PostFilter("hasRole('ADMIN') or filterObject.requester == authentication.principal.username or filterObject.currentApprover == authentication.principal.username")
public List<Approval> getMyApprovals() {
return approvalRepository.findAll();
}
}
이 예제에서는 상태 기반 권한 검사와 역할 기반 필터링을 조합해 복잡한 워크플로우 시스템의 권한을 관리하고 있어. 특히 @PostFilter를 사용해 사용자와 관련된 요청만 반환하도록 했어! 📋
시나리오 5: 멀티테넌트 SaaS 애플리케이션
여러 조직이나 테넌트가 사용하는 SaaS 애플리케이션에서는 다음과 같은 권한 규칙이 필요할 수 있어:
- 사용자는 자신이 속한 조직의 데이터만 접근 가능
- 조직 관리자는 해당 조직의 모든 데이터를 관리 가능
- 시스템 관리자는 모든 조직의 데이터에 접근 가능
@Service
public class MultitenantService {
@Autowired
private DataRepository dataRepository;
@Autowired
private OrganizationRepository organizationRepository;
@PreAuthorize("hasRole('SYSTEM_ADMIN') or @tenantSecurityService.belongsToOrganization(authentication.principal.organizationId, #organizationId)")
public List<Data> getOrganizationData(Long organizationId) {
return dataRepository.findByOrganizationId(organizationId);
}
@PreAuthorize("hasRole('SYSTEM_ADMIN') or (hasRole('ORG_ADMIN') and @tenantSecurityService.isOrganizationAdmin(authentication.principal.id, #organizationId))")
public Organization updateOrganization(Long organizationId, Organization organization) {
Organization existingOrg = organizationRepository.findById(organizationId)
.orElseThrow(() -> new OrganizationNotFoundException(organizationId));
// 조직 정보 업데이트 로직
existingOrg.setName(organization.getName());
existingOrg.setSettings(organization.getSettings());
return organizationRepository.save(existingOrg);
}
@PreAuthorize("hasRole('SYSTEM_ADMIN') or (hasRole('ORG_ADMIN') and @tenantSecurityService.isOrganizationAdmin(authentication.principal.id, #user.organizationId))")
public User createOrganizationUser(User user) {
// 사용자 생성 로직
return userRepository.save(user);
}
@PostFilter("hasRole('SYSTEM_ADMIN') or filterObject.organizationId == authentication.principal.organizationId")
public List<Data> searchData(String keyword) {
return dataRepository.findByKeywordContaining(keyword);
}
}
@Service
public class TenantSecurityService {
@Autowired
private OrganizationRepository organizationRepository;
@Autowired
private UserRepository userRepository;
public boolean belongsToOrganization(Long userOrgId, Long targetOrgId) {
return userOrgId.equals(targetOrgId);
}
public boolean isOrganizationAdmin(Long userId, Long organizationId) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return false;
}
return user.getOrganizationId().equals(organizationId) &&
user.getRoles().contains("ORG_ADMIN");
}
}
이 예제에서는 조직 ID를 기반으로 한 권한 검사를 통해 멀티테넌트 환경에서 데이터 격리를 구현하고 있어. 이를 통해 각 조직은 자신의 데이터만 접근할 수 있게 돼! 🏢
이러한 실전 예제들을 통해 Method Security가 다양한 비즈니스 시나리오에서 어떻게 활용될 수 있는지 살펴봤어. 다음 섹션에서는 Method Security의 성능을 최적화하는 방법에 대해 알아보자! 🚀
8. 성능 최적화 전략 ⚡
Method Security는 강력한 기능을 제공하지만, 잘못 사용하면 성능 문제가 발생할 수 있어. 특히 대규모 애플리케이션에서는 보안 검사로 인한 오버헤드가 누적될 수 있지. 이 섹션에서는 Method Security의 성능을 최적화하는 전략에 대해 알아보자! 🔍
캐싱 활용하기
보안 결정을 캐싱하면 반복적인 권한 검사의 성능을 크게 향상시킬 수 있어:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Bean
public EhCacheBasedAclCache aclCache() {
return new EhCacheBasedAclCache(
aclEhCacheFactoryBean().getObject(),
permissionGrantingStrategy(),
aclAuthorizationStrategy()
);
}
@Bean
public EhCacheFactoryBean aclEhCacheFactoryBean() {
EhCacheFactoryBean factoryBean = new EhCacheFactoryBean();
factoryBean.setCacheName("aclCache");
factoryBean.setMaxEntriesLocalHeap(1000);
factoryBean.setTimeToLiveSeconds(1200);
return factoryBean;
}
// 기타 필요한 빈 설정
}
또한 커스텀 권한 검사 로직에서도 캐싱을 적용할 수 있어:
@Service
public class CachedPermissionService {
@Autowired
private PermissionRepository permissionRepository;
@Cacheable("permissionCache")
public boolean hasPermission(Long userId, Long resourceId, String permission) {
// 데이터베이스에서 권한 조회
return permissionRepository.existsByUserIdAndResourceIdAndPermission(
userId, resourceId, permission
);
}
@CacheEvict(value = "permissionCache", key = "#userId + '_' + #resourceId")
public void grantPermission(Long userId, Long resourceId, String permission) {
// 권한 부여 로직
Permission perm = new Permission();
perm.setUserId(userId);
perm.setResourceId(resourceId);
perm.setPermission(permission);
permissionRepository.save(perm);
}
}
캐싱을 사용할 때는 캐시 무효화를 적절히 처리해야 해. 권한이 변경되면 관련 캐시를 삭제하거나 업데이트해야 최신 권한 정보가 반영돼! ♻️
데이터베이스 쿼리 최적화
권한 검사에 필요한 데이터베이스 쿼리를 최적화하면 성능을 크게 향상시킬 수 있어:
- 인덱스 활용: 권한 테이블에 적절한 인덱스를 생성해 조회 성능을 향상시켜
- 조인 최소화: 권한 검사에 필요한 조인을 최소화하고, 필요한 경우 뷰나 비정규화를 고려해
- 배치 로딩: N+1 쿼리 문제를 방지하기 위해 배치 로딩 기법을 활용해
// 권한 테이블에 복합 인덱스 생성 예시 (MySQL)
CREATE INDEX idx_user_resource_permission ON permissions (user_id, resource_id, permission);
// JPA에서 배치 로딩 활용 예시
@Entity
public class Project {
// ...
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "project_members")
private Set<User> members;
// ...
}
또한 권한 검사를 위한 전용 쿼리 메소드를 만들어 불필요한 데이터 로딩을 방지할 수 있어:
public interface ProjectRepository extends JpaRepository<Project, Long> {
// 프로젝트 전체를 로드하지 않고 소유자만 확인
@Query("SELECT p.owner FROM Project p WHERE p.id = :projectId")
String findOwnerById(@Param("projectId") Long projectId);
// 멤버십 여부만 확인
@Query("SELECT COUNT(p) > 0 FROM Project p JOIN p.members m WHERE p.id = :projectId AND m.username = :username")
boolean isMember(@Param("projectId") Long projectId, @Param("username") String username);
}
권한 검사 최적화
권한 검사 자체를 최적화하는 방법도 있어:
- 계층적 권한 모델 활용: 상위 권한이 하위 권한을 포함하도록 설계해 중복 검사 방지
- 권한 검사 순서 최적화: 가장 빠르고 간단한 검사를 먼저 수행
- 불필요한 검사 제거: 컨텍스트에 따라 불필요한 검사 생략
@Service
public class OptimizedSecurityService {
@PreAuthorize("hasRole('ADMIN') or @optimizedPermissionService.hasAccess(#resourceId)")
public Resource getResource(Long resourceId) {
return resourceRepository.findById(resourceId)
.orElseThrow(() -> new ResourceNotFoundException(resourceId));
}
}
@Service
public class OptimizedPermissionService {
public boolean hasAccess(Long resourceId) {
// 1. 캐시에서 먼저 확인 (가장 빠름)
Boolean cachedResult = permissionCache.get(getCacheKey(resourceId));
if (cachedResult != null) {
return cachedResult;
}
// 2. 간단한 소유권 확인 (비교적 빠름)
String username = SecurityContextHolder.getContext().getAuthentication().getName();
if (resourceRepository.isOwner(resourceId, username)) {
permissionCache.put(getCacheKey(resourceId), true);
return true;
}
// 3. 공개 리소스 확인 (비교적 빠름)
if (resourceRepository.isPublic(resourceId)) {
permissionCache.put(getCacheKey(resourceId), true);
return true;
}
// 4. 복잡한 권한 검사 (가장 느림)
boolean result = permissionRepository.hasComplexPermission(resourceId, username);
permissionCache.put(getCacheKey(resourceId), result);
return result;
}
private String getCacheKey(Long resourceId) {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
return username + "_" + resourceId;
}
}
이 예제에서는 가장 빠른 검사부터 순차적으로 수행하고, 긍정적인 결과가 나오면 즉시 반환해. 이를 통해 불필요한 복잡한 검사를 피할 수 있어! 🚀
메소드 보안 적용 범위 최적화
모든 메소드에 보안을 적용하는 것이 아니라, 정말 필요한 곳에만 선택적으로 적용하는 것이 좋아:
- 서비스 계층에 집중: 컨트롤러나 리포지토리보다는 서비스 계층에 보안 적용
- 중복 검사 방지: URL 보안과 메소드 보안이 중복되지 않도록 설계
- 내부 메소드 호출 고려: 같은 클래스 내 메소드 호출 시 보안이 적용되지 않음을 인지
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
@Autowired
private ProjectService projectService;
// URL 보안으로 인증된 사용자만 접근 가능하도록 설정
// 세부 권한은 서비스 계층에서 처리
@GetMapping("/{id}")
public Project getProject(@PathVariable Long id) {
return projectService.getProject(id);
}
}
@Service
public class ProjectService {
@Autowired
private ProjectRepository projectRepository;
// 세부 권한 검사는 서비스 계층에서 수행
@PreAuthorize("hasRole('ADMIN') or @projectPermissionService.canView(#id, authentication.principal.username)")
public Project getProject(Long id) {
return projectRepository.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
}
// 내부 호출용 메소드는 보안 애노테이션 없이 직접 구현
private boolean isProjectOwner(Long projectId, String username) {
String owner = projectRepository.findOwnerById(projectId);
return username.equals(owner);
}
}
비동기 처리 최적화
권한 검사가 오래 걸리는 경우, 비동기 처리를 활용할 수 있어:
@Service
public class AsyncSecurityService {
@Autowired
private ExecutorService executorService;
public CompletableFuture<List<Resource>> getResourcesAsync() {
return CompletableFuture.supplyAsync(() -> {
// SecurityContext 전파
SecurityContext context = SecurityContextHolder.getContext();
try {
SecurityContextHolder.setContext(context);
// 권한 검사가 포함된 메소드 호출
return getResourcesWithPermissionCheck();
} finally {
SecurityContextHolder.clearContext();
}
}, executorService);
}
@PreAuthorize("isAuthenticated()")
@PostFilter("hasRole('ADMIN') or filterObject.owner == authentication.principal.username")
public List<Resource> getResourcesWithPermissionCheck() {
return resourceRepository.findAll();
}
}
비동기 처리 시에는 SecurityContext를 올바르게 전파하는 것이 중요해! 그렇지 않으면 권한 검사가 실패할 수 있어. 🧵
벌크 연산 최적화
대량의 데이터를 처리할 때는 개별 항목마다 권한을 검사하는 것보다 벌크 연산을 활용하는 것이 효율적일 수 있어:
@Service
public class BulkOperationService {
@Autowired
private ResourceRepository resourceRepository;
// 비효율적인 방식: 각 항목마다 권한 검사
@PostFilter("hasRole('ADMIN') or filterObject.owner == authentication.principal.username")
public List<Resource> getAllResourcesInefficient() {
return resourceRepository.findAll(); // 모든 리소스를 로드한 후 필터링
}
// 효율적인 방식: 쿼리 단계에서 필터링
public List<Resource> getAllResourcesEfficient() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
boolean isAdmin = SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
if (isAdmin) {
return resourceRepository.findAll();
} else {
return resourceRepository.findByOwner(username);
}
}
// 벌크 업데이트 최적화
@PreAuthorize("hasRole('ADMIN')")
public void bulkUpdateStatus(String status) {
// 단일 쿼리로 모든 리소스 업데이트
resourceRepository.updateAllStatus(status);
}
}
가능하면 @PostFilter 대신 쿼리 단계에서 필터링하는 것이 성능에 유리해. @PostFilter는 모든 데이터를 메모리에 로드한 후 필터링하기 때문에 대용량 데이터에서는 성능 문제가 발생할 수 있어! 📊
프로파일링 및 모니터링
성능 최적화를 위해서는 먼저 현재 성능을 측정하고 병목 지점을 찾아야 해:
- AOP 프로파일링: 보안 검사에 소요되는 시간 측정
- 데이터베이스 쿼리 모니터링: 권한 검사 관련 쿼리의 성능 분석
- 캐시 히트율 모니터링: 캐시 효율성 측정
@Aspect
@Component
public class SecurityProfilingAspect {
private static final Logger logger = LoggerFactory.getLogger(SecurityProfilingAspect.class);
@Around("@annotation(org.springframework.security.access.prepost.PreAuthorize)")
public Object profilePreAuthorize(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long elapsedTime = System.currentTimeMillis() - start;
if (elapsedTime > 100) { // 100ms 이상 걸리는 경우 로깅
logger.warn("Slow security check: {} took {}ms",
joinPoint.getSignature(), elapsedTime);
}
}
}
}
이러한 프로파일링을 통해 성능 병목 지점을 식별하고 집중적으로 최적화할 수 있어! 📈
성능 최적화는 재능넷과 같은 대규모 플랫폼에서 특히 중요해. 사용자가 많고 데이터가 방대할수록 작은 성능 개선도 전체 시스템에 큰 영향을 미칠 수 있기 때문이지! 다음 섹션에서는 Method Security를 테스트하는 전략에 대해 알아보자! 🚀
9. 테스트 전략 🧪
Method Security가 제대로 작동하는지 확인하기 위한 테스트는 매우 중요해. 보안은 애플리케이션의 핵심 요구사항이므로 철저한 테스트가 필요하지! 이 섹션에서는 Method Security를 효과적으로 테스트하는 전략에 대해 알아보자! 🔍
테스트 환경 설정
먼저 Spring Security 테스트를 위한 의존성을 추가해야 해:
// Gradle
testImplementation 'org.springframework.security:spring-security-test'
// Maven
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
그리고 테스트 클래스에 필요한 애노테이션을 설정해:
@SpringBootTest
@AutoConfigureMockMvc
public class MethodSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserService userService;
// 테스트 메소드들...
}
인증된 사용자로 테스트하기
Spring Security Test는 테스트에서 인증된 사용자를 시뮬레이션할 수 있는 여러 방법을 제공해:
1. @WithMockUser 사용하기
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
public void whenAdminAccessAdminMethod_thenSucceeds() {
// 관리자 권한이 필요한 메소드 호출
List<User> users = userService.getAllUsers();
// 검증
assertNotNull(users);
}
@Test
@WithMockUser(username = "user", roles = {"USER"})
public void whenUserAccessAdminMethod_thenFails() {
// 관리자 권한이 필요한 메소드 호출 시 예외 발생 예상
assertThrows(AccessDeniedException.class, () -> {
userService.getAllUsers();
});
}
@WithMockUser는 간단한 역할 기반 테스트에 적합해. 하지만 실제 사용자 객체를 사용하지 않기 때문에 세부적인 권한 검사에는 제한이 있어! 🧩
2. @WithUserDetails 사용하기
@Test
@WithUserDetails("admin@example.com") // UserDetailsService에서 로드할 사용자명
public void whenAdminAccessAdminMethod_thenSucceeds() {
// 관리자 권한이 필요한 메소드 호출
List<User> users = userService.getAllUsers();
// 검증
assertNotNull(users);
}
@WithUserDetails는 실제 UserDetailsService를 사용해 사용자를 로드하기 때문에 더 현실적인 테스트가 가능해. 하지만 테스트 전에 해당 사용자가 데이터베이스에 존재해야 한다는 제약이 있어! 🔄
3. @WithSecurityContext 커스텀 애노테이션 만들기
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "user";
String name() default "Test User";
String[] roles() default {"USER"};
long id() default 1L;
long organizationId() default 1L;
}
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails principal = new CustomUserDetails(
customUser.username(),
"password",
customUser.name(),
customUser.id(),
customUser.organizationId(),
AuthorityUtils.createAuthorityList(
Arrays.stream(customUser.roles())
.map(role -> "ROLE_" + role)
.toArray(String[]::new)
)
);
Authentication auth = new UsernamePasswordAuthenticationToken(
principal, "password", principal.getAuthorities());
context.setAuthentication(auth);
return context;
}
}
@Test
@WithMockCustomUser(username = "manager", roles = {"MANAGER"}, id = 2L, organizationId = 3L)
public void whenManagerAccessOwnOrgData_thenSucceeds() {
// 테스트 로직
List<Data> data = dataService.getOrganizationData(3L);
// 검증
assertNotNull(data);
}
커스텀 애노테이션을 만들면 테스트에 필요한 모든 사용자 속성을 정의할 수 있어. 이 방식은 가장 유연하고 강력하지만, 구현이 복잡하다는 단점이 있어! 🛠️
컨트롤러 테스트
컨트롤러 레벨에서 Method Security를 테스트하려면 MockMvc를 사용할 수 있어:
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
public void whenAdminAccessAdminEndpoint_thenSucceeds() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@WithMockUser(username = "user", roles = {"USER"})
public void whenUserAccessAdminEndpoint_thenFails() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isForbidden());
}
이 테스트는 HTTP 요청 수준에서 보안을 검증해. 실제 사용자 경험과 가장 유사한 방식으로 테스트할 수 있어! 🌐
서비스 레이어 테스트
서비스 레이어에서 Method Security를 직접 테스트하려면:
@Test
@WithMockUser(username = "owner", roles = {"USER"})
public void whenOwnerAccessOwnProject_thenSucceeds() {
// 테스트 데이터 설정
Project project = new Project();
project.setId(1L);
project.setName("Test Project");
project.setOwner("owner");
when(projectRepository.findById(1L)).thenReturn(Optional.of(project));
// 테스트 실행
Project result = projectService.getProject(1L);
// 검증
assertEquals("Test Project", result.getName());
}
@Test
@WithMockUser(username = "other", roles = {"USER"})
public void whenNonOwnerAccessPrivateProject_thenFails() {
// 테스트 데이터 설정
Project project = new Project();
project.setId(1L);
project.setName("Private Project");
project.setOwner("owner");
project.setPublic(false);
when(projectRepository.findById(1L)).thenReturn(Optional.of(project));
// 테스트 실행 및 예외 검증
assertThrows(AccessDeniedException.class, () -> {
projectService.getProject(1L);
});
}
서비스 레이어 테스트는 비즈니스 로직과 보안 로직의 통합을 검증하는 데 유용해! 🧪
SpEL 표현식 테스트
복잡한 SpEL 표현식은 별도로 테스트하는 것이 좋아:
@Test
public void testSpelExpression() {
// 테스트 데이터 설정
Project project = new Project();
project.setOwner("owner");
project.setPublic(true);
Authentication authentication = new UsernamePasswordAuthenticationToken(
"user", "password", AuthorityUtils.createAuthorityList("ROLE_USER"));
SecurityContextHolder.getContext().setAuthentication(authentication);
// SpEL 표현식 평가
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = new StandardEvaluationContext();
context.setVariable("project", project);
context.setVariable("authentication", authentication);
Boolean result = parser.parseExpression(
"#project.owner == authentication.name or #project.public == true or hasRole('ADMIN')"
).getValue(context, Boolean.class);
// 검증
assertTrue(result);
}
이 방식으로 SpEL 표현식만 독립적으로 테스트할 수 있어. 복잡한 표현식의 경우 이 방법이 디버깅에 매우 유용해! 🔍
권한 평가자 테스트
커스텀 PermissionEvaluator를 테스트하려면:
@Test
public void testCustomPermissionEvaluator() {
// 테스트 데이터 설정
Document document = new Document();
document.setId(1L);
document.setOwner("owner");
Authentication authentication = new UsernamePasswordAuthenticationToken(
"owner", "password", AuthorityUtils.createAuthorityList("ROLE_USER"));
// PermissionEvaluator 테스트
CustomPermissionEvaluator evaluator = new CustomPermissionEvaluator();
boolean result = evaluator.hasPermission(
authentication, document, "READ");
// 검증
assertTrue(result);
}
이 테스트는 권한 평가 로직만 독립적으로 검증해. 복잡한 권한 로직이 있는 경우 유닛 테스트로 철저히 검증하는 것이 중요해! 🧩
통합 테스트
실제 환경과 유사한 통합 테스트도 중요해:
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.datasource.url=jdbc:h2:mem:testdb"
})
public class SecurityIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private ProjectRepository projectRepository;
@BeforeEach
public void setup() {
// 테스트 데이터 설정
User admin = new User();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("password"));
admin.setRoles(Set.of("ADMIN"));
userRepository.save(admin);
User user = new User();
user.setUsername("user");
user.setPassword(passwordEncoder.encode("password"));
user.setRoles(Set.of("USER"));
userRepository.save(user);
Project project = new Project();
project.setName("Test Project");
project.setOwner("user");
project.setPublic(false);
projectRepository.save(project);
}
@Test
public void whenAdminAccessAnyProject_thenSucceeds() throws Exception {
mockMvc.perform(get("/api/projects/1")
.with(user("admin").password("password").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Test Project"));
}
@Test
public void whenOwnerAccessOwnProject_thenSucceeds() throws Exception {
mockMvc.perform(get("/api/projects/1")
.with(user("user").password("password").roles("USER")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Test Project"));
}
@Test
public void whenNonOwnerAccessPrivateProject_thenFails() throws Exception {
mockMvc.perform(get("/api/projects/1")
.with(user("other").password("password").roles("USER")))
.andExpect(status().isForbidden());
}
}
통합 테스트는 실제 데이터베이스와 보안 구성을 사용해 전체 시스템을 검증해. 이는 실제 환경에서 발생할 수 있는 문제를 미리 발견하는 데 도움이 돼! 🌍
테스트 모범 사례
Method Security 테스트를 위한 몇 가지 모범 사례를 정리해보자:
- 긍정 테스트와 부정 테스트 모두 작성: 권한이 있는 경우와 없는 경우 모두 테스트
- 경계 조건 테스트: 권한의 경계에 있는 상황을 테스트
- 다양한 역할 조합 테스트: 여러 역할을 가진 사용자 시나리오 테스트
- 실제 데이터 구조 사용: 실제와 유사한 데이터 구조로 테스트
- 격리된 테스트 환경 사용: 테스트 간 상호 영향 방지
철저한 테스트는 보안 취약점을 미리 발견하고 방지하는 데 큰 도움이 돼. 재능넷과 같은 플랫폼에서는 사용자 데이터와 콘텐츠를 보호하기 위해 보안 테스트가 특히 중요하지! 다음 섹션에서는 보안 취약점을 방지하는 방법에 대해 알아보자! 🚀
10. 보안 취약점 방지하기 🛡️
Method Security를 사용하더라도 잘못 구현하면 보안 취약점이 발생할 수 있어. 이 섹션에서는 Method Security 사용 시 주의해야 할 보안 취약점과 이를 방지하는 방법에 대해 알아보자! 🔒
권한 검사 우회 취약점
가장 흔한 취약점 중 하나는 권한 검사가 우회되는 경우야:
1. 같은 클래스 내 메소드 호출
@Service
public class InsecureService {
// 이 메소드는 보안 검사를 수행함
@PreAuthorize("hasRole('ADMIN')")
public void secureMethod() {
// 보안이 필요한 로직
}
// 이 메소드는 보안 검사 없음
public void publicMethod() {
// 내부적으로 보안 메소드 직접 호출 - 보안 검사가 우회됨!
secureMethod();
}
}
이 문제를 해결하려면 자체 호출을 피하고 프록시를 통한 호출을 강제해야 해:
@Service
public class SecureService {
@Autowired
private Self self;
@PreAuthorize("hasRole('ADMIN')")
public void secureMethod() {
// 보안이 필요한 로직
}
public void publicMethod() {
// 프록시를 통해 호출하여 보안 검사 적용
self.secureMethod();
}
@Component
public static class Self {
@Autowired
private SecureService secureService;
public void secureMethod() {
secureService.secureMethod();
}
}
}
또는 더 간단하게 ApplicationContext에서 자신을 주입받는 방법도 있어:
@Service
public class SecureService {
@Autowired
private ApplicationContext context;
@PreAuthorize("hasRole('ADMIN')")
public void secureMethod() {
// 보안이 필요한 로직
}
public void publicMethod() {
// 프록시를 통해 호출하여 보안 검사 적용
SecureService proxy = context.getBean(SecureService.class);
proxy.secureMethod();
}
}
2. 누락된 권한 검사
일부 메소드에 권한 검사를 누락하면 보안 취약점이 발생할 수 있어:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public User getUser(@PathVariable Long id) {
return userService.getUser(id);
}
// 권한 검사 누락! 누구나 모든 사용자 정보를 수정할 수 있음
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.updateUser(id, user);
}
}
이 문제를 해결하려면 모든 보안 민감 메소드에 권한 검사를 추가하고, 정기적인 보안 검토를 수행해야 해:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public User getUser(@PathVariable Long id) {
return userService.getUser(id);
}
// 권한 검사 추가
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.updateUser(id, user);
}
}
객체 레벨 보안 취약점
객체 레벨 보안을 잘못 구현하면 심각한 취약점이 발생할 수 있어:
1. 객체 변조 취약점
@PutMapping("/projects/{id}")
@PreAuthorize("#project.owner == authentication.principal.username")
public Project updateProject(@PathVariable Long id, @RequestBody Project project) {
// 클라이언트가 제공한 project 객체를 그대로 사용
// 소유자 필드를 변조하여 권한 검사를 우회할 수 있음!
return projectService.save(project);
}
이 문제를 해결하려면 신뢰할 수 없는 입력을 사용하지 않고 서버에서 객체를 로드해야 해:
@PutMapping("/projects/{id}")
public Project updateProject(@PathVariable Long id, @RequestBody ProjectDto projectDto) {
// 데이터베이스에서 기존 프로젝트 로드
Project project = projectRepository.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
// 권한 검사 (실제 소유자 정보 사용)
if (!project.getOwner().equals(SecurityContextHolder.getContext().getAuthentication().getName())
&& !hasRole("ADMIN")) {
throw new AccessDeniedException("You don't have permission to update this project");
}
// 안전한 필드만 업데이트
project.setName(projectDto.getName());
project.setDescription(projectDto.getDescription());
// owner 필드는 업데이트하지 않음
return projectRepository.save(project);
}
2. 객체 참조 노출 취약점
@GetMapping("/documents/{id}")
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'READ')")
public Document getDocument(@PathVariable Long id) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
// 문서에 포함된 민감한 참조가 노출될 수 있음
return document;
}
이 문제를 해결하려면 응답 객체를 필터링하거나 DTO로 변환해야 해:
@GetMapping("/documents/{id}")
@PreAuthorize("hasPermission(#id, 'com.example.Document', 'READ')")
public DocumentDto getDocument(@PathVariable Long id) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
// 민감한 정보를 제외한 DTO로 변환
DocumentDto dto = new DocumentDto();
dto.setId(document.getId());
dto.setTitle(document.getTitle());
dto.setContent(document.getContent());
// 민감한 참조는 포함하지 않음
return dto;
}
SpEL 표현식 취약점
잘못 작성된 SpEL 표현식은 보안 취약점을 일으킬 수 있어:
1. 잘못된 비교 연산
// 잘못된 표현식 - null 값 처리 부재
@PreAuthorize("#project.owner == authentication.principal.username")
public void updateProject(Project project) {
// project가 null이면 NullPointerException 발생
// 또는 authentication이 null이면 보안 검사 실패
}
이 문제를 해결하려면 null 안전 비교와 방어적 코딩을 사용해야 해:
// 개선된 표현식 - null 안전 처리
@PreAuthorize("#project != null and #project.owner == authentication?.principal?.username")
public void updateProject(Project project) {
// null 안전 비교
}
2. 과도한 권한 부여
// 잘못된 표현식 - 논리 연산자 오용
@PreAuthorize("hasRole('ADMIN') or hasRole('USER')")
public List<User> getAllUsers() {
// USER 역할만으로도 모든 사용자 정보에 접근 가능!
return userRepository.findAll();
}
이 문제를 해결하려면 정확한 권한 표현식을 사용해야 해:
// 개선된 표현식 - 정확한 권한 검사
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
// 관리자만 접근 가능
return userRepository.findAll();
}
데이터 노출 취약점
권한 검사 후에도 민감한 데이터가 노출될 수 있어:
1. 과도한 데이터 노출
@GetMapping("/users")
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
// 비밀번호 해시 등 민감 정보가 포함된 전체 사용자 객체 반환
return userRepository.findAll();
}
이 문제를 해결하려면 응답 데이터를 필터링해야 해:
@GetMapping("/users")
@PreAuthorize("hasRole('ADMIN')")
public List<UserDto> getAllUsers() {
// 민감 정보를 제외한 DTO로 변환
return userRepository.findAll().stream()
.map(user -> {
UserDto dto = new UserDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
// 비밀번호 해시 등 민감 정보는 포함하지 않음
return dto;
})
.collect(Collectors.toList());
}
2. 간접 데이터 노출
@GetMapping("/projects/{id}/collaborators")
@PreAuthorize("hasPermission(#id, 'com.example.Project', 'READ')")
public List<User> getProjectCollaborators(@PathVariable Long id) {
Project project = projectRepository.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
// 프로젝트 접근 권한이 있다고 해서 모든 협업자의 상세 정보에
// 접근할 수 있는 것은 아님!
return project.getCollaborators();
}
이 문제를 해결하려면 연관 데이터에 대한 별도 권한 검사를 수행해야 해:
@GetMapping("/projects/{id}/collaborators")
@PreAuthorize("hasPermission(#id, 'com.example.Project', 'READ')")
public List<CollaboratorDto> getProjectCollaborators(@PathVariable Long id) {
Project project = projectRepository.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
// 협업자 정보를 제한적으로 제공
return project.getCollaborators().stream()
.map(user -> {
CollaboratorDto dto = new CollaboratorDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
// 최소한의 정보만 제공
return dto;
})
.collect(Collectors.toList());
}
보안 모범 사례
Method Security를 사용할 때 따라야 할 몇 가지 보안 모범 사례를 정리해보자:
- 최소 권한 원칙 적용: 필요한 최소한의 권한만 부여
- 다중 방어 계층 구현: URL 보안과 Method 보안을 함께 사용
- 입력 데이터 검증: 모든 입력 데이터를 신뢰하지 말고 검증
- 안전한 객체 변환: DTO 패턴을 사용해 안전하게 데이터 변환
- 명시적 거부: 기본적으로 접근을 거부하고 명시적으로 허용
- 보안 로그 기록: 중요한 보안 이벤트 로깅
- 정기적인 보안 검토: 코드와 권한 설정 정기 검토
- 보안 테스트 자동화: 보안 테스트를 CI/CD 파이프라인에 통합
@Component
public class SecurityAuditAspect {
private static final Logger logger = LoggerFactory.getLogger(SecurityAuditAspect.class);
@AfterThrowing(
pointcut = "@annotation(org.springframework.security.access.prepost.PreAuthorize)",
throwing = "ex"
)
public void logSecurityException(JoinPoint joinPoint, AccessDeniedException ex) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth != null ? auth.getName() : "anonymous";
logger.warn(
"Security violation: User '{}' attempted to access '{}' and was denied: {}",
username,
joinPoint.getSignature().toShortString(),
ex.getMessage()
);
}
}
이러한 모범 사례를 따르면 Method Security를 더 안전하게 사용할 수 있어. 재능넷과 같은 플랫폼에서는 사용자 데이터와 콘텐츠를 보호하기 위해 이러한 보안 모범 사례가 특히 중요하지! 🔒
지금까지 Spring Security Method Security를 활용한 세밀한 권한 제어에 대해 자세히 알아봤어. 이 지식을 활용하면 재능넷과 같은 복잡한 애플리케이션에서도 강력하고 유연한 보안 시스템을 구축할 수 있을 거야! 🚀
마치며 🎯
이번 글에서는 Spring Security의 Method Security를 활용한 세밀한 권한 제어 방법에 대해 깊이 있게 알아봤어. 기본 설정부터 복잡한 권한 로직 구현, 성능 최적화, 테스트 전략, 보안 취약점 방지까지 다양한 측면을 다뤘지!
Method Security는 코드 레벨에서 세밀한 보안을 구현할 수 있게 해주는 강력한 도구야. 특히 재능넷과 같이 다양한 사용자와 콘텐츠가 있는 플랫폼에서는 이러한 세밀한 권한 제어가 필수적이지. 사용자별로 접근 가능한 기능과 데이터를 정확하게 제한함으로써 안전하고 신뢰할 수 있는 서비스를 제공할 수 있어! 🔒
Spring Security Method Security를 마스터하면 보안과 사용자 경험 사이의 균형을 맞추는 데 큰 도움이 될 거야. 필요한 사용자에게는 필요한 기능과 데이터에 접근할 수 있게 하면서, 동시에 중요한 정보와 기능은 보호할 수 있으니까!
이 글이 너의 Spring Security 여정에 도움이 되길 바라! 더 안전하고 견고한 애플리케이션을 만들어 나가는 데 이 지식이 유용하게 활용되길 바랄게. 😊
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.