728x90
반응형
728x90
반응형
반응형

환경: springboot 2.7.3, java11, gradle 7.5

테스트를 돌리는데

분명 이전에 성공했던 테스트인데

시간이 흐른 뒤 재실행했을 때 아래와 같은 에러가 났다.

Execution failed for task ':test'. > No tests found for given includes:

 

그 사이 코드가 바뀐 것도 없어 안될 리가 없을 터.

게다가 다른 클래스의 테스트 코드는 잘 실행되고 한 클래스의 테스트코드만 안돼서 이상했다.

 

구글링 해보면

junit vintage 버전 충돌 어쩌구라고 하는데 나에게는 해당되지 않는 듯하여 과감하게 버리고

https://www.inflearn.com/questions/15495/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8F%84%EC%A4%91-%EC%97%90%EB%9F%AC-%EB%B0%9C%EC%83%9D

 

테스트 도중 에러 발생 - 인프런 | 질문 & 답변

FAILURE: Build failed with an exception. * What went wrong: Execution failed for task ':test'. > No tests found for given includes: [jpabook.jpashop....

www.inflearn.com

여기 문제와 비슷한 것 같아서 설정을 바꿔보았는데도 동일하였다.

 

한 30분 씨름했는데,, 해결은 

invalid cache 날리고 혹시 몰라서 gradle 클린하고 테스트 실행하니까 잘되었다..ㅎㅎㅎㅎ 츠암나..

 

728x90
반응형
반응형

람다 아키텍처

 대규모 데이터 처리 시스템을 위한 아키텍처 패턴

: 실시간 분석, 추천, 모니터링 등에 사용

  • 과거 데이터: summary data 있고
  • 신규 데이터: 최신 데이터
    • 두 개를 조합

실시간으로 들어오는 데이터와 배치 처리가 필요한 대용량 데이터를 동시에 처리하는 구조를 제공하여, 빠른 응답성과 정확성을 모두 확보하는 데 중점을 둡니다. 데이터의 수집, 처리, 분석을 효과적으로 수행하기 위해 다음과 같은 3가지 계층으로 나뉩니다.

1. 배치 레이어 (Batch Layer)

  • 역할: 대량의 데이터에서 정확한 집계와 분석을 위해 주기적인 배치 처리를 수행합니다.
  • 특징: 배치 레이어에서는 데이터가 정해진 주기에 따라 한 번에 대량으로 처리됩니다. 이때 데이터의 불변성을 유지하여 전체 데이터를 매번 다시 계산해 정확도를 보장합니다.
  • 사용 예: Hadoop, Apache Spark 등 분산 배치 처리 시스템.
  • 장점: 모든 데이터를 기반으로 계산하기 때문에 데이터 손실이 없고 최종적으로 신뢰할 수 있는 결과를 제공합니다.

2. 실시간 레이어 (Speed Layer)

  • 역할: 실시간으로 들어오는 데이터를 빠르게 처리하여 최신의 데이터에 대한 결과를 제공합니다.
  • 특징: 배치 레이어가 대규모 데이터에 대해 전체적으로 정확한 처리를 수행하는 반면, 실시간 레이어는 최신 데이터에 대한 빠른 처리와 분석을 제공합니다. 이 레이어에서는 배치 레이어에서 처리된 데이터와 별도로 결과를 제공하여 빠르게 대응할 수 있도록 합니다.
  • 사용 예: Apache Storm, Apache Kafka, Apache Flink.
  • 단점: 실시간 처리는 모든 데이터를 종합하지 않기 때문에 정확도에 제한이 있을 수 있습니다.

3. 서빙 레이어 (Serving Layer)

  • 역할: 배치 레이어와 실시간 레이어의 결과를 결합하여 사용자에게 최종적인 분석 결과를 제공합니다.
  • 특징: 서빙 레이어는 배치 및 실시간 레이어에서 계산된 결과를 사용자에게 빠르게 응답할 수 있도록 구성됩니다. 두 레이어의 결과를 결합하여 정확하고 최신화된 정보를 제공하는 역할을 합니다.
  • 사용 예: Apache HBase, Cassandra와 같은 NoSQL 데이터베이스.

람다 아키텍처의 장단점

  • 장점
    • 확장성: 대규모 데이터와 실시간 데이터를 모두 효율적으로 처리할 수 있어 시스템 확장이 용이합니다.
    • 내결함성: 실시간 처리가 실패해도 배치 처리가 보완할 수 있는 구조입니다.
    • 정확성: 실시간 데이터는 빠르게 처리하고, 배치 처리는 전체 데이터를 기준으로 정확도를 보장합니다.
  • 단점
    • 복잡성: 배치 레이어와 실시간 레이어를 동시에 관리하고 결합해야 하므로 시스템이 복잡해질 수 있습니다.
    • 유지보수 부담: 실시간과 배치 두 가지 흐름을 동시에 관리해야 하므로 유지보수가 어려울 수 있습니다.
  • 고민
    • 배치로 중간 데이터를 만들었는데.. 지나고 이전 데이터가 들어온다면  지난 데이터를 버릴 건지 다시 배치를 돌릴 건지??

 

-> 카파 아키텍처(kappa)

카파 아키텍처(Kappa Architecture)는 대용량의 실시간 데이터 처리를 위해 설계된 아키텍처로, 람다 아키텍처의 복잡성을 줄이기 위해 제안되었습니다. 카파 아키텍처는 실시간 스트리밍 데이터의 분석과 처리를 중점으로 하며, 배치 레이어 없이 단일 데이터 처리 경로만을 사용하는 점이 특징입니다. 이는 시스템의 단순성과 유지보수를 고려한 접근 방식으로, 변화가 빠른 환경에서도 효율적으로 동작합니다.

카파 아키텍처의 주요 특징

  1. 단일 데이터 경로 (Single Pathway)
    • 카파 아키텍처에서는 데이터를 오직 하나의 경로로 처리합니다. 이 데이터 경로는 실시간 스트리밍 처리를 위한 스트림 처리 엔진을 사용하며, 람다 아키텍처의 배치 레이어가 없는 구조입니다.
    • 데이터가 들어오면 스트림 처리 시스템을 통해 처리되며, 필요한 경우 결과를 실시간으로 업데이트합니다.
  2. 실시간 데이터 처리
    • 카파 아키텍처는 실시간 처리를 핵심으로 하여, 들어오는 데이터를 신속하게 처리하고 분석하는 데 집중합니다.
    • 지속적인 스트림 데이터와 그 결과를 실시간으로 쌓아 두기 때문에 새로운 데이터에 빠르게 대응할 수 있습니다.
  3. 데이터의 불변성 유지
    • 카파 아키텍처에서도 데이터는 원본 형태로 저장되고, 필요에 따라 재처리할 수 있도록 불변성을 유지합니다.
    • 스트림 처리 엔진을 통해 재처리가 필요할 경우 저장된 원본 데이터를 다시 처리할 수 있습니다.
  4. 간단한 구조
    • 배치 처리를 제거하고 스트림 처리를 중심으로 설계해 단순한 아키텍처를 유지합니다.
    • 이는 개발 및 유지보수의 복잡성을 크게 줄여 주며, 특히 운영과 관리 측면에서 효율적입니다.

카파 아키텍처의 구현 방식

  • 스트림 처리 엔진: 카파 아키텍처에서 중요한 역할을 하는 요소로, Apache Kafka, Apache Flink, Apache Samza 등이 자주 사용됩니다. 이들 엔진은 대용량의 스트리밍 데이터를 고성능으로 처리하는 데 적합합니다.
    • 카프카에서 데이터를 재정렬, 필터링 등 여러 액션을 할 수 있음
  • 데이터 저장소: 원본 데이터를 저장하는 시스템으로, Apache HBase, Cassandra, Elasticsearch와 같은 NoSQL 데이터베이스나 Kafka 같은 메시징 시스템이 사용됩니다.
    • Apache Iceberg 사용하는 추세
      • Apache Iceberg는 대규모 분석 데이터 테이블을 위한 고성능 오픈 소스 테이블 포맷입니다. Iceberg는 데이터를 대규모로 효율적으로 관리하고 처리할 수 있도록 설계되었으며, 특히 빅데이터 환경에서 기존 테이블 포맷의 문제를 해결하는 데 중점을 두고 있습니다. Iceberg는 데이터 레이크에서 다양한 저장소 및 파일 포맷을 지원하면서도 ACID 트랜잭션을 제공하고, 강력한 스키마 관리와 높은 쿼리 성능을 제공합니다.
      • 과거와 현재 데이터를 중간에 스냅샷을 찍어서 관리하는 구조

카파 아키텍처의 장단점

  • 장점
    • 단순성: 배치 레이어를 제거하고 스트림 처리만 사용하여 시스템 구조가 단순합니다.
    • 빠른 대응: 실시간 데이터를 실시간으로 처리할 수 있어 최신 데이터를 활용한 분석이 용이합니다.
    • 유지보수 용이: 단일 데이터 경로만 관리하므로 복잡한 유지보수 작업을 줄여줍니다.
    • 읽기 속도가 좋고 람다보다 유연
  • 단점
    • 재처리 문제: 배치 처리가 없기 때문에 모든 데이터를 실시간으로 처리하며, 재처리가 필요한 경우 스트림 처리 엔진에 추가적인 부담이 될 수 있습니다.
    • 적용 한계: 대용량 데이터의 정확한 분석이 필요하거나 일괄처리가 필요한 환경에서는 적합하지 않을 수 있습니다.

카파 아키텍처 vs 람다 아키텍처

카파 아키텍처는 람다 아키텍처와는 달리 실시간 스트리밍 처리만을 사용하여 시스템을 단순화합니다. 이를 통해 람다 아키텍처에서 필요했던 두 경로의 중복 코드나 복잡한 데이터 일관성 유지 문제를 해결할 수 있습니다. 그러나 재처리나 정밀한 배치 처리가 필요한 환경에서는 여전히 람다 아키텍처가 유리할 수 있습니다.

728x90
반응형

'architecture > sw architecture' 카테고리의 다른 글

[design] proxy pattern 프록시 패턴  (0) 2022.04.25
반응형

Entity Graph

  • Entity를 조회하는 시점에 연관 Entity들을 함께 조회할 수 있도록 해주는 기능

종류

  • 정적 선언 - @NamedEntityGraph
  • 동적 선언 - EntityManager.createEntityGraph()
@NamedEntityGraphs({
        @NamedEntityGraph(name = "orderWithCustomer", attributeNodes = { //같이 가져와
                @NamedAttributeNode("customer")
        }),
        @NamedEntityGraph(name = "orderWithOrderItems", attributeNodes = {
                @NamedAttributeNode("orderItems")
        }),
        @NamedEntityGraph(name = "orderWithCustomerAndOrderItems", attributeNodes = {
                @NamedAttributeNode("customer"),
                @NamedAttributeNode("orderItems")
        }),
        @NamedEntityGraph(name = "orderWithCustomerAndOrderItemsAndItem", attributeNodes = {
                @NamedAttributeNode("customer"),
                @NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
        }, subgraphs = @NamedSubgraph(name = "orderItems", attributeNodes = { //orderItem 한 단계 더 들어갈 때
                @NamedAttributeNode("item")
        }))
})
@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long orderId;

    @ManyToOne(optional = false)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private List<OrderItem> orderItems;

}
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!
public Page<Order> getPagedOrderWithAssociations(Pageable pageable) {
    QOrder order = QOrder.order;
    QCustomer customer = QCustomer.customer;
    QOrderItem orderItem = QOrderItem.orderItem;
    QItem item = QItem.item;

    // TODO : pagination + fetch join ???
    //사라진 limit
    JPQLQuery<Order> query = from(order)
            .innerJoin(order.customer, customer).fetchJoin()
            .leftJoin(order.orderItems, orderItem).fetchJoin()
            .innerJoin(orderItem.item, item).fetchJoin();

    JPQLQuery<Order> pagedQuery = getQuerydsl().applyPagination(pageable, query);

    long totalCount = 0L;

    try {
        totalCount = pagedQuery.fetchCount();
    } catch (NoResultException e) {
        // ignore
    }

    List<Order> list = pagedQuery.fetch();

    return new PageImpl<>(list, pageable, totalCount);
}

 

