Web/Gradle 2019. 10. 20. 19:41

 

이번 포스팅은 Gradle에 대한 기본을 다루어볼 것이다. 사실 Gradle이 뭔지 모르는 개발자는 거의 없을 것임으로, 자세한 설명은 하지 않을 것이다. Gradle은 빌드툴이다! (마치 Maven과 같은)

 

Gradle 내부 프로젝트 인터페이스

Project

>final DEFAULT_BUILD_FILE

>DEFAULT_BUILD_DIR_NAME

>GRADLE_PROPERTIES

>SYSTEM_PROP_PREFIX

>Task task(String name, Closure configureClosure)

 

프로젝트 인터페이스는 그래들로 프로젝트를 설정하고 구성할 때 사용하는 파일로, 그래들 프로젝트를 논리적으로 표현하는 인터페이스이다. default_build_file 파일은 프로젝트 설정에 대한 정보를 담고 있으며, 값은 build.gradle로 정의되어 있다. 그래서 build.gradle 파일에서 빌드에 필요한 기본 설정을 하게 된다. default_build_dir_name은 빌드한 결과물(ex. jar,war..)이 저장되는 디렉토리이다. Maven에서는 target 디렉토리에 저장되는데, 그래들에서는 build 디렉토리에 저장된다. gradle_properties는 그래들에서 key/value 형태로 사용할 수 있는 프로퍼티파일에 대한 속성으로 그래들이 초기화되면 build.gradle 파일과 함께 생성되고 속성을 정의해서 사용할 수 있다. 프로퍼티 파일을 사용할 때 접두어가 SYSTEM_PROP인 이유는 SystemPropertiesHandler에서 프로퍼티 파일을 읽을 때 다른 속성값들과 구분하기 위해 SystemProp\\.(.*)")와 같은 형태를 사용하기 때문이다.

 

그래들 Task 인터페이스

Task

>TASK_NAME

>TASK_TYPE

>TASK_EDPENES_ON

>TASK_ACTION

>getProject(), getDependencies()

 

프로젝트는 Task 인터페이스를 가지고 있어서 Task는 프로젝트를 기준으로 실행된다. Task를 실행할 때 Task 인터페이스에서는 기본적으로 Task의 이름과 Task의 유형, Task의 의존성 여부, Task 액션 등을 속성으로 가지고 있다. 즉, Task 인터페이스를 Action을 가지고 있는 것이다.

 

Gradle Build Lifecycle

그래들은 크게 세 단계의 빌드 라이프 사이클을 가진다.

 

1)초기화(init)

그래들이 초기화될 때 settings.gradle과 build.gradle 파일의 상호작용을 통해서 build.gradle에 있는 프로젝트의 정보를 수집한다. settings.gradle 파일의 정보로 해당 프로젝트를 싱글 프로젝트로 구성할 것인지 멀티 프로젝트로 구성할 것인지 등을 결정하고 프로젝트의 인스턴스를 생성한다. 현재 작업 중인 루트 디렉토리에 settings.gradle 파일이 없으면 상위 디렉토리에 settings.gradle파일이 있는지 확인하고, 상위 디렉토리에도 존재하지 않는다면 해당 프로젝트를 싱글 프로젝트로 인식한다.

 

2)설정

프로젝트 인스턴스화가 끝나면 설정 정보가 프로젝트에 반영되는 단계이다.

 

3)실행

그래들은 이전 단계에서 구성된 Task의 부분집합을 이름으로 구분하고, 그 이름이 Gradle 명령에 전달되어 실행된다.

 

Gradle 설치

Mac OS 기준으로 설치가 진행되었음을 알려드립니다.

 

>brew install gradle

 

설치 끝!

 

빌드 초기화

그래들에는 빌드 초기화를 지원하는 명령이 있다. 또한 init 명령은 다섯 가지 타입을 지원한다.

 

  1. Basic
  2. Groovy-library
  3. Java-library
  4. Pom
  5. Scala-library

 

간단하게 Basic 타입으로 Gradle 프로젝트를 초기화해본다.

 

>gradle init --type basic

>Select build script DSL:

  1: Groovy

  2: Kotlin

>Enter selection (default: Groovy) [1..2] 1

>Project name(default:..) gradleBasic> gradle init --type basic

Select build script DSL:

  1: Groovy

  2: Kotlin

Enter selection (default: Groovy) [1..2] 1

Project name (default: gradleExam): gradleBasic

> Task :init

Get more help with your project: https://guides.gradle.org/creating-new-gradle-builds

BUILD SUCCESSFUL in 16s

2 actionable tasks: 2 executed

 

그래들 초기화가 완료되었다. 생성된 디렉토리를 살펴보면, 아래와 같은 파일들이 존재한다.

 

  1. build.gradle
  2. gradle
  3. gradlew
  4. gradlew.bat
  5. settings.gradle

대부분 설명한 파일들이며 추후에 자세히 다루어볼 것이고, 설명하지 않은 gradlew,gradlew.bat, gradle 디렉토리가 있을 것이다. 우선 gradlew는 그래들을 설치하지 않아도 사용할 수 있게하는 Wrapping된 명령이다. gradle 디렉토리에 들어가면 Wrapper를 이용하여 그래들을 사용할 수 있게 하는 파일들이 존재한다. 다음은 자바 프로젝트로 초기화하는 그래들 초기화 명령이다.

 

> gradle init --type java-library

 

Select build script DSL:

  1: Groovy

  2: Kotlin

Enter selection (default: Groovy) [1..2] 1

Select test framework:

  1: JUnit 4

  2: TestNG

  3: Spock

  4: JUnit Jupiter

Enter selection (default: JUnit 4) [1..4] 1

Project name (default: gradle): gradleJava

Source package (default: gradleJava): com.java.gradle

> Task :init

Get more help with your project: https://docs.gradle.org/5.6.3/userguide/java_library_plugin.html

BUILD SUCCESSFUL in 18s

2 actionable tasks: 2 executed

 

생성된 build.gradle을 살펴보자.

 

 

간단히 설정을 설명하자면, 그래들은 외부 저장소를 설정할 수 있다. 일반적으로 url로 다운로드 경로를 적지만, 위와 같이 jcenter() 혹은 mavenCentral() 메서드로 대체할 수 있다. 라이브러리를 사용할 때는 dependencies 블록 안에 라이브러리 그룹명과 패키지명, 버전을 

"그룹:라이브러리:버전" 형식으로 입력한다.

 

사용자 정의 Task 만들기

이번에는 init 명령으로 생성한 build.gradle 파일을 편집해서 그래들 Task를 만든다. 다음은 "Gradle Hello"을 출력하는 Task이다.

 

task hello {

        println 'Gradle Hello'

}

 

그래들에서 Task를 만들 때는 'task Task명'을 입력한다. 스크립트를 사용할 때 'function 함수명'을 입력하는 것과 유사하다. Task를 만들면 Task 목록에 등록되며 gradle tasks명령으로 확인할 수 있다. 사용자가 정의한 Task는 Other tasks에 표시된다. task를 실행해보자.

 

>gradle hello

 

> Configure project :

Gradle Hello

BUILD SUCCESSFUL in 588ms

 

실행 결과에는 println 메서드로 출력한 문자열이 표시된다.

 

Task 실행 순서 제어하기

 

task cellphone{

        description = 'Display calling message'

        doLast{

                println '통화하기'

        }

        doFirst{

                println '전화걸기'

        }

        doLast{

                println '전화끊기'

        }

}

 

> gradle cellphone

 

> Task :cellphone

전화걸기

통화하기

전화끊기

 

결과를 보면 doFirst 태스크가 먼저 실행된 것을 볼 수 있다. 나머지는 정의된 순서대로 수행되었다. 이렇게 선행관계를 줄 수도 있다. 다음은 디폴트 Task를 지정하는 방법이다.

 

디폴트 Task 지정

그래들에서는 기본으로 실행해야 하는 Task를 디폴트로 지정할 수 있다. 지금까지는 그래들을 실행할 때 'cellphone'과 같은 Task의 이름을 파라미터로 지정하였지만, Task를 디폴트로 지정하면 'gradle'만 입력해도 실행할 수 있다.

 

defaultTasks 'defaultTask'

 

task defaultTask{

        println 'Default Task Run !'

}

 

> gradle cellphone

> Configure project :

Default Task Run !

> Task :cellphone

전화걸기

통화하기

전화끊기

BUILD SUCCESSFUL in 721ms

1 actionable task: 1 executed

=========================

> gradle

> Configure project :

Default Task Run !

BUILD SUCCESSFUL in 537ms

 

