본문 바로가기
개인 프로젝트/CRUD 웹 게시판 제작하기

[CRUD 웹 게시판 만들기] 2. 프로젝트 버전 변경, 글쓰기 기능 구현

by 카펀 2022. 2. 22.

버전 충돌 문제 해결, 모든 라이브러리 및 의존성 최신화

앞선 글에서, 그레이들의 버전을 6.7.1로 낮추어 기존의 build.gradle과 호환되도록 하였습니다.

그러나 스프링 부트 (2.1.9)와 그레이들의 버전이 꽤 오래 되어, 추가하려는 다른 의존성 등이 문제를 일으키는 경우가 있었습니다.

이를 GitHub의 issue로 등록해 두었습니다: https://github.com/kchung1995/CRUD-Web-Bulletin-Board/issues/3

 

JUnit5과 Gradle 6.7.1 호환 오류 · Issue #3 · kchung1995/CRUD-Web-Bulletin-Board

JUnit5와 Gradle 6.7.1이 호환되지 않는지, Could not complete execution for Gradle Test Executor 1 라는 에러가 뜸

github.com

 

어떻게 할까 고민해 보다가, 모든 라이브러리와 의존성을 최신으로 바꾸기로 결정했습니다.

이런 결정을 한 배경은 여러 가지가 있습니다.

  • 회사에서는 레거시 코드를 다루고 있어서, 토이 프로젝트에서는 최신 기술을 쓰고 싶은 열망
  • 정보를 직접 찾고, 시행 착오를 겪으며 하는 개발은 회사에서는 할 수 없음
  • 내 프로젝트 경험이 다른 사람에게 유용한 도움이 되었으면 하는 바람

이 글을 참고하여 build.gradle를 아예 새로 작성했습니다. 내용은 아래와 같습니다.

 

plugins {
    id 'org.springframework.boot' version '2.6.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    id 'eclipse'
}

group 'com.tistory.katfun'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {

    implementation('org.springframework.boot:spring-boot-starter-web')
    implementation('org.projectlombok:lombok')
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    implementation('com.h2database:h2')
    annotationProcessor('org.projectlombok:lombok')
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
}

test {
    useJUnitPlatform()
}

 

주요 변경점은 다음과 같습니다.

  1. plugins가 최상단에 위치하며, 플러그인 항목이 그 아래로 들어감
  2. dependencies 내에 compile 대신 implementation으로 작성
  3. lombok 의존성 추가 시 유의점

plugins가 최상단에 위치하는 것은 Gradle 7.0에서 요구하는 내용입니다.

내부에는 기존과 유사하게 Spring Boot 버전, spring dependency management, java, eclipse를 적습니다.

 

dependencies 내에는 compile 대신 implementation을 사용했습니다.

하는 역할은 같지만, 아래와 같은 차이점이 있습니다.

  • compile: 해당 의존성을 직/간접적으로 의존하고 있는 모든 의존성을 재빌드
  • implementation: 해당 의존성을 직접 의존하고 있는 모든 의존성만 재빌드

이에 따라 얻는 장점은 아래와 같습니다.

  • 빌드 속도가 빠르다: 직접 의존하고 있는 의존성만 재빌드하므로, 속도가 더 빠릅니다.
  • API의 노출을 막음: compile은 API가 노출됩니다. implementation은 필요한 API만 노출됩니다.

Gradle 7.0부터는 위의 이유로 compile이 deprecated 되었습니다 (링크). 따라서 implementation으로 옮겨 작성해 주었습니다.

 

testImplementation 내용 아래에 있는 exclude 내용은 JUnit 5 사용을 위한 것입니다.

Spring Boot에서는 JUnit 4가 기본적으로 내장되어 있지만, JUnit 5를 사용하기 위해서는 이를 비활성화 시켜야 합니다.

마찬가지 이유로, 아래 testRuntimeOnly... 쪽의 세 줄은 JUnit 5를 사용하기 위한 의존성입니다.

 

마지막으로, lombok 관련 설정입니다.

implementation에 lombok을 추가했지만, 그것만으로는 코드 실행 시 에러가 납니다.

