[Spring] 게시판 만들기 (1)

10 분 소요

이 게시물은 이동욱님의 “스프링 부트와 AWS로 혼자 구현하는 웹 서비스”를 읽고 공부하며 작성한 게시물입니다.

시작하며…

이 책은 3장부터 6장까지는 하나의 게시판을 만들어보고 7장부터 10장까지는 이 서비스를 AWS에 무중단 배포하는것 까지를 진행한다고 한다.

게시판 기능은 CRUD, Create, Read, Update, Delete가 가능하게 함으로써, 데이터베이스를 사용하기 위한 기초적인 4가지의 쿼리 형식을 배울 수 있도록 만들어 볼 것 이다.

요구사항 분석

먼저 프로그램을 만들기 전에 어떤 기능들을 추가할지 간단하게 README 파일에 기능별로 정리해준다. 이렇게 하면 어떤걸 개발하여야 할지 망설이지 않아도 되며, 기능 별로 커밋하여 버젼관리를 용이하게 할 수 있다.

구현해야할 기능 목록을 대략 정리해보면, 다음과 같다.

  • 게시판 기능
    • 게시글 조회
    • 게시글 등록
    • 게시글 수정
    • 게시글 삭제
  • 회원기능
    • 구글/네이버 로그인
    • 로그인한 사용자 글 작성 권한
    • 본인 작성 글에 대한 권한 관리

의존성 추가

게시판 기능은 게시글이나 유저에 대한 데이터베이스가 필요하기 때문에 이를 사용하기 위해서 데이터베이스를 설정해주어야 한다. 이 책에서는 JPA를 사용하고 있다. 따라서 스프링 부트용 JPA 라이브러리와 h2의 의존성을 추가해주어야 한다.

build.gradle 파일의 dependencies 부분에 다음과 같은 코드를 추가해준다.

1
2
3
4
5
dependencies {
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    implementation('com.h2database:h2')

}

spring-boot-starter-data-jpa

  • 스프링 부트용 Spring Data Jpa 추상화 라이브러리
  • 스프링 부트 버전에 맞추어 자동으로 JPA관련 라이브러리들의 버전을 관리해 준다.

h2

  • 인메모리 관계형 데이터베이스
  • 별도의 설치 없이 프로젝트 의존성만으로 관리가 가능하다.
  • 메모리에서 실행되기 떄문에 애플리케이션을 재시작할 때마다 초기화 된다는 점을 이용하여 테스트 용도로 많이 사용된다.
  • 이 책에서는 JPA테스트 및 로컬 환경에서의 구동에서 사용된다.

Posts 클래스

게시물들을 담당하는 Posts 클래스를 만들어보자.

먼저 Posts가 위치할 패키지 구조를 만들어주어야 한다. domain이라는 패키지를 만들어주고, 그 안에 posts라는 패키지를 만들어 그 안에 Posts클래스가 위치하게 될 것이다.

domain 패키지는 말그대로 도메인을 담을 패키지인데, 여기서 도메인이란 게시글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역이라고 생각하면 된다.

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

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
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @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;
    }
}

@Entity

  • 테이블과 링크될 클래스임을 나타낸다.
  • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
    ex) SalesManager.java -> salse_manager table

  • 웬만하면 Entity의 PrimaryKey는 Long 타입의 Auto_increment를 사용하는 것이 좋다. (이렇게 하면 MySQL 기준으로는 bigint 타입이 된다.) 주민등록 번호와 같이 비즈니스상 유니크 키나, 여러키를 조합한 복합키로 PrimaryKey를 설정할 경우 난감한 상황이 종종 발생 하게 된다.
    (1) FK(Foreign Key)를 맺을 떄 다른 테이블에서 복합키 전부를 갖고 있거나, 중간 테이블을 하나 더 둬야 하는 상황이 발생한다.
    (2) 인덱스에 좋은 영향을 끼치지 못한다.
    (3) 유니크한 조건이 변경될 경우 PK 전체를 수정해야 하는 일이 발생한다. 주민번호나 복합키 등은 유니크 키로 별도로 추가하는 것이 좋다.

@Id

  • 해당 테이블의 Primary Key 필드를 나타낸다.

@GeneratedValue

  • PK의 생성규칙을 나타낸다.
  • 스프링 부트 2.0 에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.

@Column

  • 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 되지만, 기본값외에 추가로 변경이 필요한 옵션이 있다면 사용한다.
  • 문자열의 경우 VARCHAR(255)가 기본값인데, 위처럼 length나 타입을 변경하고 싶다면 Column을 선언하여 변경할 수 있다.

이 Posts 클래스에는 한가지 특징이 있는데, 바로 Setter 메소드가 없다는 것이다. 자바빈 규약을 생각하면서 getter/setter를 무작정 생성부터 하고보는 경우가 있는데, 이렇게 하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수가 없어, 차후 기능 변경이 정말 복잡해진다.