해결 방법? 정해진 해결방법은 없음.

  • Pagination 쿼리와 Fetch Join을 분리
  • Pagination 쿼리에서는 entity가 아닌 ID만을 가져온다(where절을 만족하는 놈으로 미리 쳐버리는게 효율이 좋겠지)
  • Fetch Join에는 앞서 가져온 ID 조건을 추가

Q. 이럴 때 in절로 인한 디비 부하가 있을 수 있을텐데.. 괜찮나? 물론 전체를 가져오는 것 보다는 나을 것이며 PK니까 관련 인덱스도 있겠지만... DBA는 in절을 매우 싫어했는데ㅠ

public Page<Order> getPagedOrderWithAssociations(Pageable pageable) {
    QOrder order = QOrder.order;
    QCustomer customer = QCustomer.customer;
    QOrderItem orderItem = QOrderItem.orderItem;
    QItem item = QItem.item;

    // TODO #1 : pagination query
    //여기서 where절 미리 쳐버렸!
    JPQLQuery<Long> query = from(order).select(order.orderId);
    JPQLQuery<Long> pagedQuery = getQuerydsl().applyPagination(pageable, query);

    long totalCount = 0L;
    try {
        totalCount = pagedQuery.fetchCount();
    } catch (NoResultException ex) {
        // ignore
    }

    List<Long> ids = pagedQuery.fetch();

    // TODO #2 : fetch join
    List<Order> list = from(order)
            .innerJoin(order.customer, customer).fetchJoin()
            .leftJoin(order.orderItems, orderItem).fetchJoin()
            .innerJoin(orderItem.item, item).fetchJoin()
            .where(order.orderId.in(ids))
            .fetch();

    return new PageImpl<>(list, pageable, totalCount);
}

 

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]

이유?

  • org.hibernate.type.descriptor.sql.BasicBinder
    • bind 된 parameter 값을 logging
  • offset, limit 는 DBMS 별로 지원이 될 수도 있고 안 될 수도 있는 쿼리
    • cf.) org.hibernate.dialect.pagination.LimitHandler
      • MySQLDialect vs Oracle8iDialect
  • offset, limit 는 BasicBinder 에서 처리가 되지 않음
  • dialect 에서도 logging을 해주지 않고 있음
package org.hibernate.dialect.pagination;

...

public interface LimitHandler {
  boolean supportsLimit(); //각 dbms에서 구현

  boolean supportsLimitOffset();
public class MySQLDialect extends Dialect {
  private static final Pattern ESCAPE_PATTERN = Pattern.compile("\\", 16);
  public static final String ESCAPE_PATTERN_REPLACEMENT = Matcher.quoteReplacement("\\\\");
  private final UniqueDelegate uniqueDelegate;
  private final MySQLStorageEngine storageEngine;
  private static final LimitHandler LIMIT_HANDLER = new AbstractLimitHandler() {
    public String processSql(String sql, RowSelection selection) {
      boolean hasOffset = LimitHelper.hasFirstRow(selection);
      return sql + (hasOffset ? " limit ?, ?" : " limit ?");
    }

    public boolean supportsLimit() {
      return true;
    }
  };

ㅋ 구현체에서 안 찍어줌 

해결방법

  • 굳이 offset, limit 값을 로깅하길 원한다면
    • log4jdbc와 같은 JDBC 레벨에서의 로깅이 가능한 라이브러리를 써야

둘 이상의 컬렉션을 Fetch Join하는 경우

  • Order Entity에 OrderAttribute Entity로의 일대다(1:N) 연관관계를 추가하는 경우
    • Order-OrderDetails (1:N)
    • Order-OrderAttributes (1:N)

둘 이상의 컬렉션을 Fetch Join하는 경우 MultipleBagFetchException 발생

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
private List<OrderAttribute> attributes;

//
  public List<Order> getOrdersWithAssociations() {
        QOrder order = QOrder.order;
        QCustomer customer = QCustomer.customer;
        QOrderItem orderItem = QOrderItem.orderItem;
        QItem item = QItem.item;
        QOrderAttribute attribute = QOrderAttribute.orderAttribute;

        return from(order)
                .innerJoin(order.customer, customer).fetchJoin()
                .leftJoin(order.orderItems, orderItem).fetchJoin()
                .leftJoin(order.attributes, attribute).fetchJoin()
                .innerJoin(orderItem.item, item).fetchJoin()
                .fetch();
    }
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: 
[com.nhn.edu.jpa.entity.Order.orderItems, com.jpa.entity.Order.attributes]

트러블 슈팅 - 둘 이상의 컬렉션을 Fetch Join하는 경우

MultipleBagFetchException

  • 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으로 나올거잖슴,, 반복되는 내용 때문인지 중복이 왜 나는지 구분이 안되니
    • 애초에 결과 값에 중복 허용 안하면 카테시안 곱에 의한 중복이 아니라는게 확실해짐

해결 방법

1. List를 Set으로 변경 : 중복 비허용

  • 도메인이나 비즈니스 로직에 따라 중복이 나오는게 맞는 것인지 고려해보고 적용
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private Set<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
private Set<OrderAttribute> attributes;

//

