이 포스팅을 쓴 목적은
DTO의 구조와 API 호출을 파악하기 위함입니다.
(넘나리 어려움)
예전에 했던 건데 왜 봐도봐도 새롭지;;;
(익숙해 질 때까지 반복만이 살 길이다.)
이번 시리즈의 목표는 기본적인 게시판 CRUD 동작 과정을 완벽히(?) 이해하는 것입니다!
✔️ 요구사항 분석
- 게시판 기능
- 게시글 조회
- 게시글 등록
- 게시글 수정
- 게시글 삭제
✔️ 게시글 등록
1. domain 패키지를 만든다.
도메인이란, 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이다.
2. domian 패키지에 posts 패키지와 Post 클래스를 만든다.
이렇게 패키지 구조가 나옵니다.
저는 저렇게 계층 구조로 나타내는 게 편하더라구요!
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;
}
}
- @Entity
- 테이블과 링크될 클래스임을 나타낸다.
- 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
- ex) SalesManager.java → sales_manager 테이블
- @Id
- 해당 테이블의 PK(Primary Key) 필드를 나타낸다.
- @GenerateValue
- PK의 생성 규칙을 나타낸다.
- 스프링 부트 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.
- @Column
- 테이블의 칼럼을 나타내며, 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 된다.
- 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.
- 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고 싶거나 등의 경우에 사용된다.
- @NoArgsConstructor
- 기본 생성자를 자동으로 추가해준다.
- 위의 코드에선, public Posts() {}와 같은 효과이다.
- @Getter
- 클래스 내 모든 필드의 Getter 메소드를 자동생성한다.
- @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);
}
}
- @AfterEach
- Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정한다.
- 보통은 배포 전 전체 테스트를 수행할 때, 테스트간 데이터 침범을 막기 위해 사용한다.
- 여러 테스트가 동시에 수행되면, 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있어, 다음 테스트 실행 시 테스트가 실패할 수 있다.
- postRepository.save
- 테이블 posts에 insert/update 쿼리를 실행한다.
- id 값이 있다면 update가, 없다면 insert 쿼리가 실행된다.
- 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
- 자세한 포스팅은 여기에 올려놨다.
그럼 이제 등록, 수정, 삭제 기능을 만들어보자.
먼저
- web 패키지를 생성하고
- web 패키지에 PostsApiController를 생성하고
- web/dto 패키지에 PostsSaveRequestDto를 생성하고
- 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);
}
}
- @RequiredArgsConstructor
- 선언된 모든 final 필드가 포함된 생성자를 생성해 준다.
- final이 없는 필드는 생성자에 포함되지 않는다.
- 💡 스프링에선 Bean을 주입받는 방식이 @Autowired, setter, 생성자 3가지가 있다.
- 이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다.
- 즉, 생성자로 Bean 객체를 받도록 하면, @Autowired와 동일한 효과이다.
- 그럼 여기서 생성자는 어디있을까?
- 바로 @RequiredArgsConstructor에서 해결해준다.
- 생성자 직접 안 쓰고 롬복 쓰는 이유는?
- 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움 해결!
- @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 되었다!
수정 / 조회 / 삭제는 다음 포스팅에...
'프로젝트' 카테고리의 다른 글
게시물 조회수 구현, @Query (0) | 2023.02.17 |
---|---|
CRUD를 분석해보자 (2) - 게시글 수정/삭제/조회 API (0) | 2023.02.11 |
Spring Security를 이용하여 로그인을 구현해보자 (0) | 2023.01.26 |
'도대체 DTO를 왜 만드는 것인가'에 대한 고찰 (2) | 2023.01.25 |
[예외 처리] 회원가입 Custom Exception을 해보자 (0) | 2023.01.19 |