스프링 - 지인 정보 관리 시스템

패스트캠퍼스 스프링 강의 정리

JPA: Persistence 레이어 Controller: Presentation 레이어

02. HelloWorldController 생성

모름지기 모든 시작은 Hello World 찍기 인거 같다. 메인 패키지에서 하위에 controller 패키지를 만들어 준 후 HelloWorldController 자바 파일을 생성해서 입력해준다. @RestController라는 어노테이션은 스프링이 해당 클래스가 컨트롤러임을 알려준다. @GetMapping은 HTTP Method 중 GET임을 알려준다. 이후 커맨드에서 ./gradlew bootRun 입력해주면 웹 서버가 실행되고, http://localhost:8080/api/helloWorld를 입력해주면 HelloWorld가 표시된다.

@RestController
public class HelloWorldController {

    @GetMapping(value = "/api/helloWorld")
    public String helloWorld() {
        return "HelloWorld";
    }

IntelliJ 단축키 중 Command + B를 하면 클래스의 위치를 찾아서 보여준다.

웹 브라우저에서 말고 IntelliJ에서도 확인이 가능한데, 테스트 패키지에서 hello.http라는 파일을 만들어 준 후, 다음처럼 입력해 보자

GET http:localhost:8080/api/helloWorld

이후 실행을 해보면 리턴 값을 돌려받는 걸 확인할 수 있다.

03. MockMvc 테스트 만들기

Command + Shift + t를 입력하면 테스트를 만들 수 있는 단축키이다. ```java package com.fastcampus.javaallinone.project3.mycontact.controller;

import org.junit.jupiter.api.Test; // Junit5꺼 사용 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest class HelloWorldControllerTest { @Autowired // Spring Context에 Bean 주입 private HelloWorldController helloWorldController;

private MockMvc mockMvc;

@Test
void helloWorld() { //        System.out.println("test");
    System.out.println(helloWorldController.helloWorld());
}

@Test
void mockMvcTest() throws Exception {
    mockMvc = MockMvcBuilders.standaloneSetup(helloWorldController).build();

    mockMvc.perform(
            MockMvcRequestBuilders.get("/api/helloWorld"))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.content().string("HelloWorld"));
} } ```

JPA

JPA는 ORM 표준 인터페이스이다. 이 인터페이스를 활용하면 내가 어떠한 DB를 사용해도 그에 맞는 쿼리로 처리해주어 아주 좋다. build.gradle에 의존성을 추가 해준다.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'

h2는 초경량 메모리 DB이다. 테스트나 캐싱용으로 많이 사용된다.

객체를 디비에 저장하기 위해서 Repository를 만들어 준다. 아래와 같이 JPaRepository를 상속받는 인터페이스를 만들어 주면 된다. 제너릭으로는 엔티티와 primary key의 값 타입을 넣어주면 된다.

package com.fastcampus.javaallinone.project3.mycontact.repository;

import com.fastcampus.javaallinone.project3.mycontact.domain.Person;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PersonRepository extends JpaRepository<Person, Long> {
}

이후 테스트는 아래처럼 해보면 된다.

package com.fastcampus.javaallinone.project3.mycontact.repository;

import com.fastcampus.javaallinone.project3.mycontact.domain.Person;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class PersonRepositoryTest {

    @Autowired
    private PersonRepository personRepository;

    @Test
    void crud() {
        Person person = new Person();
        person.setName("seokju");
        person.setAge(10);

        personRepository.save(person);

//        System.out.println(personRepository.findAll());

        List<Person> people = personRepository.findAll();

        Assertions.assertThat(people.size()).isEqualTo(1);
        Assertions.assertThat(people.get(0).getName()).isEqualTo("seokju");
        Assertions.assertThat(people.get(0).getAge()).isEqualTo(10);
    }
}

Lombok - 1

IntelliJ에서 lombok을 사용하기 위해서는 환결설정에서 Build, Execution, Deployment -> Compiler -> Annotaion Processors에서 Enable annotaion processing을 체크해주어야 한다.

lombok을 사용하면 Getter, Setter, ToString을 편안하게 이용할 수 있다. 기존에 우리가 멤버변수를 만들고 나서는 일일히 Getter, Setter함수를 만들어 주어야 했다. 물론 IDE에서 단축키를 통해 편하게 만들 수도 있지만 이것 또한 일이다. 그런 불편함을 lombok이 해결해준다.

@Entity
@Getter
@Setter
public class Person {
    @Id
    @GeneratedValue // 자동 생성
    private Long id;

