[Spring Boot + JPA로 웹 애플리케이션 구현] 2. ATDD로 게시글 CRUD 구현하기

SungBum Park·2020년 1월 17일
2

들어가기전...

이 시리즈는 제가 지금까지 배웠던 내용을 바탕으로 웹 애플리케이션을 구현하는 과정을 담았습니다. 스프링 부트를 사용해본 기간이 짧고, 웹 애플리케이션 구현을 스프링 부트로만 경험해보아서 설명이 틀릴 수 있고 부족한 점이 많습니다. 틀린 부분이 있거나 다른 의견이 있다면 지적부탁드립니다!

ATDD(Acceptance Test Driven Development, 인수테스트 주도 개발)

해당 프로젝트의 모든 기능은 ATDD를 기반으로 구현할 예정이다. ATDD는 기능을 구현하기 전 인수테스트를 먼저 작성하는 것이다.

  • 인수는 클라이언트가 소프트웨어의 결과물을 넘겨받는다는 의미이다.
  • 테스트는 기능이 정확히 동작하는지 확인하는 것이다.

인수테스트는 요구사항을 모두 만족하여 기능 완료 여부를 확인하는 테스트이다.

ATDD를 사용한 이유는 TDD의 단점을 보완하기 위해서이다. TDD는 기능 구현보다 단위테스트를 먼저 작성하는 개발방법이다. 하지만 단위테스트만으로는 여러 객체가 조합되고, 외부 자원에 의존하는 전체 과정을 테스트하지는 못한다. 따라서 작은 단위들이 모여 하나의 요구사항 기능을 구현했을 때, 이가 정확히 동작하는지 알 수 없다. 이를 테스트하는 것이 인수테스트이다.

예를들어, 웹에서 게시글을 생성하는 요구사항이 있다고 하자. 이의 전체과정은 다음과 같을 것이다.
1. 브라우저(클라이언트)에서 게시글 생성에 대한 정보를 담아 서버에 요청한다.
2. 요청받은 서버는 해당 정보를 가지고 게시글 객체를 생성한다.
3. 생성된 게시글 객체를 데이터베이스에 저장한다.
4. 서버는 브라우저에게 정상적으로 게시글 생성을 마쳤다고 응답한다.

위 과정 모두를 테스트하는 것이 인수테스트이다. 인수테스트는 요구사항 전체를 테스트할 수 있을 뿐 아니라, 이를 문서화하여 다른 개발자들과 요구사항에 대해 명확하게 소통할 수 있다는 장점도 있다. 하지만 인수테스트를 먼저 작성하는 만큼 해당 기능에 대해 충분한 설계가 있어야 하고, 테스트 수행시간이 오래걸린다는 단점도 있다.

게시글 생성(Create) 기능 구현

ATDD를 한다면 인수테스트를 먼저 작성해야 한다. 그 전에 게시글에 대한 정보가 아무것도 없기 때문에 간단한 게시글 엔티티 설계부터 해보겠다. 현재는 게시글을 생각했을 때 기본적으로 필요할 것 같은 정보를 설정할 것이다. 기능 구현을 하면서 필요한 부분은 지속적으로 추가할 예정이다.

게시글(Article) 엔티티

@Entity
@Table(name = "ARTICLES")
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "AUTHOR")
    private String author;

    @Column(name = "TITLE", length = 200)
    private String title;

    @Lob
    @Column(name = "CONTENTS")
    private String contents;

    protected Article() {
    }
    
    public Article(String author, String title, String contents) {
        this.author = author;
        this.title = title;
        this.contents = contents;
    }
    // ...
}
  • @Entity 어노테이션이 붙은 클래스는 JPA가 관리하는 클래스로, 해당 클래스를 엔티티라고 부른다. JPA를 사용하여 테이블과 매핑해야할 클래스는 반드시 @Entity를 선언해야한다.
  • @Table: 엔티티와 매핑할 테이블을 지정한다.
    • name(String, Optional): 매핑할 테이블 이름을 지정한다.(기본값: 엔티티 이름)
  • @Id: 기본키(Primary Key)를 지정한다.
  • @GeneratedValue: 기본키를 생성하는 방법을 지정한다.
    • strategy(GenerationType, Optional): 기본키 생성 전략
      • GenerationType.AUTO: SQL 방언에 따라 아래의 세 가지 전략을 자동으로 지정한다.(기본값)
      • GenerationType.IDENTITY: 기본 키 생성을 데이터베이스에 위임한다.(id 값을 null로 하면 DB가 자동으로 AUTO_INCREMENT 를 수행한다.)
      • GenerationType.SEQUENCE: 데이터베이스 Sequence Object를 사용한다.
      • GenerationType.TABLE: 키 생성 전용 테이블 하나를 만들어 데이터베이스 Sequence 를 흉내내는 전략
  • @Column: Column을 매핑한다.
    • length(int, Optional): 문자 길이를 제한한다.(String 타입에만 사용한다.)
    • name(String, Optional): DB 컬럼명을 지정한다.(기본값: 해당 객체명)
  • @Lob: 데이터베이스에서 VARCHAR보다 큰 데이터를 담고 싶을 때 사용한다.