이를 에러 메세지로 검색했더니, 아래 내용을 추가하면 된다는 방법을 알게 되었습니다 (링크1 ,링크2).

testAnnotationProcessor 'org.projectlombok:lombok'

이에 따라, 위 내용을 추가하여 정상적인 lombok 사용이 가능하도록 하였습니다.

 

위 내용을 전부 작성한 후, Gradle 빌드를 하여 프로젝트의 라이브러리 및 의존성을 모두 최신화 하였습니다.

 

TDD 실천

테스트 코드는 전에 웹 서비스 개발을 따라 하면서 몇 번 작성해 보았지만, TDD를 실천해 본 적은 없었습니다.

TDD에 대한 자세한 내용은 여기를 추천해 주세요: https://repo.yona.io/doortts/blog/issue/1

 

"TDD 실천법과 도구" 책 전체를 PDF 공개합니다.

2010년 6월에 출간되었던 "TDD 실천법과 도구" 책 전체를 PDF로 공개합니다. 책소개: http://naver.me/GaYZCDjD Updated --- - [1장 - 테스트주도개발 Test Driven Development](https://repo.yona.io/doortts/blog/issue/2) - 18.07.18 -

repo.yona.io

 

제가 실천한 TDD는 처음이고, 시도하는 단계이기 때문에 거창하지 않습니다.

개발할 내용을 생각해서 먼저 테스트 코드를 작성하고, 이후에 테스트 코드에 들어맞는 코드를 작성하는 순서를 거쳤습니다.

 

PostsRepositoryTest에는 처음에 한 개의 테스트 메소드를 작성하였습니다.

 

@Test
public void 게시글저장_불러오기() {
    //given
    String title = "test post";
    String content = "test title";

    postsRepository.save(Posts.builder()
            .title(title)
            .content(content)
             .author("kchung1995@gmail.com")
            .build());

    //when
    List<Posts> postsList = postsRepository.findAll();

    //then
    Posts posts = postsList.get(0);
    assertEquals(title, posts.getTitle());
    assertEquals(content, posts.getContent());
}

 

given에는 테스트의 초기 조건을 작성했습니다.

글 제목이 "test post", 글 내용이 "test title"이 된 (이상한 모양새...) 상태입니다.

이를 postRepository.save를 이용해 title, post, "kchung1995@gmail.com"으로 저장하도록 합니다.

 

when에는 실제 코드가 호출되는 경우를 작성하였습니다.

 

then은 기대되는 액션을 작성했습니다.

Posts에 postsList의 내용을 받아 오고, 저장된 title과 content가 given에서 작성했던 내용과 맞는지 assertEquals를 통해 비교합니다.

성공하면 test success, 실패하면 test failed를 얻게 됩니다.

JUnit4에서 사용되던 assertThat과 JUnit5에서 사용되는 assertEquals에 대한 내용은 이 글을 참고해 주세요.

 

후술하겠지만 assertEquals는 (expected, actual) 순서로 인자를 넣어 주면 됩니다.

이게 잘못될 경우 테스트가 실패할 때 결과를 반대로 보여줍니다.

 

이후 이에 맞는 Posts, PostsRepository를 작성하였습니다.

 

public class Posts extends BaseTimeEntity {

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