그래서 Entity 클래스에서는 절대로 Setter 메소드를 만들지 않는다 대신, 해당 필드의 값 변경이 필요하다면 명확히 목적과 의도가 나타나는 메소드를 추가해야하만 한다.

예를들어 주문을 취소하는 메서드를 만든다고 가정하면 setter를 사용했다면 아래와 같이,

1
2
3
4
5
6
7
8
9
public class Order {
    public void setStatus(boolean status) {
        this.status = status
    }
}

public void 주문서비스의_취소이벤트() {
    order.setStatus(false);
}

setter 메서드를 사용하는게 아니라

목적과 의도가 명확한 방식인

1
2
3
4
5
6
7
8
9
public class Order {
    public void cancleOrder() {
        this.status = false
    }
}

public void 주문서비스의_취소이벤트() {
    order.cancleOrder();
}

위와 같은 메서드를 사용해 주어야 한다.

그렇다면 Setter가 없다면 어떻게 값을 채워 DB에 삽입해야 할까?

기본적인 구조는 생성자를 통해서 최종값을 채우고 DB에 삽입하는 것이며, 값 변경이 필요한 경우에만 해당 이벤트에 맞는 pulic 메소드를 호출하여 변경하는 것을 전제로 한다.

이 책에서는 생성자 대신에 @Builder를 사용하게 될텐데, 생성자와 빌더 모두 생성 시점에 값을 채워주는 역할은 똑같지만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없다.

예를들어, 다음과 같은 생성자가 있다고 가정하자.

1
2
3
4
public Example(String a, String b) {
    this.a = a;
    this.b = b;
}

이 경우에는 만약 개발자가 new Example(b,a)와 같이 a와 b의 위치가 변경해도 코드가 실행되기 전까지는 문제를 찾을 수가 없다

하지만 @Builder를 사용하면 다음과 같이 어느 필드에 어떤 값을 채워야 할지 명확하게 인지가 가능하다.

1
2
3
4
Example.builder()
    .a(a)
    .b(b)
    .build();

이렇게 .a(), .b()와 같이 어느 필드에 값을 채우는지를 정확하게 명시하기 떄문에 훨씬 더 명확해짐을 알 수 있다.

그럼 이제, Posts 클래스로 Database를 접근하게 해줄 JpaRepository인 PostsRepository를 생성해주자.

PostsRepository

동일하게 posts 패키지에 PostsRepository라는 이름의 인터페이스를 하나 생성해주고 다음과 같은 코드를 입력해준다.

1
2
3
4
5
import org.springframework.data.jpa.repository.JpaRepository;

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

생성된 interface인 PostsRepository에 JpaRepository <Entity 클래스, PK 타입>를 상속하면, @Repository를 추가할 필요도 없이 기본적인 CRUD 메소드가 자동으로 생성된다.

여기서 주의해야할 점은 Entity 클래스와 기본 EntityRepository는 함께 위치해야한다는 점이다. 둘은 아주 밀접하고 Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수 없기 때문이다.

만약 프로젝트 규모가 커져서 도메인별로 프로젝트를 분리해야 한다면 Entity 클래스와 기본 Repository는 함께 움직여야 하므로 도메인 패키지에서 함께 관리한다.

Spring Data JPA 테스트 코드

만들어준 postsRepository를 테스트 해보기위해 테스트 코드를 작성해보자. 테스트 코드에서는 save와 findAll 기능을 테스트 할 것 이다.

test 폴더에 동일하게 domain.posts패키지를 만들어주고, PostsRepositoryTest라는 테스트 클래스를 만들어준다.

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
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

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

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @After
    public void CleanUp() {
        postsRepository.deleteAll();
    }

    @Test
    public void 게시글저장_불러오기() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("lmj938@naver.com")
                .build());

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

        //then
        Posts posts = postsList.get(0);

        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}

@After

  • Junit에서 단위 테스트가 끝날 때마다 수행되는 메서드를 지정
  • 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용한다.
  • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있다.

@postsRepository.save

  • 테이블 posts에 insert/update 쿼리를 실행한다.
  • id 값이 있다면 update가 실행되고, 없다면 insert 쿼리가 실행된다.

postsRepository.findAll

  • 테이블 posts에 있는 모든 데이터를 조회해오는 메서드이다.

별다른 설정 없이 @SpringBootTest를 사용해주면 H2 데이터베이스를 자동으로 실행해 준다.

테스트 코드를 실행시키면, Tests passed와 함께 테스트가 올바르게 통과 했음을 알 수 있다.

실제 실행된 쿼리 보기

테스트 코드로 쿼리가 올바르게 실행됐는지를 보는것이 아니라 실제로 실행된 쿼리를 보고 싶다면 어떻게 해야할까?

자바 클래스로도 이러한 기능을 구현할 수 있지만, 스프링 부트 자체에서는 application.properties나 application.yml 등의 파일로 한줄의 코드로 설정할 수 있으니 이를 사용하는게 훨씬 편리하다.

먼저, src/main/resources 디렉토리 아래에 application.properties 파일을 생성해보자.