특정 태스트를 실행하던, 특정 태스크 이름을 넘기지 않아도 디폴트 Task는 항상 실행되는 것을 볼 수 있다.

 

Task 의존성 설정

Task가 실행될 때 다른 Task도 함께 실행하려면 'depends On'으로 의존관계를 설정하면 된다.

 

task cellphone(dependsOn:'dependTask'){

        description = 'Display calling message'

        doLast{

                println '통화하기'

        }

        doFirst{

                println '전화걸기'

        }

        doLast{

                println '전화끊기'

        }

}

task dependTask{

        println 'Depends On Task'

}

 

여기까지 간단하게 그래들 초기화방법과 태스크를 정의해 실행해보았다. 다음 포스팅은 자바 프로젝트에 초점에 맞춰 더 다양하게 그래들 예제를 다루어볼 것이다.

posted by 여성게
:
Web/Spring 2019. 10. 18. 00:33

 

오늘 다루어볼 포스팅은 ResponseBodyAdvice를 이용하여 컨트롤러 응답값을 가공해보는 예제이다. 사실 HandlerInterceptor의 postHandler 같은 곳에서 응답값을 가공할 수 있을 듯하지만, 사실 인터셉터 단에서 응답 가공은 불가능하다. 하지만 우리는 적절한 값으로 응답을 가공하고 싶을 때가 있는데, 그럴때 사용하는 것이 ResponseBodyAdvice이다.

 

예제 상황은 다음과 같다.

 

"Controller에서 응답값을 Enum class로 둔다. 하지만 실제 클라이언트에는 특정 응답용 객체로 컨버팅 후 내려준다."

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@RestControllerAdvice
public class EnumResponseCtrlAdvice implements ResponseBodyAdvice<Object> {
 
    @Override
    public boolean supports(MethodParameter returnType, Class<extends HttpMessageConverter<?>> converterType) {
        return true;
    }
 
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        return body instanceof EnumController.ResponseEnum ? new Response((EnumController.ResponseEnum) body) : body;
    }
 
    @Data
    @AllArgsConstructor
    class Response{
        EnumController.ResponseEnum status;
    }
}
 
@RestController
@RequestMapping
public class EnumController {
 
    @GetMapping
    public ResponseEnum enumResponse(){
        return ResponseEnum.SUCCESS;
    }
 
    @Getter
    enum ResponseEnum{
        SUCCESS("success"),FAIL("fail");
 
        String value;
        ResponseEnum(String value){this.value=value;}
 
    }
 
}
 
=>최종응답
{
"learnableStatus""LEARNABLE"
}
cs

 

어렵지 않다. 응답값은 분명 Enum class 인데, 클라이언트에 반환된 응답값은 ResponseEnum 타입의 오브젝트로 리턴되었다. 공통적인 응답 가공이 필요할때 사용하면 좋을 듯하다. 

posted by 여성게
:
Web/Spring 2019. 10. 12. 12:49

 

이번에 다루어볼 포스팅은 커스텀 어노테이션과 인터셉터를 이용하여 특정 컨트롤러의 매핑 메서드에 전처리(인증 등)를 하는 예제이다. 즉, 커스텀하게 어노테이션을 정의하고 컨트롤러 혹은 컨트롤러 메서드에 어노테이션을 붙여(마치 @RequestMapping과 같은) 해당 컨트롤러 진입전에 전처리(인증)을 하는 예제이다. 물론, Allow Ip 같은 것은 필터에서 처리하는 것이 더 맞을 수 있지만, 어떠한 API는 인증이 필요하고 어떠한 것은 필요없고 등의 인증,인가 처리는 인터셉터가 더 적당한 것 같다. 이번 포스팅을 이해하기 위해서는 Spring MVC의 구동 방식을 개념정도는 알고 하는 것이 좋을 듯하다. 아래 포스팅을 확인하자.

 

 

spring의 다양한 intercept 방법 (Servlet Filter, HandlerInterceptor, PreHandle, AOP)

1. 다양한 intercept 방법들과 주 사용처 Servlet Filter : 인코딩, 인증, 압축, 변환 등 HandlerInterceptor : 세션, 쿠키, 검증 등 AOP : 비즈니스단 로깅, 트랜잭션, 에러처리 등 2. Servlet Filter 와 Handler..

sjh836.tistory.com

 

개발 방향은 컨트롤러에 인증을 뜻하는 어노테이션을 커스텀하게 붙이고, 인터셉터에서 어노테이션의 유무를 판단하여 인증을 시도한다. 특별히 어려운 코드는 없어 간략한 설명만 하고 넘어가겠다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public enum Auth {
 
    NONE,AUTH
 
}
 
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsAuth {
 
    Auth isAuth() default Auth.NONE;
 
}
 
@Slf4j
@Component
public class CommonInterceptor implements HandlerInterceptor {
 
    private Map<String,User> userMap = new HashMap<>();
 
    @PostConstruct
    public void setup() {
        User user = new User("","yeoseong_gae",28);
        userMap.put("yeoseong-gae",user);
    }
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("interceptor");
        String userId = request.getParameter("userId");
 
        IsAuth annotation = getAnnotation((HandlerMethod)handler, IsAuth.class);
 
        Auth auth = null;
 
        if(!ObjectUtils.isEmpty(annotation)){
            auth = annotation.isAuth();
            //NONE이면 PASS
            if(auth == Auth.AUTH){
                if(ObjectUtils.isEmpty(userMap.get(userId))){
                    log.info("auth fail");
                    throw new AuthenticationException("유효한 사용자가 아닙니다.");
                }
            }
        }
        return true;
    }
 
    private <extends Annotation> A getAnnotation(HandlerMethod handlerMethod, Class<A> annotationType) {
        return Optional.ofNullable(handlerMethod.getMethodAnnotation(annotationType))
                .orElse(handlerMethod.getBeanType().getAnnotation(annotationType));
    }
 
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class User{
        private String id;
        private String name;
        private int age;
    }
}
 
@Slf4j
@RestControllerAdvice
public class CtrlAdvice {
 
    @ExceptionHandler(value = {Exception.class})
    protected ResponseEntity<String> example(Exception exception,
                                             Object body,
                                             WebRequest request) throws JsonProcessingException {
        log.debug("RestCtrlAdvice");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("message:"+exception.getMessage());
    }
 
}
 
@RestController
public class AuthController {
 
    @IsAuth(isAuth = Auth.AUTH)
    @GetMapping("/auth")
    public boolean acceptUser(@RequestParam String userId){
        return true;
    }
 
    @IsAuth
    @GetMapping("/nonauth")
    public boolean acceptAll(@RequestParam String userId){
        return true;
    }
}
cs

 

마지막으로 인터셉터를 등록하기 위한 Config 클래스이다.

 

1
2
3
4
5
6
7
8
9
10
11
@RequiredArgsConstructor
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
 
    private final CommonInterceptor commonInterceptor;
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(commonInterceptor);
    }
}
cs

 

커스텀한 어노테이션을 정의하고, 컨트롤러 매핑 메서드중 인증을 하고 싶은 곳에 어노테이션을 등록해준다. 그리고 인터셉터에서 현재 찾은 핸들러의 핸들러메서드에 우리가 등록한 커스텀한 어노테이션이 있는지 혹은 해당 핸들러메서드의 클래스에 붙어있는지 등을 리플렉션을 이용하여 확인한다. 만약 있다면 유효한 사용자인지 판단한다. 참고로 인터셉터에서는 디스패처 서블릿 더 뒷단에서 구동하기 때문에 현재 매핑된 핸들러 메서드의 접근이 가능하다.

 

마지막으로 인증이 실패한다면 예외를 발생시키고, 컨트롤러 어드바이스를 이용하여 예외를 처리한다. 참고로 인터셉터는 앞에서 말한 것과 같이 디스패처 서블릿 뒷단에서 구동되기때문에 ControllerAdvice 사용이 가능하다.

 

현재 다루고 있는 예제는 하나의 예시일 뿐이다. 사실 스프링 시큐리티를 사용한다면 굳이 필요없는 예제일수 있다. 하지만 인증뿐아니라 여러가지로 사용될수 있기에 응용하여 사용하면 될듯하다. 해당 코드는 절대 실무에선 사용될 수없는 로직을 갖고 있다.(예제를 위해 간단히 작성한 코드이기에) 컨셉을 갖고 더 완벽한 코드로 완성해야 한다. 만약 해당 코드가 필요하다면 밑의 깃헙에 소스를 올려놓았다.

 

 

 

yoonyeoseong/useAnnotationAndInterceptorAuth

Contribute to yoonyeoseong/useAnnotationAndInterceptorAuth development by creating an account on GitHub.

github.com

 

