앞에서 컨트롤러를 통해 API를 만들어보면서 메모리에 저장하는 방식으로 구현했었는데 스프링부트를 종료하면 전부 휘발되어 버리기 때문에 사실상 사용할 수 없는 방법이다.
그래서 데이터베이스에 데이터를 저장하는 방식을 택해야 하는데 이를 위해서는 데이터베이스에 연동을 해야 한다. 데이터베이스에 연동하기 전에 몇가지 개념들에 대해 알아보자.
Object Relational Mapping의 줄임말로 객체와 테이블을 매핑하는 방법을 말한다. 객체 즉, 클래스는 데이터베이스의 테이블과 매핑하기 위해 만들어진 것이 아니기 때문에 테이블과 불일치가 존재할 수 밖에 없는데 ORM이 그 간극을 없애주는 역할을 한다.
실제로 데이터베이스를 조작하기 위해서는 쿼리문을 작성해야 하는데 ORM을 이용하면 메서드를 통해 데이터를 조작할 수 있다. ORM은 일종의 통역가 같은 역할을 한다고 생각하면 된다.

이러한 방법은 코드의 가독성 뿐만 아니라 유지보수 수월, 데이터베이스 독립적이라는 장점이 있지만 복잡한 서비스의 경우에는 직접 쿼리를 구현하지 않고 코드를 구현하기 어렵다.
ORM이라는 큰 틀에서 자바 진영의 기술 표준으로 채택된 인터페이스의 모음집이 바로 JPA이다.
JPA는 ORM이라는 큰 개념보다 더 구체화된 스펙을 포함하며, 실제로 동작하는 것이 아닌 어떻게 동작하는지 메커니즘을 정리한 표준 명세라고 생각하면 된다.
JPA는 내부적으로 JDBC를 사용한다.JDBC를 직접 구현하면 SQL에 의존하게 되는 문제로 의존성이 떨어지는데, JPA가 적절한 SQL을 생성하고 데이터베이스를 조작해서 객체를 자동 매핑하는 역할을 수행한다.
JPA는 어디까지나 메커니즘이기 때문에 직접 동작하지 못하고 구현체를 통해 동작을 한다. 대표적으로 EclipseLink, DataNucleus, Hibernate가 있다.
이 구현체들 중 Hibernate는 자바의 ORM 프레임워크인데 Hibernate의 기능을 더 편리하게 사용하도록 모듈화한 것이 바로 Spring Data JPA이다. CRUD에 필요한 인터페이스를 제공하고 리포지토리를 정의해 사용함으로써 스프링이 적합한 쿼리를 동적으로 생성하는 방식으로 데이터베이스를 조작하는 라이브러리인 것이다.

영속성 컨텍스트라고 하며 애플리케이션과 데이터베이스 사이에서 엔티티와 레코드의 괴리를 해소하는 기능, 객체를 보관하는 기능을 담당한다.
(여기서 엔티티란 데이터베이스의 테이블과 매핑되는 클래스를 말한다)
엔티티 객체가 영속성 컨텍스트에 들어오면 JPA는 엔티티 객체의 매핑 정보를 데이터베이스에 반영하는 작업을 수행한다. 이 시점에서(영속성 컨텍스트에 들어오는 시점) 엔티티 객체는 영속 객체라고 부른다.

MariaDB 설치하기 전 스프링에 Lombok, Spring Configuration Processor, Spring Web, Spring Data JPA, MariaDB Driver를 의존성에 추가해야 한다.
MariaDB 설치가 완료된 후 HeidiSQL을 실행하면 쿼리문을 작성할 수 있는데
다음과 같이 쿼리를 작성 후 쿼리를 실행하면 데이터베이스가 생성된다.
이후 application.properties에 다음과 같이 설정하면 연동이 완료된다.
spring.datasource.url=jdbc:mariadb://localhost:3307/MyDataBase
spring.datasource.username=root
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect
spring.jpa.hibernate.ddl-auto=update
포트번호 3306은 사용중이라 3307로 설정하였고 그 옆에는 데이터베이스명을 적으면 된다.
계정은 root 계정을 사용하였으며 비밀번호는 설정하지 않아 추가하지 않았다.
마지막 줄에 있는 spring.jpa.hibernate.ddl-auto=update는 엔티티에 매핑되는 테이블을 데이터베이스에 자동생성하는 기능으로 만약 이 기능을 추가하지 않으면 테이블을 수동으로 만들어주어야 한다.
이제는 메모리에 데이터를 저장하는 방식이 아닌 데이터베이스를 조작하여 저장할 수 있도록 계층을 구성하겠다.

