프로젝트

CRUD를 분석해보자 (1) - 게시글 생성 API

ummchicken 2023. 2. 11. 01:08

이 포스팅을 쓴 목적은
DTO의 구조와 API 호출을 파악하기 위함입니다.
(넘나리 어려움)

예전에 했던 건데 왜 봐도봐도 새롭지;;;
(익숙해 질 때까지 반복만이 살 길이다.)


이번 시리즈의 목표는 기본적인 게시판 CRUD 동작 과정을 완벽히(?) 이해하는 것입니다!

✔️ 요구사항 분석

  • 게시판 기능
    • 게시글 조회
    • 게시글 등록
    • 게시글 수정
    • 게시글 삭제




 

✔️ 게시글 등록

 

1. domain 패키지를 만든다.

도메인이란, 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이다.

 

2. domian 패키지에 posts 패키지와 Post 클래스를 만든다.

posts 패키지와 클래스

이렇게 패키지 구조가 나옵니다.

저는 저렇게 계층 구조로 나타내는 게 편하더라구요!

src/main/java/com/testbook/domain/posts/... 이런 구조가 생성된 겁니다.


다음은

3. Posts 클래스 작성하기

[Posts.java]

@Getter // 6
@NoArgsConstructor // 5
@Entity // 1
public class Posts {

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

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

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

    private String author;

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

}
  1. @Entity
    • 테이블과 링크될 클래스임을 나타낸다.
    • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
    • ex) SalesManager.java → sales_manager 테이블
  2. @Id
    • 해당 테이블의 PK(Primary Key) 필드를 나타낸다.
  3. @GenerateValue
    • PK의 생성 규칙을 나타낸다.
    • 스프링 부트 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.
  4. @Column
    • 테이블의 칼럼을 나타내며, 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 된다.
    • 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.
    • 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고 싶거나 등의 경우에 사용된다.
  5. @NoArgsConstructor
    • 기본 생성자를 자동으로 추가해준다.
    • 위의 코드에선, public Posts() {}와 같은 효과이다.
  6. @Getter
    • 클래스 내 모든 필드의 Getter 메소드를 자동생성한다.
  7. @Builder
    • 해당 클래스의 빌더 패턴 클래스를 생성한다.
    • 생성자 상단에 선언 시 생성자에 포함된 빌더에 포함한다.



이 Posts 클래스에는 한 가지 특이점이 있다.
🚨 Setter 메소드가 없다!

이유가 뭘까?
💡 getter/setter를 무작정 생성하는 경우엔,
해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어,
차후 기능 변경 시 정말 복잡해진다!

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



🚨 또한 Setter가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입(insert)할까?
💡 @Builder를 통해 제공되는 빌더 클래스를 사용한다!

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

- 생성자나 빌더나 생성 시점에 값을 채워주는 역할을 똑같다.
다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수 없다.
하지만 빌더를 사용하게 되면, 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있다!



이제 Posts 클래스 생성이 끝났으니 ,
Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성한다.

4. PostRepository(JpaRepository) 생성

[PostRepository.java]

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



이제 테스트를 해보자.

5. save, findAll 기능 테스트

[PostsRepositoryTest]

@SpringBootTest
class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @AfterEach // 1
    public void cleanUp() {
        postsRepository.deleteAll();
    }

    @Test
    public void 게시글저장_불러오기() {
        // given
        String title = "테스트 게시글";
        String content = "테스트 본문";
		
        postsRepository.save(Posts.builder()  // 2
                .title(title)
                .content(content)
                .author("ummchicken")
                .build());

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

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

}
  1. @AfterEach
    • Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정한다.
    • 보통은 배포 전 전체 테스트를 수행할 때, 테스트간 데이터 침범을 막기 위해 사용한다.
    • 여러 테스트가 동시에 수행되면, 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있어, 다음 테스트 실행 시 테스트가 실패할 수 있다.
  2. postRepository.save
    • 테이블 posts에 insert/update 쿼리를 실행한다.
    • id 값이 있다면 update가, 없다면 insert 쿼리가 실행된다.
  3. postRepository.findAll
    • 테이블 posts에 있는 모든 데이터를 조회해오는 메소드이다.