생성한 파일에 아래와 같은 옵션을 추가해 준뒤, 다시 postsRepositoryTest를 실행해준다.

1
spring.jpa.show_sql = true

그렇게 하면 다음과 같이 콘솔에서 쿼리로그를 확인 할 수 있다.

1
2
3
4
5
6
7
8
Hibernate: drop table posts if exists
Hibernate: create table posts (id bigint generated by default as identity, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id))

Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: delete from posts where id=?

Hibernate: drop table posts if exists

여기서 create table의 쿼리를 보면 id bigint generated by default as identity라는 옵션으로 생성되는데, 이는 H2의 쿼리 문법이 적용되었기 떄문이다. H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 때문에, 이후 디버깅을 위해 출력되는 쿼리 로그를 MySQL 버전으로 변경해보자.

이 옵션도 마찬가지로 application.properties레서 옵션을 추가해 설정이 가능하다.

1
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect

다시 테스트 코드를 run하면,

1
Hibernate: create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB

다음과 같이 옵션이 잘 적용되어 id bigint not null auto_increment로 바뀐 것을 확인 할 수 있다.

등록/수정/조회 API 만들기

API를 만들기 위해 총 3개의 클래스가 필요하다.

  • Request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능간의 순서를 보장하는 Service

여기서 많은 사람들이 오해하는 것이, Service에서 비지니스 로직을 처리해야 한다는 것이다. 하지만, Service는 트랜잭션, 도메인 간의 순서 보장만 할뿐, 비즈니스 로직을 처리하지는 않는다.

비즈니스 로직의 처리를 담당해야 할 곳은 Service가 아닌 바로 Domain이다.

Domain에서 비즈니스로직을 처리하고, Service에서는 이를 사용해 트랜잭션과 도메인간의 순서를 보장해주는 역할 만을 담당 하는 것이다.

그럼 이를 바탕으로 등록, 수정, 삭제 기능을 만들어보자.

web 패키지를 만들어 PostsApiController를 만들어주고,
web.dto 패키지에 PostsSaveRequestDto를,
service 패키지를 만든 뒤, service.posts 패키지를 만들어 PostsService를 만들어준다.

PostApiController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import springboot.service.posts.PostsService;
import springboot.web.dto.PostsSaveRequestDto;

@RequiredArgsConstructor
@RestController
public class PostsApiController {
    
    private final PostsService postsService;
    
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}

PostsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import springboot.domain.posts.PostsRepository;
import springboot.web.dto.PostsSaveRequestDto;

import javax.transaction.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

위 코드를 보면 Controller와 Service에서 @Autowired 없이 Bean을 주입 받았는데, 스프링에선 Bean을 주입받는 방식들이 몇가지 있는데,

  • @Autowired
  • setter
  • 생성자

이렇게 세가지로 Bean을 주입받을 수 있다. 이 중 가장 권장되는 방법은 생성자로 주입받는 방식인데, 위 코드에서도 @RequiredArgsConstructor을 사용하여 생성자를 만들어 주입받은 걸 알 수 있다.

생성자를 직접 작성하지 않고 롬복 어노테이션을 사용하는 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해서다.

PostsSaveRequestDto

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
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import springboot.domain.posts.Posts;

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
    private String title;
    private String content;
    private String author;

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

    public Posts toEntity() {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

여기서는 Entity 클래스와 거의 같은 코드임에도 Dto 클래스를 추가로 생성해주었다. Entity클래스는 데이터베이스와 맞닿아 있는 핵심 클래스이기 때문에 Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다. 따라서 화면 변경은 아주 사소한 변경이기 때문에 이를 위해서 Entity 클래스를 변경하는 것은 너무나 큰 변경이다. 따라서 Entity 클래스를 절대로 Request/Response 클래스로 사용해서는 안된다.

이처럼 View Layer와 DB Layer는 역할 분리를 철저하게 해주는 것이 좋은데, 실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야하는 경우가 많기 때문에 Entity 클래스만으로 표현하기가 어려울 때가 많다. 하지만 그럼에도 꼭 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해주어야 한다.

그럼 이렇게 만들어준 PostsApiController를 테스트 하는 PostsApiControllerTest를 작성해보자.

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
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;
import springboot.domain.posts.Posts;
import springboot.domain.posts.PostsRepository;
import springboot.web.dto.PostsSaveRequestDto;

import java.util.List;

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

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception {
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto
                .builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

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

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

        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

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

ApiController를 테스트 하는데 HelloController와 다르게 @WebMvcTest를 사용하지 않았다. @WenMvcTest의 경우 JPA 기능이 작동하지 않기 때문인데, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 된다.

이 테스트를 수행해 보면 아래와 같이

1
2
3
 INFO 87751 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 49982 (http) with context path ''
 
 Hibernate: insert into posts (author, content, title) values (?, ?, ?)

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 모두 실행된 것을 확인했다.

이렇게 하면 게시판 기능의 등록 기능의 구현이 완료되었음을 테스트 코드로 확인할 수 있다.

카테고리:

업데이트:

댓글남기기