    @Override
    public List<Order> getOrdersWithAssociations() {
        QOrder order = QOrder.order;
        QCustomer customer = QCustomer.customer;
        QOrderItem orderItem = QOrderItem.orderItem;
        QItem item = QItem.item;
        QOrderAttribute attribute = QOrderAttribute.orderAttribute;

        return from(order)
                .innerJoin(order.customer, customer).fetchJoin()
                .leftJoin(order.orderItems, orderItem).fetchJoin()
                .leftJoin(order.attributes, attribute).fetchJoin()
                .innerJoin(orderItem.item, item).fetchJoin()
                .fetch();
    }

2. @OrderColumn 적용 : 순서를 부여(ordered)

  • 디비 스키마를 변경할 수 있는지 고려해야함
WARN QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
@OrderColumn //순서를 부여함; 중복 허용
private List<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
@OrderColumn
private List<OrderAttribute> attributes;
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=?

하지만 N+1은 난다.

예제

  • interface / class
  • @Value + SpEL (target)
/*
{
    "name": "",
    "details": [{
        "type": "",
        "description": ""
    }]
}
 */
public interface MemberDto {
  String getName();
  List<MemberDetailDto> getDetails();

   interface MemberDetailDto{
    //target = memberDetailEntity
     @Value("#{target.pk.type}") ///!
    String getType();
    String getDescription();
  }
//아래처럼 하면 pk 안의 type으로 나옴
//  interface MemberDetailDto{
//    PkDto getPk();
//    String getDescription();
//
//    interface PkDto{
//      String getType();
//    }
//  }
}
728x90
반응형
반응형

JPA (Java Persistence API)

  • 자바 ORM 기술 표준
  • 표준 명세
    • JSR 338 - Java Persistence 2.2

JPA (Jakarta Persistence API)

  • Jakarta Persistence 3.1

JPA 주요 Spec 및 Hibernate version

Java Persistence 2.2 (Hibernate 5.3+)

  • Stream query results
  • @Repeatable annotations
  • Support Java 8 Date and Time API
  • Support CDI Injection in AttributeConverters

Jakarta Persistence 3.1 (Hibernate 6.1+)

  • UUID as Basic Java Type
  • JPQL / Criteria API의 확장 - 날짜, 숫자 함수 추가 등
  • ...

Hibernate 최신 버전

  • Hibernate 5.6
  • Hibernate 6.1

springboot 2.7.9 -> hibernate 5.6

JPA는 스펙; hibernate는 구현


SQL 설정

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=debug

binding parameters :: request ?에 대한 바인딩 로그

logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace
  • cf.) org.hibernate.type.descriptor.sql.BasicExtractor :: result set 보여줌

기본 키 맵핑 전략

@GeneratedValue(strategy = GenerationType.IDENTITY)

자동 생성

  • TABLE 전략: 채번 테이블을 사용
  • SEQUENCE 전략: 데이터베이스 시퀀스를 사용해서 기본 키를 할당
    • ex.) Oracle
  • IDENTITY 전략: 기본 키 생성을 데이터베이스에 위임
    • ex.) MySQL
  • AUTO 전략: 선택한 데이터베이스 방언(dialect)에 따라 기본 키 맵핑 전략을 자동으로 선택

직접 할당

  • 애플리케이션에서 직접 식별자 값을 할당

복합 Key (Composite key)

  • @IdClass
  • @EmbeddedId / @Embeddable

복합 Key Class 제약조건

  • public class
  • public 기본(no-arg) 생성자
  • Serializable 인터페이스 구현
  • equals(), hashCode() 메서드 정의

영속성 전이 (cascade)

바뀔 때 같이 바뀔래? 삭제/수정같이?

  • Entity의 영속성 상태 변화를 연관된 Entity에도 함께 적용
  • 연관관계의 다중성 (Multiplicity) 지정 시 cascade 속성으로 설정
@OneToOne(cascade = CascadeType.PERSIST)
@OneToMany(cascade = CascadeType.ALL)
@ManyToOne(cascade = { CascadeType.PERSIST, CascadeType.REMOVE })

cascade 종류

안 쓰면 각각 저장; 연관관계도 같이 저장

-> 멤버 저장하면 멤버 디테일도 저장

근데 두 엔티티의 lifecycle이 항상 같을 수 없어,, 진짜 필요할 때 고려해서 넣는 게 좋음

public enum CascadeType {
    ALL,        /* PERSIST, MERGE, REMOVE, REFRESH, DETACH */
    PERSIST,    // cf.) EntityManager.persist()
    MERGE,      // cf.) EntityManager.merge()
    REMOVE,     // cf.) EntityManager.remove()
    REFRESH,    // cf.) EntityManager.refresh()
    DETACH      // cf.) EntityManager.detach()
}

연관관계의 방향성

  • 단방향(unidirectional)
  • 양방향(bidirectional)

양방향 연관 관계

  • 관계의 주인(owner)
    • 양방향의 연관 관계의 주인은 외래 키(FK)가 있는 곳
      • 주인만 @JoinColumn 어노테이션 사용 가능
    • 연관 관계의 주인이 아닌 경우, mappedBy 속성으로 연관 관계의 주인을 지정

단방향 vs 양방향

단방향 맵핑만으로 연관관계 맵핑은 이미 완료

  • 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;
    }
}

@JoinColumn, @pk안의 @Column 모두 업데이트 가능하게 만들어서 에러

하나는 조회용이라고 밝혀줘야

@ManyToOne
@JoinColumn(name = "member_id", insertable = false, updatable = false)
private Member member;

그러면 이렇게 하면 될까? 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을 사용하여 쿼리 하나로 실행하겠다.
    • JPQL join fetch
      • fetch 없으면, from절에 있는 메인 엔티티만 반환. 우선 진행시켜! 그 후에 from절 전에 있는 메인 엔티티를 하나씩 따져보면서 다시 N+1 실행
      • fetch를 써야 select절에 다른 것들도 받음
    • Querydsl fetchJoin()
  • 그룹화하여 쿼리 실행 횟수를 줄이겠다.
  • 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을 코드로 작성할 수 있도록 해 주는 오픈소스 프레임워크
  • Criteria API에 비해 복잡하지 않고 매우 편리하고 실용적

Spring Data JPA + Querydsl

  • QuerydslPredicateExecutor
  • QuerydslRepositorySupport //join이 많을 경우; 추상 interface -> impl

Custom Repository 구현

Custom Repository를 위한 interface 생성

@NoRepositoryBean
public interface MemberRepositoryCustom {
    List<Member> getMembersWithAssociation();
}

Custom Repository 기능 구현

  • QuerydslRepositorySupport 클래스 상속
    • 기본 생성자에서 Entity를 인자로 전달받는 상위 클래스의 생성자를 호출
  • Custom Repository interfade 구현
  • 구현 메서드에서 Querydsl의 Q-type class를 이용해서 쿼리 수행
public class MemberRepositoryImpl extends QuerydslRepositorySupport implements MemberRepositoryCustom {
    public MemberRepositoryImpl() {
        super(Member.class);
    }

    @Override
    public List<Member> getMembersWithAssociation() {  
        // ...
    }
}
<plugin>
 <groupId>com.mysema.maven</groupId>
 <artifactId>apt-maven-plugin</artifactId>
 <version>1.1.3</version>
 <configuration>
  <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
 </configuration>
 <executions>
  <execution>
   <phase>generate-sources</phase>
   <goals>
    <goal>process</goal>
   </goals>
   <configuration>
    <outputDirectory>target/generated-sources/annotations/java</outputDirectory> //해당 경로에 만든다
   </configuration>
  </execution>
 </executions>
</plugin>

project 설정 필요

기본 Repository interface 변경

  • 기본 Repository interface가 Custom Repository interface를 상속받도록
public interface MemberRepository extends MemberRepositoryCustom, JpaRepository<Member, Long> {
}

기본 Repository interface를 이용해서 Custom Repository interface에 선언된 확장 메서드 호출


트러블 슈팅 - Custom Repository 구현 시 흔히 하는 실수: naming rule

  • 기본 Repository interface
    • MemberRepository
  • Custom Repository interface
    • MemberRepositoryCustom
  • Custom Repository 구현 class
    • MemberRepositoryCustomImpl (X)
    • MemberRepositoryImpl (O)

참고) query dsl 사용 예시

  @Override
  public List<Student> getStudentsWithAssociations() {
    QStudent student = QStudent.student;//1개만 쓰면
    QStudent student1 = new QStudent("student1");//inner query 등으로 추가적으로 더 필요하면 인스턴스 더 만들어
    QEnrollment enrollment = QEnrollment.enrollment;
    QCourse course = QCourse.course;

    return from(student)
        .leftJoin(student.enrollments, enrollment).fetchJoin() //연관관계, q타입 걸치고
        .innerJoin(enrollment.course, course).fetchJoin()
//        .where(student.name.eq("gg"))
//        .select(student.studentId)
//        .fetchOne()
        .fetch() //list 반환 시 fetch; 하나만 가져올거면 fetchOne
        ;
  }

Repository 설정

@EnableJpaRepositories

public @interface EnableJpaRepositories {
	String[] basePackages() default {};

	Class<?>[] basePackageClasses() default {};

