Spring Boot 1으로 Todo List를 만들어 보자
이번 게시글에서는 Todo List 만들기에 앞서 간단하게 세팅과 필수적인 기능들을 살펴보도록 하자. 대상은 Spring Project는 해봤으나 Boot는 접하지 못한 분들이다.
Spring의 개념적인 부분보다는 동작하는 기능에 무게중심을 둘 예정이다.
개념에 좀 더 관심이 있다면, 토비의 스프링이나 공식 문서를 확인하도록 하자. ( Spring Project, Spring Boot )
개발 환경은 Mac OS와 Intellij로 진행이 되지만 Window나 이클립스를 쓰더라도 충분히 따라올 수 있을 것으로 보인다. 코드는 Github에 있으니 같이 보면서 하는 것이 도움이 될 것 같다. (본문코드와 조금 다를 수도 있다.)
이 포스팅은 크게 Project 생성, API구현, Test Code 작성 3가지로 이루어진다.
Project 생성
http://start.spring.io/에 접속을 하자.
Spring Boot의 버전은 스냅샷이 아닌 1.xx을 선택하도록 하자.(최신 버전 밖에 안 나오기는 하지만) 현재는 1.5.13이 최신 버전이다.
그리고 Group과 Artifact를 센스 있게 작성한다.
제일 중요한 Dependencies를 설정한다.
웹 서비스에 필요한 Web, 객체의 패러다임으로 DB를 다룰 JPA, getter/setter 등을 편하게 생성해주는 Lombok, 가볍게 쓰기 좋은 인 메모리 DB H2 정도면 충분할 것 같다.
Generate Project를 클릭하게 되면 zip 파일이 다운로드 된다. 압축을 풀자.
압축을 풀었으면, Intellij를 켜서 Import Project를 클릭 후 아래처럼 pom.xml을 Open 하도록 한다.
필요에 따라 설정을 더 해야 되겠지만, 잘 모르겠으면 Import Maven Projects automatically만 추가적으로 체크해주도록 한다.
이는 디펜던시가 추가/삭제될 때마다 자동적으로 Import 시켜주는 행동을 한다.
그럼 실행 가능한 Application이 만들어진다. main() 메소드를 실행시켜서 Application이 잘 뜨는지 각자 확인해 보도록 하자.
API 구현
Todo List의 Post를 생성하는 로직을 구현하면서 하나하나씩 알아가 보자.
먼저, 숲을 한번 보자. 필자는 다음처럼 프로젝트를 구성하였다.
게시글을 관한 기능을 post라는 패키지안에 역할 별로 나눠서 Class를 만들었다.
Repository가 생소하면 이를 DAO라고 생각해도 무방하다.
Controller, Service, Dao가 생소하다면 MVC 패턴에 대한 글을 잠깐 읽고 오도록 하자.
Controller
@RestController
@RequestMapping("/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@PostMapping
public Post savePost(@RequestBody Post post) {
return this.postService.save(post);
}
}
public PostController(...) 생성자 부분을 먼저 보자.
Spring은 몇 가지의 DI를 제공한다.
첫 번째는 위와 같은 생성자를 통한 DI (@Autowired 같은 Inject 어노테이션을 작성하지 않아도 된다.)
두 번째는 Setter를 통한 DI
세 번째는 Field에 직접 하는 DI
생성자 DI는 필수적인 Dependency를 주입할 때 쓰인다.
Setter DI는 부수적인 Dependency를 주입할 때 쓰인다.
Field DI는 되도록 사용하지 말자. 이는 Spring에 강하게 종속된다. 특히 Test할 때 자주 문제가 되는데, 1 + 1을 하기 위해서도 Spring을 띄워야 한다.
해당 프로젝트는 API로 만들어질 예정이다. 화면은 JSP, Thymeleaf(Server Side Rendering)으로 만들어질 것이 아니라, 추후에 React, Vue (Client Side Rendering)로 만들어질 예정이기 때문이다. (언젠가는...)
그래서 @RestController를 클래스 위에 작성하였다. Server Side Rendering을 사용할 것이면 @Controller를 이용하면 된다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {}
위의 두 어노테이션은 @Component를 포함(?) 하고 있다. @Component는 해당 클래스를 스프링 빈이라고 표시하는 역할을 한다.
스프링이 로드될 때 Component Scan을 하면서 @Component와 @Bean으로 표시된 클래스를 스프링 빈으로 등록을 한다.
해당 클래스에 들어오기 위해서는 /posts라는 전역의 Path를 @RequestMapping을 통하여 지정하였다.
www.todolist.com/posts처럼 말이다. ( www.todolist.com은 설명하기 위해 임의로 작성한 도메인이다. 로컬에서 돌리려면 localhost:8080/posts를 실행하면 된다. )
물론, 클래스 안에 있는 모든 메소드의 전역 Path이기 때문에 각 메소드는 이 URI를 세분화하는 Path를 정의해주어야 한다.
그래서 savePost()는 @PostMapping을 통하여 세분화를 하였다.
PostMapping는 RequestMapping를 아래처럼 포함하고 있다. 단지 HTTP Method가 Post 일 뿐이다.
[POST] www.todolist.com/posts를 호출하면 해당 메소드는 호출되게 된다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST)
public @interface PostMapping {}
[GET] www.todolist.com/posts/comments를 호출하고 싶을 경우에는 PostController 안에서 아래의 어노테이션을 붙인 메소드를 만들면 된다.
@GetMapping("/comments")
@RequestBody는 HTTP의 Body를 받게 해준다. JSON 형식의 Body를 보내게 되면 Post 클래스에 자동적으로 매핑이 된다.
public class Post {
private Long id;
private String subject;
private String content;
private Date createDate;
public Post(Long id, String subject, String content, Date createDate) {
this.id = id;
this.subject = subject;
this.content = content;
this.createDate = createDate;
}
}
HTTP를 통해서 savePost() 메소드를 호출은 어떻게 하면 될까?
HTTP method : POST
URL : localhost:8080/posts
Body : Json 형식
위처럼 호출하게 되면 savePost()는 호출되게 되고 Post 클래스의 subject와 content 변수에 각각 블로그 작성, 스프링 기본원리라는 값이 들어가게 된다.
그리고 아직 로직은 구현을 하지 않았지만, savePost() 메소드의 Return Type이 Post이기 때문에 Post 클래스를 Json 으로 변환시킨 값을 반환하게 된다. (이는 JacksonMessageConverter라는 녀석이 자동적으로 해줬던걸로 기억한다. 일단은 크게 신경쓰지 말자)
아래는 위의 요청에 대한 응답 값이다.
Service
@Service
public class PostService {
private final PostRepository postRepository;
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
public Post save(Post post) {
Post savedPost = this.postRepository.save(post);
savedPost.initCreateDate();
String subject = format("%s. %s", savedPost.getId(), savedPost.getSubject());
savedPost.setSubject(subject);
this.postRepository.save(savedPost);
return savedPost;
}
}
Service는 설명할 것이 마땅히 없다.
@Service를 통해서 스프링빈이라는 것을 표시를 한다.
그리고 Business 로직들로 이루어져 있다.
위의 코드는 Post의 제목 앞에 키값을 추가하는 로직이 있다. (예시를 위해 아무렇게나 작성했다.)
이후에 @Transactional이라는 녀석이 Service에서 중요한 역할을 하게 될 텐데 후에 살펴보도록 하자.
Repository
public interface PostRepository extends JpaRepository<Post, Long> {
}
어라? 아무 코드도 없다. 게다가 class가 아니라 Interface다. 근데 위에선 분명 postRepository.save()를 이용하지 않았는가?
Spring Data JPA는 JPARepository를 상속받은 interface에 Default로 많은 기능을 제공해준다.
위의 기능들이 자동적으로 구현이 되어있고, 추가적으로 메소드명을 통해서도 원하는 DB 작업을 할 수 있다.
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findBySubject(String subject);
}
예를 들면 Post 테이블의 subject 값이 변수로 들어오는 subject와 동일한 값만을 반환하는 메소드이다.
SELETE * FROM post WHERE subject=?
JPA에 대해서는 분량이 너무 많기 때문에 각자 공부하도록 하자.
김영한님의 자바 ORM 표준 JPA 프로그래밍이 유명하다. (700 페이지가 넘는 건 안 함정)
Test Code
API를 만들었다. Postman을 통해 통합 테스트를 진행하는 것도 훌륭하지만, 유닛 테스트를 통해 작은 단위로 테스트하는 것 또한 의미가 있다. 그리고 테스트 코드를 통해 테스트 자동화까지 하게 되면 무궁무진한 장점이 있다. (물론 귀찮긴 하다)
Controller
@RunWith(SpringRunner.class)
@WebMvcTest(PostController.class)
public class PostControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private PostService postService;
@Test
public void savePost() throws Exception {
given(this.postService.save(any(Post.class)))
.willReturn(new Post(1L, "1. 블로그 작성", "Spring 기본원리", new Date()));
this.mvc.perform(post("/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"subject\" : \"블로그 작성\", \"content\" : \"Spring 기본원리\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("1"))
.andExpect(jsonPath("$.subject").value("1. 블로그 작성"))
.andExpect(jsonPath("$.content").value("Spring 기본원리"))
.andDo(print());
}
}
Spring Controller 테스트 환경을 위해서 @Runwith(SpringRunner.class)와 @WebMvcTest(PostController.class)를 작성한다.
우리는 Controller만 테스트하면 되지 Service는 실제로 동작을 안 해도 된다. 그러나 PostController에서는 PostService의 메소드를 호출하고 있다. 모순이지 않은가? 그래서 이런 것은 Mock 이란 것을 이용한다. 마치 정상적으로 돌아가는 것처럼 하는 것이다. 다시 말해서 Service를 호출했을 때 어떤 값을 반환할지를 그냥 내 맘대로 정하는 것이다.
이것이 given() 메소드이다. postService.save()에 Post.class 형식의 어떤 오브젝트를 파라미터로 해서 넣던 간에 new Post(1L, "1. 블로그 작성", "Spring 기본원리", new Date()를 반환한다는 것이다.
그렇게 가정을 하고 위처럼 URL, content type, body를 넣어준 후 응답으로 돌아올 내가 기대하는 값을 작성한다.
응답 값은 Json으로 이루어져 있기 때문에 Jsonpath라는 것을 이용하였다.
Service
@RunWith(MockitoJUnitRunner.class)
public class PostServiceTest {
private PostService postService;
@Mock
private PostRepository postRepository;
@Before
public void setup() {
this.postService = new PostService(this.postRepository);
}
@Test
public void save() {
Date start = new Date();
given(this.postRepository.save(any(Post.class)))
.willReturn(new Post(1L, "블로그 작성", "Spring 기본원리 작성", null));
Post savedPost = this.postService.save(new Post("블로그 작성", "Spring 기본원리 작성"));
assertThat(savedPost.getId()).isEqualTo(1L);
assertThat(savedPost.getSubject()).isEqualTo("1. 블로그 작성");
assertThat(savedPost.getContent()).isEqualTo("Spring 기본원리 작성");
assertThat(savedPost.getCreateDate()).isBetween(start, new Date());
}
Controller와 Service의 @Runwith 안의 객체는 다르다. Controller는 Spring을 로드해야 되었지만 Service에서는 굳이 스프링을 로드할 필요가 없다. 제목 앞에 숫자 넣는 걸 굳이 스프링을 띄워야겠는가?
Controller와 마찬가지로 PostRepository도 Mock으로 만들고 assertThat().isEqualTo()를 통해서 내가 기대한 대로 진행되었는지 확인한다.
참고로 asserThat()은 org.assertj.core.api.Assertions의 assertThat()을 사용하였다.
Repository
@RunWith(SpringRunner.class)
@DataJpaTest
public class PostRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private PostRepository postRepository;
@Test
public void findBySubject() {
Post study1 = new Post("스터디", "Spring");
Post study2 = new Post("스터디", "JPA");
Post study3 = new Post("스터디", "DDD");
Post love = new Post("연애", "보블리");
this.entityManager.persist(study1);
this.entityManager.persist(study2);
this.entityManager.persist(study3);
this.entityManager.persist(love);
List<Post> posts = this.postRepository.findBySubject("스터디");
assertThat(posts.size()).isEqualTo(3);
assertThat(posts.get(0).getSubject()).isEqualTo("스터디");
assertThat(posts.get(0).getContent()).isEqualTo("Spring");
}
}