위 어노테이션에 대해 더 자세한 특징과 속성은 이 링크에서 확인할 수 있다.

인수테스트를 하려면 브라우저에서 요청과 응답이 필요하다. 스프링 프레임워크에서는 @RequestBody, @ResponseBody 어노테이션을 통해 HTTP 바디 영역에 있는 JSON 형태의 정보를 객체로 맵핑을 해준다. 이 객체를 DTO로 사용할 것이며, 인수테스트를 만들기 전 DTO에 대해서도 간단히 만들어보겠다.

게시글 생성 요청 DTO

public class ArticleRequestDto {
    private String author;
    private String title;
    private String contents;

    public ArticleRequestDto(String author, String title, String contents) {
        this.author = author;
        this.title = title;
        this.contents = contents;
    }

    public String getAuthor() {
        return author;
    }

    public String getTitle() {
        return title;
    }

    public String getContents() {
        return contents;
    }
}

게시글을 생성하려면 author, title, contents의 정보가 필요하다. 브라우저에서 HTTP 요청 바디로 아래와 같은 JSON으로 요청한다면, 위 DTO로 매핑이 될 것이다.(이는 이후 컨트롤러 구현에서 자세한 코드를 살펴볼 것이다.)

{
    "author": "park",
    "title": "안녕하세요.",
    "contents": "반갑습니다."
}

서버에서 게시글 생성이 끝나면 브라우저에게 응답을 해야한다. 응답 JSON 정보는 프론트엔드에서 어떤 정보가 필요할지를 생각해서 만들어야 한다. 지금은 게시글의 전체 정보가 필요하다고 가정하고 DTO를 만들어보았다.

게시글 생성 응답 DTO

public class ArticleResponseDto {
    private Long id;
    private String author;
    private String title;
    private String contents;

    public ArticleResponseDto(Long id, String author, String title, String contents) {
        this.id = id;
        this.author = author;
        this.title = title;
        this.contents = contents;
    }

    public Long getId() {
        return id;
    }

    public String getAuthor() {
        return author;
    }

    public String getTitle() {
        return title;
    }

    public String getContents() {
        return contents;
    }
}

간단히 게시글 엔티티와 응답 요청으로 어떤 데이터가 오고 갈지에 대한 설계를 마쳤다. 생성 기능을 작성하기 전에 인수테스트를 작성해보자.

게시글 생성 인수테스트

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)   // 1)
@AutoConfigureWebTestClient                                                   // 2)
public class ArticleApiControllerTest {
    private static final String LOCATION = "Location";

    @Autowired
    private WebTestClient webTestClient;                                      // 3)

    @Test
    @DisplayName("게시글을 정상적으로 생성한다.")
    void create_article() {
        String author = "park";
        String title = "안녕하세요.";
        String contents = "반갑습니다.";
        ArticleRequestDto articleRequestDto = new ArticleRequestDto(author, title, contents);

        ArticleResponseDto articleResponseDto = webTestClient
                .post()                                                       // 4)
                .uri("/api/articles")                                         // 5)
                .contentType(MediaType.APPLICATION_JSON)                      // 6)
                .accept(MediaType.APPLICATION_JSON)                           // 7)
                .body(Mono.just(articleRequestDto), ArticleRequestDto.class)  // 8)
                .exchange()                                                   // 9)
                .expectStatus().isCreated()                                   // 10)
                .expectHeader().contentType(MediaType.APPLICATION_JSON)       // 11) 
                .expectHeader().valueMatches(LOCATION, "/articles/d")         // 12)
                .expectBody(ArticleResponseDto.class)                         // 13)
                .returnResult()                                               // 14)
                .getResponseBody()                                            // 15)
        ;

        // 16)
        assertThat(articleResponseDto.getId()).isEqualTo(1L);
        assertThat(articleResponseDto.getAuthor()).isEqualTo(author);
        assertThat(articleResponseDto.getTitle()).isEqualTo(title);
        assertThat(articleResponseDto.getContents()).isEqualTo(contents);
    }
}