Client~Service 계층 간에는 DTO를 통해 데이터를 교환하고 Service~DataBase는 Entity를 통해 데이터를 교환한다.
데이터베이스에 접근하는 로직은 Repository에서 구현하고 이 Repository의 로직들을 호출하는 로직들을 DAO에서 관리한다. Repository 없이 DAO(Data Access Object)만 있는 경우도 있지만 여기서는 전부 사용하겠다.
Service에서는 Client로부터DTO를 통해 데이터를 받아 이를 Entity에 저장하여 DAO의 로직을 호출하고 Client에게 응답 데이터를 보낼 때는 ResponseDTO를 사용한다.
만약, 데이터를 저장한다면 DTO를 받아 Entity에 필요한 정보들을 저장하고 DataBase에 저장 후 다시 Entity로부터 정보를 받아 이를 ResponseDTO에 저장하여 Client에게 보냄으로써 응답 데이터를 보낸는 방식이다.
Service, DAO는 인터페이스를 생성하고 Impl로 구현하는 방식으로 사용한다.
Repository는 JPARepository를 상속받아 데이터베이스를 조작할 수 있는 메서드를 호출할 수 있다.
상품에 대한 데이터를 데이터베이스를 조작하여 CRUD 하는 방식으로 하나하나 만들어가보도록 하자.
패키지는 다음과 같이 구성하겠다.
테이블에 매핑되는 객체로 에너테이션을 통해 지정이 가능하다.
@Entity
@Getter
@Setter
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long number;
private String name;
private Integer price;
private Integer stock;
private LocalDateTime createAt;
private LocalDateTime updateAt;
}
@Entity: 해당 클래스가 엔티티임을 명시하는 기능@Id: 테이블의 기본값 역할로 사용되는 필드로 지정하는 기능@GeneratedValue: 일반적으로@Id와 함께 사용되며 해당 필드의 값을 어떤 방식으로 자동 생성한지 결정할 때 사용(AUTO, IDENTITY, TABLE, SEQUENCE), IDENTITY로 설정하면 기본값 생성을 데이터베이스에 위임@Table: 클래스와 테이블의 이름을 다르게 지정해야 하는 경우에 사용하는 에너테이션으로 일반적으로 필요하지 않음@Column: 테이블의 칼럼과 매핑되도록 필드에 지정할 수 있지만 사용하지 않아도 엔티티 클래스의 필드는 자동으로 테이블 칼럼과 매핑됨. 소괄호를 열어서 다른 설정이 가능함
여기서는 Id로 사용되는 number에만 에너테이션을 지정하고 나머지 칼럼이 되는 필드에는 별다른 지정을 하지 않았다.
필드로는 상품의 번호, 이름, 가격, 재고수, 생성시간, 수정시간이 있으며 Getter와 Setter는 Lombok을 사용하여 가독성을 높였다.
이제 이 Product 클래스는 Entity로써 역할을 하게 될 것이다.
Data Transfer Object로 데이터 전달 객체이다.
//DTO
@Getter
@Setter
@AllArgsConstructor
public class ProductDTO {
private String name;
private int price;
private int stock;
}
//ResponseDTO
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class ProductResponseDTO {
private Long number;
private String name;
private int price;
private int stock;
}
클라이언트로부터 데이터를 가져오기 위한 DTO와 클라이언트에게 데이트를 보내기 위한 응답용 객체 ResponseDTO를 생성하였고 Constructor, Getter, Setter는 전부 Lombok으로 처리하여 가독송을 높였다.
@NoArgsConstructor는 인자가 없는 생성자를, @AllArgsConstructor는 모든 필드를 인자로 가지는 생성자를 말한다.
데이터베이스에 접근하는 로직을 구현하며 인터페이스를 생성 후 JPARepository를 상속받으면 된다.
public interface ProductRepository extends JpaRepository<Product,Long> {
//Entity가 생성한 데이터베이스에 접근하는 데 사용
}
JpaRepository<T, ID>로 제네릭 타입이 보이는데 T에는 Entity 타입을, ID에는 Entity 클래스의 @Id로 지정된 필드 타입을 입력하면 된다.
Repostitory를 호출하는 로직을 관리하는 인터페이스로 구현체를 별도로 만들어야 한다.
public interface ProductDAO {
//데이터베이스에 접근하기 위해 레포지토리를 호출하는 로직을 관리하는 객체
//CRUD
public Product insertProduct(Product product);
public Product selectProduct(Long number);
public Product updateProductName(Long number, String name) throws Exception;
public void deleteProduct(Long number) throws Exception;
}
추상 메서드로 CRUD를 정의하고 수정, 삭제 메서드는 찾는 데이터가 없을 경우 예외를 던지도록 하였다.
Service에서 수정, 삭제 메서드를 호출할 때는 예외처리를 해주거나 예외를 던져야 한다.
@Component
public class ProductDAOImpl implements ProductDAO{
private final ProductRepository productRepository;
@Autowired
public ProductDAOImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
//CRUD
@Override
public Product insertProduct(Product product){
Product saveProduct = productRepository.save(product);
return saveProduct;
};
@Override
public Product selectProduct(Long number){
Product selectedProduct = productRepository.getReferenceById(number);
return selectedProduct;
};
@Override
public Product updateProductName(Long number, String name) throws Exception{
//데이터베이스 -> 레포지토리로부터 Id에 해당하는 엔티티를 가져온다
Optional<Product> selectedProduct = productRepository.findById(number); //객체 값 확인을 위해 선언 타입을 Optional로 정의
//이름 변경 후 저장할 엔티티
Product updatedProduct;
if(selectedProduct.isPresent()){
Product product = selectedProduct.get(); //Optional 클래스의 객체 접근 메서드
product.setName(name);
product.setUpdateAt(LocalDateTime.now());
//레포지토리에 수정한 엔티티를 다시 저장
updatedProduct = productRepository.save(product);
}else{
//해당하는 엔티티가 없으면 예외를 발생시킴
throw new Exception();
}
return updatedProduct;
};
@Override
public void deleteProduct(Long number) throws Exception{
Optional<Product> selectedProduct = productRepository.findById(number);
if(selectedProduct.isPresent()){
//데이터베이스에 객체가 있으면 삭제
productRepository.delete(selectedProduct.get());
}else{
throw new Exception();
}
};
}
조회, 저장 메서드는 간단하게 Repository를 호출하는 방식이지만 수정, 삭제에서는 Optional 타입을 사용하였는데 객체 값을 접근, 확인이 용이하며 NullPointerException을 발생시키지 않아 상당히 편리한 타입이다.
Optional에 대한 자세한 설명은 이전에 포스팅했던 글을 참고하면 좋을 것 같다.
https://velog.io/@nosibi/OptinalT
삭제 메서드를 제외하고는 전부 Entity를 리턴타입으로 정의하였는데 이는 클라이언트에게 응답 데이터를 보내기 위한 것이다. Service 계층을 작성하면 이해할 수 있다.
수정 메서드는 조금 특이한데 JPA에서 데이터의 값을 변경할 때는 update라는 키워드를 사용하지 않는다.
find() 메서드를 통해 데이터베이스에서 값을 가져오고 영속성 컨텍스트에 추가하여 값을 변경 후 다시 save()를 실행하면 Dirty Check라고 하는 변경 감지를 수행하여 저장한다.
즉, 바로 수정되는 것이 아닌 값을 가져와서 변경 후 다시 저장하는 방식인 것이다.
@Component로 지정한 이유는 ProductRepository를 의존성 주입을 받아야 하는데 이를 위해서는 스프링 컨테이너에 등록되어야 하기 때문에 컴포넌트 스캔의 대상으로 지정한 것이다.
사실 서비스 계층은 서비스 로직만 구현하고 비즈니스 로직은 따로 분리하는 것이 좋은데 여기서는 간단한 기능만 구현하기 때문에 분리는 하지 않겠다.
public interface ProductService {
//Controller와 DTO를 교환, DAO와는 엔티티 교환
public ProductResponseDTO getProduct(Long number);
public ProductResponseDTO saveProduct(ProductDTO productDTO);
public ProductResponseDTO changeProductName(Long number, String name) throws Exception;
public void deleteProduct(Long number) throws Exception;
}
인터페이스에 CRUD를 추상 메서드로 정의하고 수정과 삭제 메서드는 예외를 다시 던지게 하였다.
이제 이 인터페이스를 구현해보자.
@Service
public class ProductServiceImpl implements ProductService{
private final ProductDAO productDAO;
@Autowired
public ProductServiceImpl(ProductDAO productDAO) {
this.productDAO = productDAO;
}
//CRUD
public ProductResponseDTO getProduct(Long number){
//DAO로부터 Id에 해당하는 엔티티를 받는다
Product product = productDAO.selectProduct(number);
//받은 엔티티로부터 필드값을 얻어 응답DTO에 저장한다
ProductResponseDTO productResponseDTO = new ProductResponseDTO();
productResponseDTO.setNumber(product.getNumber());
productResponseDTO.setName(product.getName());
productResponseDTO.setPrice(product.getPrice());
productResponseDTO.setStock(product.getStock());
return productResponseDTO;
};
public ProductResponseDTO saveProduct(ProductDTO productDTO){
//DAO에 전달하기 위한 엔티티 생성
Product product = new Product();
//컨트롤러로부터 받은 DTO에서 엔티티에 저장할 필드를 가져온다
product.setName(productDTO.getName());
product.setPrice(productDTO.getPrice());
product.setStock(productDTO.getStock());
product.setCreateAt(LocalDateTime.now());
product.setUpdateAt(LocalDateTime.now());
//정보 입력이 완료된 엔티티를 DAO로 넘긴다(저장한다)
Product savedProduct = productDAO.insertProduct(product);
//클라이언트에 응답할 DTO에 저장한 엔티티 정보를 저장
ProductResponseDTO productResponseDTO = new ProductResponseDTO();
productResponseDTO.setNumber(savedProduct.getNumber());
productResponseDTO.setName(savedProduct.getName());
productResponseDTO.setPrice(savedProduct.getPrice());
productResponseDTO.setStock(savedProduct.getStock());
return productResponseDTO;
};
public ProductResponseDTO changeProductName(Long number, String name) throws Exception{
//수정할 데이터의 number,name을 넘겨 수정 후 엔티티를 받아온다
Product product = productDAO.updateProductName(number,name);
//클라이언트에 응답할 DTO에 받아온 엔티티 정보를 저장
ProductResponseDTO productResponseDTO = new ProductResponseDTO();
productResponseDTO.setNumber(product.getNumber());
productResponseDTO.setName(product.getName());
productResponseDTO.setPrice(product.getPrice());
productResponseDTO.setStock(product.getStock());
return productResponseDTO;
};
public void deleteProduct(Long number) throws Exception{
productDAO.deleteProduct(number);
};
}
가장 처음에 @Service 에너테이션으로 지정하였는데 이 클래스가 서비스 계층이라는 것을 명시하고 컴포넌트 스캔의 대상이 된다. 그래서 ProductDAO에 의존성 주입이 자동으로 된다. 즉, 별도로 @Component를 사용하지 않아도 된다는 것이다.
서비스 레이어는 중간다리 역할이라고 생각하면 된다. 클라이언트로부터 데이터를 받고 이를 엔티티에 저장해서 그 엔티티를 DAO에게 넘기는 것이다. 그리고 넘긴 엔티티의 정보를 ResponseDTO에 저장하여 다시 클라이언트에 응답 데이터로 보내는 방식이다.
이 방식대로 생각을 하면 코드가 길어도 굉장히 간단해보인다. 데이터를 받아 보내고 아까 보낸 것을 응답 데이터에 반영해서 되돌려준다고 생각하면 된다.
이제 마지막으로 클라이언트와 직접 통신하는 Controller를 만들어보자. 앞에서 RestAPI를 공부하였기 때문에 어렵지는 않을 것이다.
그 전에 데이터 정보 수정을 위한 별도의 DTO를 하나 만들겠다.
@Getter
@Setter
@AllArgsConstructor
public class ChangeProductDTO {
private Long number;
private String name;
private int price;
private int stock;
}
PostMapping에 사용될 객체인데 ResponseBody로 클라이언트로부터 데이터를 받아야 하기 때문에 별도의 객체를 만든 것이다.
@RestController
@RequestMapping("/product")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{productID}")
public ResponseEntity<ProductResponseDTO> getProduct(@PathVariable("productID") Long number){
ProductResponseDTO productResponseDTO = productService.getProduct(number);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDTO);
}
@PostMapping
public ResponseEntity<ProductResponseDTO> saveProduct(@RequestBody ProductDTO productDTO){
ProductResponseDTO productResponseDTO = productService.saveProduct(productDTO);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDTO);
}
@PutMapping
public ResponseEntity<ProductResponseDTO> updateProduct(@RequestBody ChangeProductDTO changeProductDTO) throws Exception{
ProductResponseDTO productResponseDTO = productService.changeProductName(changeProductDTO.getNumber(), changeProductDTO.getName());
return ResponseEntity.status(HttpStatus.OK).body(productResponseDTO);
}
@DeleteMapping("/{productID")
public ResponseEntity<String> deleteProduct(@PathVariable("productID") Long number) throws Exception{
return ResponseEntity.status(HttpStatus.OK).body("정상적으로 삭제되었습니다");
}
}
공통적으로 리턴 타입으로 ResponseEntity로 정의하여 응답 데이터를 보낼 수 있게 하였다. 서비스를 호출하여 데이터를 넘기고 넘긴 데이터의 정보를 응답 객체인 ResponseDTO에 저장한다.
서비스를 호출하면 바로 데이터를 받을 수 있게 하려고 서비스 계층에서 리턴 타입을 ProductResponseDTO로 지정한 것이다.
물론, 응답 데이터를 받지 않으려면 리턴 타입을 void로 지정해도 되지만 이러한 방식도 있다는 것도 알고 있는게 좋을 것 같다.
계층을 이렇게 구성하면 정말 편리한 점이 바로 옆에 있는 계층만 호출하면 되는 것이다.
Controller는 Service만 호출하면 되고 Service는 DAO, DAO는 Repository만 호출하면 되기 때문에 유지보수가 수월해진다.
이제 컨트롤러를 호출하여 정상적으로 작동하는지 확인해보자.
기존에는 Talend API Tester로 확인했지만 이번에는 Swagger UI를 사용하여 확인하겠다.
(Swagger에 대한 설명은 다음 포스팅에서 하겠다)
스프링부트를 실행한 후 http://localhost:8080/swagger-ui.html에 접속하면
내가 지정한 패키지의 메서드들에 대한 명세가 표기된다.
먼저 저장을 해야 하기 때문에 post 버튼을 눌러 Try it out을 누르고 JSON 형식에 맞춰 필드값을 입력하고 Execute를 누르면 서버로부터 응답을 받을 수 있다.
정상적으로 저장되어 200OK라는 응답을 받았다.
데이터베이스를 확인해보면("데이터"라는 칸을 누르면 테이블을 볼 수 있다)
엔티티에 매핑되는 테이블이 자동 생성되어 필드값이 칼럼에 저장되었다.
이제 데이터를 조회해보자.
상품 number에 해당하는 값을 파라미터로 사용하여 조회하였고 200OK라는 응답을 받았다.
이제 데이터를 수정해보자.
기존의 pencil을 super-pencil로 이름을 변경해보겠다.
이름 변경이 완료되었고 데이터베이스를 확인해보면
테이블에 반영된 것을 알 수 있다.
이제 해당 데이터를 삭제하겠다.
조회 메서드와 마찬가지로 상품의 number로 조회하여 데이터가 있으면 삭제하고 없으면 예외를 발생시킨다.
지금까지 내용을 정리하자면 데이터베이스를 Java 코드로 조작할 수 있었던 건 JpaRepository가 데이터베이스를 조작할 수 있는 메서드를 가지고 있어 이를 상속받은 ProductRepository도 사용이 가능한 것이다.
그리고 계층 분리를 하여 Controller-Service-DAO-Repository로 역할 분담을 하였고 데이터를 각각 DTO, Entity라는 형태를 통해 교환하였다.
응답 데이터를 구체적으로 받기 위해 ResponseDTO를 만들어 받거나 보낸 데이터의 정보를 여기에 저장하고 클라이언트에게 보내도록 설계하였다.
내용은
이 서적을 참고했으며 22년에 작성된 것이라 스프링부트 2버전에 맞게 설명이 되어 있어 지금과는 맞지 않는 내용들이 몇 가지 있다...(25년도 3월에 새로 출간된 책을 살걸..)
https://github.com/nosibi/ProductExample/tree/main <-----전체 코드