	Filter[] includeFilters() default {};

	Filter[] excludeFilters() default {};

	String repositoryImplementationPostfix() default "Impl";

    // ...
}

Spring Boot에서는

dependency에 spring-data-jpa를 추가하면 @EnableJpaRepositories 없어도 기본으로 세팅해 줌

  • Spring Data JPA Repository를 위한 auto configuration
    • org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
      • @Import(JpaRepositoriesImportSelector)
        • JpaRepositoriesRegistrar
          • @EnableJpaRepositories

Spring Data Repository 메서드 탐색 순서

  • Spring Data Repository 추상화 덕분에 interface 선언만으로도 쿼리 생성 가능
  • interface에 선언된 메서드의 실제 구현체는 아래 순서로 탐색하게 된다
  1. 기본 구현체(JpaRepository의 구현체인 SimpleJpaRepository::saveAll etc.)
  2. 메서드 이름 규칙 적용 (cf. 메서드 이름으로 쿼리 생성 findByNameLikeAndPhone etc.)
  3. Custom Repository Implementation

  1. MemberRepositoryImpl -> MemberRepositoryCustomImpl 로 바꾸면? O
  2. 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 구현 가능

  • 앞서 본 CustomizedMemberRepository interface와 CustomizedMemberRepositoryImpl class 와 같이
  • 예를 들면 GuestRepository interface, GuestRepositoryImpl class 같이 여러 개의 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를 회피하기 위한
728x90
반응형
반응형

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에 대한 글을 보게 된다.

https://docs.spring.io/spring-framework/docs/6.0.0/reference/html/integration.html#rest-http-interface

 

Integration

The Spring Framework provides abstractions for the asynchronous execution and scheduling of tasks with the TaskExecutor and TaskScheduler interfaces, respectively. Spring also features implementations of those interfaces that support thread pools or delega

docs.spring.io

얼핏 보니 생김새는 feign과 비슷하고 내부는 webClient로 되어 있는 듯하다.

쓱싹 만들어본다.

아래와 같이 세팅하고 받아준다.

http interface 는 webClient 기반이라 webflux dependency가 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-webflux'

 

참고로 springboot3은 gradle7.6, java17 기반이니 11을 기본으로 사용하였다면 꼭 설정을 바꿔야지 아니면 아래와 같은 에러를 만난다.

 

1. api의 정보가 담긴 interface를 아래와 같이 만든다.

@HttpExchange(url = "/api/arena-ring")
public interface GiaArenaRingService {

  @GetExchange("/{id}")
  Map<String, Object> getArenaRingGame(@PathVariable BigInteger id);
}

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 reactive return values).

 

사용법이 간단하고 api 스펙을 interface로 선언하기만 하면 되어 한눈에 볼 수 있다는 장점이 있는 것 같다.

webClient 기반이라 기존 webClient에서 지원하던 기능들은 설정방식만 조금 다를 뿐 다 지원할 듯하다.

가독성이 떨어지고 코드의 양이 많았던 webClient의 단점을 어느 정도 보완해 줄 수 있을 것 같아 기회가 되면 사용해 볼 생각.

추가 가이드: https://www.baeldung.com/spring-6-http-interface

 

++ 더불어

restTemplate, webClient 이 아직도 건재하다는 소식에 힘입어 세 방법 모두 사용해 본다.

스프링6 공식문서에 소개된 http clients

(restTemplate와 webClient를 공통 빈으로 등록하면 효율성과 가독성의 측면이 더 좋겠으나 샘플 프로젝트이므로 생략)

  //using WebClient
  public Mono<Map<String, Object>> getArenaRingGameWebClient(BigInteger id) {
    return WebClient.create(GIA_URL).get().uri(uriBuilder -> uriBuilder.path(STATIC_ARENA_RING_GAME_URI + "/" + id).build()).retrieve()
//        .onStatus(HttpStatus::isError, resp -> Mono.error(new RuntimeException("status is not 200")))
        .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
        }).doOnNext(response -> Optional.ofNullable(response).orElseThrow(() -> new RuntimeException("body is null")));
  }

  //using RestTemplate
  public Map<String, Object> getArenaRingGameRestTemplate(BigInteger id) {
    String url = GIA_URL + STATIC_ARENA_RING_GAME_URI + "/" + id;
    RestTemplate restTemplate = new RestTemplate();
    return restTemplate.getForObject(url, Map.class);
  }

  //using httpClient
  public Map<String, Object> getArenaRingGameHttpInterface(BigInteger id) {
    return giaArenaRingService.getArenaRingGame(id);
  }
restTemplate: 2
webClient: 2
httpClient: 2

세 건 모두 잘 된다.


끝으로.

springboot2.x 를 사용해 본 유저라면 누구든 springboot3을 사용하고 싶어 할 것이다.

관련 migration guide가 있으니 springboot3을 사용하기 전 뭐가 달라졌는지 간단히 살펴보는 것이 좋겠다.

https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide


참고:

https://spring.io/blog/2022/11/16/spring-framework-6-0-goes-ga

 

 

Spring | Home

Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.

spring.io

728x90
반응형
반응형

환경: java11, springboot 2.5.6

 

@SpringBootTest로 integration 테스트를 할 때 DB변경사항에 대해서 쿼리로 확인하고 싶을 때가 있다.

테스트가 끝나면 자동으로 메모리에서 사라지기 때문에 테스트 후에는 확인할 수가 없고

테스트 중간에 디버그 포인트를 걸어서 확인하는 방법을 설명한다.

 

1. properties 확인

아래 세가지 h2에 대한 설정 값이 들어있어야 한다. 특히 web-allow를 true로 주는 게 중요하다.

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.h2.console.settings.web-allow-others=true

 

2. @SpringBootTest에 옵션 주기

webEnvironment의 기본 값은 MOCK이기 때문에 테스트 시 별도 포트를 사용하지 않는다.

우리는 테스트 서버를 띄우고 ui로 확인해야하기 때문에 물리적인 포트 할당이 필요하다.

아래와 같이 설정을 변경해준다.

@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)

 

3. 디버그 걸기

이렇게 까지 하고 디버그를 걸어도 h2 ui가 무한 로딩 중일 텐데, 디버그로 인해 모든 thread가 블로킹되었기 때문이다.

아래와 같이 디버그 옵션을 thread로 변경하여 해당 스래드만 멈추게 한다.

기본값이 All이라서 Thread로 바꿔주어야 한다.(매번 하기엔 좀 번거로울 수 있다.)

 

4. h2 console 확인

http://localhost:8080/h2-console

로 들어가면 아래와 같이 화면이 나온다. 

728x90
반응형
반응형

Get, Post  등등 api를 만들다 보면 데이터를 바인드 하는 방식이 다르다는 것을 알게 된다.

대표적으로 ModelAttribute/Request Param과 Request Body 방식을 비교해 보고, 관련해서 빠질 수 없는 직렬화/역직렬화에 대해 정리해 본다.

 

GET mapping

query param -> object

@GetMapping
List<Product> searchProducts(@Valid ProductCriteria productCriteria) { //다수의 @RequestParam를 dto로 한방에 + 검증도 가능
   return productRepository.search(productCriteria);
}

1. @ModelAttribute의 DTO 요구 사항

@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 할 수 없어 예외가 발생한다.


특히 역직렬화 시 기본 생성자가 사용되면 불변 객체가 아니게 되지 않나 싶은데

@NoArgsConstructor(access = AccessLevel.PRIVATE)

위와 같이 제한을 주면 막 생기는 위험을 막을 수 있다.

728x90
반응형
반응형

환경: java11, junit5, springboot2.6

내가 생각하는 테스트 코드의 레벨은 아래와 같다.

1. 컨트롤러 단 검증: input변수 확인

1-1. 서비스로 나가는 변수 capture해서 확인

2. 서비스 검증: 서비스에 있는 로직 위주로 나머지 통신은 mocking

2-1. 유틸 클래스 및 dto 변환 로직 테스트

2-2. 외부 api는 보통 mocking 해서 진행하지만, timeout이랑 http code != 200과 같이 mocking이 힘든 경우

3. 레파지토리 검증: 쿼리 검증 위주

4. 통합검증: 2, 3번 함께 검증, 필요시 1번도 연결


1. 컨트롤러 단 검증

mockMvc를 통해 post와 같이 request body가 있는 경우를 검증

-> body로 들어오는 Json이 object mapper를 통해 파싱되는 원리 이용

-> controller 로 변수 세팅할 때 validation annotaion 검증

@ExtendWith(MockitoExtension.class)
@DisplayName("@Valid, @Validated 등 컨트롤러 단으로 들어오는 파라미터 검증")
class ControllerTest {

  private static ObjectMapper mapper;
  protected MockMvc mockMvc;
  protected MvcResult result;

  @BeforeAll
  protected static void setup() {
    mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
  }