1) @SpringBootTest는 Spring의 기능인 ApplicationContext를 수행하여 빈을 등록하고 사용할 수 있다. 즉 Spring에서 제공하는 기능을 사용하기 위해 사용한다고 볼 수 있다.

  • SpringBootTest.WebEnvironment.RANDOM_PORT: 내장 서블릿 컨테이너(ex 톰캣)를 랜덤 port로 실행한다.(실제 서버를 실행시킨다.)

2) WebTestClient를 사용할 수 있도록 한다.
3) WebTestClient: 비동기/논블럭킹 방식으로 웹 테스트를 가능하게 한다.(Spring webflex)
4) Post 방식으로 요청한다.
5) "/api/articles" uri을 사용한다.
6) 요청으로 보내는 데이터 유형을 명시한다.
7) 응답으로 받고 싶은 데이터 유형을 명시한다.
8) 요청 데이터를 설정한다.

  • Mono는 리액트한 스트림을 말한다. 이를 사용하려면 org.springframework.boot:spring-boot-starter-webflux 의존성을 추가해야한다. (자세한 것은 나중에 정리 예정)

9) 현재까지 설정한 HTTP 요청 정보를 반영한다.
10) 응답 상태 코드가 201 Created인지 확인한다.
11) 응답의 데이터 유형이 JSON인지 확인한다.
12) HTTP 상태 코드 201(Created)은 리소스가 생성된 위치(URI)를 헤더에 포함한다.
13) 응답의 바디를 ArticleResponseDto 클래스로 만든다.
14) 체이닝된 WebTestClient 메서드를 빠져나간다.

  • returnResult()이전에 수행되던 비동기 로직이 모두 끝날 때까지 기다린다. 이는 expectBody()에서 데이터를 확인할 때만 사용하는 것이 좋다.

15) 응답의 바디를 반환한다.
16) 응답의 바디인 ArticleResponseDto에 정상적인 데이터가 있는지 assert()로 검사한다.

이 상태에서 테스트를 수행하면 당연히 아래와 같이 실패를 한다.

이제부터 이를 성공하도록 만들어보자.

게시글 생성 API(Controller)

@RestController
public class ArticleApiController {
    private ArticleService articleService;

    public ArticleApiController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @PostMapping("/api/articles")
    public ResponseEntity create(@RequestBody ArticleRequestDto articleRequestDto) {
        ArticleResponseDto createdArticle = articleService.create(articleRequestDto);
        return ResponseEntity
                .created(URI.create("/articles/" + createdArticle.getId()))
                .body(createdArticle);
    }
}

인수테스트를 통과시키기 위해 Controller를 구현하였다. ArticleService는 아직 구현하지 않았으므로 컴파일에러가 발생한다.

앞으로 Controller, Service, Repository를 구현하면서 @RestController, @Service, @Repository 어노테이션을 보게 될 것이다. 이는 기본적으로 @Component를 포함하며, 스프링 프레임워크에게 이 클래스를 Bean으로 등록하여 인스턴스를 생성해달라는 것이다.

  • @RestController: 이 클래스를 RestController로 사용하겠다는 의미이다.
    • @Controller: 결과로 View를 반환(jsp, html 등)
    • @RestController: @Controller+@ResponseBody로 데이터(객체)를 반환(json, xml 등)
  • @PostMapping: 파라미터로 설정한 URI의 POST 요청을 받는다.

게시글 생성 Service 테스트

ArticleApiController에서 게시글을 실질적으로 생성하여 저장하는 로직은 ArticleService.create()에 있다. 현재는 아직 ArticleService를 구현하지 않는 상태이고 이를 TDD로 하기 위해 테스트 코드부터 구현하도록 하겠다.

@ExtendWith(MockitoExtension.class)   // 1)
public class ArticleServiceTest {

    @InjectMocks   // 2)
    private ArticleService articleService;

    @Mock          // 3)
    private ArticleRepository articleRepository;

    @Test
    @DisplayName("게시글을 레포지토리에 정상적으로 저장한다.")
    void create_article() {
        ArticleRequestDto articleRequestDto = new ArticleRequestDto("park",
                "Hello",
                "Nice to meet you.");

        given(articleRepository.save(any())).willReturn(new Article(articleRequestDto.getAuthor(),
                articleRequestDto.getTitle(), articleRequestDto.getContents()));   // 4)

        ArticleResponseDto articleResponseDto = articleService.create(articleRequestDto);

        assertThat(articleResponseDto).isNotNull();
    }
}