posted by 여성게
:
Web/Spring 2019. 10. 10. 00:22

 

오늘 포스팅할 내용은 간단하게 Springboot Test이다. 아주 자세하게 다루어보진 않지만, 유용하고 자주 사용하는 부분이다. 간단하게 유닛테스트하는 내용은 이전 포스팅을 참고하고, 이번 내용은 Springboot 환경에서의 테스트 내용을 다룰것이다.

 

TDD - 테스트 주도 개발(Test Driven Development)

작성하려는 코드가 있다면 항상 먼저 어떻게 그 코드를 테스트할지 고민해야한다. 코드를 작성한 후에 어떻게 테스트할지 고민하기보다 작성할 코드를 묘사하는 테스트를 설계해야 한다. 이것이 테스트 주도 개발..

coding-start.tistory.com

 

다음 코드는 Springboot에서 빈으로 등록된 객체들을 사용할수 있게하는 테스트이다. 즉, ApplicationContext를 띄우는 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
============application.properties============
test.property=test-property
==============================================
@Service
public class CommonService {
    @Autowired
    private CommonBean commonBean;
 
    public String print(){
        String result = commonBean.print();
        System.out.println(result);
        return result;
    }
}
@Component
public class CommonBean {
    @Value("${test.property}")
    private String testProperty;
    public String print(){
        System.out.println(testProperty);
        return testProperty;
    }
}
 
@RunWith(SpringRunner.class)
@SpringBootTest
public class TestPropertyTest {
    @MockBean
    private CommonBean commonBean;
    @Autowired
    private CommonService commonService;
    @Test
    public void print(){
        commonBean.print();
    }
}
 
cs

 

위 테스트는 성공적으로 통과할 것이다. 하지만 여기서 하나 문제점이 존재한다. 만약 아주 큰 애플리케이션이라 ApplicationContext 로드에 아주 긴 시간이 걸린다면 작은 테스트를 하나 수행할때마다, 긴 시간이 걸릴 것이다. 이러할때, 우리가 테스트에 필요한 빈만 띄울수 있다. 아래 코드를 확인하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
        CommonService.class,
        CommonBean.class
},initializers = ConfigFileApplicationContextInitializer.class)
public class TestPropertyTest {
 
    @Autowired
    private CommonService commonService;
 
    @Test
    public void print(){
        commonService.print();
    }
 
}
cs

 

2가지 방법이 존재한다. 첫번째 방법은 @ContextConfiguration 어노테이션을 이용하는 방법이다. 인자로 빈으로 띄울 클래스를 넘긴다. 여기서 중요한것을 관련된 클래스도 모두 넣어줘야한다는 것이다. 위를 예를 들면 CommonService 클래스를 빈으로 등록할 것인데, 해당 클래스의 내용을 보면 CommonBean을 DI받고 있으므로, CommonBean도 빈으로 띄워줘야하는 것이다. 나중에 다루어볼 것이지만 위와같이 빈으로 띄울 클래스를 명시적으로 등록해도되지만, 굳이 테스트에 사용하지 않으면 Mock 빈으로 등록해주어도 된다. 두번째 인자는 initializers이다. 만약 이 인자가 없다면, CommonBean에서 @Value로 DI 받고있는 프로퍼티값을 주입받지 못한다. 즉, application.properties의 내용을 읽지 못하는 것이다. 내부적으로 @Value를 받기위한 빈이 뜨지 못해서 그런 것 같다. 이와 같은 문제를 해결하기 위해서 initializers = ConfigFileApplicationContextInitializer.class를 등록해주므로써 프로퍼티값을 읽어 올 수 있게 하였다. 

 

두번째 방법은 아래와 같다. @SpringBootTest를 이용하여 더 간단한 설정으로 SpringBoot Test를 위한 설정을 할 수 있게된다. 또한 테스트에만 사용할 프로퍼티를 정의할 수도 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {CommonBean.class,CommonService.class}
,properties = {"test.property=dev-property"})
public class TestPropertyTest {
 
    @Autowired
    private CommonService commonService;
 
    @Test
    public void print(){
        commonService.print();
    }
 
}
cs

 

두번째는 Mock빈을 이용하는 방법이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
        CommonService.class
},initializers = ConfigFileApplicationContextInitializer.class)
public class TestPropertyTest {
 
    @MockBean
    private CommonBean commonBean;
    @Autowired
    private CommonService commonService;
 
    @Test
    public void mockBeanTest(){
        given(commonBean.print()).willReturn("Test Result");
        commonService.print();
    }
 
}
cs

 

CommonService의 비즈니스 로직을 테스트하고 싶은데, 굳이 CommonBean은 띄울 필요가 없을때, 위와 같이 MockBean을 등록해서 임의로 Mock 빈이 리턴할 값을 명시할 수 있다. 위 소스는 CommonService만 테스트를 위한 빈으로 띄우는데, 내부적으로 관련있는 CommonBean은 띄우지 않고 Mock 빈으로 띄운다. 그리고 CommonBean이 리턴하는 값을 임의로 "Test Result"라는 값으로 지정해주었다. 이렇게 테스트할 빈만 집중하여 테스트가 가능해지며 사이드 코드의 양을 줄일 수 있게 된다.

 

다음은 간단하게 Controller를 테스트할 수 있는 코드이다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RunWith(SpringRunner.class)
@WebMvcTest(controllers = CommonTestController.class)
@ContextConfiguration(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class}
        ,initializers = ConfigFileApplicationContextInitializer.class)
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

굳이 스프링을 실행시키지 않고도 컨트롤러를 테스트할 수 있다. @WebMvcTest 어노테이션을 이용하여 스프링 컨트롤러 테스트가 가능한 설정을 띄워준다. 즉, MVC관련 빈만 띄워 더욱 가볍게 스프링 컨트롤러 테스트가 가능하다. 위와 같은 어노테이션도 가능하며 아래와 같은 어노테이션 설정도 사용가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class
})
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

@AutoConfigureMockMvc 어노테이션을 이용하여 MVC 테스트를 위한 설정을 AutoConfiguration하게 할수 있다. 물론, MVC테스트를 위하여 내부적으로 내/외부 연동코드가 있다면 내/외부연동을 하지 않기 위해 Mock 빈등을 이용하여 테스트 가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class
})
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private CommonBean commonBean;
    @MockBean
    private CommonService commonService;
 
    @Test
    public void resultTest() throws Exception {
        given(commonBean.print()).willReturn("test");
        given(commonService.print()).willReturn("test");
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

마지막으로 테스트만을 위한 application.properties를 사용는 방법이다. 우선, application-test.properties를 classpath 밑의 resources 폴더 안에 넣어준다.(기존 application.properties 경로와 동일) 그리고 아래와 같이 @ActiveProfile 어노테이션을 이용하여 프로파일을 이용하여 읽어올 프로퍼티파일을 명시한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class
})
@ActiveProfiles("test")
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

위 코드는 기존 application.properties가 아닌, application-test.properties 파일을 읽어 컨텍스트를 구성할 것이다.

 

아주 간단하게만 springboot test를 다루어봤는데, 추후 포스팅에서 지금까지 다룬 내용과 더 자세한 테스팅관련 기술을 다루어볼 것이다.

posted by 여성게
:
Web/Spring 2019. 9. 11. 23:54

 

이번에 다루어 볼 포스팅은 Spring Cache이다. 스프링 3.1부터 빈의 메서드에 캐시 서비스를 적용할 수 있는 기능을 제공한다. 캐시 서비스는 트랜잭션(@Transaction)과 마찬가지로 AOP를 이용해 메서드 실행 과정에 우리가 모르도록 투명하게 적용된다. 또한 스프링은 장점으로 캐시 서비스 구현 기술에 종속되지 않도록 추상화된 서비스를 제공해주므로 환경이 바뀌거나 구현 기술이 바뀌어도 우리가 적용한 코드의 변경 없이 구현 기술을 바꿔줄 수 있다.

 

그렇다면 여기서 짚고 넘어갈 것이, "애플리케이션 빈의 메서드에 캐시를 적용하는 이유 혹은 목적"은 무엇일까?

 

캐시는 기본적으로 성능의 향상을 위해 사용한다. 여기서 성능 향상은 애플리케이션이 갑자기 막 빨라지고 하는 성능 향상이 아니다. 어떤 요청을 처리하는데 연산이 아주 복잡하거나 데이터베이스와 같은 백엔드 시스템에 많은 부하를 주거나, 외부 API 호출처럼 시간이 오래 걸린다면 캐시의 사용을 고려해볼 만하다. 하지만 결코 앞과 같은 상황에 직면했다고 캐시 기능을 사용하면 안된다.

 

