스프링 부트 시작(개발부터 배포까지) - 3

PM/iOS Developer KimKU·2022년 8월 15일
1
post-thumbnail

3. 스프링 부트에서 테스트 코드 작성

예전부터 테스트 코드에 대한 이야기가 많았다. 특히나 견고한 서비스를 만들고 싶은 개발자나 팀에선 TDD 를 하거나 최소한 테스트 코드는 꼭 작성했었다. 하지만, 여러 이유로 테스트 코드를 진행하는 비율은 많지 않았지만, 최근의 추세는 그렇지 않다. 대부분의 서비스 회사가 테스트 코드에 관해 요구하고 있다.

실제로 요름 뜨고 있는 서비스 회사의 경우 코딩 테스트 알고리즘이 아닌 프로젝트를 만들고, 단위 테스트 필수조건으로 두었다. 테스트 코드를 전혀 해보지 않았다면 당연하게도 코딩 테스트에서 탈락인 것이다.

그만큼 요즘 선망하는 서비스 회사에 취업과 이직을 하기 위해서는 테스트 코드는 절대 빠질 수 없는 요소이다.

이번 시간에는 앞으로 진행할 프로젝트에서 가장 중요한 테스트 코드 작성의 기본을 할 것이다.

3.1 테스트 코드 소개

먼저 한 가지 짚고 갈 것은 TDD 와 단위 테스는 다른 이야기이다. TDD는 테스트가 주도하는 개발을 이야기한다. 테스트 코드를 먼저 작성하는 것부터 시작한다.
ㅇ

  • 항상 실패하는 데스트를 먼저 작성하고(Red)
  • 테스트가 통과하는 프로덕션 코드를 작성하고(Green)
  • 테스트가 통과하면 프로적션 코드를 리팩토링한다.(Refector)

반면 단위 테스트는 TDD의 첫 번째 단계인 기능 단위의 테스트 코드를 작성하는 것을 이야기한다. TDD와 달리 테스트 코드를 꼭 먼저 작성해야 하는 것도 아니고, 리팩토링도 포함되지 않는다. 순수하게 테스트 코드만 작성하는 것을 이야기한다.

이번에는 TDD가 아닌 단위 테스트 코드에 대해 이야기를 해볼까 한다.

그렇다면 테스트 코드는 왜 작성해야 할까> 위키피디아에서는 단위 테스트 코드를 작성함으로써 얻는 이점으로 다음을 이야기한다.

  • 단위 테스트는 개발단계 초기에 문제를 발견하게 도와준다.
  • 단위 테스트는 개발자가 나중에 코드를 리팩토링하거나 라이브러리 업그레이드 등에서 기존 기능이 올바르게 작동하는지 확인할 수 있다.
  • 단위 테스트는 기능에 대한 불확실성을 감소시킬 수 있다.
  • 단위 테스트는 시스템에 대한 실제 문서를 제공한다. 즉, 단위 테스트 자체가 문서로 사용할 수 있다.

여기서 나의 경험담을 이야기해 보겠다.

가장 먼저 빠른 피드백이 있다. 내가 단위 테스트를 배우기 전에 진행한 개발 방식은 다음과 같다.

1. 코드를 작성
2. 프로프램(tomcat)을 실행한 뒤
3. Postman과 같은 API 테스트 도구로 HTTP 요청하고
4. 요청 결과를 System.out.println() 으로 눈으로 검증한다.
5. 결과가 다르면 다시 프로그램(tomcat)을 중지하고 코드를 수정한다.

여기서 2~5매번 코드를 수정할 때마다 반복해야만 한다. 톰캣을 재시작하는 시간은 수십 초에서 1분 이상 소요되기도 하며 수십 번씩 수정해야 하는 상황에서 아무런 코드 작업 없이 1시간 이상 소요되기도 한다.

왜 계속 톰캣을 내렸다가 다시 실행하는 일을 반복하는가? 이는 테스트 코드가 없다 보니 눈과 손으로 직접 수정된 기능을 확인할 수밖에 없기 때문이다. 테스트 코드를 작성하면 이런 문제가 해결되므로 굳이 손으로 직접 톰캣을 게속 올렸다 내렸다 할 필요가 없다.

두 번째는 System.out.println( ) 을 통해 눈으로 검증해야 하는 문제이다. 테스트 코드를 작성하는 더는 사람이 눈으로 검증하지 않게 자동검증이 가능하다. 작성된 단위 테스트를 실행만 하면 더는 수동검증은 필요없게 되는 것이다.