ArticleRepository는 JPA를 통해 데이터베이스와 의존하는 클래스이다. 이 역시 현재 구현되지 않아 컴파일에러가 발생할 것이다. ArticleService는 레포지토리를 참조하고 있으므로 데이터베이스에 의존하고 있다. 현재 테스트는 서비스만을 위한 단위테스트이므로 데이터베이스를 신경쓰고 싶지 않다. 외부의 의존을 분리하여 테스트할 수 있도록 해주는 것이 Mock 이다.

1) Mock을 사용하기 위해 추가해준다.
2) @Mock이나 @Spy 어노테이션이 붙은 mock 객체가 자신이 의존하는 클래스와 일치하면 주입시킨 후 mock 객체를 생성한다.
3) Mock 객체를 생성한다.
4) Mock을 사용한 테스트는 Given/When/Then형식으로 한다. 특히 Mock 테스트는 개발자가 테스트 시나리오를 설정해야 하므로 Given을 설정하는 것이 중요하다.

현재는 ArticleService.create() 메서드도 구현되어 있지 않고, ArticleRepository 역시 구현되어 있지 않아 테스트는 실패한다.

게시글 생성 Service 구현

@Service
public class ArticleService {
    private ArticleRepository articleRepository;

    public ArticleService(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @Transactional
    public ArticleResponseDto create(ArticleRequestDto articleRequestDto) {
        Article article = new Article(articleRequestDto.getAuthor(),
                articleRequestDto.getTitle(),
                articleRequestDto.getContents());

        Article savedArticle = articleRepository.save(article);

        return new ArticleResponseDto(savedArticle.getId(),
                savedArticle.getAuthor(),
                savedArticle.getTitle(),
                savedArticle.getContents());
    }
}

Service에서는 일반적으로 트랜잭션을 관리하고, 도메인(엔티티)를 조합하는 정도의 로직을 수행한다.

  • @Transactional: 선언된 메서드 범위로 트랜잭션을 설정한다.
    • 클래스위에 선언하면 모든 메서드에 적용된다.
    • 단순히 읽는 작업만 할 때는 @Transactional(readOnly = true)를 통해 좀 더 효율적으로 사용할 수 있다.

ArticleRepository 구현

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
}

JpaRepository는 스프링(Spring Data JPA)에서 제공하는 것으로 JPA를 매우 쉽게 사용할 수 있게 해준다. 선언은 위에서 볼 수 있듯이 JpaRepository<Entity 타입, ID 타입>이다. JpaRepository 내부에 @Repository 어노테이션이 선언되어 있으므로 이를 상속하는 인터페이스에서는 따로 명시할 필요는 없다.

JpaRepository를 사용하면 기본적으로 save(), findOne(), findAll(), delete(), count() 등을 제공한다. 그러므로 위처럼 아무 선언이 없어도 save()를 사용할 수 있다. 그리고 커스텀 메서드 역시 사용할 수 있는데, 이는 규칙이 있으므로 필요할 때 선언하여 사용할 수 있다.(IntelliJ Ultimate 버전의 경우 작성할 수 있는 메서드 규칙을 미리 보여준다.)

자세한 사용법은 링크를 참조하길 바란다.

테스트 성공

게시글 생성 기능 구현이 완료되었다. 이제 위에서 실패하던 ArticleServiceAticleApiController 테스트를 수행해보면 성공하는 모습을 볼 수 있을 것이다.

게시글 생성 기능 구조 살펴보기

지금까지 서버에서 동작하는 게시글 생성 API를 구현하였다. 이를 구현하면서 Controller, Service, Repository, DTO 등등 여러 클래스가 나왔고, 이들은 규칙에 따라 서로를 의존하고 있다. 이는 Spring MVC + Layered Architecture를 기반하여 구현한 결과이다.

구현 코드에 적용한 Spring MVC + Layered Architecture에 대해서는 제가 배웠던대로 설명할 예정입니다. 물론 틀린 부분이 있을 수 있습니다. 그리고 위 두 패턴뿐 아니라 DDD(Domain Driven Development) 개념도 적용되어 있습니다. 예를 들어, 서비스 계층은 예전 자료를 봤을 때 이 계층에서 비지니스 로직을 담당하라고 합니다. 하지만 DDD를 보면 서비스 계층은 최대한 얇게 유지하며, 비지니스 로직이 아닌 트랜잭션 관리와 도메인의 조합 정도를 담당하는 계층으로 설명합니다.