캐시(Cache)는 임시 저장소라는 뜻이다. 복잡한 계산이나 데이터베이스 작업, 외부 API 요청의 처리 결과 등을 임시 저장소인 캐시에 저장해뒀다가 동일한 요청이 들어오면 복잡한 작업을 수행해서 결과를 만드는 대신 이미 캐시에 보관된 결과를 바로 돌려주는 방식이다. 만약 이런 상황이 있다고 생각하자.

 

사용자가 많은 웹사이트 서비스가 있다. 메인 페이지에 접속하면 항상 공지사항이 출력돼야 한다. 공지사항은 어떠한 추가적인 내용이 추가되거나 혹은 내용이 수정되기 전까지는 모든 사용자에게 동일한 내용을 보여준다. 그렇다면 사용자가 만명이 있고 사용자가 들어올때 마다 공지사항을 데이터베이스에 접근하여 보여준다면 총 만번의 데이터베이스 액세스가 이루어질 것이다. 사실 데이터베이스에 자주 접근하는 연산은 비용이 비싸다. 그만큼 데이터베이스에 부하가 간다는 뜻이다. 만약 이럴때 캐시를 적용한다면 첫 사용자가 접근할때만 데이터베이스에서 공지사항 내용을 가져오고 나머지 9999명은 캐시에 저장된 내용을 그대로 돌려준다면 만명의 사용자가 한번의 데이터베이스 액세스로 공지사항을 볼 수 있게 되는 것이다.

 

이렇게 캐시는 여러가지 장점을 가졌지만, 그렇다면 모든 상황에서 캐시를 사용하면 안된다. 

 

"값비싼 비용이 들어가는 요청, 데이터베이스 접근, 외부 요청 등에 캐시를 사용하지만 여기서 전제가 있다. 캐시는 반복적으로 동일한 결과를 주는 작업에만 이용해야 한다. 매번 다른 결과를 돌려줘야 하는 작업에는 캐시를 적용해봐야 오히려 성능이 떨어진다."

 

그렇다면 캐시를 사용하면 아주 주의깊게 체크해야 할 것이 있다.  바로, 캐시에 저장해둔 컨텐츠의 내용이 바뀌는 상황 혹은 시점을 잘 파악하는 것이다. 공지사항이 추가되거나 수정되었다면 캐시는 아직까지 이전 내용의 공지사항을 가지고 있을 것이므로, 이러한 상황에서는 캐시에 저장된 내용을 삭제하고 첫 사용자가 접근하여 캐시의 내용을 최신 내용으로 업데이트 해야한다.

 

이제 직접 코드를 보며 스프링에서 사용할 수 있는 캐시 서비스를 알아보자. 우선 캐시 기능을 사용하기 위해서는 @EnableCaching 애너테이션을 달아주어야 한다.

 

애너테이션을 이용한 캐시 기능 사용(@Cacheable)

스프링의 캐시 서비스 추상화는 AOP를 이용한다. 캐시 기능을 담은 어드바이스는 스프링이 제공한다. 이를 적용할 대상 빈과 메서드를 선정하고 속성을 부여하는 작업은 <aop:config>,<aop:advisor> 같은 기본 AOP 설정 방법을 이용할 수 있다. 하지만 이보다는 @Transaction 애노테이션을 이용하는 것처럼 캐시 서비스도 애너테이션을 이용할 수 있다.

 

캐시 서비스는 보통 메서드 단위로 지정한다. 클래스나 인터페이스 레벨에 캐시를 지정할 수도 있지만 캐시의 특성상 여러 메서드에 일괄적인 설정을 하는 경우는 드물다. 캐시에 저장할 내용과 캐시 설정 정보로 메서드의 리턴 값과 메서드 파라미터를 사용하기에 메서드 레벨로 적용하는 것이 수월하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Product {
 
    private int productId;
    private ProductType type;
    private String productName;
    private boolean isBest;
 
}
 
@Slf4j
@Service
public class ProductService {
 
    @Cacheable("product")
    public List<Product> bestProductsByType(ProductType productType){
 
        log.info("ProductService.bestProductsByType");
 
        List<Product> bestProducts = new ArrayList<>();
        if(productType.equals(ProductType.TOP)){
            bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        }else if(productType.equals(ProductType.PANTS)){
            bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        }else{
            bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        }
 
        return bestProducts;
    }
 
    @Cacheable(value = "product", key = "#productType")
    public List<Product> bestProduct(User user, LocalDateTime time, ProductType productType){
 
        log.info("ProductService.bestProduct");
 
        List<Product> bestProducts = new ArrayList<>();
        if(productType.equals(ProductType.TOP)){
            bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        }else if(productType.equals(ProductType.PANTS)){
            bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        }else{
            bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        }
 
        return bestProducts;
    }
 
    @Cacheable(value = "product", condition = "#user.firstName == 'sora'")
    public List<Product> bestProductForGoldUser(User user){
 
        log.info("ProductService.bestProductForGoldUser");
 
        List<Product> bestProducts = new ArrayList<>();
        bestProducts.add(new Product(1,ProductType.OUTER,"CHANNEL OUTER",true));
        return bestProducts;
    }
 
    @Cacheable("product")
    public List<Product> allBestProduct(){
 
        log.info("ProductService.allBestProduct");
 
        List<Product> bestProducts = new ArrayList<>();
        bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        return bestProducts;
    }
 
}
cs

 

위 코드는 @Cacheable 애너테이션을 이용하여 캐시 기능을 사용하는 예제 코드이다. 우선 첫번째로 ProductService 클래스의 첫번째 메서드를 살펴보자. 해당 메서드가 처음 호출되는 순간에 캐시에 "product"라는 이름의 공간이 생긴다. 그리고 파리미터로 productType(ProductType.TOP이라는 enum이 들어왔다 생각)이 들어오는데, 해당 값을 이용하여 키를 만든다. 이 말은 "product"라는 캐시 공간에 특정 키값으로 데이터를 캐시한다는 뜻이다. 그리고 첫번째 호출 이후에 동일한 메서드에 동일한 파라미터로 요청이 들어오면 "product"라는 캐시 공간에 ProductType.TOP을 이용한 키값을 가진 데이터가 있는 지 확인하고 만약 데이터가 존재하면 해당 메서드를 호출하지 않고 캐시에 있는 데이터를 돌려준다. 만약에 해당 키값으로 데이터가 없다면 메서드 로직을 수행한 후에 반환 값을 캐시에 적재한다.

 

그렇다면 두번째 메서드처럼 파라미터가 여러개인 메서드라면 어떻게 할까? 답은 바로 코드에 있다. 바로 key라는 설정 값에 SpEL식을 이용해 키값으로 사용할 파라미터 값을 명시할 수 있다. 그렇다면 이제 이 메서드는 명시한 파라미터로만 키값을 생성하게 된다. 만약 파라미터가 특정 객체로 들어온다면 key = "Product.productType" 과 같이 설정할 수 있다. 만약 키값으로 사용할 어떠한 설정 내용도 명시하지 않는 다면 어떻게 처리할까? 바로 여러개의 파라미터의 hashCode()를 조합하여 키값을 생성한다. 그렇지만 해시 코드 값의 조합이 키로서 의미가 있다면 문제가 없지만 대부분은 그렇지 않은 경우가 많기에 key 설정값을 이용하여 키로 사용할 파라미터를 명시해주는 것이 좋다.

 

세번째는 특정 조건에서만 캐싱을 하고 나머지 상황에서는 캐싱을 하고 싶지 않은 경우가 있다. 이럴 경우에는 세번째 메서드처럼 condition이라는 설정 값을 이용한다. 이 설정은 파라미터가 특정 값으로 들어올때만 캐시하고 싶을 때, 사용가능하다. 예제는 위와 같이 사용하면 된다.

 

