Identity and Access Management의 약자로 AWS 리소스에 대한 액세스를 안전하게 제어할 수 있는 서비스이다.
IAM User/User Group/Role/Policy로 구성되어 있다. Policy는 리소스에 접근할 수 있는 접근 권한 정책을 말한다.
Policy는 단일 유저에게, 유저 그룹에게, 역할(Role)에 부여할 수 있다.
정책을 다루는 방식은 크게 두 가지로 분류가 가능하다.
RBAC(Role Base Access Control): 역할 기반 접근제어 정책
ABAC(Attribute Base Access Control): 속성기반 접근제어 정책
RBAC
역할 기반 접근제어 정책은 역할에 정책을 부여해서 해당 역할이 접근할 수 있는 리소스(EC2, RDS,...)에 접근 가능하도록 하는 방식이다.
예를 들어 Role DEV라는 역할을 만들고 해당 역할에 DEV 관련 리소스에만 접근 가능하도록 정책을 설정하면 해당 역할을 부여받은 사용자들은 DEV 관련 리소스에만 접근할 수 있는 방식이다.
다음 그림을 보자.
출처: AWS 공식 홈페이지
역할 A가 할 수 있는 정책을 부여하고 그 역할A에 단일 유저 또는 그룹을 할당하면 해당 단일 유저나 그룹은 그 역할이 할 수 있는 정책에 따라서 리소스에 접근이 가능한 방식이다. 간단하다. 그러나 이런 방식은 역할이 늘어날 때마다 또는 리소스가 추가될 때마다 역할과 정책을 수정하거나 새로 만들어 할당해야 하는 번거로움을 가지고 있다. 이를 해결하기 위해 ABAC이 있다.
ABAC
속성 기반 접근제어 정책은 속성을 기반으로 권한을 정의하는 권한 부여 전략이다. AWS에서는 이러한 속성을 태그라고 한다. IAM User 또는 그룹 또는 역할과 AWS 리소스에 태그를 연결할 수 있다. IAM User/Group/Role에 대해 단일 ABAC 정책 또는 정책 세트를 만들 수 있고 이 정책 세트나 단일 ABAC 정책은 IAM User/Group/Role가 가진 태그와 리소스의 태그가 일치할 때 작업을 허용하도록 설계하는 방식이다.
출처: AWS 공식 홈페이지
위 그림이 가장 가시화가 잘 되는 ABAC을 나타낸 그림이다. 하트 태그가 있는 역할은 하트 태그가 있는 인스턴스 또는 컨테이너(이러한 것들을 전부 리소스라고 한다)에만 접근이 가능하도록 설계하는 방식이다.
RBAC Vs. ABAC
기존 RBAC과 ABAC을 비교해보면 RBAC이 ABAC에 비해 가장 큰 단점은 새 리소스를 추가할 때 해당 리소스에 액세스할 수 있도록 정책을 업데이트해야 한다는 단점이 있다는 것이다.
예를 들어, 세 개의 프로젝트가 있을 때 각 프로젝트에 대한 IAM Role을 생성한다. 그 다음 각 IAM Role에 정책을 연결해서 역할을 맡을 수 있는 모든 사람이 액세스할 수 있는 리소스를 정의한다. 그러다가 세 개의 프로젝트 중 한 개의 프로젝트에서 새로운 EC2 리소스가 필요하여 EC2를 생성했지만 해당 리소스에 접근할 수 있도록 정책을 업데이트해야 하는데 까먹어서 하지 않은 경우 해당 프로젝트에 참여하는 인원이고 역할을 부여받았어도 해당 리소스에 접근하지 못한다. 즉, 리소스가 변경될 때 업데이트의 빈번함이 단점이 된다.
ABAC은 RBAC에 비해 다음과 같은 이점을 제공한다.
ABAC은 새 리소스에 액세스할 수 있도록 기존 정책을 업데이트할 필요가 없다. 위 RBAC의 단점에 대한 예시처럼 특정 프로젝트에 새 EC2 리소스가 추가될 경우 해당 프로젝트가 가지는 태그를 새 EC2 리소스를 만들 때 태그로 넣어주면 태그값이 일치하므로 접근이 가능하다.
ABAC을 사용하면 정책 수가 적어진다. 각 Role에 대해 서로 다른 정책을 생성할 필요가 없기 때문에 생성해야 하는 정책이 더 적어진다. 그에 따라 관리하기도 더 쉬워진다.
AWS Organization
여러 AWS 계정을 조직에 통합하고 중앙에서 관리할 수 있는 계정 관리 서비스이다. 계정 및 리소스 접근제어 관리와 통합 결제 기능을 활용할 수 있고 통합 결제 기능을 활용해서 기업의 예산 관리, 보안 및 규정 준수 요구 사항에 충족할 수 있다. 예를 들어 특정 규제 요구 사항을 충족하는 AWS 서비스에만 접근해야 하는 계정이 있는 경우 이러한 계정을 하나의 조직으로 만들어 넣고 관리할 수 있다.
출처: AWS 공식 블로그
그림에서 OU는 Organization Unit의 약자로 조직 단위를 의미한다. 이 조직 또한 리소스에 접근하기 위한 정책을 가질 수 있음을 확인할 수 있다.
AWS IAM 계정 생성
루트 계정은 있다고 가정하고 시작한다. 없으면 그냥 만들면 된다. AWS 콘솔에서 'IAM'을 검색하면 다음 사진처럼 'IAM' 서비스가 나온다.
들어가면 좌측 Access management 섹션에서 'Users'를 클릭한다.
메인 화면 우측 상단에 'Create user'를 클릭한다.
다음 화면에서 유저네임을 작성한다.
다음 화면에서는 권한을 설정하는데 우선 어드민 유저로 만들어보자. 그 이후 Policy는 직접 설정하거나 AWS가 만들어 놓은 기존에 있는 Policy를 선택할 수도 있다.
'Attach policies directly'를 클릭하고 하단에 'AdministratorAccess'를 선택하면 어드민 유저 권한이 생긴다.
다음 화면에서 계정 생성 전 최종 확인화면인데 이 부분에서 태그를 입력할 수 있다. 태그는 위에서 말한 ABAC과 관련된 그 태그다.
Create user 버튼을 클릭하면 유저가 최종 생성된다. 다시 IAM Users 화면으로 넘어가고 방금 만든 유저가 나온다. 이 유저를 클릭해보자.
들어오면 'Security credentials' 탭에 Console sign-in 이라는 부분이 있는데 이것을 Enable하면 이 유저로 Console에 로그인을 할 수 있는 기능을 부여하는 것이다. Enable한 후 이 유저로 로그인해보자.
우선 'Set password' 부분에 원하는 패스워드를 입력한 후 Apply해보자.
다 적용을 하면 이 IAM User로 로그인할 수 있다. 그 전에 Account Alias라는 것을 설정해야 하는데 이는 IAM Dashboard로 가보자.
이 Alias를 설정하면 로그인할 때 저 외우기 어려운 Account ID 대신 입력할 수 있다. 설정한 후 로그아웃해서 방금 만든 IAM 유저로 로그인해보자.
Account ID는 위에서 만든 Alias로 입력하고 IAM 유저네임은 방금 만든 유저로 입력해서 로그인하면 정상 로그인이 되어야 한다.
이렇게 IAM 유저를 만들어서 IAM 유저별로 콘솔 로그인이 가능하고 유저마다 역할과 책임을 구분지을 수 있다.
AWS-CLI로 현재 사용자 정보 출력해보기
사용자를 만들었으니까 CLI를 이용해서 AWS와 통신해보자. Part 2에서 AWS-CLI 설치는 했으니 바로 사용해보자.
우선 사용자 별 Access key를 받아야 하는데 그건 Access key를 받고자하는 사용자를 클릭해서 'Security credentials' 탭으로 들어가면 된다.
만들었으면 Access Key ID와 Secret Access Key를 받는데 이것을 잘 저장해서 AWS-CLI에 사용자 설정 시 사용해야 한다.
다음 명령어를 입력하자.
aws configure
그럼 총 4가지를 입력해야 한다.
AWS Access Key ID
AWS Secret Access Key
Default region name
Default output format
위에서 만든 Access Key ID와 Secret Access Key를 차례대로 입력하고 지역과 포맷 설정을 해주면 된다. 그럼 현재 로컬에서 AWS-CLI를 사용하는 유저를 지정하게 된다. 잘 지정이 됐는지 현재 유저를 확인해보는 명령어는 다음과 같다.
위 그림이 AWS가 책임지는 부분과 AWS를 사용하는 고객이 책임지는 부분을 나뉘어 놓은 공동 책임 모델이다.
AWS가 책임져야 하는 부분으로는 AWS에서 제공하는 모든 하드웨어 관련 인프라에 대한 책임을 지고 관리형으로 제공하는 소프트웨어 중에 모든 책임을 가지고 있다.
예를 들어, EC2 인스턴스 자체에 문제가 생기는 경우 AWS가 책임을 진다. 그러나 EC2 인스턴스 위에 올라가 있는 애플리케이션에 문제가 생기면 그것은 고객의 책임이다. EC2 인스턴스 위에 올라가있는 구성 시스템에 대한 모든 보안 작업은 고객의 책임이다. OS 업데이트, 보안 패치도 직접 관리해야 하고 인스턴스에 설치한 모든 소프트웨어, 유틸리티 관리는 모두 고객에게 책임이 있다.
AWS Compliance Programs
AWS 규정 준수 프로그램이다. AWS위에 구축하고자 하는 서비스가 특정 규정을 반드시 준수해야 할 때 그것이 AWS 규정 준수 프로그램에 포함되어 있는지 반드시 확인해야 한다.
서버리스 컴퓨팅 서비스이다. FaaS(Function as a Service)이며 단순히 Function을 생성해서 애플리케이션을 개발하고 실행할 수 있도록 해주는 서비스. 다양한 언어를 지원한다(Go, Java, Python, NodeJS, C#,...)
실제로 서버가 없는 서버리스 서비스이기 때문에 함수가 동작하지 않을 땐 과금되지 않는다. 이에 따라 배치성 작업에 자주 사용된다.
Amazon S3 (Storage)
Simple Storage Service의 약자 S3이다. 객체 스토리지 서비스이며 OS상에서 마운트하여 사용하는 블록 스토리지와는 다르게 Restful API를 사용하여 객체에 엑세스한다. 그렇기 때문에 HTTP/HTTPS 프로토콜을 사용하며 이미지, 동영상과 같은 정적 컨텐츠를 다루는데 자주 사용된다. 저장 가능한 파일 개수 제한은 없지만 단일 파일 크기가 5TB 까지 가능하다.
높은 내구성(99.999999999%)을 가지는 것으로 유명하다.
S3는 버킷(Bucket)이라는 개념이 있는데 이 버킷이 말 그대로 파일을 저장하는 스토리지라고 생각하면 되고 이 버킷에 들어가는 파일 하나하나를 Object라고 한다. 그래서 사용자는 Object를 REST API를 통해 접근하게 되고 당연히 접근 권한을 제어할 수 있다.
Amazon EBS (Storage)
Elastic Block Storage Service의 약자로 블록 스토리지 서비스이다. OS 내부에서 마운트하여 사용하는 블록 스토리지다. EC2에 붙이거나 뗄 수 있고 SSD/HDD 볼륨 타입을 가지고 있다. EBS는 용량이 부족한 경우 확장이 가능하나 축소는 불가한 점이 있다.
Amazon EFS (Storage)
Elastic File System의 약자로 파일 스토리지 서비스이다. Network File System(NFS) 버전4를 지원하고 그에 따라 여러 곳에서 동시에 같은 파일(공유 스토리지)에 접근하는 경우가 빈번할 때 주로 사용한다. EFS는 EBS와는 다르게 처음에 스토리지를 미리 확보해두지는 않고 사용한 만큼 용량을 차지하고 그만큼만 비용을 지불한다.
이 EFS 역시 기본적으로 버스팅 기능을 제공하기 때문에 잦은 읽기/쓰기가 발생하는 경우 성능 저하가 발생할 수 있다.
Amazon VPC (Network)
Virtual Private Network Computing의 약자로 IP 대역(CIDR)을 할당하여 가상 사설 네트워크를 구성한다.
가상의 사설망으로 구성하기 때문에 On-Premise에서 사설 네트워크망을 구축하는 것과 동일하게 구축이 가능하다.
서브넷과 라우팅테이블을 이용해서 외부에서 접근 가능한 Public 네트워크 Subnet과 외부에서 접근 불가한 Private 네트워크 Subnet을 구성할 수 있다. 특정 대역을 구분해서 여러 서브넷을 구분할 수 있다.
VPC Flow Log라는 기능을 사용해서 내부에서 일어나는 트래픽을 분석할 수 있다.
또한, VPC는 사설망으로 구성되기 때문에 서로 다른 VPC끼리 통신이 불가능한게 원칙이지만 여러개의 VPC를 하나의 사설 네트워크망으로 사용할 수 있도록 하려면 VPC Peering/Transit Gateway를 사용하면 된다.
VPC Peering
위 그림처럼 여러 VPC를 연결하여 같은 Region이든 다른 Region이든 서로 통신이 가능하도록 설정하는 것이 가능하다.
Transit Gateway
Transit Gateway는 여러 VPC간 연결 정책을 중앙에서 관리해주는 서비스이다. 이 Transit Gateway를 사용하면 VPC연결뿐 아니라 VPN Connection을 통해 On-Premise와의 연결 또한 중앙에서 관리가 가능하다. 연결을 해야하는 VPC가 많으면 많을수록 관리적인 측면에서 중앙에서 관리해주는 Transit Gateway가 VPC Peering보다 더 효율적일 것으로 생각된다.
VPC Peering과 Transit Gateway의 차이점을 살펴보자면 다음과 같다.
VPC Peering
Transit Gateway
대역폭 제한이 없음
최대 대역폭 50Gbps
Peering 연결 수는 VPC당 125개
Transit Gateway당 최대 5000개의 VPC 연결 가능
Transit Gateway 대비 약 1.5배 가량 저렴
AWS Cloudfront (Network)
CDN(Contents Delivery Network) 서비스이다. CDN은 클라이언트가 인터넷에 접속하는 곳(지역)과 가까운 곳에서 원본 데이터 서버(Origin)로부터 컨텐츠를 캐싱해둔 서버(Edge Location)에서 빠르게 응답하는 기술(캐싱)이다.
또한, 로드밸런싱과 장애 발생 시 다른 인스턴스로부터 데이터를 가져오는 장애처리 기술도 가지고 있다.
위 설명에서 이 CloudFront를 사용할 때 캐싱의 도움을 받을 수 있다는 것은 새로운 컨텐츠가 원본 서버에 업데이트 되면 그 캐시에 대한 무효화도 일어나야 한다는 것을 암시한다. 이 CloudFront에서는 이 캐시를 무효화하는 것을 Invalidation이라고 하는데 다음 그림과 같다.
Amazon Route53 (Network)
AWS DNS 서비스로 도메인을 구매 또는 등록할 수 있는 서비스이다. 인터넷 트래픽을 리소스로 라우팅을 해주며 여러 라우팅 정책이 있다. (simple, weighted, latency, failover, geolocation,...)
Relational Database Service의 약자로 관계형 데이터베이스 서비스이다. 다양한 DB 엔진(Oracle, MariaDB, MySQL, PostgreSQL,...)을 제공하며 즉각적인 DB 컴퓨팅 사이즈 조정이 가능하다. 자동 백업을 통해 가용성과 내구성을 향상시킬 수 있다. 관리 부담 감소와 사용성 편의도 장점으로 볼 수 있다.
Multi AZ 구성을 이용해 장애처리 기술을 보유하고 있다.
RDS Read Replica 구성도 취할 수 있는데 이는 무엇이냐면 Master DB에 부하분산처리를 위해 읽기 전용 Replication을 여럿 만들어 두고 Master DB에 부하 발생 시 읽기 트랜잭션을 Replication에게 위임하는 것이다.
Amazon DynamoDB (Database)
완전관리형 NoSQL 데이터베이스이다. SSD기반의 무제한 스토리지로 Key/Value 형태로 데이터를 저장한다.
10m/s 미만의 빠른 응답 시간을 가지고 있으며 확장이 단순하고 신속하다. 자동 이중화 백업을 해준다.
Elasticache (Database)
완전관리형 In-Memory Cache 서비스이다. 인 메모리 캐시에서는 가장 대표적인 기술로 Redis, Memcached가 있는데 그것들의 기술 엔진을 이용한다. Elasticache for Redis는 3가지 클러스터 형태가 존재하는데 다음과 같다.
싱글 클러스터
클러스터 모드 비활성
클러스터 모드
Replication
X
O(최대 5개)
O
Data Partitioning
O
X
O
Scaling
Scale Up/Down
Scale Up/Down
Scale In/Out(Shard)
Multi AZ
X
최소 1개 Replica 필요
O
Amazon WAF (Security)
관리형 웹 방화벽 서비스로 HTTP/HTTPS의 트래픽을 관리하고 부정 접근을 차단하여 고객의 애플리케이션을 보호하는 서비스이다. 방어하는 웹 공격의 종류에는 OWASP TOP 10(SQL injection, XSS,...)을 대응하며 이에 대응할 수 있는 보안 규칙을 설정할 수 있다. WAF는 CloudFront, Application LB에서 기본적으로 사용할 수 있도록 내재되어 있는 서비스이다. AWS 관리형 규칙과 사용자 지정 규칙을 지정할 수 있고 사용자 지정 규칙이라함은 IP, 국가, 헤더 등 차단 규칙을 설정하는 것을 말한다.
Cloudwatch를 통해 실시간으로 웹 보안 모니터링이 가능하고 AWS 서비스를 활용한 로그 통합(Kinesis Data Firehose, S3)을 하여 분석도 가능하다.
AWS WAF Architecture
1. CloudFront가 웹 애플리케이션 앞 단에서 요청들을 받고, 요청에 대한 구체적인 정보들을 담아서 S3 버킷으로 access log를 보낸다.
2. S3 버킷에 신규 액세스 로그들이 저장될 때 마다 람다 펑션이 동작한다.
3. 람다 펑션은 어떤 IP로부터 기준치 이상의 요청이 들어 왔는지 분석하고, AWS WAF 블랙리스트에 추가한다. AWS WAF는 지정된 기간동안 해당 IP를 블락한다. 이 지정된 기간이 끝나면 AWS WAF는 다시 해당 IP로부터의 요청을 수락한다. 단, 해당 IP로부터의 트래픽 모니터링은 계속된다.
AWS WAF는 수정 가능한 웹 보안 규칙을 정의하고 이것을 통해 웹 애플리케이션으로의 트래픽을 허용하거나 차단하는 기능을 제공한다. 이 서비스에서는 아래와 같이 3가지 규칙을 사용하게 된다.
자동 블락킹 - 이 규칙은 악성 요청으로 식별된 IP주소를 추가한다. 이 규칙을 설정함과 동시에 블랙리스트된 IP주소들로부터 들어오는 모든 신규 요청을 드랍하고 람다가 지정한 폐기 기간이 지나 해당 IP를 블랙리스트에서 제거하기 전까지 유효하게 동작한다.
수동 블락킹 - 이 규칙은 수작업으로 특정 IP 주소를 블랙리스트에 추가하는데 사용된다. 해당 IP주소는 관리자가 수작업으로 리스트에서 제거하기 전까지 계속 블락된다.
자동 카운트 - 지정된 IP로부터의 요청이 바로 블락킹되지는 않고, 준 실시간성으로 요청 갯수가 트랙킹됩니다. 이를 통해 자동 블락 리스트에서 제거된 이후 해당 IP의 행동에 대한 가시성을 제공해 줄 수 있습니다.
Amazon Shield (Security)
관리형 DDOS 차단 솔루션이다. DDOS 공격은 네트워크 트래픽을 의도적으로 과다하게 키워 컴퓨터 자원에 과부하를 거는 공격인데 이 DDOS 공격을 받으면 서비스 성능 저하나 다운 현상이 일어나게 되는데 이를 막기 위해 이 서비스가 있다. 이 서비스는 Standard 형태로 무료로도 사용할 수 있다. 그렇다는 것은 Advanced 형태로 유상 서비스도 있다.
Amazon KMS (Security)
Key Management Service의 약자로 AWS 키 관리 서비스이다. 리소스 데이터 암호화/복호화 기능과 디지털 서명 및 확인 기능이 있다.
Amazon Cloudwatch (Administration/Monitoring)
AWS 사용하면서 가장 많이 보게 되는 서비스 중 하나가 이 CloudWatch 서비스이다. 관리형 AWS 리소스 모니터링 서비스로 AWS 리소스의 상태에 대한 다양한 Metrics 제공을 한다. 이 다양한 리소스 상태에 대한 메트릭을 대시보드로 구성할 수 있고 SNS 서비스를 통한 알람 기능도 있다.
그러니까 AWS 리소스가 사용되고 모니터링 지표 수집이 되면 CloudWatch가 그 데이터를 받아서 대시보드로 보여주고 특정 Event가 발생하는 경우 SNS 기능을 통해 정해진 유저로부터 알람 기능도 제공할 수 있다.
Amazon SNS (Administration/Monitoring)
관리형 메시지 서비스. SMS 문자, 푸시 알림, 이메일 등을 통해 고객에게 알림(A2P, Application To Person)을 배포할 수 있으며 A2A(Application To Application)알림을 제공하여 분산 애플리케이션을 통합하고 분리할 수 있다.
Amazon Cloudtrail (Administration/Monitoring)
관리형 이벤트 추적/감사 도구. AWS 서비스가 수행하는 모든 작업들을 이벤트 로그로 기록을 한다. 이 때 이벤트는 AWS CLI, AWS SDK, Console등 API로 수행하는 모든 작업을 말하며 이 CloudTrail을 통해 AWS 인프라 전반에 걸친 계정 활동을 관리 콘솔 UI를 통해 쉽게 검색하여 확인이 가능하다. 로그 양이 굉장히 많기 때문에 로그 분석 지원 서비스인 Athena를 같이 이용하기도 한다.
해당 링크로 들어가면 OS별 설치 가능한 패키지가 있는데 본인의 OS에 맞게 설치하고 터미널에서 다음 명령어를 입력해보자.
aws
이런 결과가 나오면 AWS-CLI가 잘 설치된 것.
다음 명령어를 입력하면 전체 region을 확인해 볼 수 있으나, 최초로 aws-cli를 설치했으면 사용자 설정부터 해줘야 한다. 이를 하지 않으면 현재 유저를 알 수 없기 때문에 결과를 출력할 수 없는데 사용자 설정은 이후에 해볼 예정이니 지금은 그것을 다 했을 때 이러한 결과가 나온다는 것만 보면 좋을 것 같다.
가용영역이라고 하고 'AZ'라고 줄여 부르기도 한다. 이 가용영역은 하나의 Region에서 최소 2개 이상의 AZ로 구성이 된다.
그러니까 하나의 리전에 최소 2개 이상의 가용영역이 존재한다는 뜻이고, 서울의 경우 현재 4개의 AZ를 운영하고 있다.
가용영역 코드는 Region Code와 문자 식별자를 조합해서 사용한다. 예) us-east-1a, us-east-1b
가용영역이 여러개이기 때문에 다음과 같은 구성을 할 수 있다.
가용영역에 따른 서버 다중화
가용영역에 따른 DB 이중화
즉, 복수의 가용 영역에 걸쳐 인스턴스를 배포했을 때 하나의 인스턴스에 장애가 발생한 경우에 대비하여, 다른 가용 영역의 인스턴스가 장애가 발생한 인스턴스 관련 요청을 처리할 수 있도록 애플리케이션을 설계할 수 있다. 또한 탄력적 IP 주소를 사용하여 한 가용 영역에서 인스턴스의 장애가 발생한 경우 다른 가용 영역의 인스턴스로 주소를 신속하게 매핑함으로써 인스턴스의 장애를 마스킹할 수 있다.
AWS Edge Location
엣지 로케이션은 Amazon CloudFront, Route53을 위한 캐시 서버들의 모음이다. 무슨 말이냐면 실제 아마존 서버는 미국 Region에서 돌아가지만 한국에서도 빠르게 아마존 사이트를 접속할 수 있는 이유는 이 Edge Location 덕분이다. AWS 전세계 Region에서 빠른 접근성을 위한 글로벌 네트워크 인프라가 Edge Location이고 콘텐츠(HTML, 이미지, 동영상, 기타파일)를 사용자들이 빠르게 받을 수 있도록 전세계에 곳곳에 위치한 캐시서버에복제해주는서비스이다.
인터넷을 통해 IT 리소스와 애플리케이션을 사용자가 원할 때 언제든지 사용한 만큼 (On-demand) 요금을 내는 서비스
클라우드 컴퓨팅의 유형은 크게 보면 2가지가 있다.
퍼블릭(Public) 클라우드
사용자가 컴퓨팅 리소스를 소유하지 않는 방식
인터넷을 통해 제공
가상화 기술로 만든 서비스를 그대로 사용
프라이빗(Private) 클라우드
특정 조직내에서 컴퓨팅 리소스를 '소유'
사설(Private) 네트워크를 통해 제공
가상 컴퓨팅 기술을 직접 구축
이 두가지 경우로부터 파생되는 경우가 있는데 이는 '하이브리드 클라우드'와 '멀티 클라우드'이다. 하이브리드 클라우드는 퍼블릭 클라우드와 프라이빗 클라우드 또는 데이터센터 간 네트워크를 연결해서 사용하는 방식이다. 데이터 및 애플리케이션을 각 클라우드가 공유한다. 멀티 클라우드는 다수의 퍼블릭 클라우드를 합쳐 사용하는 방식으로 예를 들면 AWS + GCP + Azure를 합친 클라우드 방식을 말한다. 내가 공부할 AWS는 대표적인 퍼블릭 클라우드이다.
클라우드 서비스의 특징
탄력성/민첩성: 리소스에 대해 필요할 때 언제든 늘리고, 줄일 수 있고 이를 클릭 몇번으로 가능하게 한다.
확장성: 물리 서버를 확장하려면 시간이 오래 걸리며 고정 비용이 발생하는 반면 클라우드는 즉시 확장이 가능하고 역시 사용한 만큼의 비용이 발생한다. 이러한 특징 때문에 서비스에 사용자가 많아져 급증하는 서비스 트래픽에 빠르게 대비가 가능하다.
사용한 만큼만 비용을 지불: 전기 요금처럼 사용한 만큼 과금되며, 비용 예측이 가능하다.
내결함성 및 재해복구: 클라우드 백업 및 클라우드 DR 구성으로 데이터 손상 등 긴급 상황에 대처가 가능하다.
고 가용성: 손쉬운 다중 가용영역 설정에 따라 고 가용성을 보장
유지 관리 간소화: 물리적인 리소스를 유지할 필요가 없고, 부분적으로 클라우드 CSP(Cloud Service Provider) 벤더에 위임한다.
사용한 만큼만 비용을 지불하기 때문에 무리한 자본지출(CAPEX)없이 빠른 시도와 회수가 가능해진다. 좀 더 자세히 말하자면 클라우드 서비스를 이용하지 않고 초장부터 서비스의 흥행을 기대해 무리한 물리 서버 구축으로 인해 회수 불가능한 자본지출이 투자됐는데 서비스가 생각보다 성과를 이루지 못하는 경우 회수할 수 있는 비용이 없어지는 반면 클라우드 서비스를 이용해서 사용자가 늘어나면 확장하며 사용한 만큼만 지불하게 하면 운영 지출(OPEX)만으로 서비스 운영이 가능해진다.
클라우드 서비스 모델(IaaS/PaaS/SaaS)
클라우드 서비스 모델은 크게 3가지가 있다.
IaaS (Infrastructure as a Service): 서비스로 제공되는 인프라 스트럭쳐. 즉, 개발사에게 제공하는 물리적 자원을 가상화한다.
PaaS (Platform as a Service): 서비스로 제공되는 플랫폼. 즉, 개발사에게 제공하는 플랫폼을 가상화한다.
SaaS (Software as a Service): 서비스로 제공되는 소프트웨어. 고객에게 제공되는 서비스를 가상화한다.
즉, 이 세 가지의 차이는 어디까지 가상화로 제공해 주는가에 있다. 이를 그림으로 좀 더 쉽게 이해해보자.
IaaS는 개발사에게 인프라(물리적 자원)을 가상화하여 제공한다고 했다. 그렇기 때문에 가상의 서버, 스토리지, 네트워크를 제공자가 관리하는 영역으로 구분한다.
PaaS는 개발사에게 플랫폼을 가상화하여 제공한다고 했다. 그렇기 때문에 가상의 서버, 스토리지, 네트워크를 포함하며 OS와 개발 환경까지 제공자가 관리하는 영역으로 구분한다. 따라서 개발사는 그 위에 본인의 애플리케이션과 데이터를 올려두면 된다.
SaaS는 고객에게 제공하는 서비스를 가상화한다고 했다. 그렇기 때문에 서비스가 돌아가기 위해 필요한 모든 것들(서비스 포함) 제공자가 관리한다.
그에 반면 클라우드를 사용하지 않는 경우 모든 것을 사용자가 관리해야 하는 On-Premise가 있다.
기존 방식인 On-Premise와 클라우드의 차이점은 위 그림으로만 봐도 눈에 띄게 알 수 있지만 비교를 좀 더 깊게 해보자.
항목
On-Premise
Cloud
인프라 운영/보안
사용자가 모두 운영하고 관리
공동 책임 모델이 적용
구축 및 배포
자원 구축/배포 시간이 길다
단 시간에 인프라 구성이 가능
탄력성/확장성
서버 증설 시 예산 및 시간이 소요
몇번의 클릭으로 서버 증설 가능
비용지출 방식
자본 지출: Capital Expense(CAPEX)
운영 지출: Operation Expense(OPEX)
네트워크 트래픽
인터넷 공급자(ISP) 회선 계약 따라 회선속도 및 트래픽 용량을 사전에 설정
회선 속도나 용량을 정할 수 없음 트래픽을 사용한만큼 지출 (Outbound)
오픈소스
모든 오픈소스 Application을 스스로 구축
Pre-built된 오픈소스 Application을 즉시 사용
(이 외 유사한 명칭인 FaaS(Function as a Service), BaaS(Backend as a Service)가 있다.)
여기서는 서버를 띄울 때 데이터베이스에 초기데이터를 만들어주기 위해 spring.jpa.defer-datasource-initialization: true 속성과 spring.sql.init.mode: always 속성을 추가했다. 이는 resources 폴더에 data.sql 파일이 있을 때 해당 파일을 보고 초기데이터를 넣어주기 위함이다.
User Service와 구조나, 코드 내용이 상이한게 거의 없기 때문에 코드만 써도 괜찮아 보인다. 전체적인 핵심은 이렇게 도메인 별 MicroService를 만들어서 서비스의 크기를 작게 나누고 API Gateway를 통해 서비스로 접근하는 방식을 고수하고 있다는 점이다. 이 서비스 역시 유레카 서버에 등록되고 API Gateway가 관리하는 서비스이다.
추후에 설정 내용이 더 추가될 예정이다. 지금은 저 csrf를 disable()하는것에 초점을 두자. 우선 csrf는 무엇인지부터 확인해보자.
csrf
Cross Site Request Forgery의 약자로 사이트 간 위조 요청을 말한다. 이는 정상적인 유저가 의도치 않게 비정상적인 요청을 하는 것을 말하는데 특정 사이트에 정상 권한을 가지고 있는 유저에게 비정상적인 링크를 누군가가 보내고 그 링크를 아무런 의심없이 해당 유저가 클릭할 때 역시 이 비정상적인 요청을 할 수 있다. 그리고 해당 사이트는 이러한 요청에 대해 이 사용자가 악용된 사용자인지 일반 유저인지 구분할 수 없다.
그래서 이를 방어하기 위해 csrf 토큰을 웹 사이트에서는 부여하여 이 토큰이 요청에 포함되어야만 요청을 받아들인다. 그럼 csrf를 왜 disable()했을까?
REST API만을 사용한다면 CSRF는 의미가 없다. Spring security 문서를 보면 non-browser-clients만을 위한 서비스라면 csrf를 disable해도 상관이 없다. REST API만을 이용하는 클라이언트는 요청 시 요청에 인증 정보(예: JWT)를 포함하여 요청하고 서버에서 인증 정보를 저장하지 않기 때문에 굳이 불필요한 csrf 코드들을 포함할 필요가 없는것이다.
그니까 결론은, 브라우저를 이용하지 않고 모든 요청은 REST API로 들어온다면 CSRF 관련 코드를 빼주는 게 더 효율적인 서비스가 될 수 있다.
authorizeHttpRequests()
두번째 라인은 특정 패턴의 요청이 들어왔을 때 요청을 허용할지에 대한 코드이다. 다음 코드를 보자.
package springmsa.springmsa_user_service.service;
import jakarta.ws.rs.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.modelmapper.ModelMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import springmsa.springmsa_user_service.dto.ResponseOrderDto;
import springmsa.springmsa_user_service.dto.UserDto;
import springmsa.springmsa_user_service.entity.Users;
import springmsa.springmsa_user_service.repository.UserRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final ModelMapper modelMapper;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Users createUser(UserDto userDto) {
userDto.setUserId(UUID.randomUUID().toString().substring(0, 8));
Users users = modelMapper.map(userDto, Users.class);
users.setEncryptedPwd(bCryptPasswordEncoder.encode(userDto.getPwd()));
userRepository.save(users);
return users;
}
@Override
public UserDto findUserById(Long id) {
Optional<Users> user = userRepository.findById(id);
if (user.isEmpty()) {
throw new NotFoundException("User not found");
}
UserDto userDto = modelMapper.map(user.get(), UserDto.class);
List<ResponseOrderDto> orders = new ArrayList<>();
userDto.setOrders(orders);
return userDto;
}
@Override
public Iterable<Users> findAll() {
return userRepository.findAll();
}
}
findUserById(Long id)와 findAll() 메서드를 구현하는데 내용은 간단하다.
findAll()은 repository에 위임하는것이 끝이고 findUserById(Long id)는 유저 아이디를 파라미터로 받으면 repository에서 먼저 유저를 찾은 후 있다면 ModelMapper를 이용해서 DTO로 변환한다. 유저는 추후에 만들 Order MicroService에 존재하는 주문 내역을 가지는데 우선은 Order MicroService를 만들지 않았으니 유저가 가지고 있는 주문 내역은 빈 리스트로 넣어 반환한다.
컨트롤러를 보면 getUsers()와 getUser(@PathVariable Long id)가 있다.
전체 조회 코드를 먼저 보면, 서비스로부터 전체 유저 데이터를 받아온다. 그 다음 받아온 결과를 DTO로 변환해주는 코드가 필요하다.
항상 컨트롤러에서 데이터를 반환할 땐 엔티티 자체가 아닌 DTO로 변환하여 돌려주어야 한다. 그래야 해당 엔티티의 변화에도 API 스펙에 영향이 가지 않을 뿐더러 (사실 이게 제일 중요) 엔티티를 리턴하는 것 자체가 좋은 방법이 아니다. 불필요한 데이터까지 API에 모두 태울 수 있으니.
단일 조회 코드를 보면, URI로부터 유저 ID를 받아온다. 그 ID로 서비스로부터 유저를 조회하여 받아온다. 받아온 유저를 역시나 DTO로 변환한다. 굳이 ResponseUserDto와 ResponseUsersDto로 구분지은 이유는 전체 유저를 조회할 땐 유저의 주문 내역을 반환하지 않기 위해서다.
우선, 두 개의 애노테이션을 만들었다. 하나는 클래스 레벨에 적용할 애노테이션이고 하나는 메서드 레벨에 적용할 애노테이션이다.
애노테이션을 만드려면 기본적으로 두 개의 애노테이션이 필요하다. @Target, @Retention.
@Target은 이 애노테이션이 어디에 달릴지를 설명하는 애노테이션이다. ElementType.TYPE으로 설정하면 클래스 또는 인터페이스에 레벨에 적용할 애노테이션이고 ElementType.METHOD는 메서드 레벨에 적용할 애노테이션이다.
@Retention은 이 애노테이션이 살아있는 레벨을 말한다고 보면 된다. RetentionPolicy.RUNTIME으로 설정하면 런타임에도 해당 애노테이션은 살아 있는 상태로 남아있다. 그래서, 동적으로 애노테이션을 읽을 수 있다. RUNTIME말고 SOURCE도 있는데 이는 컴파일하면 컴파일된 파일에서 애노테이션이 보이지 않고 사라진다. 그래서 동적으로 이 애노테이션을 읽을 수 없다.
그리고 MethodAop 애노테이션은 value() 라는 값을 가질 수 있다. 값의 형태는 문자열이다.
MemberService
package cwchoiit.springadvanced.aop.member;
public interface MemberService {
String hello(String param);
}
MemberServiceImpl
package cwchoiit.springadvanced.aop.member;
import cwchoiit.springadvanced.aop.member.annotation.ClassAop;
import cwchoiit.springadvanced.aop.member.annotation.MethodAop;
import org.springframework.stereotype.Component;
@ClassAop
@Component
public class MemberServiceImpl implements MemberService {
@Override
@MethodAop("test value")
public String hello(String param) {
return "ok";
}
public String internal(String param) {
return "ok";
}
}
이번엔 인터페이스와 그 인터페이스를 구현한 클래스를 만들었다. 간단하게 하나의 메서드를 가지는 인터페이스(MemberService)와 그를 구현한 MemberServiceImpl이 있고, 이 구현 클래스는 @ClassAop 애노테이션을 달았다. 그리고 이 구현 클래스 내부에 hello(String param)은 @MethodAop 애노테이션이 달려있다.
'*'은 와일드카드로 모든것을 허용한다는 의미로 받아들이면 될 것 같다. 여기서 생략을 할 수 없는 필수 키워드인 반환 타입, 메서드명, 파라미터만을 작성했다. 반환 타입은 전체(*)이며 메서드명 또한 어떠한 것도 상관 없다는 의미의 '*'이고 파라미터는 어떤 파라미터라도 상관없다는 의미의 (..)를 사용했다. (..)는 파라미터가 없거나 여러개거나 한개거나 어떠한 상태여도 상관이 없다는 의미이다.
packageExactFalse()를 확인해보면 cwchoiit.springadvanced.aop.*.*(..)로 되어 있는데 이는 하위 패키지도 포함하는게 아니다. 즉, 정확히 cwchoiit.springadvanced.aop경로의 모든 타입(인터페이스, 클래스)의 모든 메서드를 지정하는 포인트 컷이다. 하위 패키지도 포함하려면 packageMatchSubPackage1()처럼 cwchoiit.springadvanced.aop..*.*(..)로 작성해야 한다.
자식은 부모에 들어가는 게 가능하기 때문에, 포인트컷 표현식을 부모로 설정하면 자식 클래스들은 포인트컷을 만족한다. 단, 인터페이스에서 선언된 메서드에 한한다. 이 말은 무슨말이냐면 부모일지언정 부모에 선언된 메서드가 아니라 자식 내부적으로만 가지고 있는 메서드는 포인트컷을 만족하지 못한다는 말이다.
위에서 MemberService와 MemberServiceImpl을 보면 부모인 인터페이스에는 hello 메서드만 있고 internal은 없다. 자식인 구현 클래스에는 internal 이라는 내부 메서드가 있다. 이 땐 부모 타입으로 포인트컷을 지정하면 자식 내부적으로만 가지고 있는 메서드에는 포인트 컷 조건이 만족하지 않는다.
/**
* String 타입의 파라미터 허용
*/
@Test
void argsMatch() {
pointcut.setExpression("execution(* *(String))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
/**
* 파라미터가 없음
*/
@Test
void noArgsMatch() {
pointcut.setExpression("execution(* *())");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
/**
* 정확히 하나의 파라미터만, 타입은 노상관
*/
@Test
void argsMatchStar() {
pointcut.setExpression("execution(* *(*))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
/**
* 숫자와 타입에 무관하게 모든 파라미터
*/
@Test
void argsMatchAll() {
pointcut.setExpression("execution(* *(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
/**
* String 타입으로 시작, 숫자와 무관하게 모든 파라미터, 모든 타입 허용. 없어도 된다.
*/
@Test
void argsMatchComplex() {
pointcut.setExpression("execution(* *(String, ..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
(String) → 정확하게 String 타입 파라미터
() → 파라미터가 없어야 한다.
(*) → 정확히 하나의 파라미터여야하고, 모든 타입을 허용한다.
(*, *) → 정확히 두개의 파라미터여야하고, 모든 타입을 허용한다.
(..) → 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. 파라미터가 없어도 상관없다.
(String, ..) → String 타입의 파라미터로 시작하고 그 이후에는 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. String 파라미터 이후에 파라미터가 없어도 된다.
within
within 지시자는 특정 타입 내의 조인 포인트들로 매칭을 제한한다. 이 말만 보면 무슨말인지 잘 모르겠다. 쉽게 말하면 작성한 타입(클래스, 인터페이스)이 매칭되면 그 안의 메서드들이 자동으로 매치된다. 참고로, 이건 거의 안쓴다. 거의 대부분은 execution으로 전부 해결이 가능하기 때문도 있고 부모 타입으로 매칭을 해야할 때도 있기 때문에 이건 그냥 알아만 두자!
WithinTest
package cwchoiit.springadvanced.aop.pointcut;
import cwchoiit.springadvanced.aop.member.MemberServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
public class WithinTest {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
Method helloMethod;
@BeforeEach
public void init() throws NoSuchMethodException {
helloMethod = MemberServiceImpl.class.getMethod("hello", String.class);
}
@Test
void withinExact() {
pointcut.setExpression("within(cwchoiit.springadvanced.aop.member.MemberServiceImpl)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void withinStar() {
pointcut.setExpression("within(cwchoiit.springadvanced.aop.member.*Service*)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void withinSubPackage() {
pointcut.setExpression("within(cwchoiit.springadvanced.aop..*)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
/**
* within 의 경우, execution 과는 반대로 부모 타입으로는 안된다.
*/
@Test
void withinSuperTypeFalse() {
pointcut.setExpression("within(cwchoiit.springadvanced.aop.member.MemberService)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
}
주의
주의할 부분이 있다. 마지막 테스트 코드인 withinSuperTypeFalse()를 보면, 표현식에 부모 타입을 지정하면 안된다. 정확하게 타입이 맞아야 한다. 이 점이 execution과 다른 점이다.
args
인자가 주어진 타입의 인스턴스인 조인 포인트로 매칭. 말이 또 어려운데 쉽게 말해 파라미터가 매치되는 녀석들이 다 조인 포인트가 된다고 보면 된다. 아래 코드를 보면 바로 이해가 될 것이다. 기본 문법은 execution의 args 부분과 같다. 참고로, 이 또한 그렇게 중요한게 아니다. 그냥 참고만 해도 무방하다.
execution과 args의 차이점
execution은 파라미터 타입이 정확하게 매칭되어야 한다. execution은 클래스에 선언된 정보를 기반으로 판단한다.
args는 부모 타입을 허용한다. args는 실제 넘어온 파라미터 객체 인스턴스를 보고 판단한다.
pointcut() → AspectJExpressionPointcut에 포인트컷은 한번만 지정할 수 있다. 이번 테스트에서는 포인트컷을 바꿔가면서 테스트 할 목적으로 포인트컷 자체를 생성하는 메서드를 만들었다.
자바가 기본으로 제공하는 String은 Object, Serializable의 하위 타입이다.
정적으로 클래스에 선언된 정보만 보고 판단하는 execution(* *(Object))은 매칭에 실패한다.
동적으로 실제 파라미터로 넘어온 객체 인스턴스로 판단하는 args(Object)는 매칭에 성공한다.
쉽게 말해, args는 부모 타입도 허용하고, execution은 부모 타입은 허용하지 않는다고 기억하면 된다.
참고로, args 지시자는 단독으로 사용되기 보다는 뒤에서 설명할 파라미터 바인딩에서 주로 사용된다.
@target, @within
정의
@target: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트
@within: 주어진 애노테이션이 있는 타입 내 조인 포인트
사실 이 지시자도 그렇게 중요하지도 않고 정의만 보고서는 뭔 말인지 감이 잘 안오지만 코드로 보면 간단하다. 우선 둘 모두 타입에 있는 애노테이션으로 AOP 적용 여부를 판단한다. 아, 그리고 앞에 @ 붙은 지시자(@target, @within, @annotation, ...)들은 애노테이션과 관련된 지시자라고 생각하면 된다.
@target(hello.aop.member.annotation.ClassAop)
@within(hello.aop.member.annotation.ClassAop)
@ClassAop
class Target {
}
여기서 두 개의 차이는 다음과 같다.
@target은 애노테이션이 달린 클래스의 부모 클래스의 메서드까지 어드바이스를 전부 적용하고, @within은 자기 자신의 클래스에 정의된 메서드만 어드바이스를 적용한다.
그래서 한 문장으로 정리를 하자면 @target, @within 둘 모두 애노테이션으로 AOP를 적용하는데 @target의 경우 애노테이션이 달린 클래스와 그 상위 클래스의 메서드 모두에게 어드바이스를 적용하고 @within의 경우 애노테이션이 달린 클래스의 메서드에만 어드바이스를 적용한다.
AtTargetAtWithinTest
package cwchoiit.springadvanced.aop.pointcut;
import cwchoiit.springadvanced.aop.member.annotation.ClassAop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
@Slf4j
@Import(AtTargetAtWithinTest.Config.class)
@SpringBootTest
public class AtTargetAtWithinTest {
@Autowired
Child child;
@Test
void success() {
log.info("child proxy = {}", child.getClass());
child.childMethod();
child.parentMethod();
}
static class Config {
@Bean
public Parent parent() {
return new Parent();
}
@Bean
public Child child() {
return new Child();
}
@Bean
public AtTargetAtWithinAspect atTargetAtWithinAspect() {
return new AtTargetAtWithinAspect();
}
}
static class Parent {
public void parentMethod() {
log.info("[parentMethod] Start");
}
}
@ClassAop
static class Child extends Parent {
public void childMethod() {
log.info("[childMethod] Start");
}
}
@Aspect
static class AtTargetAtWithinAspect {
// @target: 인스턴스 기준으로 모든 메서드의 조인 포인트를 선정 = 부모 타입의 메서드도 적용
@Around("execution(* cwchoiit.springadvanced.aop..*(..)) && @target(cwchoiit.springadvanced.aop.member.annotation.ClassAop)")
public Object atTarget(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[@target] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
// @within: 선택된 클래스 내부에 있는 메서드만 조인 포인트로 선정 = 부모 타입의 메서드는 적용되지 않음
@Around("execution(* cwchoiit.springadvanced.aop..*(..)) && @within(cwchoiit.springadvanced.aop.member.annotation.ClassAop)")
public Object atWithin(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[@within] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
}
Child, Parent 클래스가 있다. Child 클래스는 상위 클래스로 Parent 클래스가 있다.
두 클래스를 모두 스프링 빈으로 등록한다.
에스팩트가 있고 두 개의 어드바이저가 있다. 하나는 @target, 하나는 @within을 사용하여 만들어진 포인트컷이다.
@target과 @within 모두 같은 애노테이션인 ClassAop애노테이션이 달린 클래스를 찾아 AOP로 적용한다.
이 @Aspect 역시 스프링 빈으로 등록해야 한다.
스프링 빈으로 등록한 Child 클래스를 테스트 코드에서는 주입받는다.
주입받은 Child 클래스의 childMethod(), parentMethod()를 각각 호출한다. 여기서 parentMethod()는 부모 클래스인 Parent에서 정의된 메서드이다.
결과는 childMethod() 호출 시, @target과 @within 모두 적용된다. parentMethod() 호출 시 @target만 적용되고 @within은 적용되지 않는다.
주의 다음 포인트컷 지시자는 단독으로 사용하면 안된다. args, @args, @target 이번 예제를 봐도 execution(* cwchoiit.springadvanced.aop..*(..))를 통해 적용 대상을 줄여준 것을 확인할 수 있다. args, @args, @target은 실제 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다. 실행 시점에 일어나는 포인트컷 적용 여부도 결국 프록시가 있어야 실행 시점에 판단할 수 있다. 프록시가 없다면 판단 자체가 불가능하다. 그런데 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩 시점이다. 실행 시점에 일어나는 포인트컷 적용 여부도 프록시가 있어야 판단이 가능한데 프록시가 없으면 실행 시점에 판단 자체가 불가능하다. 그래서 이 args, @args, @target 과 같은 지시자를 단독으로 사용할 경우, 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도한다. 그런데 문제는 스프링이 내부에서 사용하는 빈 중에는 final로 지정된 빈들도 있기 때문에 프록시를 만들어내질 못하고 오류가 발생한다. 따라서 이러한 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해야 한다.
@annotation, @args
@annotation: 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭
@args: 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트
BeanAspect를 보면 orderService라는 bean 또는 *Repository라는 bean을 포인트컷의 조건으로 어드바이스를 만든 모습을 확인할 수 있다.
그 후 테스트 success()는 orderService의 orderItem()을 호출한다.
실행 결과
[bean] void cwchoiit.springadvanced.aop.order.OrderService.orderItem(String)
[orderService] 실행
[bean] String cwchoiit.springadvanced.aop.order.OrderRepository.save(String)
[orderRepository] 실행
매개변수 전달 ⭐️
매개변수 전달 - 메서드 파라미터 값 가져오기 (JoinPoint)
어드바이스 쪽에서 메서드의 파라미터를 전달받고 싶을 땐 어떻게 해야 할까? 예를 들어 다음 코드를 보자.
orderService.orderItem("item");
이런 코드가 있을 때, 어드바이스가 저 파라미터 "item"을 어떻게 받을 수 있을까? 이를 알아보자.
args(arg, ..)은 첫번째 파라미터를 받고 그 이후에 파라미터는 있거나 없거나 신경쓰지 않는다는 뜻이다. 그리고 이 arg를 어드바이스의 파라미터로 이름 그대로(arg) 동일하게 받아야 한다.
@Around를 사용하든, @Before를 사용하든 동일한 방식으로 접근이 가능한데 @Around는 ProceedingJoinPoint를 반드시 첫번째 파라미터로 받아야 하기 때문에 꼭 필요한 경우가 아니라면 더 깔끔한 @Before를 사용하면 된다. 상황에 따라 달라질 것이다.
@target, @within 은 타입 애노테이션에 대해 정보를 가져오는 것이다. 즉, 클래스, 인터페이스 레벨에 붙어있는 애노테이션을 가져오는 지시자이다.
@annotation은 메서드 레벨에 붙은 애노테이션을 가져오는 지시자이다. 그리고 난 위에서 그 애노테이션의 value()라는 속성에 "test value"를 넣었다. 이 값을 가져오고 싶을 때 저렇게 할 수 있다.
여기서, @annotation(annotation)이라고 썼으면 파라미터에서도 annotation이라는 이름으로 받아야 한다. 만약 @annotation(methodAop)로 썼으면 파라미터도 methodAop라는 이름으로 받으면 된다. 물론 @target, @within도 동일하다.
실행 결과
[@annotation] String cwchoiit.springadvanced.aop.member.MemberServiceImpl.hello(String), obj = test value
[@target] String cwchoiit.springadvanced.aop.member.MemberServiceImpl.hello(String), obj = @cwchoiit.springadvanced.aop.member.annotation.ClassAop()
[@within] String cwchoiit.springadvanced.aop.member.MemberServiceImpl.hello(String), obj = @cwchoiit.springadvanced.aop.member.annotation.ClassAop()
그리고 한가지 더, 원래는 @annotation 지시자를 사용할 때 패키지명부터 쭉 써줘야 한다. 아래와 같이 말이다. 근데 위에서처럼 저렇게 파라미터로 애노테이션 타입을 명시하면 이름으로 치환할 수 있다.
똑같이 MemberService를 조건으로 입력해도 this는 스프링 빈으로 등록된 프록시를, target은 스프링 빈으로 등록된 프록시가 참조하는 실제 객체를 바라본다는 뜻인데 이게 뭐 큰 의미가 있고 달라지나 싶을 수 있다. 그러나,JDK 동적 프록시와 CGLIB의 프록시 생성 방식이 다르기 때문에 차이점이 발생할 수 있다.
JDK 동적 프록시일 때
이 방식은 인터페이스가 필수이고 인터페이스를 구현한 프록시 객체를 생성한다. 다음이 그 그림이다.
그럼 이 방식으로 프록시를 만들 때 this와 target 지시자가 어떻게 다른지 보자.
MemberService 인터페이스 지정
this(hello.aop.member.MemberService)
proxy 객체를 보고 판단한다. this는 부모 타입을 허용한다. 프록시는 인터페이스인 MemberService를 참조하므로 AOP가 적용된다.
target(hello.aop.member.MemberService)
target 객체를 보고 판단한다. target은 부모 타입을 허용한다. target이 상속받는 MemberService가 있으므로 AOP가 적용된다.
MemberServiceImpl 구체 클래스 지정
this(hello.aop.member.MemberServiceImpl)
proxy 객체를 보고 판단한다. 프록시 객체의 부모는 MemberService 인터페이스이다. 인터페이스 위에 있는 것은 없다. MemberServiceImpl에 대한 정보를 아예 알 수 없으므로 AOP 적용 대상이 아니다.
target(hello.aop.member.MemberServiceImpl)
target 객체를 보고 판단한다. target은 바로 MemberServiceImpl 구체 클래스이므로 AOP 적용 대상이다.
결론은 JDK 동적 프록시는 this로 구체 클래스를 받으면 AOP 적용 대상이 아니게 된다. 반면, CGLIB는 어떨까?
CGLIB 프록시일 때
MemberService 인터페이스 지정
this(hello.aop.member.MemberService)
this는 proxy 객체를 바라본다. 프록시 객체는 구체 클래스인 MemberServiceImpl을 상속받는다. 그리고 이 구체 클래스는 부모인 MemberService를 알고 있다. this는 부모 타입을 허용하므로 AOP 적용 대상이다.
target(hello.aop.member.MemberService)
target은 실제 target 객체를 바라본다. target 객체인 MemberServiceImpl의 부모인 MemberService가 있다. target은 부모 타입을 허용하므로 AOP 적용 대상이다.
MemberServiceImpl 구체 클래스 지정
this(hello.aop.member.MemberServiceImpl)
this는 proxy 객체를 바라본다. 프록시 객체는 구체 클래스인 MemberServiceImpl을 상속받는다. this는 부모 타입을 허용하므로 AOP 적용 대상이다.
target(hello.aop.member.MemberServiceImpl)
target은 실제 target 객체를 바라본다. target 객체가 MemberServiceImpl이므로 AOP 적용 대상이다.
결론은 CGLIB 프록시는 모든 경우에 AOP 적용 대상이 된다. 그리고 스프링은 기본으로 CGLIB로 프록시를 만들어낸다.
이 라이브러리를 추가한 후에 다운된 외부 라이브러리 목록을 보면 다음 라이브러리가 있어야 한다.
이 라이브러리를 추가하면 스프링이 자동으로 무엇을 등록해준다고 했던가? 바로 빈 포스트 프로세서 중 AnnotationAwareAspectJAutoProxyCreator이 녀석을 등록해준다고 했다.
이 빈 포스트 프로세서는 빈으로 등록된 어드바이저, @Aspect 애노테이션이 붙은 빈(꼭 빈으로 등록해야 한다!)을 모두 찾아서 그 안에 포인트컷과 어드바이스를 통해 어드바이저로 만들어 둔 후, 모든 빈들에 대해 프록시가 적용될 수 있는지를 검토 후 적용해야 한다면 적용하여 프록시로 빈을 등록하거나 적용대상이 아니라면 빈을 그대로 빈으로 등록해주는 빈 포스트 프로세서다. 다시 복습 차원에서!
예제 프로젝트 만들기
AOP를 적용할 예제 프로젝트를 만들어보자. 지금까지 학습했던 내용과 비슷하다.
OrderRepository
package cwchoiit.springadvanced.aop.order;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
@Slf4j
@Repository
public class OrderRepository {
public String save(String itemId) {
log.info("[orderRepository] 실행");
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
return "ok";
}
}
OrderService
package cwchoiit.springadvanced.aop.order;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public void orderItem(String itemId) {
log.info("[orderService] 실행");
orderRepository.save(itemId);
}
}
@Around 애노테이션의 값인 "execution(* cwchoiit.springadvanced.aop.order..*(..))"는 포인트컷이 된다.
@Around 애노테이션의 메서드인 doLog는 어드바이스(Advice)가 된다.
"execution(* cwchoiit.springadvanced.aop.order..*(..))"는 cwchoiit.springadvanced.aop.order 패키지와 그 하위 패키지(..)를 지정하는 AspectJ 포인트컷 표현식이다. 앞으로는 간단히 포인트컷 표현식이라고 하겠다.
이제 OrderService, OrderRepository의 모든 메서드는 AOP 적용 대상이 된다. 위 포인트컷 조건을 만족하니까.
이렇게만 만들었다고 해서 AOP가 바로 적용되는 것은 아니다. 이 @Aspect 애노테이션이 달린 클래스를 스프링 빈으로 등록해줘야 한다.
참고로, 스프링 AOP는 AspectJ의 문법을 차용하고, 프록시 방식의 AOP를 제공한다. AspectJ를 직접 사용하는 것이 아니다. 스프링 AOP를 사용할 때는 @Aspect 애노테이션을 주로 사용하는데, 이 애노테이션도 AspectJ가 제공하는 애노테이션이다.
또한, @Aspect를 포함한 `org.aspectj` 패키지 관련 기능은 aspectjweaver.jar 라이브러리가 제공하는 기능이다. 앞서, build.gradle에 spring-boot-starter-aop를 포함했는데 이렇게 하면 스프링의 AOP 관련 기능과 함께 aspectjweaver.jar도 함께 사용할 수 있게 의존 관계에 포함된다. 그런데 스프링에서는 AspectJ가 제공하는 애노테이션이나 관련 인터페이스만 사용하는 것이고 실제 AspectJ가 제공하는 컴파일, 로드타임 위버 등을 사용하는 것은 아니다. 스프링은 지금까지 우리가 학습한 것처럼 프록시 방식의 AOP를 사용한다.
AopTest - 테스트 코드
package cwchoiit.springadvanced.aop;
import cwchoiit.springadvanced.aop.aspect.AspectV1;
import cwchoiit.springadvanced.aop.order.OrderRepository;
import cwchoiit.springadvanced.aop.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.*;
@Slf4j
@SpringBootTest
@Import(AspectV1.class) //@Import 만으로도 빈으로 등록하는것과 동일하다. 주로 @Configuration 에서 추가할 때 자주 사용됐지만, @Import 로도 그 안에 클래스들을 빈으로 등록한다.
public class AopTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
void aopInfo() {
log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
}
@Test
void success() {
orderService.orderItem("itemA");
}
@Test
void exception() {
assertThatThrownBy(() -> orderService.orderItem("ex")).isInstanceOf(IllegalStateException.class);
}
}
@Aspect는 애스팩트라는 표식이지, 컴포넌트 스캔이 되는 것은 아니다! 따라서 AspectV1을 AOP로 사용하려면 반드시 스프링 빈으로 등록을 해야 한다!
스프링 빈으로 등록하는 방법은 여러가지가 있다.
@Bean을 사용해서 직접 등록
@Component 컴포넌트 스캔을 사용해서 자동 등록
@Import 주로 설정 파일을 추가할 때 사용하지만, 이 기능으로 스프링 빈도 등록할 수 있다.
실행 결과 - success()
[log] void cwchoiit.springadvanced.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String cwchoiit.springadvanced.aop.order.OrderRepository.save(String)
[orderRepository] 실행
어드바이스 기능이 적용된 모습을 확인할 수 있을 것이다.
스프링 AOP 구현2 - 포인트컷 분리
@Around에 포인트컷 표현식을 직접 넣을 수도 있지만, @Pointcut 애노테이션을 사용해서 별도로 분리할 수도 있다.
AspectV2
package cwchoiit.springadvanced.aop.aspect;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
@Slf4j
@Aspect
public class AspectV2 {
/**
* 반환 타입은 'void' 여야 한다.
* 다른 곳에서 이 포인트컷을 사용하려면 public 이어야 하고 이 내부 안에서 사용하는 건 private 이어도 된다.
* */
@Pointcut("execution(* cwchoiit.springadvanced.aop.order..*(..))")
private void allOrder() {} // pointcut signature
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Pointcut에 포인트컷 표현식을 사용한다.
메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처라 한다.
메서드의 반환 타입은 void여야 한다.
블록 내부는 비워둔다.
포인트컷 시그니처는 allOrder()이다. 이름 그대로 주문과 관련된 모든 기능을 대상으로 하는 포인트컷이다.
@Around 어드바이스에서는 포인트컷을 직접 지정해도 되지만, 포인트컷 시그니처를 사용해도 된다. 여기서는 @Around("allOrder()")를 사용한다.
private, public 같은 접근 제어자는 내부에서만 사용하면 private을 사용해도 되지만, 다른 애스팩트에서 참고하려면 public을 사용해야 한다.
이렇게 포인트컷을 분리하여 얻는 이점은 다음과 같다.
포인트컷에 의미를 부여할 수 있다. (모든 주문에 대해: allOrder())
여러 어드바이스에서 해당 포인트컷을 가져다가 사용할 수 있다. (쉽게 말해 모듈화가 된다는 것)
이 애스팩트(AspectV2)를 임포트해서 테스트 코드를 돌려도 동일한 결과를 얻는다.
AopTest - 테스트 코드
package cwchoiit.springadvanced.aop;
import cwchoiit.springadvanced.aop.aspect.AspectV1;
import cwchoiit.springadvanced.aop.aspect.AspectV2;
import cwchoiit.springadvanced.aop.order.OrderRepository;
import cwchoiit.springadvanced.aop.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.*;
@Slf4j
@SpringBootTest
@Import(AspectV2.class) //@Import 만으로도 빈으로 등록하는것과 동일하다. 주로 @Configuration 에서 추가할 때 자주 사용됐지만, @Import 로도 그 안에 클래스들을 빈으로 등록한다.
public class AopTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
void aopInfo() {
log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
}
@Test
void success() {
orderService.orderItem("itemA");
}
@Test
void exception() {
assertThatThrownBy(() -> orderService.orderItem("ex")).isInstanceOf(IllegalStateException.class);
}
}
실행 결과 - success()
[log] void cwchoiit.springadvanced.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String cwchoiit.springadvanced.aop.order.OrderRepository.save(String)
[orderRepository] 실행
스프링 AOP 구현3 - 어드바이스 추가
이번에는 어드바이스를 하나 더 추가해서 좀 더 복잡한 예제를 만들어보자.
앞서, 로그를 출력하는 기능에 추가로 트랜잭션을 적용하는 코드도 추가해보자. 여기서는 진짜 트랜잭션을 실행하는 것은 아니고 기능이 동작하는 것처럼 로그만 남겨보자.
AspectV3
package cwchoiit.springadvanced.aop.aspect;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
@Slf4j
@Aspect
public class AspectV3 {
/**
* 반환 타입은 'void' 여야 한다.
* 다른 곳에서 이 포인트컷을 사용하려면 public 이어야 하고 이 내부 안에서 사용하는 건 private 이어도 된다.
*/
@Pointcut("execution(* cwchoiit.springadvanced.aop.order..*(..))")
private void allOrder() {
} // pointcut signature
// 클래스 이름 패턴이 *Service
@Pointcut("execution(* *..*Service.*(..))")
private void allService() {
}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
@Around("allOrder() && allService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
}
allOrder() 포인트컷은 cwchoiit.springadvanced.aop.order 패키지와 그 하위 패키지를 대상으로 한다.
allService() 포인트컷은 타입 이름 패턴이 *Service를 대상으로 하는데 쉽게 이야기해서, XxxService처럼 Service로 끝나는 것을 대상으로 한다.
여기서 타입 이름 패턴이라고 한 이유는 클래스, 인터페이스에 모두 적용되기 때문이다.
@Around("allOrder() && allService()")
포인트컷은 이렇게 조합할 수도 있다. &&, ||, ! 3가지 조합이 가능하다.
cwchoiit.springadvanced.aop.order 패키지와 그 하위 패키지이면서 타입 이름 패턴이 *Service인 것을 대상으로 한다.
결과적으로 doTransaction() 어드바이스는 OrderService에만 적용된다.
doLog() 어드바이스는 OrderService, OrderRepository에 모두 적용된다.
orderService에는 doLog(), doTransaction() 두가지 어드바이스가 적용되어 있고, orderRepository에는 doLog() 하나의 어드바이스만 적용된 것을 확인할 수 있다. 그런데, 여기에서 로그를 남기는 순서가 [doLog()→doTransaction()] 순서로 작동한다. 만약, 어드바이스가 적용되는 순서를 변경하고 싶으면 어떻게 하면 될까? 예를 들어서 실행 시간을 측정해야 하는데 트랜잭션과 관련된 시간을 제외하고 측정하고 싶다면 [doTransaction()→doLog()] 이렇게 트랜잭션 이후에 로그를 남겨야 할 것이다. 그 방법을 알아보자!
스프링 AOP 구현4 - 포인트컷 참조
다음과 같이 포인트컷으르 공용으로 사용하기 위해 별도의 외부 클래스에 포인트컷들을 모아두어도 된다. 참고로 외부에서 호출할 때는 포인트컷의 접근 제어자를 public으로 열어두어야 한다.
Pointcuts
package cwchoiit.springadvanced.aop.aspect;
import org.aspectj.lang.annotation.Pointcut;
public class Pointcuts {
@Pointcut("execution(* cwchoiit.springadvanced.aop.order..*(..))")
public void allOrder() {
}
@Pointcut("execution(* *..*Service.*(..))")
public void allService() {
}
@Pointcut("allOrder() && allService()")
public void allOrderAndService() {
}
}
allOrderAndSerivce()는 allOrder() 포인트컷과 allService() 포인트컷을 조합해서 새로운 포인트컷을 만들 수도 있다는 것을 보여주기 위함이다.
이 클래스가 실제로 외부의 포인트컷을 가져다가 사용하는 방식이다. @Around 애노테이션은 외부 포인트컷을 참조하면 된다. 살짝 불편한 부분은 패키지명까지 작성해줘야 한다는 것인데 이는 어쩔 수 없다. 문자로 입력해야 하기 때문에.
이러한 에스팩트를 가지고 위에서 사용한 테스트 코드를 수행해도 여전히 동일하게 동작한다.
스프링 AOP 구현5 - 어드바이스 순서
어드바이스는 기본적으로 순서를 보장하지 않는다. 순서를 지정하고 싶으면 @Aspect 적용 단위로 org.springframework.core.annotation.Order 애노테이션을 적용해야 한다. 문제는 이것을 어드바이스 단위가 아니라 @Aspect 적용 단위, 즉, 클래스 단위로 적용할 수 있다는 점이다. 그래서 지금처럼 하나의 애스팩트에 여러 어드바이스가 있으면 순서를 보장받을 수 없고 애스팩트를 별도의 클래스로 분리해야 한다.
첫번째, 우선 모든 어드바이스는 JoinPoint를 첫번째 파라미터로 받을 수 있다. 생략도 가능하다. 그러나, @Around는 반드시 ProceedingJoinPoint를 받아야 한다.
그 이유는 @Around 같은 경우 개발자가 직접 타겟을 호출하는 코드를 작성해야 한다.joinPoint.proceed()이 코드. 그 외 나머지 어드바이스는 개발자가 직접 타겟을 호출하지 않는다. 그래서 @Around는 ProceedingJoinPoint를 첫번째 파라미터로 받아야 하고 그 외 나머지 어드바이스는 JoinPoint를 받거나 생략할 수 있다.
두번째, @Before는 실제 타겟을 호출하는 코드를 작성안하지만 @Before의 모든 코드가 다 수행되면 자동으로 호출한다. 물론, 예외가 발생할 경우엔 다음 코드가 호출되지는 않는다.
세번째, @AfterReturning, @AfterThrowing은 각각 실제 타겟 호출의 결과와 에러를 파라미터로 받고 그 파라미터의 이름은 애노테이션에서 작성한 이름과 동일해야 한다.
네번째, @AfterReturning, @AfterThrowing에서 파라미터로 받는 실제 타겟 호출 반환값과 에러의 타입은 해당 타입과 일치하거나 그 상위 타입이어야 한다.
다섯번째, @AfterReturning에서는 @Around와 다르게 실제 타겟 호출 반환값에 대한 변경이 불가능하다.
이는 단순하게 생각해보면 된다. @Around는 개발자가 직접 실제 타겟을 호출하여 돌려받는 결과를 리턴하는데 그렇기 때문에 리턴값에 변경이 가능한것이고 @AfterReturning은 그렇지 않기 때문에 불가능한 것. 다만, 이 반환값을 가지고 어떤 행동을 취할 순 있다. 그 반환값을 변경하지 못한다는 말이다.
여섯번째, @Around는 joinPoint.proceed()를 여러번 호출할 수도 있다.
일곱번째, @Around는 joinPoint.proceed()를 반드시 호출해야 한다. 그래야 다음 어드바이스 또는 실제 객체를 호출할 수 있다.
여덟번째, @After는 메서드 실행이 정상적이든 예외가 발생하든 상관없이 종료되면 실행된다.
참고로, ProceedingJoinPoint는 JoinPoint의 하위 타입이다.
JointPoint 인터페이스의 주요 기능
getArgs() → 메서드 인수를 반환한다.
getThis() → 프록시 객체를 반환한다.
getTarget() → 대상 객체를 반환한다.
getSignature() → 조인 포인트(스프링 AOP면 메서드에 한함)에 대한 여러 정보를 반환한다.
toString() → 포인트컷에 대한 설명을 인쇄한다.
ProceedingJoinPoint 인터페이스의 주요 기능
proceed() → 다음 어드바이스나 타겟을 호출한다.
이 여러 어드바이스의 호출 순서는 다음과 같다.
스프링은 5.2.7 버전부터 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 정했다.
적용되는 순서는 이렇게 적용되지만, 호출 순서와 리턴 순서는 반대라는 점을 알아두자. 위 그림을 보면 이해가 될 것이다.
물론, @Aspect 안에 동일한 종류의 어드바이스가 2개 이상이면 순서가 보장되지 않는다. 이 경우에 보장된 순서를 원한다면 @Aspect를 분리해서 @Order를 적용해서 순서를 적용해야 한다.
그럼 왜 @Around만 사용하더라도 모든게 가능한데 이렇게 부분적으로 나뉘어진 어드바이스가 있을까?
이 부분에 대한 답은 이런것들이다. 다음 코드엔 심각한 문제가 있다.
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doBefore(ProceedingJoinPoint joinPoint) {
log.info("[before] {}", joinPoint.getSignature());
}
어떤 문제가 있을까? 바로 @Around 어드바이스인데도 실제 객체를 호출하지 않는다. 이 코드를 작성한 개발자의 의도는 실제 객체를 호출하기 전에 무언가를 로그로 출력하고 싶었던 것 뿐인데 @Around이기 때문에 실제 객체를 반드시 호출해야 한다.
그럼 이 코드를 보자. 이 코드에는 문제가 전혀 없다.
@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doBefore(JoinPoint joinPoint) {
log.info("[before] {}", joinPoint.getSignature());
}
@Before이기 때문에 실제 객체를 호출하는 고민을 전혀 할 필요가 없다.
이 말은, @Around는 가장 넓은 기능을 제공하나 실수할 가능성이 있다. 반면, @Before, @After 같은 어드바이스는 기능은 적더라도 실수할 가능성이 적으며 코드가 단순해진다. 그리고 가장 중요한 부분은 이 코드를 작성한 의도가 분명해진다는 것이다. @Before 애노테이션을 본 순간 "아, 이 코드는 실제 객체를 호출하기 전에 무언가를 하기 위해 만들어진 어드바이스구나." 라고 자연스레 생각할 수 있다.
즉, 좋은 설계는 제약이 있는 것이다. 제약은 실수의 가능성을 줄여준다. 애시당초 @Around가 아니라 @Before를 사용하면 실제 객체를 호출할 고민조차 할 필요가 없기 때문에 그 부분을 고려하지 않아도 되는것이다.