    @Column(length = 500, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

Posts 내용입니다. Spring Data JPA를 사용하였습니다.

각 column은 테이블의 칼럼을 의미합니다. 글의 제목, 내용, 작성자를 나타내는 항목이 각각 존재합니다.

builder는 테이블의 한 tuple을 만들어 (build) 줍니다.

 

public interface PostsRepository extends JpaRepository<Posts, Long> {
}

PostsRepository는 Posts가 데이터베이스에 접근할 수 있도록 해 줍니다.

지금으로써는 JpaRepository를 상속받기만 하면 됩니다.

 

테스트가 성공적으로 동작하는 것을 확인할 수 있네요 ㅎㅎ

 

이후 글 내용 조회, 수정하는 내용 역시 추가하였습니다.

수정하는 내용은 마찬가지로 테스트 코드를 먼저 작성하고, 이어서 본 코드를 작성하였습니다..

글 등록과 수정을 위한 API입니다.

 

둘 다 잘 성공합니다.

 

마지막으로 JPA Audit을 이용한 최초 작성일 / 마지막 수정일 등록을 자동화 하는 코드를 작성하였습니다.

 

테스트 코드를 잘 통과하는 모습입니다.

 

그렇다면 모든 코드가 정상적으로 잘 작동할까요? Gradle의 테스트를 통해 전체 테스트를 진행해 보았습니다.

 

테스트가 실패했습니다.

위 에러를 보면 Expected: title, Actual: title2라고 하는데, 사실 반대여야 하죠.

앞서 언급한 대로 제가 assertEquals의 인자를 반대로 적어서 발생한 일입니다.

하지만 테스트의 성공/실패 여부와는 무관합니다.

 

PostsApiControllerTest에는 두 개의 테스트를 작성했는데요.

 

public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private PostsRepository postsRepository;
    @Autowired
    private TestRestTemplate restTemplate;


    @Test
    public void Posts_Registered() throws Exception {
        //given
        String title = "title";
        String content = "content";
        String author = "kchung1995@gmail.com";

        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertEquals(responseEntity.getStatusCode(), HttpStatus.OK);
        Assertions.assertTrue(responseEntity.getBody() > 0L);

        List<Posts> all = postsRepository.findAll();
        assertEquals(title, all.get(0).getTitle());
        assertEquals(content, all.get(0).getContent());
    }

    @Test
    public void Posts_edited() throws Exception {
        //given

        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());
        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<PostsUpdateRequestDto>(requestDto);

        //when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(
                url, HttpMethod.PUT, requestEntity, Long.class);

        //then
        assertEquals(responseEntity.getStatusCode(), HttpStatus.OK);
        Assertions.assertTrue(responseEntity.getBody() > 0L);

        List<Posts> all = postsRepository.findAll();
        assertEquals(expectedTitle, all.get(0).getTitle());
        assertEquals(expectedContent, all.get(0).getContent());
    }
}

 

위 테스트를 각자 실행하는 경우 모두 성공하지만, 함께 실행하는 경우 문제가 됩니다.

앞서 Posts_registered()에서 생성한 Posts는 title = "title", content = "content"인 반면,

Posts_edited()에서 생성한 Posts는 Posts는 title = "title" -> "title2", content = "content" -> "content2"로 업데이트 되었는데요.

앞에서 생성한 Posts(0)의 내용이 그대로 남아 있고, edited에서는 Posts(1)을 생성한 후 Posts(0)을 참조하기 때문에 생기는 문제입니다.

 

따라서 테스트 이후에는 생성한 Posts를 삭제하도록 코드를 작성하고, 이를 수행하도록 한 줄씩 추가해 주었습니다.

 

@Transactional
public void delete (Long id) {
    Posts posts = postsRepository.findById(id).orElseThrow(() -> IllegalArgumentException("해당 게시글이 존재하지 않습니다.\n게시글 ID: " + id));
    postsRepository.delete(posts);
}

PostsService에는 위 내용을 추가하고,

 

@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
    postsService.delete(id);
    return id;
}

PostsApiController에는 위 내용을 추가하여 삭제 기능을 추가하였습니다.

 

...
@Autowired
private PostsApiController postsApiController;

...
postsApiController.delete(all.get(0).getId());

 

PostsApiController를 사용할 수 있도록 추가해 주고, posts 등록과 수정 테스트 메소드의 맨 마지막에 값 삭제를 위한 코드를 추가해 주었습니다.

 

이번엔 모든 테스트를 통과했습니다!

이렇듯 단위 테스트가 모든 기능의 정상 동작을 보장해 주지는 않습니다. 테스트 코드를 제대로 작성하는 것이 중요한 이유이기도 합니다.

위 문제를 GitHub의 Issues에 문서화 하여 남겨 두었습니다.

다음 개발 계획

Log4j2를 도입하고, 클라스 내에 로깅 관련 내용을 추가해 볼 예정입니다.

Posts 관련 내용의 고도화는 나중에 추가할 계획입니다.

Vue.js의 기초를 공부하고, 이를 이용하여 게시판의 디자인을 작성해 보려고 합니다.

댓글