    private String name;

    private int age;

    private String hobby;

    private String bloodType;

    private String address;

멤버 변수마다 @Getter, @Setter 넣어주는 방법도 있지만, 위처럼 클래스 위에 입력하면 모든 변수가 적용된다.

다음은 ToString인데, ToString 또한 만약 변수가 추가되어 새로 넣어야 하면 이것 또한 노가다이다. lombok은 ToString도 자동으로 처리해준다.

@Entity
@Getter
@Setter
@ToString
public class Person {
    @Id
    @GeneratedValue // 자동 생성
    private Long id;

    private String name;

    private int age;

    private String hobby;

    private String bloodType;

    private String address;

    private String phoneNumber;

@ToString 어노테이션을 넣어주면 클래스 내의 멤버변수들로 적절하게 만들어 준다. 만약 전화번호는 개인정보라 노출되지 않길 바란다면 예외로 둘 수 있다. 아래와 같이 두가지 방법이 있다.

// 1번 - 클래스 위의 ToString에 내용 추가
@ToString(exclude="phoneNumber")

// 2번 - 멤버변수 위에 Exclude 어노테이션을 추가
@ToString.Exclude
private String phoneNumber;

lombok - 2

자바 클래스는 기본적으로 매개변수가 없는 생성자를 가지고 있다. 생성자 또한 lombok을 통해 기본 생성자와 전체 매개변수를 가진 생성자, 또는 특정 생성자를 만들 수 있다.

  • NoArgsConstructor: 매개 변수 없는 생성자
  • AllArgsConstructor: 전체 매개변수 가진 생성자
  • RequiredArgsConstructor: 특정 매개변수 생정자이다. 이거 같은 경우에는 멤버 변수 위에 @NonNull 어노테이션을 달아준다.
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
public class Person {
    @Id
    @GeneratedValue // 자동 생성
    private Long id;

    @NonNull
    private String name;

    @NonNull
    private int age;

    private String hobby;

또한 HashCode와 equlas 메소드도 오버라이딩 해서 재정의를 많이 해주는데 이 또한 lombok에서 제공해준다. 위에 @EqualsAndHashCode 어노테이션을 달아주면 필드에 맞게 값을 맞춰 줄 것이다.

@EqualsAndHashCode
public class Person{}

마지막으로 @Getter, @Setter, ToString, EqualsAndHashCode 등을 한번에 제공하는 어노테이션이 있는데 이것이 @Data 어노테이션이다. 기존꺼를 다 지우고 @Data만 적으면 동일하게 작동한다.

@Entity
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Data
public class Person {
    @Id
    @GeneratedValue // 자동 생성
    private Long id;

    @NonNull
    private String name;

    @NonNull
    private Integer age;

    private String hobby;

    @NonNull
    private String bloodType;

    private String address;

    private LocalDate birthday;

    private String job;

