delete 함수 호출 시 /api/v1/posts/{id} URL 로 DELETE Method 방식으로 호출하여 게시글을 삭제 요청
var main = {
init : function () {
var _this = this;
$('#btn-save').on('click', function () { _this.save(); });
$('#btn-update').on('click', function () { _this.update(); });
$('#btn-delete').on('click', function () { _this.delete(); });
},
// ... save, update
delete : function () {
var id = $('#id').val();
$.ajax({
type: 'DELETE',
url: '/api/v1/posts/'+id,
dataType: 'json',
contentType:'application/json; charset=utf-8'
}).done(function() {
alert('글이 삭제되었습니다.');
window.location.href = '/';
}).fail(function (error) {
alert(JSON.stringify(error));
});
}
};
main.init();
BackEnd
PostsAPIController
PostsAPIController
게시글의 Id를 arguements로 받아 PostsService.delete(id)를 호출
URL을 Delete method 방식으로 호출하는 경우 게시글 삭제
@RequiredArgsConstructor
@RestController
public class PostsAPIController {
private final PostsService postsService;
// ... save, update, findById
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}
PostsService
PostsService
postsRepository.delete(posts)
JpaRepository에서 이미 delete 메서드를 지원
엔티티를 파라미터를 삭제할 수도 있고, deleteById 메서드를 이용하면 id로 삭제할 수도 있다.
존재하는 Posts인지 확인하기 위해 Entity 조회 후 그대로 삭제
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// ... save, update, findById, findAllDesc
@Transactional
public void delete(Long id) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시물이 없습니다. id=" + id));
postsRepository.delete(posts);
}
}
btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트 등록
update : function()
신규로 추가될 update function()
type: "PUT"
여러 HTTP Method 중 PUT 메서드를 선택
REST에서 CRUD는 다음과 같이 HTTP Method에 매핑된다.
생성(Create) - POST
읽기(Read) - GET
수정(Update) - PUT
삭제(Delete) - DELETE
URL: "/api/v1/posts/" + id
어느 게시글을 수정할 지 URL path로 구분하기 위해 Path에 id 값 추가
var index = {
init : function () {
var _this = this;
// ...
$('#btn-update').on('click', function () { _this.update(); });
},
// ... save
update : function () {
var data = {
title: $('#title').val(),
content: $('#content').val()
};
var id = $('#id').val();
$.ajax({
type: 'PUT',
url: '/api/v1/posts/'+id,
dataType: 'json',
contentType:'application/json; charset=utf-8',
data: JSON.stringify(data)
}).done(function() {
alert('글이 수정되었습니다.');
window.location.href = '/';
}).fail(function (error) {
alert(JSON.stringify(error));
});
},
};
index.init();
BackEnd
IndexController
IndexController
게시글의 id로 /posts/update/{id} postsUpdate 메서드를 호출하는 메서드를 정의
게시글을 가져와 model에 넣어 templates로 전달
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
// ... index, save
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
PostsAPIController
PostsAPIController
update메서드 호출로 게시글 수정
@RequiredArgsConstructor
@RestController
public class PostsAPIController {
private final PostsService postsService;
// ... save, findById
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
}
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스 No. 3</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href = "/posts/save" role="button" class="btn btn-primary">
글 등록
</a>
</div>
</div>
<br>
<!-- 목록 출력 영역 -->
<table class="table table-horizontal table-bordered">
<thead class="thead-strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thead>
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
BackEnd
PostsListResponseDto
PostsListResponseDto
Posts의 Entity를 이용하여 필요한 필드만 Dto로 구성
@Getter
public class PostsListResponseDto {
private Long id;
private String title;
private String author;
private LocalDateTime modifiedDate;
public PostsListResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.author = entity.getAuthor();
this.modifiedDate = entity.getModifiedDate();
}
}
PostsRepository
PostsRepository
JpaRepository 인터페이스에 Entity에 접근하여 가져오는 findAll() 메서드가 있다.
Spring Data JPA에서 제공하지 않는 메서드를 사용하기 위해서 @Query를 사용하는 방법도 있다.
findAllDesc을 새로 작성하여 PostsListResponseDto라는 Controller와 Service에서 접근가능한 Dto를 사용하여 호출
@Query가 가독성이 더 좋을 수도 있다.
Entity 클래스만으로 처리하기 어려운 경우
querydsl, jooq, MyBatis 등 프레임워크를 추가하여 조회용으로 사용할 수 있다.
기본적인 등록 / 수정 / 삭제는 Spring Data JPA만으로도 충분히 가능하다.
querydsl을 추천하는 이유
타입의 안정성
단순한 문자열로 쿼리를 생성하는 것이 아니라, 메서드를 기번으로 쿼리를 생성
오타나 존재하지 않는 컬럼명을 명시하는 경우 IDE에서 자동으로 검증이 가능하다.
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}
PostsService
PostsService
PostsRepository에서 구현한 findAllDesc() 메서드를 호출
List<Posts> 를 PostsListResponseDto로 매핑하여 List<PostsResponseDto>로 리턴
findAllDesc() 메서드의 트랜잭션 어노테이션에 readOnly = true라는 옵션을 추가
- 이는 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도를 개선할 수 있다.
등록, 수정, 삭제 기능이 전혀없는 서비스 메서드에서 사용하는 것을 추천
람다 설명
- .map(PostsListResponseDto::new)
- .map(posts -> new PostsListResponseDto(posts))
- postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListReponseDto로 변환 -> List로 반환하는 메서드
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// ... save, update, findById
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAll().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
IndexController
IndexController
Service Layer에서 List<PostsListResponseDto> 를 반환하는 findAllDesc()를 model에 담아 View로 전달한다.
Model
서버 템플릿 엔진에서 사용할 수 있는 객체를 저장
postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
//... save
}
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스 No. 3</h1>
{{>layout/footer}}
View Resolver
Mustache starter로 인한 View Resolver가 하는 일
컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
prefix: src/main/resources/templates
suffix: .mustache
index를 반환하는 경우 'src/main/resources/templates/index.mustache'로 전환
IndexControlerTest
기본 페이지 테스트 코드
restTemplate.getForObject("/")를 호출하는 경우 index.mustache에 포함된 코드 확인
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void loadMainPage() {
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
SpringBoot 1.x 버전을 사용하는 경우 Hibernate 5.2.10 버전 이상을 사용하도록 설정 필요
SpringBoot 2.x 버전을 사용하는 경우 별다른 설정이 없이 바로 적용가능
BaseTimeEntity
BaseTimeEntity 클래스
domain 패키지에 생성
모든 Entity의 상위 클래스로 사용하여 createDate, modifiedDate를 자동으로 관리
@MappedSuperclass
JPA Entity 클래스들이 BaseTimeEntity를 사용할 경우 필드들(createDate, modifiedDate)도 컬럼으로 인식하도록 한다.
@EntityListeners(AuditingEntityListener.class)
BaseTimeEntity 클래스에 Auditing 기능을 포함
@CreatedDate
Entity가 생성되어 저장될 때 자동 저장
@LastModifiedDate
조회한 Entity의 값을 변경할 때 시간이 자동 저장
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
domain 클래스에서 BaseTimeEntity 상속
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
...
}
JPA Auditing 어노테이션들을 활성화 할 수 있도록 Application 클래스에 활성화 어노테이션 추가
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String\[\] args) {
SpringApplication.run(Application.class, args);
}
}
JPA Auditing 테스트 코드
PostsRepositoryTest 클래스
LocalDateTime.of()로 날짜 설정
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup() {
postsRepository.deleteAll();
}
// getBoard
@Test
public void regBaseTimeEntityTest() {
// given
LocalDateTime now = LocalDateTime.of(2020,5,28,19,28,0);
postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
// when
List<Posts> postsList = postsRepository.findAll();
// then
Posts posts = postsList.get(0);
System.out.println(">>> createDate=" + posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}
}
createDate, modifiedDate 확인으로 정상적으로 JPA Auditing이 적용되었음을 알 수 있다.
앞으로 생성되는 Entity들은 BaseTimeEntity를 상속받아 등록일/수정일을 자동화할 수 있다.
생성자로 Bean을 주입받아 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함
해당 컨트롤러에 새로운 서비스를 추가하거나, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드를 수정하지 않아도 된다.
@RequiredArgsConstructor
@RestController
public class PostsAPIController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
service/PostsService
Service Layer
트랜잭션
도메인 간 순서 보장 (@Transactional)
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
PostsControllerTest
Posts.save 테스트
@WebMvcTest 대신 @SpringBootTest와 TestRestTemplate을 사용
@WebMvcTest는 JPA 기능이 작동하지 않는다.
@SpringBootTest와 TestRestTemplat을 통해 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 사용
@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 testSave() {
// given
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";
// when
ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, 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);
}
}
로그 확인
WebEnvironment.RANDOM_PORT를 통해 tomcat의 포트가 3421로 구동된 것을 확인
insert 쿼리 실행 확인
Update / findById API 만들기
PostsResponseDto
PostsReponseDto
Entity의 필드 중 일부를 사용하므로 생성자로 Entity를 받아 필드에 대입
모든 필드를 가진 생성자가 필요하지 않으므로 Dto는 Entity를 받아 처리
@Getter
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
PostsAPIController
PostsAPIController
Posts의 id값과 update할 PostsUpdateRequestDto 값을 json 데이터 타입으로 전달하여 호출
해당 Posts의 값을 조회하기 위한 findById 메서드를 정의
@RequiredArgsConstructor
@RestController
public class PostsAPIController {
private final PostsService postsService;
// save
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id) {
return postsService.findById(id);
}
}
PostsService
PostsService
update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
JPA의 영속성 컨텍스트, 엔티티를 영구 저장하는 환경
JPA의 엔티티 매니저가 활성화된 상태(Spring Data JPA의 기본 옵션)로 트랜잭션안에서 데이터베이스에서 데이터를 가져오는 경우 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영
Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다. (더티 체킹)
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
...
@Transactional
public Long update(Long id, PostsUpdateRequestDto requestDto) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시물이 존재하지 않습니다. id=" + id));
posts.update(requestDto.getTitle(), requestDto.getContent());
return id;
}
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시물이 존재하지 않습니다. id=" + id));
return new PostsResponseDto(entity);
}
}
PostsAPIControllerTest
PostsAPIControllerTest
Update 기능을 테스트 하기 위해서 savePosts를 통해 Insert 쿼리 호출
Update할 값을 설정하여 PostsUpdateRequestDto를 통해 데이터를 만들기
restTemplate.exchange(url.toString(), HttpMethod.PUT, requestEntity, Long.class)로 update 실행
assertThat을 통해 정상 호출 확인
postsRepository.findAll()를 이용하여 데이터 호출
입력된 값이 기대값과 같은지 확인 후 완료
@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();
}
// save
@Test
public void testUpdate() {
// 변경하기 전 데이터 입력
Posts savePosts = postsRepository.save(
Posts.builder()
.title("title")
.content("content")
.author("author")
.build()
);
Long updateId = savePosts.getId();
String exceptedTitle = "title2";
String exceptedContent = "content2";
// 데이터 변경을 위한 Dto 생성
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(exceptedTitle)
.content(exceptedContent)
.build();
StringBuilder url = new StringBuilder();
url.append("http://localhost:");
url.append(port);
url.append("/api/v1/posts/");
url.append(updateId);
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
// when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url.toString(), HttpMethod.PUT, requestEntity, 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(exceptedTitle);
assertThat(all.get(0).getContent()).isEqualTo(exceptedContent);
}
}