💡 별다른 설정 없이 @SpringBootTest를 사용할 경우, H2 데이터베이스를 자동으로 실행해준다.

테스트에 성공한다.


❓ 그럼 실제로 실행된 쿼리는 뭘까?

→ id bigint generated by defailt as identity : H2 쿼리 문법

→ 내 뇌피셜론, 첫 번째 select 쿼리는 assertThat(posts.getTitle()).isEqualTo(title); 때문에 나간 것 같고,
→ 두 번째 select 쿼리는 assertThat(posts.getContent()).isEqualTo(content); 때문에 나간 것 같다.
→ 마지막 delete 쿼리는 @AfterEach 안의 postsRepository.deleteAll(); 때문에 나간 게 아닐까.

아닐 시 ㅈㅅ;;;


JPA와 H2에 대한 기본적인 기능과 설정을 진행했으니,
이제 본격적으로 API를 만들어보자.

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

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

  • Request 데이터를 받을 DTO
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
    • 자세한 포스팅은 여기에 올려놨다.



그럼 이제 등록, 수정, 삭제 기능을 만들어보자.

먼저

  1. web 패키지를 생성하고
  2. web 패키지에 PostsApiController를 생성하고
  3. web/dto 패키지에 PostsSaveRequestDto를 생성하고
  4. service/posts 패키지에 PostsService를 생성한다.



[PostsApiController]

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

}
  1. @RequiredArgsConstructor
    • 선언된 모든 final 필드가 포함된 생성자를 생성해 준다.
    • final이 없는 필드는 생성자에 포함되지 않는다.
    • 💡 스프링에선 Bean을 주입받는 방식이 @Autowired, setter, 생성자 3가지가 있다.
      • 이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다.
      • 즉, 생성자로 Bean 객체를 받도록 하면, @Autowired와 동일한 효과이다.
      • 그럼 여기서 생성자는 어디있을까?
        • 바로 @RequiredArgsConstructor에서 해결해준다.
      • 생성자 직접 안 쓰고 롬복 쓰는 이유는?
        • 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움 해결!
  2. @RequestBody
    • 스프링으로 Rest Api를 개발할 때, 클라이언트와 데이터를 JSON으로 주고받고 한다.
    • 이때 사용되는 어노테이션 중 하나가 @RequestBody이다.
    • 클라이언트측에서 요청 데이터를 body에 담고 content-type을 application/json으로 설정해줘야 동작된다.



다음은 PostsService를 작성해보자.
[PostsService]

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto postsSaveRequestDto) {

        return postsRepository.save(postsSaveRequestDto.toEntity()).getId();
    }

}




이제 Controller와 Service에서 사용할 Dto 클래스를 생성해보자.
[PostsSaveRequestDto]

@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();
    }
    
}
  • 여기서 Posts Entity와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성했다.
  • 🚨 절대로 Entity 클래스를 Request/Response 클래스로 사용하면 안 된다.
    • Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.
    • 즉, View Layer와 DB Layer의 역할 분리를 철저히 해라.
  • 또한 Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하다.
    • Entity 클래스만으로 표현하기 어렵다.
    • 💡 꼭 Entity 클래스와 Controller에서 쓸 Dto는 분리해라!




이제 테스트 코드를 작성해보자.
[PostsApiControllerTest]

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

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

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

    @Test
    public void Posts_등록된다() throws Exception {
        // given
        String title = "title";
        String content = "content";

        PostsSaveRequestDto postsSaveRequestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("ummchicken")
                .build();

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

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

        // then
        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);
    }

}



❓ 그럼 실제로 실행된 쿼리는 뭘까?

→ posts 테이블 create 쿼리

→ insert 쿼리가 나간다!


7. Postman으로 API 보내보기

  • post : http://localhost:8080/api/v1/posts
  • body : json
{
  "title":"test title",
  "content":"test content",
  "author":"ummchichen"
}

담아서 보내본다.

  • 결과

→ Controller에서 저장된 PostsSaveRequestDto의 id를 반환하도록 했으므로, 1이 반환된다.

  • DB

→ 정상적으로 insert 되었다!







수정 / 조회 / 삭제는 다음 포스팅에...