  protected void thenExpectResponseHasBadRequestStatus() {
    assertEquals(HttpStatus.BAD_REQUEST.value(), result.getResponse().getStatus());
  }

  protected void thenExpectResponseHasOkRequestStatus() {
    assertEquals(HttpStatus.OK.value(), result.getResponse().getStatus());
  }

  protected void thenExceptionIncludeBadParameter(String... requestMembers) {
    var allIncluded = Arrays.asList(requestMembers).stream().allMatch(val -> result.getResolvedException().getMessage().contains(val));
    assertTrue(allIncluded);
  }

  protected void thenNoValidationException() {
    assertNull(result.getResolvedException());
  }

  @SneakyThrows
  protected String objectToJsonString(Object object) {
    return mapper.writeValueAsString(object);
  }
}
class MailControllerTest extends ControllerTest {

  @InjectMocks
  private MailController mailController;
  @Mock
  MailService mailService

  @BeforeEach
  void init() {
    mockMvc = MockMvcBuilders.standaloneSetup(mailController).setControllerAdvice(ExceptionAdvice.class).build();
  }

  @Test
  @DisplayName("updateReserveMail : 불량한 파라미터")
  void updateReserveMail__validation_test_fail() throws Exception {
    //given
    SendRequest request = new SendRequest();
    var url = "/ss/1234";

    //when
    result = mockMvc.perform(put(url).contentType(MediaType.APPLICATION_JSON).content(objectToJsonString(request)))
        .andDo(MockMvcResultHandlers.print())
        .andReturn();

    //then
    thenExceptionIncludeBadParameter("mailIdx", "expireDate", "mailAttachment", "mailMessages");
    thenExpectResponseHasBadRequestStatus();
  }
  ...
  
////  in SendRequest.class
  @Valid
  @NotNull(groups = {SendValidation.class, UpdateValidation.class})
  private MailAttachment mailAttachment;

 

1-1. controller로 변수 받아 세팅 -> service에 넘어간 변수가 잘 세팅되었는지 확인하기 위해 captor사용

@ExtendWith(MockitoExtension.class) //필요

@Captor
ArgumentCaptor<ExternalAddResidentRequest> addRequestCaptor;

 @Test
void addResident__validation() throws Exception {
    AddResidentRequest request = AddResidentRequest.builder().searchType(UserSearchType.USER_ID).searchValue("1050").newCharacterId("123").build();

    //mockito 사용 captor 주입
    when(residentService.addResident(addRequestCaptor.capture())).thenReturn(Mono.empty());

    result = mockMvc.perform(
            post("/ss/residents/" + CHARACTER_INDEX).contentType(MediaType.APPLICATION_JSON).content(objectToJsonString(request)))
        .andDo(MockMvcResultHandlers.print())
        .andReturn();
    ExternalAddResidentRequest expected = addRequestCaptor.getValue(); //꺼내서

    thenNoValidationException();
    thenExpectResponseHasOkRequestStatus();
    assertThat(request.getNewCharacterId()).isEqualTo(expected.getNewCharacterId());	//비교
}

 

2. 서비스 단 검증

-> mockitoExtension을 이용하여 외부 api/db로 받을 데이터를 다 가정(given-willReturn)

@ExtendWith(MockitoExtension.class)
class TableServiceTest {

  @InjectMocks
  @Spy
  private TableService tableService;

  @Mock
  private TableExternalService tableExternalService;
  @Mock
  private CharacterExternalService characterExternalService;

  @BeforeEach
  void init() {
    ReflectionTestUtils.setField(tableService, "FOLDER_PATH", path);
  }

  @Test
  @DisplayName("Files 클래스의 함수 확인: valid path")
  void testFilesWith__ValidPath() {
    ...
    given(tableExternalService.getTableData(FILE_NAME)).willReturn(Mono.just(CONTENT));
	...

    assertTrue(Files.exists(itemPath));
    assertFalse(Files.isDirectory(itemPath));
    assertFalse(!Files.exists(itemPath) || Files.isDirectory(itemPath));

    //파일이 중복 존재하면 에러 뱉음
    assertThrows(FileAlreadyExistsException.class, () -> Files.createDirectories(itemPath));
    //폴더가 이미 존재하면 에러 안 뱉음
    assertDoesNotThrow(() -> Files.createDirectories(Paths.get(path)));
  }

 

2-1. 유틸 테스트

딱히 가정하거나 따로 라이브러리 쓰는 것 없이 간단히 한다.

  @Test
  @DisplayName("String[] empty 확인")
  void checkUtilEmpty() {
    var arrayEmpty = new String[]{};
    assertThat(arrayEmpty).isEmpty(); //length == 0
    assertThat(ArrayUtils.isEmpty(arrayEmpty)).isTrue();

    var arrayHavingEmptyString = new String[]{""};
    assertThat(arrayHavingEmptyString).hasSize(1); //length == 1
    assertThat(ArrayUtils.isEmpty(arrayHavingEmptyString)).isFalse();
  }

 

2-2. 외부 api mocking

-> 외부 api에서 타임아웃이 나면 어떻게 테스트하냐는 PR을 받아서.. 계속 고민하다가 짜게 된 코드

-> webClient를 통해 외부 api호출을 하고 있던 터라 MockWebServer라는 라이브러리를 dependency에 추가해야 한다.

testImplementation 'com.squareup.okhttp3:mockwebserver:4.10.0'
testImplementation 'com.squareup.okhttp3:okhttp:4.10.0'
testImplementation 'io.projectreactor:reactor-test'

공통으로 쓸 클래스를 만들어서 이걸 extend 하여 사용하게 했다.

핵심은 주입받을 서비스에서 생성자로 받는 것.. 그래서 별도 mocking이 필요 없다.

class ExternalMockTest {

  protected MockWebServer mockWebServer;
  protected String url;

  protected void setupMockWebServer() {
    mockWebServer = new MockWebServer();
    url = mockWebServer.url("/").url().toString();
  }

  protected WebClient.Builder getWebClient() {
    ConnectionProvider provider = ConnectionProvider.builder("webclient-pool")
        .maxConnections(500)
        .maxIdleTime(Duration.ofSeconds(20))
        .maxLifeTime(Duration.ofSeconds(60))
        .pendingAcquireTimeout(Duration.ofSeconds(60))
        .evictInBackground(Duration.ofSeconds(120))
        .build();

    HttpClient httpClient = HttpClient.create(provider)
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
        .doOnConnected(
            conn -> conn.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS)).addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));

    ClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);

    return WebClient.builder().clientConnector(connector);
  }

}
class TableExternalMockTest extends ExternalMockTest {

  private TableService tableService;
  private CharacterExternalService characterExternalService;

  @BeforeEach
  void init() {
    super.setupMockWebServer();
    characterExternalService = new CharacterExternalService(getWebClient(), url);
    tableService = new TableService(null, characterExternalService);
  }


  @Test
  @DisplayName("지역 탐사탑 해금 드랍다운: api timeout")
  void getDivisionInfo__timeout() {
    mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBodyDelay(5100, TimeUnit.MILLISECONDS) //5초 설정이라 5.1초로 확인
        .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody("{\"resultCode\":0}"));

    assertThatThrownBy(() -> tableService.getDivisionInfo()).isInstanceOf(WebClientResponseException.class)
        .hasMessageContaining(ExternalApiResultCode.UNKNOWN_ERROR.getStringCode());
  }
  ...

 

3. 레파지토리 테스트

사실 이게 왜 필요한가 싶은데,, jpa의 작동을 확인하거나 복잡한 조회 쿼리를 확인하는 정도로 사용하고 있다.

-> 우선 테스트용 DB를 사용하기로 했다. 여기서는 h2로 했다.

-> 그래서 별도의 디비설정이 필요하다. h2 관련 dependency를 추가하고 application.property도 다시 써야 한다. 

-> dataSource와 transactionManager도 바뀌기에 재 설정이 필요했다. 

testRuntimeOnly 'com.h2database:h2'
spring.test.database.replace=none
spring.main.allow-bean-definition-overriding=true
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem://localhost/~/TESTDB;MODE=MYSQL;INIT=CREATE SCHEMA IF NOT EXISTS TESTDB
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.show_sql=true
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=none
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.metadata_builder_contributor=com.local.SqlMetaBuilderContributor
spring.jpa.defer-datasource-initialization=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
external.admin-support.use.server-list=dev
## log
logging.level.org.hibernate.type.descriptor.sql=trace
@TestConfiguration
public class TestDataSourceConfig {

  @PersistenceContext
  private EntityManager entityManager;

  @Bean
  public JPAQueryFactory jpaQueryFactory() {
    return new JPAQueryFactory(entityManager);
  }
}
@Sql({"classpath:maalog/item-log.sql", "classpath:maalog/item-withdraw-log.sql"})
@Import(TestDataSourceConfig.class)
@DataJpaTest
class ItemLogRepositoryTest {