    @ToString.Exclude
    private String phoneNumber;
}

JPA Relation

테이블 간 관계를 맺기 위해서 @OneToOne, @OneToMany 같은 어노테이션을 설정해야한다. 아래의 예제는 Block 엔티티를 연결하는 방법이다. CASACADETYPE과 orphanRemoval 등을 설정해주어야 삭제 시 같이 연쇄적으로 삭제되거나 업데이트 될 것이다. 자세한 내용은 추후에 정리해봐야겠다. FetchType EAGER는 조인을 통해 갖고오는 형태이고, LAZY는 실제 사용할 때 SELECT해온다.

public class Person {
    @OneToOne(cascade = {CascadeType.ALL}, orphanRemoval = true)
    @ToString.Exclude
    private Block block;
}

JPA Query Method

레포지토리의 findAll과 stream을 적절히 조합하면 내가 원하는 데이터를 찾을 수 있지만 이것은 모든 데이터를 갖고오는 비효율을 초래한다. 따라서 JPA의 Query Method를 이용해서 적절한 값을 갖고 올 수 있도록 하는 것이 좋다. 메소드의 구문은 대략 이러하다.

findById

sql로 따지면 find는 select, by는 where, Id가 이제 컬럼명이 된다. repository에 직접 구현을 해보자.

public interface PersonRepository extends JpaRepository<Person, Long> {
    // 이름으로 검색
    List<Person> findByName(String name);

    // Block이 null인 것들 검색
    List<Person> findByBlockIsNull();

    // 혈액형으로 검색
    List<Person> findByBloodType(String bloodType);

    // 날짜로 검색 Between의 경우 두 인자 사이에 포함된 값들을 검색한다.
    List<Person> findByBirthdayBetween(LocalDate startDate, LocalDate endDate);
}

JPA @Query

위 Query Method를 사용해서 8월달이 생일인 친구들을 갖고오는 테스트를 만들어 보겠다.

@Test
void findByBirthdayBetween() {
    givenPerson("seokju", 10, "A", LocalDate.of(1991, 8, 15));
    givenPerson("david", 9, "B", LocalDate.of(1992, 7, 10));
    givenPerson("dennis", 8, "O", LocalDate.of(1993, 1, 5));
    givenPerson("sophia", 7, "AB", LocalDate.of(1994, 6, 30));
    givenPerson("benny", 6, "A", LocalDate.of(1995, 8, 30));

    List<Person> result = personRepository.findByBirthdayBetween(LocalDate.of(1991, 8, 1), LocalDate.of(1995, 8, 31));

    result.forEach(System.out::println);
}

private void givenPerson(String name, int age, String bloodType) {
    givenPerson(name, age, bloodType, null);
}

private void givenPerson(String name, int age, String bloodType, LocalDate birthday) {
    Person person = new Person(name, age, bloodType);
    person.setBirthday(birthday);
    personRepository.save(person);
}

List result = personRepository.findByBirthdayBetween(LocalDate.of(1991, 8, 1), LocalDate.of(1995, 8, 31));

이렇게만 보면 8월달 생일인 친구들을 갖고오는거 갖지만 Between이 무엇인가. 그 사이에 있는 데이터를 다 갖고오는 것이기 때문에 위에서 만든 친구들이 전부 갖고 와 질 것이다. 이렇게 되면 우리가 의도했던거와는 전혀 다른 방향으로 바뀌는 것이다. 이것을 @Embedded, @Embeddable, @Valid, @Query 어노테이션으로 해결해보자.

@Valid 어노테이션을 사용하려면 build.gradle dependencies에 implementation ‘org.springframework.boot:spring-boot-starter-validation’를 추가해주자.

먼저 domain/dto/Birthday.java를 생성하여 Person의 birthday를 조금 수정해 준다.

@Embeddable // Entity에 속해있는 DTO
@Data
@NoArgsConstructor
public class Birthday {
    private int yearOfBirthday;

    @Min(1)
    @Max(12)
    private int monthOfBirthday;

    @Min(1)
    @Max(31)
    private int dayOfBirthday;