Web 애플리케이션에서 사용하는 레이어드 아키텍처는 위 그림처럼 4개의 레이어로 나눈다. 그림을 보면 의존성의 방향이 보이는데, 상대적으로 위 레이어가 아래의 레이어를 의존해야한다. 이는 게시글 생성 구현 코드에서도 볼 수 있었다.

@RestController
public class ArticleApiController {
    private ArticleService articleService;  // 의존(객체 참조)

    //...
}

ArticleApiController(프레젠테이션 레이어)는 ArticleService(애플리케이션 레이어)를 의존한다. 하지만 ArticleService에서 ArticleApiController를 의존해서는 안된다. 의존 방향이 아래로 가야하는 이유는 예를들어 프레젠테이션 레이어가 현재 웹이었다가 모바일로 변경된다고 했을 때, 애플리케이션부터 그 아래의 레이어에게는 영향을 미쳐서는 안된다. 즉, 변경 가능성을 최소화하기 위함이다. 정리하면 변경을 최소화하고, 재사용성을 증가시키 위해 레이어드 아키텍처를 사용한다고 볼 수 있다. 레이어드 아키텍처와 DDD는 OOP와 추구하는 방향이 같다.

프레젠테이션 레이어

프레젠테이션 레이어는 UI 레이어라고도 하며, 사용자의 요청을 받아 애플리케이션 레이어로 전달하고 처리 결과를 받아 다시 사용자에게 보여주는 역할을 한다. 보여주는 역할이라는 것은 View나 JSON과 같은 데이터를 보내주는 일을 말한다. 그 외에도 세션을 관리하는 등의 역할을 한다.

스프링에서는 프레젠테이션 레이어가 Spring MVC 구조로 구현되어 있다.

위 그림은 스프링 MVC 모델의 모습이다. 프론트 컨트롤 패턴(Front Control Pattern)에 따라 Dispatcher Servlet이 클라이언트의 모든 요청을 받은 후 적절한 처리를 수행한다.

Dispatcher Servlet, Handler Mapping, View Resolver 등은 스프링 프레임워크 내부에서 구현되어 있다.

애플리케이션 레이어

애플리케이션 레이어는 서비스 레이어라고도 하며, 프레젠테이션 레이어에서 전달받은 사용자의 요청을 처리한다. 이를 처리하기 위해 리포지토리로부터 도메인 객체를 가져오거나, 이를 사용한다. 중요한 점은 여기서 직접적인 로직을 수행하기 보다는 도메인 모델로 이를 위임해야 한다.

서비스 레이어는 단지 도메인 객체 간의 실행 흐름이나 도메인에서 발생한 이벤트를 처리한다. 그리고 하나의 요청은 여러 도메인 로직이 함께 실행되거나, 여러 번의 데이터베이스 접근이 있을 수 있다. 이러한 요청에 대한 신뢰성을 위해 트랜잭션을 사용해야 하는데, 서비스 레이어에서 트랜잭션의 범위를 설정한다.

도메인 레이어

도메인은 비즈니스에서 핵심 역할을 하는 것으로, 소프트웨어로 해결하고자 하는 문제 영역이다. 핵심 로직은 도메인 레이어에 위치해야 한다.

인프라스트럭처 레이어

인프라스트럭처 레이어는 구현 기술에 대한 것을 다룬다.

패키지 구조

레이어드 아키텍처는 패키지 구조로 구현된다. 게시글 생성 기능의 패키지 구조는 아래와 같다.

├── domain(도메인 레이어)
│   ├── Article.java
│   └── ArticleRepository.java
├── service(애플리케이션 레이어)
│   ├── ArticleService.java
│   └── dto
│       ├── ArticleRequestDto.java
│       └── ArticleResponseDto.java
└── web(프레젠테이션 레이어)
    └── controller
        └── ArticleApiController.java

한 가지 의아한 점은 데이터베이스와 의존하고 있는 Repository가 도메인 레이어에 있다는 것이다. 이는 JpaRepository 인터페이스를 사용하면서 내부에서 Article 도메인을 직접적으로 사용하고 있다. 따라서 도메인과 깊게 연관하고 있어 도메인 레이어에 있어야 한다고 생각한다.

게시글 조회, 수정, 삭제

지금까지 게시글 CRUD 중 C에 해당하는 생성을 구현하였다. C를 제외한 조회, 수정, 삭제를 구현하는 과정은 생성과 거의 비슷하므로 생략하도록 하겠다. 자세한 코드는 아래 링크에서 확인할 수 있다.

profile
https://parker1609.github.io/ 블로그 이전

0개의 댓글