2023. 6. 20. 18:20ㆍCode
- 도메인 엔티티 클래스를 이용해 서비스 클래스와 리포지토리(Repository) 클래스를 구현해 봅시다.
- Spring Data JDBC, Spring Data JPA에서는 데이터 액세스 계층에서 데이터베이스와 상호작용하는 역할을 하는 인터페이스를 리포지토리(Repository)라고 한다
( 리포지토리(Repository)라는 용어는 DDD(Domain Driven Design, 도메인 주도 설계)에서 사용하는 용어 이다 )
리포지토리(Repository) 인터페이스 정의
- 커피 주문을 하기 위해서는 회원 정보, 커피 정보, 주문 정보가 모두 필요하므로 이 세 개 정보를 모두 저장 및 조회할 수 있도록 각각의 리포지토리 인터페이스를 정의해 보겠습니다
MemberRepository 인터페이스 코드
- Spring Data JDBC에서는 CrudRepository라는 인터페이스를 제공해주고 있으며, 이 CrudRepository의 기능을 사용하기 위해서 MemberRepository가 CrudRepository를 상속하고 있습니다.
- CrudRepository 인터페이스를 통해서 편리하게 데이터를 데이터베이스의 테이블에 저장, 조회, 수정, 삭제할 수 있습니다.
(1)의 CrudRepository<Member, Long>에서 Member는 Member 엔티티 클래스를 가리키며, Long은 Member 엔티티 클래스에서 @Id 애너테이션이 붙은 멤버 변수의 타입을 가리킨다
(2)는 Spring Data JDBC에서 지원하는 쿼리 메서드(Query Method) 정의를 이용한 데이터 조회 메서드를 정의하였다
## Spring Data JDBC에서는 ‘find + By + SQL 쿼리문에서 WHERE 절의 열명 + (WHERE 절 열의 조건이 되는 데이터) ’ 형식으로 쿼리 메서드(Query Method)를 정의하면 조건에 맞는 데이터를 테이블에서 조회한다
- (2)에서는 email 열을 WHERE 절의 조건으로 지정해서 MEMBER 테이블에서 하나의 row를 조회하겠다고 정의하였다
- (2)에서 정의한 쿼리 메서드(Query Method)는 내부적으로 아래의 SQL 쿼리문으로 변환되어 데이터베이스의 MEMBER 테이블에 질의를 보냅니다.
( SELECT "MEMBER"."NAME" AS "NAME", "MEMBER"."PHONE" AS "PHONE", "MEMBER"."EMAIL" AS "EMAIL", "MEMBER"."MEMBER_ID" AS "MEMBER_ID" FROM "MEMBER" **WHERE "MEMBER"."EMAIL" = ?** )
- (2)의 쿼리 메서드는 이미 테이블에 등록된 이메일 주소가 있는지 확인하기 위한 용도로 사용한다
- (2)의 리턴값으로 SQL 질의를 통한 결과 데이터를 Member 엔티티 클래스의 객체로 지정하였다
( Spring Data JDBC에서는 Optional을 지원하기 때문에 리턴값을 Optional로 래핑 할 수 있다 )
( 이처럼 리턴값에 Optional을 사용하면 서비스 클래스에서 이 Optional을 이용해 코드를 좀 더 효율적이면서 간결하게 구성할 수 있다 )
쿼리 메서드(Query Method)
- Spring Data JDBC에서는 쿼리 메서드를 이용해서 SQL 쿼리문을 사용하지 않고 데이터베이스에 질의를 할 수 있다
- 기본적인 사용법은 ‘find + By + SQL 쿼리문에서 WHERE 절의 열명 + (WHERE 절 열의 조건이 되는 데이터) ’ 형식이며, WHERE 절의 조건 열을 여러 개 지정하고 싶다면 ‘And’를 사용하면 된다
( 예를 들어 EMAIL 열과 NAME 열을 조건으로 지정하고 싶다면, findByEmailAndName(String email, String name)과 같이 쿼리 메서드를 정의하면 된다 )
## 주의해야 되는 부분은 findByxxxx에서 사용하는 열명은 내부적으로는 테이블의 열명으로 변경되지만 Spring JDBC 입장에서는 엔티티 클래스를 바라보고 작업을 하기 때문에 반드시 엔티티 클래스의 멤버 변수명을 적어주어야 한다
## 만약 Member 엔티티 클래스에 firstName이라는 멤버 변수가 있고, 테이블에 있는 FIRST_NAME이라는 열명과 매핑이 된다고 가정할 경우, 쿼리메서드는 findByFirstName이 되어야지 findByFIRST_NAME이 되어서는 안 된다
( 엔티티 클래스의 멤버 변수명과 테이블 열명을 일치시키는 것이 가장 좋지만 두 단어 이상 조합이 될 경우 일반적으로 Java에서는 Carmel Case 표기법을 사용하고 테이블 열명은 언더스코어( _ ) 표기법을 사용하는 경우가 많다 )
CoffeeRepository 인터페이스 코드
- (1)은 WHERE 절에서 COFFEE_CODE를 조건으로 질의하게 해주는 쿼리 메서드
- (2)에서는 COFFEE 테이블에 질의하기 위해 @Query라는 애너테이션을 사용하였다
@Query 애너테이션은 쿼리 메서드명을 기준으로 SQL 쿼리문을 생성하는 것이 아니라 개발자가 직접 쿼리문을 작성해서 질의를 할 수 있도록 해준다
@Query에 작성된 쿼리문에서 :coffeeId는 findByCoffeeId(Long coffeeId)의 coffeeId 변수 값이 채워지는 동적 쿼리 파라미터(named parameter) 이다
## @Query 애너테이션을 이용하면 SQL 쿼리문을 직접 작성할 수 있기 때문에 복잡한 쿼리문의 경우 @Query 애너테이션을 이용해서 직접 쿼리문을 작성할 수 있습니다.
하지만 단순한 쿼리의 경우 Spring Data JDBC에서 지원하는 Query Method를 정의해서 사용하는 것이 간결한 코드 유지와 생산성 면에서 바람직하다
( (2)의 경우 쿼리로 작성하는 방법이 있다는 것을 보여주기 위해서 쿼리문을 직접 작성한 것이고, 실제로는 CrudRepository 인터페이스에 내장되어 있는 findById(ID id)를 사용하면 된다 )
( findById(ID id)는 테이블에서 기본키를 WHERE절의 조건으로 지정해 데이터를 조회할 수 있는 편리한 쿼리메서드 이다 )
OrderRepository 인터페이스 코드
- MemberRepository나 CoffeeRepository에는 작성된 쿼리 메서드가 있었는데 OrderRepository는 작성된 쿼리메서드가 없다
- OrderRepository 인터페이스는 CrudRepository 인터페이스를 상속하기 때문에 CrudRepository에 이미 정의되어 있는 기본 쿼리메서드를 서비스 클래스에서 사용할 수 있다
서비스(Service) 클래스 구현
MemberRepository 인터페이스를 이용해야 되므로 (1)과 같이 생성자를 통해 MemberRepository 인터페이스를 DI 받는다
( MemberRepository 인터페이스는 정의했지만 인터페이스의 구현 클래스는 별도로 구현을 한 적이 없다 // Spring Data JDBC에서 내부적으로 Java의 리플렉션 기술과 Proxy 기술을 이용해서 MemberRepository 인터페이스의 구현 클래스 객체를 생성해 준다 )
(2)에서는 회원 정보를 저장하기 전에 테이블에 이미 등록된 이메일인지 여부를 검증하기 위해 (11)의 verifyExistsEmail(String email) 메서드를 사용하고 있다
(11)의 verifyExistsEmail(String email) 메서드에서는 MemberRepository에 정의되어 있는 findByEmail() 쿼리 메서드로 이메일에 해당하는 회원이 있는지를 조회한다
( findByEmail() 쿼리 메서드의 리턴값이 Optional이기 때문에 isPresent()를 통해 결과 값이 존재한다면 예외를 던지도록 해주었다 )
## (2)와 같이 비즈니스 로직에서 어떤 검증이 필요한 로직은 (11)의 verifyExistsEmail(String email)와 같이 별도의 로직으로 추출해서 검증 메서드에 검증을 해달라고 요청하는 것이 코드의 간결성과 가독성을 향상하는 기본적인 방법 중 하나이다
(2)에서 이메일에 대한 검증이 끝났으므로, (3)에서 회원 정보를 저장한다
(4)에서는 회원 정보를 수정하기 전에 수정하려는 회원 정보가 테이블에 존재하는지 여부를 검증하고 있다
(5)에서는 회원 존재 여부 검증에 통과한 회원이라면 이름과 주소 정보를 setter 메서드를 통해 변경 한다
## Optional.ofNullable(…)을 사용하는 이유
- 파라미터로 전달받은 member 객체는 클라이언트 쪽에서 사용자가 이름 정보나 휴대폰 정보를 선택적으로 수정할 수 있기 때문에 name 멤버 변수가 null일 수 도 있고, phone 멤버 변수가 null일 수도 있다
- 멤버 변수 값이 null일 경우에는 Optional.of()가 아닌 Optional.ofNullable()을 이용해서 null 값을 허용할 수 있다
- 값이 null이더라도 NullPointerException이 발생하지 않고, 다음 메서드인 ifPresent() 메서드를 호출할 수 있다
- 수정할 값이 있다면(name 또는 phone 멤버 변수의 값이 null이 아니라면) ifPresent() 메서드 내의 코드가 실행이 되고, 수정할 값이 없다면 (name 또는 phone 멤버 변수의 값이 null이라면) 아무 동작도 하지 않는다
(6)에서는 name 또는 phone 멤버 변수의 수정된 값이 적용되어 테이블에서 회원 정보를 업데이트 한다
## Spring Data JDBC에서는 @Id 애너테이션이 추가된 엔티티 클래스의 멤버 변수 값이 0 또는 null이면 신규 데이터라고 판단하여 테이블에 insert 쿼리를 전송한다
반면에 @Id 애너테이션이 추가된 엔티티 클래스의 멤버 변수 값이 0 또는 null이 아니라면 이미 테이블에 존재하는 데이터라고 판단하여 테이블에 update 쿼리를 전송한다
이미 테이블에 존재하는 회원 정보를 테이블에서 조회한 findMember 객체에서 name 또는 phone 멤버 변수만 setter 메서드로 값을 변경하는 방식을 이용해서 테이블에 update 쿼리를 보내게 된다
(7)에서는 memberId에 해당하는 특정 회원을 조회합니다.
(8)에서는 테이블에 존재하는 모든 회원 정보를 조회합니다. findAll() 메서드의 리턴값이 Iterable<T>이기 때문에 List<Member>로 캐스팅했습니다.
(9)에서는 특정 회원 정보를 삭제한다
## (9)에서는 학습을 위해 회원 정보 자체를 테이블에서 삭제했지만 실무에서는 테이블의 데이터 자체를 삭제하기보다는 MEMBER_STATUS 같은 열을 두어 상태 값만 변경한다
( 회원의 회원 가입 상태를 ‘가입’, ‘휴면’, ‘탈퇴’ 등의 상태 정보로 나누어서 관리하는 것이 바람직하다 )
(10)은 이미 존재하는 회원인지를 검증한 후, 검증된 회원 정보를 리턴해주는 기능을 한다
- optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));에서 orElseThorow()는 optionalMember 객체가 null 이 아니라면 해당 객체를 리턴하고 null이라면 예외를 던진다
(11)은 이미 등록된 이메일이 존재하는지를 검증해 주는 기능을 한다
CoffeeService 코드
- (1)에서는 영문으로 구성된 커피 코드(CoffeeCode)를 대문자로 변경하고 있다
커피 코드의 경우는 클라이언트 쪽에서 사용자가 대소문자를 가리지 않고 입력하더라도 (1)에서 일괄적으로 대문자로 변경하도록 했습니다.
결과적으로 (1)은 사용자가 대소문자에 신경 쓰지 않고 입력할 수 있도록 사용자 편의성을 높여주는 기능을 합니다.
- (2)는 주문한 커피 정보를 조회하는 메서드 이다
Order 객체는, memberId와 orderStatus 값만 얻을 수 있지 실제 회원이 주문한 커피 정보는 얻을 수 없습니다. 따라서 getOrderCoffees()를 통해서 주문한 구체적인 커피 정보를 얻어와야 합니다.
## (2)의 findOrderedCoffees(Order order) 메서드의 리턴값은 OrderResponseDto 클래스에 포함 된
OrderService 코드
- 주문 정보가 저장되기 전에는 주문 정보에 포함된 회원이 존재하는 회원인지 (1)과 같이 검증할 필요가 있다
- 주문하려는 커피 정보 역시 테이블에 존재하는지 (2)와 같이 검증해 주어야 한다
- 주문하려는 커피가 존재하는지 여부는 (2)와 같이 order.getOrderCoffees()를 통해서 Set<OrderCoffee>를 가져온 후, Java의 Stream으로 각각의 coffeeId를 얻은 후에 findVerifiedCoffee(orderCoffee.getCoffeeId()) 메서드를 통해 coffeeId에 해당하는 커피 정보가 유효한지 검증한다
- (3)에서는 주문 정보를 취소한다
- 일반적으로 커피 주문이 확정되면 커피 주문을 취소할 수 없어야 될 것이다
- (4)와 같이 OrderStatus가 주문 요청(ORDER_REQUEST) 단계를 넘어가면 주문 정보를 변경할 수 없도록 하며, 주문 요청(ORDER_REQUEST) 단계까지만 (5)와 같이 주문을 취소할 수 있도록 한다
기타 이번 챕터에서 수정된 코드
CoffeePostDto 코드
- 커피 코드는 커피라는 상품의 고유 식별 코드를 의미한다
OrderController 코드
postOrder() 핸들러 메서드의 수정 내용
- 등록할 주문 정보는 데이터베이스에 저장이 되고, ORDER 테이블에 하나의 row로 저장이 됩니다. 즉, ORDER_ID라는 고유한 식별자(기본키)를 가지는 진정한 주문 정보로써의 역할을 하게 된다
- 일반적으로 클라이언트 측에서 백엔드 애플리케이션 측에 어떤 리소스(회원 정보, 커피 정보, 주문 정보 등)의 등록을 요청할 경우, 백엔드 애플리케이션은 해당 리소스를 데이터베이스에 저장한 후 요청한 리소스가 성공적으로 저장되었음을 알리는 201 Created HTTP Status를 response header에 추가해서 클라이언트 측에 응답으로 전달한다, 추가적으로 데이터베이스에 저장된 리소스의 위치를 알려주는 위치 정보(Location)인 URI를 역시 response header에 추가해서 응답으로 전달한다
- 클라이언트 측에서는 response header에 포함된 리소스의 위치 정보(Location)를 얻은 후에 해당 리소스의 URI로 다시 요청을 전송해서 리소스의 정보를 얻어온다
(1)에서 리소스(주문 정보)의 디폴트 URL을 정의한다
(2)에서 UriComponentsBuilder를 이용해 등록된 리소스(주문 정보)의 위치 정보인 URI 객체를 생성한다
(3)에서 ResponseEntity.*created*(location).build();를 이용해 응답 객체를 리턴한다
( ResponseEntity.*created*(location) 메서드는 내부적으로 201 Created HTTP Status를 response header에 추가하고, 별도의 response body는 포함하지 않는다 )
- postOrder() request를 전송할 경우의 response의 모습입니다. 보다시피 201 Created HTTP Status이고, 등록된 주문 정보의 위치 정보가 Location header에 포함되어 있다
## 백엔드 애플리케이션 측에 리소스를 등록할 경우에는 등록된 리소스의 정보를 응답으로 리턴할 필요가 없다
(4), (5)에서는 주문한 커피 정보가 OrderResponseDto에 포함되도록 수정되었다
( CoffeeService 객체를 CoffeeMapper 매핑 메서드의 파라미터로 넘겨줌으로써 내부적으로 주문한 커피 정보를 OrderResponseDto에 포함시킬 수 있다 )
OrderPostDto 코드
- (1)과 같이 여러 잔의 커피를 주문할 수 있도록 수정되었습니다. List안에 포함된 객체에 대한 유효성 검증을 위해서는 (1)과 같이 @Valid 애너테이션을 추가해 주면된다
OrderCoffeeDto 클래스 추가
- OrderCoffeeDto 클래스는 여러 잔의 커피 정보를 주문하기 위해 추가된 DTO 클래스
OrderCoffeeResponseDto 코드 추가
- OrderCoffeeResponseDto 클래스는 주문한 여러 잔의 커피 정보를 응답으로 제공하기 위해 추가된 DTO 클래스
OrderResponseDto 코드
- OrderResponseDto 클래스는 아래와 같은 기능이 추가되어 수정되었습니다.
주문한 여러 건의 커피 정보를 응답으로 전송할 수 있도록 변경
주문 시간과 주문 상태를 응답으로 전송할 수 있도록 변경
OrderMapper 코드
- OrderMapper의 경우, DTO와 Entity 간의 매핑 작업이 복잡하기 때문에 우리가 서비스 계층에서 배웠던 Mapsturct의 매핑 방식 중에서 개발자가 직접 매핑 로직을 구현하는 직접 매핑 방식을 사용하고 있습니다.
orderPostDtoToOrder(OrderPostDto orderPostDto)
orderPostDtoToOrder() 메서드는 등록하고자 하는 커피 주문 정보(OrderPostDto)를 Order 엔티티 클래스의 객체로 변환하는 역할을 한다
(1) 에서는 orderPostDto에 포함된 memberId를 Order 클래스의 memberId에 할당해 줍니다
(2)에서는 orderPostDto에 포함된 주문한 커피 정보인 List<OrderCoffeeDto> orderCoffees를 Java의 Stream을 이용해 Order 클래스의 Set<OrderCoffee> orderCoffees으로 변환하고 있습니다
- (2-1)에서는 OrderCoffee 클래스에 @Builder 애너테이션이 적용되어 있으므로 lombok에서 지원하는 빌더 패턴을 사용할 수 있습니다.
- 따라서 빌더 패턴을 이용해 List<OrderCoffeeDto> orderCoffees에 포함된 주문한 커피 정보를 OrderCoffee의 필드에 추가하고 있습니디
orderToOrderResponseDto(CoffeeService coffeeService, Order order)
orderToOrderResponseDto()는 데이터베이스에서 조회한 Order 객체를 OrderResponseDto 객체로 변환해 주는 역할을 합니다.
(3)에서는 Order의 memberId 필드 값을 얻습니다
(4)에서는 주문한 커피의 구체적인 정보를 조회하기 위해 orderToOrderCoffeeResponseDto(coffeeService, order.getOrderCoffees());를 호출합니다.
- order.getOrderCoffees()의 리턴 값은 Set<OrderCoffee> orderCoffees이고, 이 orderCoffees에는 커피명이나 가격 같은 구체적인 커피 정보가 포함된 것이 아니기 때문에 데이터베이스에서 구체적인 커피 정보를 조회하는 추가 작업을 수행해야 합니다.
orderCoffeesToOrderCoffeeResponseDtos(CoffeeService coffeeService, Set<OrderCoffee> orderCoffees)
orderCoffeesToOrderCoffeeResponseDtos()는 데이터베이스에서 커피의 구체적인 정보를 조회한 후, OrderCoffeeResponseDto에 커피 정보를 채워 넣는 역할을 합니다.
(5)에서는 파라미터로 전달받은 orderCoffees를 Java의 Stream을 이용해 데이터베이스에서 구체적인 커피 정보를 조회한 후, OrderCoffeeResponseDto로 변환하는 작업을 하고 있습니다
- (5-1)에서 파라미터로 전달받은 coffeeService 객체를 이용해 coffeeId에 해당하는 Coffee를 조회하고 있습니다
ExceptionCode 클래스
- Spring Data JDBC의 CrudRepository 인터페이스를 상속하면 CrudRepository에서 제공하는 CRUD 메서드를 사용할 수 있다.
- Spring Data JDBC에서는 SQL 쿼리를 대신하는 다양한 쿼리 메서드(Query Method) 작성을 지원한다.
- Spring Data JDBC에서 지원하는 쿼리 메서드의 정의가 어렵다면 @Query 애너테이션을 이용해서 SQL 쿼리를 직접 작성할 수 있다.
- 회원 정보, 커피 정보 등의 리소스를 데이터베이스에 Insert할 경우 이미 Insert 된 리소스인지 여부를 검증하는 로직이 필요하다.
- Optional을 이용하면 데이터 검증에 대한 로직을 간결하게 작성할 수 있다.
- 복잡한 DTO 클래스와 엔티티 클래스의 매핑은 Mapper 인터페이스에 default 메서드를 직접 구현해서 개발자가 직접 매핑 로직을 작성해 줄 수 있다.
'Code' 카테고리의 다른 글
2023.06.22 코드스테이츠 51회차. ( 엔티티 간의 연관 관계 매핑 ) (0) | 2023.06.22 |
---|---|
2023.06.21 코드스테이츠 50회차. ( JPA 시작 ) (0) | 2023.06.21 |
2023.06.19 코드스테이츠 48회차. ( Spring Data JDBC 기반의 도메인 엔티티 및 테이블 설계 ) (1) | 2023.06.19 |
2023.06.16 코드스테이츠 47회차. ( Spring JDBC ) (0) | 2023.06.16 |
2023.06.15 코드스테이츠 46회차. ( Spring 비지니스적인 예외 던지기, 예외 처리 ) (0) | 2023.06.15 |