마지막으로 메서드에 매개변수가 없다면 어떻게 될까? 이럴때는 디폴트 키 값으로 세팅이 되기 때문에 메서드가 처음 호출되면 무조건 데이터가 캐싱이 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {
 
    @Autowired
    ProductService service;
    List<Product> bestProducts;
    User basicUser;
    User goldUser;
 
    @Before
    public void init(){
        bestProducts = new ArrayList<>();
        basicUser = new User(1,"yeoseong","yoon",28, Level.BASIC);
        goldUser = new User(1,"sora","hwang",30, Level.GOLD);
    }
 
    @Test
    public void noArgCacheTest(){
        bestProducts.addAll(service.allBestProduct());
 
        MatcherAssert.assertThat(bestProducts.size(), CoreMatchers.equalTo(3));
 
        service.allBestProduct();
    }
 
    @Test
    public void oneArgCacheTest(){
 
        bestProducts.addAll(service.bestProductsByType(ProductType.TOP));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI SHIRTS"));
 
        service.bestProductsByType(ProductType.TOP);
 
        clearList(bestProducts);
 
        bestProducts.addAll(service.bestProductsByType(ProductType.PANTS));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI PANTS"));
 
        service.bestProductsByType(ProductType.PANTS);
 
        clearList(bestProducts);
 
        bestProducts.addAll(service.bestProductsByType(ProductType.OUTER));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI OUTER"));
 
        service.bestProductsByType(ProductType.OUTER);
 
    }
 
    @Test
    public void manyArgsCacheTest(){
        bestProducts.addAll(service.bestProduct(basicUser, LocalDateTime.now(),ProductType.TOP));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI SHIRTS"));
 
        service.bestProduct(goldUser, LocalDateTime.now(),ProductType.TOP);
    }
 
    @Test
    public void conditionCacheTest(){
        bestProducts.addAll(service.bestProductForGoldUser(basicUser));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("CHANNEL OUTER"));
 
        service.bestProductForGoldUser(goldUser);
        service.bestProductForGoldUser(goldUser);
    }
 
    private void clearList(List<Product> products){
        products.clear();
    }
 
}
cs

 

위의 테스트 코드를 실행시키면 캐시가 적용된 이후에는 더 이상 ProductService 메서드의 로그가 출력되지 않을 것이다. 그 말은 캐시가 적용되어있다면 메서드 자체를 호출하지 않는 것을 알 수 있다.

 

캐시 데이터 삭제(@CacheEvict)

캐시는 적절한 시점에 제거돼야 한다. 캐시는 메서드를 실행했을 때와 동일한 겨로가가 보장되는 동안에만 사용돼야 하고 메서드 실행 결과가 캐시 값과 달리지는 순간 제거돼야 한다. 캐시를 적절히 제거해주지 않으면 사용자에게 잘못된 결과를 리턴하게 된다.

 

캐시를 제거하는 방법은 두 가지가 있다. 하나는 일정한 주기로 캐시를 제거하는 것과 하나는 캐시에 저장한 값이 변경되는 상황이 생겼을 때 캐시를 삭제하는 것이다.

 

캐시의 제거에도 AOP를 이용한다. 간단하게 메서드에 @CacheEvict 애너테이션을 붙여주면 된다. 캐시 삭제도 캐시 적재와 동일하게 키값을 기준해 적용한다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @CacheEvict("product")
    public void clearProductCache(){
        log.info("ProductService.clearProductCache");
    }
 
    @CacheEvict(value = "product", key = "#productType")
    public void clearProductCache(ProductType productType){
        log.info("ProductService.clearProductCache");
    }
 
    @CacheEvict(value = "product", condition = "#user.firstName == 'sora'")
    public void clearProductCache(User user){
        log.info("ProductService.clearProductCache");
    }
    //product의 모든 키값에 해당하는 캐시 데이터 삭제
    @CacheEvict(value = "product", allEntries = true)
    public void clearProductCacheAll(){
        
    }
cs

 

위의 코드를 이용하여 테스트 코드를 만들어 로그가 찍히는 횟수를 확인하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {
 
    @Autowired
    ProductService service;
    List<Product> bestProducts;
    User basicUser;
    User goldUser;
 
    @Before
    public void init(){
        bestProducts = new ArrayList<>();
        basicUser = new User(1,"yeoseong","yoon",28, Level.BASIC);
        goldUser = new User(1,"sora","hwang",30, Level.GOLD);
    }
 
    @Test
    public void clearCacheTest(){
        service.allBestProduct();
        service.clearProductCache();
        service.allBestProduct();
        System.out.println("===================================");
        service.bestProductsByType(ProductType.TOP);
        service.bestProductsByType(ProductType.PANTS);
        service.clearProductCache(ProductType.TOP);
        service.bestProductsByType(ProductType.TOP);
        service.bestProductsByType(ProductType.PANTS);
        System.out.println("===================================");
        service.bestProductsByType(ProductType.TOP);
        service.clearProductCache();
        service.bestProductsByType(ProductType.TOP);
        System.out.println("===================================");
        service.bestProductForGoldUser(goldUser);
        service.clearProductCache(basicUser);
        service.bestProductForGoldUser(goldUser);
        service.clearProductCache(goldUser);
        service.bestProductForGoldUser(goldUser);
    }
 
}
cs

 

이외에 @CachePut이라는 애너테이션이 존재한다. 이 애너테이션은 캐시에 값을 저장하는 용도로 사용한다. @Cacheable과 비슷하게 메서드 실행 결과를 캐시에 저장하지만 @CachePut은 캐시 데이터를 사용하는 것이 아니고 해당 애너테이션이 붙은 메서드를 호출 할때마다 결과 데이터를 캐시에 적재한다. 보통 한 번에 캐시에 많은 정보를 저장해두는 작업이나, 다른 사용자가 참고할 정보를 생성하는 용도로만 사용되는 메서드에 이용할 수 있다.

 

Cache Manager

스프링의 캐시 서비스는 AOP를 이용해 애플리케이션 코드를 수정하지 않고도 캐시 부가기능을 메서드에 적용할 수 있게 해준다. 동시에 캐시 기술의 종류와 상관없이 후상화된 스프링 캐시 API를 이용할 수 있게 해주는 서비스 추상화를 제공한다. 캐시 기능을 적용하는 AOP 어드바이스는 스프링이 제공해주는 것을 애너테이션을 통해 적용하면 되므로, 우리가 신경 쓸 부분은 적용할 캐시 기술을 선정하고 캐시 관련 설정을 넣어주는 것이다. 

 

캐시 추상화에서는 적용할 캐시 기술을 지원하는 캐시 매너저를 빈으로 등록해줘야 한다. 여기서는 자세한 설정법은 다루지 않고 캐시 매니저에는 무엇이 있는 지만 다루어 볼 것이다.

 

캐시 매니저 설명
ConcurrentMapCacheManager ConcurrentMapCache 클래스를 캐시로 사용하는 캐시 매니저다. ConcurrentHashMap을 이용해 캐시 기능을 구현한 간단한 캐시다. 캐시 정보를 Map 타입으로 메모리에 저장해두기 때문에 빠르고 별다른 설정이 필요없다는 장점이 있지만, 실제 서비스에서 사용하기에는 기능이 빈약하다. 캐시별 용량 제한이나 다양한 저장 방식 지원, 다중 서버 분산과 같이 고급 캐시 프레임워크가 제공하는 기능을 지원하지 않는다. 따라서 저장될 캐시 양이 많지 않고 간단한 기능에 적용할때 혹은 테스트 용도로만 사용해야 한다.
SimpleCacheManager 기본적으로 제공하는 캐시가 없다. 따라서 프로퍼티를 이용해서 사용할 캐시를 직접 등록해줘야 한다. 스프링 Cache 인터페이스를 구현해서 캐시 클래스를 직접 만드는 경우 테스트에서 사용하기 적당하다.
EhCacheCacheManager 자바에서 가장 인기있는 캐시 프레임워크의 하나인 EhCache를 지원하는 캐시 매니저다. 본격적으로 캐시 기능을 적용하려면 사용을 고려할만하다.
CompositeCacheManager 하나 이상의 캐시 매니저를 사용하도록 지원해주는 혼합 캐시 매니저다. 여러 개의 캐시 기술이나 캐시 서비스를 동시에 사용해야 할 때 이용할 수 있다. CompositeCacheManager의 cacheManagers 프로퍼티에 적용할 캐시 매니저 빈을 모두 등록해주면 된다.
NoOpCacheManager 아무런 기능을 갖지 않은 캐시 매니저다. 보통 캐시가 지원되지 않는 환경에서 동작할 때 기존 캐시 관련 설정을 제거하지 않아도 에러가 나지 않게 해주는 기능이다.

 

여기까지 스프링 캐시 추상화에 대해 다루어보았다. 아마 다음 포스팅은 직접 상용에서 사용할 수 있을 만한 캐시 매니저 구현를 이용해보는 포스팅이 될 것같다.

posted by 여성게
:
Web/Spring 2019. 9. 10. 17:06

 

우리는 컨트롤러에서 사용자가 넘겨준 파라미터를 전달 받을 때, @ModelAttribute를 붙여서 혹은 생략한 특정 객체로 파라미터를 받게된다. 우리는 이때 2가지 상황을 고려할 수 있다. 

 

  1. 파라미터 바인딩에 실패(데이터 타입 등이 맞지 않는 경우)
  2. 파라미터 바인딩은 문제없이 됬으나, 들어온 파라미터가 비즈니스 로직에 맞지 않는 혹은 유효하지 않은 파라미터 일경우

