안녕하세요. 몇달전에 썻던 Board 게시판 예제를 이용하여 rest api를 작성했던 포스팅에 이어 두번째 더욱 restful api 개발 예제를 작성해보려고 합니다.
최근 백기선님의 REST API 강의를 보고 많은 영감을 받았으며 해당 강의를 수강하면서 얻은 저의 영감..? 을 공유해드리기 위해 또한 기선님의 강의내용에 대한 스포가 존재할까 염려하여 전혀 다른 저만의 코딩스타일로 진행하고자하니 이점 유의해주시기 바랍니다.
모든 소스코드는 여기에서 확인하실 수 있습니다. git branch 별로 stage를 나눠놓았으니 확인하실 수 있으실 겁니다.
더욱 RESTFul한 API를 제작하는데 들어가는 스펙은 아래와 같습니다.
- Spring Boot 2.3.4 - Java8 - gradle - Spring Data JPA - JUnit 5.x - H2 Database - Spring Hateoas - Spring Rest Docs |
* 향후 포스팅을 하면서 몇가지 소프트웨어나 개발 스펙이 추가될 수 있습니다.
가장 기본적인, 흔히 REST API라고 불리는 API
프로젝트 디렉토리 구조
가장 기본적인 디렉토리 구조로 대부분의 Java/Spring 개발을 하셨던 분들은 익숙하게 접하셨을 키워드들입니다.
main 디렉토리
- 각각의 Controller 별로 controller 라는 패키지에 담을 것입니다.
- 마찬가지로 각각의 service, repository 계층에 속하는 클래스들을 패키지에 담을 것입니다.
- param은 Add와 Edit의 형태가 아래의 예제를 쭉 이어가신다면 아시겠지만 비슷한 형태입니다. 하지만 이를 분리하여 클래스를 만들었습니다.
- entity 클래스는 JPA 계층 클래스입니다. 실질적인 Database의 테이블을 스펙을 구현하는 클래스입니다.
test 디렉토리
- 처음에는 기본 Board에 대한 테스트만 진행하였습니다. stage1이기 때문에 간단히 TestBoardController에 대한 테스트만 진행하였습니다.
- 포스팅이 진행될 수록 변화되는 모습을 확인하시면 좋을 것 같습니다.
소스리뷰
build.gradle
plugins {
id 'org.springframework.boot' version '2.3.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'org.asciidoctor.convert' version '1.5.8'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('snippetsDir', file("build/generated-snippets"))
set('vaadinVersion', "14.3.7")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.vaadin:vaadin-spring-boot-starter'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-api'
}
dependencyManagement {
imports {
mavenBom "com.vaadin:vaadin-bom:${vaadinVersion}"
}
}
test {
outputs.dir snippetsDir
useJUnitPlatform()
}
asciidoctor {
inputs.dir snippetsDir
dependsOn test
}
먼저 TDD를 지향하기 때문에 Test 코드를 먼저 리뷰하도록 하겠습니다. 현재 단계에서 Spring Rest Docs 관련 API 문서 제작 프로그래밍 코드는 포함하지 않습니다.
TestBoardController
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest
public class TestBoardController {
private final String BASE_URL = "/boards";
private MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Autowired
BoardRepository boardRepository;
@Autowired
WebApplicationContext ctx;
@BeforeEach
public void setup() {
mockMvc = MockMvcBuilders.webAppContextSetup(ctx)
// 2.2 버전 이후 mock 객체에서 한글 인코딩 처리해야함. -> 필터추가
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.alwaysDo(print())
.build();
}
@Order(1)
@DisplayName("게시글 등록")
@Test
public void addBoard() throws Exception {
AddBoardParam param = AddBoardParam.builder()
.content("게시글을 등록합니다.")
.username("유저1")
.build();
MvcResult mvcResult = mockMvc.perform(post(BASE_URL)
.accept(MediaTypes.HAL_JSON_VALUE)
.contentType(MediaTypes.HAL_JSON_VALUE)
.content(objectMapper.writeValueAsString(param)))
.andDo(print())
.andExpect(status().isCreated())
.andReturn();
Board board = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Board.class);
Assertions.assertEquals(board.getContent(), param.getContent());
Assertions.assertEquals(board.getUsername(), param.getUsername());
Board savedBoard = boardRepository.findById(board.getSeq()).orElse(null);
Assertions.assertEquals(savedBoard.getContent(), param.getContent());
Assertions.assertEquals(savedBoard.getUsername(), param.getUsername());
}
@Order(2)
@DisplayName("게시글 리스트 조회")
@Test
public void getBoardList() throws Exception {
mockMvc.perform(get(BASE_URL)
.accept(MediaTypes.HAL_JSON_VALUE))
.andDo(print())
.andExpect(status().isOk());
}
@Order(3)
@DisplayName("게시글 1건 조회(OK)")
@Test
public void getBoard() throws Exception {
mockMvc.perform(get(BASE_URL + "/{seq}", 1L)
.accept(MediaTypes.HAL_JSON_VALUE))
.andDo(print())
.andExpect(status().isOk());
}
@Order(4)
@DisplayName("게시글 1건 조회(NOF FOUND)")
@Test
public void getBoard_NotFound() throws Exception {
mockMvc.perform(get(BASE_URL + "/{seq}", -1L)
.accept(MediaTypes.HAL_JSON_VALUE))
.andDo(print())
.andExpect(status().isNotFound());
}
@Order(5)
@DisplayName("게시글 수정(OK)")
@Test
public void editBoard() throws Exception {
EditBoardParam param = EditBoardParam.builder().content("수정하는 게시글입니다.").build();
MvcResult mvcResult = mockMvc.perform(put(BASE_URL + "/{seq}", 1L)
.contentType(MediaTypes.HAL_JSON_VALUE)
.accept(MediaTypes.HAL_JSON_VALUE)
.content(objectMapper.writeValueAsString(param)))
.andDo(print())
.andExpect(status().isOk())
.andReturn();
Board board = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Board.class);
Assertions.assertEquals(param.getContent(), board.getContent());
Assertions.assertEquals(param.getContent(), boardRepository.findById(1L).orElse(null).getContent());
}
@Order(6)
@DisplayName("게시글 수정(Not Found)")
@Test
public void editBoard_NotFound() throws Exception {
EditBoardParam param = EditBoardParam.builder().content("수정하는 게시글입니다.").build();
mockMvc.perform(put(BASE_URL + "/{seq}", -2L)
.contentType(MediaTypes.HAL_JSON_VALUE)
.accept(MediaTypes.HAL_JSON_VALUE)
.content(objectMapper.writeValueAsString(param)))
.andDo(print())
.andExpect(status().isNotFound());
}
@Order(10)
@DisplayName("게시글 삭제")
@Test
public void deleteBoard() throws Exception {
mockMvc.perform(delete(BASE_URL + "/{seq}", 1L)
.accept(MediaTypes.HAL_JSON_VALUE))
.andDo(print())
.andExpect(status().isOk());
Assertions.assertNull(boardRepository.findById(1L).orElse(null));
}
@Order(11)
@DisplayName("게시글 삭제(Not Found)")
@Test
public void deleteBoard_NotFound() throws Exception {
mockMvc.perform(delete(BASE_URL + "/{seq}", -1L)
.accept(MediaTypes.HAL_JSON_VALUE))
.andDo(print())
.andExpect(status().isNotFound());
}
}
- Controller 테스트 코드는 일단 간단하여 많은 프로그래밍을 요하지는 않았습니다. 단순히 추가, 수정, 삭제, 조회 후 Assertions 클래스로 결과값을 확인하거나 404에 대비한 테스트를 진행하였습니다.(실무에서는 더 많은 실패 케이스가 존재할 수 있습니다.)
- @TestMethodOrder(MethodOrder.OrderAnnotation.class)를 통해서 @Order 어노테이션을 이용하여 테스트의 순서를 결정할 수 있습니다. 이는 delete하고나서 1번 게시글을 get하게 될때 발생하는 에러케이스를 통제하기 위함입니다.
- Assertions 클래스의 assertXXX 메소드를 이용하여 직접 API를 호출하여 처리된 결과를 테스트해봅니다. 개인적으로 테스트 코드에 단언문이 들어가는 편이 좋다고 생각합니다.^^
BoardController
@RequestMapping("/boards")
@RequiredArgsConstructor
@RestController
public class BoardController {
private final BoardService boardService;
@PostMapping(produces = MediaTypes.HAL_JSON_VALUE, consumes = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity addBoard(@RequestBody AddBoardParam param) throws Exception {
Board board = boardService.addBoard(param);
URI createdURI = linkTo(BoardController.class).slash(board.getSeq()).toUri();
return ResponseEntity.created(createdURI).body(board);
}
@GetMapping(produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity getBoardList() throws Exception {
return ResponseEntity.ok(boardService.getBoardList());
}
@GetMapping(value = "/{seq}", produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity getBoard(@PathVariable("seq") Long seq) throws Exception {
Board board = boardService.getBoard(seq);
if (board == null) {
return ResponseEntity.notFound().build();
} else {
return ResponseEntity.ok(board);
}
}
@PutMapping(value = "/{seq}", produces = MediaTypes.HAL_JSON_VALUE, consumes = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity editBoard(@PathVariable("seq") Long seq,
@RequestBody EditBoardParam param) throws Exception {
Board board = boardService.editBoard(param, seq);
if (board == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
} else {
return ResponseEntity.ok(board);
}
}
@DeleteMapping(value = "{seq}", produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity deleteBoard(@PathVariable("seq") Long seq) throws Exception {
if (boardService.deleteBoard(seq)) {
return ResponseEntity.ok().build();
}
return ResponseEntity.notFound().build();
}
}
- @RequestMapping 어노테이션을 컨트롤러 클래스의 prefix path를 설정해주기 위함입니다. BoardController 클래스에 선언된 모든 API 메소드들은 /boards로 시작되는 path를 공통적으로 갖게 됩니다.
- @RequiredArgsConstructor 어노테이션은 final 키워드를 갖는 클래스변수들을 초기화해주는 생성자를 만들어냅니다. 즉, Bean을 주입받을 때 @Autowired 타입 주입방법을 선택하는 것이 아닌 생성자 주입을 받도록 유도합니다.
- ResponseEntity 객체는 HttpResponse를 반환하기 위한 기능을 가지고 있는 클래스로 http status를 설정하거나 body에 내용을 담을 수 있도록 처리하는 객체입니다. 이 부분을 좀더 약속된 객체를 활용하는 모습으로 바꿀 예정이니 어떻게 바뀌어 나가는지 지켜보시면 좋을 것 같습니다.
- 보시면 ADD 부분(POST Method)에서 반환 시, linkTo 메소드를 활용하는 부분이 있는데요. 이 부분은 Hateoas 라는 개념과 이어지므로 이후 포스팅에서 자세히 다뤄보겠습니다. 한가지 created()라는 부분에 uri를 넣어주는 부분은 201 코드를 반환함과 동시에 response Header 쪽에 redirect location을 제공해주게 되는데, 그 uri를 활용해서 등록된 객체를 조회하는 api인 GET http://localhost:8080/boards/1(만약 최초 등록되었다면) 과 같은 정보로 이용할 수 있게 합니다.(이는 이후 등장하는 Hateoas에 대한 개념으로 발전하게 됩니다.)
BoardService
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
public List<Board> getBoardList() throws Exception {
return boardRepository.findAll();
}
public Board getBoard(Long seq) throws Exception {
return boardRepository.findById(seq).orElseGet(() -> null);
}
@Transactional
public Board editBoard(EditBoardParam param, Long seq) throws Exception {
Board board = boardRepository.findById(seq).orElseGet(() -> null);
if(board != null) {
board.setContent(param.getContent());
}
return board;
}
@Transactional
public Board addBoard(AddBoardParam param) throws Exception {
return boardRepository.save(new Board(param));
}
@Transactional
public boolean deleteBoard(Long seq) throws Exception {
Board board = boardRepository.findById(seq).orElse(null);
if (board == null) {
return false;
}
boardRepository.delete(board);
return true;
}
}
- @Transactional(readOnly = true)는 JPA를 활용하기 때문에 일단 서비스의 모든 메소드의 트랜잭션 처리를 readOnly로 합니다. default는 false 입니다. 필요한 메소드(DML을 호출하게 되는 add, edit, delete)에서 @Transactional로 처리해줄 것 입니다.
- editBoard() 메소드를 보시면 repository에서 조회만 하고 추가적인 update 처리가 없어보이지만 사실 JPA의 1차캐시 영역에 Board 객체를 올려둔 상태이므로 Spring Data JPA 내부적으로 @Transactional 에 의해 트랜잭션 처리가 될때 1차캐시 영역에 있는 데이터가 변경되었을 경우, 이를 감지하여(변경감지, Dirty Checking) update 문을 날려주게 됩니다.
- 서비스에서 일단 볼 것은 orElse(null) 이부분입니다. 현재 모든 에러처리를 해놓지 않았으므로 Exception을 던지게 된다면 Response는 받지 않게됩니다. 개발자가 findById(seq)에 Entity Empty 처리를 Exception으로 처리하느냐 null로 내주느냐에 따라 다르겠지만 저는 나중에 EntityNotFoundException을 발생시킬 것이지만 현재는 에러처리 별도로 하지 않았기 때문에 null로 두고 null이 리턴되는 경우 404를 내뱉는 구조로 하였습니다.
- list는 추후 페이징 처리를 어떻게 하는지 보면 좋을 것 같습니다.
BoardRepository
public interface BoardRepository extends JpaRepository<Board, Long> {
}
- JpaRepository 인터페이스를 상속하게 된다면 여러가지 JPA 기본 CURD 메소드 및 자주 사용하는 메소드들을 상속받게 됩니다.
- Spring Data JPA에서 기존 JPA에서 발생하는 뻔한 boilerplate code를 줄이는 효과를 줬다고 생각합니다.
Board
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
@Setter
@Getter
@Entity
public class Board {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long seq;
private String username;
@Column(length = 300)
private String content;
@Column(name = "created_at", updatable = false, nullable = false)
private LocalDateTime createdAt;
public Board(String username, String content) {
this.username = username;
this.content = content;
this.createdAt = LocalDateTime.now();
}
public Board(AddBoardParam param) {
this.username = param.getUsername();
this.content = param.getContent();
this.createdAt = LocalDateTime.now();
}
}
- Board 클래스는 데이터베이스 테이블이 될 클래스이므로 @Entity 어노테이션으로 엔티티 객체로 만들어줍니다.
- Entity 객체는 기본 생성자를 protected 처리하여 직접생성을 방어하는 프로그래밍을 구사하는 편이 좋습니다.
- createdAt 컬럼은 데이터베이스에서는 created_at이라는 이름으로 스네이스케이스 방식을 구사하고 싶어서 따로 name을 지정해주었습니다. 이 컬럼은 수정할 수 없고(updatable = false), null 일 수 없기때문에(nullable = false) @Column 어노테이션에 설정해둡니다.
AddBoardParam, EditBoardParam
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
public class AddBoardParam {
private String username;
private String content;
}
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
public class EditBoardParam {
private String content;
}
- 두 클래스는 현재까지는 비슷한 모습이어서 AddBoardParam 하나로도 사용이 가능하겠지만 클래스의 단일 용도를 위해 두가지로 따로 만들었습니다. 이는 저의 개인적인 정책일 뿐입니다.
BoardV2Application
@SpringBootApplication
public class BoardV2Application {
@Autowired
private BoardRepository boardRepository;
public static void main(String[] args) {
SpringApplication.run(BoardV2Application.class, args);
}
@Bean
ApplicationRunner applicationRunner() {
return args -> {
for(int i = 1; i <= 10; i++) {
boardRepository.save(new Board("유저" + i, "유저" + i + "의 게시판 내용"));
}
};
}
}
- 테스트를 돌리기 전!! 미리 데이터를 등록해두는 편이 좀더 테스트에 용이하기 때문에 ApplicationRunner 객체를 빈으로 등록하여 정의해주면서 더미데이터를 등록해줍니다.
테스트 결과
현재까지 모든 결과가 이상없이 테스트되는 것을 확인할 수 있습니다! 여기서 Service, Repository(커스텀 메소드가 있다면) 까지 테스트하게 된다면 더할 나위 없이 내 소스에 대한 자신감이 생길 것 같습니다. ^^