세 번째로 개발자가 만든 기능을 안전하게 보호해준다. 예를 들어 B 라는 기능이 추가되어 테스트한다. B 기능이 잘 되어 오픈했더니 기존에 잘되던 A 기능에 문제가 생긴 것을 발견한다. 이런 문제는 규모가 큰 서비스에서는 빈번하게 발생하는 일이다. 하나의 기능을 추가할 때마다 너무나 많은 자원이 들기 때문에 서비스의 모든 기능을 테스트할 수는 없다.

이렇게 새로운 기능이 추가될 때, 기존 기능이 잘 작동되는 것을 보장해 주는 것이 테스트 코드이다. A 라는 기존 기능에 기본 기능을 비롯해 여러 경우를 모두 테스트 코드로 구현해 놓았다면 테스트 코드를 수행만 하면 문제를 조기에 찾을 수 있다.

서비스 기업에서는 특히나 강조되고 있어 나의 생각으로는 100% 익혀야 할 기술이자 습관이다.

테스트 코드 작성을 도와주는 프레임워크들이 있다. 가장 대중적인 테스트 프레임워크로는 xUnit이 있는데 이는 개발환경에 따라 Unit 테스트를 도와주는 도구라고 생각하면 된다. 대표적인 xUnit 프레임워크들은 다음과 같다.

  • JUnit - Java
  • DBUnit - DB
  • CppUnit - C++
  • NUnit - .net

이 중에서 자바용인 JUnit을 앞으로 사용하겠다. 자바의 테스트 도구인 JUnit은 계속해서 개선 중이며 최근에는 버전 5까지 나왔다.

하지만 아직 많은 회사에서 JUnit5 보다는 JUnit4를 사용하고 있기에, 스터디를 하는 과정중에도 역시 JUnit으로 테스트를 작성한다.

3.2 Hello Controller 테스트 코드 작성하기

프로젝트 패키지를 하나 생성한다. Java 디렉토리를 마우스 오른쪽 버튼으로 클릭하여, [New -> Package] 를 자례로 선택해서 생성한다.

일반적으로 패키지명은 웹 사이트 주소의 역순으로 한다. 예를 들어 admin.springbootwebist.com 이라는 사이트라면 패키지명은 com.springbootwebsite.admin 으로 하면 된다.

그리고 패키지 아래에 Java 클래스를 생성한다. 패키지와 마찬가지로 마우스 오른쪽 버튼으로 클릭, [New -> Java class]를 차례로 선택하면 된다.

클래스의 이름은 SpringbootWebsite 으로 한다.

클래스의 코드를 다음과 같이 작성한다.

package com.example.springbootwebsite;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringbootWebsiteApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebsiteApplication.class, args);
    }
}

참고로 인텔리제이에서 패키지 가져오기는 Mac 에선 [option + Enter] 이다.(Window 는 써본적이 없어서 댓글로 알려주시면 감사...)

코드를 다 등록했다면 잠깐 이 클래스를 소개하겠다. 방금 생성한 Application 클래스는 앞으로 만들 프로젝트의 메인 클래스가 된다.

@SpringBootApplication 으로 인해 스프링 부트의 자동 설정, 스프링 Bean 읽기와 생성을 모두 자동으로 설정된다. 특히나 @SpringBootApplication 이 있는 위치부터 설정을 읽어가기 때문에 이 클래스는 항상 프로젝트의 최상단에 위치해야만 한다.

main 메소드에서 실행하는 SpringBootApplication.run 으로 인해 내장 WAS(웹 애플리케이션 서버)를 실행한다. 내장 WAS란 별도로 외부에 WAS를 두지 않고 애플리케이션을 실행할 때 내부에서 WAS를 실행하는 것을 이야기한다. 이렇게 되면 항상 서버에 톰캣을 설치할 필요가 없게 되고, 스프링 부트로 안들어진 Jar 파일로 실행하면 된다.

꼭 스프링 부트에서만 내장 WAS를 사용할 수 있는 것은 아니지만, 스프링 부트에서는 내장 WAS를 사용하는 것을 권장하고 있다. 이유는 정말 간단하다.

'언제 어디서나 같은 환경에서 스프링 부트를 배포'할 수 있기 때문이다. 외장 WAS를 쓴다고 하면 모든 서버는 WAS의 종류와 버전, 설정을 일치시켜야만 한다. 새로운 서버가 추가되면서 모든 서버가 같은 WAS 환경을 구축해야만 한다. 1대면 다행이지만, 30대의 서버에 설치된 WAS의 버전을 올린다고 하면 어떻게 될까? 실수할 여지도 많고 시간도 많이 필요한 큰 작업이 될 수도 있다. 하지만 이렇게 내장 WAS를 사용할 경우 이 문제를 모두 해결할 수 있다. 그래서 많은 회사에서 내장 WAS를 사용하도록 전환하고 있다.

