'React.js, 스프링 부트, AWS로 배우는 웹 개발 101'을 읽고 정리한 글입니다.
학습 내용
- 레이어드 아키텍처 패턴
- REST 아키텍처 스타일
- 스프링 어노테이션
- JPA와 스프링 Data JPA
실습 내용
- Model/Entity와 DTO 클래스
- Controller, Service, Persistence 클래스
- 테스팅용 REST API
레이어드 아키텍처 패턴은 스프링 프로젝트 내부에서 어떻게 코드를 적절히 분리하고 관리할 것이냐에 대한 것이다. 코드를 적절히 분리하고 관리하는 것은 코드 베이스가 커질수록 중요하다.
REST 아키텍처 스타일은 클라이언트(브라우저)가 우리 서비스를 이용하려면 어떤 형식으로 요청을 보내고 응답을 받는지에 대한 것이다. 클라이언트는 몇 개의 정해진 메서드로 우리 서비스를 이용할 예정이다.
이렇게 REST 아키텍처 스타일을 따라 설계된 구현된 서비스를 RESTful 서비스라고 한다.
스프링은 레이어드 아키텍처 패턴이 REST 아키텍처 스타일을 이용하는 데 도움을 주는 어노테이션을 제공한다.
이 어노테이션을 이용해 테스팅용 API를 구현하고 전체적인 틀을 이해한 후 본격적으로 Todo 서비스를 만들 것이다.
2.2.1 레이어드 아키텍처 Layered Architecture
레이어드 아키텍처 패턴은 애플리케이션을 구성하는 요소들을 수평으로 나눠 관리하는 것이다.
수평으로 나눴다는 것을 무슨 뜻일까?
그림처럼 레이어로 나눠 놓은 것들을 하나의 클래스, 하나의 메서드 안에 전부 구현한다고 생각해 보자.
public String getTodo(Request request) {
// 요청 검사
if (request.userId == null) {
JSONOjbect json = new JSONOjbect();
json.put("error", "missing user id");
return json.toString();
}
List<Todo> todos = new ArrayList<>();
// 데이터베이스 콜
String sqlSelectAllPersons = "SELECT * FROM TOdo where USER_ID = "
+ request.getUserId();
String connectionUrl = "jdbc:mysql://mydb:3306/todo";
try (Connection conn = DriverManager.getConnection(connectionUrl, "username", "password");
PreparedStatement ps = conn.prepareStatement(sqlSelectAllPersons);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("ID");
String title = rs.getString("TITLE");
Boolean isDone = rs.getBoolean("IS_DONE");
todos.add(new Todo(id, title, isDone));
}
} catch (SQLException e) {
// handle the exception
}
// 응답 생성
JSONObject json = new JSONObject();
JSONArray array = new JSONArray();
for (Todo todo : todos) {
JSONObject todoJson = new JSONObject();
jsonObj.put("id", todo.getId());
jsonObj.put("title", todo.getTitle());
json.put("isdone", todo.isDone());
array.put(json);
}
json.put("data", array);
return json.toString();
}
위와 같은 코드는 데이터베이스 라이브러리와 JSON을 위한 라이브러리가 많기 때문에 잘 작성하지 않는다. 그러나 라이브러리가 없던 시절이라 생각하면 getTodo보다 복잡한 비즈니스 로직을 구현한다면 메서드가 금방 몇 백 줄을 넘을 것이다. 이런 경우 메서드를 쪼개 작은 메서드로 나누는 것이 좋다.
public String getTodo(Request request) {
// 요청 검사
if (request.userId == null) {
JSONOjbect json = new JSONOjbect();
json.put("error", "missing user id");
return json.toString();
}
List<Todo> todos = this.getTodoFromPersistence(request);
return this.getResponse(todos);
}
private List<Todo> getTodoFromPersistence(Request request) {
List<Todo> todos = new ArrayList<>();
// 데이터베이스 콜
String sqlSelectAllPersons = "SELECT * FROM TOdo where USER_ID = "
+ request.getUserId();
String connectionUrl = "jdbc:mysql://mydb:3306/todo";
try (Connection conn = DriverManager.getConnection(connectionUrl, "username", "password");
PreparedStatement ps = conn.prepareStatement(sqlSelectAllPersons);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("ID");
String title = rs.getString("TITLE");
Boolean isDone = rs.getBoolean("IS_DONE");
todos.add(new Todo(id, title, isDone));
}
} catch (SQLException e) {
// handle the exception
}
return todos;
}
private String getResponse(List<Todo> todos) {
// 응답 생성
JSONObject json = new JSONObject();
JSONArray array = new JSONArray();
for (Todo todo : todos) {
JSONObject todoJson = new JSONObject();
jsonObj.put("id", todo.getId());
jsonObj.put("title", todo.getTitle());
json.put("isdone", todo.isDone());
array.put(json);
}
json.put("data", array);
return json.toString();
}
메서드로 코드를 더욱 깔끔하게 만들었지만, 여전히 문제가 있다.
다른 클래스에서 위 코드와 같이 데이터베이스에서 Todo를 불러오는 작업을 하는 경우다. 이런 경우 메서드를 복사-붙여 넣기 하면 된다. 이렇게 복사-붙여 넣기를 하다 보면 이 메서드는 클래스로 따로 빼서 쓰는 것이 더 낫다는 생각이 들기 시작한다. 이 작업이 작게 봤을 때 레이어로 나누는 것이다. 이렇게 레이어로 나눈다는 것은 메서드를 클래스 또는 인터페이스로 쪼개는 것이다. 이 레이어는 여러 레이어로 나누는 것부터 아주 다른 애플리케이션으로 레이어를 분리하는 경우까지 범위가 다양하다.
이 레이어의 또 다른 특징은 레이어 사이에 계층이 있다는 점이다. 그래서 레이어는 자기보다 한 단계 하위의 레이어만 사용한다. 대략 설명하자면 아래와 같다.
- 컨트롤러가 요청을 받는다.
- 컨트롤러는 서비스를 호출하고 서비스는 퍼시스턴스를 호출한다.
- 퍼시스턴스는 요청한 데이터를 반환한다.
- 서비스는 데이터를 검토 및 가공한 후 컨트롤러에게 반환한다.
- 컨트롤러 또한 데이터를 검토 및 가공한 후 응답을 반환한다.
반드시 정해진 대로 하위 레이어만 사용해야 하는 것은 아니다. 필요에 따라 서비스가 서비스를 사용하기도 하고 레이어가 많은 경우 중간 레이어를 섞어 사용하는 경우도 있다. 하지만 기본적인 레이어드 아키텍처에서는 상위 레이어가 자신의 바로 하위 레이어를 사용한다고 한다.
public class TodoService {
public List<Todo> getTodos(String userId) {
List<Todo> todos = new ArrayList<>();
// ... 비즈니스 로직
return todos;
}
}
public class WebController {
private TodoService todoService;
public String getTodo(Request request) {
// request validation
if (request.userId == null) {
JSONObject json = new JSONObject();
return json.toString();
}
// 서비스 레이어
List<Todo> todos = service.getTodos(request.userId);
return this.getResponse(todos);
}
}
위처럼 분리한 경우 다른 클래스에서 getTodos를 사용하고 싶다면 서비스 클래스를 사용하면 된다. 또 비즈니스 로직이 변하는 경우 서비스 레이어의 클래스만 수정하면 된다.
2.2.2 모델, 엔티티, DTO
이제 용도가 조금 다른 클래스에 대해 살펴보자.
보통 자바로 된 비즈니스 애플리케이션의 클래스는 두 가지 종류로 나눌 수 있다.
- 일을 하는 클래스, 즉 기능을 수행하는 클래스 (Controller, Service, Persistence...)
- 컨트롤러, 서비스, 퍼시스턴스처럼 로직을 수행한다.
- 데이터를 담는 클래스 (DTO, DAO...)
- 말 그대로 데이터만 갖고 있다.
모델과 엔티티
이 프로젝트에서는 모델과 엔티티를 한 클래스에 구현한다.
따라서 모델은 비즈니스 데이터를 담는 역할과 데이터베이스의 테이블과 스키마를 표현하는 두 역할을 한다.
큰 애플리케이션의 경우 모델과 엔티티를 따로 구현하지만 우리는 규모가 작으므로 합쳐서 구현하는 것이다.
이 모델/엔티티의 이름은 TodoEntity이고 Todo 리스트의 한 아이템에 해당한다.
TodoEntity
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoEntity {
private String id; // 오브젝트의 아이디
private String userId; // 이 오브젝트를 생성한 사용자의 아이디
private String title; // Todo 타이틀(ex: 운동하기)
private boolean done; // true - todo를 완료한 경우(checked)
}
@Builder
- 오브젝트 생성을 위한 디자인 패턴 중 하나
- Builder 클래스를 따로 개발하지 않고도 Builder 패턴을 사용해 오브젝트를 생성할 수 있다.
아래 코드와 같이 사용할 수 있다.
TodoEntity todo = TodoEntity.builder()
.id("t-10328373")
.userId("developer")
.title("Implement Model")
.build();
- 생성자를 이용해 오브젝트를 생성하는 것과 비슷하다.
- 생성자 매개변수의 순서를 기억할 필요가 없다.
@NoArgsConstructor
- 매개변수가 없는 생성자를 구현해 준다.
public TodoEntity() {
}
@AllArgsConstructor
- 모든 멤버 변수를 매개변수로 받는 생성자를 구현해 준다.
public TodoEntity(String id, String userId, String title, boolean done) {
super();
this.id = id;
this.userId = userId;
this.title = title;
this.done = done;
}
@Data
- 클래스 멤버 변수의 Getter/Setter 메서드를 구현해 준다.
- @Data 어노테이션을 사용하면 아래와 같은 메서드를 사용할 수 있다.
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public boolean isDone() {
return done;
}
public void setDone(boolean done) {
this.done = done;
}
DTO (Data Transfer Object)
서비스가 요청을 처리하고 클라이언트로 반환할 때 모델 자체를 그대로 리턴하는 경우는 별로 없다.
보통은 데이터를 전달하는 데 사용하는 오브젝트인 DTO로 변환해 리턴한다.
왜 그냥 모델을 리턴하지 않고 DTO로 변환하는 것일까?
- 비즈니스 로직을 캡슐화하기 위함이다.
모델이 갖고 있는 필드들은 데이터베이스 테이블의 스키마와 비슷할 확률이 높다. 대부분의 비즈니스는 외부인이 자사의 데이터베이스의 스키마를 아는 것을 원치 않는다. 이때 DTO처럼 다른 오브젝트로 바꿔 반환하면 외부 사용자에게 서비스 내부의 로직, 데이트베이스의 구조 등을 숨길 수 있다. - 클라이언트가 필요한 정보를 모델이 전부 포함하지 않는 경우가 많기 때문이다.
대표적인 예로 만약 서비스 실행 도중 사용자 에러가 나면 에러 메시지를 어디에 포함해야 하는가? 모델은 서비스 로직과는 관련이 없기 때문에 모델에 담기는 애매하다. 이런 경우 DTO에 에러 메시지 필드를 선언하고 DTO에 포함하면 된다.
TodoDto
package com.example.demo.dto;
import com.example.demo.model.TodoEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoDTO {
private String id;
private String title;
private boolean done;
public TodoDTO(final TodoEntity entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.done = entity.isDone();
}
}
DTO에 userId가 없다. 이 프로젝트는 스프링 시큐리티를 이용해 인증을 구현할 것이다. 따라서 사용자가 자기 아이디를 넘겨주지 않아도 인증이 가능하다. userId는 애플리케이션과 데이터베이스에서 사용자를 구별하는 고유 식별자로 사용하기 때문에 숨길 수 있다면 숨기는 것이 보안상 맞다. 따라서 DTO에는 userId를 포함하지 않았다.
ResponseDTO
이제 HTTP 응답으로 사용할 DTO가 필요하다.
package com.example.demo.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseDTO<T> {
private String error;
private List<T> data;
}
TodoDTO뿐만 아니라 이후 다른 모델의 DTO도 ResponseDTO를 이용해 리턴할 수 있도록 Generic을 이용했다.
또 Todo를 하나만 반환하는 경우보다 리스트를 반환하는 경우가 많으므로 데이터를 리스트로 반환하도록 작성했다.
2.2.3 REST API
- 아키텍처 패턴: 어떤 반복되는 문제 상황을 해결하는 도구
- 아키텍처 스타일: 반복되는 아키텍처 디자인
REST 아키텍처 스타일은 6가지 제약조건으로 구성되는 이 조건을 따르는 API를 RESTful API라고 한다.
REST 제약조건
- 클라이언트-서버 Client-Server
- 상태가 없는 Stateless
- 캐시되는 Cachable 데이터
- 일관적인 인터페이스 Uniform interface
- 레이어 시스템 Layered System
- 코드-온-디맨트 Code-On-Demand (선택사항)
클라이언트-서버
클라이언트-서버라는 것은 리소스를 관리하는 서버가 존재하고 다수의 클라이언트가 리소스를 소비하려고 네트워크를 통해 서버에 접근하는 구조를 의미한다.
상태가 없음
클라이언트가 서버에 요청을 보낼 때 이전 요청의 영향을 받지 않음을 의미한다.
예를 들어 /login으로 로그인 요청을 보내고 로그인이 되어 다음 페이진 /page로 넘어갔다고 가정하자.
/page로 리소스를 불러올 때 이전 요청에서 login 한 사실을 서버가 알고 있어야 한다면 그것은 상태가 있는 아키텍처다. 서버가 그 사실을 알지 못한다면 상태가 없는 것이다.
그럼 로그인을 어떻게 할까?
클라이언트는 서버에 요청을 할 때마다 요청에 리소스를 받기 위한 모든 정보를 포함해야 한다.
예를 들어 로그인의 경우 서버는 로그인 상태를 유지하지 못하므로 요청을 보낼 때마다 로그인 정보를 항상 함께 보내야 한다. 리소스를 수정한 후 수정한 상태를 유지해야 하는 경우에는 서버가 아닌 데이터베이스 같은 퍼시스턴스에 상태를 저장해야 한다.
- HTTP는 기본적으로 상태가 없는 프로토콜이다.
- 따라서 HTTP를 사용하는 웹 애플리케이션은 기본적으로 상태가 없는 구조를 따른다.
캐시되는 데이터
- 서버에서 리소스를 리턴할 때 캐시가 가능한지 아닌지 명시할 수 있어야 한다.
- HTTP에서는 cache-control이라는 헤더에 리소스의 캐시 여부를 명시할 수 있다.
일관적인 인터페이스
시스템 또는 애플리케이션의 리소스에 접근할 때 인터페이스가 일관적이어야 한다는 뜻이다.
예를 들어 Todo 아이템을 가져오라고 https://google.com/todo를 사용했다고 가정하자.
이때 Todo 아이템을 업데이트하는 데 https://google2.com/todo를 사용해야 한다면 이는 일관적인 인터페이스가 아니다.
이 경우 URI의 일관성이 지켜지지 않은 것이다.
다른 예로 https://google.com/todo는 JSON 형식의 리소스를 리턴했다.
그런데 https://google.com/account는 HTML을 리턴했다.
이런 인터페이스는 리턴 타입에 일관성이 있다고 할 수 없다.
이렇게 리소스에 접근하는 방식, 요청 형식, 응답 형식, 즉 URI, 요청의 형태와 응답의 형태가 애플리케이션 전반에 걸쳐 일관적이야 한다는 것이 일관적인 인터페이스 방침이다.
또 서버가 리턴하는 응답에는 해당 리소스를 수정할 수 있는 충분한 정보가 있어야 한다.
예를 들어 Todo 아이템을 받아왔는데 ID가 없다면 클라이언트는 Todo 아이템을 업데이트하거나 삭제하지 못한다.
이 경우 리소스를 수정하는 데 충분한 정보가 부족한 것이다.
레이어 시스템
클라이언트가 서버에 요청을 할 때 여러 개의 레이어로 된 서버를 거칠 수 있다.
예를 들어 서버가 인증 서버, 캐싱 서버, 로드 밸런서를 거쳐서 최종적으로 애플리케이션에 도착한다고 가정하자.
이 사이의 레이어들은 요청과 응답에 어떤 영향을 미치지 않으며 클라이언트는 서버의 레이어 존재 유무를 알지 못한다.
코드-온-디맨드 (선택사항)
클라이언트는 서버에 코드를 요청할 수 있고 서버가 리턴한 코드를 실행할 수 있다.
REST와 HTTP는 다르다.
엄밀히 말하면 REST는 아키텍처이고, HTTP는 REST 아키텍처를 구현할 때 사용하면 쉬운 프로토콜이다.
2.2.4 컨트롤러 레이어: 스프링 REST API 컨트롤러
서버는 HTTP 요청을 받은 후 어떻게 처리해야 할까?
GET /test HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 17
{
"id": 123
}
위와 같은 HTTP 리퀘스트가 있다고 가정하자.
- localhost:8080에 GET 메서드를 이용해 test라는 리소스를 요청한다는 뜻이다.
- 서버는 자기 주소를 제외한 /{리소스} 부분을 이해하고 또 이 요청이 어떤 HTTP 메서드를 이용했는지 알아야 한다.
- 그 후 해당 리소스의 HTTP 메서드에 연결된 메서드를 실행해야 한다.
spinrg-boot-starter-web의 어노테이션을 이용하면 이 연결을 쉽게 할 수 있다.
앞서 프로젝트 설정 시 build.gradle에 스프링 부트 스타터 웹 라이브러리를 다운로드하였다.
implementation 'org.springframework.boot:spring-boot-starter-web'
지금부터 할 연결 작업의 어노테이션은 모두 스프링 부트 스타터 웹 패키지에서 제공하는 것이다.
TestController
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("test") // 리소스
public class TestController {
@GetMapping
public String testController() {
return "Hello World!";
}
}
controller 패키지에 컨트롤러와 관련된 클래스만 추가한다.
- REST API를 구현할 것이므로 @RestController 어노테이션을 이용해 이 컨트롤러가 RestController임을 명시한다.
- RestController를 이용하면 http와 관련된 코드 및 요청/응답 매핑을 스프링이 알아서 해준다.
컨트롤러 테스팅을 위해 testController 메서드를 추가했다.
- @GetMapping 어노테이션을 이용해 이 메서드의 리소스와 HTTP 메서드를 지정한다.
클라이언트가 이 리소스에 대해 Get 메서드로 요청하면 @GetMapping에 연결된 컨트롤러가 실행된다.
위 메서드에서는 "localhost:8080/text"의 GET 메서드가 testController()에 연결된다는 의미다. 이 작업을 스프링이 해준다.
실행 결과
브라우저에서 URI를 입력해 접근하는 것은 GET 요청이다.
포스트맨을 이용해 테스팅하면 HTTP 메서드를 확실히 구분할 수 있다.
스프링은
- @RequestMapping("/test")는 URI 경로에
- @GetMapping은 HTTP 메서드에 매핑한다.
그런데 URI 경로를 꼭 @RequestMapping에서만 지정할 수 있는 것은 아니다.
@GetMapping에서도 URI 경로를 지정할 수 있다.
@GetMapping("/testGetMapping")
public String testControllerWithPath() {
return "Hello World! testGetMapping";
}
위 메서드를 추가로 TestContoller 클래스에 구현하고 실행해보자.
@GetMapping의 매개변수로 경로를 지정하는 경우 스프링이 어떻게 URI를 매핑하는지 알아보자.
- 스프링은 이 요청이 GET 요청임을 알고 있으므로 GetMapping이 지정된 메서드를 실행해야 한다는 사실을 안다.
- 또 메서드 testControllerWithPath()의 클래스에 추가된 @RequestMapping("test"와 @GetMapping("/testGetMapping")을 통해 /test/testGetMapping이 이 메서드에 연결돼야 한다는 사실도 안다.
만약 @ReqeustMapping 어노테이션을 추가하지 않는다면 이 메서드는 localhost:8080/testGetMapping에 연결된다.
@RestController
public class TestController {
@GetMapping
public String testController() {
return "Hello World!";
}
@GetMapping("/testGetMapping")
public String testControllerWithPath() {
return "Hello World! testGetMapping";
}
}
또 전체 경로를 @GetMapping에 지정할 수도 있다. 섞어서 사용하는 것도 가능하다.
@GetMapping("/test/testGetMapping")
매개변수를 넘겨받는 방법
/test가 아닌 /test/{id}로 PathVariable이나 /test?id=123처럼 요청 매개변수를 받아야 한다면 어떻게 해야 할까?
@PathVariable
@PathVariable을 이용하면 /{id}와 같이 URI의 경로로 넘어오는 값을 변수로 받을 수 있다.
TestController 클래스에 아래 코드를 구현한 후 실행해 보자.
@GetMapping("/{id}")
public String testControllerWithPathVariables(@PathVariable(required = false) int id) {
return "Hello World! ID" + id;
}
PathVariable도 마찬가지로 위 그림처럼 매핑된다.
- @GetMapping("/{id}")의 매개변수 /{id}는 경로로 들어오는 임의의 숫자 또는 문자를 변수 id에 매핑하라는 뜻이다.
- 위 그림에서 id가 정수형이므로 test/ 다음에 오는 정수가 id에 매핑된다.
- required = false 는 이 매개변수가 꼭 필요한 것은 아니라는 뜻이다.
id=123을 명시하지 않아도 에러가 나지 않는다.
@RequestParam
@RequestParam을 이용하면 ?id={id}와 같이 요청 매개변수로 넘어오는 값을 변수로 받을 수 있다.
TestController 클래스에 아래 코드를 구현한 후 실행해보자.
@GetMapping("/testRequestParam")
public String testControllerRequestParam(@RequestParam(required = false) int id) {
return "Hello World! Id " + id;
}
@RequestBody
@RequestBody는 보통 반환하고자 하는 리소스가 복잡할 때 사용한다.
예를 들어 오브젝트처럼 복잡한 자료형을 통째로 요청에 보내고 싶은 경우가 이에 해당한다.
package com.example.demo.dto;
import lombok.Data;
@Data
public class TestRequestBodyDTO {
private int id;
private String message;
}
dto 패키지 안에 TestRequestBodyDTO를 생성한 후 위 코드를 구현한다.
그다음 TestController에 요청 바디를 넣은 메서드를 추가해 보자.
@RestController
@RequestMapping("test")
public class TestController {
// /test 경로는 이미 존재하므로 /test/testRequestBody로 지정했다.
@GetMapping("/testRequestBody")
public String testControllerRequestBody(@RequestBody TestRequestBodyDTO testRequestBodyDTO) {
return "Hello World! ID " + testRequestBodyDTO.getId() +
" Message : " + testRequestBodyDTO.getMessage();
}
}
public String testControllerRequestBody(@RequestBody TestRequestBodyDTO testRequestBodyDTO)
@RequestBody TestRequestBodyDTO testRequestBodyDTO는
RequestBody로 보내오는 JSON을 TestRequestBodyDTO 오브젝트로 변환해 가져오라는 뜻이다.
다시 말해 클라이언트는 Request Body로 JSON 형태의 문자열을 넘겨준다.
이 JSON의 내부는 의미적으로 TestRequestBodyDTO와 같아야 한다.
다른 타입의 값이 들어오면 에러가 발생한다.
포스트맨에서 Body => raw => JSON을 선택하고 id와 message를 넣고 실행하면 결과가 리턴된다.
@ResponseBody
만약 문자열보다 복잡한 오브젝트를 리턴하려면 어떻게 해야 할까?
요청을 통해 오브젝트를 가져올 수 있는데 응답으로 오브젝트를 리턴할 수 있다.
이를 구현하는 방법은 그냥 오브젝트를 리턴하면 된다.
@RestController 어노테이션이 이를 도와준다.
@Controller
@ResponseBody
public @interface RestController {
...
}
@RestController의 내부를 보면 크게 두 어노테이션의 조합으로 이뤄져 있다.
- @Controller는 @Component로 스프링이 이 클래스의 오브젝트를 알아서 생성하고 다른 오브젝트들과의 의존성을 연결한다는 뜻이다.
- @Responsebody는 이 클래스의 메서드가 리턴하는 것은 웹 서비스의 ResponseBody라는 뜻이다.
즉, 메서드가 리턴할 때 스프링은 리턴된 오브젝트를 JSON의 형태로 바꾸고 HttpResponse에 담아 반환한다는 뜻이다.
ResponseDTO를 리턴하는 컨트롤러를 구현해보자.
이 프로젝트의 모든 컨트롤러는 ResponseDTO를 반환할 예정이다.
@GetMapping("/testResponseBody")
public ResponseDTO<String> testControllerResponseBody() {
List<String> list = new ArrayList<>();
list.add("Hello World! I'm ResponseDTO");
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
return response;
}
ResponseEntity
ReponseEntity는 HTTP 응답의 바디뿐만 아니라 여러 다른 매개변수들, status나 header 등을 조작할 때 사용한다.
@GetMapping("/testResponseEntity")
public ResponseEntity<?> testControllerResponseEntity() {
List<String> list = new ArrayList<>();
list.add("Hello World! I'm ResponseEntity. And you got 400!");
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
// http status를 400으로 설정
return ResponseEntity.badRequest().body(response);
}
ResponseDTO
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseDTO<T> {
private String error;
private List<T> data;
}
포스트맨을 이용해 400 Bad Request가 반환된 것을 확인할 수 있다.
ResponseEntity를 리턴하는 것과 ResponseDTO를 리턴하는 것을 비교했을 때 리턴된 body에는 아무 차이가 없다.
단지 Header와 HTTP Status를 조작할 수 있다는 점이 다르다는 것을 기억하자.
정상적으로 응답을 반환한다면 ok() 메서드를 사용하면 된다.
return ResponseEntity.ok().body(response);
TodoController
package com.example.demo.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("todo")
public class TodoController {
// testTodo 메서드 작성하기
}
2.2.5 서비스 레이어 : 비즈니스 로직
서비스 레이어는
- 컨트롤러와 퍼시스턴스 사이에서 비즈니스 로직을 수행하는 역할을 한다.
- HTTP와 긴밀히 연관된 컨트롤러와 데이터베이스와 긴밀히 연관된 퍼시스턴스와도 분리돼 있다.
따라서 서비스 레이어에서는 개발하고자 하는 로직에 집중할 수 있다.
TodoService
Todo 프로젝트를 위한 비즈니스 로직 구현을 위해 TodoService를 만들어 보자.
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service
public class TodoService {
public String testService() {
return "Test Service";
}
}
@Service
- 스테레오 타입 어노테이션이다.
- @Service는 내부에 @Component 어노테이션을 갖고 있는데 @Component 어노테이션과 비교했을 때 특별한 기능 차이는 없다.
- 단지 이 클래스는 스프링 컴포넌트이며 기능적으로는 비즈니스 로직을 수행하는 서비스 레이어임을 알려주는 것이다.
간단하게 서비스를 만들었으므로 이 서비스의 사용을 위해 TodoController를 수정하도록 하자.
TodoController
@RestController
@RequestMapping("todo")
public class TodoController {
// testTodo 메서드 작성하기
@Autowired
private TodoService service;
@GetMapping("/test")
public ResponseEntity<?> testTodo() {
String str = service.testService(); // 테스트 서비스 사용
List<String> list = new ArrayList<>();
list.add(str);
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
return ResponseEntity.ok().body(response);
}
}
@RestController도 내부에 @Component 어노테이션을 갖고 있기 때문에 @Service, @RestController 모두 자바 빈이고 스프링이 관리한다.
- 스프링은 TodoController 오브젝트를 생성할 때 TodoController 내부에 선언된 TodoService에 @Autowired 어노테이션이 붙어 있다는 것을 확인한다.
- @Autowired가 알아서 빈을 찾은 다음 그 빈을 이 인스턴스 멤버 변수에 연결하라는 뜻이다.
그러므로 TodoController를 초기화할 때 스프링은 알아서 TodoService를 초기화 또는 검색해 TodoController에 주입해 준다.
테스팅
Test Service를 담은 ResponseDTO가 리턴되는 것을 확인할 수 있다.
2.2.6 퍼시스턴스 레이어 : 스프링 데이터 JPA
이전에 Todo 아이템을 관계형 데이터베이스에 저장하기로 했다.
보통 관계형 데이터베이스에 쿼리를 날리기 위해 어떻게 하는가?
- 데이터베이스 클라이언트를 설치한다.
예를 들어 MySQL이라면 MySQL Workbench 같은 MySQL 클라이언트를 설치한다. - 클라이언트는 데이터베이스에 연결하는 작업을 도와준다.
- 클라이언트를 이용해 데이터베이스에 연결되면 쿼리를 작성한다.
CREATE TABLE IF NOT EXISTS Todo(
id VARCHAR(100) NOT NULL PRIMARY KEY,
userId VARCHAR(100) NOT NULL,
title VARCHAR(100) NOT NULL,
done boolean DEFAULT false
);
위와 같은 쿼리를 통해 테이블을 생성하고 몇 개 넣었다고 가정하자.
그러면 이후 이 아이템의 검색을 위해 SELECT 쿼리를 작성한다.
SELECT id, title, done
FROM Todo
where id = "ff80808177"
이 결과 조건에 맞는 결과는 다음과 같은 모양일 것이다.
그런데 이렇게 리턴된 결과를 자바 애플리케이션 내에서 사용해야 한다. 어떻게 해야 할까?
자바에서 데이터베이스에 연결할 수 있도록 도와주는 라이브러리인 JDBC 드라이버가 필요하다.
쉽게 말하자면 MySQL 클라이언트 같은 것이다.
JDBC 드라이버를 통해 데이터베이스에 연결했다고 가정하자. SQL을 작성해 보내면 위와 같은 테이블이 자바로 리턴된다.
이해를 위해 데이터베이스 호출 부분의 코드 스니펫을 살펴보자.
// 데이터베이스 호출
String sqlSelectAllTodos = "SELECT * FROM Todo where USER_ID = " + request.getUserId();
String connectionUrl = "jdbc:mysql://mydb:3306/todo";
try {
/* (1) 데이터베이스에 연결 */
Connection conn = DriverManager.getConnection(connectionUrl, "username", "password");
/* (2) SQL 쿼리 준비 */
PreparedStatement ps = conn.prepareStatement(sqlSelectAllTodos);
/* (3) 쿼리 실행 */
ResultSet rs = ps.executeQuery();
/* (4) 결과를 오브젝트로 파싱 */
while (rs.next()) {
long id = rs.getString("id");
String title = rs.getString("title");
Boolean isDone = rs.getBoolean("done");
todos.add(new Todo(id, title, isDone));
}
} catch (SQLException e) {
// handle the exception
}
- JDBC 커넥션인 Connection을 이용해 데이터베이스에 연결하고
- sqlSelectAllTodos에 작성된 SQL을 실행한 후
- ResultSet이라는 클래스에 결과를 담아온다.
- 그리고 while문 내부에서 ResultSet을 Todo 오브젝트로 바꿔준다.
이러한 일련의 작업을 ORM(Object-Relation Mapping)이라고 한다.
- 자바 내에서 데이터베이스 테이블을 사용하려면 이 작업을 엔티티마다 해줘야 한다.
보통 데이터베이스 테이블 하나마다 그에 상응하는 엔티티 클래스가 존재한다.
ex) MySQL의 Todo 테이블 - TodoEntity.java - 또 이런 ORM 작업을 집중적으로 해주는 DAO(Data Access Object) 클래스를 작성해야 한다.
테이블과 매핑하는 오브젝트만 다르고 하는 일은 거의 비슷하다. - 보통 CRUD 같은 기본적인 연산은 엔티티마다 작성해 준다.
시간이 흐르며 이런 반복 작업을 줄일 수 있는 Hibernate 같은 ORM 프레임워크가 등장했고 JPA나 스프링 데이터 JPA 같은 도구들이 개발됐다.
JPA는 반복해서 데이터베이스 쿼리를 보내 ResultSet을 파싱해야 하는 개발자들의 노고를 덜어준다.
JPA란 자바에서 데이터베이스 접근, 저장 관리에 필요한 스펙(Specification)이다.
이 스펙을 구현하는 구현자를 JPA Provider라고 부르는데 그중 대표적으로 Hibernate가 있다.
그렇다면 스프링 데이터 JPA는 무엇이고 JPA와 어떤 관계인가?
스프링 데이터 JPA는 JPA + a라고 생각하면 된다.
JPA를 더 사용하기 쉽게 도와주는 스프링의 프로젝트인데 기술적으로는 이를 추상화했다고 한다.
추상화했다는 것은 사용하기 쉬운 인터페이스를 제공하는 것이다.
그런 인터페이스 중 하나인 JpaRespository를 사용해보자.
데이터베이스와 스프링 데이터 JPA 설정
데이터베이스에 연결하려면 우선 데이터베이스가 필요하다. 이전에 우리는 H2를 디펜던시에 추가했다.
H2는 In-Memory 데이터베이스로 로컬 환경에서 메모리상에 데이터베이스를 구축해 준다. H2를 사용하면 개발자들이 따로 데이터베이스 서버를 구축하는 데 시간을 할애할 필요가 없으므로 초기 개발 시에 많이 사용한다.
runtimeOnly 'com.h2database:h2'
build.gradle에 h2를 디펜던시로 설정하면 @SpringBootApplication 어노테이션의 일부로 스프링이 알아서 애플리케이션을 H2데이터베이스에 연결해 준다. 애플리케이션 실행 시 테이블이 생성되고 종료 시 소멸된다.
스프링 데이터 JPA를 사용하려면 build.gradle에 spring-boot-start-jpa를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
TodoEntity.java
보통 데이터베이스 테이블마다 그에 상응하는 엔티티 클래스가 존재한다고 언급했다.
따라서 Todo 테이블에 상응하는 TodoEntity가 존재한다.
하나의 엔티티 인스턴스는 데이터베이스 테이블의 한 행에 해당한다.
엔티티 클래스는 클래스 그 자체가 테이블을 정의해야 한다. 이 말은 ORM이 엔티티를 보고 어떤 테이블의 어떤 필드에 매핑해야 하는지 알 수 있어야 한다는 뜻이다. 또 어떤 필드가 기본 키인지, 외래 키인지도 구분할 수 있어야 한다. 이런 데이터베이스 테이블 스키마에 관한 정보는 JPA 관련 어노테이션을 이용해 정의한다.
자바 클래스를 엔티티로 정의할 때 주의해야 할 점이 몇 가지 있다.
- 클래스에는 매개변수가 없는 생성자, NoArgsConstructor가 필요하다.
- Getter/Setter가 필요하다.
- 기본 키를 지정해 줘야 한다.
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
@Table(name = "Todo")
public class TodoEntity {
@Id
@GeneratedValue(generator = "system-uuid")
@GenericGenerator(name="system-uuid", strategy = "uuid")
private String id; // 오브젝트의 아이디
private String userId; // 이 오브젝트를 생성한 사용자의 아이디
private String title; // Todo 타이틀(ex: 운동하기)
private boolean done; // true - todo를 완료한 경우(checked)
}
@Entity
- 자바 클래스를 엔티티로 지정
- 엔티티에 이름을 부여하고 싶다면 @Entity("TodoEntity")처럼 매개변수를 넣어줄 수 있다.
@Table(name = "Todo")
- 테이블 이름을 지정
- 데이터베이스의 Todo 테이블에 매핑된다는 뜻
- @Table을 추가하지 않거나 name을 명시하지 않은다면 @Entity의 이름을 테이블 이름으로 간주한다.
- @Entity에 이름을 지정하지 않는 경우 클래스의 이름을 테이블 이름으로 간주한다.
@Id
- 기본 키가 될 필드에 지정한다.
- 오브젝트를 데이터베이스에 저장할 때마다 생성할 수도 있지만
@GeneratedValue 어노테이션을 이용해 자동으로 생성할 수도 있다.
@GeneratedValue, @GenericGenerator
- ID를 자동으로 생성하겠다는 뜻
- 매개변수 generator는 ID 생성 방식을 지정할 수 있다.
- system-uuid라는 generator는 @GenericGenerator에 정의된 generator의 이름으로
@GenericGenerator는 Hibernaer가 제공하는 기본 Generator가 아니라 커스텀 Generator를 사용하고 싶을 경우 이용한다. - 기본 Generator로는 INCREMENTAL, SEQUENCE, IDENTITY 등이 있는데 여기선 문자열 형태의 UUID의 사용을 위해 커스텀 generator를 만들었다.
- UUID의 사용을 위해 GenericGenerator의 매개변수 strategy로 "uuid"를 넘겼다.
- uuid를 사용하는 "system-uuid"라는 이름의 GenericGenerator를 만들었고 이 Generator는 @GeneratedValue가 참조해 사용한다.
TodoRepository.java
퍼시스턴스를 관리하는 패키지 persistence를 만들고 TodoRepository를 구현한다.
package com.example.demo.persistence;
import com.example.demo.model.TodoEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String> {
}
JpaRepository는 인터페이스이므로 사용하려면 새 인터페이스를 작성해 상속받아야 한다.
JpaRepository<T, ID>가 Generic Type을 받는 것에 주의하자.
- T: 테이블에 매핑될 엔티티 클래스 (TodoEntity)
- ID: 엔티티의 기본키의 타입 (TodoEntity의 기본키인 id의 타입: String)
@Repository 어노테이션 또한 @Component 어노테이션의 특별 케이스로 스프링이 관리한다.
테스팅을 위해 아래와 같이 서비스에 임의로 TodoEntity 오브젝트를 추가해 보자.
package com.example.demo.service;
import com.example.demo.model.TodoEntity;
import com.example.demo.persistence.TodoRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TodoService {
@Autowired
private TodoRepository repository;
public String testService() {
// TodoEntity 생성
TodoEntity entity = TodoEntity.builder().title("My first todo item").build();
// TodoEntity 저장
repository.save(entity);
// TodoEntity 검색
TodoEntity savedEntity = repository.findById(entity.getId()).get();
return savedEntity.getTitle();
}
}
기본 쿼리와 쿼리 작성 방법 (JpaRepository 인터페이스 내부)
@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
@Override
List<T> findAll();
@Override
List<T> findAll(Sort sort);
@Override
List<T> findAllById(Iterable<ID> ids);
@Override
<S extends T> List<S> saveAll(Iterable<S> entities);
void flush();
<S extends T> S saveAndFlush(S entity);
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);
@Deprecated
default void deleteInBatch(Iterable<T> entities) {
deleteAllInBatch(entities);
}
void deleteAllInBatch(Iterable<T> entities);
void deleteAllByIdInBatch(Iterable<ID> ids);
void deleteAllInBatch();
@Deprecated
T getOne(ID id);
@Deprecated
T getById(ID id);
T getReferenceById(ID id);
@Override
<S extends T> List<S> findAll(Example<S> example);
@Override
<S extends T> List<S> findAll(Example<S> example, Sort sort);
}
JpaRepository는 기본적인 데이터베이스 오퍼레이션 인터페이스를 제공한다. save, findById, findAll 등이 기본적으로 제공되는 인터페이스에 해당한다. 구현은 스프링 데이터 JPA가 실행 시에 알아서 해준다.
스프링이 JpaRepository를 실행하는 과정을 정확히 알려면 AOP를 알아야 한다. 간단하게 설명하자면 스프링은 MethodInterceptor라는 AOP 인터페이스를 사용하는데 JpaRepository의 메서드를 부를 때마다 이 메서드 콜을 가로채 간다. 가로챈 메서드의 메서드 이름을 확인하고 메서드 이름을 기반으로 쿼리를 작성한다.
기본적인 쿼리가 아닌 쿼리는 어떻게 작성할까?
@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String> {
List<TodoEntity> findByUserId(String userId);
}
TodoRepository 아래에 findByUserId라는 메서드를 작성한다. 이 메서드를 작성하면 스프링 데이터 JPA가 메서드 이름을 파싱해서 SELECT * FROM TodoRepository WHERE userId = '{userId}' 와 같은 쿼리를 작성해 실행한다.
@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String> {
// ?1은 메서드의 매개변수의 순서 위치다.
@Query("select * from Todo t where t.userId = ?1")
List<TodoEntity> findByUserId(String userId);
}
메서드의 이름은 쿼리, 매개변수는 쿼리의 where문에 들어갈 값을 의미한다. 더 복잡한 쿼리는 @Query 어노테이션을 사용해 지정할 수 있다.
2.2.7 정리
레이어드 아키텍처 패턴과 REST API, JPA 등 스프링 부트를 이용해 웹 서비스를 구현하는 데 필요한 개념을 공부하고 실습해 봤다.
주로 서비스 개발을 위한 기초 작업을 했다.
기초 작업이란 패키지를 나누는 방법을 정하거나 모델과 엔티티를 정의하는 것이다.
레이어드 아키텍처를 따라 패키지를 model, dto, persistence, service, controller로 나눴다.
퍼시스턴스 레이어는 데이터베이스와 통신하며 필요한 쿼리를 보내고 해석해 엔티티 오브젝트로 변환해 주는 역할을 했다.
서비스 레이어는 HTTP나 데이터베이스 같은 외부 컴포넌트로부터 추상화돼 온전히 비즈니스 로직에만 집중할 수 있었다.
컨트롤러 레이어는 주로 HTTP 요청과 응답을 어떻게 넘겨받고 리턴하느냐, 즉 외부 세계와 통신하는 규약을 정의했다.