토비의 스프링을 공부하다가 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()를 통해 콘솔에 출력도 해보았다.
먼저 프록시가 뭘까? 프록시에 대해 알아보기 전 이전 스터디때 배웠던 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 이라고 한다.
현재 트랜잭션 기술과, UserService, UserDao같은 어플리케이션 로직에 대해 추상화기법이 적용되어 있다. UserDao와 UserService는 각각 User에 대한 데이터 처리, 비지니스 처리에 대한 관심으로 구분되어 있다.
이 둘 모두 전략 패턴이 적용되어 있으며, 낮은 결합도를 갖는다. 결합도가 낮기 때문에 UserDao의 데이터 처리 로직이 바뀌어도 UserService의 코드에는 영향을 주지 않는다. 반대도 마찬가지다.
UserDao는 DB Connection을 생성하는 방법에 대해서도 독립적이다. DataSource 인터페이스와 DI를 통해 추상화된 방식으로 로우레벨의 DB 연결 기술을 사용하기 때문이다. DB 풀링 라이브러리를 사용하든, JDBC의 DriverManager를 사용하든, WAS가 JNDI를 통해 데이터 소스 서비스를 이용하든 상관없이 UserDao 코드에는 영향을 주지 않는다. 즉, UserDao와 DB 연결 기술도 결합도가 낮고, 수직적으로 분리되어 있다는 뜻이다.
이렇게 결합도가 낮은 구조로 만들 수 있는 데에는 스프링의 DI가 중요한 역할을 하고 있다. DI의 가치는 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다. 이제 이러한 내용을 단일 책임원칙이라는 개념과 함께 이해해보자.
2. 단일 책임 원칙
2.1. 단일 책임 원칙이란?
DI의 가치인 '분리'는 객체지향 설계의 원칙 중 하나인 '단일 책임 원칙'으로 설명할 수 있다.
단일 책임 원칙(SRP : Single Responsibility Principle) 객체는 단 하나의 책임만 가져야 한다.
UserService에 JDBC Connection 메서드를 직접 사용했을 때에는 사용자의 레벨 관리와 트랜잭션 관리에 대한 두가지 책임을 갖고 있었다. 단일 책임 원칙을 지키지 못하는 것이다. 이로써 사용자의 레벨 관리 정책이 바뀌거나, 트랜잭션 기술이 JDBC 에서 JTA로 변경될 경우 UserService 클래스의 코드도 반드시 수정되어야 했었다.
추후 트랜잭션 기술에 대한 추상화 기법의 도입하여 트랜잭션 관리 책임을 트랜잭션 매니저에게 위임했다. 이로써 UserService는 단일 책임 원칙을 지키게 됐고, 트랜잭션 기술이 바뀌어도 UserService의 코드는 바뀔 필요가 없게 되었다.
2.2. 단일 책임 원칙의 장점
어떤 변경이 필요할 때 수정 대상이 명확해진다. 트랜잭션 기술이 바뀌면 기술 추상화 계층의 설정만 바꿔주면 되고, 데이터를 가져오는 테이블이 바뀌었다면 데이터 액세스 로직을 담고있는 UserDao만 변경하면 된다. 마찬가지로 레벨 관리 정책이 바뀌면 UserService만 변경하면 된다.
이러한 구조로 만들기 위해 빠지지 않았던게 바로 스프링 DI였다. 인터페이스의 도입과 적절한 DI는 단일 책임 원칙 뿐 아니라 개방 폐쇄 원칙도 잘 지키게 되니 결합도가 낮아 변경에 유연하며, 단일 책임에 집중하는 응집도 높은 코드를 개발할 수 있다.
3. 메일 서비스 추상화
위 내용을 생각하며 메일 발송에 대한 추상화를 적용해보자. 메일은 자바에서 제공하는 메일 발송 표준 기술인 JavaMail을 사용한다. 일반적으로 사용되는 예제 코드를 사용했으며, 필자의 경우 NAVER에서 제공하는 SMTP를 사용하였기에 사용에 필요한 여러 프로퍼티와 id, password 정보를 담고있는 Authenticator 객체 활용해주었다.
3.1. DefaultUserLevelUpgradePolicy.java
public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{
...
public String upgradeLevel(User user){
user.upgradeLevel();
userDao.update(user);
sendUpgradeMail(user); // 메일 발송 추상화메서드
return user.getId();
}
private void sendUpgradeMail(User user) {
Properties props = new Properties();
props.put("mail.host", "smtp.naver.com");
props.put("mail.port", "465");
props.put("mail.smtp.auth" , "true");
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.ssl.trust", "smtp.naver.com");
props.put("mail.debug", "true");
Session s = Session.getInstance(props, new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication("ID","PASSWORD");
}
});
MimeMessage message = new MimeMessage(s);
try{
message.setFrom(new InternetAddress("test@naver.com"));
message.addRecipient(Message.RecipientType.TO, new InternetAddress(user.getEmail()));
message.setSubject("Upgrade 안내");
message.setText("사용자님의 등급이 " + user.getLevel().name() +" 로 업그레이드 되었습니다.");
Transport.send(message);
} catch (AddressException e) {
e.printStackTrace();
throw new RuntimeException(e);
} catch (MessagingException e) {
e.printStackTrace();
throw new RuntimeException(e);
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
}
3.2. 테스트 결과
기존에 작성했던 테스트 코드를 실행하니 레벨 업그레이드 관련된 테스트 시 지정한 메일 주소로 메일이 발송됨을 확인할 수 있었다. (필자의 메일로 보냈다.)
3.3. 메일이 발송되는 테스트
이제 테스트 코드를 실행할 때마다 메일이 발송되게 되었다. 그런데 이처럼 메일이 발송되는 테스트는 바람직한 테스트라고 할 수 없다. 그 이유는 다음과 같다.
첫째, 메일 발송이란 부하가 큰 작업이다. 필자의 경우 네이버에서 제공하는 메일 서버를 사용했지만, 만약 유료 메일서버나, 본인이 속한 회사에서 실제 운영중인 메일 서버를 사용한다면 해당 서버에 상당한 부담을 줄 수 있다.
둘째, 실수로 인해 메일을 발송할 수 있다. 메일 테스트를 위해 개발자 자신의 이메일이나, 더미 이메일을 설정할수도 있지만, 개발, 운영 환경 설정 실수로 인해 테스트 메일을 실 사용자에게 발송할 수도 있다.
셋째, 테스트 속도가 느려진다. 레벨 업그레이드에 대한 테스트 케이스가 많다면, 실제 메일 발송도 많이 일어나게 된다. 메일 발송이 많다면, 테스트도 느려질 수 있다.
메일 서버는 충분히 테스트된 시스템이므로 초기에 몇번정도만 실제 주소로 메일을 보내고 받는걸 확인하는 걸로 충분하다. 결국 바람직한 메일 발송 테스트란 메일 전송 요청은 받되 메일 발송은 되지 않도록 하는것이다. 운영 시에는 JavaMail을 직접 이용해서 동작하도록 하고, 테스트 중에는 JavaMail을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 구현해보자.
4. 메일 발송 테스트를 위한 서비스 추상화
실제 메일 전송을 수행하는 JavaMail 대신 JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들어 사용하려했으나 그럴 수 없었다. 이유는 JavaMail의 핵심 API에는 DataSource처럼 인터페이스로 만들어진게 없기때문에 구현부를 바꿀 수 없다. 메일 발송 시 사용하는 Session, MailMessage, Transport 모두 인터페이스가 아닌 클래스이다. 실제로 JavaMail은 확장이나 지원이 불가능하도록 만들어진 API 중 하나라고 한다.
스프링에서는 이러한 JavaMail에 대한 추상화 기능을 제공하고 있다. JavaMail의 서비스 추상화 인터페이스는 다음과 같다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
class DefaultUserLevelUpgradePolicyTest {
@Autowired
IUserDao userDao;
@Autowired
DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;
List<User> users; // 테스트 픽스처
public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
public static final int MIN_RECCOMEND_FOR_GOLD = 30;
@BeforeEach
void setUp(){
users = Arrays.asList(
new User("test1","테스터1","pw1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER-1, 0, "tlatmsrud@naver.com"),
new User("test2","테스터2","pw2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0, "tlatmsrud@naver.com"),
new User("test3","테스터3","pw3", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD-1, "tlatmsrud@naver.com"),
new User("test4","테스터4","pw4", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD, "tlatmsrud@naver.com"),
new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
);
}
@Test
void canUpgradeLevel() {
assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).isFalse();
assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).isTrue();
assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).isFalse();
assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).isTrue();
assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).isFalse();
}
@Test
void upgradeLevel() {
userLevelUpgradePolicy.upgradeLevel(users.get(1));
assertThat(users.get(1).getLevel()).isEqualTo(Level.SILVER);
userLevelUpgradePolicy.upgradeLevel(users.get(3));
assertThat(users.get(3).getLevel()).isEqualTo(Level.GOLD);
}
}
테스트 코드는 기존과 바뀐게 없다. 이로써 아래와 같이 MailSender 인터페이스를 핵심으로 하는 메일 전송 서비스 추상화 구조가 아래와 같이 구성되었다.
4.4. 테스트
테스트가 성공했을 때 메일 발송은 되지 않는 걸 확인할 수 있을 것이다.
5. 테스트 대역
5.1. 테스트 대역이란?
그런데 사실 이제까지 작성한 테스트 코드는 문제점이 하나 있다. 바로 테스트의 범위이다. UserServiceTest 클래스의 경우 UserService 클래스의 메서드를 테스트하는 코드이다. 하지만 그와 동시에 의존하는 UserLevelUpgradePolicy의 실제 로직도 수행된다. 사실 UserServiceTest 클래스의 관심사는 오로지 UserService 클래스의 로직이어야 한다. 의존하는 객체에 대해서는 그 클래스의 Test 코드에서 수행하는게 맞다.
테스트 대상 오브젝트에는 여러 의존 오브젝트들이 있을 수 있다. 이때에는 의존 오브젝트에 대한 '대역' 역할을 하는 오브젝트를 만들어 테스트가 이상없이 동작되도록 해야한다. 이러한 오브젝트들을 테스트 대역(test double) 이라고 한다.
5.2. 테스트 대역의 종류
5.2.1. Dummy
아무런 동작을 하지 않는 테스트 대역이다. 인스턴스화된 객체는 필요하지만 기능은 굳이 필요없는 경우에 사용한다. 앞서 생성한 DummyMailSender가 Dummy에 속한다.
public class DummyMailSender implements MailSender{
@Override
public void send(SimpleMailMessage simpleMessage) throws MailException {
}
@Override
public void send(SimpleMailMessage... simpleMessages) throws MailException {
}
}
5.2.2. Fake
동작은 하지만, 실제 동작 방식과 다르게 구현하는 테스트 대역이다. DB 데이터 처리를 Fake로 만들고자 한다면 Repository 인터페이스에 대한 구현체 클래스를 만들고 내부에 ArrayList와 같은 멤퍼 필드를 생성하여 인메모리로 관리할 수 있다. 즉, DB가 아닌 다른 방식으로 구현하는 것이다.
아래는 데이터를 실제 DB가 아닌 인메모리에 저장한 list로 처리하도록 구현한 예이다.
public class FakeUserDao implements IUserDao{
private final List<User> list = new ArrayList<>();
@Override
public void add(User user) {
list.add(user);
}
@Override
public void deleteAll() {
list.clear();
}
@Override
public User get(String id) {
return list.stream()
.filter(user -> id.equals(user.getId()))
.findFirst()
.get();
}
@Override
public int getCount() {
return list.size();
}
...
}
5.2.3. Stub
Dummy 객체가 실제로 동작하는 것처럼 구현하는 테스트 대역이다. 어떤 메서드를 호출했을 때 반환 값이 없을 경우 Dummy를 사용해도 되지만, 반환 값이 있거나 네거티브 테스트로 인해 예외를 발생시켜야 할 경우에는 기존 Dummy 객체에 의도한 동작을 구현해줘야 한다.
아래는 ID가 100인 사용자 정보를 요청할 경우 RuntimeException을 발생시키고, 그 외에는 무조건 테스터 유저에 대한 픽스처를 생성 후 리턴하는 예이다.
public class StubUserDao implements IUserDao{
...
@Override
public User get(String id) {
if("100".equals(id)){
throw new RuntimeException("삭제된 ID입니다.");
}
return new User("1", "테스터","비밀번호", Level.BASIC,0 ,0,"test@naver.com");
}
...
}
5.2.4. Spy
기본적으론 실제 오브젝트처럼 동작한다. 대신, 원하는 메서드에 Stubbing 처리를 하여 응답을 미리 지정할 수 있다. 즉, Subbing이라는 스파이를 지정할 수 있는 테스트 대역이다. mockito 라이브러리에서 제공하는 SpyBean 어노테이션을 사용하면 쉽게 구현할 수 있다.
아래는 upgradeLevel() 메서드 호출 시 users.get(3)에 대해서만 RuntimeException을 발생시키고, 나머지는 실제 로직을 수행시키도록 하는 예이다. given 메서드를 사용하여 upgradeLevel에 대한 Stub를 지정하고 있다.
@SpyBean
DefaultUserLevelUpgradePolicy spyUserLevelUpgradePolicy;
...
@Test
@DisplayName("레벨업 처리 도중 예외 발생 테스트")
void exceptionDuringLevelUp(){
...
// users.get(3) 오브젝트로 upgradeLevel 메서드를 호출하면 RuntimeException을 발생시키도록 Stubbing
given(spyUserLevelUpgradePolicy.upgradeLevel(users.get(3))).willThrow(new RuntimeException());
/* upgradeLevels 메서드 내부에서 spyUserLevelUpgradePolicy.upgradeLevel()를 호출하고 있음.
* users.get(0), users.get(1), users.get(2)에 대한 매개변수로 호출 시 실제 로직이 실행되나,
* users.get(3) 으로 호출 시 RuntimeException이 발생함.
*/
userService.upgradeLevels();
...
}
5.2.5. Mock
가짜 객체를 만든 후 Stubbing을 통해 미리 명세를 정의해 놓은 대역을 말한다. Stub와 다른 점은 메서드의 리턴 값으로는 판단할 수 없는 행위를 검증할 수 있다는 점이다. 예제에서는 리턴 값이 없어 메일 발송 내역 확인이 어려운 DummyMailSender 클래스 대신 발송 내역을 확인할 수 있는, 즉 어떤 행위를 했는지를 명확히 검증하기 위해 MockMailSender 클래스를 생성하였다. 테스트 코드에서는 requests를 조회한 후 누구에게 발송했는지를 확인할 수 있다.
public class MockMailSender implements MailSender {
private final List<String> requests = new ArrayList<>();
@Override
public void send(SimpleMailMessage simpleMessage) throws MailException {
requests.add(simpleMessage.getTo()[0]);
}
@Override
public void send(SimpleMailMessage... simpleMessages) throws MailException {
}
public List<String> getRequests(){
return requests;
}
}
추가로 mockito 라이브러리에서 제공하는 기능 중 Mock 객체에 대해 특정 메서드를 어떤 파라미터로 호출 했는지를 검증할 수 있는 기능을 활용할 수 있다. 아래의 경우 userService.upgradeLevels() 내부에서 호출되는 mockUserLevelUpgrdePolicy라는 Mock 객체에 대해 users.get(3)을 파라미터로 하여 upgradeLevel 메서드가 호출됐는지를 확인한다. 이처럼 Mock은 상태 뿐 아닌 행위까지 검증할 수 있다.
Redis를 활용하여 사전순으로 조회되는 자동완성 기능을 구현했으나 검색빈도에 따라 자동완성 리스트를 뿌려주는 것이 사용자 입장에서 유용할 것 같다는 생각이 들었다. 또한 사용자 검색한 새로운 키워드도 데이터 셋에 추가해야 트렌드에 맞는 자동완성 리스트를 제공할 수 있을 것 같아 이를 적용하는 작업을 진행하였다.
2. 분석
현재 redis에 들어가 있는 자동완성 데이터는 Score가 모두 0인 Sorted Set 형식이다. 또한 사용자가 검색한 단어를 prefix로 갖고 있는 완성된 단어를 뿌려주기 위해 필자가 정한 규칙에 맞게 데이터가 들어가 있는 상황이다. 예를들어 '대', '대한', '대한민', '대한민국' 이라는 키워드 입력시 자동완성 리스트에 '대한민국' 이라는 단어가 나오도록 하기 위해 score를 모두 0으로하여 '대', '대한', '대한민', '대한민국', '대한민국*' 값을 sorted Set 형식의 데이터 셋에 저장시킨 상태이다.
비지니스 로직은 다음과 아래와 같이 구현되어 있다.
1) 검색어와 일치하는 단어의 index 조회(zrank 명령어 사용)
2) index 번째부터 index + 100번째까지의 데이터 조회(zrange 명령어 사용)
어쨌든 검색빈도 순으로 조회시키기 위해서는 반드시 검색횟수를 관리하는 데이터셋이 필요했다. 먼저 기존 데이터셋을 활용하는 쪽으로 시도하였으나 실패했다.
3. 기존 테이터 셋 활용
기존 데이터 셋의 score를 활용할 경우 사용자가 단어를 입력 후 '검색' API를 호출했을 때 검색한 단어가 데이터 셋에 추가되어야 하고, score가 1이 올라가야 한다. 이때 두가지 케이스로 나눌 수 있는데, 검색한 단어에 대해서만 score를 올리는 케이스, 검색한 단어와 연관된 데이터들의 score를 올리는 케이스이다.
3.1. 검색한 단어에 대해서만 Score 증가
전자의 경우 score가 올라가는 완성된 단어들은데이터셋의 하단부에 위치하게 되고, 나머지 데이터들은 상단부에 위치하게 된다. 원래 나머지 데이터들은 자동완성 단어를 index 기반으로 빠르게 조회하기 위해서 사용했었으나, 자동완성 단어의 score가 올라가며 index 규칙이 깨지게 되었다.
아까 말한대로 자동완성들은 하단부, 나머지 데이터는 상단부에 위치하게 되니 결국 index부터 끝까지 모든 값을 조회해야하는 상황이 발생했다. 지금은 데이터 셋이 적어서 큰 문제가 되지 않을테지만, 시간 복잡도에 따라 데이터 양이 증가할수록 속도가 느려질 것이다. (zrange의 시간복잡도는 O(log(n)+m)이다. m은 반환된 결과의 수이다.) 아래는 그 예이다.
AS-IS의 경우 '대'에 대한 zrange 시 index 기준 +4에 대한민국*이 위치한다. 즉, 가까운 곳에 자동완성 단어들이 위치하고 있기 때문에 굳이 데이터셋의 끝까지 조회할 필요가 없다. 필자의 경우 가중치를 두어 index ~ index+1000 까지만 조회하도록 했다.
index
score
value
n
0
대
n+1
0
대한
n+2
0
대한민
n+3
0
대한민국
n+4
0
대한민국*
TO-BE의 경우 '대'에 대한 zrange 시 index와 먼 곳에 자동완성 단어들이 위치한다. 데이터 셋의 끝까지 조회해야 할 필요성이 느껴진다.
index
score
value
n
0
대
n+1
0
대한
n+2
0
대한민
n+3
0
대한민국
...
...
..
m
1
대한민국*
3.1. 검색한 단어와 연관된 데이터들의 Score 증가
후자의 경우는 자동완성 단어 누락을 야기할수 있다. '대만'이라는 데이터를 추가하여 예를 들어보겠다.
index
score
value
n
0
대
n+1
0
대만
n+2
0
대만*
n+3
0
대한
n+4
0
대한민
n+5
0
대한민국
n+6
0
대한민국*
score가 0일때는 위와 같이 사전순으로 정렬된다. 사용자가 '대한민국'이라는 검색 API를 통해 검색하는 순간 관련된 데이터인 '대', '대한', '대한민', '대한민국', '대한민국*'의 score가 1씩 올라갈것이다. 그리고 데이터는 아래와 같이 조회된다.
index
score
value
n-2
0
대만
n-1
0
대만*
n
1
대
n+1
1
대한
n+2
1
대한민
n+3
1
대한민국
n+4
1
대한민국*
이때 '대'에 대한 자동완성 단어들은 index 기준 앞, 뒤에 위치해있다. 기존에는 자동완성 단어들을 찾기위해 한쪽 방향으로 조회했다면, 이제는 양쪽 방향으로 조회하며 찾아야한다.
또한 새로운 단어들이 추가될 경우 score에 따라 데이터들이 뒤죽박죽 섞여버린다. 섞여버린 데이터들에서 자동완성 단어를 찾으려면 무조건 처음부터 끝까지 풀서치를 할수밖에 없는 상황이 되었다.
index
score
value
n-?
0
대만
n-?
0
대만*
n-?
100
필라델피아*
...
...
n
500
대
...
...
n+?
1000
대한민국*
...
...
n+?
20000
삼성전자 주가*
결국 기존 데이터셋은 그대로 유지하고, 검색 횟수를 관리하는 새로운 데이터셋을 추가하기로 했다.
4. 새로운 데이터 셋 도입
검색 단어들을 관리하는 새로운 데이터 셋을 만들었다. value는 검색어, score는 검색횟수이다. 기존 데이터셋은 자동완성 데이터를 조회하기 위해 기존과 동일하게 사용하고, 새로운 데이터셋은 기존 데이터셋에서 조회한 자동완성 단어에 대해 score(검색횟수)를 조회하는 용도로 사용했다. 이후 비지니스 로직을 통해 score 기준으로 내림차순 정렬처리 하였다.
자동완성 데이터 셋
검색 횟수 데이터셋
score
value
score
value
0
대
0
대만
0
대만
0
대한민국
0
대만*
0
대한
0
대한민
0
대한민국
0
대한민국*
검색 횟수 데이터셋이 추가됨에 따라 기존 로직에서 아래 빨간 부분에 대한 로직을 추가하였다.
1) 검색어와 일치하는 단어의 index 조회(zrank 명령어 사용)
2) index 번째부터 index + 100번째까지의 데이터 조회(zrange 명령어 사용)
3) '*' 문자를 포함하는 완전한 단어를 필터링
4) 검색어의 prefix와 일치하는 완전한 단어를 필터링 및 limit
5) 필터링된 데이터의 score 조회 (zscore)
6) score를 기준으로 정렬
* 참고로 zscore를 사용한 이유는 시간복잡도가 O(1)이기 때문이다. 시간이 지날수록 데이터 양이 많아지는 데이터 특성 상 처리 속도를 일정하게 유지하는 것이 효율적이라고 판단하여 이 방식을 채택했다.
이제 실제로 구현해보자.
5. 구현
5.1. AutocompleteController.kt
@RestController
@RequestMapping("/api/autocomplete")
class AutocompleteController (
private val autocompleteService : AutocompleteService
){
@GetMapping("/{searchWord}")
@ResponseBody
fun getAutocompleteList(@PathVariable searchWord : String) : ResponseEntity<AutocompleteResponse> {
return ResponseEntity.ok(autocompleteService.getAutocomplete(searchWord))
}
}
기존과 동일하다.
5.2. AutocompleteService.kt
@Service
class AutocompleteService (
private val redisTemplate : RedisTemplate<String, String>,
@Value("\${autocomplete.limit}") private val limit: Long,
@Value("\${autocomplete.suffix}") private val suffix : String,
@Value("\${autocomplete.key}") private val key : String,
@Value("\${autocomplete.score-key}") private val scoreKey : String
){
fun getAutocomplete(searchWord : String) : AutocompleteResponse {
val autocompleteList = getAutoCompleteListFromRedis(searchWord)
return sortAutocompleteListByScore(autocompleteList)
}
fun addAutocomplete(searchWord : String ){
val zSetOperations = redisTemplate.opsForZSet()
zSetOperations.incrementScore(scoreKey, searchWord, 1.0)
zSetOperations.score(key, searchWord)?:let {
for(i in 1..searchWord.length){
zSetOperations.add(key, searchWord.substring(0,i),0.0)
}
zSetOperations.add(key, searchWord+suffix,0.0)
}
}
fun getAutoCompleteListFromRedis(searchWord : String) : List<String> {
val zSetOperations = redisTemplate.opsForZSet()
var autocompleteList = emptyList<String>()
zSetOperations.rank(key, searchWord)?.let {
val rangeList = zSetOperations.range(key, it, it + 1000) as Set<String> // 가중치 1000
autocompleteList = rangeList.stream()
.filter { value -> value.endsWith(suffix) && value.startsWith(searchWord) }
.map { value -> StringUtils.removeEnd(value, suffix) }
.limit(limit)
.toList()
}
return autocompleteList
}
fun sortAutocompleteListByScore(autocompleteList : List<String>) : AutocompleteResponse{
val zSetOperations = redisTemplate.opsForZSet()
val list = arrayListOf<AutocompleteResponse.Data>()
autocompleteList.forEach{word ->
zSetOperations.score(scoreKey, word)?.let {
list.add(AutocompleteResponse.Data(word, it))
}
}
list.sortByDescending { it.score }
return AutocompleteResponse(list)
}
}
1) getAutoCompleteListFromRedis 메서드를 통해 Redis의 자동완성 데이터셋에서 자동완성 데이터를 가져온다. 가중치는 1000, limit는 10으로 설정하였다.
2) sortAutocompleteListByScore 메서드를 통해 검색횟수 데이터 셋에 값이 있는지 확인한다. 값이 있을 경우 score 가져와 list에 추가하고, sortByDescending 메서드를 사용해 score 기준으로 내림차순 정렬한다.
3) addAutocomplete는 검색 로직에서 사용하는 메서드로, 자동완성 데이터셋에 검색어가 없을 경우 규칙에 맞게 추가하고, 검색횟수 데이터 셋에 score를 추가 및 증가시킨다.
* 참고로 검색횟수 데이터 셋 score를 증가시킬 때 사용한 incrementScore는 데이터 셋에 값이 없을경우 자동으로 값을 추가해준다.
검색관련 비지니스 로직이다.(자동완성 로직이 아니다.) 여기서 autocompleteService를 DI받아 addAutocomplete를 호출하고 있다.
5.4. AutocompleteResponse.kt
data class AutocompleteResponse(
val list : List<Data>
){
data class Data(
val value: String,
val score: Double
)
}
자동완성 응답 DTO 클래스이다.
6. 테스트
게임 기종 관련하여 자동완성 데이터셋과 검색횟수 데이터셋을 생성한 후 테스트를 진행하였다.
6.1. 자동완성 리스트 조회
'닌'이라는 단어를 입력했을 때 조회되는 자동완성 리스트들이다. value에는 자동완성 단어, score에는 검색횟수가 조회되고 있다.
6.2. 검색 후 자동완성 리스트 조회
이제 제일 하단에 조회되는 '닌텐도 DS'라는 단어를 검색 API를 통해 검색한 후 '닌'에 대한 자동완성 리스트를 다시 조회해보자. 아래와 같이 닌텐도 DS라는 단어가 최상단에 조회됨을 확인할 수 있다.
6.3. 새로운 단어 검색 후 조회
검색 API를 통해 '닌텐도 new 3DS'라는 새로운 단어를 두번 검색 후 다시 조회해보자. 아래와 같이 '닌텐도 new 3DS' 새로 추가됨과 동시에 최상단에 위치해있는 걸 확인할 수 있다.
7. 회고
기존 데이터셋을 활용하는게 메모리적와 속도면에서 훨씬 효율적일 것 같아 여러 시도를 해보았지만, 결국 새로운 데이터셋을 추가하는 쪽으로 구현하게 되었다.
새로운 데이터 셋 추가를 꺼려했던 이유는 속도 때문이었다. 처음엔 메모리도 신경쓰였으나 이 경우 기존 데이터셋보다 10분의 1도 안될 것이었기때문에 큰 걱정은 되지 않았다. 하지만 Redis를 한번 더 거쳐야 하고, 거기서 많은 자원을 사용하는 로직이 포함될 경우 처리 속도가 너무 느려지지 않을까하는 걱정이 앞섰다.
때문에 redis 문법에 대한 처리 속도와 redis 라이브러리에서 제공하는 여러 메서드들을 하나하나 찾아가며 어떤게 더 효율적일지를 고민하게 되었고, 값에 대한 score를 조회한 후 비지니스 로직에서 정렬하는 방식을 채택하게 되었다.
score 조회 명령어의 시간복잡도와, 비지니스 로직에서 정렬하는 데이터의 개수(10개)를 고려했을 때 처리속도에 큰 영향을 끼칠만큼의 복잡도가 아니라고 생각했기 때문이다.
글로 남기지 않은 많은 시행착오가 있었지만 결국 나름 괜찮은(?) 자동완성 API를 구현하게 된것 같다. 혹시 포스팅 관련하여 수정할 내용이나 피드백이 있다면 꼭! 꼭! 알려주시길 바란다 :)