Service Layer - Transactional

우선 Add, Update, Delete 부분을 구현하기에 앞서서, Spring Layer 에서 더 언급하고 넘어가야 할 부분이 있다.

기존에는 DAO method 에 대해서만 @Transactional annotation 을 사용하여 atomic 하게 작업이 수행되도록 하였다. 그러나, 이제는 Service Layer 의 하나의 service method 에서 여러 개의 DAO method 가 사용될 수 있다는 것을 알고 있다. 만약 하나의 DAO method 에서 오류가 발생하면 해당 service method 는 atomic 하게 수행되지 않는다는 위험성이 존재한다.

따라서, DAO method 보다 service method 에 @Transactional annotation 을 사용하여 service layer 가 transaction boundary 를 책임지도록 하는 것이 더 바람직할 것이다.

이제 본격적으로 구현을 해보자.

Update DAO

처음에는 DAO 에 대하여 구현을 시작해보자. Interface 는 다음과 같이 findById(), save(), deleteById method 에 대하여 declare 해주자.

public interface EmployeeDAO {   
    List<Employee> findAll();  
    Employee findById(int theId);  
    Employee save(Employee employee);  
    void deleteById(int theId);  
}

그리고 이에 대한 implementation 은 다음과 같다.

@Override  
public List<Employee> findAll() {  
    TypedQuery<Employee> theQuery = entityManager.createQuery("from Employee", Employee.class);  
    return theQuery.getResultList();  
}  
  
@Override  
public Employee findById(int theId) {  
    return entityManager.find(Employee.class, theId);  
}  
  
@Override  
public Employee save(Employee theEmployee) {  
    return entityManager.merge(theEmployee);  
}  
  
@Override  
public void deleteById(int theId) {  
    Employee theEmployee = entityManager.find(Employee.class, theId);  
    entityManager.remove(theEmployee);  
}

위에서 언급했듯이, DAO method 에는 @Transactional 을 설정하지 않을 것이다. 이후에 Service method 에 추가할 것이다.

Update Service

마찬가지로 Service Interface 에 DAO Interface 와 동일하게 추가한다.

public interface EmployeeService {   
    List<Employee> findAll();  
    Employee findById(int theId);  
    Employee save(Employee theEmployee);  
    void deleteById(int theId);  
}

그리고 이에 대한 Implementation 을 수행하자. Service method 에서는 단순하게 EmployeeDAO 를 사용하여 DAO method 를 call 하면 된다.

@Override  
public List<Employee> findAll() {  
    return employeeDAO.findAll();  
}  
  
@Override  
public Employee findById(int theId) {  
    return employeeDAO.findById(theId);  
}  
  
@Transactional  
@Override  
public Employee save(Employee theEmployee) {  
    return employeeDAO.save(theEmployee);  
}  
  
@Transactional  
@Override  
public void deleteById(int theId) {  
    employeeDAO.deleteById(theId);  
}

단순히 값을 read 만 하는 것에는 atomicity 가 필요없기 때문에 save, deleteById method 들에만 @Transactional annotation 을 추가해주면 된다.

Update Controller

Controller 를 update 하기 전에 API Design 단계에서 설계하였던 RESTful endpoint 들을 다시 확인하고, 순서대로 구현해보자.

HTTP MethodEndpointCRUD ActionImpl
GET/api/employeesRead a list of employeesO
GET/api/employees/{employeeId}Read a single employeeX
POST/api/employeesCreate a new employeeX
PUT/api/employeesUpdate an existing employeeX
DELETE/api/employees/{employeeId}Delete an existing employeeX

첫 번째 Specification(명세) 에 대하여는 이미 구현이 완료되었다. 또한 위 명세에서 볼 수 있듯이, 모두 같은 endpoint 를 사용하고 있기 때문에 올바른 HTTP method 를 사용하여 이어서 다음 Specification 에 대하여 구현을 이어가보자.

Update Controller - Read a Single Employee
@GetMapping("/employees/{employeeId}")  
public Employee getEmployee(@PathVariable int employeeId) {  
    Employee theEmployee = employeeService.findById(employeeId);  
  
    if (theEmployee == null) { throw new RuntimeException("Employee id not found - " + employeeId); }  
  
    return theEmployee;  
}

해당 specification 에서는 GET method 를 사용하며, employeeId 라는 path variable 을 Client 측으로부터 Controller 에 전달한다.

Update Controller - Create a New Employee
@PostMapping("/employees")  
public Employee addEmployee(@RequestBody Employee theEmployee) {  
    theEmployee.setId(0);  
  
    return employeeService.save(theEmployee);  
}

여기서 setId(0) 을 해주는 특별한 이유가 있다. save() 의 내부 구현인 entityManager.merge() method 는 id0 이나 null 일 경우에는 새로운 data 로 판단하여 INSERT query 가 실행되는 반면에, id 가 다른 값이라면 기존 DB 에 존재하는 data 라고 판단하여 UPDATE query 가 실행된다.

초기에 DB 를 설정할 때, id column 에 대하여는 자동 생성되도록 설정했었다. 하지만 만약 Client 가 임의로 request message 에 임의의 id 값을 추가하여 기존 DB 의 row 가 update 될 수 있는 위험성이 존재하기 때문에 id 값을 explicit 하게 0 으로 설정해주는 codeline 이 필요한 것이다.

또한 specification 에서는 POST method 를 사용한다. 그리고 addEmployee method 에서 사용되는 parameter 는 @RequestBody, 즉 request message 의 body 에 존재하는 data 이다.