    public Birthday(LocalDate birthday) {
        this.yearOfBirthday = birthday.getYear();
        this.monthOfBirthday = birthday.getMonthValue();
        this.dayOfBirthday = birthday.getDayOfMonth();
    }
}

이후 Person의 birthday를 다음과 같이 수정해준다.

@Valid
@Embedded
private Birthday birthday;

이렇게 하고 PersonRepository에 다음을 추가해준다. @Query어노테이션을 활용하면 우리가 JPQL이라 해서 Java와 Sql문을 혼재해서 사용할 수 있게 해주는 역할을 한다. 이렇게 하여 월에 해당하는 조건을 추가하여 해당 월에 맞는 데이터를 추출할 수 있게 해준다. native=true를 사용하면 실제 sql문을 사용하게 된다. ?1이나 ?2는 메소드의 매개변수 순서를 가르키는 것이고, 추가로 @Param 어노테이션을 활용하면 ?숫자 대신 지정한 이름으로 쓸 수 있다.

//    @Query(value = "select person from Person person where person.birthday.monthOfBirthday = ?1 and person.birthday.dayOfBirthday=?2") // jpql (Entity 기반 퀀리), ?1은 첫번째 인자
// native = true는 실제 SQl로
//    @Query(value = "select * from person where month_of_birthday = :monthOfBirthday and day_of_birthday =:dayOfBirthday", nativeQuery = true)
@Query(value = "select person from Person person where person.birthday.monthOfBirthday = :monthOfBirthday")
List<Person> findByMonthOfBirthday(@Param("monthOfBirthday") int monthOfBirthday);
//    List<Person> findByMonthOfBirthday(@Param("monthOfBirthday") int monthOfBirthday, @Param("dayOfBirthday") int dayOfBirthday);

JPA data.sql

test시 data.sql을 만들어 사용하면 편하게 데이터를 삽입하여 관리할 수 있다.

spring2.5부터는 위 내용을 이용하려면 applicaion.yml 같은 파일에 다음을 추가해준다. defer-datasource-initialization: true

test디렉토리에 resources 디렉토리를 만든 후 data.sql을 만든다.

insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (1, 'martin', 10, 'A', 1991, 8, 15);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (2, 'david', 9, 'B', 1992, 7, 21);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (3, 'dennis', 8, 'O', 1993, 10, 15);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (4, 'sophia', 7, 'AB', 1994, 8, 31);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (5, 'benny', 6, 'A', 1995, 12, 23);

insert into block(`id`, `name`) values (1, 'dennis');
insert into block(`id`, `name`) values (2, 'sophia');

update person set block_id = 1 where id = 3;
update person set block_id = 2 where id = 4

이런식으로 하여 repository에서 굳이 우리가 객체 생성없이 db에 있는 내용으로 실습할 수 있게 된다.

Controller

RESTful하게 사용하기 위한 어노테이션으로 GET과 관련된 어노테이션은 아래와 같다.

  • @GetMapping
  • @PathVariable

@RequestMapping(value = “url명”)을 클래스 위에 두면 전체적인 url로 해당 내용의 root url이 된다 보면 된다. @RestController는 Rest컨트롤러임을 알려준다. @GetMapping은 GET메소드로 오는 것에 대한 어노테이션이다. 메소드에서 @GetMapping(“/{id}”)와 매개변수 쪽에 @PathVariable을 사용하여 매칭시켜준다. 일반적인 GET URL양식은 http:url/api/person?id=1형태로 끝에 물음표와 값이 들어가는데 REST적으로 사용하면 http://url/api/person/1 이런식으로 사용하게 된다.

@RequestMapping(value = "/api/person")
@RestController
public class PersonController {
    @Autowired
    private PersonService personService;

    @GetMapping("/{id}")
    public Person getPerson(@PathVariable Long id) {
        return personService.getPerson(id);
    }
}

POST

POST는 다음과 같이 입력해보면 된다. @PostMapping 어노테이션을 걸어주면 POST 처리가 된다. 보통 REST 방식의 처리는 Request body안의 내용가지고 처리하기에 매개변수란에 @RequestBody 어노테이션을 걸어주면 된다. 그리고 처리가 정상적으로 완려되면 200 코드를 보내주는데, @ResponseStatus어노테이션을 활용하면 내가 원하는 코드로 보낼 수 있다.

@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 201 Response
public void postPerson(@RequestBody Person person) { // 어노테이션이 없으면 @RequestParam으로 처리
    personService.put(person);

    log.info("person -> {}", personRepository.findAll());
}

Put

Shift + fn + f6을 누르면 메소드내의 이름 바꿀 때 좋다.