    @Autowired
    private ItemLogRepository itemLogRepository;

    @Test
    void givenValidateData_whenFindBySeq_thenSuccess() {
        var result = itemLogRepository.findBySeq(BigInteger.valueOf(22L)).get();

        assertThat(result, notNullValue());
        assertThat(result.getInvenSeq(), is(BigInteger.valueOf(0)));
    }

 

4. 통합검증

외부 api는 나에게 제어권이 없기 때문에 mocking으로 테스트가 충분하다고 생각했고

디비는 내가 제어할 수 있기 때문에 통합테스트가 필요할 수 있다고 느꼈다.

근데 운영은 다중 디비지만 테스트는 로컬 하나의 디비라서 설정이 까다로웠고.. 아래와 같이 다중 디비 연결 관련 설정을 오버라이드하기 위해 해당 위치에 빈 설정을 넣어줘야 했다.

아 쿼리 dsl 관련 빈도.. 빈 설정으로 오버라이드 했다.

@TestConfiguration
class LogDbConfig {

}

공통으로 사용하려고 설정으로 빼두었는데 3번 설정과 겹쳐서 extend한다.

그리고 추가적으로 3개의 transactionManager도 h2용으로 연결했다.

여기서 계속 헤맸던 게 소스 상에서는 3개의 transactionManager만 사용하는데 자꾸 default transactionManager가 없다고 나오는 게 아닌가.. 이게 왜 필요한가 봤더니.. @Sql 때문인 것 같았다..

@TestConfiguration
public class IntegrationTestConfigurations extends TestDataSourceConfig {

  //NOTE: 통합테스트의 목적은 서비스의 비즈니스 로직 + 디비 로직 검증이므로 아래와 같은 외부 api 연동은 Mockito를 이용하도록 한다.
  //외부 api 쏘는 서비스
  @MockBean
  TanServerConfigService tanServerConfigService;
...
  //TODO: 아래 서비스들은 서비스 내부에 비즈니스 로직 + 외부 api 연동이 섞여 있으므로 통합테스트를 위해서는 external은 분리하는 작업이 필요하다.
  @MockBean
  GoodsService goodsService;
...

  @Bean({"aTransactionManager", "bTransactionManager", "cTransactionManager", "transactionManager"})
  public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
    return new JpaTransactionManager(entityManagerFactory);
  }
@Import(IntegrationTestConfigurations.class)
@SpringBootTest
class EventLoginRewardIntegrationTest {
//필요한 서비스, 레파지토리 주입
  @Autowired
  private EventLoginRewardService eventLoginRewardService;
...

  @AfterEach
  void tearDown() {
  //관련 디비 내용물 삭제
  //테스트용 @Transactional은 사용하지 않았음(의도적)
    staticMessageRepository.deleteAll();
   ...
  }

  @Test
  @DisplayName("전체 리스트 조최; 데이터 들어있다는 전제")
  @Sql({"classpath:dynamic-login-reward.sql", "classpath:service-login-reward.sql",
      "classpath:message-login-reward.sql",})
  void getRewardEvents__success() {
    //when
    Page<LoginRewardResponse> responsePage = eventLoginRewardService.getRewardEvents(PageRequest.of(0, 10, Sort.by("seq").descending()));
    //then
    assertEquals(4, responsePage.getContent().size());
    assertEquals("일정 기간 동안 접속 시 보상 획득 (한번)", responsePage.getContent().get(3).getDescription());
  }
  ...

테스트용 데이터를 삽입하려고 @Sql을 달았는데, 이것 때문에 별도 트랜젝션이 필요한 듯하다. 그래서 기본 transactionManager를 달아주었다..

물론 이 설정이 다 맞고 옳은 방법인지 확신은 없는데, 어쨌건 우선 잘 되니까 지켜보려고 한다.ㅠㅠ

 


참고

1. 나와 비슷하게 생각하는 사람, 테스트의 종류에 대해

https://howtodoinjava.com/spring-boot2/testing/spring-boot-2-junit-5/

 

Testing Controller, Service and DAO in Spring Boot - HowToDoInJava

Learn to test a Spring boot 2.4 application which has JUnit 5 dependencies. Learn to write unit tests and integration tests for all layers.

howtodoinjava.com

 

2. 왜 통합테스트에서 @Transactional로 자동 롤백을 사용하지 않았는가, 이 역시 나와 동일한 의견

https://javabom.tistory.com/103

 

JPA 사용시 테스트 코드에서 @Transactional 주의하기

서비스 레이어(@Service)에 대해 테스트를 한다면 보통 DB와 관련된 테스트 코드를 작성하게 된다. 이러면 테스트 메서드 내부에서 사용했던 데이터들이 그대로 남아있게 되어서 실제 서비스에 영

javabom.tistory.com

 

3. mockWebServer 사용법

https://www.arhohuttunen.com/spring-boot-webclient-mockwebserver/

 

Testing Spring Boot WebClient With MockWebServer | Code With Arho

Mocking the Spring Boot WebClient can be difficult. Learn how to replace the remote service with a mock service using MockWebServer.

www.arhohuttunen.com

 

728x90
반응형
반응형

레디스

  • nosql
    • not only sql
  • 비정형화된 데이터 사용 -> 유연성, 확장성, 실시간 응답
  • 레디스는 key-value형태이고 
    • 그래프, 다큐먼트, 콜롬배이스 형태도 있음
  • in memeory 데이터베이스 -> 모든 데이터가 메모리 위에 있어 디스크까지 가서 받아오지 않아도 돼서 빠름
  • 단순성 - 다양한 자료구조 제공(10가지 이상), 커맨드 처리, 이벤트 루프로 동작(싱글스레드; 한 번에 하나의 작업만 함)
  • 클라의 처리가 빠르고 단순
  • 확장성/고가용성
    • 클러스터 -> 샤딩을 위한 기술
    • 여러개 서버에 나눠서 저장

MSA에서 레디스?

  • 데이터 저장소(권장하지 않음) 쓸 수는 있다(10가지의 자료구조 제공; 개발의 편리성) -> 데이터의 영구저장을 할 수는 있다.(파일)
  • 캐시: 최소한의 리소스로 막대한 처리; 자체 HA 기능 제공
  • 메시지 브로커: pub/sub, event queue, stream

redis 자료구조

