브라우저가 웹 서버에 접속하여 받아온 정적 컨텐츠 (html, 이미지, js 등)를 메모리 또는 디스크에 저장해 놓는 것을 말한다. 이후 HTTP 요청을 할 경우 해당 리소스가 캐시에 있는지 확인하고 이를 재사용함으로써 응답시간과 네트워크 대역폭을 줄일 수 있다.
웹 캐시의 장점
1. 불필요한 네트워크 통신을 줄인다.
클라이언트가 서버에게 문서를 요청할 때, 서버는 해당 문서를 클라이언트에게 전송하게 된다. 재차 똑같은 문서를 요청할 경우 똑같이 전송하게 된다.
캐시를 이용하면, 첫번째 응답은 브라우저 캐시에 보관되고, 클라이언트가 똑같은 문서를 요청할 경우 캐시된 사본이 이에대한 응답으로 사용될 수 있기 때문에, 중복해서 트래픽을 주고받는 네트워크 통신을 줄일 수 있다.
2. 네트워크 병목을 줄여준다.
많은 네트워크가 원격 서버보다 로컬 네트워크에 더 넓은 대역폭을 제공한다. WAN 보다 LAN이 구성이 더 쉽고, 거리도 가까우며, 비용도 적게들기 때문이다. 만약 클라이언트가 빠른 LAN에 있는 캐시로부터 사본을 가져온다면, 캐싱 성능을 대폭 개선할 수 있다.
예를들어 샌프란시스코 지사에 있는 사용자는 애틀랜타 본사로부터 문서를 받는데 30초가 걸릴 수 있다. 만약 이 문서가 샌프란시스코의 사무실에 캐시되어 있다면, 로컬 사용자는 같은 문서를 이더넷 접속, 즉 LAN을 통해 1초 미만으로 가져올 수 있을 것이다.
3. 거리로 인한 네트워크 지연을 줄여준다.
대역폭이 문제가 되지 않더라도, 거리가 문제될 수 있다. 만약 보스턴과 샌프란시스코 사이에 네트워크 통신을 한다면 그 거리는 4,400 킬로미터이고, 신호의 속도를 빛의 속도(300,000 킬로미터 / 초)로 가정하면 편도는 약 15ms, 왕복은 30ms 가 걸린다.
만약 웹페이지가 20개의 작은 이미지를 포함하고 있다면, 이 속도에 비례하여 통신 시간이 소요된다. 추가 커넥션에 의한 병렬처리가 이 속도를 줄일 수 있지만, 이보다 더 떨어진 거리거나, 훨씬 더 복잡한 웹페이지일 경우 거리로 인한 속도 지연은 무시할 수 없다.
캐시는 이러한 거리를 수천 킬로미터에서 수십 미터로 줄일 수 있다.
4. 갑작스런 요청 쇄도(Flash Crowds)에 대처 가능하다.
원 서버로의 요청을 줄이기때문에 갑작스런 요청 쇄도 (Flash Crowds) 에 대처할 수 있다.
적중과 부적중 (cache hit, cache miss)
캐시에 요청이 도착했을 때, 그에 대응하는 사본이 있다면 이를 이용해 요청이 처리될 수 있다. 이를 캐시 적중(cache hit)라고 하고, 대응하는 사본이 없다면 원 서버로 요청이 전달된다. 이를 캐시 부적중(cache miss)라고 한다.
웹 캐시 체감하기
1. 기본 셋팅
실제로 웹 캐시를 적용했을때와 그렇지 않았을 때의 차이를 비교해보자. 테스트 환경으로는 웹서버인 Apache 2.4 버전을 사용했으며, 보다 확실하게 체감하기 위해 Network 속도를 Slow 3G로 설정하였다. 이는 크롬 개발자 도구에서 설정 가능하다.
1. 웹 캐시로부터 읽어오지 않은 응답
1) 최초 HTTP 통신
최초 웹서버 접속 시 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 로딩하기 위해 서버로 요청하고 있다. 이때 해당 리소스의 Size는 5~20 kb, Time은 약 2초 정도 걸렸다.
2) 두번째 HTTP 통신
이후 동일 URI로 재요청 한다. 마찬가지로 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 로딩하기 위해 다시 서버로 요청하고 있다. Size와 Time 모두 이 전 요청과 동일하다. 이를 통해 정적데이터가 필요할 때마다 서버로 요청한다는 것을 알 수 있다.
2. 웹 캐시로부터 읽어온 응답
1) 최초 HTTP 통신
최초 웹서버 접속 시 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 읽어오기 위해 서버로 요청하고 있다. 이때 해당 리소스의 Size는 5~20 kb, Time은 약 2초 정도 걸렸다.
2) 두번째 HTTP 통신
이후 동일 URI로 재요청 한다. 마찬가지로 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 읽고 있다. 그런데 다른점이 있다. Size에 memory cache 가 적혀있고, Time은 0ms이다. 해당 리소스를 서버가 아닌 memory에서 조회했다는 것을 알 수 있다. 이를 통해 정적데이터가 캐시에 저장되어 있을 경우 캐시에서 로드한다는 것을 알 수 있다.
캐시 옵션 설정하기
캐시는 Cache-Control이라는 HTTP Header로 설정할 수 있다. 먼저 해당 옵션을 살펴보자.
1. Cache-Control
설정 값
내용
no-store
캐시에 리소스를 저장하지 않는다.
no-cache
캐시 만료기간에 상관하지 않고 항상 원 서버에게 리소스의 재검사를 요청한다.
must-revalidate
캐시 만료기간이 지났을 경우에만 원 서버에게 리소스의 재검사 요청한다.
public
해당 리소스를 캐시 서버에 저장한다.
private
해당 리소스를 캐시 서버에 저장하지 않는다. 개인정보성 리소스이거나 보안이 필요한 리소스의 경우 이 옵션을 사용한다.
max-age
캐시의 만료기간(초단위)을 설정한다.
※ 재검사가 뭔가요?
재검사(Revalidation)는 신선도 검사라고도 하며, 캐시가 갖고 있는 사본이 최신 데이터인지를 서버를 통해 검사하는 작업을 말한다. 최신 데이터인 경우 304 Not Modifed 응답을 받게 되는데, 이는 '캐시에 있는 사본 데이터가 최신이며, 수정되지 않았다'라는 뜻을 의미한다. 이 경우 클라이언트는 해당 리소스를 캐시로부터 로드하게 된다.
2. Apache httpd.conf 설정
Apache 웹서버의 httpd.conf를 통해 설정할 수 있다. 필자의 경우 캐시의 만료기간을 10초로 설정하기 위해 아래 구문을 최하단에 넣어주었다. 서버를 재시작하여 설정을 적용하고 서버로 요청을 보내보자.
Header Set Cache-Control "max-age=10"
만료기간 10초가 지나기 전에 다시 요청할 경우 캐시 메모리에 저장된 리소스를 가져옴을 확인할 수 있다.
3. HTTP Status 304
캐시의 만료기간인 10초가 지나자 재검사를 진행했고, 서버의 리소스가 바뀌지 않아 304 상태코드를 리턴받고 있다. 그럼 재검증은 HTTP 메시지의 어떤 값을 통해 확인할 수 있는걸까?
서버를 통해 리소스를 응답받으면 Response Header에 해당 리소스의 마지막 수정날짜가 들어간다. 아래 이미지를 보면 Last-Modified 헤더에 Sat, 04 May 2013 12:52:00 GMT로 되어있다.
이를 우리나라 시간으로 환산하면 2013년 5월 4일 21시 52분인데, 실제 서버에 있는 리소스의 마지막 수정날짜이다.
이 후 서버로 리소스 요청을 보낼 때 요청 헤더의 If-Modified-Since 에 리소스의 마지막 수정날짜를 보낸다. 서버는 이 값과 실제 수정 날짜를 비교하여 일치하지 않을 경우, 즉 리소스가 변경된 경우에 200 코드와 함께 해당 리소스를 내려준다.
리소스가 변경되지 않았을 땐 304를, 리소스가 삭제되었다면 404를 응답한다.
캐시 포톨로지
캐시는 한 명의 사용자에게만 할당될 수 있고, 수천 명의 사용자에게 공유될 수도 있다. 한명에게만 할당된 캐시를 전용 캐시, private cache라 하고, 여러 사용자가 공유하는 캐시는 공용 캐시, public cache 라고 한다.
private cache의 대표적인 예는 방금 설명했던 브라우저 캐시이다. 웹 브라우저는 개인 전용 캐시를 내장하고 있으며 컴퓨터의 디스크 및 메모리에 캐시해놓고 사용한다.
public cache의 대표적인 예는 프락시 캐시라고 불리는 프락시 서버이다. 각각 다른 사용자들의 요청에 대해 공유된 사본을 제공할 수 있어 private cache보다 네트워크 트래픽을 줄일 수 있다.
캐시 처리 단계
웹 캐시의 기본적인 동작은 총 일곱 단계로 나뉘어져 있다.
1. 요청 받기
먼저 캐시는 네트워크 커넥션에서의 활동을 감지하고, 들어오는 데이터를 읽어들인다. 즉, 서버로 요청하기 전 캐시에서 선 작업이 진행된다. (캐시는 HTTP의 응용계층에서 처리된다.)
2. 파싱
캐시는 요청 메시지를 여러 부분으로 파싱하여 헤더 부분을 조작하기 쉬운 자료구조에 담는다. 이는 캐싱 소프트웨어가 헤더 필드를 처리하고 조작하기 쉽게 만들어준다.
3. 검색
캐시는 URL을 알아내고 그에 해당하는 로컬 사본이 있는지 검사한다. 만약 문서를 로컬에서 가져올 수 없다면, 그것을 원 서버를 통해 가져오거나 실패를 반환한다.
4. 신선도 검사
HTTP는 캐시가 일정 기간 동안 서버 문서의 사본을 보유할 수 있도록 해준다. 이 기간동안 문서는 신선하다고 간주되고 캐시는 서버와의 접촉 없이 이 문서를 제공할 수 있다. 하지만 max-age를 넘을 정도로 너무 오래 갖고 있다면, 그 객체는 신선하지 않은 것으로 간주되며, 캐시는 그 문서를 제공하기 전 문서에 어떤 변경이 있었는지 검사하기 위해 서버와 통신하여 재검사 작업을 진행한다.
5. 응답 생성
캐시는 캐시된 응답을 원 서버에서 온 것처럼 보이게 하고 싶기 때문에, 캐시된 서버 응답 헤더를 토대로 응답 헤더를 새로 생성한다.
6. 전송
응답 헤더가 준비되면, 캐시는 응답을 클라이언트에게 돌려준다.
7. 로깅
대부분의 캐시는 로그 파일과 캐시 사용에 대한 통계를 유지한다. 각 캐시 트랜잭션이 완료된 후, 캐지 적중과 부적중 횟수에 대한 통계를 갱신하고, 로그파일에 요청 종류, URL 그리고 무엇이 일어났는지를 알려주는 항목을 추가한다.
올해 6월부터 코로나 위기 단계가 하향 조정됨에 따라 7일 격리였던 격리 수준이 5일로 완화되었다. :) 어떤 단계에 따라 격리 수준 변경되고 있는데 우리가 사용하는 DB, 트랜잭션에도 격리 수준이란게 존재한다. 트랜잭션 격리수준이란 무엇인지 차근차근 이해해보자.
2. 트랜잭션 격리수준이란?
2.1. 정의
트랜잭션 격리수준이란 동시에 여러 트랜잭션이 처리될 때, 트랜잭션끼리 얼마나 고립(격리)되어 있는가에 대한 수준을 말한다.
2.2. 트랜잭션은 원래 격리된거 아닌가?
트랜잭션의 성질 중 격리성(Isolation)이 있다. 격리성이란 트랜잭션 수행 시 다른 트랜잭션이 끼어들이 못하도록 보장하는 것을 말한다. 이는 '트랜잭션은 서로 완전히 격리되어 있으니 다른 트랜잭션이 끼어들지 못합니다. 땅땅땅!' 이 아니다. 격리성을 강화시킨다면 다른 트랜잭션이 끼어들지 못하게 보장할 수 있고, 약화시킨다면 다른 트랜잭션도 중간에 끼어들 수 있다는 의미이다. 코로나처럼 7일간 무조건 집콕일수도 있고, 5일간 집콕 권고일수도 있다.
그럼 이렇게 격리수준을 나눈 이유는 뭘까? 먼저 트랜잭션의 성질을 살펴보자.
2.3. 트랜잭션 ACID
1) Atomicity (원자성)
한 트랜잭션의 연산은 모두 성공하거나, 모두 실패해야한다. 예를들어 돈을 이체할 때 보내는 쪽에서 돈을 빼오는 작업만 성공하고, 받는 쪽에 돈을 넣는 작업은 실패하면 안된다.
2) Consistency (일관성, 정합성)
트랜잭션 처리 후에도 데이터의 상태는 일관되어야 한다. '모든 계좌 정보에는 계좌 번호가 있어야 한다.'라는 제약 조건 즉, 상태가 걸려있을 때, 계좌번호를 삭제하거나 계좌번호가 없는 상태로 계좌정보를 추가하려 한다면 '모든 계좌 정보에는 계좌 번호가 있어야 한다' 에서 '모든 계좌 정보에는 계좌 번호가 없어도 된다' 라는 상태로 변경되게 된다. 때문에 상태를 변화시키는 트랜잭션은 실패하는것이다.
3) Isolation (격리성)
트랜잭션 수행 시 다른 트랜잭션의 연산이 끼어들이 못하도록 보장한다. 하나의 트랜잭션이 다른 트랜잭션에게 영향을 주지 않도록 격리되어 수행되어야한다.
4) Durability (지속성)
성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다. 중간에 DB에 오류가 발생해도 다시 복구되어야 한다. 즉, 트랜잭션에 대한 로그가 반드시 남아야한다는 의미하기도 한다.
2.4. 트랜잭션의 Isolation
여기서 격리성을 따져보자. 트랜잭션 수행 중간에 다른 트랜잭션이 끼어들수 없다면 어떻게될까? 모든 트랜잭션은 순차적으로 처리될 것이고, 데이터의 정확성은 보장될 것이다. 속도는 어떨까? 트랜잭션이 많아질수록 처리를 기다리는 트랜잭션은 쌓여간다. 앞에 있는 트랜잭션이 기다리는 시간만큼 대기 시간은 늘어난다. 결국 트랜잭션이 처리되는 속도가 느려지게 되고, 어플리케이션 운용에 심각한 문제가 발생할 수 있다.
결국 준수한 처리 속도를 위해서는 트랜잭션의 완전한 격리가 아닌 완화된 수준의 격리가 필요하다. 이처럼 속도와 데이터 정확성에 대한 트레이드 오프를 고려하여 트랜잭션의 격리성 수준을 나눈것이 바로 트랜잭션의 격리수준이다.
3. 트랜잭션 격리수준 단계
3.1. DBMS마다 다른 격리수준
트랜잭션 격리수준은 총 4단계로 Uncommitted Read, Committed Read, Repeatable Read, Serializable 로 구성된다. DBMS 마다 격리 수준에 대한 내용이 다를 수 있으니 보다 정확하게 알기 위해서는 공식 문서를 확인해야 한다. 필자는 MySQL 에서 제공하는 격리수준을 기준으로 하였으며, 필요에 따라서는 타 DBMS에서의 격리수준도 비교 분석하였다. 참고로 MySQL의 기본 격리수준은 Repeatable Read 이다.
먼저 격리수준이 가장 낮은 Uncommitted Read부터 알아보자.
3.2. Uncommitted Read (커밋되지 않은 읽기)
다른 트랜잭션에서 커밋되지 않은 데이터에 접근할 수 있게 하는 격리 수준이다. 가장 저수준의 격리수준이며, 일반적으로 사용하지 않는 격리수준이다.
10번 트랜잭션이 '박기영'이라는 데이터를 '박경'으로 UPDATE 한 후 Commit 하지 않았을 때 13번 트랜잭션에서 접근하여 커밋되지 않은 데이터를 읽을 수 있다.
그런데 13번 트랜잭션이 데이터를 읽은 후 10번 트랜잭션에 문제가 발생하여 롤백된다면 데이터 부정합을 발생시킬 수 있다.
데이터 부정합은 어플리케이션에 치명적인 문제를 야기할 수 있다. 그래서인지 오라클에서는 이 수준을 아예 지원하지 않는다. 이처럼 커밋되지 않는 트랜잭션에 접근하여 부정합을 유발할 수 있는 데이터를 읽는 것을 더티읽기(Dirty Read)라고 한다.
3.2 Committed Read (커밋된 읽기)
다른 트랜잭션에서 커밋된 데이터로만 접근할 수 있게 하는 격리 수준이다. MySQL을 제외하고 대부분 이를 기본 격리수준으로 사용한다.
10번 트랜잭션이 '박기영'이라는 데이터를 '박경'으로 UPDATE 한 후 Commit 하지 않았을 때, 13번 트랜잭션에서 이를 조회할 경우 UPDATE 전 데이터인 '박기영' 이라는 값이 조회된다. Dirty Read 현상은 발생하지 않는다.
그럼 어떻게 Read Committed는 UPDATE 전 값을 조회한걸까? 그 키는 바로 Undo 영역에 있다.
※ Undo 영역
앞서 살펴본 트랜잭션의 성질 중 지속성(Durability)을 보면 다음과 같이 정의되어 있다.
성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다. 중간에 DB에 오류가 발생해도, 다시 복구되어야 한다. 즉, 트랜잭션에 대한 로그가 반드시 남아야한다는 성질을 의미하기도 한다.
트랜잭션에 대한 로그가 반드시 남아있어야 한다. 즉, 복구는 로그를 기반으로 처리된다. 이 로그는 크게 두 가지가 있다. 오류에 의한 복구에 사용되는 Redo Log와 트랜잭션 롤백을 위해 사용되는 Undo Log이다.
다시실행의 뜻을 갖는 Redo는 커밋된 트랜잭션에 대한 정보를 갖고 있고(왜? 복구하려면 다시 실행해줘야하니까), 실행 취소의 뜻을 갖는 Undo는 데이터베이스의 변경이 발생할 경우 변경되기 전 값과 이에 대한 PK 값을 갖고 있다(왜? 롤백하면 다시 되돌려야 하니까)
그런데 Undo 영역이라고 말한 이유는 Undo Log가 Undo Log Buffer 형태로 메모리에 저장되고, 특정 시점에 디스크에 저장된 Undo Log File 에 I/O 작업으로 쓰여지기 때문이다. 추가로 이렇게 단계가 나눠지는 이유는 데이터에 변경사항이 생길때마다 Disk에 I/O 작업을 하는것보다 메모리 입력하고, 읽는것이 속도와 리소스 측면에서 유리하기 때문이다.
정리하면, Undo 영역이란 변경 전 데이터가 저장된 영역이고, Commit 하기 전 데이터를 읽어올 수 있는 이유는 Undo 영역에 있는 데이터를 읽어오기 때문이다.
※ Non Repeatable Read(반복 가능하지 않은 읽기) 현상 발생
Committed Read 수준에서는 Non Repeatable Read 현상이 발생한다. 이는 하나의 트랜잭션에서 동일한 SELECT 쿼리를 실행했을 때 다른 결과가 나타나는 것을 말한다.
아래 그림을 보면 13번 트랜잭션이 동일한 SELECT 쿼리를 두 번 실행했을 때 결과가 다른 것을 볼 수 있는데, 10번 트랜잭션이 데이터 UPDATE 후 COMMIT 하기 전, 후에 SELECT 쿼리를 실행했었기 때문이다.
3.3. Repeatable Read (반복 가능한 읽기)
Non Repeatable Read 문제를 해결하는 격리 수준으로, 커밋된 데이터만 읽을 수 있되 자신보다 낮은 트랜잭션 번호를 갖는 트랜잭션에서 커밋한 데이터만 읽을 수 있는 격리수준이다. 이게 가능한 이유는? 그렇다 Undo 로그때문이다. 또한 트랜잭션 ID를 통해 Undo 영역의 데이터를 스냅샷처럼 관리하여 동일한 데이터를 보장하는 것을 MVCC(Multi Version Concurrency Control) 라고 한다.
아래 그림에서 10번 트랜잭션은 10번 보다 작은 트랜잭션에서 커밋한 데이터만 읽을 수 있으므로 13번 트랜잭션에서 변경한 내용은 조회할 수 없다. 같은 SELECT 쿼리가 두 번 실행됐을 때 같은 결과가 조회되므로 Non-Repeatable-Read 현상이 해결됨을 확인할 수 있다.
※ Repeatable Read를 지원하지 않는 오라클?!
오라클은 Repeatable Read 수준을 지원하지 않는다. 그럼 Non Repeatable Read 문제를 해결할 수 없을까? 아니다! 해결할 수 있는 방법이 있다. 바로 Exclusive Lock을 사용하는 방법이다.
※ Exclusive Lock (배타적 잠금 / 쓰기 잠금)
Exclusive Lock이란 특정 레코드나 테이블에 대해 다른 트랜잭션에서 읽기, 쓰기 작업을 할 수 없도록 하는 Lock 이다. SELECT ~ FOR UPDATE (업데이트 하려고 조회하는거에요~ 그러니까 다른 트랜잭션에서 접근못하도록 막아주세요~) 구문을 통해 사용할 수 있다.
아래 참고 자료를 보자. SELECT ~ FOR UPDATE 를 사용하여 조회된 레코드에 대해 Exclusive Lock 을 걸면 다른 트랜잭션에서 해당 레코드에 대해 쓰기 작업 시 LOCK이 해제될때까지 대기하는 것을 볼 수 있다.
그런데 이상한 점이 하나 있다. Exclusive Lock은 읽기나 쓰기 작업을 할 수 없도록 한다고 했는데 아래 gif를 보니 다른 트랜잭션에서 SELECT를 통해 읽기 작업을 하고있다. 이건 바로 MVCC 기술을 통해Undo 영역에서 읽어오는 것이다.
이제 이 과정을 그림으로 이해해보자. 10번 트랜잭션이 select id, name, from user for update 를 실행하여 레코드를 조회함과 동시에 Exclusive Lock이 건다. 다른 트랜잭션에서 접근 시 Lock이 풀릴때까지 대기하게 된다. 이후 10번 트랜잭션이 똑같은 쿼리를 실행해도 처음 조회했던 데이터와 같은 데이터가 조회되게 된다. Non-Repeatable Read 문제가 해결된것이다.
※ Phantom Read (유령 읽기)
Repeatable Read와 Exclusive Lock 를 통해 Non Repeatable Read 문제를 해결했다. 그런데 새로운 문제가 발생한다. 바로 Phantom Read 현상이다.
Phantom Read는 하나의 트랜잭션 내에서 여러번 실행되는 동일한 SELECT 쿼리에 대해 결과 레코드 수가 달라지는 현상을 말한다. Non Repeatable Read는 레코드의 데이터가 달라지는 것을 의미한다면 Phantom Read는 기존 조회했던 레코드의 데이터는 달라지지 않지만, 새로운 레코드가 나왔다가 사라졌다가 하는 것이다. 마치 유령처럼!! :(
먼저 Exclusive Lock 을 보자. UPDATE, DELETE에 대한 Lock을 걸어 읽기, 쓰기 작업을 막을 수 있었지만, INSERT에 대한 LOCK은 걸 수 없다. 그 이유는 조회된 레코드에 대해서만 Exclusive Lock을 거는 것이지 조회되지 않은 레코드, 즉 나중에 추가할 레코드에 대해서는 Lock을 걸지 않기 때문이다.
이는 Exclusive Lock을 사용해도 다른 트랜잭션에서 INSERT 작업이 가능하다는 뜻이고, 아래와 같이 처음엔 조회되지 않았던 레코드가 조회될 수 있다는 것이다. 마치 유령처럼!! :)
※ MySQL 에서는 발생하지 않는 Phantom Read
InnoDB 엔진을 사용하는 MySQL 에서는 Repeatable Read 수준에서 Phantom Read 현상이 발생하지 않는다. 그 이유는 SELECT ~ FOR UPDATE를 통해 Lock을 걸때 Exclusive Lock이 아닌 Next Key Lock 방식을 사용하기 때문이다.
※ Next Key Lock
Next Key Lock은 조회된 레코드에 대한 Lock 뿐 아니라 실행 쿼리에 대한 범위에 설정되는 Lock이다. 즉, Next Key Lock은 Record Lock, Gap Lock 이 조합된 Lock 이다.
예를 들어 SELECT * FROM USERS WHERE ID BETWEEN 0 AND 10 FOR UPDATE쿼리를 실행시키면, 조회된 레코드에 대한 Record Lock과,0 < ID <=10 에 해당하는 범위에 해당하는 Gap Lock이 걸린다. 이뿐 아니다! 마지막으로 조회된 레코드의 Index인 ID에 대해 그 다음 존재하는 ID 까지의 범위를 Gap Lock으로 설정한다. 만약 아래와 같이 2 이후 ID가 20인 레코드가 있다면 2 ~ 20 까지 Gap Lock을 건다.
때문에 다른 트랜잭션에서 SELECT 쿼리를 통해 정해진 GAP에 해당하는 데이터를 INSERT 시도할 경우 Gap Lock으로 인해 대기상태에 들어가기 되고, 이는 기존 트랜잭션의 여러 동일 SELECT 쿼리에 대한 동일성이 보장되게 된다.
ID
NAME
1
심승갱
2
박기영
20
홍길동
참고로 SELECT * FROM USERS FOR UPDATE 쿼리를 실행한다면 조회된 모든 레코드에 대한 Lock과 모든 범위에 대한 GAP LOCK이 걸리게 된다.
3.4. Serializable
가장 고수준의 격리수준으로 트랜잭션을 무조건 순차적으로 진행시킨다. 트랜잭션이 끼어들 수 없으니 데이터의 부정합 문제는 발생하지 않으나, 동시 처리가 불가능하여 처리 속도가 느려진다.
트랜잭션이 중간에 끼어들 수 없는 이유는 SELECT 쿼리 실행 시 Shared Lock(공유 잠금)을, INSERT, UPDATE, DELETE 쿼리 실행 시 Exclusive Lock (MySQL의 경우 Nexy Key Lock)을 걸어버리기 때문이다.
※ Shared Lock
Shared Lock이란 다른 트랜잭션에서의 읽기 작업은 허용하지만, 쓰기 작업은 불가능하도록 한다. SELECT ~ FOR SHARE 문법을 통해 사용하는데, 키 포인트는 이 Lock의 경우 동시에 Exclusive Lock을 허용하지 않는다는 것이다.
SELECT 쿼리를 실행하면 Shared Lock이 걸리게 되고, 다른 트랜잭션에서 UPDATE, DELETE, INSERT와 같은 쿼리 실행 시Exclusive Lock, Next Key Lock을 얻어오려고 할텐데 Shared Lock 은 이를 허용하지 않아 대기 상태가 된다. 이러한 원리에 의해 트랜잭션들이 중간에 끼어들 수 없고 순차적으로 되는것이다.
4. 회고
처음엔 트랜잭션의 격리 수준에 따라 트랜잭션 내에서 실행한 쿼리들이 어떻게 동작하는지만 이해하고 넘어가려 했지만, 이를 이해하기 위해서는 Commit, Rollback의 내부 동작 부터 시작해 Undo Log, Undo Log Buffer와 File, Cache Miss, Hit, MVCC, Lock 등.. DB에 대한 전반적인 흐름과 개념들을 알아야 했다. 또한 DBMS 마다 격리 수준의 내용이 달라지다보니 이를 비교 분석해야 했다.
먼저 영상을 통해 기본 개념들을 학습했고, 여러 블로그 글들을 참고하여 정리해나갔다. chat gpt를 통해 이해한 내용을 검증하기도 했다. (근데 이녀석이 자꾸... 말을 바꾸네...??) 잘못된 내용을 바로잡을때마다 쓰고, 지우고를 반복했다. 그림을 갈아 엎어야할때는 마음이 너무 아팠지만, 그때 나사가 빠진(?) 부분이나 이해가 필요한 부분들을 시각적으로 찾을 수 있어서 매우 유익했다.
이 글은 필자가 이해한 내용을 정리한거라 틀린 내용이 있을 수 있다. 독자분들께서 읽고 수정이 필요한 부분이나 추가 정보가 있다면 꼭! 댓글로 부탁드린다! :)
토비의 스프링을 공부하다가 SQL 쿼리 정보를 담은 XML 파일을 언마샬링하여 객체로 만들고, 이를 DAO 로직에 적용하는 부분이 있었다. 이때 사용했던 마샬링과 언마샬링에 대해 포스팅한다.
2. 마샬링
2.1. 마샬링이 뭔가요?
마샬링이란 객체나 특정 형태의 데이터를 저장 및 전송 가능한 데이터 형태로 변환하는 과정을 말한다.
언마샬링은 변환했던 데이터를 원래대로 복구하는 과정을 말한다.
2.2. 개념이 잘 와닿지 않아요
데이터를 다른 형태로 변환한다는 개념이 너무 추상적이라 그런지 마샬링의 개념이 잘 이해가 되지 않았다. 그래서 데이터 형태를 변환하는 다른 개념들을 함께 찾아보고, 비교하면서 개념을 이해해보기로 했다.
2.3. 여러가지 변환 과정
찾아보니 인코딩, 디코딩, 파싱과 같은 친숙한 용어들이 보였다. 마샬링도 이와 비슷한 맥락의 개념이라고 생각하니 이해가 훨씬 쉬웠다.
- 마샬링 / 언마샬링
객체나 특정 형태의 데이터를 저장 및 전송 가능한 데이터 형태로 변환하는 과정이다. 예를 들어, 네트워크 통신에서 객체를 전송하기 위해 바이트 스트림으로 변환하는 것이 마샬링이고, 받은 데이터 스트림을 다시 객체로 변환하는 것이 언마샬링이다.
- 인코딩 / 디코딩
데이터를 특정 형식이나 표현 방식으로 변환하는 과정이다. 예를 들어, 텍스트를 UTF-8이나 Base64 형식으로 변환하는 것이 인코딩이고, 원래의 텍스트로 변환하는 것이 디코딩이다.
- 파싱
웹, 문자열, 파일 등으로부터 정보를 추출하여, 원하는 데이터 구조나 객체로 변환하는 과정이다. 예를 들어, 특정 웹페이지의 HTML 문자열을 로드하여 필요한 데이터만을 추출한 후 객체 형태로 저장하는 것이 파싱이다.
- 정규화 / 비정규화
데이터베이스에서 데이터를 효과적으로 저장하거나 조회하기 위해 테이블 구조를 변환하는 과정이다.
- 직렬화 / 역직렬화
객체나 데이터 구조를 연속적인 바이트 스트림으로 변환하는 과정이다. 웹에서는 그 의미가 확장되어 Json 데이터를 객체로 변환하거나, 객체를 Json 데이터로 변환하는 것을 의미한다. 네트워크를 통한 데이터 전송이나 파일 저장 등에 사용된다.
직렬화는 마샬링과 비슷한 개념을 갖고 있는데, 직렬화는 연속적인 바이트 스트림으로의 변환을, 마샬링은 특정 통신 프로토콜이나 파일 포맷에 맞게 데이터를 변환하는 것에 초점을 둠으로써 마샬링이 직렬화보다 더 큰 범위의 과정을 의미한다. 즉, 직렬화는 마샬링이라고 할 수 있지만, 마샬링은 꼭! 직렬화다! 라고 할 순 없다.
3. 마샬링, 어디에 쓰이는데?
필자의 경우 마샬링이라는 용어를 2년차에 처음 접했다. 특정 설정 정보들을 DB에 저장하고, 이 데이터를 통해 XML 파일로 변환하는 방식을 알아보라는 윗분의 요청이 있었고, 이때 마샬링이라는 개념이 XML에만 국한된 것으로 이해했다. 하지만 앞서 내용을 보다시피 아주 포괄적인 개념임을 알 수 있다.
그렇다면, 전송 가능한 데이터 형태로 변환하는 것은 어플리케이션 개발을 함에 있어 꼭 들어가야하는 과정 중 하나인데, 왜 빨리 접하지 못했고, 마샬링이란 기술을 사용한 기억이 없을까? 그 이유는 대부분 프레임워크 내부에서 마샬링이 진행되기 때문이다.
3.1 @RequestBody와 @ResponseBody
Controller 클래스를 작성한다면, 요청은 @RequestBody를 통해 Json 형식의 데이터를 객체로 변환하여 사용하고, 응답은 @ResponseBody를 통해 객체 형태의 데이터를 Json 형식으로 변환하여 내려준다.
이러한 어노테이션을 사용하면 내부적으로 MappingJackson2HttpMessageConverter에 의해 객체와 Json간 변환 과정을 거치게 된다. 웹에서는 이러한 변환 과정을 직렬화, 역직렬화라고 하며, 이는 곧 마샬링, 언마샬링이라고도 할 수 있다.
3.2. Mybatis
전세계적으로 데이터 처리를 위해 JPA를 사용하고 있지만, 우리나라에서 만큼은 Mybatis도 꽤 많이 사용한다고 한다. 필자도 현업에서 JPA 사용을 안하고 Mybatis만을 사용했다. Mybatis는 SQL 쿼리를 자바 코드와 분리하여 개발자가 비지니스 로직에만 집중할 수 있도록 한다. 이때 사용되는 쿼리 정보들은 XML 파일로 관리한다.
포스팅했던 트랜잭션 기능 구현 내용을 정리하면, 어드바이스에는 트랜잭션 기능을, 포인트컷에는 타겟 설정을 하고, 이 둘을 갖는 객체인 어드바이저와 어드바이저 빈을 스캔하는 자동 프록시 생성 객체를 빈으로 등록하여 구현한다.
어찌됐던 아래와 같이 트랜잭션을 시작하고, 타겟 메서드를 실행하고, 트랜잭션을 Commit 또는 Rollback한 후 트랜잭션을 종료하게 된다.
이와 같이 트랜잭션 기능을 부여하려면 AOP 기반의 여러 설정들이 필요한데, 이러한 설정들과 상호작용하여 트랜잭션 기능을 제공받게 하는 것이 바로 @Transactional 이다.
2. @Transactional 선언만 해줬을 뿐인데??
이 어노테이션을 메서드, 클래스, 인터페이스에 선언만 하면 타겟 메서드 호출 시 기본 속성을 갖는 트랜잭션이 시작된다. 4 가지 속성이 있으며 전파속성, 격리수준 속성, 제한시간 속성, 읽기전용 속성이다.
2.1. 전파 속성
트랜잭션이 어떻게 전파되는지를 정의하는 속성이다. 예를들어 AClass, BClass에 @Transactional을 선언하고, AClass의 메서드 내부에서 BClass의 메서드가 실행된다고 가정해보자. 이때 트랜잭션은 어떻게 전파될까? AClass의 메서드 호출 시 트랜잭션이 시작되고, BClass의 메서드 호출 시 새로운 트랜잭션이 시작될까?
아니다. @Transactional을 선언하게 되면 기본 전파 방식인 PROPAGATION_REQUIRED 방식을 사용하게 되어 BClass의 메서드는 자신에게 전파된 트랜잭션에 참여하게 된다.
반대로 다른 전파 속성으로 설정한다면, 트랜잭션에 참여하지 않거나, 새로운 트랜잭션을 생성하도록 할 수 도있다.
@Service
@Transactional
public class AClass {
private final BClass bClass;
public AClass(BClass bClass){
this.bClass = bClass;
}
public void method(){
// Data 처리 로직
bClass.method();
// Data 처리 로직
}
}
..
@Service
@Transactional
public class BClass {
public void method(){
// Data 처리 로직
}
}
2.2. 전파속성의 종류
일반적으로 사용되는 3가지의 전파 속성이다.
1) PROPAGATION_REQUIRED
가장 많이 사용되는 트랜잭션 전파 속성이다. 진행 중인 트랜잭션이 없으면 새로 시작하고, 있으면 이에 참여한다. 이 방식을 사용할 경우 AClass.method와 BClass.method는 하나의 작업 단위 즉, 하나의 트랜잭션으로 구성되게 되고, 두 메서드가 종료되기 전에 내부에서 예외 (정확히는 런타임 예외)가 발생한다면 둘다 롤백된다.
2) PROPAGATION_REQUIRES_NEW
NEW ! 항상 새로운 트랜잭션을 시작한다. 진행 중인 트랜잭션이 있건 없건 새로운 트랜잭션을 생성하고 시작한다. 이 방식을 사용할 경우 BClass의 method 실행 시 1번 트랜잭션과 독립되어 동작하는 2번 트랜잭션이 생성된다.
BClass의 method가 정상적으로 호출 및 종료되면 2번 트랜잭션은 Commit 되므로, 이후 AClass의 method에서 예외가 발생한다 한들 BClass의 Commit된 내용은 Rollback되지 않는다.
3) PROPAGATION_NOT_SUPPORTED
전파 지원 안해줘!진행 중인 트랜잭션이 있건 없건 트랜잭션 없이 동작하도록 한다. 트랜잭션이 없으니 트랜잭션 전파 지원도 없다는 뜻으로 해석했다. 이 방식을 사용할 경우 DB Connection이 발생할 때마다 트랜잭션 없이 DB 연산이 수행되고 BClass 내부에서 Exception이 발생한다 하더라도, 그 전에 Commit 됐던 내용은 Rollback 되지 않는다.
이와 별개로 AClass는 호출했던 BClass 메서드에서 발생한 예외로 인해 Rollback 된다.
2.3. 격리수준
트랜잭션 격리 수준은 DB에서 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 어느정도까지 허용할거냐를 결정하는 것이다.
격리 수준은 SERIALIZABLE, REPEATABLE READ, READ COMMITED, READ UNCOMMITED 가 있고, 기본적으로 DB 설정에 따르지만 트랜잭션 레벨에서도 설정이 가능하다. 이 내용은 매우 중요하기 때문에 따로 포스팅하도록 하겠다.
2.4. 제한시간
트랜잭션에 대한 제한시간을 설정할 수 있다. 기본 설정은 제한시간이 없는 것이다.
2.5. 읽기전용
트랜잭션에서 읽기 작업만 수행할 수 있도록 제한하는 것이다. 만약 Update, Delete, Insert와 같이 데이터를 조작하는 행위를 할 경우 예외가 발생한다.
3. 어드바이스와 포인트컷
@Transactional은 특정 메서드(포인트컷)에 특정 속성(어드바이스)을 갖는 트랜잭션을 생성하고 시작하도록 한다. 그럼 어디선가 @Transactional 을 포인트컷으로 지정하고, @Transactional에 속성 값에 설정한 트랜잭션 속성을 가져다가 어드바이스에서 트랜잭션 생성 시 사용해야 한다. 이 기능을 하는 클래스들이 뭔지 알아보자.
3.1. 포인트컷
@Transactional이 선언된 타입 혹은 메서드에 대해서만 대상으로 선정하는 포인트컷이 필요한데, 이 기능을 하는 포인트컷이 바로 TransactionAttributeSourcePointcut 이다.
이 클래스는 @Transactional 붙은 타입 혹은 메서드를 찾아 포인트 컷의 선정 결과로 돌려준다. @Transactional의 속성을 통해 트랜잭션의 속성도 정의하지만 포인트컷의 선정 대상으로도 사용된다.
3.2. 어드바이스
@Transactional에 속성 값을 읽어 트랜잭션을 생성하는 어드바이스가 필요한데, 이 기능을 하는 어드바이스가 바로 TransactionInterceptor 이다. 정확히는 트랜잭션 매니저와 트랜잭션 속성 두가지를 설정하는데, 트랜잭션 속성은 AnnotationTransactionAttributeSource 라는 클래스에게 요청하면, 해당 클래스에서 @Transactional 에 입력된 속성을 읽어온다.
3.3. 어드바이저
이 포인트컷과 어드바이스를 갖고 있는 어드바이저는 BeanFactoryTransactionAttributeSourceAdvisor 로 어플리케이션 실행 시 자동으로 빈으로 등록된다.
4. @Transactional 선언만 해줬을 뿐인데!!
정리하면, @Transactional 선언만 해주면 아래와 같은 매커니즘에 따라 동작하게 된다.
1) 어플리케이션 시작 시 TransactionAttributeSourcePointcut 포인트컷과 TransactionInterceptor 어드바이스를 갖는 BeanFactoryTransactionAttributeSourceAdvisor어드바이저가 빈으로 등록된다.
2) 빈 후처리기가 트랜잭션 기능을 부여하는 어드바이저 빈을 조회한다.
3) 어드바이저의 포인트 컷을 통해 @Transactional이 붙은 Bean 을 선정하고 프록시로 생성한다.
4. 생성한 프록시에 어드바이저를 연결한다.
5. 완성된 프록시를 스프링 컨테이너에게 전달한다.
6. 스프링 컨테이너는 전달받은 프록시를 빈으로 등록하고 사용한다.
7. 추후 해당 프록시 빈이 호출될 경우 트랜잭션 속성에 따른 트랜잭션 부가기능이 수행된다.
restdocs를 사용하여 JUnit 테스트 코드에서 REST API에 대한 명세 파일을 생성 도중 특정 케이스에서만 Form parameters with the following names were not documented: [_csrf] 에러가 발생하였다.
2. 오류내용
_csrf 라는 이름을 가진 매개변수가 formParameters에 정의되지 않았고, 최종적으로 문서화 되지않았다는 오류였다. 즉, 요청 파라미터에는 _csrf 값이 있는데, formParameters에는 정의하지 않아 발생했다.
3. 코드
@Test
@TestMemberAuth
@DisplayName("운동 상태정보 수정")
void updateExecStatusToW() throws Exception{
mockMvc.perform(
patch("/api/member/exec-status")
.header("Authorization", "Bearer JWT_ACCESS_TOKEN")
.param("status", ExecStatus.W.name())
.with(csrf()))
.andExpect(status().isNoContent())
.andDo(document(
"updateExecStatus"
, requestHeaders(
headerWithName("Authorization").description("JWT_ACCESS_TOKEN"))
, formParameters(
parameterWithName("status").description("헬스 상태 (W : 준비중, H : 헬스중, I : 부상으로 쉬는중"))
)
);
}
* with(csrf()) 구문이 포함되어 있는 이유
with(csrf()) 는 MockHttpServletRequest에 CSRF 토큰 값을 추가하는 메서드이다. SpringSecurity 옵션을 통해 csrf 토큰 사용을 disable 처리했는데, mockMvc를 사용할 경우 기본적으로 csrf 토큰이 사용되도록 동작하기 때문이다. 이를 해결하기 위해 해당 구문을 사용하고 있었다.
5. 원인 분석
5.1. [ formParameters() + _csrf ] 케이스 예외 발생
예외가 발생하는 케이스는 Paramters에 _csrf 값이 들어가고, form 으로 전송되는 Parameters 에 대한 문서화 메서드인formParameters() 를 사용하는 케이스였다. Paramters에 _csrf가 들어가고 있는데 formParameters에는 이에 대한 명세를 하지 않았기 때문에 발생했다.
반대로 Json 형태의 요청 값을 문서화 시킬 경우 requestFields() 메서드를 사용하는데 이는 Parameters가 아닌 Body에 있는 값을 기준으로 매핑하기 때문에 _csrf 관련 에러가 발생하지 않았던 것이다.
6. 해결
6.1. csrf 토큰을 헤더로!
알려진 해결방안은 테스트 전용 SecurityConfig 설정 파일을 만들어서 csrf에 대해 disable 처리하고, MockMvc 테스트 마다 생성한 설정파일을 Configuration 파일로 로드하는 코드를 추가하면 된다고 하는데, _csrf 값을 요청 파라미터가아닌 헤더로 받으면 되지 않을까라는 생각이 들었다.
클라이언트에서 CSRF 토큰 값을 서버로 전송하는 방법은 요청 헤더에 토큰 값을 넣거나요청 파라미터에 토큰 값을 넣는 것이며, 요청 헤더의 경우 X-CSRF-TOKEN, 요청 파라미터의 경우 _csrf 라는 키 값에 넣으면 된다.
with(csrf())를 사용할 경우 요청 파라미터에 csrf 토큰 값이 포함되어 들어가고 있는데, 이를 요청 헤더로 이동시키면 Paramters의 _csrf 값을 제거될 것이고, 에러도 해결될 것 같았다.
곧장 csrf() 의 내부코드를 보니 아래와 같이 asHeader 값이 true일 경우 토큰 값을 헤더에, 그 외에는 Paramter에 설정하는 것을 확인할 수 있었다. 기본값은 false 였기에 Paramter로 토큰이 전송되고 있었다.
asHeader만 true로 설정해주는 메서드인 asHeader()도 곧바로 찾을 수 있었다. 바로 적용해보았다.
6.2. 적용 및 테스트
기존 csrf() 메서드에 체인 메서드 형태로 asHeader() 메서드만 추가해줬다. 테스트 결과 예외는 해결되었으며, 요청 값을 확인해보면 csrf 토큰 값이 Headers로 요청되는 것을 확인할 수 있다.
@Test
@TestMemberAuth
@DisplayName("운동 상태정보 수정")
void updateExecStatusToW() throws Exception{
mockMvc.perform(
patch("/api/member/exec-status")
.header("Authorization", "Bearer JWT_ACCESS_TOKEN")
.param("status", ExecStatus.W.name())
.with(csrf().asHeader())) // asHeader() 추가
.andExpect(status().isNoContent())
.andDo(document(
"updateExecStatus"
, requestHeaders(
headerWithName("Authorization").description("JWT_ACCESS_TOKEN"))
, formParameters(
parameterWithName("status").description("헬스 상태 (W : 준비중, H : 헬스중, I : 부상으로 쉬는중"))
)
);
}
예외 메시지를 보니 DTO 객체를 직렬화하는 과정에서 필드에 존재하는 `java.time.LocalDateTime` 의 데이터 타입을 지원되지 않아 처리할 수 없다는 오류였다.
3.1. 갑자기 웬 직렬화?
@RestController 또는 @ResponseBody 어노테이션을 사용할 경우 리턴 값이 Json 스트링 형식으로 변환되어 응답되는데, 이때 내부 MessageConverter에 정의된 ObjectMapper에 의해 데이터가 직렬화된다. (직렬화 : Object to Json)
반대로 @RequestBody 어노테이션을 사용할 경우 요청 Json 데이터를 Object로 변환하는데, 이때는 역직렬화 기술이 사용된다.
3.2. 잘못잡은 포인트
내부 MessageConverter에서 직렬화 도중 발생한 예외인줄 알았으나, 테스트 코드의 objectMapper를 사용하여 응답 값을 검증하는 부분에서 발생했던 것이었다. ^^;;
private final ObjectMapper objectMapper = new ObjectMapper();
...
@Test
@TestMemberAuth
@DisplayName("채팅방 ID에 대한 채팅내역 조회")
void getChatHistory() throws Exception {
mockMvc.perform(
get("/api/chatting/history/{roomId}",CHATTING_ROOM_ID_FOR_LOGIN_MEMBER_AND_VALID_MEMBER))
.andExpect(status().isOk())
.andExpect(content().string(objectMapper.writeValueAsString(CHATTING_HISTORY_DTO))); // 예외발생 부분
}
4. 해결
테스트 코드에서 objectMapper를 생성한 이후 아래 설정을 추가하였다.
objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
위 설정은 ObjectMapper에 JavaTimeMoule을 등록한 후, 날짜와 시간을 타임스탬프로 직렬화하지 않도록 한다.
참고로 예외 메시지에서 jackson-datatype-jsr310 의존을 추가하라고 하는데, spring-boot-starter-web 에 의존하는 경우 종속관계인 jackson-datatype-jsr310 도 자동 의존하는 것을 확인할 수 있었다.
AOP는 말 그대로 Aspect(관점) Oriented(지향) Programming(프로그래밍). 관점 지향적인 프로그래밍이다. 풀어 말하면 어떤 로직에 대해 핵심 기능과 부가 기능이라는 관점으로 나누어 모듈화하는 프로그래밍 기법을 말한다. 토비의 스프링에도 비슷하게 정의되어 있어 가져와봤다.
AOP란 어플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법이다.
예를들어 @Transactional을 사용하지 않고 DB 데이터 처리를 하는 특정 메서드에 대한 트랜잭션 기능을 부여한다면, 아래와 같은 코드를 구현할 수 있다.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
userService.upgradeLevels(); // 핵심기능
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
현재는 upgradeLevels() 메서드에 대해서만 트랜잭션을 적용하였으나 다른 메서드에도 적용해야한다면 아래와 같이 많은 중복코드가 생길것이다.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
userService.upgradeLevels();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
public void saveUserInfo() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
userService.saveUserInfo();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
...
public void update() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
userService.update();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
...
중복을 없애려면 어떻게 해야할까? 중복되는 트랜잭션 로직을 분리하고, 다른 클래스에서도 재사용할 수 있도록 모듈화 해야한다. 이 말은 UserService의 메서드와 트랜잭션 로직을 각각 핵심기능과 부가기능으로 분리하고, 부가기능을 모듈화 해야 한다는 것인데. 이렇게 접근하는 프로그래밍 방식이 AOP, 관점 지향 프로그래밍이라고 할 수 있다.
2.2. AOP 용어
AOP 사용 시 자주 사용되는 용어들이 있다. 이 중에서도 스프링 AOP에서 주로 사용되는 Advice, Pointcut, Advisor는 꼭 숙지하자.
Target
부가 기능을 부여할 대상이다. 핵심기능을 담은 클래스일 수도 있지만 경우에 따라 다른 부가기능을 제공하는 프록시 오브젝트일 수도 있다.
Advice
타겟에게 제공할 부가 기능을 담은 모듈이다. 어드바이스는 오브젝트로 정의하기도 하지만 메서드 레벨에서 정의할 수도 있다
JoinPoint
어드바이스가 적용될 수 있는 위치를 말한다. 스프링 AOP에서 조인포인트는 메서드의 실행단계 뿐이다. 타깃 오브젝트가 구현한 인터페이스의 모든 메서드는 조인 포인트가 된다.
Pointcut
어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 스프링 AOP의 조인포인트는 메서드의 실행이므로 스프링의 포인트컷은 메서드를 선정하는 기능을 갖고 있다. 그래서 포인트컷 표현식에서도 메서드의 시그니처를 비교하는 방법을 주로 사용한다. 메서드는 클래스 안에 존재하는 것이기 때문에 메서드 선정이란 결국 클래스를 선정하고 그 안의 메서드를 선정하는 과정을 거치게 된다.
Advisor
어드바이저와 포인트컷을 하나씩 갖고 있는 오브젝트이다. 어드바이저는 어떤 기능(어드바이스)을 어디에(포인트컷) 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다. 어드바이저는 스프링 AOP에서만 사용되는 용어이고, 일반적인 AOP에서는 사용되지 않는다.
예를 들어서 살펴보면 좀 더 쉬운데요. MemberService의 hello()라는 메소드 실행 전,후에 hello랑 bye를 출력하는 일을 한다고 가정해보죠. 이때 MemberService 빈이 타겟, "hello() 메소드 실행 전,후"가 포인트컷, "메소드 실행 전,후"라는게 조인포인트, "hello랑 bye를 출력하는 일"이 Advice입니다. 포인트컷과 조인포인트가 많이 햇갈릴텐데 조인포인트가 메타적인 정보라고 생각하시면 되고 포인트컷이 좀 더 구체적인 적용 지점이라고 생각하시면 됩니다.
- 인프런 문의사항 답변 내용 (답변자 : 백기선님)
3. 빈 후처리기를 통한 AOP
스프링에서는 AOP를 위한 다양한 모듈을 제공한다. 일단 빈 후처리기를 활용하여 AOP를 적용해보도록 하겠다.
3.1. 빈 후처리기가 뭔가요?
BeanPostProcessor 인터페이스를 구현한 클래스로 빈을 생성한 후 후처리 기능을 하는 클래스이다.
스프링의 대표적인 빈 후처리기는 DefaultAdvisorAutoProxyCreator로 등록된 빈 중에서 Advisor 인터페이스를 구현한 클래스에 대한 자동 프록시 생성 후처리기이다.
이를 활용하면 스프링이 생성하는 빈 오브젝트 중 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다. 그림과 함께 동작과정을 이해해보자.
3.2. DefaultAdvisorAutoProxyCreator 동작과정
1) 어플리케이션이 시작되면 빈 설정파일을 읽어 빈 오브젝트를 생성한다.
2) BeanPostProcessor 인터페이스를 구현한 클래스(DefaultAdvisorAutoProxyCreator)가 빈으로 등록되어 있다면 생성된 빈을 여기로 전달한다.
3) DefaultAdvisorAutoProxyCreator는 생성된 빈 중에서 Advisor 인터페이스를 구현한 클래스가 있는지 스캔한다.
4) Advisor 인터페이스를 구현한 클래스가 있다면 Advisor의 포인트 컷을 통해 프록시를 적용할지 선별한다.
5) Advisor가 없거나 포인트 컷 선별이 되지 않았다면 전달받은 빈을 그대로 스프링 컨테이너에게 전달하고, 선별됐다면 프록시 생성기 역할을 하는 객체에서 프록시 생성 요청을 한다.
6) 프록시 생성기는 프록시를 생성하고 프록시에 어드바이저를 연결한다.
7) 완성된 프록시를 스프링 컨테이너에게 전달한다.
8) 스프링 컨테이너는 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.
3.3. DefaultAdvisorAutoProxyCreator 예제
UserService 인터페이스에 대한 구현체 클래스는 UserServiceImpl은 DB 데이터 처리를 하는 IUserDao와 비지니스 로직을 담당하는 UserLevelUpgradePolicy를 DI받는다.
UserServiceImpl 메서드 실행 도중 예외가 발생할 경우 모든 트랜잭션을 rollback하기 위해 트랜잭션 처리를 하는 부가기능을 빈 후처리기를 통해 구현해보도록 하자. 매커니즘을 이해하는 것에 초점을 맞췄기 때문에 부가적인 코드는 첨부하지 않도록 하겠다.
1) UserService
public interface UserService {
void upgradeLevels();
void add(User user);
}
2) UserServiceImpl
public class UserServiceImpl implements UserService {
private IUserDao userDao;
private UserLevelUpgradePolicy userLevelUpgradePolicy;
public void setUserDao(IUserDao userDao){
this.userDao = userDao;
}
public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy){
this.userLevelUpgradePolicy = userLevelUpgradePolicy;
}
public void upgradeLevels() {
List<User> users = userDao.getAll(); // DB /
for(User user : users) {
if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
userLevelUpgradePolicy.upgradeLevel(user);
}
}
}
public void add(User user) {
if(user.getLevel() == null){
user.setLevel(Level.BASIC);
}
userDao.add(user);
}
}
3.3.1. 빈 후처리기 등록
DefaultAdvisorAutoProxyCreator를 빈으로 등록하면 된다. xml 설정을 통해 빈을 등록하였다.
<bean class ="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"></bean>
3.3.2. 포인트컷 정의
DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 클래스를 모두 찾는다. 구현 클래스를 생성하기 전, Advisor에 필요한 포인트컷과 어드바이스를 준비해야 한다. 먼저 포인트컷을 생성하였다.
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {
public void setMappedClassName(String mappedClassName){
this.setClassFilter(new SimpleClassFilter(mappedClassName));
}
static class SimpleClassFilter implements ClassFilter {
String mappedName;
private SimpleClassFilter(String mappedName){
this.mappedName = mappedName;
}
public boolean matches(Class<?> clazz){
return PatternMatchUtils.simpleMatch(mappedName,
clazz.getSimpleName());
}
}
}
포인트컷은 NameCatchMethodPointcut을 내부 익명 클래스 방식으로 확장해서 만들었다. 이름에서 알 수 있듯이 메서드 선별 기능을 가진 포인트컷인데, 클래스에 대해서는 필터링 기능이 없는게 아닌 모든 클래스를 다 허용한는 기본 클래스 필터가 적용되어 있다. 때문이 이 클래스 필터를 재정의 하였다.
Canonical instance of a ClassFilter that matches all classes : 모든 클래스와 일치하는 ClassFilter의 정식 인스턴스
주석을 보면 알 수 있듯이 기본 클래스 필터인 TrueClassFilter.INSTANCE는 모든 클래스와 일치시킨다.
3.3.3. 어드바이스 정의
이제 부가기능을 포함하는 어드바이스를 정의해보겠다. MethodInterceptor 인터페이스를 구현하면 된다. invoke 메서드에 부가기능 및 타겟 오브젝트 호출 로직을 넣어준다.
public class TransactionAdvice implements MethodInterceptor {
private PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionManager = transactionManager;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
TransactionStatus status = transactionManager
.getTransaction(new DefaultTransactionDefinition());
try{
Object ret = invocation.proceed(); //타겟 메서드 실행
transactionManager.commit(status);
return ret;
} catch (RuntimeException e){
transactionManager.rollback(status);
throw e;
}
}
}
3.3.4. 어드바이저 등록
어드바이저는 스프링에서 제공하는 DefaultPointcutAdvisor를 사용한다.
앞서 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 클래스를 모두 찾는다고 했는데 DefaultPointcutAdvisor 클래스가 Advisor 인터페이스의 구현체 클래스 중 하나이다.
어드바이저 등록은 'XML을 통한 빈 설정' 부분에 기재하였다.
3.3.5. XML을 통한 빈 설정
이제 작업한 내용을 바탕으로 스프링 빈 설정을 한다. 어드바이저에 대한 자동 프록시 생성 후처리기인 DefaultAdvisorAutoProxyCreator 빈을 등록하고, 스캔할 어드바이저로 DefaultPointcutAdvisor 타입의 transactionAdvisor 빈을 등록한다. 생성 시 필요한 어드바이스와 포인트컷도 마찬가지로 빈으로 등록해줬다.
필자는 클래스 이름의 suffix가 ServiceImpl인 클래스, 메서드 이름의 prefix가 upgrade 인 메서드에 대해 포인트컷을 설정하기 위해 mappedClassName엔 "*ServiceImpl"를, mappedName엔 "upgrade*" 를 설정해주었다.
...
<bean id = "userService" class = "org.example.user.service.UserServiceImpl">
<property name="userDao" ref = "userDao"></property>
<property name="userLevelUpgradePolicy" ref = "defaultUserLevelUpgradePolicy"></property>
</bean>
...
<!-- 빈 후처리기 등록 -->
<bean class ="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>
<!-- 어드바이스 설정 -->
<bean id = "transactionAdvice" class = "org.example.proxy.TransactionAdvice">
<property name="transactionManager" ref = "transactionManager"></property>
</bean>
<!-- 포인트컷 설정 -->
<bean id = "transactionPointcut" class = "org.example.proxy.NameMatchClassMethodPointcut">
<property name="mappedClassName" value = "*ServiceImpl"/>
<property name="mappedName" value = "upgrade*"/>
</bean>
<!-- 어드바이저 (어드바이스 + 포인트컷) 설정 -->
<bean id = "transactionAdvisor" class = "org.springframework.aop.support.DefaultPointcutAdvisor">
<property name="advice" ref = "transactionAdvice"></property>
<property name="pointcut" ref ="transactionPointcut"></property>
</bean>
...
3.3.6. 테스트
게시글에 누락시킨 클래스들이 많아 테스트 케이스를 이해하기 힘든 관계로 간단히 설명하자면, 모든 유저들에 대해 정해진 조건을 만족할 경우 다음 레벨로 업그레이드하는 UserService의 upgradeLevels()를 메서드를 테스트하며, 메서드 실행 도중 예외발생 시 트랜잭션이 적용되는지 확인하기 위함이다.
테스트를 위해 upgradeLevels 내부에서 실행되는 UserLevelUpgradePolicy의 upgradeLevel() 메서드에 대해 예외가 발생하도록 Stubbing 처리하였다.
test2 유저의 경우 SIVER로 업그레이드 됐었으나, 커밋 전 예외 발생으로 인해 BASIC 레벨로 롤백되는지 확인하는 케이스를 진행하였고, 트랜잭션이 적용되어 테스트가 성공함을 확인하였다.
public class UserServiceTest {
@Autowired
private UserService userService;
@SpyBean
private IUserDao userDao;
@SpyBean
private DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;
private final List<User> users = Arrays.asList(
new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
);
@BeforeEach
void setUp(){
// 모든 유저 조회 시 미리 정의한 유저 텍스처 조회
given(userDao.getAll()).willReturn(users);
// 4번째 유저에 대한 업그레이드 메서드 실행 시 예외 발생
willThrow(new RuntimeException()).given(userLevelUpgradePolicy).upgradeLevel(users.get(3));
}
@Test
void upgradeAllOrNothing(){
// 테이블 데이터 초기화
userDao.deleteAll();
// 테이블에 유저 정보 insert
users.forEach(user -> userDao.add(user));
// 유저 레벨 업그레이드 메서드 실행 및 예외 발생 여부 확인 (setUp 메서드에 4번째 유저 업그레이드 처리 시 예외 발생하도록 스터빙 추가)
assertThatThrownBy(() -> userService.upgradeLevels())
.isInstanceOf(RuntimeException.class);
// DB
assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.BASIC);
assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);
System.out.println(userService.getClass().getName()); //com.sun.proxy.$Proxy50
}
}
추가적으로 Autowired한 userService의 클래스 타입은 자동 프록시 생성 빈 후처리기에 의해 Proxy 객체가 생성된 관계로 UserServiceImpl가 아닌 Proxy임을 확인할 수 있었다.
만약 포인트컷에 선별되지 않도록 mappedName 혹은 mappedClassName을 변경한다면 프록시를 생성하지 않고 아래와 같이 UserServiceImpl 타입으로 출력되는 것도 확인할 수 있었다.
4. 세밀한 포인트컷
4.1. 리플렉션 API 활용?!
예제에서는 단순히 클래스나 메서드의 이름으로만 포인트컷을 지정했는데, 더 세밀하고 복잡한 기준을 적용해 포인트컷을 지정할 수도 있다. 바로 리플렉션 API를 활용하는 것이다. 어차피 TransactionAdvice의 invoke 메서드 파라미터인 MethodInvocation도 리플렉션이 적용된 파라미터이기 때문에 메서드나 클래스, 리턴 값 등 대부분의 정보를 얻을 수 있기 때문이다.
하지만 리플렉션 API를 사용하면 코드가 지저분해지고 포인트컷 비교 정보가 달라질때마다 해당 로직을 수정해야한다. 이에 스프링은 표현식을 통해 간단하게 포인트컷의 클래스와 메서드를 선별할 수 있도록 하는 방법을 제공하는데 이를 포인트컷 표현식이라고 한다.
4.2. 포인트컷 표현식
포인트컷 표현식을 지원하는 포인트컷을 적용하려면 포인트컷 빈으로 AspectExpressionPointcut 클래스를 사용하면 된다.
4.3. 포인트컷 표현식 문법
포인트컷 표현식은 포인트컷 지시자를 이용하여 작성하며 대표적으로 execution()이 있다. 메서드의 풀 시그니처를 문자열로 비교하는 개념이며, 문법은 아래와 같다. 참고로 괄호([ ])안은 생략 가능하다.
예를들어 execution("* org.test.service.*ServiceImpl.upgrade*(..)) 는 모든 접근제한자 및 리턴타입을 갖고, ServiceImpl로 끝나는 클래스 명을 갖고, 메서드명이 upgrade로 시작하는 모든 메서드 시그니처를 의미한다.
4.4. AspectExpressionPointcut 적용해보기
포인트컷 표현식 사용을 위한 의존성을 추가하고, xml에 설정했던 포인트컷 빈을 수정해보자.
1) 의존성 추가
implementation 'org.aspectj:aspectjtools:1.9.19'
2) xml 설정 변경
<bean id = "transactionPointcut" class = "org.springframework.aop.aspectj.AspectJExpressionPointcut">
<property name="expression" value = "execution(* org.example.user.service.*ServiceImpl.upgrade*(..))"/>
</bean>
기존엔 포인트컷에 대한 클래스를 생성해주었지만, AspectJExpressionPointcut을 사용하니 그럴 필요가 없게 되었다. 테스트 코드를 실행해보면 테스트가 성공하는 것을 확인할 수 있다.
4.5. 스프링 AOP 정리
스프링의 AOP를 적용하려면 최소한 네 가지 빈을 등록해야 한다.
* 자동 프록시 생성기
스프링의 DefaultAdvisorAutoProxyCreator 클래스를 빈으로 등록한다. 빈으로 등록된 어드바이저를 이용해서 프록시를 자동으로 생성하는 기능을 담당한다.
* 어드바이스
부가기능을 구현할 클래스를 빈으로 등록한다. TransactionAdvice는 AOP 관련 빈 중 유일하게 직접 구현한 클래스이다.
* 포인트컷
스프링의 AspectJExpressionPointcut을 빈으로 등록하고 포인트컷 표현식을 넣어주면 된다.
* 어드바이저
스프링의 DefaultPointcutAdvisor를 빈으로 등록한다. 어드바이스와 포인트컷을 참조하는 것 외에는 기능이 없다. 자동 프록시 생성기에 의해 검색되어 사용된다.
5. AOP 네임스페이스
포인트컷 표현식을 사용하니 어드바이스를 제외하고는 모두 스프링에서 제공하는 클래스를 사용하고 있다. 스프링에서는 이렇게 AOP를 위해 기계적으로 적용해야하는 빈들을 간편하게 등록하는 방법을 제공한다. 바로 aop 스키마를 이용하는 것이다.
aop 스키마를 사용하려면 bean xml 설정파일에 aop 네임 스페이스 선언을 추가해줘야 한다.
이를 추가하면 빈 후처리기, 포인트컷, 어드바이저가 자동으로 등록되므로 xml 설정에서 제거할 수 있다. 이제 aop 네임 스페이스를 사용하여 bean 설정 xml을 아래와 같이 작성할 수 있다. 단위 테스트를 통해 트랜잭션이 적용됨을 확인할 수 있었다.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-3.0.xsd"
>
...
<!-- 빈 후처리기, 포인트컷, 어드바이저 빈 생성 코드 제거
<bean class ="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>
<bean id = "transactionPointcut" class = "org.example.proxy.NameMatchClassMethodPointcut">
<property name="mappedClassName" value = "*ServiceImpl"/>
<property name="mappedName" value = "upgrade*"/>
</bean>
<bean id = "transactionAdvisor" class = "org.springframework.aop.support.DefaultPointcutAdvisor">
<property name="advice" ref = "transactionAdvice"></property>
<property name="pointcut" ref ="transactionPointcut"></property>
</bean>
-->
<bean id = "transactionAdvice" class = "org.example.proxy.TransactionAdvice">
<property name="transactionManager" ref = "transactionManager"></property>
</bean>
<aop:config>
<aop:advisor advice-ref="transactionAdvice"
pointcut="execution(* org.example.user.service.*ServiceImpl.upgrade*(..))"></aop:advisor>
</aop:config>
</beans>
다이나믹 프록시를 공부하던 중 리플렉션이라는 개념이 두둥등장하였다. 간단하게 개념만 짚고 넘어가려했으나, Spring DI의 동작원리와 밀접하고, 프레임워크를 이해하는데 중요한 개념이라고 판단되어 자세히 알아보았다.
2. Reflection이 뭔가요?
2.1. 사전적 의미
많은 영상이나 글에서 리플렉션에 대해 설명할 때 사전적 의미를 짚고 넘어간다. 사전적 의미와 유사한 기능을 하는 개념들이 많은데, 리플렉션 또한 이와 같기도 하고, 개념을 이해하기가 쉬운 편은 아니라 그런 것 같다. 사전적 의미는 다음과 같다.
1. (거울에 비친) 상, 모습 2. 반사
이제 이 사전적 의미를 기술적 의미와 함께 이해해보자.
2.2. 기술적 의미
런타임 단계에서 클래스의 정보를 분석해내는 자바 API로 클래스의 메서드, 타입, 필드, 어노테이션 등의 정보를 접근하거나 수정할 수 있다.
정리하면 리플렉션이란 클래스의 정보 통해 '거울에 비친 상'과 같이 똑같은 형태를 만들고 이를 통해 메서드, 타입, 필드, 어노테이션과 같은 자원에 접근하거나 수정할 수 있는 자바 API이다.
그렇다면, 실제 클래스와 똑같은 형태를 가진 정보는 대체 어디서 얻어오는 걸까??
2.3. 어디서? JVM에서!
정확히는 JVM의 메모리 영역에서 가져온다.
어플리케이션을 실행하면 작성한 자바 코드는 컴파일러에 의해 .class 형태의 바이트 코드로 변환되고, 이 정보들은 클래스 로더를 통해 JVM 메모리 영역에 저장된다. 그리고 클래스 정보를 통해 객체가 생성된다면 이는 JVM 힙 영역에 저장된다. 즉, JVM의 메모리영역에서 클래스의 정보를 가져올 수 있다.
2.4. 리플렉션이란?!
다시! 리플렉션이란, 어플리케이션이 실행되어 JVM 메모리 영역에 클래스 정보들이 저장된 시점인 '런타임' 시에 이 영역에 접근하여 클래스의 정보를 분석, 수정하는 작업을 하는 API가 바로 자바 리플렉션이다!
3. 리플렉션 실습
리플렉션 API를 테스트하는 간단한 실습을 해보자.
3.1. 초간단 Human 클래스 생성
public class Human {
private String name;
public Human(String name){
this.name = name;
}
private Human(){
}
public void goRestRoom(){
System.out.println(name +"이 화장실로 갑니다.");
}
public void offPants(){
System.out.println(name +"이 바지를 내립니다.");
}
public void doWork(){
System.out.println(name + "이 볼일을 봅니다.");
poopOut();
}
private void poopOut(){
System.out.println("똥이 나왔습니다.");
}
}
화장실에서 볼일을 보는 Human 클래스를 생성하였다. name 파라미터를 받는 생성자 메서드와 private 접근 제어자를 가진 기본 생성자 메서드를 생성하였다. poopOut 메서드는 외부에서 호출되는 것을 막기 위해 private 접근 제어자로 설정하였다.
3.2. 클래스 정보 조회하기
먼저 JVM에 저장될 클래스 정보를 조회하는 코드이다. 아래와 같이 크게 세가지 방법이 있다.
// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = human.getClass();
Class<?> class2 = Human.class;
Class<?> class3 = Class.forName("org.example.reflection.Human");
3.3. 생성자 조회 및 호출
이제 리플렉션 기능을 사용해보자. 먼저 클래스의 생성자 정보를 가져오고 이를 호출해보도록 하겠다.
3.3.1. getConstructor()
// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");
// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getConstructor(); // NoSuchMethodException !!
Constructor<?> constructor2 = class1.getConstructor(String.class);
// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance();
Object human2 = constructor2.newInstance("승갱이");
위 코드를 실행시키면 메서드 호출 시 NoSuchMethodException이 발생한다. 리플렉션 기능을 통해 생성자 메서드 정보를 가져오려 시도하였으나, 기본 생성자의 접근 제어자가 private 라 메서드를 찾지 못해 발생했다. 접근 제어자에 관계 없이 클래스 정보를 가져오려면 getConstructor() 대신 getDeclaredConstructor() 메서드를 사용하면 된다.
리플렉션에서 호출하는 대부분의 메서드는 getXXX, getDeclaredXXX 처럼 쌍을 이루고 있다. 아래의 특징을 숙지하여 상황에 맞게 사용해야 한다.
getXXX 상위 클래스와 상위 인터페이스에서 상속한 메서드를 포함하여 public인 값들을 가져온다. private와 같은 메서드를 조회할 경우 NoSuchMethodException 예외가 발생한다.
getDeclaredXXX 접근 제어자와 관계 없이 상속한 메서드들을 제외하고 직접 클래스에서 선언한 값들을 가져온다.
3.3.2. getDeclaredConstructor()
// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");
// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getDeclaredConstructor();
Constructor<?> constructor2 = class1.getDeclaredConstructor(String.class);
// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance(); // IllegalAccessException !!
Object human2 = constructor2.newInstance("승갱이");
이로써 private로 선언된 생성자 정보는 가져왔으나, 생성자를 통해 객체 생성 시 IllegalAccessException이 발생했다. 이유는 접근 제어자가 private 이기 때문에 외부 호출이 불가능하기 때문이다. 앞서 발생한 예외는 클래스의 정보에서 기본 생성자 메서드를 찾지 못해 발생했고, 이번 예외는 해당 메서드를 호출하지 못해 발생한 것이다.
이를 해결하기 위해서 Human 클래스의 기본 생성자를 public으로 수정하여야 할까? 아니다. 리플렉션을 통해 private 메서드에도 접근할 수 있도록 조작하면 된다.
3.3.3. setAccessible(true)
setAccessible(true) 메서드를 통해 해당 생성자에 접근할 수 있도록 설정하였다. 여기서 중요한 점은 클래스를 수정하지 않고, 리플렉션을 통해 클래스의 생성자 정보를 조작한 후 호출까지 했다는 점이다.
// JVM에 있는 클래스 정보 가져오기
Class<?> class1 = Class.forName("org.example.reflection.Human");
// 리플렉션을 통해 생성자 가져오기
Constructor<?> constructor1 = class1.getDeclaredConstructor();
Constructor<?> constructor2 = class1.getDeclaredConstructor(String.class);
constructor1.setAccessible(true); // 해당 생성자에 접근할 수 있도록 설정
// 가져온 생성자를 통해 객체 생성하기
Object human1 = constructor1.newInstance();
Object human2 = constructor2.newInstance("승갱이");
3.4. 멤버필드 조회하기
다음은 리플렉션 기능을 사용하여 클레스의 멤버필드를 조회해보자.
3.4.1. getFields()
Class<?> class1 = Class.forName("org.example.reflection.Human");
for(Field field : class1.getFields()){
System.out.println(field);
}
Human 클래스에 name 멤버필드가 있지만 콘솔에 조회되지 않았다. 클래스 정보를 조회했더니 name은 찾을 수 없어 조회가 되지 않았다. 이유는 name의 접근제어자가 private이기 때문이다. getDeclaredFields() 메서드를 사용해야 한다.
poopOut 메서드만 접근제어자가 private이므로 invoke 메서드 호출 전에 setAccessible(true) 메서드를 호출해주었다. 필자의 의도는 poopOut 메서드의 접근 제어자를 private 로 생성하여 바지를 내리기 전에 똥을 싸거나, 화장실에 들어가기 전에 똥을 싸는 불상사를 막으려 했는데, 리플렉션을 사용하니 똥을 먼저 싸버리는 걸 볼 수 있다.
다시한번 리플렉션의 강력함(?)을 느낄 수 있는 부분이다.
4. 어디서 사용하나요?
근데 이런 기능들을 대체 어디서 사용할까? 필자가 이 글을 쓰는 이유인 '다이나익 프록시' 라는 API에서도 사용하나, 대부분의 프레임워크나 라이브러리에서도 리플렉션 기능을 사용한다. 프레임워크나 라이브러리에서는 들어오는 클래스의 정보를 모르기 때문이다.
코드를 작성한 개발자는 당연히 내가 작성한 클래스의 정보를 알 수 있지만, 프레임워크 입장에서 보면 모르는게 당연하다. 이때 리플렉션을 통해 런타임 시 클래스의 정보를 얻고 이를 기반으로 하여 프레임워크나 라이브러리가 지원하는 기능을 수행하는 것이다. 스프링의 주요 기능인 DI도 리플렉션의 원리가 들어있다.
5. 리플렉션을 통한 DI 프레임워크 구현해보기
DI를 지원하는 초간단 프레임워크를 구현해보았다.
5.1. SSKAutowired
먼저 커스텀 어노테이션을 구현하였다. 특정 클래스의 멤버필드에 @SSKAutowired 어노테이션이 붙어있을 경우 해당 리플렉션을 통해 객체를 생성하기 위함이다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SSKAutowired {
}
5.2. Robot
기본 생성자를 갖는 간단한 Robot 를 구현하였다. 특정 클래스의 멤버필드로 사용되며, DI를 위해 @SSKAutowired를 붙여줄 예정이다.
public class Robot {
public void fight(){
System.out.println("로봇이 싸웁니다.");
}
public void clean(){
System.out.println("로봇이 청소합니다.");
}
private void destroy(){
System.out.println("로봇이 파괴됩니다.");
}
}
5.3. TestService
@SSKAutowired가 붙은 robot 멤버필드를 갖고, robot의 기능을 추상화한 메서드를 갖는 클래스이다. 테스트 단계에서 robot 객체가 주입됐는지 확인하기 위해 getRobot 메서드도 추가하였다.
public class TestService {
@SSKAutowired
private Robot robot;
public Robot getRobot(){
return robot;
}
public void start(){
robot.fight();
robot.clean();
}
}
5.4. CustomApplicationContext
특정 클래스를 스캔하여 필요한 의존성을 주입해주는 클래스이다. getInstance(TestService.class) 메서드를 호출할 경우 @SSKAutowired 멤버필드에 대한 의존성이 주입된 TestService 객체를 생성 및 리턴한다.
public class CustomApplicationContext {
/**
* 클래스의 멤버필드 중 SSKAutowired가 붙어있을 경우 의존성 주입
* @param clazz - 스캔 클래스
* @return - 의존주입이 완료된 스캔 클래스
* @throws Exception
*/
public static <T> T getInstance(Class<T> clazz) throws Exception{
T instance = createInstance(clazz);
Arrays.stream(clazz.getDeclaredFields()).forEach(field -> {
if(field.getAnnotation(SSKAutowired.class) != null){ // SSKAutowired가 붙은 멤버필드일 경우
try {
Object fieldInstance = createInstance(field.getType()); // 멤버필드에 대한 객체 생성
field.setAccessible(true);
field.set(instance, fieldInstance); // 생성된 객체를 instance에 셋팅 (DI)
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
return instance;
}
/**
* 리플렉션 기본 생성자를 통해 객체 생성
* @param clazz - 클래스 타입
* @return 클래스 객체
* @throws Exception
*/
private static <T> T createInstance(Class<T> clazz) throws Exception{
Constructor<T> constructor = clazz.getDeclaredConstructor(); // 리플렉션을 통해 클래스의 기본생성자 정보 조회
constructor.setAccessible(true);
return constructor.newInstance(); // 객체 생성
}
}
5.5. Test
CustomApplicationContext의 테스트 코드이다. TestService를 파라미터로 한 getInstance 메서드를 호출하면 의존성이 주입된 TestService 객체를 리턴받고, 확인하는 메서드이다. testService.start()를 통해 콘솔에 출력도 해보았다.
리플렉션은 동적으로 클래스를 생성하기 때문에 JVM 컴파일러가 최적화 할 수 없다. 컴파일 시에는 타입이 정해지지 않았기 때문이다. 해당 클래스의 타입이 맞는지, 생성자가 존재하는지 등의 벨리데이션 과정을 런타임 시 처리해야하기 때문에 성능이 떨어진다.
6.2. 컴파일 시 타입 체크가 불가능하다.
리플렉션은 런타임 시점에 클래스의 정보를 알게 되므로 컴파일 시점에 타입 체크가 불가능하다.
6.3. 캡슐화가 깨지고 추상화가 파괴한다.
리플렉션을 사용하는 모든 클래스의 정보를 알 수 있다. 외부로 노출시키지 않기 위해 private 접근제어자를 사용해도 접근할 수 있다. 이는 곧 클래스를 보호한다는 '데이터 측면'에서는 캡슐화를 깨트리고, 내부 구현을 외부로 유출시키지 않게 하여, 내부 구현을 알고있지 않아도 사용할 수 있게한다는 '설계 측면'에서는 추상화를 깨뜨릴 수있습니다. 즉, 캡슐화와 함께 추상화도 깨지게 된다.
먼저 프록시가 뭘까? 프록시에 대해 알아보기 전 이전 스터디때 배웠던 UserService의 트랜잭션 기능을 회고해보았다.
2.1. UserService의 트랜잭션 기능 회고
비지니스 로직에 대해 얼마의 처리 시간이 걸렸는지에 대한 로깅처리, 트랜잭션 처리와 같은 부가 기능을 추가하기 위해 인터페이스를 통한 추상화 방법을 사용할 수 있다. 앞선 스터디에서 UserService에 트랜잭션 기능을 추가하기 위해 인터페이스를 통한 추상화 방법을 적용하였고, 아래와 같이 부가기능과 핵심기능을 분리하는 구조가 되었다.
2.2. '핵심인 척' 하는 '부가기능'
이러한 구조가 가지는 중요한 특징이 있는데, 부가 기능 수행 후 핵심 기능을 가진 클래스로 요청을 위임해줘야 한다는 것과, 핵심기능은 부가기능을 가진 클래스의 존재 자체를 몰라야 한다는 것이다. 존재 자체를 모르는 것은 런타임시 DI를 통한 추상화 때문이다.
클라이언트도 인터페이스를 통해 서비스를 호출하기 때문에 실제로 어떤 구현체를 사용하는지 모른다. 핵심기능을 호출한다고 생각할 뿐이다. 부가기능(UserServiceTx)을 통해 핵심기능(UserServiceImpl)을 사용하고 있는데 말이다. 즉, 부가기능 클래스(UserServiceTx)는 의도치않게 클라이언트를 속여 '핵심인 척' 하고있다.
2.3. 그래서 프록시가 뭔가요?
이렇듯 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 요청을 받아주는 오브젝트를 프록시라고 한다. 추가로 프록시를 통해 최종적으로 요청을 위임받아 핵심 기능을 처리하는 오브젝트를 타겟이라고 한다. 앞서 구성했던 Client, UserServiceTx, UserServiceImpl 구조를 아래와 같이 프록시와 타겟으로 표현할 수 있다.
2.4. 프록시의 목적
프록시의 목적은 크게 두가지이다. 첫째는 클라이언트가 타겟에 접근하는 방법을 제어하기 위해서, 두 번째는 타겟에 부가적인 기능을 부여하기 위해서이다. 각각의 목적에 따라 디자인 패턴에서는 다른 패턴으로 구분한다.
2.5. 데코레이터 패턴
데코레이터 패턴은 타겟에 부가적인 기능을 런타임 시 다이나믹하게 부여하기 위해 프록시를 사용하는 패턴이다. 핵심 기능은 그대로 두고 부가 기능, 즉 데코레이션만 추가하는 것이다.
부가기능은 하나일수도, 여러개일수도 있다. 때문에 이 패턴에서는 프록시가 한 개로 제한되지 않는다.
UserService 인터페이스를 구현한 타겟인 UserServiceImpl에 트랜잭션 부가기능을 제공하는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것이다.
데코레이터 패턴은 인터페이스를 통해 위임하는 방식이기 때문에 어느 데코레이터에서 타겟으로 연결될지는 코드 레벨에선 알 수 없다. DI 설정에 따라 다이나믹하게 구성되게 때문이다.
이 패턴은 타겟 코드의 수정도 없고, 클라이언트 호출 방법도 변경하지 않은 채 새로운 기능을 추가할 때 유용한 방법이다.
2.5. 프록시 패턴
프록시 패턴의 프록시는 타겟에 대한 접근 방법을 제어하는 의미의 프록시이다. 즉, 클라이언트가 타겟에 접근하는 방식을 변경해주는 패턴이다.
클라이언트에게 타겟에 대한 래퍼런스를 넘길 때 실제 타겟이 아닌 프록시를 넘겨주는 것이다. 그리고 프록시의 메서드를 통해 타겟을 사용하려고 시도하면, 그때 프록시가 타겟 오브젝트를 생성하고 요청을 위임해준다.
이 방식의 장점은 해당 객체가 메모리에 존재하지 않아도 프록시를 통해 정보를 참조할 수 있고, 타겟이 반드시 필요한 시점까지 타겟 객체의 생성을 미뤄 메모리를 사용 시점을 늦출 수 있다.
2.6. 프록시는 어디에 쓰나요?
프록시는 기존 코드에 영향을 주지 않으면서 기능을 확장하거나 접근 방법을 제어한다. 그럼에도 불구하고 많은 개발자는 타겟 코드를 고치고 말지 번거롭게 프록시를 만들지는 않는다고 한다.
그 이유는 프록시를 만드려면 부가 기능을 추가하고자 할때마다 새로운 클래스를 정의해야 하고, 인터페이스의 구현 메서드가 많다면 모든 메서드에 일일히 구현하고 위임하는 코드를 넣어야 한다.
UserService가 아닌 ProductService가 있다고 가정하고, 여기에 트랜잭션 부가기능을 처리하는 프록시를 통해 구성한다고 하자. ProductServiceTx 클래스를 만들고, 구현해야할 메서드마다 트랜잭션 로직과 핵심 기능을 담당하는ProductServiceImpl를 만들어 위임하는 로직을 넣어야 한다. 메서드 양이 많아지면 작업량도 많아지고, 중복코드도 많아진다.
이러한 문제점들을 해결하여 좀더 간단하게 프록시를 구성하는 방법이 있을까? 있다! 그게 바로 다이나믹 프록시이다.
3. 다이나믹 프록시
3.1. 다이나믹 프록시란?
다이니믹 프록시는 런타임 시점에 프록시를 자동으로 만들어서 적용해주는 기술이다. 자바에서는 리플렉션 기능을 사용해서 프록시를 만드는 JDK 다이나믹 프록시를 사용한다.
3.2. 리플렉션이란?
리플렉션에 대한 내용은 따로 포스팅하였다. 다이나믹 프록시의 기반 기술이기도 하지만 스프링 프레임워크의 기반 기술이기도 하기에 이 개념에 대해 잘 모른다면 이해해보길 권장한다.
결론은 리플렉션을 사용하면 런타임시에 클래스에 대한 정보를 가져올 수 있고, 클래스의 메서드를 호출할 수 있는 것이다. 그럼 이 클래스의 메서드를 호출하기 전, 후에 부가기능을 추가한다면 앞서 배웠던 프록시 클래스를 만들어 적용했던 것과 동일하게 동작시킬수도 있는 것이다.
3.3. 프록시 클래스
프록시 객체를 이용한 방법과 다이나믹 프록시를 이용한 방법의 차이를 느끼기 위해 먼저 데코레이터 패턴을 적용해보자. 패턴을 만들기 위해 타겟 클래스와 인터페이스, 부가기능 클래스를 정의했다.
public class HelloTarget implements Hello{
@Override
public String sayHello(String name) {
return "Hello "+name;
}
@Override
public String sayHi(String name) {
return "Hi "+name;
}
@Override
public String sayThankYou(String name) {
return "Thank You "+name;
}
}
3.2.3. Test.java
간단한 테스트 코드이다.
@Test
public void simpleProxy(){
Hello hello = new HelloTarget(); // 타깃은 인터페이스를 통해 접근
assertThat(hello.sayHello("Sim")).isEqualTo("Hello Sim");
assertThat(hello.sayHi("Sim")).isEqualTo("Hi Sim");
assertThat(hello.sayThankYou("Sim")).isEqualTo("Thank You Sim");
}
일단은 부가기능은 넣지 않고 타겟클래스와 인터페이스만 사용하였다. 이제 문자열을 대문자로 치환하는 부가기능을 추가한 프록시를 만들어보자.
3.2.4. HelloUppercase.java
public class HelloUppercase implements Hello{
private final Hello hello;
public HelloUppercase(Hello hello){
this.hello = hello;
}
@Override
public String sayHello(String name) {
return hello.sayHello(name).toUpperCase();
}
@Override
public String sayHi(String name) {
return hello.sayHi(name).toUpperCase();
}
@Override
public String sayThankYou(String name) {
return hello.sayThankYou(name).toUpperCase();
}
}
멤버 필드로 정의된 Hello는 타겟 클래스로 HelloTarget을 참조하도록 해야한다.
3.2.5. Test.java
@Test
public void simpleProxy(){
Hello hello = new HelloTarget(); // 타겟 오브젝트 생성
Hello proxyHello = new HelloUppercase(hello); // 프록시 오브젝트 생성 및 의존성 주입
assertThat(proxyHello.sayHello("Sim")).isEqualTo("HELLO SIM");
assertThat(proxyHello.sayHi("Sim")).isEqualTo("HI SIM");
assertThat(proxyHello.sayThankYou("Sim")).isEqualTo("THANK YOU SIM");
}
이로써 데코레이터 패턴을 적용한 프록시 구조가 만들어졌다. 부가 기능을 하는 HelloUppercase 코드를 보면 프록시의 문제점인 부가기능(대문자로 치환)과 위임(hello.method) 코드가 중복되는 것을 알 수 있다.
이제 다이나믹 프록시를 적용하여 이 문제를 해결해보자.
3.3. 다이나믹 프록시 적용
다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트이다. 다이나믹 프록시의 오브젝트는 타겟의 인터페이스와 같은 타입으로 자동으로 만들어진다. 다이나믹 프록시는 오브젝트를 타겟 인터페이스를 통해 사용할 수 있다.
부가기능 제공 코드는 InvocationHandler를 구현한 오브젝트로 생성해야 한다.
3.3.1. UppercaseHandler.java
타겟 메서드를 실행한 결과 값이 String이거나 해당 메서드명이 say로 시작할 경우 대문자로 변환하는 부가기능을 추가하였다.
public class UppercaseHandler implements InvocationHandler {
private Object target;
public UppercaseHandler(Object target){
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args); // invoke를 통해 target 오브젝트의 method 실행
if(ret instanceof String && method.getName().startsWith("say")){ // 문자열 타입이거나 say로 시작하는 메서드일 경우
return ((String)ret).toUpperCase();
}
return ret;
}
}
3.3.2. Test.java
Hello 인터페이스에 대한 부가기능을 UppercaseHandler로 설정함과 동시에 타겟 오브젝트를 HelloTarget으로 설정하였다. 데코레이터 패턴에서 부가기능을 처리하기 위해 구현했던 HelloUppercase 클래스가 필요하지 않게 되었고, 이 클래스가 안고 있던 부가기능 로직과 위임 로직의 중복이 모두 해결되었다.
@Test
public void dynamicProxy(){
Hello proxyHello = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(), // 다이나믹 프록시를 정의하는 클래스 로더
new Class[] {Hello.class}, // 다이나믹 프록시가 구현해야할 인터페이스
new UppercaseHandler(new HelloTarget())); // 부가기능 및 위임 코드를 담는 InvocationHandler 구현 클래스
assertThat(proxyHello.sayThankYou("Sim")).isEqualTo("THANK YOU SIM");
}
4. 트랜잭션 기능 리팩토링
이제 트랜잭션 기능을 넣기 위해 사용했던 전략 패턴 대신 다이나믹 프록시를 사용하도록 변경해보자. 먼저 부가기능을 처리하는 InvocationHandler 구현체 클래스를 구현하자.
4.1. TransactionHandler.java
public class TransactionHandler implements InvocationHandler {
private Object target;
private PlatformTransactionManager transactionManager;
private String pattern;
public void setTarget(Object target){
this.target = target;
}
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionManager = transactionManager;
}
public void setPattern(String pattern){
this.pattern = pattern;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 메서드 명이 pattern 으로 시작할 경우
if(method.getName().startsWith(pattern)){
return invokeInTransaction(method, args); // 트랜잭션 기능과 함께 메서드 실행
}
return method.invoke(target, args); // 트랜잭션 기능 없이 메서드 실행
}
public Object invokeInTransaction(Method method, Object[] args) throws Throwable {
// 트랜잭션 생성
TransactionStatus status = this.transactionManager
.getTransaction(new DefaultTransactionDefinition());
try{
// 메서드 실행
Object ret = method.invoke(target, args);
// 트랜잭션 commit
this.transactionManager.commit(status);
return ret;
} catch (InvocationTargetException e) {
// 타겟 메서드 실행 중 예외 발생 시 트랜잭션 rollback
this.transactionManager.rollback(status);
throw e.getTargetException();
}
}
}
메서드 명이 pattern으로 시작할 경우 트랜잭션 기능 사이에 타겟 메서드를 호출하는 invokeInTransaction() 메서드를 호출한다. 내부에서는 리플렉션을 사용하여 타겟 오브젝트에 대한 메서드를 실행한다. 예외가 발생할 경우 롤백이, 발생하지 않을 경우 커밋이 된다.
그리고 다이나믹 프록시를 통한 타겟 메서드 호출 시 예외가 발생할 경우 InvocationTargetException 예외 안에 타겟 메서드에서 발생한 예외가 포장되므로 이에 맞게 예외 처리 로직을 수정한다.
4.2. 테스트
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
public class DynamicProxyUserServiceTest {
@Autowired
private UserServiceImpl userService;
@Autowired
private PlatformTransactionManager transactionManager;
@SpyBean
private IUserDao userDao;
@SpyBean
private DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;
private final List<User> users = Arrays.asList(
new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
);
@BeforeEach
void setUp(){
userService.setUserDao(userDao);
userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy);
given(userDao.getAll()).willReturn(users);
willThrow(new RuntimeException()).given(userLevelUpgradePolicy).upgradeLevel(users.get(3));
}
@Test
void upgradeAllOrNothingWithNoProxy(){
// 테이블 데이터 초기화
userDao.deleteAll();
// 테이블에 유저 정보 insert
users.forEach(user -> userDao.add(user));
// 유저 레벨 업그레이드 메서드 실행 및 예외 발생 여부 확인 (setUp 메서드에 4번째 유저 업그레이드 처리 시 예외 발생하도록 스터빙 추가)
assertThatThrownBy(() -> userService.upgradeLevels())
.isInstanceOf(RuntimeException.class);
// DB
assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.SILVER); // 트랜잭션이 적용되지 않아 BASIC 레벨로 롤백되지 않음.
assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);
}
@Test
void upgradeAllOrNothingWithProxy(){
// 부가기능 핸들러 객체 생성
TransactionHandler txHandler = new TransactionHandler();
txHandler.setTarget(userService);
txHandler.setTransactionManager(transactionManager);
txHandler.setPattern("upgradeLevels");
// 다이나믹 프록시 생성
UserService proxyUserService = (UserService) Proxy.newProxyInstance(
getClass().getClassLoader() // 다이나믹 프록시 클래스의 로딩에 사용할 클래스 로더
,new Class[] {UserService.class} // 구현할 인터페이스
,txHandler // 부가 기능과 위임 코드를 담은 InvocationHandler
);
// 테이블 데이터 초기화
userDao.deleteAll();
// 테이블에 유저 정보 insert
users.forEach(user -> userDao.add(user));
assertThatThrownBy(() -> proxyUserService.upgradeLevels())
.isInstanceOf(RuntimeException.class);
assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.BASIC); // 트랜잭션이 적용되지 않아 BASIC 레벨로 롤백됨.
assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);
}
}
users 필드에 설정한 테스트 픽스처에 대해 두가지 테스트를 진행하도록 하였다.
upgradeAllOrNothingWithNoProxy 테스트는 다이나믹 프록시를 적용하지 않은 케이스로 UserService의 구현체 클래스를 직접 호출하고 있어 트랜잭션이 적용되지 않는다. upgradeAllOrNothingWithProxy는 다이나믹 프록시를 적용한 케이스로 트랜잭션이 적용되어 있다.
setUp 메서드에서 4번째 유저의 업그레이드 시 예외가 발생하도록 스터빙 처리하였기에, 첫번째 테스트 케이스에서는 예외가 발생해도 이전 업그레이드 처리 된 유저에 대해서 롤백이 되지 않는지를 체크했고, 두번째 테스트 케이스에서는 예외가 발생할 경우 롤백이 되는지를 체크했다. 테스트 결과는 성공적이었다.
AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3개 기반 중 하나이며, 이해하기 어려운 기술 중 하나이다. 스프링에 적용된 가장 인기있는 AOP의 적용 대상은 선언적 트랜잭션 기능이다. 서비스 추상화를 적용한 트랜잭션 경계설정 기능을 AOP를 사용해 더욱 깔끔한 코드로 개선함과 동시에 AOP라는 기술을 이해해보도록 하자.
2. 트랜잭션 코드의 분리
현재 트랜잰션 관련 코드는 아래와 같이 UserService에 비지니스 로직과 공존한다. 트랜잭션 경계 설정은 비지니스 로직 전/후에 설정되어야 하므로 틀렸다고 할 순 없지만, 트랜잭션 관련 코드와 비지니스 로직 관련 코드가 함께 존재하므로 리팩토링이 필요해 보인다. 트랜잭션 코드 분리를 통해 리팩토링 해보자.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
); // 트랜잭션 관련 코드
try{
List<User> users = userDao.getAll(); // 비지니스 로직 관련코드
for(User user : users) { // 비지니스 로직 관련코드
if (userLevelUpgradePolicy.canUpgradeLevel(user)) { // 비지니스 로직 관련코드
userLevelUpgradePolicy.upgradeLevel(user); // 비지니스 로직 관련코드
}
}
transactionManager.commit(status); // 트랜잭션 관련 코드
}catch(Exception e){
transactionManager.rollback(status); // 트랜잭션 관련 코드
}
}
2.1. 메서드 분리
트랜잭션 경계 설정의 코드와 비지니스 로직 코드 간에는 서로 주고받는 정보가 없다. 이 메서드에서 시작된 트랜잭션 정보는 트랜잭션 동기화 방법을 통해 DAO가 알아서 사용한다. 즉, 이 두 가지 코드는 성격이 다르고, 주고받는 것도 없는 독립적인 코드이므로 아래와 같이 메서드로 분리할 수 있다.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
upgradeLevelsInternal();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
private void upgradeLevelsInternal(){
List<User> users = userDao.getAll();
for(User user : users) {
if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
userLevelUpgradePolicy.upgradeLevel(user);
}
}
}
internal 메서드를 만들어 분리하긴 했지만 여전히 트랜잭션을 담당하는 코드가 UserService에 포함되어 있다. 이번에는 트랜잭션 코드를UserService 밖으로 뽑아내보자.
2.2. DI를 통한 트랜잭션 분리
DI를 사용한다는 것은 인터페이스를 도입한 후 런타임에 구현체 클래스를 설정해주어 확장과 변경을 용이하게 하기 위함이다. 현재 클라이언트는 UserServiceTest이며, UserService에 직접 접근하고 있으나 인터페이스를 도입하면 다음과 같은 형태로 구현 가능하다.
그런데 이 작업의 목표는 트랜잭션 로직을 분리하는 것이다. 이 상태라면 UserServiceImpl에 기존 UserService의 코드가 들어가야 하기에 결국 트랜잭션 로직과 비지니스 로직이 아직도 한 클래스에 들어가 있는 상태이다. 때문에 아래와 같이 트랜잭션 기능을 처리하는 UserService의 구현체 클래스를 새로 생성해야한다.
키포인트는 UserServiceImpl는 비지니스 로직을, UserServiceTx는 트랜잭션 로직을 담당하게 하는 것이다. 그리고 UserServiceTx 에서 UserService의 구현체를 사용하고 있는데, 이때 사용하는 UserService의 구현체를 UserServiceImpl로 설정하는 것이다. 이런 구조를 채택한 이유는 의존관계를 아래와 같이 설정하여 트랜잭션 로직과 비지니스 로직을 분리하기 위함이다. 이제 코드를 수정하자.
2.2.1. UserService.java
public interface UserService {
void upgradeLevels();
void add(User user);
}
UserService 인터페이스를 생성한다.
2.2.2. UserServiceImpl.java
public class UserServiceImpl implements UserService {
private IUserDao userDao;
private UserLevelUpgradePolicy userLevelUpgradePolicy;
public void setUserDao(IUserDao userDao){
this.userDao = userDao;
}
public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy){
this.userLevelUpgradePolicy = userLevelUpgradePolicy;
}
public void upgradeLevels() {
List<User> users = userDao.getAll(); // DB /
for(User user : users) {
if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
userLevelUpgradePolicy.upgradeLevel(user);
}
}
}
public void add(User user) {
if(user.getLevel() == null){
user.setLevel(Level.BASIC);
}
userDao.add(user);
}
}
UserServiceImpl 클래스를 생성한다. 기존 트랜잭션 관련 코드는 UserServiceTx 클래스에 넣을 예정이므로 제거한다. 그와 동시에 트랜잭션과 관련된 멤버필드인 transactionManager도 제거한다.
2.2.3. UserServiceTx.java
public class UserServiceTx implements UserService{
private PlatformTransactionManager transactionManager;
private UserService userService;
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionManager = transactionManager;
}
public void setUserService(UserService userService){
this.userService = userService;
}
@Override
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try{
userService.upgradeLevels();
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
@Override
public void add(User user) {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
userService.add(user);
try{
transactionManager.commit(status);
}catch(Exception e){
transactionManager.rollback(status);
}
}
}
UserServiceTx 클래스를 생성한다. 각각의 메서드에 트랜잭션 관련 코드를 넣는다. 그리고 비지니스 로직을 담당하는 UserServiceImpl을 DI받기 위해 UserService에 대한 멤버필드와 수정자 메서드를 추가한다.
2.2.4. application-context.xml
<bean id = "userService" class ="org.example.user.service.UserServiceTx">
<property name="transactionManager" ref = "transactionManager"></property>
<property name="userService" ref = "userServiceImpl"></property>
</bean>
<bean id = "userServiceImpl" class = "org.example.user.service.UserServiceImpl">
<property name="userDao" ref = "userDao"></property>
<property name="userLevelUpgradePolicy" ref = "userLevelUpgradePolicy"></property>
</bean>
이제 DI 정보를 위와같이 수정한다. userService 빈 구현체는 UserServiceTx로 DI하고, UserServiceTx에서 사용하는 userService 멤버필드는 userServiceImpl 빈을 DI한다.
2.2.5. 테스트
이로써 같은 인터페이스에 대한 두 개의 구현체 클래스를 사용하여 비지니스 로직과 트랜잭션 로직을 분리하였다. 이제 작성한 테스트 코드를 위 구조에 맞게 수정한 후 테스트를 통해 확인해보자.
2.3. 트랜잭션 코드 분리의 장점
위와 같은 작업을 통해 얻을 수 있는 장점은 뭘까?
첫째, 비지니스 로직을 담당하는 코드를 작성할 때에는 트랜잭션 로직에 대해 신경쓰지 않아도 된다. 또 트랜잭션 적용이 필요한지도 신경쓰지 않아도 된다. 트랜잭션은 모두 UserServiceTx와 같은 트랜잭션 관련 클래스가 신경쓸 일이다.
둘째, 비지니스 로직에 대한 테스트를 쉽게 만들 수 있다. 이에 대한 내용은 아래에서 더 자세하게 다룬다.
3. 고립된 단위 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 테스트가 실패했을때 원인을 찾기 쉽기 때문이다. 클래스 하나에 대한 테스트와 여러 클래스에 대한 테스트 중 전자가 오류를 찾기 쉽다는건 매우 당연하다.
3.1. 복잡한 의존관계 속의 테스트
UserService를 테스트하기 위해선 아래와 같이 의존하고 있는 클래스인 UserDao, MailSender, PlatformTransactionManager, UserLevelUpgradePolicy를 설정해야 한다. UserServiceTest 클래스는 비지니스 로직인 UserServiceImpl 클래스를 테스트하기 위함이나, 의존 클래스인 UserDao, MailSender, PlatformTransactionManager, UserLevelUpgradePolicy 클래스도 테스트하게 되는 격이다. 왜? 언급한 4개의 클래스 중 한 부분에서 에러가 날 경우 UserServiceTest 의 테스트는 실패하기 때문이다.
이럴때 Mock과 같은 테스트 대역을 사용하여 테스트 대상이나 환경이 다른 클래스에 영향을 받지 않도록 고립시켜야 한다.
3.2. UserServiceImpl 고립
현재 필자의 UserServiceImpl은 UserDao와 UserLevelUpgradePolicy를 의존하고 있다. 때문에 이 두 클래스에 대한 테스트 대역인 Mock을 사용하거나 UserDao의 경우 Fake 대역을 사용하는 방법으로 구성할 수 있다. 필자는 Mockito에서 제공하는 MockBean을 사용하여 구현하였다.
class UserServiceTest {
private final UserServiceImpl userServiceImpl = new UserServiceImpl();
private final IUserDao userDao = mock(IUserDao.class);
private final UserLevelUpgradePolicy userLevelUpgradePolicy = mock(UserLevelUpgradePolicy.class);
private final List<User> users = Arrays.asList(
new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
);
@BeforeEach
void setUp(){
userServiceImpl.setUserDao(userDao);
userServiceImpl.setUserLevelUpgradePolicy(userLevelUpgradePolicy);
given(userDao.getAll()).willReturn(users);
willDoNothing().given(userDao).add(any(User.class));
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).willReturn(false);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).willReturn(true);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).willReturn(false);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).willReturn(true);
given(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).willReturn(false);
given(userLevelUpgradePolicy.upgradeLevel(any(User.class))).will(invocation -> {
User source = invocation.getArgument(0);
return source.getId();
});
}
@Test
@DisplayName("업그레이드 레벨 테스트")
void upgradeLevels(){
userServiceImpl.upgradeLevels();
verify(userDao).getAll();
verify(userLevelUpgradePolicy,times(5)).canUpgradeLevel(any(User.class));
verify(userLevelUpgradePolicy).upgradeLevel(users.get(1));
verify(userLevelUpgradePolicy).upgradeLevel(users.get(3));
}
@Test
@DisplayName("레벨이 할당되지 않은 User 등록")
void addWithNotAssignLevel(){
User user = users.get(0);
user.setLevel(null);
userServiceImpl.add(user);
assertThat(user.getLevel()).isEqualTo(Level.BASIC);
verify(userDao).add(any(User.class));
}
@Test
@DisplayName("레벨이 할당된 User 등록")
void addWithAssignLevel(){
User user = users.get(0);
Level userLevel = user.getLevel();
userServiceImpl.add(user);
assertThat(user.getLevel()).isEqualTo(userLevel);
}
}
given을 통해 Mock에 대한 Stub을 설정하였다. 그리고 userLevelUpgrade가 실제로 이루어졌는지에 대한 테스트코드는 모두 삭제했는데, 그 이유는 해당 테스트는 UserSeviceImpl이 아닌 UserLevelUpgradePolicy에서 이루어져야 하기 때문이다.
4. 단위 테스트와 통합 테스트
4.1. 단위테스트
테스트 대상 클래스를 테스트 대역을 이용해 고립시키는 테스트이다.
4.2. 통합 테스트
두 개 이상의 성격이나 계층이 다른 오브젝트를 연동하여 테스트하거나, 외부 파일, DB 등의 리소스가 참여하는 테스트이다. 스프링 컨텍스트에서 DI된 오브젝트를 테스트하는 것도 통합 테스트이다.
4.3. 테스트 선택 가이드라인
1) 항상 단위 테스트를 먼저 고려한다.
2) 테스트 코드에서의 의존관계는 모두 차단하고 Stubbing이나 목 오브젝트 등의 테스트 대역을 사용하여 테스트한다.
3) 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
4) DAO의 경우도 Stubbing이나 목 오브젝트로 대처해서 테스트한다.
5) 여러 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 줄어든다.
6) 가능하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하여 단위테스트를 하는게 좋지만 스프링의 설정 자체도 테스트 대상이고, 스프링을 이용해 좀 더 추상적인 레벨에서 테스트를 해야할 경우는 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.
5. Mockito 프레임워크
목 클래스를 생성할 필요 없이 메서드 호출만으로 테스트용 목 오브젝트를 구현할 수 있는 프레임워크이다. 이를 통해 생성된 목 오브젝트는 아무 기능이 없기때문에 특정 메서드가 호출됐을 때 어떤 값을 리턴해야하는지와 같은 Stub 기능을 추가해야한다. 이를 Stubbing 이라고 한다.