  • @PutMapping

수정하는 서비스 내용은 다음과 같다.
근데 이런식으로 하면 내가 업데이트 정보에서 누락된게 있으면 null로 되어버리는 경우도 있기 때문에 좋지 않은 코드이다. 
@Transactional
public void modify(Long id, PersonDto personDto ) {
    Person personAtDb = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));

    if (!personAtDb.getName().equals(personDto.getName())) {
        throw new RuntimeException("이름이 다릅니다.");
    }

    personAtDb.setName(personDto.getName());
    personAtDb.setPhoneNumber(personDto.getPhoneNumber());
    personAtDb.setJob(personDto.getJob());

    if (personDto.getBirthday() != null) {
        personAtDb.setBirthday(new Birthday(personDto.getBirthday()));
    }
    personAtDb.setAddress(personDto.getAddress());
    personAtDb.setBloodType(personDto.getBloodType());
    personAtDb.setHobby(personDto.getHobby());
    personAtDb.setAge(personDto.getAge());

    personRepository.save(personAtDb);
}

Person DTO를 만들어 주자

package com.fastcampus.javaallinone.project3.mycontact.controller.dto;

import lombok.Data;

import java.time.LocalDate;

@Data
public class PersonDto {
    private String name;
    private int age;
    private String hobby;
    private String bloodType;
    private String address;
    private LocalDate birthday;
    private String job;
    private String  phoneNumber;
}

그러고 Person Entity에서 유효성 검사라 해야하나 암튼 set 메서드를 만들어 주자.

public void set(PersonDto personDto) {
    if (personDto.getAge() != 0) { // personDTO에서 age를 primitive형으로 해서 가능
        this.setAge(personDto.getAge());
    }

    if (!StringUtils.isEmpty(personDto.getHobby())) {
        this.setHobby(personDto.getHobby());
    }

    if (!StringUtils.isEmpty(personDto.getBloodType())) {
        this.setBloodType(personDto.getBloodType());
    }

    if (!StringUtils.isEmpty(personDto.getAddress())) {
        this.setAddress(personDto.getAddress());
    }

    if (!StringUtils.isEmpty(personDto.getJob())) {
        this.setAddress(personDto.getJob());
    }

    if (!StringUtils.isEmpty(personDto.getPhoneNumber())) {
        this.setPhoneNumber(personDto.getPhoneNumber());
    }
}

Patch

데이터의 일부만 수정할 때 쓰이는 메소드이다.


// Controller
@PatchMapping("/{id}") // 일부 리소스만 변경
public void modifyPerson(@PathVariable Long id, String name) {
    personService.modify(id, name);

    log.info("person -> {}", personRepository.findAll());
}

// Services
@Transactional
public void modify(Long id, String name) {
    Person person = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));

    person.setName(name);

    personRepository.save(person);
}

// Test
@Test
void modifyName() throws Exception {
    mockMvc = MockMvcBuilders.standaloneSetup(personController).build();

    mockMvc.perform(
            MockMvcRequestBuilders.patch("/api/person/1")
            .param("name", "martin22"))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(MockMvcResultMatchers.status().isOk());
}

DELETE

// Controller
@DeleteMapping("/{id}")
public void deletePerson(@PathVariable Long id) {
    personService.delete(id);

    log.info("person -> {}", personRepository.findAll());
}

// Services
@Transactional
public void delete(Long id) {
//        Person person = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));

//        personRepository.delete(person);
    personRepository.deleteById(id);
}