이러한 파라미터를 검증하는 방법은 무엇이 있을까? 크게 2가지 방법이 존재한다.

 

  1. 사용자 정의 Validator 구현
  2. JSR-303 애너테이션

간단히 두가지를 다루어본다.

 

사용자 정의 Validator

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Slf4j
@Component
public class UserParamValidator implements Validator {
 
    @Override
    public boolean supports(Class<?> aClass) {
//isAssignableFrom은 하위 타입도 적용되게 하는 메서드이다.
        return (User.class.isAssignableFrom(aClass));
    }
 
    @Override
    public void validate(Object o, Errors errors) {
        log.info("User Param Validator");
        User user = (User)o;
 
        if(StringUtils.isEmpty(user.getFirstName())){
            errors.rejectValue("firstName","param.required");
        }
 
    }
}
 
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @Autowired
    private UserParamValidator validator;
 
    @InitBinder
    public void initBinder(WebDataBinder webDataBinder){
 
        webDataBinder.setValidator(validator);
    }
 
    @GetMapping("/find")
    public Object findById(@ModelAttribute("findUser") @Valid User user, BindingResult bindingResult){
        List<String> resultMessages = new ArrayList<>();
        if(bindingResult.hasErrors()){
            List<FieldError> fieldsErrors = bindingResult.getFieldErrors();
            fieldsErrors.stream().forEach(fieldError->{
                log.info("bind error objectName = {}, fieldName = {} ,error code = {}",fieldError.getObjectName(),fieldError.getField(),fieldError.getCode());
            });
            return resultMessages;
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

위와 같이 사용자 정의 Validator 클래스를 정의하고 특정 컨트롤러에서 DI 받은 후, @InitBinder 메서드에 해당 Validator를 등록해준다. 그리고 검증이 실패한 값이 들어온다면 컨트롤러 BindingResult에 검증 실패한 정보가 저장된다. 이러한 검증 실패정보를 로그로 찍고 있는 예제이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @GetMapping("/find")
    public Object findById(@ModelAttribute("findUser") @Valid User user, BindingResult bindingResult, Locale locale){
        List<String> resultMessages = new ArrayList<>();
        if(bindingResult.hasErrors()){
            List<FieldError> fieldsErrors = bindingResult.getFieldErrors();
            fieldsErrors.stream().forEach(fieldError->{
                log.info("bind error objectName = {}, fieldName = {} ,error code = {}",fieldError.getObjectName(),fieldError.getField(),fieldError.getCode());
                resultMessages.add(messageSource.getMessage(fieldError.getField()+"."+fieldError.getCode(),null, locale));
            });
            return resultMessages;
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    @NotNull
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

두번째 방법은 직접 엔티티 클래스에 제약조건 애너테이션을 달아주는 방법이다. 그러면 별도의 Validator DI 없이도 BindingResult에 엔티티 클래스에 있는 애너테이션을 이용한 검증을 한 후 검증에 실패하면 정보를 넣어준다.

 

만약 검증이 아니고 데이터 타입이 잘못넘어와서 바인딩 자체가 실패하면 어떻게 될까?

 

그렇다면 아래와 같은 errorCode가 발생한다.

 

FieldName.typeMismatch

 

좋다 여기까지, 바인딩 오류 혹은 검증이 실패한 결과가 BindResult에 담겼다. 그렇다면 그 이후에는 어떻게 처리하면 좋을까? 보통 이럴때는 MessageSource를 이용해 errorCode에 해당되는 미리 준비된 문자열을 사용자에게 보여준다. 예를 들면 아래와 같은 상황이다.

 

회원가입을 하는 과정에서 사용자가 userId값을 넘기지 않으면 "ID를 입력해주세요."라는 문구가 뿌려지는데, 이럴 경우 미리 준비된 errorCode에 해당되는 메시지를 준비하여 userId 파라미터가 빈값일때 해당 메시지를 전달해줄 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
 
    @Autowired
    private LocaleChangeInterceptor localeChangeInterceptor;
 
    @Value("${spring.messages.basename}")
    String baseName;
    @Value("${spring.messages.encoding}")
    String defaultEncoding;
    @Value("${cookie.locale.param}")
    String cookieName;
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
 
        registry.addInterceptor(localeChangeInterceptor);
 
    }
 
    /*
     * MessageSource Bean register
     */
    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource reloadableResourceBundleMessageSource = new ReloadableResourceBundleMessageSource();
        reloadableResourceBundleMessageSource.setBasename(baseName);
        reloadableResourceBundleMessageSource.setDefaultEncoding(defaultEncoding);
 
        return reloadableResourceBundleMessageSource;
    }
 
    /*
     * Locale Resolver Bean register
     */
    @Bean
    public LocaleResolver localeResolver() {
        CookieLocaleResolver cookieLocaleResolver = new CookieLocaleResolver();
        cookieLocaleResolver.setCookieName(cookieName);
        cookieLocaleResolver.setDefaultLocale(Locale.KOREA);
 
        return cookieLocaleResolver;
    }
 
    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName(cookieName);
 
        return  localeChangeInterceptor;
    }
}
cs

 

위와 같이 필요한 빈을 등록해준다.

 

 

message.properties 파일들을 생성해준다.

 

1
2
firstName.param.required=이름 입력은 필수입니다.
id.typeMismatch=유효하지 않은 유저 아이디 값입니다.
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @Autowired
    private UserParamValidator validator;
    @Autowired
    private MessageSource messageSource;
 
    @InitBinder
    public void initBinder(WebDataBinder webDataBinder){
        webDataBinder.setValidator(validator);
    }
 
    @GetMapping("/find")
    public Object findById(@ModelAttribute("findUser") @Valid User user, BindingResult bindingResult, Locale locale){
        List<String> resultMessages = new ArrayList<>();
        if(bindingResult.hasErrors()){
            List<FieldError> fieldsErrors = bindingResult.getFieldErrors();
            fieldsErrors.stream().forEach(fieldError->{
                log.info("bind error objectName = {}, fieldName = {} ,error code = {}",fieldError.getObjectName(),fieldError.getField(),fieldError.getCode());
                resultMessages.add(messageSource.getMessage(fieldError.getField()+"."+fieldError.getCode(),null, locale));
            });
            return resultMessages;
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
}
cs

 

바인딩 errorCode에 해당 되는 Message가 생성되는 것을 볼 수 있다.

posted by 여성게
:
Web/Spring 2019. 9. 10. 15:22

 

이번 포스팅은 @RequestParam과 @ModelAttribute의 차이점에 대해 다루어볼 것이다. 이번에 다루어볼 내용은 특정 유저관련 컨트롤러 코드를 작성하여 살펴볼 것이다.

 

작업환경은 MacOS+intelliJ로 구성하였다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 
    @GetMapping("/insert")
    public Object insertUser(@ModelAttribute("findUser") User user, BindingResult bindingResult){
 
        if(bindingResult.hasErrors()){
            log.info("binding error");
            return "invalidParam";
        }
 
        if(user.getId() == 1){
            user.setLastName("yoon");
            user.setAge(28);
            user.setLevel(Level.BASIC);
 
            return user;
        }
 
        return null;
    }
 
    @GetMapping("/insert2")
    public Object insertUser(@RequestParam int id, @RequestParam String firstName, @RequestParam String lastName){
        return "RequestParamBinding";
    }
 
}
 
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
class User{
 
    private int id;
    private String firstName;
    private String lastName;
    private int age;
    private Level level;
 
}
cs

 

위와 같은 코드가 있다. 세부적인 로직은 제외하고 파라미터를 바인딩하는 부분을 집중해보자. 우선 @RequestParam과 @ModelAttribute의 파라미터 바인딩에는 큰 차이가 있다. 바로 객체로 받는냐, 혹은 쿼리스트링을 하나하나 바인딩 받느냐의 차이이다. 이것은 코드의 가독성에 아주 큰 차이가 있다. 두번째 차이점은 @ModelAttribute는 사용자가 직접 View에 뿌려질 Model객체를 직접 set하지 않아도 된다는 점이다. @ModelAttribute는 스프링이 직접 ModelMap에 객체를 넣어주기 때문이다. 이러한 바인딩 차이점 이외에 한가지 아주 큰 차이점이 있다.

 

매핑을 보면 @ModelAttribute에는 BindingResult라는 인자가 들어가있는 것을 볼 수 있다. 하지만 @RequestParam은 없다. 과연 무슨 말일까?

 

