서로 다른 레이어에서 변수들을 전달할 때 서로 다른 dto를 사용하는데, 얼핏 비슷하면서도 한두 개 다른 변수들을 하나씩 매핑해 주는 게 매우 귀찮고 번거로웠다.
어휴..
그래서 이를 자동(?)으로 해주는 라이브러리 같은 게 있을까 싶어서 몇 개 찾아보았다.
구글링 해보면 여러 개가 나오는데 아래 3개를 먼저 확인해 보았다.
1. 스프링에 기본 내장되어 있는 BeanUtils.copyProperties
2. 사용하기 편해 보이는 ModelMapper
3. 요즘 제일 인기 있는 Mapstruct
실제로 사용하려는 목적이기에 현실적인 사용성을 중심으로 살펴보았다.
dependency
di주입
원리(setter 선언 필요?)
필드 커스텀 가능 여부
Mapstruct
추가 필요(4개)
빈 주입 필요 interface 작성
리플랙션X - 컴파일 시 작성된 getter/setter or 빌더 or 생성자 등 관련 코드를 이용하여 변환 class 만듦 - setter 없어도 됨
매핑으로 커스텀 설정 가능
ModelMapper
추가 필요(1개)
주입 필요 없음
리플렉션O getter/setter 기반 - 필드명/타입이 동일할 경우 필드 엑서스 레벨을 private으로 설정하면 setter가 필요없지만 - 커스텀 시 setter 선언 필요
커스텀을 위해 typeMap과 addMapping 을 이용하여 손수 매핑해줘야 함
BeanUtils.copyProperties
추가 필요 없음 spring 내장
주입 필요 없음 static method void type
리플렉션O getter/setter 기반 - setter 선언 필요 - 커스텀 시 setter 수정 필요
더 복잡한 변환이 필요할 경우 사용 불가하며spring BeanWrapper를 직접 구현해야 함
Mapstruct
lombok과 함께 사용가능(lombok compile 이후에 작동)
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
testAnnotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
//If you are using Lombok 1.18.16 or newer you also need to add lombok-mapstruct-binding in order to make Lombok and MapStruct work together.
implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
public interface OrderRepository extends JpaRepository<Order, Long> {
//get, read, query, find -> select문
//All -> 의미 없음; 뭘 가져올지는 return type으로
//by -> 조건
@EntityGraph("orderWithCustomer")
//select * from order left join customer -> 커스토머 가져오고
//select * from orderItem left join item -> 추가로 발생
List<Order> getAllBy();
@EntityGraph("orderWithOrderItems")
//select * from order left join orderItem -> 한방에
List<Order> readAllBy();
@EntityGraph("orderWithCustomerAndOrderItems")
//select * from order left join customer left join orderItem -> 한방에
List<Order> queryAllBy();
@EntityGraph("orderWithCustomerAndOrderItemsAndItem")
//select * from order left join customer left join orderItem left join item -> 한방에
List<Order> findAllBy();
}
Pagination 쿼리에 Fetch Join
Pagination 쿼리에 Fetch Join을 적용하면 실제로는 모든 레코드를 가져오는 쿼리가 실행된다
: 다 가져와서 필요한 부분만 발라서 줌
: DB 서버는 전체를 부르게 되니 부하 오짐
실제로는 에러가 아닌 warning에 아래와 같은 메세지가 나고 있었다..
디비에서는 다가져와서 메모리에서 할게 ㅎㅎ
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
cf.) pagination query 에서 offset, limit 에 bind 된 parameter 값은 왜 log에 안 나오죠?
limit 뒤의 값을 알고싶어요..
select
order0_.order_id as order_id1_3_,
order0_.customer_id as customer3_3_,
order0_.order_dt as order_dt2_3_
from orders order0_
where
order0_.order_id=?
limit ?
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [1]
Hibernate는 collection type으로 list, set, bag, map 등 다양한 타입을 지원
Java의 java.util.List 타입은 기본적으로 Hibernate의Bag타입으로 맵핑됨
Bag은 Hibernate에서 중복 요소를 허용하는 비순차(unordered) 컬렉션
둘 이상의 컬렉션(Bag)을 Fetch Join하는 경우, 그 결과로 만들어지는 카테시안 곱(Cartesian Product)에서 어느 행이 유효한 중복을 포함하고 있고 어느 행이 그렇지 않은 지 판단할 수 없어 Bag컬렉션으로 변환될 수 없기 때문에 MultipleBagFetchException 예외 발생
조인 할 때 row에 옆으로 쫙 늘어나면서 데이터가 n*n으로 나올거잖슴,, 반복되는 내용 때문인지 중복이 왜 나는지 구분이 안되니
Hibernate:
create table order_attributes (
order_attribute_id bigint generated by default as identity,
attribute varchar(255),
order_id bigint,
attributes_order integer, ///순서를 저장하기 위한 콜롬이 추가로 생성된
primary key (order_attribute_id)
)
Hibernate:
create table order_items (
order_line_id bigint generated by default as identity,
quantity bigint,
item_id bigint,
order_id bigint,
order_items_order integer, ///순서를 저장하기 위한 콜롬이 추가로 생성된
primary key (order_line_id)
)
1, 2번으로 해결이 안되면 Jpa를 쓰지말자! ㅋㅋ
Repository: spring이 제공하는 데이터에 접근하는 layer
Spring Data Repository
Repository는 JPA의 개념이 아니고, Spring Framework가 제공해주는 것임.
data access layer 구현을 위해 반복해서 작성했던, 유사한 코드를 줄일 수 있는 추상화 제공
이름 규칙으로 join 쿼리 가져오기(N+1 날 수 있음)
public interface MemberRepository extends JpaRepository<Member, Long> {
// select m from Member m inner join MemberDetail md where md.type = ?
// 연관관계가 있을때만 가능
// _ 로 안으로 들어갈 수 있음
List<Member> findByDetails_Pk_Type(String type);
}
DTO Projection
DTO Projection 이란: entity 바를 때
Repository 메서드가 Entity를 반환하는 것이 아니라 원하는 필드만 뽑아서 DTO(Data Transfer Object)로 반환하는 것
메모리나 디비 성능에 도움
Dto Projection 방법
Interface 기반 Projection: 아래에 계속!
Class 기반 (DTO) Projection: 생성자
Dynamic Projection: runtime에 결정
트러블 슈팅 - Spring Data Repository 로는 Dto Projection을 할 수 없다?
Spring Data Repository를 이용한 Dto Projection
Repository 메서드의 반환 값으로 Entity가 아닌 Dto를 사용할 수 있다
interface / class
@Value+ SpEL (target)
public interface OrderRepository extends OrderRepositoryCustom, JpaRepository<Order, Long> {
List<OrderDto> findAllBy();
}
public interface OrderDto {
Long getOrderId();
CustomerDto getCustomer();
List<OrderItemDto> getOrderItems();
interface CustomerDto {
String getCustomerName();
}
interface OrderItemDto {
ItemDto getItem();
Long getQuantity();
}
interface ItemDto {
String getItemName();
Long getItemPrice();
}
}
Hibernate:
select
order0_.order_id as order_id1_3_,
order0_.customer_id as customer3_3_,
order0_.order_dt as order_dt2_3_
from
orders order0_
Hibernate:
select
customer0_.customer_id as customer1_0_0_,
customer0_.customer_name as customer2_0_0_
from
customers customer0_
where
customer0_.customer_id=?
Hibernate:
select
customer0_.customer_id as customer1_0_0_,
customer0_.customer_name as customer2_0_0_
from
customers customer0_
where
customer0_.customer_id=?
Hibernate:
select
orderitems0_.order_id as order_id4_2_0_,
orderitems0_.order_line_id as order_li1_2_0_,
orderitems0_.order_line_id as order_li1_2_1_,
orderitems0_.item_id as item_id3_2_1_,
orderitems0_.quantity as quantity2_2_1_,
item1_.item_id as item_id1_1_2_,
item1_.item_name as item_nam2_1_2_,
item1_.item_price as item_pri3_1_2_
from
order_items orderitems0_
left outer join
items item1_
on orderitems0_.item_id=item1_.item_id
where
orderitems0_.order_id=?
Hibernate:
select
orderitems0_.order_id as order_id4_2_0_,
orderitems0_.order_line_id as order_li1_2_0_,
orderitems0_.order_line_id as order_li1_2_1_,
orderitems0_.item_id as item_id3_2_1_,
orderitems0_.quantity as quantity2_2_1_,
item1_.item_id as item_id1_1_2_,
item1_.item_name as item_nam2_1_2_,
item1_.item_price as item_pri3_1_2_
from
order_items orderitems0_
left outer join
items item1_
on orderitems0_.item_id=item1_.item_id
where
orderitems0_.order_id=?
Hibernate:
select
orderitems0_.order_id as order_id4_2_0_,
orderitems0_.order_line_id as order_li1_2_0_,
orderitems0_.order_line_id as order_li1_2_1_,
orderitems0_.item_id as item_id3_2_1_,
orderitems0_.quantity as quantity2_2_1_,
item1_.item_id as item_id1_1_2_,
item1_.item_name as item_nam2_1_2_,
item1_.item_price as item_pri3_1_2_
from
order_items orderitems0_
left outer join
items item1_
on orderitems0_.item_id=item1_.item_id
where
orderitems0_.order_id=?
JPA 연관관계도 내부적으로 FK 참조를 기반으로 구현하므로 본질적으로 참조의 방향은 단방향
단방향에 비해 양방향은 복잡하고 양방향 연관관계를 맵핑하려면 객체에서 양쪽 방향을 모두 관리해야 함
물리적으로 존재하지 않는 연관관계를 처리하기 위해 mappedBy 속성을 통해 관계의 주인을 정해야 함
단방향을 양방향으로 만들면 반대 방향으로의 객체 그래프 탐색 가능
우선적으로는 단방향 맵핑을 사용하고 반대 방향으로의 객체 그래프 탐색 기능이 필요할 때 양방향을 사용
일반적으로 단방향으로도 충분하지만 그게 아닌 경우가 있다.
일대다 연관관계 시 '다'에 해당하는 양만큼 update문이 나갈 수 있음 -> 양방향 연관관계 필요
복합키까지 쓰는 경우라면, 그리고 그 값이 FK에도 쓰는 경우라면 @MapsId로 지정해야 한다.
예시: 단방향으로 설정 시 원치 않은 update문이 나갈 수 있음
class Member{
...
@OneToMany(cascade = CascadeType.ALL) //member 바뀌면 아래도 알아서 바뀌라
@JoinColumn(name = "member_id")
private List<MemberDetail> details = new ArrayList<>();
}
//CASCADE 넣으면 이거 하나로 끝
memberRepository.save(member);
Hibernate: insert into members (create_dt, name, member_id) values (?, ?, ?)
Hibernate: insert into member_details (description, type, member_detail_id) values (?, ?, ?)
Hibernate: insert into member_details (description, type, member_detail_id) values (?, ?, ?)
Hibernate: update member_details set member_id=? where member_detail_id=?
Hibernate: update member_details set member_id=? where member_detail_id=?
insert 할 때 한 번에 하면 되지 않나 왜 update를?
해결: 양방향 일대다(1:N)로 변경
class Member{
...
@OneToMany(cascade = CascadeType.ALL, mappedBy = "member")
private List<MemberDetail> details = new ArrayList<>();
}
//
class MemberDetail{
...
@ManyToOne
@JoinColumn(name = "member_id")//column이름; 양방향일 때 관계 주인은 fk를 가지고 있는 여기!
private Member member;
}
///
//양방향이기 때문에 양쪽으로 다 세팅해야 함
memberDetail1.setMember(member);
member.getDetails().add(memberDetail1);
Repeated column in mapping for entity: com.jpa.entity.MemberDetail column: member_id (should be mapped with insert="false" update="false")
근데 에러가 남 두둥
@Entity
@Table(name = "MemberDetails")
public class MemberDetail {
@EmbeddedId
private Pk pk;
private String description;
@ManyToOne
@JoinColumn(name = "member_id")///
private Member member;
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode
@Embeddable
public static class Pk implements Serializable {
@Column(name = "member_id") ////
private Long memberId;
private String type;
}
}
그러면 이렇게 하면 될까? nope! 왜냐면 cascade 때문에. 그걸로 업데이트/인서트 하겠다고 한 건데 안 하겠다고 하면(updatable false) 안되지
그렇다면?
@ManyToOne
@MapsId("memberId")
private Member member;
pk에도 쓰이는 칼럼이 @JoinColumn에서도 써야 한다면, 같은 거를 쓴다고 알려줘야 함
MapsId 만 써도 되는데 PK가 복합키라서 그중에 뭐? 를 알려줘야 할 때
PK에서 사용되는 콜롬이 FK에도 쓰인다! @MapsId(변수명)
그러면 insert 세 개만 나간다!
정리: 양방향을 맺자
@Entity
@Table(name = "Orders")
public class Order {
...
@OneToMany(mappedBy = "order", cascade = CascadeType.Merge, CasecadeType.PERSIST)//order 저장 시 detail도 저장하게 하려면 여기다가 cascade option 필요
private List<OrderDetail> details = new ArrayList<>();
}
////
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
...
@EmbeddedId
private Pk pk = new Pk();
@ManyToOne
// @JoinColumn(name = "order_id") //원래대로라면 이렇게 하지만 PK에도 사용되니..
@MapsId("orderId")
private Order order;
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode
@Embeddable
public static class Pk implements Serializable {
@Column(name = "order_id")
private Long orderId;
private String type;
}
}
////
@Transactional
public void doSomething() {
Order order = new Order();
order.setOrderDate(LocalDateTime.now());
OrderDetail orderDetail1 = new OrderDetail("type1");
orderDetail1.setDescription("order1-type1");
orderDetail1.setOrder(order);
OrderDetail orderDetail2 = new OrderDetail("type2");
orderDetail2.setDescription("order1-type2");
orderDetail2.setOrder(order);
order.getDetails().add(orderDetail1);
order.getDetails().add(orderDetail2);
orderRepository.save(order); ///여기서 한번만 해도 detail이 들어가려면 Cascade가 필요한 것이다!
}
N + 1 문제
JPA에서 N+1 문제는 자식 엔티티가 얼마나 있는지에 관계없이 부모 엔티티 개수에 따라 추가적인 쿼리가 발생하기 때문에, 자식 엔티티의 개수와는 상관이 없습니다. 이 문제는 정확히 부모 엔티티의 개수와 관련이 있습니다. 좀 더 명확히 설명하자면 다음과 같습니다:
1. N+1 문제의 본질
N+1 문제란, 부모 엔티티를 조회하는 1번의 쿼리와, 각 부모 엔티티에 대해 자식 엔티티를 조회하기 위한 N번의 추가 쿼리가 발생하는 문제를 의미합니다. 여기서 N은 부모 엔티티의 개수를 의미합니다.
1번의 쿼리: 부모 엔티티를 조회하는 쿼리입니다.
N번의 쿼리: 각 부모 엔티티마다 자식 엔티티를 조회하는 쿼리가 발생하는 것입니다.
이 문제는 자식 엔티티의 수가 아니라, 부모 엔티티의 수만큼 추가적인 쿼리가 발생하는 것이 문제의 핵심입니다.
2. 부모 1건, 자식 여러 건의 경우
부모 엔티티가 1건이라면 자식 엔티티가 아무리 많더라도, 부모 엔티티를 조회한 후 자식 엔티티를 조회하기 위한 쿼리가 단 1번 발생합니다.
@Entity
public class Parent {
@Id
private Long id;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;
}
@Entity
public class Child {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}
쿼리 한 번으로 N 건의 레코드를 가져왔을 때, 연관관계 Entity를 가져오기 위해 쿼리를 N번 추가 수행하는 문제
@Entity
@Table(name = "Orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private Long orderId;
@ManyToOne(optional = false) //eager -> join
@JoinColumn(name = "customer_id")
private Customer customer;
@Column(name = "order_dt")
private LocalDateTime orderDate;
@OneToMany(cascade = CascadeType.ALL) //lazy 아직..
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems;
}
//
@Entity
@Table(name = "OrderItems")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_line_id")
private Long orderLineId;
@ManyToOne
@JoinColumn(name = "item_id")
private Item item;
private Long quantity;
}
public void getOne() {
//단건은 연관관계 매핑을 고려해서 join사용
orderRepository.findById(1L);
}
public void getMulti() {
//findall은 명확하게 모르는 쿼리를 날리면 우선 실행하고 연관관계매핑을 적용
orderRepository.findAll();
}
단 건을 실행하면 join으로 한 번에 가져오는데
findAll실행하면 우선 order 실행하고 eager인 customer 실행
Hibernate:
select
order0_.order_id as order_id1_3_,
order0_.customer_id as customer3_3_,
order0_.order_dt as order_dt2_3_
from
orders order0_
//
select
customer0_.customer_id as customer1_0_0_,
customer0_.customer_name as customer2_0_0_
from
customers customer0_
where
customer0_.customer_id=?
//
select
customer0_.customer_id as customer1_0_0_,
customer0_.customer_name as customer2_0_0_
from
customers customer0_
where
customer0_.customer_id=?
feachType의 문제가 아니야!
해결 방법
Fetch Join
join을 사용하여 쿼리 하나로 실행하겠다.
JPQLjoin fetch
fetch 없으면, from절에 있는 메인 엔티티만 반환. 우선 진행시켜! 그 후에 from절 전에 있는 메인 엔티티를 하나씩 따져보면서 다시 N+1 실행
fetch를 써야 select절에 다른 것들도 받음
QuerydslfetchJoin()
그룹화하여 쿼리 실행 횟수를 줄이겠다.
Entity Graph //선언적으로 어디까지 탐색할 수 있는지 지정가능
그 외
Hibernate @BatchSize //나눠서
Hibernate @Fetch(FetchMode.SUBSELECT) //in절에 넣어서 실행
주의. 그지 같은 join문으로 인해.. 성능이 더 나빠질 수 있음.
@Query("select o from Order o "
+ " inner join fetch o.customer as c "
+ " left join fetch o.orderItems as oi "
+ " inner join fetch oi.item as i")
List<Order> getOrdersWithAssociations();
Querydsl
복잡한 쿼리 작성 시 컴파일러의 도움을 받을 수 있음
JPA에서 제공하는 객체 지향 쿼리
JPQL: Entity 객체를 조회하는 객체 지향 쿼리 // text 기반이라 compiler의 도움을 못 받음
Criteria API: JPQL을 생성하는 빌더 클래스 //복잡함
third party library를 이용하는 방법
Querydsl
jOOQ //native query 기반
…
JPQL vs Criteria API
JPQL
SQL을 추상화해서 특정 DBMS에 의존적이지 않은 객체지향 쿼리
문제 : 결국은 SQL이라는 점
문자 기반 쿼리이다 보니 컴파일 타임에 오류를 발견할 수 없다
Criteria API
프로그래밍 코드로 JPQL을 작성할 수 있고 동적 쿼리 작성이 쉽다
컴파일 타임에 오류를 발견할 수 있고 IDE의 도움을 받을 수 있다
문제 : 너무 복잡
Querydsl
Criteria API처럼 정적 타입을 이용해서 JPQL을 코드로 작성할 수 있도록 해 주는 오픈소스 프레임워크
Spring Data Repository 추상화 덕분에 interface 선언만으로도 쿼리 생성 가능
interface에 선언된 메서드의 실제 구현체는 아래 순서로 탐색하게 된다
기본 구현체(JpaRepository의 구현체인 SimpleJpaRepository::saveAll etc.)
메서드 이름 규칙 적용 (cf. 메서드 이름으로 쿼리 생성 findByNameLikeAndPhone etc.)
Custom Repository Implementation
MemberRepositoryImpl -> MemberRepositoryCustomImpl 로 바꾸면? O
OrderRepositoryImpl -> OrderRepositoryCustomImpl 로 바꾸면? O
Repository Fragment 를 이용한 Custom Repository 구현
: 최근에 바뀜; 하나의 커스텀 레파지토리에 다 넣을 필요 없이 여러개로 구현체를 나눠서 구현 가능(repository fragment)
: 나눠서 구현하고 주 repository에 상속하듯 사용해도 된다
Repository Fragment 를 이용한 Custom Repository
// no `@NoRepositoryBean`
public interface CustomizedMemberRepository {
List<Member> getMembersWithAssociation();
}
// NOT MemberRepository + `Impl`
// BUT **CustomizedMemberRepository** + `Impl`
public class CustomizedMemberRepositoryImpl implements CustomizedMemberRepository {
// ...
}
여러 개의 Custom Repository 구현 가능
앞서 본CustomizedMemberRepositoryinterface와CustomizedMemberRepositoryImplclass 와 같이
예를 들면GuestRepositoryinterface,GuestRepositoryImplclass 같이 여러 개의 Custom Repository 구현 가능
Custom Repository 들로 구성된 Repository
public interface MemberRepository
extends CustomizedMemberRepository, GuestRepository {
}
참고) @Repository 어노테이션이란(not jpa 과거에..)
방식: streotype bean -> 해당 이름으로 된 빈들을 찾아서 자동으로 등록
그러나 jpa는.. 그 방식이 아니고
interface extends JpaRepository -> 다 뒤져서 jpa 후보군으로 등록
@NoRepositoryBean -> 그 후보군에서 빼줘
Qtype이 안 생겨서 -> no complie... -> 못 찾으면.. 세상 망함.. 온 세상이 빨개요..
cf.) 트러블 슈팅 - Querydsl Q-type class variable로 "member"나 "order"를 쓸 수 없다?!
Querydsl에서 Q-type class 인스턴스 생성 시 variable을 "member"로 주면 에러 발생
예제
QMember member = new QMember("member");
QOrder order = new QOrder("order");
unexpected token: member
unexpected token: order
이유
JPQL에MEMBER OF연산자가 있기 때문에MEMBER가 예약어라 variable에 쓸 수 없음
@Query("SELECT m FROM Member m WHERE :detail MEMBER OF m.details")
Member getMemberContainingDetail(MemberDetail detail);
마찬가지로 JPQL에ORDER BY연산자가 있기 때문에ORDER가 예약어라 variable에 쓸 수 없음
해결방법
아래 코드에서 각각의 Q-type class의 static 변수는 variable 값이 뭐라고 되어 있을까?
QMember member = QMember.member;
QOrder order = QOrder.order;
member, order :: 예약어; 다른걸로 쓰면 된다.
소스를 까보면 지들도 member를 회피하기 위해 member1로 쓰고있음
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {
private static final long serialVersionUID = 1029385075L;
public static final QMember member = new QMember("member1"); /// member를 회피하기 위한
22년 초에 restTemplate을 사용하는 프로젝트를 작업하다가 위 문구를 보게 되었다.
음..? 잘 쓰고 있던 rest template이 deprecated 된다고?
그래서 그 이후에 신규로 진행하는 프로젝트는 webClient를 사용하였다.
왜 webClient를 사용하였냐고 물으신다면, 위에서처럼 굳이 spring java doc에 대체하여 쓰라고 할 정도니, 스프링 진영에서 정식으로 밀고 있는 것이라 생각했기 때문이다(곧 대세가 될 것이라 생각했다).
참고로 webClient는 springframework 5에 추가된 것으로 기본적으로 reactive, non-blocking 요청을 지원한다(그래서 응답이 Mono, Flux 등으로 온다).
무튼 그렇게 webClient를 신규 프로젝트들에서 사용하게 되는데, 설정과 사용 시 상당한 라인의 코드가 필요한 것을 깨닫게 되었다.
공통 설정을 빈에 등록하는 코드, 그걸 가져와서 서비스마다 주입을 하고, 주입된 webClient로 get/post 등의 요청을 하는데도 상당한 코드가 필요하다.
get 사용 예시
물론 공통화하여 사용하고 있기는 하지만 외부 api가 새로 추가할 때마다 비슷한 양을 추가해야 한다.
사실 처음에는 webClient를 사용함으로써 webFlux에 친숙해지고, 궁극적으로는 non-blocking 요청에 대한 친근감(..)이 생기지 않을까 하는 마음이 컸다. 하지만 업무에서는 실질적으로 동기 요청이 훨씬 많았고, 이를 위해 억지로 mono.block()을 하고 있어 코드 양만 늘어난 샘이 되었다.. 결국 제대로 활용하지 못하고 있다는 생각이 들었다.
그렇게 시간이 지나고 22년 11월 springframework6이 정식(GA) 출시하면서 진짜 restTemplate에 @Deprecated가 달렸는지 궁금해졌다.
그런데 새로운 프래임워크를 열어보기도 전, 현재 사용하는 프로젝트(springboot2.7.3; springframework 5.3)에서 먼저 확인하니 안내 문구가 바뀌어져 있었다?
(확인해 보니 springframework3, 4에는 안내하는 javadoc 조차 없음)
NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.
webClient를 추천하는 문구는 그대로인데.. deprecated 된다는 말은 쏙 빠지고, 유지보수모드(간단한 버그수정만 지원)로 전환한다는 말로 바뀌어져 있었다.요 녀석들.. 고도의 밑장 빼기인가..
위에서 webClient를 사용하면서도 굳이 이걸 써야하는가? 다음 프로젝트에서도 또 webClient를 쓸 것인가? 대해 의문을 가지고 있었는데.. 마침 springframework6 문서에서 Http interface에 대한 글을 보게 된다.
2. 이 interface의 구현체는 스프링으로 부터 자동으로 생성되는데, 아래와 같이 빈을 등록해야 한다.
@Configuration
public class GiaHttpConfig {
@Bean
GiaArenaRingService getGiaArenaRingService(@Value("${gia-aapoker-dev}") String url) {
WebClient client = WebClient.builder().baseUrl(url).build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(client)).build();
GiaArenaRingService service = factory.createClient(GiaArenaRingService.class);
return service;
}
}
3. 사용하고자 하는 곳에서 이 빈을 주입한 후 해당 함수를 호출하면 된다.
@Service
@RequiredArgsConstructor
public class ExternalService {
private final GiaArenaRingService giaArenaRingService;
public Map<String, Object> getArenaRingGameHttpInterface(BigInteger id) {
return giaArenaRingService.getArenaRingGame(id);
}
}
끝.
예시는 바로 객체를 꺼내 오도록 했으나 기존의 webClient처럼 Mono나 Flux로 반환하게끔 할 수도 있다(support both blocking and reactivereturn values).
사용법이 간단하고 api 스펙을 interface로 선언하기만 하면 되어 한눈에 볼 수 있다는 장점이 있는 것 같다.
webClient 기반이라 기존 webClient에서 지원하던 기능들은 설정방식만 조금 다를 뿐 다 지원할 듯하다.
가독성이 떨어지고 코드의 양이 많았던 webClient의 단점을 어느 정도 보완해 줄 수 있을 것 같아 기회가 되면 사용해 볼 생각.
@ModelAttribute는 폼 데이터나 쿼리 파라미터에서 값을 받아서 Java 객체로 바인딩합니다.
일반적으로 필요한 요소:
기본 생성자 (No-args Constructor): 필수 @ModelAttribute는 데이터 바인딩을 위해 기본 생성자를 사용합니다. 기본 생성자가 없으면 바인딩이 불가능합니다.
Getter/Setter 메서드: 필수 @ModelAttribute는 요청 데이터(폼 데이터나 쿼리 파라미터)를 필드에 바인딩할 때, 객체 필드에 직접 접근하지 않고 setter 메서드를 사용합니다. 따라서 필드마다 setter가 필요합니다. 또한, 검증 후 데이터를 읽어올 때는 getter가 필요합니다.
1. NoArgsConstructor로 객체 생성 후 Setter로 주입
setter가 없다면 값을 넘겨도 null로 세팅된다.
setter로 변수 이름 변경 가능하다.(받는 변수명은 idSeq -> 바인딩하는 변수는 seq 로 가능)
@Setter
public class ReceivedUserRequest {
@NotBlank
private String mailIdx;
}
2. public AllArgsConstructor 로 주입
public class ReceivedUserRequest {
@NotBlank
private String mailIdx;
public ReceivedUserRequest(String mailIdx) {
this.mailIdx = mailIdx;
}
}
Spring은 때때로 생성자 기반 바인딩을 사용할 수 있으며, 이는 주로 다음과 같은 상황에서 발생합니다:
폼 데이터나 URL 파라미터에서 넘어오는 값이 모든 필드에 전달될 때.
객체 생성 시 한 번에 모든 필드를 초기화할 수 있는 AllArgsConstructor가 있는 경우.
> deserialize 자체에는 getter가 없어도 됨(그렇지만 결국 serialization 하다가 필요해서 에러가 남 ㅋㅋ)
Java implicitly adds a no-arg constructor to all classes when there is no constructors defined. If you define any parameterized constructor then the no-arg constructor will not be added.
You need a default constructor (constructor without any argument) in entities. This is needed because JPA/Hibernate uses the default constructor method to create a bean class using reflections. If you don't specify any constructor (nor Lombok annotation), Java will generate a default constructor (automatically generated by the compiler if no constructors have been defined for the class). But if you add a constructor with parameters (or @AllArgsConstructor), then you'll need to add a no args constructor (or @NoArgsConstructor) as well, for JPA/Hibernate to work.
POST mapping
request body -> object
참고로 아래 지식이 필요하다.
When using JSON format, Spring Boot will use an ObjectMapper instance to serialize responses and deserialize requests.
<getter/setter/constructor 없이 매핑을 시도하면 에러가 난다>
2. @RequestBody의 DTO 요구 사항
@RequestBody는 JSON 또는 XML과 같은 요청 본문을 Java 객체로 **역직렬화(deserialize)**합니다. @RequestBody는 JSON 데이터를 객체 필드에 직접 바인딩하는 방식이므로 필드 접근 방식이 조금 다릅니다.
필요한 요소:
기본 생성자 (No-args Constructor): 필수 아님 @RequestBody는 JSON 데이터를 역직렬화할 때 Jackson 라이브러리를 사용합니다. Jackson은 기본 생성자를 사용하거나, @JsonCreator 어노테이션을 사용하여 특정 생성자를 통해 객체를 생성할 수 있습니다. 하지만, 기본적으로 기본 생성자가 있는 것이 편리합니다.
Getter/Setter 또는 필드 접근: 필수는 아님 Jackson은 JSON 데이터를 객체로 변환할 때 필드 또는 Getter/Setter를 통해 값을 설정합니다. 필드가 public이라면 직접 필드에 접근할 수도 있지만, 일반적으로 getter/setter를 통해 데이터를 주고받는 것이 더 안전합니다.
1. setter가 없을 경우, (역직렬화에 setter가 사용되기 때문에)
값을 보내도 null로 인식하여 @NotNull validation이 실패한다.
Field error in object 'cancelRequest' on field 'mailIdx': rejected value [null]; codes [NotBlank.cancelRequest.mailIdx,NotBlank.mailIdx,NotBlank]
2. 또한 역직렬화 시 기본 생성자 생성 후 setter를 사용하기 때문에
@NoArgsConstructor나 @AllArgsConstructor가 아닌 일부 생성자만 있는 경우는 아래 에러가 난다.(둘 중 하나만 있어도 매핑이 잘 된다)
cannot deserialize from Object value (no delegate- or property-based Creator)
3. @Getter가 없을 경우, 역직렬화는 문제가 없지만 후에 직렬화 과정에서 null로 내려간다.
역직렬화 / 직렬화에 대해 정리하자면 아래와 같다.
역직렬화는 기본적으로 다음과 같은 과정을 거쳐서 처리된다. - object mapper 사용 - 기본 생성자로 객체를 생성함 -> 기본 생성자가 없으면 에러 -필드값을 찾아서 값을 바인딩해줌 -> public 필드 또는 public 형태의 setter로 바인딩
직렬화는 다음과 같다. - Spring에서는 기본적으로 jackson 모듈의 ObjectMapper라는 클래스가 직렬화를 처리하며 ObjectMapper의 writeValueAsString이라는 메서드가 사용됨 - ObjectMapper는 public 필드 또는 public 형태의 getter로 값을 가져옴
> 직렬화에 대해 조금 더 깊숙이 들어가면..
application/json타입의 데이터는 스프링부트의 MessageConverter -> MappingJackson2HttpMessageConverter -> Jackson 라이브러리의 Object mapper클래스를 이용해 Reflection으로 객체를 생성하게 된다. 이때 기본 생성자가 없을 경우 Jackson 라이브러리가 deserialize 할 수 없어 예외가 발생한다.