Application 클래스에 대한 설명이 끝났으니, 테스트를 위한 Controller를 만들어보겠다.

이번에는 현재 패키지 하위에 web이란 패키지를 만들어보겠다. 위에서 만들어진 패키지를 선택 후 마우스 오른쪽 버튼으로 클릭 [New -> Package] 를 선택한다. 이름은 web으로 한다.
d
앞으로 컨트롤러와 관련된 클래스들은 모두 이 패키지에 담겠다. 그리고 테스트해볼 컨트롤러를 만들어 보겠다. 마찬가지로 마우스 오른쪽 버튼으로 클릭 [New -> Java Class] 를 선택한다. 이름은 HelloController 로 하겠다.
f
생성되었으면 간단한 API를 만들겠다.

package com.example.springbootwebsite.web;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    
    @GetMapping("/hello")
    public String hello(){
        return "hello";
    }
}

코드 설명을 해보겠다.

@RestController

  • 컨트롤러를 JSON을 반환하는 컨트롤러로 만들어 준다.
  • 예전에는 @ReponseBody를 각 메소드마다 선언했던 것을 한번에 사용할 수 있게 해준다고 생각하면 된다.

@GetMapping

  • HTTP Method인 Get의 요청을 받을 수 있는 API를 만들어준다.
  • 예전에는 @RequestMapping으로 사용되었다. 이제 이 프로젝트는 /hello 로 요청이 오면 문자열 hello를 반환하는 기능을 가지게되었다.

작성한 코드가 제대로 작동하는지 테스트를 하겠다. WAS를 실행하지 않고, 테스트 코드로 검증해 보겠다. src/test/java 디렉토리 앞에서 생성했던 패키지를 그대로 다시 생성해본다.

그리고 테스트 코드를 작성할 클래스를 생성한다. 일반적으로 테스트 클래스는 대상 클래스 이름에 Test를 붙인다. 그러므로 여기서는 HelloControllerTest 로 생성한다.
ㅇ

생성된 클래스에 다음과 같은 테스트 코드를 추가한다.

package com.example.springbootwebsite;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultMatcher;


import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@WebMvcTest(controllers = HelloController.class)
public class HelloControllerTest {
    
    @Autowired
    private MockMvc mvc;
    
    @Test
    public void hello가_리턴된다() throws Exception{
        String hello = "hello";
        
        mvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect((ResultMatcher) content().string(hello));
    }
}

@RunWith(SpringRunner.class)

  • 태스트를 진행할 때 JUnit에 내장된 실행자 외에 다른 실행자를 실행시킨다.
  • 여기서는 SpringRunner 라는 스프링 실행자를 사용한다.
  • 즉, 스프링 부트 테스트와 JUnit 사이에 연결자 역할을 한다.

@WebMvcTest

  • 여러 스프링 테스트 어노테이션 중, Web에 집중할 수 있는 어노테이션이다.
  • 선언할 경우 @Component, @Repository 등은 사용할 수 없다.
  • 여기서는 컨트롤러만 사용하기 때문에 선언한다.

@Autowired

  • 스프링이 관리하는 빈(Bean)을 주입 받는다.

private MockMvc mvc

  • 웹 API 테스트할 때 사용한다.
  • 스프링 MVC 테스트의 시작점이다.
  • 이 클래스를 통해 HTTP GET, POST 등에 대한 API 테스트를 할 수 있다.

mvc.perform(get("/hello"))

  • MockMVC 를 통해 /hello 주소로 HTTP GET 요청을 한다.
  • 체이닝이 지원되어 아래와 같이 여러 검증 기능을 이어서 선언할 수 있다.

.andExpect(status().isOk())

  • mvc.perform 의 결과를 검증한다.
  • HTTp Header 의 Statud 를 검증한다.
  • 우리가 흔히 알고 있는 200, 404, 500 등의 상태를 검증한다.
  • 여기선 OK 즉, 200인지 아닌지를 검증한다.

.andExpect((ResultMatcher) content().string(hello));

  • mvc.perform의 결과를 검증한다.
  • 응답 본문의 내용을 검증한다.
  • Controller 에서 "hello" 를 리턴하기 때문에 이 값이 맞는지 검증한다.

코드를 모두 적상했다면, 테스트 코드를 한번 실행해보겠다. 다음과 같이 메소드 왼쪽의 화살표를 클릭한다.
ㅇ