@ModelAttribute는 사실 파라미터를 검증할 수 있는 기능을 제공한다. 이 말은 파라미터 바인딩에 예외가 발생하여도 혹은 파라미터 바인딩은 문제없이 됬으나 로직처리에 적합하지 않은 파라미터가 들어오는 등 이러한 파라미터를 검증할 수 있는 기능이 있다. 만약 파라미터 바인딩에 예외가 발생하였을 때, @ModelAttribute와 @RequestParam의 차이점을 보자. 전자는 바인딩 예외를 BindingResult에 담아준다. 즉, 4xx 예외가 발생하지 않는 것이다. 하지만 @RequestParam 같은 경우 바인딩 예외가 발생하는 순간 TypeMisMatch 예외를 발생시켜 사용자에게 4xx status code를 전달한다. 

 

그러면 개발자는 파라미터를 검증하는 로직을 컨트롤러 코드와 따로 분리하여 바인딩된 파라미터의 값의 유효성을 검사할 수 있게 해준다. 만약 @RequestParam이라면 직접 비즈니스 로직내에서 파라미터를 검증하는 코드가 들어가야 할 것이다.

 

바인딩 예외 처리의 차이점을 간단한 테스트 코드로 테스트 해보았다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@SpringBootTest
@RunWith(SpringRunner.class)
public class UserControllerTest {
 
    private static final String FINDBYID_MODELATTRIBUTE_REQUEST_URI = "/user/find";
    private static final String FINDBYID_REQUESTPARAM_REQUEST_URI = "/user/find2";
    private User validParamForFindByUserId ;
 
    private MockMvc mockMvc;
 
    @Before
    public void init(){
        this.mockMvc = MockMvcBuilders.standaloneSetup(new UserController()).build();
    }
 
    @Before
    public void initParam(){
        validParamForFindByUserId = new User(1,"yeoseong","yoon",28,Level.valueOf(1));
    }
 
    @Test
    public void paramVaildationWhenInvalidInputParamWithModelAttribute() throws Exception {
        MvcResult createResult = mockMvc.perform(get(FINDBYID_MODELATTRIBUTE_REQUEST_URI+"?id=abc"))
                                        .andDo(print())
                                        .andExpect(content().string("invalidParam"))
                                        .andReturn()
                                        ;
    }
 
    @Test
    public void paramValidationWhenInvalidInputParamWithRequestParam() throws Exception {
        MvcResult createResult = mockMvc.perform(get(FINDBYID_REQUESTPARAM_REQUEST_URI+"?id=abc"))
                                        .andDo(print())
                                        .andExpect(status().is4xxClientError())
                                        .andReturn()
                                        ;
    }
 
}
cs

 

위 테스트를 돌려보면 우리가 기대하는 바인딩 예외처리 테스트가 무사히 통과할 것이다.

posted by 여성게
:
Web/TDD 2019. 9. 10. 12:59

 

작성하려는 코드가 있다면 항상 먼저 어떻게 그 코드를 테스트할지 고민해야한다. 코드를 작성한 후에 어떻게 테스트할지 고민하기보다 작성할 코드를 묘사하는 테스트를 설계해야 한다. 이것이 테스트 주도 개발(TDD, Test Driven Development)에 기반을 둔 단위 테스트 전략의 핵심이다.

 

TDD에서 단위 테스트를 시스템의 모양을 잡고 통제하는 도구로 활용해야 한다. 단위 테스트는 종종 잘 선별한 후 한쪽에 치워 놓고 나중에 반영하려는 코드가 될 수 있는데, 단위 테스트는 소프트웨어를 어떻게 만들어야 할지에 관한 잘 훈련된 사이클의 핵심적인 부분이다. 따라서 TDD를 채택하면 소프트웨어 설계는 달라지고, 아마 훨씬 더 좋은 설계의 코드가 될 것이다.

 

TDD의 주된 이익

단위 테스트를 사후에 작성하여 얻을 수 있는 가장 분명하고 명확한 이익은 "코드가 예상한 대로 동작한다는 자신감을 얻는 것" 이다. TDD에서도 역시 동일한 이익과 그 이상을 얻을 수 있다. 

 

코드를 깨끗하게 유지하도록 치열하게 싸우지 않으면 시스템은 점점 퇴화한다. 코드를 재빠르게 추가할 수는 있지만 처음에는 좋은 코드라기보다는 그다지 위대하지 않은 코드일 가능성이 높다. 보통은 개발 초기부터 나쁜 코드를 여러 가지 이유로 정리하지 않고는 한다.

 

TDD에서는 코드가 변경될 것이라는 두려움을 지울 수 있다. 정말로 리팩토링은 위험 부담이 있는 일이기도 하고 우리는 위험해보이지 않는 코드를 변경할 때도 실수를 하곤 한다. 하지만 TDD를 잘 따른다면 구현하는 실질적인 모든 사례에 대해 단위 테스트를 작성하게 된다. 이러한 단위 테스트는 코드를 지속적으로 발전시킬 수 있는 자유를 준다.

 

TDD는 세 부분의 사이클로 구성된다.

  • 실패하는 테스트 코드 작성하기
  • 테스트 통과하기
  • 이전 두 단계에서 추가되거나 변경된 코드 개선하기

첫 번째 단계는 시스템에 추가하고자 하는 동작을 정의하는 테스트 코드를 작성하는 것이다.

 

우리는 이미 이전 포스팅에서 이미 다루어봤던 Profile이라는 클래스를 새로 만들 것이다. 가장 단순한 사례로, Profile 클래스 자체를 만들기 전에 먼저 테스트 코드를 아래와 같이 작성해보자.

 

1
2
3
4
5
6
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      new Profile();
   }
}
cs

 

Profile이라는 클래스가 존재하지 않기 때문에 컴파일 에러가 발생할 것이다. 또한 IDEA는 Profile이라는 클래스가 없으므로 클래스를 생성해달라 할 것이다. Profile 클래스를 Quick Fix 기능으로 생성하면 컴파일에러는 해결될 것이다. 사실 이러한 작은 테스트는 컴파일되는 것만으로 충분한 테스트가 되기 때문에 굳이 테스트를 실행시킬 필요는 없다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1"Relocation package?");
      Criterion criterion = 
         new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
}
 
public class Profile {
   public boolean matches(Criterion criterion) {
      return true;
   }
}
cs

 

컴파일 에러를 해결하며 위와 같이 작은 테스트 단위로 하나씩 코드를 증가시켜가며 작성해준다. 여기까지 메서드 내부를 세부적으로 구현하지 않고 단순히 true를 리턴하는 메서드를 작성하므로써 실패하는 테스트 코드를 작성하였다. 그리고 테스트 성공을 위해 assertTrue(result)로 변경하여 테스트를 성공시킨다.

 

여기까지 우리는 Profile 클래스의 한 작은 부분을 만들었고 그것이 동작함을 알게되었다. 여기까지 소스를 작성하였다면 Git과 같은 VCS에 커밋할 차례이다. TDD를 하면서 작은 코드를 커밋하는 것은 필요할 때 백업하거나 방향을 반대로 돌리기 수월해진다. 만약 큼지막한 단위로 커밋한다면 롤백은 그만큼 힘든 작업이 된다.

 

다음 작성할 테스트 코드는 Profile이 갖고 있는 Answer 객체와 Criterion이 가지고 있는 Answer 객체를 매칭시키는 테스트이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1"Relocation package?");
      Criterion criterion = 
         new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1"Relocation package?");
      Answer answer = new Answer(question, Bool.TRUE);
      profile.add(answer);
      Criterion criterion = new Criterion(answer, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
}
cs

 

위와 같이 profile.matches()를 테스트 코드로 추가한다. 하지만 matches()는 세부적인 구현이 이루어지지 않은 상태이므로 아래와 같이 Profile에 matches()를 수정한다.

 

1
2
3
4
5
6
7
8
9
10
11
public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null;
   }
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}
cs

 

이제 Profile이 Answer 객체만 가지고 있다면 true를 리턴할 것이다.

 

여기까지 테스트 코드를 작성하였다면 이제는 테스트 코드를 조금은 정리할 필요가 있습니다. 두 개의 테스트만 보더라도 중복되는 코드 라인이 보이기 때문에 @Before 메서드로 중복된 초기화 코드를 분리해줍니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionAndAnswer() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
   }
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
}
cs

 

테스트 코드의 리팩토링 과정에서 프로덕 코드의 변경은 없었고 물론 테스트도 통과할 것이다. 하지만 분명한 필드이름과 중복된 코드의 제거로 더욱 깔끔한 테스트코드가 되었다.

 