@Test
void deletePerson() throws Exception {
    mockMvc.perform(
            MockMvcRequestBuilders.delete("/api/person/1"))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(MockMvcResultMatchers.status().isOk());
}

이런식으로 지울 수도 있지만, 이런 경우 데이터 복구하기 어려워지기에 플래그를 활용하여 소프트하게 삭제를 한다. @Where(clause = “deleted = false”)는 findAll같은거 할 때 where절을 추가할 때 사용하는 것이다. @ColumnDefault(“0”)는 false로 저장할 때 사용되는 부분이다. 변경

// Entity
@Where(clause = "deleted = false")
public class Person {
    @ColumnDefault("0")
    private boolean deleted;
}

@Transactional
public void delete(Long id) {
    Person person = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));

    person.setDeleted(true);

    personRepository.save(person);
}

Refactoring

테스트를 잘 만들면 변경내용이 적용되는 범위를 금방 찾고, 사이드이펙트를 체크해볼 수 있다. 오류는 컴파일 단계, 테스트 단계, 런타임 단계 순으로 빠르게 발생할 수 있도록 해야 좋은 코드이다.

Controller Test

Delete Controller에서 기존까지는 void로 반환했기에 삭제가 제대로 되었는지 알 수 없었다. 이를 해결하기 위한 방법으로는 크게 세가지 정도가 있다.

  1. boolean 형태로 반환하기. 반환형을 void에서 boolean타입으로 지정하고 return true로 바꾼다. 그런데 만약 우리가 삭제하는 서비스 코드를 지워도 무조건 true로 반환하기 때문에 좋진 않다.

  2. 삭제됐는지 확인하여 true, false 반환
    @DeleteMapping("/{id}")
    public boolean deletePerson(@PathVariable Long id) {
     personService.delete(id);
    
     log.info("person -> {}", personRepository.findAll());
    
     return personRepository.findPeopleDeleted().stream().anyMatch(person -> person.getId().equals(id));
    }
    
  3. 테스트에서 assert들을 활용하여 확인한다.

assertAll은 jUnit5의 기능인데, 한번에 assert들을 확인할 수 있다.

@Test
void modifyPerson() throws Exception {
    PersonDto dto = PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");

    mockMvc.perform(
            MockMvcRequestBuilders.put("/api/person/1")
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content(toJsonString(dto)))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(MockMvcResultMatchers.status().isOk());

    Person result = personRepository.findById(1L).get();

    assertAll(
            () -> assertThat(result.getName()).isEqualTo("martin"),
            () -> assertThat(result.getHobby()).isEqualTo("programming"),
            () -> assertThat(result.getAddress()).isEqualTo("seoul"),
            () -> assertThat(result.getBirthday()).isEqualTo(Birthday.of(LocalDate.now())),
            () -> assertThat(result.getJob()).isEqualTo("programmer"),
            () -> assertThat(result.getPhoneNumber()).isEqualTo("010-1111-2222")
    );
}

03

Requestbody 파라미터로 엔티티 객체를 받는 것은 안전하지 못한 방법이다. ID나 Delete관련해서 사용자가 보내면 안되기 때문이다. 그래서 Dto 객체를 파라미터로 지정하여 값을 받고 서버에서 엔티티 객체를 만들도록 하는 것이 좋다.

// Controller
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 201 Response
public void postPerson(@RequestBody PersonDto personDto) { // 어노테이션이 없으면 @RequestParam으로 처리
    personService.put(personDto);
}


// Service
@Transactional
public void put(PersonDto personDto) {
    Person person = new Person();
    person.set(personDto);
    person.setName(personDto.getName());
    personRepository.save(person);
}