혹시 아래와 같은 오류가 났다면 이 링크를 참고하길 바란다.(https://ddasi-live.tistory.com/35)
ㅇ
빌드가 성공적으로 되었다면 다음과 같이 테스트가 통과하는 것을 확인할 수 있다.
ㅇ
즉, 우리가 검증용으로 선언했던 .andExpect(status().isOk()).andExpect((ResultMatcher) content().string(hello)) 가 모두 테스트를 통과했음을 의미한다.

테스트 코드로 검증했지만, 아직 의심이 된다. 그럼 수동으로도 실행해서 정상적으로 값이 출력되는지 한번 확인해 보겠다.

SpringbootWebsiteApplication.java 파일로 이동해서 마찬가지로 메인 메소드의 왼쪽 화살표 버튼을 클릭한다.
ㅇ

실행해보면 테스트 메소드 실행 때와 마찬가지로 스프링 부트 로그가 보인다. 톰캣 서버가 8080 포트로 실행되었다는 것도 로그에 출력된다.

ㅇ

실행이 잘 끝났다면 웹 브라우저를 열어 localhost:8080/hello 로 접속해보자. 그럼 다음과 같이 문자열 hello 가 노출됨을 확인할 수 있다.
ㅇ
테스트 코드의 결과와 같은 것을 알 수 있다. 이후에도 테스트 코드는 계속 작성한다.

브라우저로 한 번씩 검증은 하되, 테스트 코드는 꼭 따라 해야 한다. 그래야만 견고한 소프트웨어를 만드는 역량이 성장할 수 있다. 추가로, 절대 수동으로 검증하고 테스트 코드를 작성하진 않는다. 테스트 코드로 먼저 검증 후, 정말 못 믿겠다는 생각이 들 땐 프로젝트를 실행해 확인한다는 점 명심하자.

3.3 롬복 소개 및 설치

다음으로 소개할 것은 자바 개발자들의 필수 라이브러리 롬복(Lombok)이다.

롬복은 자바 개발할 때 자주 사용하는 코드 Getter, Setter, 기본생성자, toString 등을 어노테이션으로 자동 생성해 준다.

이클립스의 경우엔 롬복 설치가 번거롭지만, 인텔리제이에선 플러그인 덕분에 쉽게 설정이 가능하다. 그럼 먼저 프로젝트에 롬복을 추가해 보겠다. build,gradle 에 다음의 코드를 추가한다.

compileOnly 'org.projectlombok:lombok' 
annotationProcessor 'org.projectlombok:lombok'

build.gradle 에 등록하였으니, Refresh 로 새로고침해서 라이브러리를 내려받는다.

라이브러리를 다 받았다면 롬복 플로그인을 설치하겠다. 단축기로 플러그인 Action을 검색한다. 맥에서는 [Command + shift + A] 이다. Plugins Action 을 선택하면 플러그인 설치 팝업이 나올것이다.

Marketplace 탭으로 이동하여 "lombok" 을 검색한다. 설치하고 인텔리제이를 재시작한다. 마지막으로 Enable annotation processing을 체크한다.
ㅇ
이제 이 프로젝트에서는 롬복을 사용할 수 있게 되었다.

롬복을 설정했으니 이제 한번 기존 코드를 롬복으로 리팩토링해보겠다.

2.4 Hello Controller 코드를 롬복으로 전환하기

기존 코드를 롬복으로 변경해 보겠다. 만약 이 프로젝트가 지금처럼 작은 규모가 아닌 큰 규모의 프로젝트였다면 롬복으로 전환할 수 있었을까? 쉽지 않았을 것이다. 어떤 기능이 제대러 작동될지 안 될지 예측할 수 없기 때문이다.

하지만 우리는 쉽게 변경할 수 있다. 테스트 코드가 우리의 코드를 지켜주기 때문이다. 롬복으로 변경하고 문제가 생기는지는 테스트 코드만 돌려보면 알 수 있다. 그러니 마음 편하게 변경해 보겠다.

먼저 web 패키지에 dto 패키지를 추가하겠다. 앞으로 모든 응답 Dto는 이 Dto 패키지에 추가하겠다. 이 패키지에 HelloResponseDto 를 생성한다.
ㅇ
HelloResponseDto 코드를 작성한다.

package com.example.springbootwebsite.web.dto;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class HelloResponseDto {
    
    private final String name;
    private final int amount;
}

코드 설명을 하겠다.

@Getter

  • 선언된 모든 필드의 get 메소드를 생성해 준다.

@RequiredArgsConstructor

  • 선언된 모든 final 빌드가 포하된 생성자를 생성해 준다.
  • final 이 없는 필드는 생성자에 포함되지 않는다.

이 Dto 에 적용된 롬복이 잘 작동하는지 간단한 테스트 코드를 작성해보겠다.

d

HelloResponseDtoTest 클래스의 코드는 다음과 같다.

package com.example.springbootwebsite.dto;

import com.example.springbootwebsite.web.dto.HelloResponseDto;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class HelloResponseDtoTest {

    @Test
    public void 롬복_기능_테스트(){
        //given
        String name = "test";
        int amount = 1000;

        //when
        HelloResponseDto dto = new HelloResponseDto(name, amount);

        //then
        assertThat(dto.getName()).isEqualTo(name);
        assertThat(dto.getAmount()).isEqualTo(amount);
    }
}

코드 설명하겠다.

assertThat

  • assertj 라는 테스트 검증 라이브러리의 검증 메소드이다.
  • 검증하고 싶은 대상을 메소드 인자로 받는다.
  • 메소드 체이닝이 지원되어 isEqualTo 와 같이 메소드를 이어서 사용할 수 있다.

isEqualTo

  • assertj 의 동등 비교 메소드이다.
  • assertThat 에 있는 값고 isEqualTo 의 값을 비교해서 같을 때만 성공이다.

여기서 나는 JUnit 의 기본 assertThat 이 아닌 assertj 의 assertThat 을 사용했다. assertj 역시 Junit 에서 자동으로 라이브러리 등록을 해준다.

Junit 과 비교하여 assertj 의 장점은 다음과 같다.

  • CoreMatchers 와 달리 추가적으로 라이브러리가 필요하지 않다.
    JUnit 의 assertThat 을 쓰게 되면 is() 와 같이 CoreMathchers 라이브러리가 필요하다.
  • 자동완성이 좀 더 확실하게 지원된다.
    IDE 에서는 CoreMatchers 와 같은 Matcher 라이브러리의 자동완성 지원이 약하다.

작성된 테스트 메소드를 실행해 본다.

d

정상적으로 기능이 수행되는 것을 확인했다. 롬복의 @Getter 로 get 메소드가, @RequiredArgsConstructor 로 생성자가 자동으로 생성되는 것이 증명되었다.

자 그럼 HelloController 에도 새로 만든 ResponseDto 를 사용하도록 코드를 추가해보자.

  @GetMapping("/hello/dto")
    public HelloResponseDto helloResponseDto(@RequestParam("name") String name, @RequestParam("amount") int amount){
        return new HelloResponseDto(name, amount);
    }

코드를 설명하겠다.

@RequestParam

  • 외부에서 API 로 넘긴 파라미터를 가져오는 어노테이션이다.
  • 여기서는 외부에서 name(@RequestParam("name")) 이란 이름으로 넘긴 파라미터를 메소드 파라미터 name(String name)에 저장하게 된다.

name 과 amount 는 API 를 호출하는 곳에서 넘겨준 값들이다. 추가된 API 를 테스트하는 코드를 HelloControllerTest 에 추가한다.

package com.example.springbootwebsite;

import com.example.springbootwebsite.web.HelloController;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.is;
import org.springframework.test.web.servlet.ResultActions;


import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringRunner.class)
@WebMvcTest(controllers = HelloController.class)
public class HelloControllerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    public void hello가_리턴된다() throws Exception {
        String hello = "hello";

        mvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect(content().string(hello));
    }

    @Test
    public void holloDto가_리턴된다() throws Exception {
        String name = "hello";
        int amount = 1000;

        mvc.perform(
                        get("/hello/dto")
                                .param("name", name)
                                .param("amount", String.valueOf(amount)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name", is(name)))
                .andExpect(jsonPath("$.amount", is(amount)));

    }
}

코드 설명하겠다.

param

  • API 테스트할 때 사용될 요청 파라미터를 설정
  • 단, 값은 String 만 허용
  • 그래서 숫자/날짜 등의 데이터도 등록할 때는 문자열로 변경해야만 가능하다.

**jsonPath

  • JSON 응답값을 필드별로 검증할 수 있는 메소드이다.
  • $ 를 기준으로 필드명을 명시한다.
  • 여기서는 name 과 amount 를 검증하니 $.name, $.amount 로 검증한다.

그럼 추가된 API 도 한번 테스트를 실행하보겠다.
ㅇ
JSON 이 리턴되는 API 역시 정상적으로 테스트가 통과하는 것을 확인할 수 있다.

profile
With passion and honesty

0개의 댓글