여기서 자칫하면 "어? 여러개의 @Test 메서드가 동일한 필드들을 공유하는데, 테스트 결과가 잘못 나오는 거 아니야?"라고 질문을 던질 수 있다. 하지만 이전 포스팅에서 얘기 햇듯이 @Test 마다 새로운 ProfileTest 인스턴스를 생성하기 때문에 매 테스트마다 인스턴스 변수는 독립적으로 사용한다.

 

다음 테스트는 Profile 인스턴스가 매칭되는 Answer 객체가 없을 때, matches() 메서드가 false를 반환하는 테스트이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class ProfileTest {
   private Answer answerThereIsNotRelocation;
   // ... 
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionAndAnswer() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
   }
   // ...
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
   
   @Test
   public void doesNotMatchWhenNoMatchingAnswer() {
      profile.add(answerThereIsNotRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
}
cs

 

테스트가 통과하려면 matches() 메서드는 Profile 객체가 들고 있는 단일 Answer 객체가 Criterion 객체에 저장된 응답과 매칭되는 지 결정해야 한다. Answer 클래스를 보면 어떻게 응답들을 비교하는지 알 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Answer {
   // ...
   private int i;
   private Question question;
 
   public Answer(Question question, int i) {
      this.question = question;
      this.i = i;
   }
 
   public Answer(Question question, String matchingValue) {
      this.question = question;
      this.i = question.indexOf(matchingValue);
   }
   
   public String getQuestionText() {
      return question.getText();
   }
 
   @Override
   public String toString() {
      return String.format("%s %s"
         question.getText(), question.getAnswerChoice(i));
   }
 
   public boolean match(int expected) {
      return question.match(expected, i);
   }
 
   public boolean match(Answer otherAnswer) {
      // ...
      return question.match(i, otherAnswer.i);
   }
   // ...
 
   public Question getQuestion() {
      return question;
   }
}
cs

 

이제 match 메서드를 이용하여 테스트를 통과하는 matches() 메서드 내의 단일 조건문을 추가한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null && 
         answer.match(criterion.getAnswer());
   }
   // ...
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}
cs

 

TDD로 성공하러면 테스트 시나리오를 테스트로 만들고 각 테스트를 통과하게 만드는 코드 증분을 최소화하는 순으로 코드를 작성한다.

 

이제 Profile 클래스가 다수의 Answer 객체를 갖도록 수정할 것이다. 다수의 Answer 객체를 Profile 클래스는 Map으로 갖도록 설계하였다.(key,value)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1"Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
   }
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
   
   @Test
   public void doesNotMatchWhenNoMatchingAnswer() {
      profile.add(answerThereIsNotRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
 
   @Test
   public void matchesWhenContainsMultipleAnswers() {
      profile.add(answerThereIsRelocation);
      profile.add(answerDoesNotReimburseTuition);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertTrue(result);
   }
}
cs

 

먼저 Profile이 다수의 Answer 객체를 가지는 테스트 코드를 작성한다.(matchesWhenContainsMultipleAnswers)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return answer != null && 
         answer.match(criterion.getAnswer());
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

테스트 통과를 위한 Profile 클래스를 수정한다. 테스트 통과를 위해 matches() 메서드의 일부로 getMatchingProfileAnswer() 메서드를 호출하여 반환값이 null인지 여부를 확인한다. 하지만 이러한 널 체크 구문을 다른 곳으로 숨기고 싶다. 그래서 Answer 클래스의 match() 메서드로 널체크를 보낼 것이다.

 

그렇다면 이전에 matches의 리턴문이 answer.match(criterion.getAnswer()) 였다면 아래와 같이 코드는 수정될 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

또한 Answer의 match()에는 null을 체크하는 구문이 추가된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Answer {
   private int i;
   private Question question;
 
   ...
 
   public boolean match(Answer otherAnswer) {
      if (otherAnswer == nullreturn false;
      // ...
      return question.match(i, otherAnswer.i);
   }
 
   ...
}
cs

 

하지만 여기서 끝이 아니다. 바로 위의 Answer를 수정하기 전에 아래와 같은 테스트코드가 작성되어야한다.

 

1
2
3
4
5
6
7
public class AnswerTest {
   @Test
   public void matchAgainstNullAnswerReturnsFalse() {
      assertFalse(new Answer(new BooleanQuestion(0""), Bool.TRUE)
        .match(null));
   }
}
cs

 

TDD를 할 때 다른 코드를 전혀 건드리지 않고 Profile 클래스만 변경할 필요는 없다. 필요한 사항이 있다면 설계를 변경하여 다른 클래스로 너어가도 된다.(Answer 클래스)

 

이제는 조금 코드를 확장할 것이다. 단일 Criterion 객체로만 매칭하는 것이 아니라 다수의 Criterion 객체를 가지는 Criteria 객체를 인수로 받아 매칭하는 코드로 변화 시킬 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
   private Answer answerReimbursesTuition;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1"Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
      answerReimbursesTuition = 
         new Answer(questionReimbursesTuition, Bool.TRUE);
   }
 
   ...
 
   @Test
   public void doesNotMatchWhenNoneOfMultipleCriteriaMatch() {
      profile.add(answerDoesNotReimburseTuition);
      Criteria criteria = new Criteria();
      criteria.add(new Criterion(answerThereIsRelo, Weight.Important));
      criteria.add(new Criterion(answerReimbursesTuition, Weight.Important));
      
      boolean result = profile.matches(criteria);
      
      assertFalse(result);
   }
}
cs

 

이제는 Profile 객체가 Criteria 객체를 받아 매칭하는 테스트 코드를 작성하였다. 결과를 통과 시키기 위해서 Profile 클래스에 Criteria를 인자로 받는 하드 코딩한 메서드를 추가한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

그리고 빠르게 다음 테스트를 작성한다. 단순히 true를 리턴하는 메서드에서 Criteria 객체를 순회하며 하나씩 꺼낸 Criterion 객체를 매치하는 메서드로 수정한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      for (Criterion criterion: criteria)
         if (matches(criterion))
            return true;
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

여기서 Criteria 객체를 로컬 변수로 매번 새롭게 생성하는데, 이것을 @Before 메서드를 활용하여 초기화하는 코드로 변경하면 더 깔끔한 코드가 된다.

 

이제 이 단계에서 여러 특별한 사례를 추가한다. Criterion의 MustMatch에 해당 되는 질문이 Profile 객체가 가지고 있지 않다면 실패하는 테스트 코드를 작성한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class ProfileTest {
   // ...
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
   private Answer answerReimbursesTuition;
   private Criteria criteria;
   
   @Before
   public void createCriteria() {
      criteria = new Criteria();
   }
   // ...
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1"Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
      answerReimbursesTuition = 
         new Answer(questionReimbursesTuition, Bool.TRUE);
   }
 
   ...
 
   
   @Test
   public void doesNotMatchWhenAnyMustMeetCriteriaNotMet() {
      profile.add(answerThereIsRelo);
      profile.add(answerDoesNotReimburseTuition);
      criteria.add(new Criterion(answerThereIsRelo, Weight.Important));
      criteria.add(new Criterion(answerReimbursesTuition, Weight.MustMatch));
      
      assertFalse(profile.matches(criteria));
   }
}
 
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
 
   public boolean matches(Criteria criteria) {
      boolean matches = false;
      for (Criterion criterion: criteria) {
         if (matches(criterion))
            matches = true;
         else if (criterion.getWeight() == Weight.MustMatch)
            return false;
      }
      return matches;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

테스트를 통과시키기 위해 Profile matches 메서드로 들어오고 Profile 객체의 Answer 중 매치되지 않은 질문의 weight이 MustMatch라면 false를 반환하는 코드로 작성한다.(즉, MustMatch는 반드시 매칭되어야 하는 필수조건인 것이다. 나머지가 다 맞고 MustMatch 하나만 안맞아도 매치는 false를 날린다.)

 

이런식으로 TDD를 이용하여 코드를 작성한다. 마지막으로 코드가 완성되었다면 테스트 클래스의 메서드의 이름들은 어떠한 코드의 명세서가 될 수 있다. 오늘 다루어본 TDD는 사실 두서없이 기본만 다루어본 내용이다. 사실 필자로 TDD에 익숙치 않은 개발자이기 때문에 TDD가 무엇인가 정도만 습득했어도 성공이라고 생각했다. 추후에는 실제 웹개발 코드를 작성할때 TDD를 이용한 개발 등의 포스팅을 할 예정이다.

 

사실 노력없는 결실은 없는 것 같다. 꾸준히 TDD로 개발하다보면 언젠간 적응되고 더 좋은 코드를 개발하는 날이 오지 않을까?

 

앞으로 더욱 많은 TDD 관련 포스팅을 할 예정이며, 현재까지 작성된 포스팅은 아래 책을 기반으로 작성하였다.

 

posted by 여성게
: