Spring Boot Controller, Service, Repository 구분, JSCODE Spring Boot Day 04
역할에 따라 구분해보자
구현해야 하는 API가 앞에서 연습했던 방식처럼 간단하지만은 않을 것이다.
요청에 따라 특정 값을 데이터베이스에 저장하고, 저장된 값을 꺼내고, 특정한 로직에 따라 값을 바꿔서 응답을 보내는 등…
매핑을 해 주는 Controller에서 그 모든 일을 처리해줄 수 있겠지만 그럼 컨트롤러의 크기가 무한히 커질 것이고… 수정을 해야 할 때 그 수많은 코드를 한줄한줄 읽어가며 추적해야 한다.
어떻게 구분할까?
역할에 따라 Controller, Service, Repository
로 구분할 수 있다.
요청과 응답 처리, Controller
앞서 RequestMapping 등으로 API 요청에 대한 매핑을 Controller에서 진행했다.
이처럼 Controller는 요청과 응답에 대한 처리를 하는 객체이다.
@Controller
어노테이션을 추가해 역할을 부여한다.
비즈니스 로직 처리, Service
특정한 로직에 따라 데이터를 변경하고 조작할 때, 즉 비즈니스 로직을 처리할 때 Service 객체에서 진행한다.
요청에 대해 데이터베이스에서 가져온 값에 대해 특별한 로직에 의해 변경이 필요할 때 그 동작을 해서 응답할 수 있도록 컨트롤러로 보내준다.
즉, Controller와 데이터베이스의 중간에서 값을 원하는대로 처리해주는 객체이다.
@Service
어노테이션을 추가해 역할을 부여한다.
데이터에 접근해 CRUD 처리, Repository
Controller와 Service는 데이터베이스에서 값을 꺼내고, 데이터베이스에 값을 생성하고, 변경하고, 삭제하는 동작에서 추가적으로 붙는 동작을 수행한다.
반면 Repository는 데이터베이스에 직접 접근해 데이터를 CRUD하는 객체이다.
Service가 Controller와 데이터베이스의 중간 다리 역할이라고 했는데, 여기서 데이터베이스는 Repository 객체에 의해 접근되므로, 결국 Client - Controller - Service - Repository - Database
의 연결관계를 갖게 된다.
@Repository
어노테이션을 추가해 역할을 부여한다.
@Component
Controller, Service, Repository 모두 각 객체에서 필요한 서로의 객체를 생성할 때 new
키워드 없이도 정상적으로 생성과 동작이 가능하다.
이는 Spring 프레임워크가 세 객체를 Bean
으로 등록해 특별 취급을 해주는 것이다.
Bean
으로 등록된 객체들은 new
로 인스턴스를 생성할 필요 없이 멤버 변수로 선언하고 생성자만 만들어주면 Spring이 알아서 의존성을 주입해준다.
이 Bean
으로 등록되려면 어떻게 해야할까? @Component
어노테이션을 붙이면 된다.
???? Controller, Service, Repository는 Component 어노테이션이 없는디???
우리 눈엔 안보이지만 어노테이션이 정의된 곳을 들어가보면 세 객체 모두 Component 어노테이션이 붙어 있다. 그래서 세 객체 모두 Bean으로 등록돼 Spring이 자동으로 의존성 주입을 해 주는 것이다.
의존성?
앱 개발 할 때 ‘의존성 주입, 관리’ 등 의존성이라는 단어 자체에 대해 이해가 잘 안 됐다. 대충 라이브러리 추가해서 쓸 때 버전관리나 기타등등 해주는게 의존성 주입, 관리인가보다 했는데, 여기서 ‘의존성’에 대한 약속을 알게 되었다.
Service 객체의 메서드를 쓰기 위해 Controller가 service 객체를 가지고 있을 때, Controller가 Service에의존
하고 있다고 한다.
결국 객체 간 연결관계를 의존성이라고 하는 것 아닐까 싶다.
코드… 코드를 다오..
말로만 설명하면 이게 뭔 소리고? 일 수 있으니 코드로 각각을 나누어보자.
코드를 작성하기 전에 Controller - Service - Repository가 필요한 상황을 구성해보자. 나는 연습문제로 받은 상황을 해보려고 한다.
상품 등록/조회 API에서 특정 상품 id를 통해 상품의 가격과 재고 여부를 요청한다고 하자.
- GET 요청 mapping
- 요청에서 받은 상품 id를 repository로 전달
- 상품 id로 데이터베이스에서 상품 정보 조회, service로 반환
- 반환받은 상품 정보를 controller로 전달
- 응답 return
요청 매핑과 응답 반환에 해당하는 1,5는 Controller가, 상품 id로 데이터베이스에서 Read하는 3은 Repository가, 두 객체 사이에서 필요한 리소스를 전달하고 그 과정에서 필요한 비즈니스 로직을 수행하는 2,4가 Service가 할 일이다.
Controller
@RestController
@RequestMapping("/products")
@Slf4j
public class ProductController {
// 1
final ProductService productService;
// 2
public ProductController(ProductService productService) {
this.productService = productService;
}
// 3
@GetMapping("/{id}")
public String getProductInformation(@PathVariable int id) {
Product product;
// 4
try {
product = productService.getProductInformation(id);
} catch (Exception e) {
throw new RuntimeException(e);
}
return productService.generateReturnString(product);
}
}
주석 1, 2
앞서 설명했듯 @Controller는 Bean으로 취급되기 때문에 ProductService의 의존성을 Spring이 알아서 주입해준다.
주석 3
GET, /products/{id}
요청을 받아 처리하는 controller다.
지금까지 GetMapping을 쓸 때 고정된 값을 써왔는데, 이번엔 유동적으로 변하는 값을 요청받을 수 있도록 했다.
매핑할 URI에 {}
와 안에 들어갈 변수명을 써주고, 이 변수명을 매핑 함수의 인자로 넣어준다. 이 때 이 인자는 @PathVariable
노테이션으로 해당 경로에 들어갈 변수라는 것을 나타내준다.
주석 4
try - catch, throw 블록으로, Service 단에서 날아온 에러를 받아 새로운 Exception을 던져준다.
나는 여기서 id 값으로 들어온 상품이 데이터베이스에 없을 때 에러를 일으키게 했다. 지금으로썬 이렇게 처리하면 클라이언트에서 500 서버 에러를 확인하게 된다.
Service
@Service
public class ProductService {
final ProductRepository productRepository;
ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// 1.
public Product getProductInformation(int id) throws Exception {
Product product = productRepository.getProductInformation(id);
// 2.
if (product == null) {
throw new NullPointerException();
}
return product;
}
// 3.
public String generateReturnString(Product product) {
if (product.isOnSale) {
return String.format("product: %s(%d) is now on sale. cost: %d won.", product.name, product.id, product.cost);
} else {
return String.format("product: %s(%d) is now out of stock. cost: %d won.", product.name, product.id, product.cost);
}
}
}
주석 1, 2
앞서 Controller에서 try - catch 블록으로 날아온 Exception을 받았다면, 어딘가에선 Exception을 날려야한다.
레포지토리에서 id에 해당하는 상품을 찾았으나 그런 상품이 없다면 Exception을 날려 비정상적인 상태를 알려야 한다.
그렇기에 상품이 없을 때 NullPointerException을 날리도록 짰다.
왜 여기서 throw?
아직 많은 서치를 해보지 않아서 잘 모르지만 Exception 처리는 비즈니스 로직 단에서 많이들 하는 것 같다.
처음엔 Repository에서 해야 하는 것 아닌가 했는데, Repository의 역할상 데이터 CRUD에 충실해야 하기 때문에 이러한 예외처리는 비즈니스 로직에서 하는 게 맞겠다는 생각이 들었다.
주석 3
상품에 대한 정보 조회가 완료되면 Controller가 이에 대해 응답을 해 줘야 한다.
여기서는 isOnSale
값을 확인해 판매중인지 아닌지 여부를 나타내는 문자열을 만들어주기로 했다.
응답에 대한 특정한 조작이 필요한 부분으로, Service 객체에 해당 메서드를 정의했다.
Repository
@Repository
public class ProductRepository {
public List<Product> productList = List.of(
new Product(1234,"blue shorts", false, 50000),
new Product(1235, "red tie", true, 40000),
new Product(1236, "yellow shirts", true, 30000)
);
public Product getProductInformation(int id) {
for (Product product : productList) {
if (product.id == id) {
return product;
}
}
return null;
}
}
목 데이터를 만들어두고 해당 데이터를 하나씩 순회하며 찾고 있는 id에 해당하는 상품이 있는지 없는지 체크한다.
Swift를 하면서 이런 상황에 Optional을 많이 썼다. Swift에선 상황에 따라 특정 타입 혹은 nil 값을 반환하기 위해선 해당 타입을 옵셔널로 만들어야 한다. Java도 그럴거라 생각하고 서치해본 결과, ‘null을 반환했을 때 에러를 일으킬 수 있고, 명확하게 “결과 없음”을 표시해야 할 메서드의 반환 값으로 사용한다’고 되어 있다. (공식 문서 참고)
그래서 처음엔 모든 Repository, Service, Controller에 있는 getProductInformation 메소드의 리턴 타입을 Optional
하지만 Optional은 오버헤드가 크기 때문에 과도하게 사용하지 않아야 하고, 단순히 값 또는 Null을 얻기 위한 목적이라면 옵셔널을 쓸 필요가 없다는 의견들이 있었다.
그래서 null 반환함~!
결론
객체지향의 사실과 오해 책을 읽으면서 Swift에 대입하는게 너무 어려웠다. 완전한 객체지향 언어도, 프레임워크도 아닌 터라 그냥 무작정 class에 대입해서 생각했다.
근데 이번에 Controller, Service, Repository를 분리하는 이유, 각각의 역할을 보면서 그 책의 내용이 훨씬 더 잘 와닿았다. 다시 한 번 더 읽어봐야겠다는 생각,,
Exception이 발생했을 때 서버단에서 그냥 저렇게 퉁 치고 마는건지.. 아니면 클라이언트를 위해 뭔가 더 정보를 주는건지… 진짜 서비스를 할 땐 어떻게 할까 궁금하다. 조금 더 찾아봐야 할 영역인 듯 싶다.
여기선 GET 요청으로 정보만 얻어오게끔 만들었는데, POST 요청으로 상품을 등록하고, 예외처리를 좀 더 구체적으로 하는 코드를 추가해봐야겠다! +) 이젠 String이 아닌 JSON 형태로 반환도 한번 해보자구,,
댓글남기기