  • string(모든 종류의 문자열 저장 가능; 이진 데이터 포함)
    • set/get으로
      • set hello world -> key: hello, value: world
      • get hello -> world
    • 이미지, html 도 가능
    • 최대 512MB
    • string -> string
    • 키도 최대 512MB
    • command
      • incr(1씩 증가) / incrby(지정한 수만큼 증가; 락 안 잡고도 증가시킬 수; mysql은 락을 잡음)
      • getset: 조회하고 결과 나오고 치환
      • 옵션: xx/nx
        • xx: 키가 있을 때만 저장(없으면 안 해; 없으면 nil 반환)
        • nx: 키가 없을 때만 저장(없으면 해; 있으면 nil반환)
  • list 순서를 가지는 문자열의 목록
    • 리스트 안에 인덱스 있음 reverse index도 있음; 추가되면 인덱스는 알아서 재정리됨  
      • command
        • rpush rpop lpush lpop(return 있음)
          • pop은 조회하고 삭제
        • ltrim 원하는 인덱스가 아니면 다 지움(void)
        • linsert(어디 앞에 넣는다) / lset(지정한 인덱스에 치환) 중간에 넣는 작업
          • 중간에 넣을 때는 O(n)이라 안 빠름
  • hash
    • hset(데이터 넣고 1반환) 
    • field-value 쌍을 가진 아이템의 집합; 데이터 페어를 무제한으로 늘릴 수 있음
    • rdbms의 테이블 데이터와 가장 유사 -> 변환 가능
    • hrandfield 랜덤 / 양수를 주면 겹치지 않게; 음수는 겹쳐도 되게
  • set
    • sadd
    • 중복 안됨
    • 정렬되지 않은 문자열
    • srandmember 랜덤
    • 객체 간 관계 계산(교/차/합/원소개수)
      • sunion 합
      • sinter 교
      • sdiffer 차
  • sortedSet 
    • 스코어 값으로 정렬되는 고유한 문자열의 집합(정렬되어 있음; 매번 정렬할 필요 없음)
    • 모든 아이템은 value-score 쌍
    • 같은 스코어라면 사전순 정렬
    • value는 중복되지 않음
    • 인덱스로 접근 가능(O(logn))
    • -인덱스 사용 가능 나부터 -1
    • command
      • zadd 키 스코어 밸류
      • 옵션
      • xx/nx/lt/gt / withscores(밸류랑 스코어랑 같이 조회)/ rev(역순)/byscore
      • zrange 인덱스 기반으로 데이터 조회
      • zrandmember 랜덤 뽑기
      • bylex(사전순)
        • zrange mySortedSet (b (f bylex
          • (: 포함하지 않고 
          • (b 초과
          • [: 포함하고
  • bitmap
    • 확장된 string형태의 자료구조
    • 2^32개의 비트를 가질 수
    • 저장공간을 줄일 수
    • setbit, getbit, bitcount(1 개수 카운트)
  • hyperloglog
    • 집합의 카디널리티를 추정하는 자료구조
    • 대용량(로그)의 개수 카운팅; 중복제거
    • 12KB 고정용량(엄청 큰 데이터도), 1% 미만의 오차
    • pfadd, pfcount, pfmerge
  • geospatial
    • 위도, 경도 쌍; 지구가 완벽한 '구'라는 전제; 오차가 약간 있음
    • 내부적으로는 sortedSet
    • 특정 위치를 기준으로 검색할 수
    • 반경 몇 미터 안 검색 가능
    • 사각형으로도 조회가능
  • stream
    • 아파치 카프카에서 영향
    • append only 한 key-value형식의 자료구조
    • 메시지 브로커에 사용

key를 관리하는 법

  • 자동으로 생성되고 삭제됨
  • 키가 존재하지 않을 때 아이템을 넣으면 삽입 전 빈 자료구조 생성
  • 모든 아이템을 삭제하면 키도 삭제됨(스트림 예외)
  • 키가 존재하지 않을 때
    • 키를 삭제하는 명령을 하거나;
    • 키에 저장된 아이템을 삭제하거나;
    • 자료구조 크기를 조회하는 read only 커맨드를 수행하면
      • -> 키가 없지만 있는 척; 있고 아이템이 없는 것처럼 동작
  • command
    • 조회
    • keys 명령어는 위험; 다른 요청 blocking 됨;
      • scan 커맨드로 대체가능(여러 번 끊어서 데이터 가져옴)
      • 다음에 조회할 커서를 알려줌 그걸로 연쇄적으로 조회 가능
    • sort, exists, rename/renamenx, copy, type
    • 삭제 flushall, del, unlink
    • 만료시간 expire, expireat, expiretime, ttl

리더보드

  • sorted set: 중복을 허용하지 않고 정렬이 되는 자료구조
  • 데이터입력: ZADD daily-score:220817 200 player:286
  • 랭킹 합산; ZUNIONSTORE weekly-score:2208-3 3 daily-score:220815 daily-score:220816 daily-score:220817 (integer) 4 
  • 최대 다섯 개 까지만 저장 하려면 
    • 검색 기록이 다섯개 이하인 경우 → 삭제 안 함 
    • 검색 기록이 다섯 개보다 많을 경우 → 인덱스 0 삭제
    • 항상 인덱스 -6을 삭제한다면? 오버헤드 감소! 
    • ZREMRANGEBYRANK 사용

캐시로의 레디스

  • 레디스는 빨라, 응답속도를 줄일 수, 자체적인 ha 제공

캐싱 전략

  • 읽기 전략: look aside(lazy loading)
    • 우선 캐시에 데이터가 있는지 확인; 없으면(cache miss) 디비에서 읽고 캐시에 저장
      • 초반에 느릴 수 있음
      • 미리(오픈전) 데이터를 넣어주는 작업을 하기도 하는데 이를 cache warming
    • 캐시가 없더라도 장애로 이어지지 않음
    • 반대로 디비에 몰리면 장애가 날 수도 있음
    • 원본과 동일하도록 유지
      • 디비 업데이트 칠 때 캐시에도 업데이트해야(write through)
  • 쓰기 전략(데이터 일관성)
    • write through(중요하면 동기방식; 별로 안 중요하면 비동기)
      • 항상: redis -> db 업데이트
      • 매번 2개 저장
      • 쓰기 시간이 듦
      • 다시 사용하지 않아도 될 데이터가 이중으로 저장될 수도
      • expire time 설정 추천
    • cache invalidation
      • 디비에 저장할 때 캐시를 삭제
      • 찾을 때 디비에서 가져오고 후에 캐시 업데이트
      • 불일치는 발생하지 않음
    • write behind(나중에 디비에 써; 핫해서 계속 디비 넣기 부하 걸리니; 일정 주기로 동기화)
      • 쓰기가 빈번하다면 
      • 디비에 대량의 write면 디스크 IO -> 성능저하로 이어지기 때문에
      • 캐시에 먼저 업데이트하고
      • 비동기적으로 디비에 업데이트
      • 실시간으로 엄청 정확하지 않아도 될 경우
      • 단, 캐시 날아가면,, 끝

 

레디스를 캐시로 사용할 때 주의할 점

  • 자주 사용하는 데이터 일부만 임시로 저장해야(적은 양)
  • 적절한 메모리를 유지할 수 있도록 적절히 삭제하고 들어오도록 유지해야 함
  • TTL(time to live) 사용은 필수(캐시 유지시간)
    • 메모리 꽉 차면 레디스 자체의 max memory policy에 의해 가장 오래된 키부터 삭제
      • 쓰기를 막는 설정이 기본이긴 한데 이게 더 장애 유발 가능성 그래서 오래된 키부터 삭제하는 정책
      • default는 저장 불가 -> 장애로
      • 자주 사용하지 않는 거 삭제하는 방식으로 설정 변경 필요
  • TTL을 너무 작게 하면 cache stampede 현상
    • cache miss가 날 경우 조회 시간차로 (db) duplicate read, (redis) duplicate write 발생할 수도
  • 캐시로 사용할 경우
    • 영구저장소가 아닌 임시라서 캐시의 백업정책 필수는 아님; 비추
    • 캐시가 없는 경우 서비스장애로 이어진다면 ha구성 필요

메시지 브로커로서의 레디스

메세징 큐

  • 프로듀서 -> -> 컨수머
  • 데이터 푸시
  • 데이터 영속성: 읽으면 큐에서 데이터 삭제
  • 컨수머 추가되면 그 후에 추가된 메시지만 확인가능
  • 1:1

이벤트 스트림

  • 퍼블리셔 -> -> 섭스크리버(구독자)
  • 당겨감
  • 데이터 영속성: 저장소별 설정에 따라 특정기간 동안 저장
  • 구독자 추가되면 이전부터도 확인가능
  • 다:다; 1:다

레디스에서는

  • pub/sub
    • 최소한의 메시지 기능
    • 클러스터에서도 사용가능
    • 한번 채널에 전파되면 사라져; 일회성
    • 메시지가 잘 갔는지 모름(보관 안 함)
    • end-end에서는 별로
    • notification 딱 보내고 끝이면 괜찮음
    • command
      • subscribe / publish
  • list를 이용한 메시지 큐
    • list.exists
    • rpushx 이미 캐싱되어 있는(존재하는) 경우에만 입력(불필요한 리소스 줄일 수)
    • event loop 
      • 폴링: 이벤트 큐에서 확인 -> 핸들러
      • 즉각적인 확인 X, 주기별로 체크
      • 리스트에서 blocking을 추가하면 큐확인을 기다릴 필요 없음
      • brpop / blpop -> 있음 반환; 없으면 새로 올 때까지 기다렸다가 반환
      • circular queue: rpoplpush
  • stream 자료구조
    • 연속적으로 계속 들어오는 데이터 처리
    • 써도 사라지지 않고 남아있음
    • 카프카처럼 사용가능
    • 로그를 저장하기 적절
      • append only; 중간에 저장하지 않음
    • range 검색가능, tail 가능, 그룹핑 가능
    • xadd 
      • command에 "*" : 시간 millisec을 timestamp로 갖겠다,

데이터 영구적으로 저장하는 법

  • 재시작하면 데이터 날아감
  • 서버에 파일로 저장함
  • AOF: 커맨드 자체 저장; 과정을
    • 파일이 커지는 걸 막기 위해 aof는 bgrewriteaof로 파일을 다시 써서 압축
  • RDB: 데이터의 최종 스냅샷 저장(메모리 자체); 결과만
    • bgsave 커맨드로 새로운 스냅샷 저장
  • 이때 백그라운드 프로세스를 생성하기 때문에 메모리 초과로 인한 장애 발생 가능성 매우 큼
  • 비추

레디스 HA(high availability)

고가용성, 안정성 = 사용시간/전체시간

  • 복제: 마스터 -> 복제본 실시간 복사
  • 자동 failover: 마스터 노드에 발생한 장애를 감지해서 연결을 복제본 노드로 리디렉션 하도록; 수동으로 안 해도 됨

replication

  • master-replica
  • 마스터는 하나; 다중 마스터 불가(지원 안 함)
  • 마스터 노드는 rdb파일을 생성하여 리플리카 노드에 전달
    • 만드는 중에 메모리 증가
  • 리플리카에서 replicaof 실행
  • 쓰기는 마스터에서만
  • 장애 시 자동 failover 안돼서 잘 안 씀(커넥션 정보 수동으로 설정 변경해 줘야)

sentinel

  • ifnot: 복제 구성 시 장애 발생하면 직접 레디스 엔드포인트 변경 필요
    • replica of no one(복제본으로 활동하지 않도록) 설정하고 설정 변경 후 재시작
  • 데이터를 저장하지 않고 모니터링만 하는 프로세스(최소 3대 필요; 과반수가 필요; 홀수 구성)
  • 같이 설치됨; 추가로 다운로드할 필요 없음
  • 정상적으로 동작하기 위해선 최소 세 대의 센티널 프로세스가  필요
  • 마스터 파악; 마스터로 직접 연결 -> 변경되면 클라한테 알려줌
  • 마스터가 정상적으로 동작하는지 판단할 때 쿼럼(과반수) 이상일 때 페일오버 
  • 새로운 마스터가 되면 그동안의 데이터를 모두 날린 후 새로운 데이터를 복제받음
  • 다운된 마스터가 살아나면 새로운 마스터에 복제본으로 연결됨

cluster

  • full mesh 구조; 서로서로 감시(최소 3대, replica까지 치면 6대 필요)
  • 스캐일 업; 수직 확장: 스펙업
  • 스캐일 아웃; 수평 확장: 개수 증가
  • 일반적으로 업을 먼저 하고 아웃을 해
    • 키가 차있으면 먼저 메모리를 올려 스캐일 업
    • 메모리 문제가 아니라 처리량 문제면 스캐일 아웃 -> 처리량이 많아짐; 병렬도 가능; 마스터도 많아질 수
  • 데이터 샤딩
    • 개발(애플리케이션)에서 신경 쓰지 않아도 됨
      • 클러스터 아무 곳으로 던지면 알아서 리디렉션
    • 클라이언트 -> 클러스터 맵에 키랑 어디에 있는지 매핑 정보 들고 있음
      • 1 키 - 1 마스터
    • full mesh구조
      • 모든 마스터, 리플리카 노드는 가십 프로토콜을 사용해 서로서로 통신
      • 최소 세 개의 마스터 노드 필요
    • 샤딩 방법
      • 모든 키는 해시 슬롯에 연결됨(최대 16384개; mod 16384)
      • 해시 슬롯을 마스터 노드가 나눠가짐(나눠서 저장됨)
      • 키가 어떤 마스터에 저장되어 있는지 해시 함수로 알 수 있음(저장; 조회 모두 사용)
    • 마스터 노드 추가/삭제
      • 카프카는 데이터 이동 시 해당 데이터 사용 불가한데(전체 블라킹)
      • 레디스는 키를 새 마스터 옮기더라도 하나씩 옮기기 때문에 다운타임이 안 길어
      • 간편한 편.. 

레디스 도입 시

  • 어떤 용도?(persistence 기능 사용 유무)
    • 캐시
      • 저장 기능을 사용하지 않도록(원본이 디비에 있으면)
      • 캐시가 죽어도 애플리케이션에 영향가지 않도록(look-aside , write-through)
    • 디비
      • sentinel 사용
      • 애플리케이션 죽으면 영향 갈 수 있음
      • aof 사용하는 것이 안전
        • 주기적으로 rewrite 되도록 파라미터 조절 필요

레디스 구성을 할 때 미래를 생각하여 데이터를.. 짜야한다.

싱글 했다가 중간에 cluster로 바꿨는데 미리 염두에 두고 작업해야.. 나중에 cluster 옮겼는데 일부 명령어 안되고 잘 안될 수도 있다.

 

메모리 관리

  • used_memory: 논리적으로 Redis가 사용하는 메모리
  • used_memory_rss: OS가 Redis에 할당하기 위해 사용한 물리적 메모리 양
  • 삭제되는 키가 많으면 fragmentation 증가
  • 특정 시점에 피크를 찍고 다시 삭제되는 경우
  • TTL로 인한 eviction이 많이 발생하는 경우
  • 파편화 된 데이터 정리 해주는 명령어
    • CONFIG SET activedefrag yes

 

del 명령어보다 unlink

728x90
반응형

'개발 > cache' 카테고리의 다른 글

[redis] sorted set  (1) 2024.12.16
분산락 - redis 사용  (0) 2024.11.08
반응형

환경: java 8 ++

 

stream을 사용하여 리스트의 홀수번째(index 기준 0, 2, 4)에 있는 원소를 콘솔에 찍어본다고 가정하자.

아래와 같이 짜야지라고 쉽게 생각할 수 있다.

variable used in lambda expression should be final or effectively final

근데 위와 같은 에러가 난다.

그 이유는 아래와 같다. 자세한 내용을 알려면 람다 캡쳐링과 그 원리에 대해 이해해야한다.

The restriction to effectively final variables prohibits access to dynamically-changing local variables, whose capture would likely introduce concurrency problems.

람다식 내부에서는 람다식 안에서 정의된 변수가 아닌 외부 변수에 접근할 수 있는데 이를 람다 캡쳐링이라고 한다.

스트림은 여러 thread의 병렬처리를 염두하고 만들어졌다. 즉 별도의 스레드에서 실행할 수 있다. 그렇다면 어떻게 기존 스레드의 값을 참조하여 쓸 수 있을까? 기존 스레드의 작업이 종료되었을 수도 있는데 말이다.

람다 캡쳐링이 일어날 때 데이터의 참조값(call by reference)이 아닌 데이터 값 그 자체(call by value)를 복사하여 자신의 스택에 두고 작업을 한다. 그렇기 때문에 값이 변경될 여지가 있는 변수는 사용할 수 없고 final에 준하는(effectively final) 변수만 람다 안에서 사용할 수 있다.

반면 heap에 저장된 값은 thread끼리 공유하고 있기 때문에 언제든지 구할 수 있으므로 변경하더라도 에러가 나지 않는다.

Java의 스트림 API에서 외부 변수를 사용할 때 해당 변수가 effectively final해야 하는 이유는 다음과 같습니다:

1. 스레드 안전성

  • 스트림의 연산은 종종 병렬로 실행되며, 외부 변수가 여러 스레드에서 동시에 접근될 수 있습니다. 이를 방지하기 위해 외부 변수가 변경되지 않도록 보장해야 합니다.

2. 불변성

  • 외부 변수가 effectively final이면, 그 값이 변경되지 않는 것을 보장합니다. 이로 인해, 람다 표현식이나 메서드 참조가 이 변수를 안전하게 사용할 수 있습니다. 불변성을 유지함으로써 예측 가능한 동작을 보장합니다.

3. 람다 캡처

  • Java의 람다 표현식은 외부 변수를 캡처할 수 있지만, 캡처된 변수는 내부적으로 복사되어 사용됩니다. 만약 이 변수가 변경 가능하다면, 예상치 못한 결과를 초래할 수 있습니다. 이를 방지하기 위해 effectively final 조건이 필요합니다.`

 

위 함수의 에러는 여러 방법으로 수정할 수 있다.

1. AtomicInteger를 사용하여 수정

2. list/array를 이용해 수정

 

2번 방식으로 수정하다 보니 신기했던 것은 단순 값이 변경된다는 것에 초점을 둘게 아니라 final이면 된다는 점에 초점을 뒀어야 한다는 것이다. 일반적으로 final이라고 하면 불변, 즉 string이나 int인 경우 값이 바뀌면 안 된다고 인식하기 때문에 '값의 변경'에 나도 모르게 초점이 갔는데, collection 같은 경우에는 final이어도 값이 변동(추가 혹은 수정)될 수 있다!

전체 변경; 재할당
일부 변경

즉 참조값이 바뀌면 안 되고 같은 참조값 안에서의 변경은 된다(final but mutable)

final --> You cannot change the reference to the collection (Object). You can modify the collection / Object the reference points to. You can still add elements to the collection
immutable --> You cannot modify the contents of the Collection / Object the reference points to. You cannot add elements to the collection.

실제로 list를 final로 선언하고 값을 수정할 때 별문제 없이 작동한다.


위에서 말한 것과 같이 heap에 있는 변수는 수정이 가능하다.

stack
heap

 


 

Arrays.asList는 값 추가 안됨; size가 정해진 list라서: https://www.baeldung.com/java-list-unsupported-operation-exception

collection의 final이란? https://stackoverflow.com/questions/26500423/what-does-it-mean-for-a-collection-to-be-final-in-java

https://www.geeksforgeeks.org/final-arrays-in-java/

람다 캡쳐링: https://perfectacle.github.io/2019/06/30/java-8-lambda-capturing/

728x90
반응형

'개발 > java' 카테고리의 다른 글

디자인 패턴  (0) 2023.07.11
[이슈해결] NPE at HSSFSheet.autoSizeColumn  (0) 2023.06.28
[google admob] ssv 콜백 적용  (0) 2023.01.06
[Date] java8 이하에서 날짜 timezone 변환  (0) 2022.11.04
[mac] oracle jdk -> open jdk 교체하기  (0) 2022.08.22

+ Recent posts