// Test
@Test
void postPerson() throws Exception {
    PersonDto dto = PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");
    mockMvc.perform(
            MockMvcRequestBuilders.post("/api/person")
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content(toJsonString(dto)))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(MockMvcResultMatchers.status().isCreated());

    Person result = personRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).get(0);
    assertAll(
            () -> assertThat(result.getName()).isEqualTo("martin"),
            () -> assertThat(result.getHobby()).isEqualTo("programming"),
            () -> assertThat(result.getAddress()).isEqualTo("seoul"),
            () -> assertThat(result.getBirthday()).isEqualTo(Birthday.of(LocalDate.now())),
            () -> assertThat(result.getJob()).isEqualTo("programmer"),
            () -> assertThat(result.getPhoneNumber()).isEqualTo("010-1111-2222")
    );
}

Repository Test

@Transactional
@SpringBootTest
class PersonRepositoryTest {

    @Autowired
    private PersonRepository personRepository;

    @Test
    void findByName() {
        List<Person> people = personRepository.findByName("tony");
        Assertions.assertThat(people.size()).isEqualTo(1);

        Person person = people.get(0);
        assertAll(
                () -> Assertions.assertThat(person.getName()).isEqualTo("tony"),
                () -> Assertions.assertThat(person.getHobby()).isEqualTo("reading"),
                () -> Assertions.assertThat(person.getAddress()).isEqualTo("서울"),
                () -> Assertions.assertThat(person.getBirthday()).isEqualTo(Birthday.of(LocalDate.of(1991, 7, 10))),
                () -> Assertions.assertThat(person.getJob()).isEqualTo("officer"),
                () -> Assertions.assertThat(person.getPhoneNumber()).isEqualTo("010-2222-5555"),
                () -> Assertions.assertThat(person.isDeleted()).isEqualTo(false)
        );
    }

    @Test
    void findByNameIfDelted() {
        List<Person> people = personRepository.findByName("andrew");
        Assertions.assertThat(people.size()).isEqualTo(0);
    }

    @Test
    void findByMonthOfBirthday() {
        List<Person> people = personRepository.findByMonthOfBirthday(7);

        Assertions.assertThat(people.size()).isEqualTo(2);
        assertAll(
                () -> Assertions.assertThat(people.get(0).getName()).isEqualTo("david"),
                () -> Assertions.assertThat(people.get(1).getName()).isEqualTo("tony")
        );
    }

    @Test
    void findPeopleDeleted() {
        List<Person> people = personRepository.findPeopleDeleted();

        Assertions.assertThat(people.size()).isEqualTo(1);
        Assertions.assertThat(people.get(0).getName()).isEqualTo("andrew");

    }
}

Service Test

mockito 테스트로 테스트를 해보자. build.gradle에 의존성을 추가해주자.

testImplementation 'org.mockito:mockito-junit-jupiter'

PersonServiceTest를 아래와 바꿔서 실행해본다.

@ExtendWith(MockitoExtension.class)
class PersonServiceTest {
    @InjectMocks
    private PersonService personService;
    @Mock
    private PersonRepository personRepository;

    @Test
    void getPeopleByName() {
        when(personRepository.findByName("martin"))
                .thenReturn(Lists.newArrayList(new Person("martin")));

        List<Person> result = personService.getPeopleByName("martin");

        Assertions.assertThat(result.size()).isEqualTo(1);
        Assertions.assertThat(result.get(0).getName()).isEqualTo("martin");
    }

    @Test
    void getPerson() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.of(new Person("martin")));

        Person person = personService.getPerson(1L);

        Assertions.assertThat(person.getName()).isEqualTo("martin");
    }

    @Test
    void getPersonIfNotFound() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.empty());

        Person person = personService.getPerson(1L);

        Assertions.assertThat(person).isNull();
    }

    @Test
    void put() {
        PersonDto dto = PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");

        personService.put(mockPersonDto());

        verify(personRepository, times(1)).save(argThat(new IsPersonWillBeInserted()));
    }

    @Test
    void modifyIfPersonNotFound() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.empty());

        assertThrows(RuntimeException.class, () -> personService.modify(1L, mockPersonDto()));
    }

    @Test
    void modifyIfNameIsDifferent() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.of(new Person("tony")));
        assertThrows(RuntimeException.class, () -> personService.modify(1L, mockPersonDto()));
    }

    @Test
    void modify() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.of(new Person("martin")));

        personService.modify(1L, mockPersonDto());