이는 JSON 형식의 data 이기 때문에 Controller 가 해당 data 를 JSON format 으로 정상적인 parsing 을 하기 위하여는 HTTP request header 의 Content-type(MIME) variable 을 applicaion/json 으로 설정해주어야 한다.

Postman 에서는 위와 같이 [Body] > [raw] > [JSON] 으로 설정해주면, Postman 이 알아서 Header Variable 을 applicaion/json 으로 설정해줄 것이다. 그리고 HTTP method 를 POST 로 바꾸고, firstName, lastName, email 값을 설정하여 JSON 형식으로 [body] 에 작성하여 request 를 보내면 위와 같이 성공적으로 add 된 object data 를 확인할 수 있다.

실제 Database 에서도 잘 add 가 된 결과를 볼 수 있다.

Update Controller - Update an Existing Employee (PUT)
@PutMapping("/employees")  
public Employee updateEmployee(@RequestBody Employee theEmployee) {  
    return employeeService.save(theEmployee);  
}

save method 는 위에서 봤듯이 entityManager.merge method 를 사용하기 때문에 id0 이 아닐 경우에는 UPDATE query 가 실행된다. test 를 해보자.

마찬가지로 Postman 에서 위와 같이 설정을 하고, request 를 보내면 성공적으로 update 된 Database row 의 data 가 response 된 것을 볼 수 있다.

Update Controller - Update an Existing Employee (PATCH)

기존에 존재하는 entity 를 update 하는 방법은 PUT method 를 사용하는 것 외에 PATCH method 를 사용하는 방법도 있다. PUT method 는 request 를 통해 전달된 data 전체를 기존 data 에 덮어쓰기하는 방식이라면, PATCH 는 부분적으로, 예를 들면 column 하나의 값만을 바꾸는 식으로 update 가 가능하다.

@PatchMapping("/employees/{employeeId}")  
public Employee patchEmployee(@PathVariable int employeeId, @RequestBody Map<String, Object> patchPayload) {  
    Employee tempEmployee = employeeService.findById(employeeId);  
  
    if (tempEmployee == null) { throw new RuntimeException("Employee id not found - " + employeeId); }  
    if (patchPayload.containsKey("id")) { throw new RuntimeException("Employee id not allowed in request body - " + employeeId); }  
  
    Employee patchedEmployee = apply(patchPayload, tempEmployee);  
  
    return employeeService.save(patchedEmployee);  
}

우선 method 는 위와 같이 작성한다.

PUT method 와는 달리 partial update 를 실행하므로, request body 에 { "firstName": "Lucvs" } 와 같은 식으로 부분적인 JSON 만 전달한다. 그리고 이를 @RequestBody annotation 을 통하여 body 에 있는 data 를 Map<String, Object> 의 type 으로 받아온다.

method 의 전체적인 흐름은 다음과 같다. 먼저 findById method 를 통하여 path variable 로 받아온 employeeId 에 해당하는 employee 를 가져오고, 만족하는 employee 가 없다면 RuntimeException 을 throw 한다.

이후 patchPayload, 즉 request body 로 넘어온 data 에 id field 가 포함되어 있는지 검사한다. id 가 포함되었을 때의 위험성은 위에서 다루었다. 마지막으로 tempEmployeepatchPayload 를 합치는 작업을 수행한다. apply 라는 method 를 만들어서 사용하는데, 이에 대한 definition 은 다음과 같이 작성한다.

private Employee apply(Map<String, Object> patchPayload, Employee tempEmployee) {  
    ObjectNode employeeNode = objectMapper.convertValue(tempEmployee, ObjectNode.class);  
    ObjectNode patchNode = objectMapper.convertValue(patchPayload, ObjectNode.class);  
    employeeNode.setAll(patchNode);  
  
    return objectMapper.convertValue(employeeNode, Employee.class);  
}

method 설명에 앞서 ObjectMapper 에 대하여 알아야 한다. ObjectMapper 는 Jackson library 의 핵심 class 중 하나로, Java object 와 JSON 간의 변환(serialization/deserialization)을 담당한다.

또한 ObjectNode 는 Jackson 의 JsonNode 하위 class 로, immutable(불변) object 인 JsonNode 를 mutable 하게 사용하기 위하여 ObjectNode 를 사용한다.

method 흐름은 다음과 같다. 우선 tempEmployeepatchPayload 를 모두 objectMapper.convertValue() method 를 사용하여 ObjectNode type 으로 변환하고, setAll() method 를 통하여 key-value 쌍으로 구성된 field 를 employeeNode 에 overwrite 한다.

마지막으로, employeeNodeEmployee class type 으로 변환하여 return 하는 구조이다. 이렇게 되면 최종적으로는 request body 로 전달한 JSON format 의 data 가 말그대로 patch 되어 DB 에 update 된다.

test 해보면 성공적으로 결과가 response 된 것을 확인할 수 있다.

Update Controller - Delete an Existing Employee
@DeleteMapping("/employees/{employeeId}")  
public String deleteEmployee(@PathVariable int employeeId) {  
    Employee tempEmployee = employeeService.findById(employeeId);  
  
    if (tempEmployee == null) { throw new RuntimeException("Employee id not found - " + employeeId); }  
  
    employeeService.deleteById(employeeId);  
    
    return "Deleted employee id - " + employeeId;  
}

Delete 는 간단하다. path variable 로 전달받은 employeeId 값으로 findById() method 를 사용하여 해당 object 를 deleteById method 를 이용하여 삭제하는 방식이다.

test 해보면 성공적으로 삭제가 된 것을 알 수 있다.

실제 DB table 에서도 잘 적용이 된 모습을 볼 수 있다.