목차
기능을 하나 추가할 때마다 if (user.isAdmin())이 코드 여기저기에 늘어난다.
권한 검사가 코드 곳곳에 흩어졌다
서비스가 자라면 대개 이런 코드가 쌓인다.
public void deletePost(Long postId, User user) {
if (!user.isAdmin() && !"MANAGER".equals(user.getGrade())) {
throw new AccessDeniedException();
}
postRepository.delete(postId);
}
public Report exportReport(User user) {
if (!user.isAdmin() && user.getDeptCode() != 10) { // 조건이 조금 다르다
throw new AccessDeniedException();
}
return reportService.build();
}문제가 하나가 아니다.
- 같은 뜻의 조건이 조금씩 다르게 적혀 있다.
isAdmin()과"MANAGER".equals(...)가 어떤 관계인지 코드만 봐서는 모른다. - “누가 무엇을 할 수 있나”를 한눈에 볼 수 없다. 물어보려면 코드를 전수 조사해야 한다.
- 정책이 바뀌면 다 찾아 고쳐야 한다. 하나 빠뜨리면 거기만 조용히 뚫린다.
인증은 답이 하나다. “이 요청의 주인은 7번 사용자다.” 인가는 그렇지 않다. 경로마다, 리소스마다, 상황마다 답이 다르다. 그래서 표현할 방법을 정해두지 않으면 이렇게 흩어진다.
이 글은 두 가지를 정한다. 권한을 무엇으로 적을 것인가, 그리고 어디서 검사할 것인가.
권한은 결국 한 문장이다
흩어진 조건들을 정리하려면 먼저 공통 모양을 찾아야 한다. 위 코드들이 실제로 묻는 것은 전부 이 문장이다.
누가(주체) - 무엇을(대상) - 어떻게 할 수 있나(행위)
| 조건 | 주체 | 행위 | 대상 |
|---|---|---|---|
| 관리자만 게시글 삭제 | 관리자 | 삭제 | 게시글 |
| 본인 글만 수정 | 작성자 본인 | 수정 | 그 게시글 |
| 10번 부서만 보고서 조회 | 10번 부서원 | 조회 | 보고서 |
셋 다 같은 틀이다. 그러면 권한 모델을 고르는 문제는 “이 문장의 주체 자리를 무엇으로 채울 것인가” 로 좁혀진다.
가장 단순한 답은 사용자를 직접 적는 것이다. “7번 사용자는 게시글을 삭제할 수 있다.” 동작은 하지만 금방 무너진다. 사용자가 만 명이면 규칙이 만 줄이 되고, 사람이 들어오고 나갈 때마다 규칙을 손대야 한다.
그래서 사이에 한 겹을 끼운다.
역할로 묶는다
사용자와 권한 사이에 역할(role)을 둔다. 사용자에게 역할을 주고, 권한은 역할에 붙인다. 이 방식을 RBAC(Role-Based Access Control) 라고 부른다.
사람이 늘어도 역할 수는 안 늘어난다. 새 직원이 들어오면 역할 하나만 주면 되고, 정책이 바뀌면 역할에 붙은 권한만 고치면 그 역할을 가진 전원에게 반영된다.
역할과 권한을 나눠 두는 것도 흔하다.
| 개념 | 예시 | 성격 |
|---|---|---|
| 역할(role) | ADMIN, MANAGER, USER | 사람에게 붙인다. 조직의 언어 |
| 권한(permission) | post:delete, report:read | 기능에 붙인다. 시스템의 언어 |
이렇게 두면 “관리자는 무엇을 할 수 있나”가 데이터로 존재한다. 코드를 뒤지지 않고 테이블 하나로 답할 수 있다.
권한 이름은 대상:행위 형태로 통일하면 나중이 편하다. post:delete, report:read처럼. 이름만 봐도 앞의 “누가-무엇을-어떻게” 문장의 뒤 두 자리가 읽히고, 목록으로 뽑았을 때 대상별로 묶인다. canDeletePost 같은 이름은 하나씩 보면 읽기 좋지만 모아놓으면 정렬도 안 되고 규칙도 안 보인다.
역할이 부족해지는 순간
RBAC로 대부분이 해결된다. 그런데 어느 시점에 딱 막힌다.
“본인이 쓴 글만 수정할 수 있다.”
이 규칙에는 역할이 안 나온다. 김철수가 USER 역할이라는 사실만으로는 판단이 안 된다. 어떤 글을 수정하려는지 알아야 답이 나온다.
두 질문의 성격이 다르다. 앞은 주체만 보면 되고, 뒤는 주체와 대상을 함께 봐야 한다. 이걸 억지로 역할로 풀려고 하면 이상해진다. 글 하나마다 역할을 만들 수는 없다.
같은 성격의 규칙이 실무에 널려 있다.
- 자기 부서의 문서만 조회
- 근무 시간에만 결제 승인
- 결제 금액이 100만 원 미만이면 팀장, 이상이면 임원
전부 요청 시점의 값에 따라 답이 갈린다. 역할은 사람에게 미리 붙여둔 라벨이라 이런 규칙을 담지 못한다.
속성으로 판단한다
그래서 다른 축이 나온다. 미리 붙여둔 라벨 대신 판단 시점의 속성들을 조합해서 결정하는 방식이다. ABAC(Attribute-Based Access Control) 라고 부른다.
| 어디의 속성 | 예 |
|---|---|
| 주체 | 부서, 직급, 소속 |
| 대상 | 작성자, 상태, 금액 |
| 환경 | 시각, 접속 위치 |
앞의 규칙을 이 언어로 다시 쓰면 이렇게 된다.
// "본인 글만 수정" - 주체의 id와 대상의 작성자를 비교한다
public boolean canEdit(User user, Post post) {
return post.getAuthorId().equals(user.getId())
|| user.hasRole("ADMIN");
}두 방식이 대립하는 게 아니라는 점이 여기서 드러난다. 위 코드에도 역할 검사가 그대로 들어 있다. 실무에서는 대체로 이렇게 쓴다.
- 경로 단위의 굵은 검사는 역할로. “관리자 화면은
ADMIN만” - 리소스 단위의 세밀한 검사는 속성으로. “이 글의 작성자거나 관리자”
속성 조합은 표현력이 좋은 만큼 읽기 어려워지기 쉽다. 조건이 다섯 개 얽힌 규칙은 만든 사람도 6개월 뒤에 못 읽고, “이 사람이 왜 막혔는지”를 설명할 수 없게 된다. 설명할 수 없는 권한 규칙은 운영에서 사고를 부른다. 역할로 되는 것은 역할로 두고, 속성은 꼭 필요한 자리에만 쓰는 게 안전하다.
스코프는 또 다른 축이다
여기에 축이 하나 더 있다. 앞 글에서 본 스코프다.
역할과 속성은 “이 사람이 무엇을 할 수 있나” 를 말한다. 스코프는 “이 사람이 이 앱에 무엇을 위임했나” 를 말한다. 둘은 다른 질문이라 둘 다 통과해야 한다.
예를 보면 명확하다. 어떤 앱이 report:write 스코프로 토큰을 받았다. 그런데 그 토큰의 주인인 김철수는 보고서를 쓸 권한이 없는 일반 사원이다. 그러면 거부해야 한다. 위임은 원래 갖고 있는 권한을 넘어설 수 없다.
반대도 마찬가지다. 김철수가 관리자여도, 그 앱에 report:write를 허락하지 않았다면 그 앱은 못 쓴다.
| 스코프 | 역할·속성 | |
|---|---|---|
| 묻는 것 | 앱에 얼마나 위임했나 | 이 사람이 무엇을 할 수 있나 |
| 정하는 주체 | 사용자의 동의 | 시스템의 정책 |
| 담기는 곳 | 토큰 | 사용자 계정 |
| 바뀌는 시점 | 동의할 때 | 인사·정책이 바뀔 때 |
사용자가 브라우저로 직접 쓰는 서비스에는 스코프 축이 없을 수도 있다. 위임할 제3자가 없기 때문이다. 외부 앱에 API를 열어주기 시작할 때 이 축이 생긴다. 처음부터 두 축을 다 만들 필요는 없지만, 나중에 생길 자리라는 건 알아두는 게 좋다.
어디서 검사하나
이제 두 번째 질문이다. 무엇으로 적을지 정했으면, 그 검사를 코드의 어느 층에 둘 것인가.
층은 대체로 셋이다.
길목 - 요청이 애플리케이션에 들어오는 자리에서 경로 단위로 막는다. /admin/**은 ADMIN만, 같은 규칙이다. 여기서 막히면 컨트롤러가 그 요청을 아예 보지 못한다. 이 층이 Middleware 글에서 본 필터 체인이고, 인증이 앞이고 인가가 뒤인 이유도 거기서 나온다. 구현 상세는 그 글이 갖고 있다.
서비스 - 기능 하나를 실행하기 직전에 검사한다. 리소스를 꺼내봐야 판단되는 규칙(“본인 글인가”)은 여기 있어야 한다. 길목에서는 대상이 아직 로딩되지 않았으니 판단할 수가 없다.
데이터 조회 - 목록을 뽑을 때 아예 볼 수 있는 것만 뽑는다. “내 부서 문서만” 같은 규칙은 조회 조건에 넣는 게 맞다. 전부 꺼낸 뒤에 걸러내면 안 되는 이유가 둘이다. 필요 없는 데이터를 읽고, 거르는 코드를 빠뜨리면 그대로 새기 때문이다.
세 층을 어떻게 나눌지는 규칙의 성격이 정한다.
| 규칙의 성격 | 두는 곳 |
|---|---|
| 경로만 보면 판단된다 | 길목 |
| 대상을 꺼내봐야 판단된다 | 서비스 |
| 목록의 범위를 정한다 | 데이터 조회 |
화면에서 버튼을 숨기는 것은 권한 검사가 아니다. 버튼을 안 보여주는 것은 사용성이고, 요청은 API를 직접 불러서 얼마든지 보낼 수 있다. 서버에서 같은 검사를 반드시 다시 해야 한다. 프런트엔드의 조건문은 편의이고, 진짜 관문은 서버에만 있다.
실무에서 정책을 한곳에 모으기
Spring Security는 위 세 층에 각각 자리를 준다.
길목은 설정 한 곳에 모은다.
http.authorizeHttpRequests(reg -> reg
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/posts/**").hasAuthority("post:write")
.requestMatchers("/posts/**").permitAll()
.anyRequest().authenticated());이 블록 하나가 “어느 경로를 누가 쓸 수 있나”의 목차가 된다. 흩어진 if문과 가장 크게 다른 점이 이것이다. 한 화면 안에서 전체 정책이 읽힌다.
서비스는 애너테이션으로 붙인다.
@PreAuthorize("hasRole('ADMIN') or @postAuth.isAuthor(authentication, #postId)")
public void editPost(Long postId, String content) { ... }대상을 보고 판단하는 규칙이라 길목에는 둘 수 없는 것들이다.
여기서 반드시 지켜야 할 게 하나 있다. 판단의 근거를 요청이 준 값에서 꺼내면 안 된다.
위 메서드가 Post 객체를 통째로 받고 #post.authorId로 판단한다고 해보자. 그 객체가 요청 본문에서 그대로 바인딩된 것이라면, 공격자가 authorId를 자기 것으로 채워 보내면 검사를 통과한다. 인가가 통째로 무의미해진다.
그래서 식별자만 받고, 판단할 데이터는 저장소에서 직접 꺼내야 한다. 위 코드가 Post가 아니라 postId를 받는 이유다. “요청이 뭐라고 하든, 실제로 저장된 값은 이렇다”를 확인하는 게 인가다.
규칙이 복잡해지면 표현식에 다 적지 말고 판단하는 객체를 따로 만든다.
@Component("postAuth")
public class PostAuthorizer {
private final PostRepository posts;
private final UserRepository users;
public boolean canEdit(Authentication auth, Long postId) {
Post post = posts.findById(postId); // 저장된 값을 직접 꺼낸다
if (hasRole(auth, "ADMIN")) return true;
if (post.isLocked()) return false;
Long myId = users.findByUsername(auth.getName()).getId();
return post.getAuthorId().equals(myId); // id끼리 비교한다
}
}
// @PreAuthorize("@postAuth.canEdit(authentication, #postId)")두 가지를 눈여겨볼 만하다. 판단할 글을 저장소에서 꺼내 온다는 것, 그리고 비교하는 두 값의 타입이 같다는 것이다. authentication.getName()은 대개 로그인 아이디라서, 그걸 숫자 authorId와 바로 견주면 언제나 거짓이 된다. 그러면 권한이 있는 사람도 막히는데, 아무 에러 없이 403만 나와서 원인을 찾기 어렵다.
정책이 자바 코드로 한 곳에 모이니 테스트할 수 있고, 왜 막혔는지 설명할 수 있다. 문자열 표현식에 조건을 계속 붙이다 보면 둘 다 못 하게 된다.
마지막으로 운영에서 자주 밟는 자리 몇 개를 적어둔다.
hasRole과hasAuthority를 섞어 쓰는 것.hasRole("ADMIN")은 실제로ROLE_ADMIN을 찾는다. 권한 문자열을 저장할 때 접두사를 빠뜨리면 조용히 403이 난다.- 권한을 토큰에 담고 잊는 것. 토큰에 역할을 넣어두면 권한을 회수해도 만료 전까지 반영되지 않는다. 즉시 반영이 필요한 권한이라면 요청 때 조회해야 한다.
- 거부 로그를 안 남기는 것. 403이 났을 때 “누가·무엇을·왜 막혔는지”가 없으면 문의가 들어와도 답할 수가 없다.
정리
| 권한의 모양 | 누가(주체) - 무엇을(대상) - 어떻게(행위) |
| 역할(RBAC) | 사용자와 권한 사이에 역할을 끼운다. 대부분이 여기서 해결 |
| 한계 | ”본인 글만”처럼 대상을 봐야 하는 규칙은 못 담는다 |
| 속성(ABAC) | 주체·대상·환경의 속성을 조합해 판단한다. 남용하면 못 읽는다 |
| 스코프 | 다른 축. 앱에 위임한 범위. 원래 권한을 넘어설 수 없다 |
| 검사 위치 | 경로는 길목 · 리소스는 서비스 · 목록은 조회 조건 |
| 화면의 조건문 | 사용성이지 권한 검사가 아니다 |
이 시리즈는 “너 누구냐”와 “너 그거 해도 되냐”를 갈라놓는 데서 시작했다. 앞의 답을 세션에 둘지 토큰에 둘지 골랐고, 그 답을 남에게 위임하는 방법을 봤고, 마지막으로 뒤의 답을 무엇으로 적고 어디서 검사할지까지 왔다. 두 질문이 갈라진 채로 끝까지 갔다는 것, 그게 이 시리즈가 남기려던 하나다.