//        verify(personRepository, times(1)).save(any(Person.class)); // return void에 대한 확인
        verify(personRepository, times(1)).save(argThat(new IsPersonWillBeUpdated()));
    }

    @Test
    void modifyByNameIfPersonNotFound() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.empty());

        assertThrows(RuntimeException.class, () -> personService.modify(1L, "daniel"));
    }

    @Test
    void modifyByName() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.of(new Person("martin")));

        personService.modify(1L, "daniel");

        verify(personRepository, times(1)).save(argThat(new IsNameWillBeUpdated()));
    }

    @Test
    void deleteIfPersonNotFound() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.empty());

        assertThrows(RuntimeException.class, () -> personService.delete(1L));
    }

    @Test
    void delete() {
        when(personRepository.findById(1L))
                .thenReturn(Optional.of(new Person("martin")));
        personService.delete(1L);
        verify(personRepository, times(1)).save(argThat(new IsPersonWillBeDeleted()));
    }

    private PersonDto mockPersonDto() {
        return PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");
    }

    private static class IsPersonWillBeInserted implements ArgumentMatcher<Person> {
        @Override
        public boolean matches(Person person) {
            return equals(person.getName(), "martin")
                    && equals(person.getHobby(), "programming")
                    && equals(person.getAddress(), "seoul")
                    && equals(person.getBirthday(), Birthday.of(LocalDate.now()))
                    && equals(person.getJob(), "programmer")
                    && equals(person.getPhoneNumber(), "010-1111-2222");
        }
        private boolean equals(Object actual, Object expected) {
            return expected.equals(actual);
        }
    }

    private static class IsPersonWillBeUpdated implements ArgumentMatcher<Person> {
        @Override
        public boolean matches(Person person) {
            return equals(person.getName(), "martin")
                    && equals(person.getHobby(), "programming")
                    && equals(person.getAddress(), "seoul")
                    && equals(person.getBirthday(), Birthday.of(LocalDate.now()))
                    && equals(person.getJob(), "programmer")
                    && equals(person.getPhoneNumber(), "010-1111-2222");
        }

        private boolean equals(Object actual, Object expected) {
            return expected.equals(actual);
        }
    }

    private static class IsNameWillBeUpdated implements ArgumentMatcher<Person> {
        @Override
        public boolean matches(Person person) {
            return person.getName().equals("daniel");
        }
    }

    private static class IsPersonWillBeDeleted implements ArgumentMatcher<Person> {
        @Override
        public boolean matches(Person person) {
            return person.isDeleted();
        }
    }

Paging

많은 웹사이트들이 비단 제품 하나만 보는게 아니라 전체보기 같은 기능들을 제공한다. 그런데 정말로 서버에서 모든 데이터를 갖고오는 것은 비효율적이기 때문에 페이징 처리를 해야한다.

// Controller
@GetMapping
public Page<Person> getAll(@PageableDefault Pageable pageable) { // 기본 페이지 내용 설정
    return personService.getAll(pageable);
}

// Service
public Page<Person> getAll(Pageable pageable) {
    return personRepository.findAll(pageable);
}

// Test
@Test
void getAll() throws Exception {
    mockMvc.perform(
            MockMvcRequestBuilders.get("/api/person")
                    .param("page", "1")
                    .param("size", "2"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$.totalPages").value(3)) // $ 해당 객체
            .andExpect(MockMvcResultMatchers.jsonPath("$.totalElements").value(6))
            .andExpect(MockMvcResultMatchers.jsonPath("$.numberOfElements").value(2))
            .andExpect(MockMvcResultMatchers.jsonPath("$.content.[0].name").value("dennis"))
            .andExpect(MockMvcResultMatchers.jsonPath("$.content.[1].name").value("sophia"));
    }