728x90
반응형
SMALL

스프링 데이터 JPA에서 Querydsl을 지원하는 QuerydslRepositorySupport 라는 추상 클래스가 있다. 어떤걸 도와주냐면 우리가 이전에 사용했던 Querydsl 용 리포지토리를 아래와 같이 변경해보자.

public class MemberRepositoryImpl extends QuerydslRepositorySupport implements MemberRepositoryQueryDsl {

    // private final JPAQueryFactory queryFactory;

    public MemberRepositoryImpl(Class<?> domainClass) {
        super(Member.class);
    }
    
    ...
    
}
  • 기존에는, extends QuerydslRepositorySupport 가 없었는데 이 부분을 추가했다.
  • 그리고, JPAQueryFactory를 주입 받아야 하는 부분도 제거했다. 대신 저 QuerydslRepositorySupport를 상속받으면 반드시 생성자를 만들어 줘야 하는데 그 부분이 바로 이 부분이다.
public MemberRepositoryImpl(Class<?> domainClass) {
    super(Member.class);
}

 

이렇게 해주면 아래와 같은 장점이 있다.

  • JPAQueryFactory를 주입받지 않아도 된다.
  • from()으로 시작하는 쿼리를 작성할 수 있다. (이게 장점인지는 모르겠다)
  • getQuerydsl().applyPagination() 이라는 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 반환이 가능한데 사실 이것도 편리한지는 잘 모르겠다. 심지어 Sort도 제대로 동작안한다.
@Override
public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            ).select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .fetch();

    /*return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            ).fetch();*/
}
  • 주석 처리한 부분이 기존에 작성했던 코드이다.
  • 보면, from()으로 시작하는데, select()가 뒤에 있고 뭔가 좀 어색하고 쿼리를 한번에 읽기엔 명시적이지 않다. 난 이걸 장점으로 생각하지는 않는다. 
@Override
    public Page<MemberTeamDto> searchPaging(MemberSearchCondition condition, Pageable pageable) {

        JPQLQuery<MemberTeamDto> jpaQuery = from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                ).select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")));

        JPQLQuery<MemberTeamDto> query = getQuerydsl().applyPagination(pageable, jpaQuery);
        query.fetch();

        /*List<MemberTeamDto> result = queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")))
                .from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Member> countQuery = queryFactory
                .select(member)
                .from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())); */

        return PageableExecutionUtils.getPage(result, pageable, () -> countQuery.stream().count());
    }
  • 이게 이제 페이징을 좀 편리하게 해준다는 getQuerydsl().applyPagination()을 사용한 방법인데, 기존에 작성했던 코드랑 차이점은 쿼리에 offset(), limit()을 작성하지 않는다는 점이다. 
  • 근데, 이거 하나 줄이겠다고 저렇게 from()부터 시작하는 게 영 맘에 들지는 않는다.
728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

컨텐츠 관리 데이터베이스에 웹 사이트에 공개할 기사를 저장했다. 기사 테이블과 태그 테이블 사이의 다대다 관계를 위해 교차 테이블을 사용했다.

CREATE TABLE ArticleTags
(
    id         BIGINT PRIMARY KEY,
    article_id BIGINT NOT NULL,
    tag_id     BIGINT NOT NULL,
    FOREIGN KEY (article_id) REFERENCES Articles (id),
    FOREIGN KEY (tag_id) REFERENCES Tags (id)
)

 

그러나, 특정 태그가 다린 기사 수를 세는 쿼리에서 잘못된 결과가 나오고 있었다. "경제" 태그가 달린 가사가 다섯 개라는 것을 알고 있었지만, 쿼리를 실행하면 일곱 개로 나왔다.

SELECT tag_id, COUNT(*) AS articles_per_tag
FROM ArticleTags
WHERE tag_id = 327;

 

tag_id와 같은 모든 행을 조회해봤더니, 태그가 하나의 기사와 중복해 연관되어 있었다. 세 개의 행은 id 값만 달랐지 동일한 연관을 나타내는 것이었다. 

id tag_id article_id
22 327 1234
23 327 1234
24 327 1234

 

이 테이블은 PK를 가지고 있었지만, PK가 중요 칼럼의 중복을 막지 못했다. 나머지 두 칼럼에 대해 UNIQUE 제약 조건을 생성하면 해결되겠지만, 그렇다면 id 칼럼은 왜 필요한 것일까?

 

목표: PK 관례 확립

데이터베이스 설계를 접했던 사람이라면, 모두 PK가 중요하고 꼭 필요한 테이블의 일부라는 사실을 알 것이다. PK는 좋은 데이터베이스 설계에 정말 중요하다. PK는 테이블 내의 모든 행이 유일함을 보장하기 때문에, 각 행에 접근하는 논리적 메커니즘이 되고 중복 행이 저장되는 것을 방지한다. 또한 PK는 관계를 생성할 때 FK로부터 참조되기도 한다. 까다로운 부분은 PK로 사용할 칼럼을 선정하는 일이다. 대부분의 테이블에서 어느 속성의 값이든 하나 이상의 행에서 나타날 잠재적 가능성이 있다. 교과서적인 예제로 자주 나오는 이름도 분명 중복될 수 있다. 심지어 이메일 주소도 마찬가지다.

 

이런 테이블에는 테이블로 모델링한 영역에서는 아무런 의미도 가지지 않는 인위적인 값을 저장할 새로운 칼럼이 필요하다. 이 칼럼을 PK로 사용하면 다른 속성 칼럼에는 중복 값이 들어가는 것을 허용하는 반면, 특정 행에 유일하게 접근할 수 있게 된다. 이런 형태의 PK를 가상키 또는 대체키라고 한다. 

 

안티패턴: 만능키

책이나 기사, 프로그래밍 프레임워크는 데이터베이스 내 모든 테이블이 다음과 같은 특성을 가지는 PK 칼럼을 가지도록 하는 문화적 관례를 만들었다. 

  • PK 칼럼의 이름은 id다.
  • PK 칼럼의 데이터 타입은 32비트 또는 64비트 정수다.
  • 유일한 값은 자동 생성된다.

모든 테이블에 id란 이름의 칼럼이 있는 것은 너무도 흔해져 이게 PK와 동의어가 되어 버렸다. SQL을 배우는 프로그래머들은 PK가 항상 다음과 같은 식으로 정의되는 칼럼이라는 잘못된 생각을 갖게 된다.

CREATE TABLE Bugs (
    id          BIGINT PRIMARY KEY,
    description VARCHAR(1000),
    ...
)

 

모든 테이블에 id 칼럼을 추가하는 것은, 그 사용을 이상하게 만드는 몇가지 효과를 초래한다.

 

문제1 - 중복 키 생성

테이블 안의 다른 칼럼이 자연키로 사용될 수 있는 상황에서조차 단지 통념에 따라 id 칼럼을 PK로 정의한 것을 봤을 것이다. 그 다른 칼럼에 UNIQUE 제약조건이 설정되어 있는 경우도 있다. 예를 들어, Bugs 테이블에서는 프로젝트 코드를 앞에 붙여 bug_id를 만들 수 있을 것이다.

CREATE TABLE Bugs (
    id          BIGINT PRIMARY KEY,
    bug_id      VARCHAR(10) UNIQUE,
    description VARCHAR(1000),
    ...
)
INSERT INTO Bugs (bug_id, description, ...)
VALUES ('VIS-018', 'crashes on save', ...)

 

이 예에서 bug_id 칼럼은 각 행을 유일하게 식별할 수 있도록 해준다는 면에서 id와 사용 목적이 동일하다. 그럼 굳이 id가 왜 필요할까?

 

문제2 - 중복 행 허용

복합키는 여러 칼럼을 포함한다. 복합키가 사용되는 전형적인 예는 BugsProducts와 같은 교차 테이블 안에서다. PK는 특정한 bug_idproduct_id 값의 조합이 테이블 안에서 한 번만 나타난다는 것을 보장해야 한다. 각 값이 다른 쌍으로 여러 번 나타날 수 있을지라도 말이다. 그러나, id 칼럼을 PK로 사용하는 경우에는 유일해야 하는 두 칼럼에 제약조건이 적용되지 않는다.

CREATE TABLE BugsProducts
(
    id         BIGINT PRIMARY KEY,
    bug_id     BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
    FOREIGN KEY (product_id) REFERENCES Products (product_id)
)
INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1), (1234, 1), (1234, 1); -- 중복이 허용됨

BugsProducts를 연결하기 위해 이 교차 테이블을 사용할 때, 중복 때문에 의도하지 않은 결과가 발생한다. 중복을 방지하기 위해서는 id뿐 아니라 다른 두 칼럼에 UNIQUE 제약 조건을 걸어줘야 한다.

CREATE TABLE BugsProducts
(
    id         BIGINT PRIMARY KEY,
    bug_id     BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    UNIQUE KEY (bug_id, product_id),
    FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
    FOREIGN KEY (product_id) REFERENCES Products (product_id)
)

그러나 이 두칼럼에 UNIQUE 제약조건을 걸어야 한다면, id 칼럼은 불필요한 것이다.

 

안티패턴 인식 방법

이 안티패턴의 징후는 쉽게 인식할 수 있다. 테이블에서 PK 칼럼 이름으로 id가 사용되고 있으면 이 안티패턴의 징후로 볼 수 있다. 좀 더 의미 있는 이름 대신 id를 선호해야 할 이유는 없다. 다음과 같은 말 또한 이 안티패턴의 증거가 될 수 있다.

  • "이 테이블에는 PK가 없어도 될 것 같은데." → 이런 말을 하는 개발자는 PK와 가상키 용어의 의미를 혼동하는 것이다. 모든 테이블은 중복 행을 방지하고 각 행을 유일하게 식별하기 위해 PK 제약조건을 가져야 한다. 아마 자연키나 복합키 사용이 필요할 것이다.
  • "다대다 연결에서 왜 중복이 발생했지?" → 다대다 관계를 위한 교차 테이블에는 FK 칼럼을 묶어 PK 제약조건을 걸거나 최소한 UNIQUE 제약조건이라도 걸어줘야 한다.
  • "나는 데이터베이스 이론에서 값은 색인 테이블로 옮기고 ID로 참조해야 한다고 하는 걸 읽었어. 그러나 그렇게 하고 싶지 않아. 내가 원하는 실제 값을 얻기 위해 매번 조인을 해야 하기 때문이지." → 이는 데이터베이스 설계 이론에서 말하는 정규화에 대한 흔한 오해다. 정규화는 가상키와 아무런 상관이 없다.

 

안티패턴 사용이 합당한 경우

일부 객체-관계 프레임워크에서는 CoC(Convention over Configuration)를 통해 개발을 단순화한다. 이런 프레임워크에서는 모든 테이블이 동일한 방식(칼럼 이름은 id고 데이터 타입은 정수인 가상키)으로 PK를 정의한다고 가정한다. 이런 프레임워크를 사용한다면 그 관례를 따르고 싶을 것이다. 그렇게 해야 프레임워크의 다른 원하는 기능을 사용할 수 있기 때문이다. 물론 가상키를 사용하고 자동 증가하는 정수를 사용해 키값을 할당하는 것이 잘못은 아니다. 그러나 모든 테이블에 가상키가 필요한 것도 아니고, 모든 가상키 컬럼 이름을 id로 해야하는 것도 아니다. 가상키는 지나치게 긴 자연키를 대체하기 위해 사용한다면 적절한 선택이다. 예를 들어, 파일 시스템의 파일 속성을 저장하는 테이블에서, 파일 경로는 좋은 자연키가 될 수 있겠지만, 이렇게 긴 문자열을 키로 하면 인덱스를 만들고 유지하는 데 많은 비용이 들 것이다. 

 

해법: 상황에 맞추기

PK는 제약조건이지 데이터 타입이 아니다. 데이터 타입이 인덱스를 지원하기만 하면, 어느 칼럼 또는 칼럼의 묶음에 대해서도 PK를 선언할 수 있다. 또한 테이블의 특정 칼럼을 PK로 잡지 않고도 자동 증가하는 정수값을 가지도록 정의할 수 있다. 이 두 개념은 서로 독립적인 것이다. 좋은 설계 방법에 경직된 관례가 끼어드는 것을 허용하지 말기 바란다.

 

해법: 있는 그대로 말하기

PK에 의미 있는 이름을 선택해야 한다. 이 이름은 PK가 식별하는 엔티티의 타입을 나타내야 한다. 예를 들어, Bugs 테이블의 PK는 bug_id가 되어야 한다. FK에서도 가능하다면 같은 칼럼 이름을 사용해야 한다. 이는 종종 PK 이름이 스키마 내에서 유일해야 함을 뜻한다. 하나가 다른 쪽의 FK가 아닌 한, 동일한 PK 이름이 다른 테이블에 나오면 안된다. 그러나 예외가 있다. 연결의 본질을 더 잘 표현하는 경우라면, FK를 자신이 참조하는 PK 이름과 다르게 하는 것도 괜찮다. 

CREATE TABLE Bugs (
    ....
    reported_by BIGINT NOT NULL,
    FOREIGN KEY (reported_by) REFERENCES Accounts(account_id)
);

 

해법: 자연키와 복합키 포용

유일함이 보장되고, NULL 값을 가지는 경우가 없고, 행을 식별하는 용도로 사용할 수 있는 속성이 테이블에 있다면, 단지 통념을 따르기 위해 가상키를 추가해야 한다는 의무감을 느낄 필요는 없다. 실제로 테이블에 있는 각 속성은 변하기 마련이고, 유일하지 않게 될 수도 있다. 데이터베이스는 프로젝트 기간 동안 변화하며, 결정권자들이 자연키의 신성함을 존중하지 않을 수도 있다. 처음에는 자연키로 손색이 없어 보이던 칼럼이 나중에 알고 보니 적법하게 중복을 허용하는 것으로 밝혀질 수도 있다. 이런 경우에는 가상키를 사용할 수 있다.

 

복합키가 적절한 경우에는 이를 사용하기를 바란다. BugsProducts 테이블에서와 같이 여러 칼럼의 조합으로 행을 가장 잘 식별할 수 있다면, 이 칼럼 조합을 복합키로 사용해야 한다. 

CREATE TABLE BugsProducts
(
    bug_id     BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    PRIMARY KEY (bug_id, product_id),
    FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
    FOREIGN KEY (product_id) REFERENCES Products (product_id)
);
INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1), (1234, 2), (1234, 3);

INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1); -- error: duplicate entry

이런식으로 설계했다면, 제일 처음에 봤던 문제인 ArticleTags에서 발생한 중복 문제도 해결할 수 있었을 것이다. 복합 PK를 참조하는 FK또한 복합키가 되어야 함에 유의하기 바란다. 종속되는 테이블에 이렇게 칼럼 조합을 중복해야 하는 것은 안 좋아 보이지만, 장점도 있다. 중복된 칼럼 값을 얻을 때 조인을 안 해도 되기 때문에 쿼리가 단순해진다. 

 

참고로, 복합 PK를 참조하는 FK또한 복합키가 되어야 한다는 말은 이런 것이다.

CREATE TABLE OrderDetail (
    OrderID INT,
    ProductID INT,
    Quantity INT,
    PRIMARY KEY (OrderID, ProductID)
);

이런 OrderDetail 이라는 테이블이 있고 이 테이블의 PK는 OrderID, ProductID 복합키를 가지고 있을 때, 이를 참조하는 테이블에서 FK도 반드시 OrderID, ProductID를 모두 포함해야 한다는 뜻이다.

CREATE TABLE Shipment (
    ShipmentID INT,
    OrderID INT,
    ProductID INT,
    ShipDate DATE,
    PRIMARY KEY (ShipmentID),
    FOREIGN KEY (OrderID, ProductID) REFERENCES OrderDetail(OrderID, ProductID)
);

 

 

SQL AntiPatterns Tip

관례는 도움이 될 때만 좋은 것이다.

728x90
반응형
LIST
728x90
반응형
SMALL

SQL 관련 안티패턴을 공부하고자 마음먹고 새로 산 책인 [SQL AntiPatterns]을 보면서 배운 내용을 정리하려고 한다. 이 책에서 여러 안티 패턴을 설명하고 이 방식이 왜 잘못된건지, 어떤 문제를 야기하는지, 그 해결 방법은 무엇인지 자세하게 설명해주고 있는데 정말 재밌다.

 

참고 저서: [SQL Antipatterns]

 

목표: 다중 값 속성 저장

테이블의 칼럼이 하나의 값을 가질 땐 설계가 쉽다. 그러나 관련된 값의 집합은 어떻게 한 칼럼에 저장할 수 있을까? 예를 들면, 제품과 담당자가 있을 때 한 제품에 여러 담당자가 있을 수 있다. 최초에는 한 제품에는 한명의 담당자만으로 충분했는데 프로젝트가 성숙해가면서 제품의 담당자가 여러 명일 수 있다는 사실을 깨닫는다. 

 

안티패턴: 쉼표로 구분된 목록에 저장

데이터베이스 구조의 변경을 최소화하기 위해, account_id 칼럼을 VARCHAR로 바꾸고, 여기에 여러 개의 계정 아아디를 쉼표로 구분해 나열하기로 했다. 아래와 같이 테이블을 정의한다.

CREATE TABLE Products
(
    product_id   BIGINT PRIMARY KEY,
    product_name VARCHAR(50),
    account_id VARCHAR(100), -- 쉼표로 구분된 목록
    ...
);

 

그리고 이렇게 데이터를 넣어본다.

INSERT INTO Products (product_id, product_name, account_id) 
VALUES (DEFAULT, 'Visual TurboBuilder', '12,34');

 

성공한 것 같다. 테이블을 새로 만들지도 않았고, 칼럼을 추가하지도 않았기 때문이다. 단지 칼럼 하나의 데이터 타입만 바꿨을 뿐이다. 그러나 이 테이블 설계로부터 겪어야 할 성능 문제와 데이터 정합성 문제를 살펴보자.

 

문제1 - 특정 계정에 대한 제품 조회

모든 FK가 하나의 필드에 결합되어 있으면 쿼리가 어려워진다. 더 이상 같은지를 비교할 수 없다. 대신 어떤 패턴에 맞는지를 검사해야 한다. 예를 들어, MySQL에서는 계정 '12'에 대한 제품을 찾기 위해 다음과 같은 쿼리를 사용할 수 있다.

SELECT * FROM Products WHERE account_id REGEXP '[[:<:]]12[[:>:]]';

 

패턴 매칭을 사용하면 잘못된 결과가 리턴될 수 있고 인덱스도 활용하지 못한다. 패턴 매칭 문법은 데이터베이스 제품에 따라 다르기 때문에 이렇게 작성한 SQL은 벤더 중립적이지도 않다. 

 

문제2 - 주어진 제품에 대한 계정 정보 조회

마찬가지로, 쉼표로 구분된 목록을 참조하는 테이블의 대응되는 행과 조인하기도 불편해지고 비용이 많이 든다.

내가 어떤 제품의 ID를 알고 있을 때 이 제품이 가지고 있는 계정 ID를 통해 조인하는 쿼리를 아래와 같이 기괴하게 작성해야 한다.

SELECT * FROM Products AS p JOIN Accounts AS a 
ON p.account_id REGEXP '[[:<:]]' || a.account_id || '[[:>:]]'
WHERE p.product_id = 123;

이런 식의 표현을 사용해 두 테이블을 조인하면 인덱스를 활용할 기회가 사라진다. 이 쿼리는 두 테이블을 모두 읽어 카테시안 곱(Cartesian product)을 생성한 다음, 모든 행의 조합에 대해 정규 표현식을 평가해야 한다.

 

문제3 - 집계 쿼리 만들기

집계 쿼리는 COUNT(), SUM(), AVG()와 같은 함수를 사용한다. 그러나 이런 함수는 행의 그룹에 대해 사용하도록 설계되었지, 쉼표로 구분된 목록에 대해 사용하도록 설계된 것이 아니다. 따라서 다음과 같은 기교에 의지해야 한다.

SELECT product_id, LENGTH(account_id) - LENGTH(REPLACE(account_id, ',', '')) + 1 AS contacts_per_product
FROM Products;

이런 기교는 교묘하긴 하지만 명확하지 않다. 솔직히 쿼리를 딱 봤을때 이게 뭔지 한번에 알 수 있는 사람이 몇이나 될까? 이런 해법은 개발하는 데 시간도 오래 걸리고 디버깅하기도 어렵다. 심지어, 어떤 집계 쿼리는 이런 기교로도 만들어낼 수 없다. 

 

문제4 - 특정 제품에 대한 계정 갱신

목록의 마지막에 문자열 연결을 통해 새로운 아이디를 추가할 수 있지만, 이렇게 하면 목록이 정렬된 상태로 유지되지 않는다. 

UPDATE Products
SET account_id = account_id || ',' || 56
WHERE product_id = 123;

 

목록에서 항목을 삭제하려면 두 개의 SQL 쿼리를 실행해야 한다. 하나는 예전 목록을 불러오는 데, 다른 하나는 목록을 갱신하기 위해 필요하다. 

 

문제5 - 제품 아이디 유효성 검증

사용자가 'banana'와 같은 유효하지 않은 항목을 입력하는 것을 어떻게 방지할 수 있을까? 

INSERT INTO Products (product_id, product_name, account_id) 
VALUES (DEFALUT, 'Visual TurboBuilder', '12,34,banana');

사용자들은 유효하지 않은 값을 입력하는 방법을 찾아낼 것이고, 데이터베이스는 쓰레기 더미가 될 것이다. 데이터베이스에서 에러가 발생하지는 않지만, 데이터는 의미 없는 것이 될 것이다.

 

문제6 - 구분자 문자 선택

정수 목록 대신 문자열 목록을 저장하는 경우 목록의 일부 항목이 구분자 문자를 포함할 수 있다. 항목 간의 구분자로 쉼표를 사용하면 모호해질 수 있다. 구분자로 다른 문자를 사용할 수도 있으나, 이 새로운 구분자가 항목에 절대 안 나온다고 보장할 수 있을까?

 

문제7 - 목록 길이 제한

VARCHAR(30) 칼럼에 얼마나 많은 목록 항목을 저장할 수 있을까? 각 항목의 길이에 따라 다르다. 각 항목의 길이가 2라면(쉼표 포함) 항목을 열 개 저장할 수 있다. 그러나 각 항목의 길이가 6이라면 항목을 네 개 저장할 수 있을 뿐이다.

UPDATE Products 
SET account_id = '10,14,18,22,26,30,34,38,42,46'
WHERE product_id = 123;
UPDATE Products 
SET account_id = '101418,222630,343842,467790'
WHERE product_id = 123;

 

VARCHAR(30)이 미래에 필요한 가장 긴 목록을 지원할 수 있는지 어떻게 알 수 있겠는가? 얼마나 길게 하면 충분할까? 이런 길이 제한에 대한 이유를 상사나 고객에게 설명해보기 바란다.

 

안티패턴 인식 방법

프로젝트 팀에서 다음과 같은 말이 나온다면, 이 안티패턴이 사용되고 있음을 나타내는 단서로 간주할 수 있다.

  • "이 목록이 지원해야 하는 최대 항목 수는 얼마나 될까?" → VARCHAR 칼럼의 최대 길이를 선정하려 할 때 이런 질문이 나온다.
  • "SQL에서 단어의 경계를 어떻게 알아내는지 알아?" → 문자열의 일부를 찾아내기 위해 정규 표현식을 사용한다면, 이런 부분을 별도로 저장해야 함을 뜻하는 단서일 수 있다.  
  • "이 목록에서 절대 나오지 않을 문자가 어떤 게 있을까?" → 모호하지 않은 문자를 구분자로 사용하고 싶겠지만, 어떤 구분자를 사용하든 언젠가는 그 문자가 목록의 값에 나타날 것이라 예상해야 한다.

 

안티패턴 사용이 합당한 경우

어떤 종류의 쿼리는 데이터베이스에 반정규화를 적용해 성능을 향상시킬 수 있다. 목록을 쉼표로 구분된 문자열로 저장하는 것도 반정규화의 예다. 애플리케이션에서 쉼표로 구분된 형식의 데이터를 필요로 하고, 목록 안의 개별 항목에는 접근할 필요가 없을 수 있다. 비슷하게 애플리케이션이 다른 출처에서 쉼표로 구분된 형식으로 데이터를 받아 데이터베이스에 그대로 저장하고 나중에 동일한 형식으로 불러내야 하며, 목록 안의 개별 값을 분리할 필요가 없다면 안티패턴을 사용할 수 있다.

 

반정규화를 사용하기로 결정할 때는 보수적이어야 한다. 데이터베이스를 정규화하는 것이 먼저다. 정규화는 애플리케이션 코드를 좀 더 융통성 있게 하고, 데이터베이스의 정합성을 유지할 수 있게 한다.

 

해법: 교차 테이블 생성

account_idProducts 테이블에 저장하는 대신, 별도의 테이블에 저장해 account_id가 별도의 행을 차지하도록 하는 것이 좋다. 이 새로 만든 Contacts 테이블은 ProductsAccounts 사이의 다대다 관계를 구현한다.

 

CREATE TABLE Contacts
(
    product_id BIGINT NOT NULL,
    account_id BIGINT NOT NULL,
    PRIMARY KEY (product_id, account_id),
    FOREIGN KEY (product_id) REFERENCES Products (product_id),
    FOREIGN KEY (account_id) REFERENCES Accounts (account_id)
);
INSERT INTO Contacts (product_id, account_id)
VALUES (123, 12), (123, 34),
       (345, 23), (567, 12),
       (567, 34);

 

어떤 테이블이 FK로 두 테이블을 참조할 때 이를 교차 테이블이라 한다. 교차 테이블은 참조되는 두 테이블 사이의 다대다 관계를 구현한다. 즉 각 제품은 교차 테이블을 통해 여러 개의 계정과 연관되며, 마찬가지로 각 계정은 여러 개의 제품과 연관된다. 안티패턴의 문제가 이 교차 테이블을 사용하면 어떻게 해결될 수 있는지 살펴보자.

 

문제1, 2 타파 - 계정으로 제품 조회하기와 제품으로 계정 조회하기

주어진 계정에 대한 모든 제품의 속성을 조회하려면, Products 테이블과 Contacts 테이블을 조인하면 된다.

SELECT p.*
FROM Products AS p JOIN Contacts AS c
ON (p.product_id = c.product_id)
WHERE c.account_id = 34;

 

어떤 사람들은 조인을 포함한 쿼리를 거부하는데, 성능이 나쁘다고 생각하기 때문이다. 그러나 이 쿼리는 안티패턴에서 문제를 어떻게 어떻게 해결하려 본 방법보다 인덱스를 훨씬 잘 사용한다. 마찬가지로 계정 상세 정보를 조회하는 쿼리도 읽기 쉽고 최적화하기도 쉽다.

SELECT a.*
FROM Accounts AS a JOIN Contacts AS c
ON (a.account_id = c.account_id)
WHERE c.products_id = 123;

 

문제3 타파 - 집계 쿼리 만들기

다음 쿼리는 제품당 계정 수를 리턴한다.

SELECT product_id, COUNT(*) AS accounts_per_product
FROM Contacts
GROUP BY product_id;

 

계정당 제품 수를 구하는 것도 마찬가지로 간단하다.

SELECT account_id, COUNT(*) products_per_account
FROM Contacts
GROUP BY account_id;

 

가장 많은 담당자를 할당 받은 제품을 구하는 것과 같이 좀 더 복잡한 리포트를 만드는 것도 가능하다.

SELECT c.product_id, c.contacts_per_product
FROM (
    SELECT product_id, COUNT(*) AS contacts_per_product
    FROM Contacts
    GROUP BY product_id
) AS c
ORDER BY c.contacts_per_product DESC LIMIT 1

 

문제4 타파 - 특정 제품에 대한 계정 갱신 

목록에 항목을 추가하거나 삭제하는 것은 교차 테이블에 행을 삽입하거나 삭제하는 방법으로 할 수 있다. 각 제품에 대한 참조는 Contacts 테이블에 별도의 행으로 저장되므로, 한번에 하나씩 추가 또는 삭제할 수 있다. 

INSERT INTO Contacts (product_id, account_id) VALUES (456, 34);

DELETE FROM Contacts WHERE product_id = 456 AND account_id = 34;

 

문제5 타파 - 제품 아이디 유효성 검증

어떤 항목이 다른 테이블에 있는 합당한 값에 대해 유효한지를 확인하기 위해 FK를 사용할 수 있다. Contacts.account_idAccounts.account_id를 참조하도록 선언해, 참조 정합성을 데이터베이스가 강제하도록 할 수 있다. 이렇게 하면 교차 테이블에는 실제로 존재하는 계정 아이디만 들어있음을 확신할 수 있다. 

 

항목을 제한하는 데 SQL 데이터 타입을 사용할 수도 있다. 예를 들어, 목록에 들어갈 항목이 유효한 INTEGER 또는 DATE 값이어야 하고, 해당 칼럼이 이 데이터 타입을 사용하도록 선언했다면, 모든 항목이 해당 타입의 유효한 값이라 확신할 수 있다. 즉, `banana`와 같은 무의미한 값이 없다고 확신할 수 있다. 

 

문제6 타파 - 구분자 문자 선택

각 항목을 별도의 행으로 저장하므로, 구분자를 사용하지 않는다. 쉼표나 구분자로 사용하는 다른 문자가 항목에 포함되어 있을지 걱정할 필요가 없다.

 

문제7 타파 - 목록 길이 제한

각 항목이 교차 테이블에 별도 행으로 존재하기 때문에, 한 테이블에 물리적으로 저장할 수 있는 행 수에만 제한을 받는다. 항목 수를 제한하는 것이 적당하다면, 목록의 항목을 합한 길이를 보는 것보다는 애플리케이션에서 항목 수를 세어 이 정책을 강제해야 한다.

 

교차 테이블의 다른 장점

Contacts.account_id에 걸린 인덱스를 활용하면 쉼표로 구분된 목록에서 부분 문자열 매칭하는 것보다 성능이 좋아진다. 칼럼에 FK를 선언하면 많은 데이터베이스가 내부적으로 해당 칼럼에 대한 인덱스를 생성한다. (그러나 확실한 건 해당 데이터베이스 문서를 확인)

 

또한, 교차 테이블에 칼럼을 추가해 각 항목에 추가 속성을 넣을 수 있다. 예를 들어, 주어진 제품에 담당자가 할당된 날짜를 저장하거나, 누가 주 담당자이고 누가 부 담당자인지를 표시하는 속성을 추가할 수 있다. 

728x90
반응형
LIST

'SQL AntiPatterns' 카테고리의 다른 글

2. PK 관례 확립: 만능키  (6) 2024.12.30
728x90
반응형
SMALL

이전 포스팅까지는 Querydsl의 기본 사용 방법에 대해 쭉 정리를 해봤는데, 이제 그 지식을 바탕으로 실무에 가까운 코드를 작성해보자.

먼저, 순수한 JPA 레포지토리와 Querydsl을 사용해보고, 이후에 스프링 데이터 JPAQuerydsl을 사용해보는 것도 하나씩 해보자.

 

순수 JPA 레포지토리와 Querydsl

MemberJpaRepository

package cwchoiit.querydsl.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import cwchoiit.querydsl.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

import static cwchoiit.querydsl.entity.QMember.member;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public void save(Member member) {
        em.persist(member);
    }

    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(em.find(Member.class, id));
    }

    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }

    public List<Member> findAllQuerydsl() {
        return queryFactory
                .selectFrom(member)
                .fetch();
    }

    public List<Member> findByUsername(String username) {
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList();
    }

    public List<Member> findByUsernameQuerydsl(String username) {
        return queryFactory
                .selectFrom(member)
                .where(member.username.eq(username))
                .fetch();
    }
}
  • 순수 JPA 레포지토리는 워낙 많이 만들어봤기 때문에 다른 설명이 필요없다. 그런데 이 레포지토리에서 Querydsl을 사용하기 위해 JPAQueryFactory를 주입받는 방법이 취향차이인데, 지금 방식은 빈으로 등록해서 주입받는 방법이다. 빈으로는 어디에 등록했나? 빈을 등록할 수 있는 어디든 상관은 없다. 나의 경우 엔트리 클래스에 했다. 아래 코드처럼.
package cwchoiit.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class QuerydslApplication {

    public static void main(String[] args) {
        SpringApplication.run(QuerydslApplication.class, args);
    }

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}
  • 빈으로 JPAQueryFactory를 등록해서 여기저기 주입받아 사용한다. 그럼 여기서 의문이 든다. 동시성 문제가 있지 않을까? 그 걱정은 하지 않아도 된다. 왜냐하면 여기서 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 엔티티 매니저를 찾아주는 프록시 엔티티 매니저를 주입해준다. 이 프록시가 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저를 할당해준다.
  • 이렇게 빈으로 등록하고 주입받는 방법을 사용해도 되고, 아래와 같이 사용해도 상관없다.
@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }
    
    ...
}
  • 각자 장단점이 있는데, 이렇게 생성자에서 new로 새로 만들어내는 방법은 테스트 코드를 작성할 때 더 편리하다. 빈으로 등록한 것을 주입 시킬 필요가 없으니까. 근데 이제 실제 코드에서 생성자를 만드는 코드를 작성하는 게 좀 귀찮아진다. 

 

그리고, findAll(), findByUsername() 이 두 메서드를 보면 Querydsl로 만든 버전이 있고 순수 JPA로 만든 버전이 있는데 차이가 명확하다.

public List<Member> findAll() {
    return em.createQuery("select m from Member m", Member.class)
            .getResultList();
}

public List<Member> findAllQuerydsl() {
    return queryFactory
            .selectFrom(member)
            .fetch();
}

public List<Member> findByUsername(String username) {
    return em.createQuery("select m from Member m where m.username = :username", Member.class)
            .setParameter("username", username)
            .getResultList();
}

public List<Member> findByUsernameQuerydsl(String username) {
    return queryFactory
            .selectFrom(member)
            .where(member.username.eq(username))
            .fetch();
}
  • 순수 JPA로 작성하려면 JPQL을 작성해줘야 하는데 이 부분에서 실수를 해도 컴파일 시점에 그 부분을 잡아내지는 못한다. 왜냐? 단순 문자열이니까. 그렇지만 Querydsl을 사용하는 경우, 컴파일 시점에 문제를 다 잡아낼 수 있다. 왜냐? 자바 코드니까.
  • 또 한가지는, 파라미터 바인딩 코드가 Querydsl은 필요가 없다. 
  • Querydsl을 사용하면 여러모로 장점이 참 많다.

 

동적 쿼리와 성능 최적화 조회 - Builder 사용

동적 쿼리가 존재하는 순수 JPA 레포지토리도 만들어보고, 극한의 최적화를 위해 딱 필요한 것만 조회해오는 DTO로 성능 최적화를 해보자.

MemberTeamDto

package cwchoiit.querydsl.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;

@Data
public class MemberTeamDto {

    private Long memberId;
    private String username;
    private int age;
    private Long teamId;
    private String teamName;

    @QueryProjection
    public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {
        this.memberId = memberId;
        this.username = username;
        this.age = age;
        this.teamId = teamId;
        this.teamName = teamName;
    }
}
  • 쿼리를 날릴 때 SELECT절에 딱 필요한 데이터만 메모리에 퍼올리기 위해 원하는 필드만 있는 DTO를 만들었다.
  • 그리고, @QueryProjection을 사용해서, Querydsl을 사용할 때 DTO를 편리하게 사용할 수 있게 했다.

MemberSearchCondition

package cwchoiit.querydsl.dto;

import lombok.Data;

@Data
public class MemberSearchCondition {

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}
  • 동적 쿼리에 사용될 조건 객체를 만들었다.

MemberJpaRepository 일부분

public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {

    BooleanBuilder builder = new BooleanBuilder();

    if (StringUtils.hasText(condition.getUsername())) {
        builder.and(member.username.eq(condition.getUsername()));
    }

    if (StringUtils.hasText(condition.getTeamName())) {
        builder.and(team.name.eq(condition.getTeamName()));
    }

    if (condition.getAgeGoe() != null) {
        builder.and(member.age.goe(condition.getAgeGoe()));
    }

    if (condition.getAgeLoe() != null) {
        builder.and(member.age.loe(condition.getAgeLoe()));
    }

    return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(builder)
            .fetch();
}
  • BooleanBuilder를 사용해서 동적 쿼리를 먼저 만들어봤다. 이전에도 이 녀석을 사용해보고 이후에 WHERE 다중 파라미터 방식을 했던것처럼 이번에도 그렇게 해보자.
  • BooleanBuilder를 사용해도 코드가 나쁘지 않다. 느낌은 JDBC를 사용해서 동적 쿼리를 만들어내는 거랑 비슷한데, 확연히 다른 점은 단순 문자열로 동적 쿼리를 만들어내는 게 아니란 점이다. 이게 정말 가장 강력한 장점이다.
  • 한가지 위 코드에는 오점이 있다. 오점이라기 보단 불 필요한 부분. 바로 SELECT절에 퍼올리는 데이터에 as(...)로 별칭을 주는 부분이다. 저렇게 작성한 이유는 데이터베이스에는 MemberID 필드가 `member_id`인데 DTO`memberId`이기 때문에 기본적으로 저렇게 별칭을 주는게 맞다. 근데 저건 Q클래스의 DTO이고 생성자이다. 즉, 필드가 들어가는 순서가 명확하고 어떤 타입인지도 이미 컴파일러는 알고 있으며, 생성자를 사용해 DTO 타입의 객체를 만들어내기 때문에 저렇게 할 필요가 없다. 그러니까 아래처럼 작성해도 된다.
return queryFactory
    .select(new QMemberTeamDto(
            member.id,
            member.username,
            member.age,
            team.id,
            team.name))
    .from(member)
    .leftJoin(member.team, team)
    .where(builder)
    .fetch();

 

그러나, 이 BooleanBuilder를 사용하는 것보다 무조건 WHERE절 다중 파라미터를 사용하는 게 더 좋다. 일단 코드를 보면 무슨말인지 확 이해가 된다.

 

동적 쿼리와 성능 최적화 조회 - WHERE절 다중 파라미터 사용

public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                   teamNameEq(condition.getTeamName()),
                   ageGoe(condition.getAgeGoe()),
                   ageLoe(condition.getAgeLoe()))
            .fetch();
}

private BooleanExpression usernameEq(String username) {
    return StringUtils.hasText(username) ? member.username.eq(username) : null;
}

private BooleanExpression teamNameEq(String teamName) {
    return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
}

private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe != null ? member.age.goe(ageGoe) : null;
}

private BooleanExpression ageLoe(Integer ageLoe) {
    return ageLoe != null ? member.age.loe(ageLoe) : null;
}
  • 같은 코드가 이렇게 바뀌었다. 쿼리만 따로 보면 이렇다.
public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                   teamNameEq(condition.getTeamName()),
                   ageGoe(condition.getAgeGoe()),
                   ageLoe(condition.getAgeLoe()))
            .fetch();
}
  • 이게 가장 큰 장점이 뭐냐? 쿼리가 읽힌다는 점이다. 동적 쿼리를 처리하는 방법은 다양하다. JDBC를 사용해서 동적쿼리를 처리하나 위에서 다뤘던 BooleanBuilder를 사용하나 쿼리만 딱 봤을 때 어떤 쿼리가 나갈지 예상하기 쉽지 않다. 위에 "어떤 코드가 있나~?" 하고 보고 내려와야 하니까. 
  • 그런데 이 방식은 그럴 필요가 없다. 쿼리만 봐도 어떤 쿼리가 나갈지 그냥 눈에 보인다. 이게 정말 정말 큰 장점이다. 그리고 또 하나의 장점은 조건에 사용되는 usernameEq, teamNameEq, ageGoe, ageLoe 와 같은 메서드는 메서드이기 때문에 재사용이 가능하다는 점이다. 다른 동적 쿼리를 작성할 때 이렇게 범용적으로 사용될 것 같은 조건들은 또 사용될 가능성이 높다. 그럴때 그냥 만들어 둔 이 녀석들을 가져다가 사용하면 된다. 
  • 또다른 장점은, 각 메서드를 조합하는 게 가능하다는 점이다. 다 이전 포스팅에서 다뤘던 내용이지만 한번 더 강조하고자 말하는 중이니 어떻게 조합한다는 거지?에 대한 의문은 이전 포스팅이 해결해 줄 것이다.

 

조회 API 컨트롤러 개발

이제 이 동적쿼리를 실제로 어디선가 호출하는 그런 케이스를 만들어보자. REST API로 어떤 데이터를 조회할 때 저 동적 쿼리가 사용된다고 가정해보는 것이다. 이걸 테스트해보기 위해서는 더미데이터가 데이터베이스에 좀 있어야 한다. 그래서 이 부분을 먼저 좀 처리해보자.

 

우선, 프로파일을 나눠서 테스트 코드에는 영향이 끼치지 않도록 아래와 같이 해보자.

 

`src/main/resources/application.yaml`

spring:
  profiles:
    active: local
...

 

`src/test/resources/application.yaml`

spring:
  profiles:
    active: test
...

 

InitMember

package cwchoiit.querydsl.controller;

import cwchoiit.querydsl.entity.Member;
import cwchoiit.querydsl.entity.Team;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Profile("local")
@Component
@RequiredArgsConstructor
public class InitMember {

    private final InitMemberService memberService;

    @PostConstruct
    public void init() {
        memberService.init();
    }

    @Component
    @RequiredArgsConstructor
    static class InitMemberService {
        private final EntityManager em;

        @Transactional
        public void init() {
            Team teamA = new Team("teamA");
            Team teamB = new Team("teamB");
            em.persist(teamA);
            em.persist(teamB);

            for (int i = 0; i < 100; i++) {
                Team selectedTeam = i % 2 == 0 ? teamA : teamB;
                em.persist(new Member("member"+i, i, selectedTeam));
            }
        }
    }
}
  • 이 클래스는 오직 프로파일이 `local`인 경우에만 살아있을 것이다. @Profile("local") 애노테이션을 달았기 때문이다.
  • 그리고, @PostConstruct 애노테이션을 사용해서, 스프링 띄우고 바로 호출되도록 했다. 뭘 바로 호출해야 하냐? 가 데이터를 만드는 작업이다. 
  • 그런데, 가 데이터를 만들 땐 트랜잭션이 필요하다. 그래서 @Transactional@PostConstruct를 분리해야 한다. 이 둘은 동시에 적용될 수 없다. 
  • 이렇게 가 데이터를 만들게 하고 나서, 컨트롤러 하나를 추가해보자.

MemberController

package cwchoiit.querydsl.controller;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import cwchoiit.querydsl.repository.MemberJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMembersV1(MemberSearchCondition condition) {
        return memberJpaRepository.search(condition);
    }
}
  • 컨트롤러 하나를 간단히 만들고, 파라미터로 위에서 동적 쿼리 조건 처리할 때 사용하는 MemberSearchCondition을 사용했다.

 

이제 이 API를 호출해보자.

### GET Members
GET http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=50&username=member49

 

스프링 데이터 JPAQuerydsl

이제 순수 JPA 레포지토리를 스프링 데이터 JPA로 변경해보자.

 

MemberRepository

package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsername(String username);
}
  • 스프링 데이터 JPA로 변경하려면 이렇게 만들면 끝난다. 기존에 있던, save(), findAll(), findById()와 같은 공통 메서드들은 이미 스프링 데이터 JPA는 다 만들어서 제공해주기 때문에 그런 공통 메서드가 아닌 findByUsername()만 만들면 되는데 이마저도 스프링 데이터 JPA가 제공하는 메서드 쿼리 기능을 사용해서 시그니처만으로 끝낼 수 있다.
  • 문제는 Querydsl을 사용했던 부분들이다. 걔네들은 어떻게 구현하면 될까?

 

사용자 정의 레포지토리

Querydsl을 사용하려면 구현 코드를 작성해야 하는데, 스프링 데이터 JPA는 인터페이스이기 때문에 조금 복잡한 과정이 필요하다.

 

1. 사용자 정의 인터페이스 작성

package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;

import java.util.List;

public interface MemberQuerydslRepository {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}
  • 인터페이스 이름은 원하는대로 만들면 된다. 그리고, 이전에 순수 JPA 레포지토리에서 만들었던 search()를 그대로 만들어준다.

2. 사용자 정의 인터페이스 구현

package cwchoiit.querydsl.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import cwchoiit.querydsl.dto.QMemberTeamDto;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;

import java.util.List;

import static cwchoiit.querydsl.entity.QMember.member;
import static cwchoiit.querydsl.entity.QTeam.team;

@RequiredArgsConstructor
public class MemberQuerydslRepositoryImpl implements MemberQuerydslRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name))
                .from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return StringUtils.hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
}
  • 구현체를 만들고, 순수 JPA 레포지토리에서 만들었던 그 메서드를 그대로 가져와보자. 

3. 스프링 데이터 레포지토리에 사용자 정의 인터페이스 상속

package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long>, MemberQuerydslRepository {
    List<Member> findByUsername(String username);
}
  • 스프링 데이터 JPA 레포지토리에 위에서 만든 우리의 커스텀 레포지토리를 상속시키면 끝이다.

 

스프링 데이터 페이징 활용1 - Querydsl 페이징 연동

  • Querydsl을 사용하면서 스프링 데이터의 Page, Pageable을 활용해보자.
package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.List;

public interface MemberQuerydslRepository {
    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> search(MemberSearchCondition condition, Pageable pageable);
}
  • 우선, 반환타입이 Page<MemberTeamDto>이고, 파라미터로 Pageable을 받는 메서드를 하나 추가한다.
  • 이제 이 녀석을 새로 구현하면 된다.
@Override
public Page<MemberTeamDto> search(MemberSearchCondition condition, Pageable pageable) {
    List<MemberTeamDto> contents = queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    List<String> totalContents = queryFactory
            .select(member.username)
            .from(member)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .fetch();

    return new PageImpl<>(contents, pageable, totalContents.size());
}
  • 이전에는 fetchResults()라는 메서드를 사용하면, 데이터와 전체 개수를 같이 받아오는 메서드가 있었는데 이제는 Deprecated됐다. 그래서 데이터를 가져오는 쿼리와 카운트 쿼리를 따로 날려야 한다. 그리고 이후에 설명하겠지만 이게 맞다.
  • 위 코드를 보면, 파라미터로 받은 Pageable에서 offset, limit을 뽑아올 수가 있다. Querydsl을 사용해서 그냥 offset, limit을 넣어주면 페이징은 끝이다.
  • 그럼 카운트 쿼리를 자세히 보자. 이게 중요하다.
List<String> totalContents = queryFactory
                .select(member.username)
                .from(member)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
  • 카운트 쿼리를 보니, 데이터를 가져오는 쿼리랑 다르다. 조인도 안하고 있고, SELECT절에서 가져오는 것들도 다르다. 이게 최대한의 성능 최적화를 할 수 있는 방법이다. 전체 개수를 가져오는 쿼리는 생각보다 굉장히 무겁다. 데이터가 100백만건이면 그 데이터를 메모리에 퍼 올리는 것만으로도 문제가 될 수 있다. 거기에 더해서, 조인을 하고 SELECT절에 필요하지 않은 데이터까지 다 퍼올리면 이 부분도 성능에 마이너스 요소이다. 
  • LEFT JOIN을 하면 전체 개수는 LEFT JOIN을 아예 하지 않아도 동일하다. 어차피 조인한 결과가 없으면 빈 값으로 그대로 레코드를 가져오기 때문이다. 그래서 굳이 조인을 하지 않아도 전체 개수에 아무런 영향을 끼치지 않는다.
  • 또한, SELECT절에는 다른 데이터가 전혀 필요없이 딱 하나만 있어도 전체 개수에 아무런 영향을 끼치지 않는다. 그래서 굳이 데이터를 가져오는 쿼리에서 봤던 여러 다른 데이터를 가져올 필요가 없다. 
  • 이렇게 전체 개수를 가져오는 쿼리를 최적화할 수 있으면 해야 한다. 매번 이렇게 할 수 있는건 아니지만 가능하면 반드시 해야 한다.
return new PageImpl<>(contents, pageable, totalContents.size());
  • 이제 반환을 하면 된다. 첫번째 파라미터는 contents, 두번째 파라미터는 pageable, 세번째 파라미터는 전체 개수를 받는다. 
  • 이것 또한, 최적화가 가능하다. 다음과 같이 말이다.
JPAQuery<String> countQuery = queryFactory
                .select(member.username)
                .from(member)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()));

return PageableExecutionUtils.getPage(contents, pageable, () -> countQuery.fetch().size());
  • PageableExecutionUtils.getPage()를 사용하면 어떤 최적화가 가능하냐면, contents를 가져왔을 때 전체 개수 쿼리를 날릴 필요가 없으면 날리지 않는다. 엥? 어떤 경우에 이럴까? 예를 들어 한 페이지의 개수가 100개인 페이징 쿼리를 날렸는데 한 페이지에 데이터가 3개만 들어왔다. 이 말은 그 쿼리 자체의 전체 개수가 3개라는 말이다. 그럼 카운트 쿼리를 날릴 필요없이 그냥 전체 개수는 3인 것이다. 

 

스프링 데이터 페이징 활용2 - 컨트롤러 개발

package cwchoiit.querydsl.controller;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import cwchoiit.querydsl.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMembersV1(MemberSearchCondition condition) {
        return memberRepository.search(condition);
    }

    @GetMapping("/v2/members")
    public Page<MemberTeamDto> searchMembersV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.search(condition, pageable);
    }
}
  • searchMembersV2()를 보자. 페이징 처리가 가능한 REST API를 만들었다.
  • 우리가 위에서 만든 Querydsl을 사용해서 페이징 쿼리를 날려보자.
### GET Members
GET http://localhost:8080/v2/members?page=0&size=200
  • 의도적으로 한 페이지에 200개를 가져오게 해봤다. 위에서 말한대로, 카운트 쿼리를 날릴 필요가 없으면 카운트 쿼리를 날리지 않게 최적화했으니 실제로 그런지 확인해보자! 전체 데이터는 100개뿐인데 한 페이지에 200개를 뽑아오게 하면 들어오는 데이터는 100개라 전체 카운트 쿼리를 날리지 않을 것이다.

  • 카운트 쿼리는 예상대로 날라가지 않았다. 그럼 카운트 쿼리가 날라가도록 바꿔서 테스트도 해보자.
### GET Members
GET http://localhost:8080/v2/members?page=0&size=50

  • 카운트 쿼리가 날라가야 하는 경우 제대로 날리는 모습을 확인할 수 있다.

 

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

프로젝션과 결과 반환 - 기본

SELECT절에 어떤걸 가져올지를 정하는 것을 프로젝션이라고 한다. 프로젝션 대상이 하나라면 타입을 명확하게 지정할 수 있고 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회할 수 있다.

 

프로젝션 대상이 하나인 경우

@Test
void simpleProjection() {
    List<String> results = queryFactory
            .select(member.username)
            .from(member)
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }

    List<Member> members = queryFactory
            .select(member)
            .from(member)
            .fetch();

    for (Member member1 : members) {
        System.out.println("member1 = " + member1);
    }
}
  • SELECT절에 퍼올리는 데이터가 딱 하나인 경우를 프로젝션 대상이 하나인 경우라고 말하고, 위의 경우들이 그 예시이다.
  • 회원의 유저명만 딱 가져오는 경우 또는 회원 엔티티 자체를 딱 가져오는 경우를 프로젝션 대상이 하나인 경우라고 표현한다.

실행 결과

result = member1
result = member2
result = member3
result = member4

member1 = Member(id=1, username=member1, age=10)
member1 = Member(id=2, username=member2, age=20)
member1 = Member(id=3, username=member3, age=30)
member1 = Member(id=4, username=member4, age=40)

 

프로젝션 대상이 둘 이상인 경우 - 튜플 조회

@Test
void tupleProjection() {
    List<Tuple> results = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();

    for (Tuple tuple : results) {
        System.out.println(tuple.get(member.username));
        System.out.println(tuple.get(member.age));
    }
}
  • 위 코드와 같이 회원의 유저명과 나이 두 개를 가져오는 경우 동일한 타입이 아닌것부터 해서 하나의 타입으로 반환할 수가 없다. 이럴때 QuerydslTuple 타입으로 반환하게 된다.
  • 튜플 타입을 반환하고 데이터를 꺼낼때는 tuple.get(...)을 사용하면 된다.
  • 참고로, 그냥 이런게 있구나? 정도로 넘어가자. 왜냐하면 DTO로 반환하는 것을 잘 아는게 더 중요하고 잘 사용되기 때문이다.

실행 결과

member1
10
member2
20
member3
30
member4
40

 

프로젝션과 결과 반환 - DTO 조회

거의 대부분의 경우, DTO로 반환하는 경우를 많이 사용한다. 그리고 이 DTO로 조회하는 것도 여러 방법이 있는데 하나씩 소개하고 어떤게 더 좋은지도 알아보자! 우선 DTO가 필요하다.

package cwchoiit.querydsl.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 위와 같이 DTO를 만들었다. 이후에 Querydsl에서 DTO 조회를 할때 기본 생성자가 반드시 필요하기 때문에 기본 생성자를 대신 만들어주는 롬복의 @NoArgsConstructor도 사용했다.

 

순수 JPA에서 DTO 조회

먼저, 순수한 JPA를 사용한 DTO 조회 방법을 알아보자.

@Test
void findDtoByJPQL() {
    List<MemberDto> memberDtos = em.createQuery("SELECT new cwchoiit.querydsl.dto.MemberDto(m.username, m.age) FROM Member m", MemberDto.class)
            .getResultList();

    for (MemberDto memberDto : memberDtos) {
        System.out.println("memberDto = " + memberDto);
    }
}
  • 우선, 순수 JPA로 하려면, 위 코드처럼 new 키워드를 사용해서 패키지명부터 다 적어줘야 한다.
  • 조금 불편하고 지저분한 감은 지울수가 없다.

실행 결과

memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

 

Querydsl에서 DTO 조회 - Bean 생성

여기가 중요하니까 글자도 큼지막하게 하고 볼드체도 빵빵하게 넣었다. 크게 3가지 방법이 있다.

  • 프로퍼티 접근 (Setter)
  • 필드 직접 접근
  • 생성자 사용

1. 프로퍼티 접근 - Setter

@Test
void findDtoBySetter() {
    List<MemberDto> results = queryFactory
            .select(Projections.bean(MemberDto.class, member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
  • DTO로 조회하는 방법 중 Setter를 사용하려면, 위와 같이 Projections.bean(...)을 사용하면 된다. 처음 파라미터는 어떤 클래스인지를, 그 다음부터는 원하는 각 필드를 채우면 된다.

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

 

2. 필드 직접 접근

@Test
void findDtoByFields() {
    List<MemberDto> results = queryFactory
            .select(Projections.fields(MemberDto.class, member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 위와 다른 것은 Projections.fields(...)를 사용했다는 점이다. 이 방법은 필드에 직접적으로 값을 넣어준다. DTO 클래스에 필드들은 다 private으로 선언했지만? 알다시피 리플렉션은 private도 접근 가능하게 해버릴 수 있기 때문에 상관없다.

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

별칭이 다른 경우

그런데, 가끔 별칭이 다른 DTO가 있을 수 있다. 무슨 말이냐면, 다음과 같은 DTO가 있다고 해보자.

package cwchoiit.querydsl.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class UserDto {
    private String name;
    private int age;

    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
  • 이 경우엔 유저명이 `username`이 아니라 `name`이다. 
  • 이런식으로 컬럼명과 필드명이 다른 경우엔 값을 제대로 인식을 못한다. 그래서 이렇게 별칭이 다른 DTO를 사용하려면 다음과 같이 사용해야 한다.
@Test
void findDtoByFieldsAlias() {
    List<UserDto> results = queryFactory
            .select(Projections.fields(UserDto.class, member.username.as("name"), member.age))
            .from(member)
            .fetch();

    for (UserDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 별칭이 다른 필드에 .as(...)를 사용해서 해당 별칭에 맞게 변경해줘야 한다.
  • 또 다른 예시는 서브 쿼리를 사용할때도 있다. 아래 코드를 보자.
@Test
void findDtoByFieldsAlias() {
    QMember memberSub = new QMember("memberSub");

    List<UserDto> results = queryFactory
            .select(Projections.fields(UserDto.class, 
                    member.username.as("name"),
                    ExpressionUtils.as(JPAExpressions.select(memberSub.age.max()).from(memberSub), "age"))
            )
            .from(member)
            .fetch();

    for (UserDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 그러니까, DTO로 조회를 하긴 하는데, 나이에 해당하는 값은 서브쿼리를 통해 어떤 고정값을 넣고 싶은 경우다. 여기서 서브쿼리는 회원의 가장 높은 나이를 넣는 것이다. 
  • 이런 경우에는, ExpressionUtils.as(서브쿼리, "별칭") 이렇게 사용할 수 있다.
  • 여기서 ExpressionUtilscom.querydsl.core.types.ExpressionUtils이다.

 

 

3. 생성자 사용

@Test
void findDtoByConstructor() {
    List<MemberDto> results = queryFactory
            .select(Projections.constructor(MemberDto.class, member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 이것도 다른 점은 Projections.constructor(...) 를 사용했다는 점이다. 내가 만들어둔 username, age를 받는 생성자를 그대로 사용한다고 보면 된다. 
  • 생성자의 파라미터 순서대로 필드들을 넣어야 한다. 
  • 생성자를 사용하는 경우에는 별칭이 달라도 상관없다. 어차피 생성자가 있냐 없냐가 중요하기 때문이다. 

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

 

프로젝션과 결과 반환 - @QueryProjection

이 방법은 이제 궁극의 방법인데, 장점이 매우 많지만 단점도 없지는 않다. 일단 한번 보자.

 

조회하려는 DTO의 생성자에 아래와 같이 해보자.

package cwchoiit.querydsl.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • @QueryProjection 애노테이션을 사용해서, 원하는 생성자에 저 애노테이션을 붙여주면 Querydsl이 Q클래스를 만들때 이 DTO도 Q클래스를 만들어준다.

 

이러면 어떻게 사용할 수가 있냐? 다음 코드를 보자.

@Test
void findDtoByQueryProjection() {
    List<MemberDto> results = queryFactory
            .select(new QMemberDto(member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 위 코드처럼 SELECT절에 QMemberDto를 그대로 넣어버릴 수 있다. 저 위에 있는 순수 JPA를 사용해야 할 땐 패키지명까지 다 작성해야 했는데 이건 그럴 필요가 없다. 
  • 또 다른 장점으로는 컴파일 단계에서 오류를 알려준다. 즉, 저 생성자에 들어갈 필드를 이미 컴파일러가 알고 있다는 말이다. 아래 스크린샷을 보자.

  • 이미 지금 저 QMemberDto는 인자는 2개가 필요하고 어떤 타입이 와야 하는지까지 다 알고 있다. 이게 정말 궁극의 장점이라고 볼 수 있다. 이전까지 알아봤던 DTO로 조회는 그렇지가 않다. 순수 JPA를 사용해서 DTO로 조회하는 것을 제외하고는 위에서 봤던 세터를 사용한 방법, 필드에 직접 접근, 생성자를 사용하는 방법 모두 컴파일러는 뭐가 들어올지 모른다. 그래서 이것이 정말 큰 장점이라고 할 수 있다.

 

그러나, 단점도 없지는 않다. 어떤 단점이 있냐? 

  • DTO까지 Q클래스를 만들어야 한다는 것 자체가 단점이다.
  • DTO는 순수한 자바 코드가 아니라 Querydsl을 의존한다.

두번째 말은 이런 것이다. 

package cwchoiit.querydsl.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 지금 이 MemberDto는 이제 Querydsl을 의존하고 있다. import 부분을 잘 보면 무슨 말인지 알 수 있다.
  • 이것 자체가 큰 단점이다. 구조적인 부분에서 말이다.
  • 만약, Querydsl을 사용하지 않게 기술을 바꾼다면, 이 부분이 영향을 받게 될 것이다.

 

선택하자

그래서 여기서부터는 선택이다. 실용적 관점에서 그냥 @QueryProjection을 사용하던가, 순수한 자바 코드를 최대한 유지하고 싶으면 @QueryProjection을 사용하지 않고, 위에서 봤던 Projections.bean(...) 이나 Projections.fields(...), Projections.constructor(...)를 사용하면 된다. 정답은 없다. 프로젝트마다, 구성원마다 차이가 있을 것이고 그에 맞게 유동적으로 맞춰가면 된다.

 

 

동적 쿼리 - BooleanBuilder 사용

동적 쿼리를 해결하는 두가지 방식이 있는데 그 중 하나인 BooleanBuilder를 사용해보자.

@Test
void dynamicQuery_booleanBuilder() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    List<Member> results = searchMember1(usernameParam, ageParam);
    assertThat(results).hasSize(1);
}

private List<Member> searchMember1(String usernameParam, Integer ageParam) {

    BooleanBuilder builder = new BooleanBuilder();

    if (usernameParam != null) {
        builder.and(member.username.eq(usernameParam));
    }

    if (ageParam != null) {
        builder.and(member.age.eq(ageParam));
    }

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}
  • 위 코드가 BooleanBuilder를 사용하는 방법이다. 딱히 별 게 없다. 약간 JDBC 기술을 사용해서 동적 쿼리를 작성하는 것과 느낌이 비슷하긴 한데 훨씬 깔끔하다. 문자열이 없기 때문에.

 

동적 쿼리 - WHERE 다중 파라미터 사용

@Test
void dynamicQuery_whereParam() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    List<Member> results = searchMember2(usernameParam, ageParam);
    assertThat(results).hasSize(1);
}

private List<Member> searchMember2(String usernameParam, Integer ageParam) {
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameParam), ageEq(ageParam))
            .fetch();
}

private BooleanExpression ageEq(Integer ageParam) {
    return ageParam == null ? null : member.age.eq(ageParam);
}

private BooleanExpression usernameEq(String usernameParam) {
    return usernameParam == null ? null : member.username.eq(usernameParam);
}
  • 이게 이제 WHERE 다중 파라미터 사용 방법이다. 훨씬 더 깔끔하다.
  • 왜냐하면, 쿼리 자체의 가독성이 높아지기 때문이다. ageEq, usernameEq를 보지말고, 딱 쿼리자체만 보면 굳이 저 메서드를 추적하지 않아도 WHERE절에 usernameEq(...), ageEq(...)만 보더라도 어떤 조건인지 예측이 가능해진다.
  • 저렇게 WHERE절에 다중 파라미터로 만들면 파라미터에 null이 들어오는 경우 그냥 무시를 해버리기 때문에 동적 쿼리를 매우 깔끔하고 간단하게 만들 수 있다. 예를 들어, ageEq(...)null을 반환하면 WHERE절안에 null은 무시가 된다.
  • 그리고 또 장점은 재사용이 가능하다는 점이다. 분명히 회원의 유저명이 같은 조건같은 건 여기저기 사용될 가능성이 높다. 이게 메서드로 만든 이상 여기저기 가져다가 사용이 가능해진다.
  • 또 한가지 장점은  조합이 가능하다는 점이다. 이것도 엄청 강력한 장점인데 아래 코드를 보자.
private BooleanExpression ageAndUsernameEq(Integer ageParam, String usernameParam) {
    return ageEq(ageParam).and(usernameEq(usernameParam));
}
  • 저 두 메서드(조건) ageEq(...), usernameEq(...)는 메서드이고 타입이 BooleanExpression이기 때문에 두 개를 AND 또는 OR로 조합해서도 사용이 가능하다. 기가맥히다.
  • 물론, 이렇게 조합하는 경우, 앞 부분의 null 체크는 조심해야 한다. 왜냐하면 ageEq(...)null인 경우엔 이렇게 될 것 아닌가?null.and(...) 이러면 NullPointerException이기 때문에 이 부분만 조심하면 된다. 뭐 아래와 같이 작성하면 되겠지.
private BooleanExpression ageAndUsernameEq(Integer ageParam, String usernameParam) {
    if (ageParam == null) {
        return usernameEq(usernameParam);
    }
    return ageEq(ageParam).and(usernameEq(usernameParam));
}

 

나는 이 WHERE절에 다중 파라미터 방식을 너무나 선호한다.

 

수정, 삭제 벌크 연산

쿼리 한번으로 대량 데이터에 쓰기 작업을 하는 것을 말한다.

@Test
void bulkUpdate() {
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);
}
  • 쿼리 자체는 새로운 게 전혀 없다.
  • 그렇지만, 벌크 연산은 가장 중요한 부분이 벌크 연산을 하고 나면 반드시 영속성 컨텍스트를 flush(), clear() 해주는 게 그냥 무조건 좋다. 왜 그러냐면, 벌크 연산은 영속성 컨텍스트를 무시하고 바로 데이터베이스에 쿼리를 날리기 때문에 영향받는 레코드가 이미 영속성 컨텍스트에서 관리되고 있다면 이 관리되는 데이터는 벌크 연산을 날린 데이터랑 불일치가 일어난다. 그래서 원치 않는 결과를 마주할 수도 있으니 항상 벌크 연산은 flush(), clear()를 해준다고 그냥 머리에 박아두자.
@Test
void bulkUpdate() {
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);

    em.flush();
    em.clear();
}
  • 이런식으로 말이다.

 

그리고, 벌크 연산으로 자주 사용되는 특정 컬럼에 모두 같은 값을 더하거나 곱하는 경우도 보자.

@Test
void bulkAdd() {
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.add(1))
            .execute();

    assertThat(count).isEqualTo(4);

    em.flush();
    em.clear();

    List<Member> members = queryFactory
            .selectFrom(member)
            .fetch();

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}

@Test
void bulkMultiply() {
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.multiply(2))
            .execute();

    assertThat(count).isEqualTo(4);

    em.flush();
    em.clear();

    List<Member> members = queryFactory
            .selectFrom(member)
            .fetch();

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}
  • 간단하게 나이에 1을 모두 더하거나, 나이에 2를 모두 곱하거나에 대한 쿼리이다.

 

쿼리 한번으로 대량 데이터 삭제

@Test
void bulkDelete() {
    long count = queryFactory
            .delete(member)
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);

    em.flush();
    em.clear();

    List<Member> members = queryFactory.selectFrom(member).fetch();

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}
  • 이번엔 삭제를 벌크로 하는 방법에 대한 쿼리이다.

 

SQL Function 호출

@Test
void sqlFunction() {
    // member.username 에서 'member' 라는 단어를 'M' 으로 변경
    List<String> results = queryFactory
            .select(Expressions.stringTemplate(
                    "function('replace', {0}, {1}, {2})", member.username, "member", "M"))
            .from(member)
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}

실행 결과

2024-12-24T22:29:10.394+09:00 DEBUG 39987 --- [    Test worker] org.hibernate.SQL                        : 
select
    replace(m1_0.username, ?, ?) 
from
    member m1_0
    
result = M1
result = M2
result = M3
result = M4

 

 

이번에는 소문자로 변경하는 함수를 사용하는 쿼리를 보자.

@Test
void sqlFunction2() {
    /*List<String> results = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(Expressions.stringTemplate("function('lower', {0})", member.username)))
            .fetch();*/

    List<String> results = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(member.username.lower()))
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 주석 처리한 부분을 먼저보자. 이번에는 WHERE절에 함수를 사용했다. 간단하게 소문자로 변경하는 함수인데, 왜 주석처리했냐? 저런 ANSI 표준 함수들은 Querydsl이 상당 부분 다 내장하고 있어서 저렇게 어렵게 작성할 필요없이 그 바로 아래에 주석 처리하지 않은 부분처럼 할 수 있다.

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

이전 포스팅에서 Querydsl 세팅하는 방법과 왜 Querydsl을 사용해야 하는지를 이야기했다. 이제 천천히 하나씩 Querydsl을 사용해보면서 이게 얼마나 막강한 녀석인지 직접 체감해보자.

 

우선, 엔티티를 정의해야 한다. 참고로, 나는 JPAQuerydsl을 같이 사용한다. 그래서 JPA로 엔티티를 만들어내고 Querydsl을 곁들인다.

 

엔티티 정의

Member

package cwchoiit.querydsl.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username) {
        this(username, 0, null);
    }

    public Member(String username, int age) {
        this(username, age, null);
    }

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    private void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

Team

package cwchoiit.querydsl.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
    private final List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }
}
  • 간단하게 Team - Member 엔티티를 만들었다.
  • 당연히, 팀과 멤버는 1:N 관계이고 여기서는 다대일 양방향 연관관계로 만들었다.

 

Querydsl 맛보기

이제 테스트 코드로 간단한 Querydsl 코드를 작성하자.

QuerydslBasicTest

package cwchoiit.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import cwchoiit.querydsl.entity.Member;
import cwchoiit.querydsl.entity.Team;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static cwchoiit.querydsl.entity.QMember.member;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Transactional
public class QuerydslBasicTest {

    @Autowired
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void setUp() {
        queryFactory = new JPAQueryFactory(em);
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);
        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
    }

    @Test
    void querydsl() {
        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();

        assertThat(findMember).isNotNull();
        assertThat(findMember.getUsername()).isEqualTo("member1");
        assertThat(findMember.getAge()).isEqualTo(10);
    }
}
  • 우선, Querydsl을 사용하려면 JPAQueryFactory가 필요하다. 이 친구는 필드 레벨에 선언하는 게 가장 좋다. 어차피 멀티스레드 환경에서도 안전하게 동작하게 설계됐기 때문에 필드 레벨에 선언해도 아무런 문제도 없다.
  • 그리고 여기서는 각 테스트 별로 데이터가 준비될 수 있게 @BeforeEach 애노테이션으로 테스트 데이터를 만들어 낸다.
  • JPAQueryFactoryEntityManager가 필요하다. 그래서, 생성자로 전달하는 모습을 확인할 수 있다.
  • 실제로 Querydsl을 사용하는 코드를 중점적으로 봐보자.
@Test
void querydsl() {
    Member findMember = queryFactory
            .select(member)
            .from(member)
            .where(member.username.eq("member1"))
            .fetchOne();

    assertThat(findMember).isNotNull();
    assertThat(findMember.getUsername()).isEqualTo("member1");
    assertThat(findMember.getAge()).isEqualTo(10);
}
  • QMemberstatic-import를 하면 그 클래스 안에 필드로 선언된 `member`를 위 코드처럼 간단하고 명료하게 사용할 수 있다.
  • 그리고 코드를 보면, 자바 코드인데도 불구하고 SQL처럼 보여지는 이 가시성이 정말 막강한 장점이다. 동적 쿼리를 작성할때도 정말 강력한 것이 where(...)안에 필요한 조건문을 아주 간결하게 작성할 수 있다. 이후에 저 부분은 더 멋지게 변경될 것이다.

 

검색 조건 쿼리

아래 코드를 보자.

@Test
void search() {
    /*Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1").and(member.age.eq(10)))
            .fetchOne();*/

    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1"), member.age.eq(10))
            .fetchOne();

    assertThat(findMember).isNotNull();
    assertThat(findMember.getUsername()).isEqualTo("member1");
}
  • 주석 처리한 것과 주석 처리하지 않은 두 쿼리가 완전히 동일한 쿼리이다. and(...) 으로 메서드 체인형식으로 사용할 수도 있고, and의 경우에는 저렇게 (,)로 연결해도 동일한 결과이다.
  • 참고로, and가 있으면 당연히 or도 있다.
  • 그러 뭐가 더 좋냐? 뭐 사람마다 다르겠지만, 개인적으로는 (,) 방식을 더 선호한다. 근데 이건 뭐가 됐든 상관없다.

 

결과 조회

@Test
void resultFetch() {
    List<Member> members = queryFactory.selectFrom(member).fetch();

    Member findMember = queryFactory.selectFrom(member).fetchOne();

    // 아래와 동일한 코드 queryFactory.selectFrom(member).limit(1).fetchOne();
    Member findMemberFirst = queryFactory.selectFrom(member).fetchFirst();
}
  • fetch() → 리스트 조회, 데이터 없으면 빈 리스트 반환
  • fetchOne() → 단건 조회, 결과가 없으면 null, 결과가 둘 이상이면 NonUniqueResultException 발생
  • fetchFirst()limit(1).fetchOne()과 동일한 편의 메서드

 

정렬

/**
 * 회원 정렬
 * 회원 나이 내림차순
 * 회원 이름 올림차순
 * 단 2에서 회원 이름이 null 이면 마지막에 출력
 */
@Test
void sort() {
    Member memberNull = new Member(null, 100);
    Member member5 = new Member("member5", 100);
    Member member6 = new Member("member6", 100);
    em.persist(memberNull);
    em.persist(member5);
    em.persist(member6);

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.eq(100))
            .orderBy(member.age.desc(), member.username.asc().nullsLast())
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.getFirst().getUsername()).isEqualTo("member5");
    assertThat(members.get(1).getUsername()).isEqualTo("member6");
    assertThat(members.get(2).getUsername()).isNull();
}
  • 정렬도 Querydsl을 사용하면 여려 정렬을 한번에 할 수 있으며, 해당값이 null인 경우, 마지막에 위치시킬지 맨 처음에 위치시킬지를 정할 수 있다.
  • 마지막에 위치시키는 건 nullsLast(), 맨 처음에 위치시키는 건 nullsFirst()를 사용하면 된다.
  • 여기서는 nullsLast()를 사용했다.

 

페이징

@Test
void paging1() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);
    assertThat(members.getFirst().getUsername()).isEqualTo("member3");
    assertThat(members.get(1).getUsername()).isEqualTo("member2");
}

@Test
void paging2() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();

    int totalCount = queryFactory
            .selectFrom(member)
            .fetch()
            .size();

    assertThat(members).isNotNull();
    assertThat(totalCount).isEqualTo(4);
    assertThat(members.size()).isEqualTo(2);
    assertThat(members.getFirst().getUsername()).isEqualTo("member3");
    assertThat(members.get(1).getUsername()).isEqualTo("member2");
}
  • 페이징은 간단하게, offset(), limit()을 사용하면 된다. 
  • 그리고 전체 수를 가져오는 건 별도의 쿼리로 작성해줘야 한다. 예전에는 fetchResults()라는 것을 사용해서 전체 개수를 가져올 수 있었는데 그 메서드는 Deprecated 됐고, 애시당초에 그 메서드도 내부에서 전체 카운트를 가져오는 쿼리를 또 날리는 것 밖에 안된다.
  • 그래서 전체 개수를 가져오는 쿼리는 별도로 작성해서 위 paging2()처럼 가져온다. 보면 알겠지만, 전체 개수를 가져오는 쿼리는 굉장히 간단하고 실제 쿼리랑은 다르다. 지금이야 orderBy() 정도만 있고 없고의 차이지만 어떤것은 그 이상으로 전체 개수를 가져오는 쿼리가 최적화되기 때문에 이 부분은 직접 구현하도록 바뀌었다.

 

집합

집합 함수를 의미하고, SUM, COUNT, AVG, MAX, MIN, GROUP BY를 사용한다.

@Test
void aggregation() {
    List<Tuple> results = queryFactory
            .select(
                    member.count(),
                    member.age.sum(),
                    member.age.avg(),
                    member.age.min(),
                    member.age.max()
            )
            .from(member)
            .fetch();

    Tuple tuple = results.getFirst();

    assertThat(tuple.get(member.count())).isEqualTo(4);
    assertThat(tuple.get(member.age.sum())).isEqualTo(100);
    assertThat(tuple.get(member.age.avg())).isEqualTo(25);
    assertThat(tuple.get(member.age.min())).isEqualTo(10);
    assertThat(tuple.get(member.age.max())).isEqualTo(40);
}
  • COUNT, SUM, AVG, MIN, MAX도 굉장히 간단하게 그저 메서드를 호출하는 것으로 구현해낼 수 있다.
  • 그리고 타입이 가지각색이거나, 어떤 값으로 딱 추출을 해내는 경우엔 객체 타입이 아니라 Tuple 이라는 타입으로 반환받게 되는데 이 Tuple을 통해 값을 가져오는 건 그냥 get(...)으로 내가 원하는 값을 뽑아오면 된다. 
  • 참고로, 실무에서는 Tuple 타입으로 받아오는 것보다 DTO로 변환해서 가져오는 방법이 훨씬 더 많이 사용된다. 이후에 같이 알아보자.

GROUP BY도 간단하게 사용할 수 있다. 아래 코드를 보자.

/**
 * 팀의 이름과 각 팀의 평균 연령을 구해라.
 */
@Test
void group() {
    List<Tuple> results = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .fetch();

    Tuple teamA = results.getFirst();
    Tuple teamB = results.get(1);

    assertThat(teamA.get(team.name)).isEqualTo("teamA");
    assertThat(teamA.get(member.age.avg())).isEqualTo(15);

    assertThat(teamB.get(team.name)).isEqualTo("teamB");
    assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}

/**
 * 팀의 이름과 각 팀의 평균 연령을 구하고 팀의 이름이 teamA 인것만 가져와라.
 */
@Test
void groupByHaving() {
    List<Tuple> results = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .having(team.name.eq("teamA"))
            .fetch();

    Tuple teamA = results.getFirst();

    assertThat(results.size()).isEqualTo(1);
    assertThat(teamA.get(team.name)).isEqualTo("teamA");
    assertThat(teamA.get(member.age.avg())).isEqualTo(15);
}
  • GROUP BY, HAVING을 사용한 코드이다. 참고로 HAVINGGROUP BY로 그룹화 된 결과를 제한하는 것이다.

 

조인

INNER

@Test
void join() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);

    assertThat(members)
            .extracting(Member::getUsername)
            .containsExactly("member1", "member2");
}
  • 가장 기본이 되는 조인인 INNER 조인하는 방법이다. ON절은 어디있나요?에 대한 대답은 기본으로 ON절을 넣어준다. 이 테스트 코드를 실행했을 때 나가는 쿼리를 보면 바로 이해가 될 것이다.
  • 아 그리고 innerJoin() 메서드도 있는데 이게 join()과 동일하다.
2024-12-22T13:19:06.677+09:00 DEBUG 58042 --- [    Test worker] org.hibernate.SQL                        : 
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    t1_0.name=?

 

[LEFT|RIGHT] OUTER

@Test
void join() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();

    /*List<Member> members = queryFactory
            .selectFrom(member)
            .rightJoin(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();*/

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);

    assertThat(members)
            .extracting(Member::getUsername)
            .containsExactly("member1", "member2");
}
  • 외부 조인 역시 가능하다. leftJoin(), rightJoin()이 있다.

 

THETA

"막 조인"이라고 하는 세타 조인도 역시 가능하다. 

/**
 * 회원이 이름이 팀 이름과 같은 회원 조회
 */
@Test
void thetaJoin() {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));
    em.persist(new Member("teamC"));

    List<Member> members = queryFactory
            .select(member)
            .from(member, team)
            .where(member.username.eq(team.name))
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);
    assertThat(members).extracting(Member::getUsername).containsExactly("teamA", "teamB");
}
  • 억지스러운 예제이긴 하지만, 회원의 이름이 "teamA", "teamB", "teamC"라고 만들고 이 회원 이름과 팀 이름이 같은 회원들을 한번 조회해보고 싶은것이다. 원래 세타 조인이 이렇게 막 조인이다.
  • 세타 조인을 할땐 FROM절에 원하는 엔티티를 여러개 넣으면 된다.

 

ON

ON절을 활용한 조인은 다음 두가지 케이스에서 사용된다.

  • 조인 대상 필터링
  • 연관관계 없는 엔티티의 외부 조인

조인 대상 필터링
지금까지 join()을 사용하면서 on()을 사용하지 않으면 그냥 기본으로 조인 대상의 ID가 같은 것들을 넣어주곤 했다. 

이 경우에 더해서, 조인 대상을 필터링하고 싶을 때 추가적으로 ON절을 사용할 수가 있다.

/**
 * 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
 */
@Test
void on_filtering() {
    List<Tuple> results = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team)
            .on(team.name.eq("teamA"))
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}
  • 위 코드처럼 LEFT JOIN을 한다고 가정해보자. LEFT JOIN은 왼쪽 엔티티를 기준으로 오른쪽에 조인 대상이 없어도 결과로 출력이 된다. 그런데 그 조인 대상을 필터링을 하고 싶은 것이다. MEMBER.TEAM.ID = TEAM.ID 뿐 아니라, TEAM의 이름이 "teamA"인 애들만 조인하고 싶은 것이다.
  • 이렇게 되면 결과는 어떻게 될까? TEAM의 이름이 "teamA"인 녀석들은 조인 결과에서 팀까지 같이 출력이 되고, "teamA"가 아닌 녀석들은 조인 결과에서 팀은 빠지고 멤버만 남을 것이다. LEFT JOIN이니까.

실행 결과

result = [Member(id=1, username=member1, age=10), Team(id=1, name=teamA, members=[Member(id=1, username=member1, age=10), Member(id=2, username=member2, age=20)])]
result = [Member(id=2, username=member2, age=20), Team(id=1, name=teamA, members=[Member(id=1, username=member1, age=10), Member(id=2, username=member2, age=20)])]
result = [Member(id=3, username=member3, age=30), null]
result = [Member(id=4, username=member4, age=40), null]

 

나가는 쿼리

2024-12-22T13:36:36.154+09:00 DEBUG 58927 --- [    Test worker] org.hibernate.SQL                        : 
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
        and t1_0.name=?

 

그런데 만약, 외부 조인이 아니라 내부 조인을 사용하면 ON절을 사용하는 것 말고 WHERE를 사용해도 완전히 동일한 결과를 얻을 것이다. 왜냐하면, 내부 조인은 애시당초에 조인 결과에서 대상이 없는 녀석들은 제외시키기 때문에 조인 대상이 있는 녀석들만 결과로 출력될 것이고 거기서 팀 이름이 "teamA"인 녀석들만 간추리려면 그냥 WHERE절 사용하면 된다. 그러니까, 내부 조인인데 굳이 ON절로 안 익숙한 것을 사용하지 말고 내부조인인 경우에는 WHERE가 더 익숙하니 이걸 사용하면 좋다는 말이다.

 

연관관계 없는 엔티티 외부 조인

/**
 * 연관 관계가 없는 엔티티 외부 조인
 * 회원의 이름이 팀 이름과 같은 대상 외부 조인
 */
@Test
void thetaJoin_on() {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));
    em.persist(new Member("teamC"));

    List<Tuple> results = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(team)
            .on(member.username.eq(team.name))
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}
  • 세타 조인은 아닌데, 외부 조인을 하고 싶은데 외부 조인을 할 때 연관관계가 없는 엔티티와 외부 조인을 하려고 할 때 이 ON을 사용할수가 있다. 
  • 위 코드를 보면, 지금 leftJoin()을 사용하는데 매우 주의 깊게 봐야한다! 원래는 leftJoin(member.team, team)이렇게 사용하곤 했는데 여기서는 leftJoin(team)을 사용한다. 즉, FROM절의 member와 아무런 연관이 없는 그냥 팀을 다 가져오는데 조인 조건으로 ON을 사용해서 회원이 이름이 팀의 이름과 동일한 조건을 부여했다.
  • 세타 조인을 할 땐 FROM절에 세타 조인하고 싶은 엔티티들을 여러개 넣었는데 이건 그게 아니다. 즉, 외부 조인인데 연관관계가 없는 녀석들과 외부 조인을 하려고 하는 것이다.

실행 결과

result = [Member(id=1, username=member1, age=10), null]
result = [Member(id=2, username=member2, age=20), null]
result = [Member(id=3, username=member3, age=30), null]
result = [Member(id=4, username=member4, age=40), null]
result = [Member(id=5, username=teamA, age=0), Team(id=1, name=teamA, members=[Member(id=1, username=member1, age=10), Member(id=2, username=member2, age=20)])]
result = [Member(id=6, username=teamB, age=0), Team(id=2, name=teamB, members=[Member(id=3, username=member3, age=30), Member(id=4, username=member4, age=40)])]
result = [Member(id=7, username=teamC, age=0), null]
  • 외부 조인이니까 조인 대상이 없어도 결과로 출력된다. 단지, 조인 대상이 없는 것들은 null로 표시될 뿐.

나가는 쿼리

2024-12-22T13:58:35.827+09:00 DEBUG 60017 --- [    Test worker] org.hibernate.SQL                        : 
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on m1_0.username=t1_0.name
  • ON절에 외래키와 PK가 같은 것들을 고려하지 않고 있다. 외부 조인인데 연관관계가 없음을 의미한다.
  • 세타 조인은 FROM절에 여러개가 들어가는 것이다.
  • 물론 외부 조인 말고 내부 조인도 가능하다. 근데 내부조인은 조인 대상이 없으면 결과로 나오지 않기 때문에 그냥 세타 조인을 사용하고 WHERE로 필터링한 결과랑 동일하니까 그 방법을 사용하면 된다.

 

페치 조인

페치 조인은 SQL에서 제공하는 기능이 아니고, SQL 조인을 활용하고 연관된 엔티티를 SQL 한번에 조회하는 기능이다. JPA에서 제공하는 기능으로써 주로 성능 최적화에 사용하는 방법이다. 

@Test
void fetchJoin() {

    Member findMember = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .fetchJoin()
            .where(member.username.eq("member1"))
            .fetchOne();

    assertThat(findMember).isNotNull();
    assertThat(findMember.getUsername()).isEqualTo("member1");
    assertThat(findMember.getTeam().getName()).isEqualTo("teamA");
}
  • Querydsl을 사용해서 페치 조인을 하는 방법은 너무나 간단하다. join() 이후에 fetchJoin()만 붙여주면 끝난다.
  • leftJoin()이든 뭐든 상관없다.

 

서브 쿼리

당연하게도 서브 쿼리 역시 지원한다. 하나씩 천천히 보자. QueryDsl을 사용할때 서브 쿼리를 사용하려면 com.querydsl.jpa.JPAExpressions를 사용해야 한다. 

/**
 * SubQuery - 나이가 가장 많은 회원 조회 (EQ)
 */
@Test
void subQuery() {

    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    JPAExpressions
                            .select(memberSub.age.max())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(40);
}
  • WHERE절에 서브쿼리를 사용한 모습이다. 쿼리 자체는 뭐 별게 없는데 이렇게 사용하면 된다는 것을 보여주기 위해 작성했다.
  • 당연하지만, 서브 쿼리에서 엔티티에 대한 Alias는 다른 녀석을 사용해야 한다. SQL과 똑같이 생각하면 된다. 그래서 이 경우에는 Q타입 객체를 새로 생성해야만 한다.
  • 참고로, 저 JPAExpressionsstatic-import를 하면 좀 더 깔끔하다. 아래와 같이 말이다.
/**
 * SubQuery - 나이가 동일한 회원 (EQ)
 */
@Test
void subQuery() {

    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    select(memberSub.age.max())
                    .from(memberSub)
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(40);
}

 

EQ말고, GOE, INSELECT절에서도 사용하는 예제들도 있다.

/**
 * SubQuery - 나이가 평균 이상인 회원 (GOE)
 */
@Test
void suqQuery2() {
    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.goe(
                    select(memberSub.age.avg())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(30, 40);
}

/**
 * SubQuery - IN절 사용
 */
@Test
void subQuery3() {
    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.in(
                    select(memberSub.age)
                            .from(memberSub)
                            .where(memberSub.age.gt(10))
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(20, 30, 40);
}

/**
 * SubQuery - SELECT절
 */
@Test
void subQuery4() {
    QMember memberSub = new QMember("memberSub");

    List<Tuple> results = queryFactory
            .select(
                    member.username,
                    select(memberSub.age.avg())
                            .from(memberSub))
            .from(member)
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}

 

FROM절에서 서브쿼리는 불가능하다. 이유는 JPQLFROM절의 서브쿼리를 지원하지 않기 때문. 그리고 사실 FROM 절의 서브쿼리를 꼭 사용해야 할 필요가 없다. 거의 대부분은 JOIN으로 해결이 가능하다. 물론 모든게 다 가능하지는 않다. 그러나 간과하면 안될 것이 있다. 이게 데이터베이스에서 데이터를 가져오는 건 정말 데이터를 가져오는 것이지 데이터를 정제하고 분리하는 건 데이터베이스 레벨에서만 해야하는 게 아니다. 애플리케이션 레벨에서 충분히 가능하다. 만약 애플리케이션 레벨에서 죽어도 하기 싫다면 그냥 네이티브쿼리를 날리면 된다.

 

Case

@Test
void basicCase() {
    List<String> results = queryFactory
            .select(member.age
                    .when(10).then("열살")
                    .when(20).then("스무살")
                    .otherwise("기타")
            )
            .from(member)
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}

@Test
void complexCase() {
    List<String> results = queryFactory
            .select(new CaseBuilder()
                    .when(member.age.between(0, 20)).then("0-20살")
                    .when(member.age.between(21, 30)).then("21-30살")
                    .otherwise("기타")
            )
            .from(member)
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 아주 단순한 경우에는 그냥 바로 when().then()을 사용하면 된다. 그러나 조금 더 복잡한 경우에는 CaseBuilder()를 사용할 수도 있다.

나는 개인적으로 Case문을 좋아하지 않는다. 가끔 효율적일 때가 있겠지만, 100% 데이터를 퍼올려서 애플리케이션 레벨에서 이 과정을 대체할 수 있기 때문이다. 데이터베이스에서 굳이 이 계산을 할 필요가 없다고 생각한다. 

 

상수, 문자 더하기

이 경우는 꽤나 자주 사용된다.

@Test
void constant() {
    List<Tuple> results = queryFactory
            .select(member.username, Expressions.constant("A"))
            .from(member)
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}

@Test
void concat() {
    // {username}_{age}
    List<String> results = queryFactory
            .select(member.username.concat("_").concat(member.age.stringValue()))
            .from(member)
            .where(member.username.eq("member1"))
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 상수가 필요하면, Expressions.constant("xxx") 이와 같이 사용하면 된다.
  • 그러면, SELECT절에 동일한 상수를 넣어서 다음과 같이 결과가 출력된다.
result = [member1, A]
result = [member2, A]
result = [member3, A]
result = [member4, A]
  • 이제 문자를 더하는 경우도 있는데, 같은 문자면 상관이 없는데 만약 문자와 숫자라면 자바는 타입에 매우 민감한 언어이기 때문에 단순히 더하는걸로 안되고 위 코드처럼 stringValue()로 형변환을 해줘야 한다.
result = member1_10

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

참고자료

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

스프링 데이터 JPA 구현체 분석

그래서 스프링 데이터 JPA 공통 인터페이스의 구현체는 어떻게 생겨먹었을까? 그 부분을 파헤쳐보자!

스프링 데이터 JPA가 제공하는 공통 인터페이스인 JpaRepository를 찾아가보면 이렇게 되어 있다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.data.jpa.repository;

import java.util.List;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.ListPagingAndSortingRepository;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.query.QueryByExampleExecutor;

@NoRepositoryBean
public interface JpaRepository<T, ID> extends ListCrudRepository<T, ID>, ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    void flush();

    <S extends T> S saveAndFlush(S entity);

    <S extends T> List<S> saveAllAndFlush(Iterable<S> entities);

    /** @deprecated */
    @Deprecated
    default void deleteInBatch(Iterable<T> entities) {
        this.deleteAllInBatch(entities);
    }

    void deleteAllInBatch(Iterable<T> entities);

    void deleteAllByIdInBatch(Iterable<ID> ids);

    void deleteAllInBatch();

    /** @deprecated */
    @Deprecated
    T getOne(ID id);

    /** @deprecated */
    @Deprecated
    T getById(ID id);

    T getReferenceById(ID id);

    <S extends T> List<S> findAll(Example<S> example);

    <S extends T> List<S> findAll(Example<S> example, Sort sort);
}
  • 이 녀석은 그냥 인터페이스고 실제 이 인터페이스를 구현한 구현체가 있다. 물론, 스프링 데이터 JPA가 구현체를 미리 만들어서 제공해준다. 
  • 그 구현체의 이름은 SimpleJpaRepository이다.
package org.springframework.data.jpa.repository.support;

import ...

@Repository
@Transactional(
    readOnly = true
)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {...}
  • 클래스가 굉장히 크기 때문에 여기다 그 내용을 다 적을 순 없고 직접 들어가서 확인해 보길 바란다.
  • 확인해보면 정말 별 게 없다. 우리가 다 이전에 순수 JPA로 해봤던 코드들이 그대로 여기에도 적용되어 있다.
  • 이런것을 보면, 잘 만든 라이브러리의 주인이 우리가 될 수도 있다는 가슴 설레는 기분이 든다.
  • 그리고 이렇게 스프링 데이터 JPA가 만들어주는 구현체에 이미 @Repository, @Transactional(readOnly = true)가 들어있기 때문에 JpaRepository를 상속받는 인터페이스를 우리가 만들때 @Repository를 굳이 붙이지 않아도 됐던 것이고, 서비스에 @Transactional을 걸지 않아도 스프링 데이터 JPA를 사용해서 공통 인터페이스를 사용하면 쓰기 작업도 원활히 됐던 것이다.  물론 이 내용은 모두 스프링 데이터 JPA를 사용한다는 가정하에 말이다.

예시로 delete()를 보면 이렇게 생겼다.

@Transactional
public void delete(T entity) {
    Assert.notNull(entity, "Entity must not be null");
    if (!this.entityInformation.isNew(entity)) {
        if (this.entityManager.contains(entity)) {
            this.entityManager.remove(entity);
        } else {
            Class<?> type = ProxyUtils.getUserClass(entity);
            T existing = (T)this.entityManager.find(type, this.entityInformation.getId(entity));
            if (existing != null) {
                this.entityManager.remove(this.entityManager.merge(entity));
            }

        }
    }
}
  • 뭐 딱히 다른게 없다! 결국은 EntityManager.remove() 호출하는 것이다.

 

자자, 그리고 변경 감지를 사용해야 한다는 말을 하면서 데이터를 저장하는게 아니라 변경 시에는 save()를 호출하는 게 아니라고 여러번 얘기했는데 이 save() 코드도 보자. 진짜 뭐가 없다.

@Transactional
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null");
    if (this.entityInformation.isNew(entity)) {
        this.entityManager.persist(entity);
        return entity;
    } else {
        return (S)this.entityManager.merge(entity);
    }
}
  • 자, 엔티티가 새로운 엔티티라면 persist()를 호출하고 그렇지 않다면(데이터를 변경하는 거라면) merge()를 호출하고 있다. 이렇기에 데이터를 변경 시 save()를 호출하는 게 아니라 변경감지를 사용해야 한다고 말하는 것이다. 변경감지를 사용해야 하는 이유는 크게 3가지가 있다.
  1. 어차피 merge()도 결국엔 영속성 컨텍스트에 올려서 데이터를 변경한 다음 변경감지로 데이터를 바꾼다.
  2. 위 1번에 따르면, merge()를 호출하면 영속성 컨텍스트에 올린다고 했는데 그럼 데이터베이스에서 해당 데이터를 조회하는 과정이 일어나는 것 아닌가? 맞다. 데이터베이스에 이 변경하려는 데이터의 레코드가 있는지 확인하기 위해 조회 쿼리가 나간다. 
  3. 조회한 데이터에 전달한 변경할 데이터를 담은 엔티티의 모든 값으로 덮어씌운다. null이 있었다면? null로 덮어씌운다.

 

데이터를 변경할 때 save() 호출하지 말자. 절대!! 

 

새로운 엔티티를 구별하는 방법

바로 위에서 본 save() 메서드에서, 새로운 엔티티라면 persist()를 호출하고, 그게 아니라면 (즉, 데이터베이스에 이미 저장된 데이터라면) merge()를 호출하는 것을 보았다. 그런데 말이다. 새로운 엔티티인지 어떻게 판단할까?

 

이 내용이 꽤나 중요하기 때문에 집중해야 한다. 우선 결론부터 말하면 이렇다.

 

새로운 엔티티를 판단하는 기본 전략

  • 식별자(PK)가 객체(Long과 같은)타입일 때 null이라면 새로운 엔티티라고 판단
  • 식별자(PK)가 자바 기본 타입(long)일 때 0이라면 새로운 엔티티라고 판단

이게 무슨말인지 코드를 통해서 조금 더 자세히 알아보자.

 

Item

package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

@Entity
public class Item {
    
    @Id
    @GeneratedValue
    private Long id;
}
  • 아주 간단한 엔티티 하나를 만든다. 식별자 필드 딱 하나만 있으면 된다. 
  • 주의 깊게 볼 부분은 @GeneratedValueLong 타입이다.
  • @GeneratedValue는 사용하는 데이터베이스에게 PK 생성을 위임하는 방식인데 이렇게 하면 EntityManager.persist()를 호출했을 때는 아직 데이터베이스에 저장하기 전이기 때문에 이 Id값이 없다. 즉 null이다.

 

그리고, 테스트 코드를 작성해서 save()를 호출해보자!

ItemTest

package cwchoiit.datajpa.entity;

import cwchoiit.datajpa.repository.springdatajpa.ItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ItemTest {

    @Autowired
    private ItemRepository itemRepository;

    @Test
    void isNew() {
        itemRepository.save(new Item());
    }
}
  • 이렇게 했을 때 아까 위에서 본 SimpleJpaRepositorysave()는 어떻게 동작할까?
  • 한번 디버깅을 해보자.

  • isNew(entity)는 어떤 판단을 할까?

  • 우선 브레이크 포인트에 걸렸을 때는 다음과 같이 당연히 id값은 null이다. 아직 데이터베이스에 저장하기 전이니까 말이다.

  • 그래서 persist()를 호출하는 코드를 그대로 수행하게 된다.
  • 이처럼, 식별자가 객체(Long)일 때는 null인 경우 새로운 엔티티라고 판단한다.
  • 그러면 자바 기본 타입인 long같은 경우 값을 세팅 안하면 0이 기본값이라서 0일때 새로운 엔티티라고 판단한다고 했던것이다.

 

아니, 근데 이건 너무 쉬운데 왜 이 내용이 중요하다고 했을까? 지금의 경우는 식별자를 @GeneratedValue로 설정했을 때 이야기다. 만약, 이 방식을 사용하지 않고 개발자가 직접 아이디를 세팅한다고 한다면 어떻게 될까? 그러니까 엔티티를 다음과 같이 작성하는 것이다.

Item

package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {

    @Id
    private Long id;
}
  • @Id만 사용해서 직접 식별자 값을 할당하는 경우에는 당연히 이미 식별자 값이 있는 상태로 save()를 호출한다. 아래 코드처럼 말이다.
@Test
void isNew() {
    itemRepository.save(new Item(1L));
}
  • 와.. 이러면 객체값인데 null이 아니게 된다. 어떻게 될까? 디버깅 해서 다시 한번 돌려보자.

  • 디버깅해서 직접 확인해보면 알겠지만, merge(entity)로 가버린다. 
  • merge()를 호출한다는 건 일단 무조건 데이터베이스에 해당 값이 있다고 간주한다. 그렇기에 merge()는 무조건 이 값을 가져오기 위해 데이터베이스에 조회를 하게 된다. 실제로 그런지는 쿼리 나가는 것을 보면 알 수 있다.

  • SELECT 쿼리가 보이는가? SELECT를 했는데 해당 값이 없으니까, "아 뭐야? 없잖아?" 하고 새로운 엔티티라고 판단을 여기서나마 해서 INSERT를 하게 된다.
  • 이게 어떤 문제냐? 당연히 너무나 비효율적이다. 이러면 이제 모든 새 엔티티를 만들때마다 이 조회 쿼리가 무의미하게 나가는데 이 조회라는 건 꽤나 성능에 영향을 끼치는 작업이다. 
  • 이건 논외지만 변경을 할때 무조건 변경 감지를 사용하자. 보다시피 merge()는 해당 데이터를 데이터베이스에서 조회하는 작업을 무조건 거치게 된다. 

 

다시 주제로 돌아와서, 이 경우에 그러면 어떻게 하면 될까? Persistable 인터페이스를 구현해서 판단 로직을 변경할 수 있는 기능을 제공한다.

Persistable 구현

package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Persistable;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<Long> {

    @Id
    private Long id;
    
    @Override
    public Long getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return false;
    }
}
  • Persistable<PK의 타입>을 구현하면, 다음 두가지 메서드를 구현해야 한다. getId(), isNew().
  • 여기서 isNew를 직접 구현해서 새로운 엔티티에 대한 판단 로직을 작성하면 되는데 어떻게 하면 좋을까?
  • 아래와 같은 기가막힌 방법이 있다.
package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Persistable;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item extends BaseEntity implements Persistable<Long>{

    @Id
    private Long id;

    @Override
    public Long getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return getCreatedDate() == null;
    }
}
  • Auditing에 대해 공부할 때 BaseEntity를 만들고 그 안에 createdDate 필드를 직접 만들어 넣는게 아니라 스프링 데이터 JPAAuditing 기능을 사용해서 자동으로 세팅하게 설정했다.
  • 결국 새로운 엔티티인 경우엔 이 createdDate값이 null이고 새로운 엔티티가 아니라면 이 값은 무조건 null이 아니게 된다.
  • 이 필드의 값을 사용해서 @GeneratedValue를 사용하지 않았을 때도 정상적으로 새 엔티티를 판단해서 merge()를 호출하는 무식한 짓을 하지 않아도 된다.
  • 테스트 코드로 이제 다시 테스트 해보자.

  • 이제는 persist(entity)를 호출하는 라인으로 넘어가는 것을 볼 수 있다.

 

728x90
반응형
LIST

'Spring Data JPA' 카테고리의 다른 글

Spring Data JPA (2)  (0) 2024.12.12
Spring Data JPA (1)  (0) 2024.12.12
728x90
반응형
SMALL

참고자료

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

이전 포스팅에서, 그냥 메서드 시그니처만 만들어도 자동으로 스프링 데이터 JPA가 쿼리를 만들어주는 놀라운 기능을 제공한다고 했었다! 이게 사실일까?!

 

쿼리 메서드

이 기능을 쿼리 메서드라고 표현한다. 순수 JPA를 사용해서 만약 유저를 찾는데, 특정 유저이름 조건과 나이 조건을 부합하는 유저를 찾는 쿼리를 순수 JPA로 만든다고 해보자.

public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
    return em.createQuery("SELECT m FROM Member m WHERE m.username = :username AND m.age > :age", Member.class)
            .setParameter("username", username)
            .setParameter("age", age)
            .getResultList();
}
  • 그럼 이런식으로 직접 JPQL을 작성해야 한다. 물론 어려운게 아니다. 귀찮은 거지.

 

그런데, 저 코드를 그냥 메서드 시그니처 하나로 알아서 만들어준다면? 개발자들의 개발자 경험은 기가막히게 올라갈 것이다. 

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 이렇게 메서드 시그니처 하나만 만들어주면 끝이다. 물론, 규칙이 있는데 엔티티 필드명을 완벽하게 그대로 작성해야 하고, findBy..., GreaterThan 등 스프링 데이터 JPA가 정한 규칙을 따라 만들어야 한다.
  • 실제로 테스트 코드를 돌려보면 정상적으로 수행된다.
@Test
void findByUsernameAndAgeGreaterThan() {
    Member member1 = Member.builder()
            .username("memberA")
            .age(20)
            .build();

    memberRepository.save(member1);

    List<Member> result = memberRepository.findByUsernameAndAgeGreaterThan("memberA", 10);

    assertThat(result.size()).isEqualTo(1);
}

실행 결과

 

그럼 시그니처 어떻게 작성해야 하나?에 대한 대답은 당연히 공식문서를 참고하면 된다. 딱히 어렵지도 않다. 

 

JPA Query Methods :: Spring Data JPA

By default, Spring Data JPA uses position-based parameter binding, as described in all the preceding examples. This makes query methods a little error-prone when refactoring regarding the parameter position. To solve this issue, you can use @Param annotati

docs.spring.io

 

그런데, 이 방식의 가장 큰 문제점 중 하나는 여기서 조건이 한 두개 정도만 더 추가되도 메서드 이름이 너무 길어진다는 것이다. 생각해보자. 만약 나이 조건과 이름 조건 말고 팀 조건도 있고 등급 조건도 있으면 아마 이렇게 생겨먹을 것이다.

List<Member> findByUsernameAndAgeGreaterThanAndFindByTeamEqualsAndFindByGradeEquals(String username, int age, Team team, Grade grade);
  • 으악이다. 이걸 누가 보고 좋다 할 수 있겠나? 
  • 대신, 조건 2개 정도안에서는 정말 정말 좋은 기능이다. 
  • 그럼 이런 경우엔 어떻게 해결하면 될까? 직접 JPQL을 사용할 수 있게도 지원해준다.

 

JPA NamedQuery

이 방법은 위의 문제를 해결해주지만 결론부터 말하면 이 기능은 거의 사용하지 않으니 그냥 이런게 NamedQuery구나? 하고 넘어가면 된다. 바로 아래 코드를 보자.

 

Member 엔티티

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedQuery(
        name = "Member.findByUsername",
        query = "SELECT m FROM Member m WHERE m.username = :username"
)
public class Member {...}
  • 엔티티를 정의한 클래스에 @NamedQuery 라는 애노테이션을 달고, 거기에 쿼리에 대한 명칭과 쿼리를 직접 작성해줄 수 있다.
  • 쿼리에 명칭을 부여한다고 해서 NamedQuery이다.
  • 그리고 이걸 어떻게 사용하면 되냐? 바로 다음 코드를 보자.

 

우선, 순수 JPA만을 사용했을 때 방법이다. 순수 JPA 레포지토리에서 다음과 같이 작성할 수 있다.

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;

  	...

    public List<Member> findByUsername(String username) {
        return em.createNamedQuery("Member.findByUsername", Member.class)
                .setParameter("username", username)
                .getResultList();
    }
}
  • em.createNamedQuery() 라는 메서드가 있는데 이게 바로 아까 만든 NamedQuery의 이름을 가져다가 사용하는 방법이다.
  • 그런데 생각해보면, 굳이 이걸 왜써? 라는 생각이 드는게 그냥 JPQL을 바로 직접 사용하면 되는것 아닌가? 순수 JPA는 그게 맞다. 그런데 스프링 데이터 JPA는 좀 편리하게 사용할 수가 있다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query(name = "Member.findByUsername")
    Optional<Member> findByUsername(@Param("username") String username);

    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 이게 스프링 데이터 JPANamedQuery를 사용하는 방식이다. @Query(name = "...") 이렇게 내가 지정한 이름으로 name 값을 지정해주면 끝난다.
  • 그리고, NamedQuery를 사용할 때 파라미터가 필요한 경우에는 @Param(...) 애노테이션을 달아주면 된다.
  • 그런데, 여기서 한 발 더 나아갈 수가 있다!
package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    // @Query(name = "Member.findByUsername")
    Optional<Member> findByUsername(@Param("username") String username);

    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • @Query(name = "...")이 없어도 동작한다! 왜 그럴까? 스프링 데이터 JPA가 규칙이 있는데 이게 NamedQuery인지를 먼저 확인하기 위해 "엔티티.메서드명"으로 된 NamedQuery를 찾는게 첫번째 하는 작업이다.
  • 그래서 Member.findByUsername 으로 된 NamedQuery를 찾는데 아까 내가 만들었으니 그걸 그대로 가져다가 적용하는 것이다.
  • 만약, 찾았는데 없다면 위에서 배운 쿼리 메서드를 만드려고 시도한다.

 

그러나, 이 방법은 깊이 있게 알 필요가 없다. 왜냐하면 이 NamedQuery는 너무 불편해서 거의 사용하지 않는다. 왜 불편하냐면 이 다음에 말할 최고의 막강한 기능이 있다. 그걸 다 사용하기 때문에 이건 거의 사용하지 않는다. 그래도 이 NamedQuery의 좋은 점도 있다. 좋은 점은 쿼리에 문제가 있으면 컴파일 오류를 뱉어준다는 점이다. 다음 코드를 보자.

 

순수 JPA로 만든 레포지토리를 보면, 이 JPQL은 결국 다 문자열이다.

  • 이건 다 문자열이기 때문에 저 문자열로 만들어진 JPQL에 문법적 오류가 있어도 컴파일 시점에 그것을 확인할 수가 없다. 그래서 컴파일 시 문제가 안 생기는데 만약, 유저가 이 쿼리를 호출해야 하는 어떤 작업을 하는 순간 빵! 하고 런타임 에러가 발생하겠지.
  • 그건 좋지 않다. 

그런데, 이 NamedQuery는 문자열이긴 한데 컴파일 시점에 문법 오류를 잡아준다! 다음 코드를 보자!

  • 저기 보면, `m.us123123ername`으로 오타가 발생했다. 이 상태에서 컴파일 하면 컴파일 오류가 난다!

 

NamedQuery는 이런 장점이 있기도 하다. 그런데 결국 안쓴다. 왜냐? 다음에 배울 녀석은 이 장점을 그대로 가짐과 동시에 훨씬 더 편하게 사용할 수 있다!

 

@Query, 레포지토리 메소드에 쿼리 직접 정의

이 방법이 최고의 방법이다. 바로 코드로 보자.

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    Optional<Member> findByUsername(@Param("username") String username);

    @Query("SELECT m " +
            "FROM Member m " +
            "WHERE m.username = :username AND m.age > :age")
    List<Member> findByUsernameAndAgeGreaterThan(@Param("username") String username, @Param("age") int age);
}
  • 이렇게 @Query로 바로 JPQL을 작성할 수 있다. 얼마나 편리한가?
  • 그리고 이 기능 역시 컴파일 시 문법 오류를 잡아준다.

  • 보다시피, `m.user123123name` 이라고 잘못 입력했을 때 이걸 컴파일 하면 바로 아래와 같이 컴파일 오류를 뱉어낸다.

그래서 쿼리 메서드를 사용하는데 조건이 여러개가 늘어나는 경우에는 이 기능을 잘 사용하기를 권장한다!

 

그리고, 이 @Query를 사용하면서, 단순히 값 하나만 조회하거나 DTO로 변환하여 조회할 수도 있다.

 

단순히 값 하나만을 조회

@Query("SELECT m.username FROM Member m")
List<String> findUsernames();
  • m.username처럼 원하는 값 하나를 SELECT절을 채우면 끝이다.

DTO로 조회

우선, DTO로 조회하려면 DTO가 당연히 있어야 하겠지. DTO를 아래와 같이 만들었다.

 

MemberDto

package cwchoiit.datajpa.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberDto {

    private String username;
    private String teamName;
    private int age;

}

 

아래와 같이 DTO로 조회하면 된다. 순수 JPA에서 했던 방식이랑 동일하다. `new` 키워드를 사용해서 패키지명까지 다 작성하는 방식.

@Query("SELECT new cwchoiit.datajpa.dto.MemberDto(m.username, t.name, m.age) FROM Member m JOIN m.team t")
List<MemberDto> findMembersWithDto();

 

테스트 코드로 테스트 해보자.

@Test
void findMemberWithTeam() {

    Team teamA = Team.builder()
            .name("teamA")
            .build();

    teamRepository.save(teamA);

    Member member1 = Member.builder()
            .username("memberA")
            .age(20)
            .team(teamA)
            .build();

    memberRepository.save(member1);

    List<MemberDto> membersWithDto = memberRepository.findMembersWithDto();
    for (MemberDto memberDto : membersWithDto) {
        System.out.println("memberDto = " + memberDto);
    }
}
  • 테스트 코드로 테스트를 해보면, 다음과 같이 잘 나오는 것을 확인할 수 있다.

실행 결과

 

 

파라미터 바인딩

파라미터 바인딩은 위에서 이미 본 내용이다. 근데 위치 기반과 이름 기반이 있는데 위치 기반같은 싯 코드는 작성하지 말자! 위에서 작성한대로 이름기반으로 파라미터를 바인딩하면 된다. 그런데 컬렉션 파라미터를 바인딩할 수도 있다!

컬렉션 파라미터 바인딩

@Query("SELECT m FROM Member m WHERE m.username IN :usernames")
List<Member> findByUsernames(@Param("usernames") List<String> usernames);
  • 이렇게 IN절을 사용할 때 리스트를 넘길수가 있다. 그럼 리스트를 괄호로 예쁘게 아주 잘 바꿔서 알아서 다 해준다.

테스트 코드로 확인해보자!

@Test
void findByUsernames() {
    Member member1 = Member.builder()
            .username("memberA")
            .age(20)
            .build();

    Member member2 = Member.builder()
            .username("memberB")
            .age(20)
            .build();

    memberRepository.save(member1);
    memberRepository.save(member2);

    List<String> usernames = new ArrayList<>(List.of("memberA", "memberB"));

    List<Member> byUsernames = memberRepository.findByUsernames(usernames);

    for (Member byUsername : byUsernames) {
        System.out.println("byUsername = " + byUsername);
    }
}

실행 결과

byUsername = Member(id=1, username=memberA, age=20)
byUsername = Member(id=2, username=memberB, age=20)

 

 

반환 타입

스프링 데이터 JPA는 유연한 반환 타입을 지원한다.

List<Member> findByUsername(String name); //컬렉션 
Member findByUsername(String name); //단건
Optional<Member> findByUsername(String name); //단건 Optional

 

이렇게 여러 반환 타입을 지원하는데 이때 고민해볼 거리가 있다.

 

조회 결과가 많거나 없으면?

  • 컬렉션 반환 타입
    • 결과 없는 경우 → 빈 컬렉션 반환
  • 단건 조회
    • 결과 없는 경우 → null 반환
    • 결과가 2건 이상인 경우 → NonUniqueResultException 발생

근데 이게 순수 JPA를 사용하는 경우 단건 조회 시 결과가 없으면 NoResultException을 발생시킨다. 그런데 스프링 데이터 JPA는 결과가 없다고 에러를 발생시키는 게 맞아? 하면서 이 경우 자기들이 내부적으로 try - catch로 이 예외를 잡아서 null을 반환해버리게 만들어줬다. 그래서 이것에 대해 갑론을박이 있다. 내 개인적인 생각으로도 예외보다는 null이 맞다고 보는데 이게 자바8 이전에 있던 갑론을박이고 자바8 이후로 뭐가 나왔냐?! Optional 이라는 아주 기특한 녀석이 나왔기 때문에 단건 조회는 그냥 무조건 Optional을 사용하는 게 좋다.

 

그리고, 단건 반환 타입인데 결과가 2건 이상인 경우 스프링 데이터 JPA고 순수 JPA고 그냥 무조건 NonUniqueResultException이 발생한다. 

참고로, 이 뿐만 아니라 여러 반환 타입이 있다. 이는 공식 문서를 참고해보면 되는데 가장 많이 사용되는 타입이 저 세가지라고 보면 된다.

 

순수 JPA 페이징과 정렬

JPA에서는 페이징을 어떻게 할까? JPA는 페이징 처리를 굉장히 편하게 할 수가 있는데, 우선 순수 JPA로 페이징하는 방법을 보고 스프링 데이터 JPA가 페이징을 어떻게 하는지도 보자!

public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery("SELECT m FROM Member m WHERE m.age = :age ORDER BY m.username DESC", Member.class)
            .setParameter("age", age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

public long totalCount(int age) {
    return em.createQuery("SELECT COUNT(m) FROM Member m WHERE m.age = :age", Long.class)
            .setParameter("age", age)
            .getSingleResult();
}
  • JPAsetFirstResult(), setMaxResults() 메서드를 지원한다. 이게 MySQL로 치면 offset, limit에 해당하는 부분이다.
  • 그래서 굉장히 간단하게 페이징 처리를 할 수 있다.
  • 그리고, 전체 개수를 가져오는 쿼리도 만들어야 한다. 그래서 totalCount()라는 메서드를 만들고 전체 개수를 가져오는 JPQL을 구현했다.

테스트 코드로 잘 동작하는지 확인해보자.

@Test
void paging() {
    memberJpaRepository.save(new Member("memberA", 10));
    memberJpaRepository.save(new Member("memberB", 10));
    memberJpaRepository.save(new Member("memberC", 10));
    memberJpaRepository.save(new Member("memberD", 10));
    memberJpaRepository.save(new Member("memberE", 10));

    List<Member> members = memberJpaRepository.findByPage(10, 0, 3);
    long totalCount = memberJpaRepository.totalCount(10);

    //페이지 계산 공식 적용...
    // totalPage = totalCount / size ... 
    // 마지막 페이지 ...
    // 최초 페이지 ...

    assertThat(members.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
}
  • 나이가 10살인 회원을 조회한다. 그리고 그 쿼리에 대한 페이징 처리를 했다. offset은 0, limit은 3이다.
  • 순수 JPA를 사용하면, 데이터와 전체 개수를 가져오면 페이징 계산을 따로 해줘야하는 번거로움이 있다. 그런데 이 부분을 스프링 데이터 JPA가 아주 쉽게 처리해준다. 이후에 봐보자!
  • 테스트 실행 결과는 아주 잘 동작한다.

실행 결과

 

 

스프링 데이터 JPA 페이징과 정렬

스프링 데이터 JPA를 사용하면 페이징 처리가 아주 기가막히다. 그런데 이건, 스프링 데이터 JPA가 아니라 Spring Data가 표준으로 제공하는 것이라 구현한 어떤 데이터 접근 기술이든 공통 사항이다. 

 

  • 정렬 기능 → org.springframework.data.domain.Sort
  • 페이징 기능 (내부에 Sort 포함) → org.springframework.data.domain.Pageable

패키지를 보면 알겠지만, data.jpa가 아니라 data에서 끝난다. 스프링 데이터 JPA뿐 아니라, Redis, MongoDB 등 어떤걸 사용해도 동일한 인터페이스라는 이야기다. 정말 잘 만들었다는 생각이 든다.

 

그리고 추가적으로 특별한 반환 타입이 있다.

  • org.springframework.data.domain.Page → count 쿼리 결과를 포함하는 페이징
  • org.springframework.data.domain.Slice → count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1로 조회)

Slice라는 건 뭐냐면, 전체 개수는 없는데 그 모바일에서 보면, 화면을 쭉 내리다보면 보이는 [더보기] 버튼 같은 페이징 처리에 사용되는 것이다. 그래서 사용자가 더보기 버튼을 누르면 (누르지 않아도 되는 경우도 있고) 추가적으로 데이터를 가져오는 그런 방식이다. 그래서 limit + 1을 조회해서 limit까지 데이터를 보여주고 +1 했을때 데이터가 있는 경우 [더보기] 버튼을 보여지게 하는 그런 방식이다.

 

Page 사용 

우선, Page를 먼저 사용해보자. 다음과 같이 딱 한 줄을 추가한다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...
    
    Page<Member> findByAge(int age, Pageable pageable);
}
  • 타입을 Page로 받고, 파라미터로 Pageable만 추가하면 끝이다! 그리고 스프링 데이터 JPA는 쿼리 메서드 기능이 있어서 findByAge하면 넘겨받은 age 파라미터랑 같은 age를 가지는 데이터를 가져온다.
  • 물론, 당연히 원하는 JPQL을 작성하는 @Query()를 사용해도 된다.
@Test
void paging() {
    memberRepository.save(new Member("memberA", 10));
    memberRepository.save(new Member("memberB", 10));
    memberRepository.save(new Member("memberC", 10));
    memberRepository.save(new Member("memberD", 10));
    memberRepository.save(new Member("memberE", 10));

    int age = 10;
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

    Page<Member> members = memberRepository.findByAge(age, pageRequest);

    // Contents
    List<Member> content = members.getContent();

    // Total Count
    long totalCount = members.getTotalElements();

    // Page Number (현재 페이지 번호)
    int pageNumber = members.getNumber();

    // Total Pages (전체 페이지 수)
    int totalPages = members.getTotalPages();

    // 첫번째 페이지인지
    boolean isFirst = members.isFirst();

    // 다음 페이지가 있는지
    boolean hasNext = members.hasNext();

    assertThat(content.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
    assertThat(pageNumber).isEqualTo(0);
    assertThat(totalPages).isEqualTo(2);
    assertThat(isFirst).isTrue();
    assertThat(hasNext).isTrue();
}
  • Pageable에 들어갈 파라미터를 만들기 위해 PageRequest.of(...)PageRequest 객체 하나를 만든다.
  • PageRequestPageable을 구현했기 때문에 아무 문제없이 저 자리에 들어갈 수 있다.
  • 그리고, PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username")) 이라고 하면, 현재 페이지 0, 조회할 데이터 수 3, `username`을 기준으로 내림차순으로 정렬한다는 뜻이다. 참고로 가져온 데이터를 기준으로 내림차순으로 정렬하는게 아니라, 내림차순으로 정렬된 데이터를 페이지 0번에서 데이터 3개를 가져온다. 
  • 이렇게 해서 데이터 가져오면 어떤것들을 할 수 있냐? 데이터, 전체 개수, 현재 페이지 번호, 전체 페이지 수, 첫번째 페이지인지 여부, 다음 페이지가 있는지에 대한 여부를 모두 알 수 있다!
  • 참고로, 첫번째 페이지는 1부터 시작이 아니라 0부터 시작이다.

 

Slice 사용

이번에는 Slice를 사용해보자!

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Page<Member> findByAge(int age, Pageable pageable);

    @Query("SELECT m FROM Member m WHERE m.age = :age")
    Slice<Member> findByAgeWithSlice(@Param("age") int age, Pageable pageable);
}
  • 메서드 이름을 바꿔야 했어서 @Query()를 사용했다. 아까 위에서 말했듯, 쿼리 메서드뿐 아니라 @Query로 직접 JPQL을 작성할 수도 있다! 

테스트 코드로 테스트 해보자!

@Test
void paging_slice() {
    memberRepository.save(new Member("memberA", 10));
    memberRepository.save(new Member("memberB", 10));
    memberRepository.save(new Member("memberC", 10));
    memberRepository.save(new Member("memberD", 10));
    memberRepository.save(new Member("memberE", 10));

    int age = 10;
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

    Slice<Member> members = memberRepository.findByAgeWithSlice(age, pageRequest);

    // Contents
    List<Member> content = members.getContent();

    // Page Number (현재 페이지 번호)
    int pageNumber = members.getNumber();

    // 첫번째 페이지인지
    boolean isFirst = members.isFirst();

    // 다음 페이지가 있는지
    boolean hasNext = members.hasNext();

    assertThat(content.size()).isEqualTo(3);
    assertThat(pageNumber).isEqualTo(0);
    assertThat(isFirst).isTrue();
    assertThat(hasNext).isTrue();
}
  • Slice는 전체 개수나, 전체 페이지 수를 가져오지 않는다!  그냥 이 방식은 다음 페이지가 있는지 없는지만 판단하고 있으면 [더보기] 버튼 만들어서 사용자에게 보여주면 된다.
  • 이게 스프링 데이터가 아니라 다른 기술이었다면 이 방식으로 변경하기 위해 코드를 아예 다시 작성했어야 했을텐데 스프링 데이터의 도움을 받아서 그냥 타입만 Page → Slice로 변경하면 끝이다. 놀랍지 않은가?

 

전체 Count 쿼리는 고민할 거리가 많다.

위에서 Page를 사용한 경우를 보았다. 얘는, 전체 카운트 개수도 뽑아준다. 근데 같은 쿼리로 내보내서 카운트를 가져오는데 이게 생각보다 간단치 않은 문제다. 왜냐하면, 데이터가 정말 많은 경우에 전체 카운트 개수를 가져오는 건 꽤나 무거운 행위이다. 예를 들어, 데이터가 100만건이 있다고 생각했을 때 이 전체 개수를 가져오는 쿼리는 굉장히 무겁다. 반면, 페이징 해서 데이터를 짤라서 가져오는 건 아무 문제가 되지 않지만 말이다. 

 

그럼 만약, 이런 쿼리가 있다고 해보자. 

SELECT m FROM Member m LEFT JOIN m.team t
  • 이건 지금 멤버와 팀을 조인하는 쿼리이다. 이대로 Page 타입의 쿼리를 날리면 전체 개수를 가져오는 것도 이 쿼리로 나간다.
  • 그런데 생각해보면, 지금 멤버를 기준으로 LEFT JOIN 할 때 다른 WHERE조건도 없고 딱 이 쿼리 그대로라면 전체 카운트는 저 쿼리 대신 아래와 같은 쿼리를 날려도 완전히 동일한 개수를 가져온다.
SELECT COUNT(m.username) FROM Member m
  • 곰곰히 생각해보면 이렇게 날려도 동일한 전체 개수를 가져온다는 것을 알 것이다. 그럼 카운트 쿼리는 이게 더 좋지 않을까? 조인을 하면 그만큼 낭비를 하는 것인데 말이다.

 

그래서! 스프링 데이터 JPA에서는 쿼리와 카운트 쿼리를 분리할 수가 있다!

@Query(value = "SELECT m FROM Member m LEFT JOIN m.team t", countQuery = "SELECT COUNT(m) FROM Member m")
Page<Member> findByAge(int age, Pageable pageable);
  • 이렇게 카운트 쿼리를 최적화할 수도 있는 것이다. 이건 굉장히 중요하고 최적화 아이템 중 하나다.

 

Sort 조건이 복잡한 경우, 쿼리로 풀자!

PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
  • 아까 작성해본 PageRequest이고, 여기에 세번째 파라미터로 Sort.by(...)해서 정렬 기준을 작성해서 사용했다. 
  • 그런데, 이 정렬 조건이 꽤나 복잡해 지는 경우가 있을 수가 있다. 그런 경우에는 이게 이걸로 풀리지가 않는다. 그래서 그냥 JPQL로 풀어버리면 된다. 아래와 같이 말이다.
@Query("SELECT m FROM Member m WHERE m.age = :age ORDER BY m.username DESC")
Slice<Member> findByAgeWithSlice(@Param("age") int age, Pageable pageable);

 

 

페이징 처리를 해도 엔티티를 DTO로 변환하는 것은 필수!

int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

Page<Member> members = memberRepository.findByAge(age, pageRequest);
  • REST API의 경우, 이 members 그대로 내보내면 큰일난다! 엔티티는 절대 외부로 노출하지 말자고 여러번 말했다.
  • 그리고, 이 엔티티를 아주 간단하게 DTO로 변경할 수가 있다.
Page<Member> members = memberRepository.findByAge(age, pageRequest);
Page<MemberDto> membersDto = members.map(m -> new MemberDto(m.getUsername(), m.getTeam().getName(), m.getAge()));
  • 이렇게 간단하게 map()을 사용해서 DTO로 변환하면 그대로 Page<MemberDto>로 변경할 수 있다.

 

벌크성 수정 쿼리

여러건의 데이터를 한번에 수정하는 쿼리를 날리는 것 역시 지원한다. 순수 JPA부터 해보자.

public int bulkAgePlus(int age) {
    return em.createQuery("UPDATE Member m SET m.age = m.age + 1 WHERE m.age >= :age")
            .setParameter("age", age)
            .executeUpdate();
}
  • 별거 없다. 그냥 executeUpdate()를 호출하면 끝이다.

 

스프링 데이터 JPA에서는 어떻게 해야할까?

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

	...
    
    @Modifying
    @Query("UPDATE Member m SET m.age = m.age + 1 WHERE m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
}
  • 쿼리 날리는 건 똑같은데, @Modifying 애노테이션을 꼭 붙여줘야 한다. 그래야 스프링 데이터 JPA는 아 이게 업데이트 쿼리를 날리는 구나를 인지할 수 있다. 

테스트 코드로 확인만 해보자!

@Test
void bulkUpdate() {
    memberRepository.save(new Member("memberA", 10));
    memberRepository.save(new Member("memberB", 20));
    memberRepository.save(new Member("memberC", 30));
    memberRepository.save(new Member("memberD", 40));
    memberRepository.save(new Member("memberE", 50));

    int resultCount = memberRepository.bulkAgePlus(20);

    assertThat(resultCount).isEqualTo(4);
}
  • 실행 결과는 정상적으로 수행된다.

벌크성 쿼리를 날릴 때 무조건 조심해야 할 것

근데 이 벌크성 쿼리를 날리는걸 어떻게 하냐가 문제가 아니다! 벌크성 쿼리는 조심해야 할 부분이 하나 있다. 순수 JPA든, 스프링 데이터 JPA든 벌크성 쿼리는 영속성 컨텍스트를 무시하고 바로 데이터베이스에 업데이트 쿼리를 빵! 날려버린다. 근데 이게 상황에 따라 다르겠지만 만약, 벌크성 쿼리 대상이 되는 레코드가 이미 영속성 컨텍스트에 관리되는 상태라면 실제 데이터베이스의 해당 레코드와 영속성 컨텍스트가 관리하는 해당 레코드의 데이터 불일치가 일어난다.

 

생각해보자. 영속성 컨텍스트는 쓰기 지연 기능이 있기 때문에 트랜잭션이 완전히 끝나는 시점에 쓰기 쿼리가 나간다. 그래서 트랜잭션이 살아있는 시점에는 쓰기 쿼리가 안 나가는 상태인데 이 상태 어느 순간에 벌크성 쿼리를 날리면 영속성 컨텍스트에 보관되고 있는 데이터는 변경되지 않고 데이터베이스에 직접적으로 적용되기 때문에 데이터베이스에 있는 데이터와 영속성 컨텍스트에 있는 데이터가 값이 달라질 수가 있다. 그래서 결론적으로는, 벌크성 쿼리를 날리면 무조건 플러시, 클리어를 해줘야한다. 

 

저기서도 플러시가 핵심이 아니라, 클리어가 핵심이다. 플러시는 바로 위에서 말한 쓰기 지연 쿼리가 있는 경우 이 쿼리는 당연히 날려줘야 하니까 플러시를 호출해서 쓰기 지연 쿼리를 날리는 데 의의가 있는것이고, 클리어를 해줘야 영속성 컨텍스트가 관리하고 있는 모든 레코드가 정리되고 깨끗해진다. 

 

근데, 스프링 데이터 JPA의 공통 인터페이스에는 플러시는 있지만 클리어는 없다. 이 경우에 어떻게 하면 되냐면, 방법은 두가지 정도가 있는데 첫번째 방법EntityManager를 주입받으면 된다. 주입 받아서 이렇게 작성하면 된다.

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.entity.Team;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import cwchoiit.datajpa.repository.springdatajpa.TeamRepository;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    EntityManager entityManager;

    @Test
    void bulkUpdate() {
        memberRepository.save(new Member("memberA", 10));
        memberRepository.save(new Member("memberB", 20));
        memberRepository.save(new Member("memberC", 30));
        memberRepository.save(new Member("memberD", 40));
        memberRepository.save(new Member("memberE", 50));

        int resultCount = memberRepository.bulkAgePlus(20);

        entityManager.flush();
        entityManager.clear();

        Member member = memberRepository.findByUsername("memberE").orElseThrow();
        System.out.println("member = " + member);

        assertThat(resultCount).isEqualTo(4);
    }
}
  • EntityManager를 주입받았다. 
  • flush(), clear()를 연달아 호출하면 된다.

실행 결과

2024-12-13T12:01:39.214+09:00 DEBUG 50911 --- [    Test worker] org.hibernate.SQL                        : 
    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.username=?
member = Member(id=5, username=memberE, age=51)
  • 51살로 잘 찍힌다. 

 

그런데, 이건 굉장히 비효율적이다. 어떤 방법이 있냐면, 이렇게 해버리면 끝이다.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Member m SET m.age = m.age + 1 WHERE m.age >= :age")
int bulkAgePlus(@Param("age") int age);
  • @Modifying 애노테이션에는 clearAutomatically, flushAutomatically 속성이 있다.

 

@EntityGraph

지연로딩을 해결하기 위해 페치 조인을 사용하는 데 가끔 JPQL을 작성하기가 귀찮을때가 있다.

그럴때, 이 @EntityGraph를 이용하면 조금 더 편하게 페치 조인을 사용하는 것처럼 할 수 있다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
}
  • 예시일뿐이다. 스프링 데이터 JPA가 만들어준 기본 findAll()을 사용하면 페치 조인이 먹히질 않는다. 그리고 연관 객체를 가져올때 N+1 문제가 발생하는데, 그러기가 싫고 페치 조인으로 한번에 다 가져오고 싶은데 JPQL을 작성하기 귀찮다! 하면 이렇게 하면 된다.
  • attributePaths = {...} 에 들어가는 게 연관 객체들이다.

 

근데 이렇게 기존에 있는 메서드를 재정의하는 방식 말고, 내가 새로 만든 메서드를 사용하긴 하는데 이것도 페치 조인을 해야 하긴 하는데 JPQL을 작성하기 귀찮은 경우에 이렇게도 작성할 수 있다.

@Query("SELECT m FROM Member m")
@EntityGraph(attributePaths = {"team"})
List<Member> findMemberEntityGraph();
  • @Query로 가장 간단하게 JPQL을 작성하고 페치 조인을 작성하는 부분은 빼버린 다음에 @EntityGraph 애노테이션을 사용하면 된다.
  • 근데 난 굳이?란 생각은 든다. 그냥 아래와 같이 하면 되지 않나.
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findMemberEntityGraph();

 

근데 이런 경우는 그냥 JPQL 작성으로 한번에 하는게 더 편하고, 위에 경우처럼 스프링 데이터 JPA가 제공하는 공통 메서드를 재정의해서 사용하고 싶은 경우나 아니면 쿼리 메서드를 사용할때는 꽤나 유용할 것 같다. 아래와 같이 말이다.

@EntityGraph(attributePaths = {"team"})
Optional<Member> findByUsername(@Param("username") String username);
  • 이런 쿼리 메서드의 경우, 기본으로는 페치 조인이 안 먹힌 상태이니까 만약 이 쿼리 메서드에 페치 조인이 필요한 경우 @Query 애노테이션으로 JPQL을 다 작성하는 것보다는 이 @EntityGraph가 더 편리해 보이긴 한다.

 

그리고, 이 @EntityGraph는 사실 @NamedEntityGraph라고 JPA에서 제공하는 기능이다. 그래서 그냥 JPA를 사용할 때 이렇게 작성을 할 수가 있다. 

@Entity
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {...}
  • 엔티티 객체에 @NamedEntityGraph로 마치 @NamedQuery 작성하듯 작성할 수가 있다. 근데 잘 사용하지는 않는다.
  • 그리고 이렇게 해 놓으면 스프링 데이터 JPA가 이것을 지원하는데 아래와 같이 사용하면 된다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @EntityGraph("Member.all")
    List<Member> findMemberEntityGraph();
}

 

JPA HintLock

HintLock은 자주 사용하는 것은 아니고 알아두면 될 정도만 이해하고 있으면 된다.

 

Hint

이 힌트라는건 어떤거냐면, 예를 들어 데이터베이스에서 어떤 데이터를 가져오고 그걸 엔티티로 우리가 사용할 때 정말 죽었다가 깨어나도 이 데이터는 변경할 필요도 없고 영속성 컨텍스트에 스냅샷을 가지고 있을 이유가 하나도 없다면 사용해볼 법한 기능이다. 결국 변경 감지든 1차 캐시든 어쨌든간에 영속성 컨텍스트가 그 데이터를 가지고 관리한다는 것은 메모리를 사용한다는 것이고 사용하지 않아도 될 메모리를 사용하는 것보단 사용 안할 수 있다면 안하는게 더 좋으니까 "스냅샷 가지고 있지 말아라"라고 JPA에게 알려주는 거라고 생각하면 된다.

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
    Member findReadOnlyMemberByUsername(String username);
}
  • 레포지토리에 @QueryHints 애노테이션을 달아서, 위와 같이 작성해주면 이 메서드로 가져오는 멤버를 영속성 컨텍스트가 관리하지 않는다. 그래서 오로지 정말 READ의 목적만 있는 경우에는 이런 방법을 고려해봐도 좋다. 성능도 메모리를 쓰는것보단 덜 잡아 먹을테니. 
  • 테스트 코드로 테스트를 해보면 어떤 말인지 알 수 있을 것이다.
@Test
void hint() {
    Member memberA = memberRepository.save(new Member("memberA", 10));

    entityManager.flush();
    entityManager.clear();

    Member findMember = memberRepository.findReadOnlyMemberByUsername("memberA");

    findMember.setUsername("memberB");
}
  • 이렇게 작성하면 변경감지 기능으로 인해 해당 유저의 이름을 변경하는 쿼리를 날려야 한다. 기본적으로는 그게 맞는데 저렇게  @QueryHintreadOnly를 설정하면 영속성 컨텍스트가 관리하지 않게 되어 변경 감지도 하지 않는다.

실행 결과

  • 읽기만 하고 업데이트 쿼리는 없다.

 

Lock

락 같은 경우에는 뭐냐면, SELECT로 데이터를 가져올 때 FOR UPDATE 구문을 사용하는 것이다. 내가 읽은 데이터를 내가 커넥션을 반납하기 전에 (다른 말로 커밋하기 전에) 다른 커넥션에서 해당 데이터를 업데이트 치지 못하도록 하는 방법인데, 거의 보통은 사용하지 않는다. 특히나 트래픽이 많고 유저수가 많고 활발한 서비스는! 그런데 이런게 있다는것 정도만 알아두자.

 

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Member> findLockByUsername(String username);
}
  • @Lock 애노테이션으로 PESSIMISTIC_WRITE 옵션을 주면 된다. 그럼 이 메서드로 데이터를 가져온다면 해당 커넥션이 반납되기 전에는 다른 커넥션에서 같은 데이터에 쓰기 작업이 불가능하다. 쿼리를 보면 가장 확실하다.
@Test
void lock() {
    List<Member> memberA = memberRepository.findLockByUsername("memberA");
}

실행 결과

  • SELECT FOR UPDATE 쿼리가 나가는 것을 볼 수 있다.

 

사용자 정의 레포지토리 구현

스프링 데이터 JPA가 제공하는 인터페이스만으로 개발이 가능하면 아무런 문제가 없다. 그냥 그대로 쭉 사용해도 된다. 그런데 가끔은 정말 복잡한 동적 쿼리나 통계형 쿼리를 짜야하는 경우도 있다. 그런 경우에는 스프링 데이터 JPA의 공통 인터페이스가 제공하는 기능으로는 충분하지 않을 것이다. QueryDsl을 사용해야 할 때도 있고, NativeSQL을 작성해야 할 수도 있다. 

 

이럴땐 두 가지 방법이 있다. 사용자 정의 레포지토리를 구현하거나 아예 통계형(쿼리형) 레포지토리를 직접 만들면 된다. 말보단 코드로!

사용자 정의 레포지토리 구현해보기

MemberRepositoryCustom

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;

import java.util.List;

public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}
  • 별게 아니다. 그냥 나만의 인터페이스를 만들면 된다. 그리고 그 인터페이스에서 정의할 메서드도.

MemberRepositoryCustomImpl

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;

import java.util.List;

@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("SELECT m FROM Member m", Member.class).getResultList();
    }
}
  • 그 다음 이걸 구현할 구현체를 만들면 된다. 여기서는 예시이기 때문에 내용은 의미가 없지만 이렇게 하면 된다는 것이다. 보통은 이렇게 할 땐 QueryDsl을 사용해서 QueryDsl용 레포지토리 구현할때 많이 사용한다. 
  • 그래서 저 코드가 QueryDsl용 코드가 되면 된다. 

이렇게 만든 인터페이스와 구현체를 스프링 데이터 JPA가 제공하는 공통 인터페이스가 상속받으면 된다. 다음과 같이 말이다.

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {...}
  • 이렇게 하면 끝이다. 

 

근데 착각하면 안되는 게 있다. 이 과정이 절대 필수가 아니다. 이렇게 해도 된다는 것이지 이렇게 안해도 상관이 전혀 없다. 스프링 데이터 JPA가 제공하는 공통 인터페이스가 제공해주는 기능으로 충분하면 이 과정 필요없다. 그리고 특정 화면에서만 사용되는 복잡한 쿼리를 다룰때는 그냥 레포지토리를 만들면 된다. 그 안에서 QueryDsl을 사용하던 네이티브 쿼리를 작성하던 그건 개발하는 사람의 몫이고 반드시 이렇게 스프링 데이터 JPA가 제공하는 공통 인터페이스가 상속받도록 만들 필요는 없다는 것이다.

 

그러니까 이렇게 만들면 된다는 것이다.

package cwchoiit.datajpa.repository.springdatajpa;

import org.springframework.stereotype.Repository;

@Repository
public class MemberQueryRepository {
    
    // QueryDsl 사용
    
    // nativeSQL 사용
    
    // ...
}
  • 이렇게 나만의 레포지토리를 만들고 @Repository 애노테이션을 붙여서 스프링 빈으로 등록시킨 다음에 사용하는 곳에서 주입받아서 사용해도 아무런 문제가 없다는 말이다.
  • 그리고 이 방식이 더 효율적이고 가시성이 좋을수도 있다. 위에서 말한 스프링 데이터 JPA가 제공하는 공통 인터페이스가 상속받게 만들면 오히려 핵심 쿼리(CRUD)와 쿼리형 레포지토리가 전부 짬뽕이 되서 가시성이 떨어질 수도 있기 때문이다.

 

Auditing

이 내용은 꽤나 중요하고 실무에서도 거의 무조건 사용하는 기능이다. 다른건 아니고 데이터베이스에 레코드 하나를 추가하거나 업데이트할 때, 누가 생성하고 수정했고, 그 시간이 어떻게 되는지?를 자동으로 넣어주는 방법이다. 

 

우선, 이 기능을 사용하려면 다음과 같이 엔트리 클래스에 @EnableJpaAuditing 애노테이션을 넣어줘야한다.

package cwchoiit.datajpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import java.util.Optional;
import java.util.UUID;

@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
    }

    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.of(UUID.randomUUID().toString());
    }

}

 

그리고, 이 등록일, 수정일은 거의 대부분의 엔티티가 반드시 필요한 필드라서 여러 엔티티에서 사용할 것이기 때문에 따로 하나 빼면 된다.

BaseEntity

package cwchoiit.datajpa.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String modifiedBy;
}
  • @EntityListeners(AuditingEntityListeners.class) 애노테이션을 반드시 붙여줘야한다.
  • @MappedSuperclass 애노테이션 역시 반드시 붙여줘야 한다.
  • @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 애노테이션을 각 필드에 붙여주면 된다.
  • 이렇게 해 준 다음에 사용할 엔티티가 이 BaseEntity를 상속받으면 끝이다.
@Entity
public class Member extends BaseEntity {...}
  • 한가지 유념할 부분은, @CreatedBy, @LastModifiedBy는 저렇게 애노테이션만 붙이면 다 되는게 아니라 한가지 작업을 더 해줘야 한다. 아래와 같이 말이다.
@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.of(UUID.randomUUID().toString());
}
  • 우선 빈으로 AuditorAware를 등록해줘야 한다. 그리고 반환값은 이제 이 엔티티를 생성하거나 수정한 사용자의 이름이나 아이디 정도면 적당할 것 같은데 지금은 그런 정보가 없고 실제 서비스라면, Spring Security를 사용하면 현재 로그인 한 사용자 정보를 가져올 수 있고 Http Session을 사용한다면 이 세션으로도 로그인 한 사용자 정보를 가져올 수 있을것이다. 그것을 반환하면 된다. 
  • 테스트 한번 해보자!

 

@Test
void auditing() {
    memberRepository.save(new Member("memberA", 10));

    entityManager.flush();
    entityManager.clear();

    Member findMember = memberRepository.findByUsername("memberA").orElseThrow();

    System.out.println("findMember created: = " + findMember.getCreatedDate());
    System.out.println("findMember updated: = " + findMember.getModifiedDate());
    System.out.println("findMember.getCreatedBy() = " + findMember.getCreatedBy());
    System.out.println("findMember.getModifiedBy() = " + findMember.getModifiedBy());
}

실행 결과

findMember created: = 2024-12-18T19:17:51.345707
findMember updated: = 2024-12-18T19:17:51.345707
findMember.getCreatedBy() = ebb665c7-b5d4-4640-8e1a-870fe6bd52ff
findMember.getModifiedBy() = ebb665c7-b5d4-4640-8e1a-870fe6bd52ff

 

요 기능은 굉장히 유용하고 운영에 있어서 매우 많은 도움을 주는 데이터이다. 그래서 꼭 참고하길 바란다!

 

 

Web 확장 - 도메인 클래스 컨버터

결론부터 말하면, 쓰지 말거나 최소한으로 사용할 것을 권장한다. 코드로 바로 보자.

MemberController

package cwchoiit.datajpa.controller;

import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String getMember(@PathVariable Long id) {
        return memberRepository.findById(id)
                .map(Member::getUsername)
                .orElse(null);
    }
}
  • 컨트롤러가 있을 때, PathVariableID값을 받으면 그 값으로 엔티티를 찾아내는 방법은 굉장히 일반적이다.
  • 그래서 저렇게 멤버를 찾아서 멤버의 이름을 반환하거나 못 찾으면 null을 반환하는 아주 간단한 코드가 있다고 쳐보자.

이 코드를 스프링 데이터 JPA가 컨버터를 적용해주는데 아래와 같이 할 수 있단 뜻이다.

package cwchoiit.datajpa.controller;

import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String getMember(@PathVariable Long id) {
        return memberRepository.findById(id)
                .map(Member::getUsername)
                .orElse(null);
    }

    @GetMapping("/members2/{id}")
    public String getMember(@PathVariable("id") Member member) {
        return member.getUsername();
    }
}
  • 비교를 위해 기존의 코드를 남겨두었다. `/member2/{id}` 를 보면, @PathVariableID를 받을 때 Member 타입으로 받으면 스프링 데이터 JPA는 이 받아온 아이디를 통해 바로 데이터베이스의 해당 ID를 가지고 있는 멤버를 뽑아준다. 
  • 당연히 파라미터 이름이 다르니까 @PathVariable("id") 이렇게 작성해야 한다.

실행 결과

GET http://localhost:8080/members2/1

HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 7
Date: Wed, 18 Dec 2024 10:44:41 GMT

memberA

Response code: 200; Time: 18ms (18 ms); Content length: 7 bytes (7 B)

 

보면 굉장히 획기적인 기능 같은데 쓰지말자. 왜냐하면, 일단 이 코드를 이해하기가 정말 어렵다. 아니 분명 PathVariableID를 받는데 갑자기 멤버로 받아? 싶기도 한데다가 가장 문제는 이 멤버는 절대적으로 조회용으로 밖에 사용할 수 없다. 왜냐하면 컨버터를 통해 멤버를 찾아오는 과정은 스프링 데이터 JPA 내부에서 해주는 작업이기 때문에 트랜잭션이 이 코드안에 없다. 따라서 쓰기 작업이 불가능하다. 즉, 영속성 컨텍스트가 이 컨트롤러 안에 없다는 의미다.

 

Web 확장 - 페이징과 정렬

스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수가 있다. 다음 코드를 보자.

@GetMapping("/members")
public Page<MemberDto> getMembers(Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}
  • 파라미터로 Pageable을 받으면 된다.
  • Pageableorg.springframework.data.domain.Pageable이다.
  • 당연히 DTO로 변환해서 반환해야 한다.

 

이렇게 만들어 두면, 어떤식으로 요청할 수가 있냐? 바로 이렇게 하면 된다.

GET http://localhost:8080/members?page=1&size=5&sort=id,desc&sort=username,desc
  • page → 현재 페이지를 의미한다. 참고로 0부터 시작한다!
  • size → 한 페이지에 노출할 데이터 건 수
  • sort → 정렬 조건을 정의한다. 

 

기본 설정값 변경 

그런데, 가끔은 기본 페이지 사이즈나 최대 페이즈 사이즈같은 값을 바꾸고 싶을 때가 있다. 이 경우엔, 글로벌 설정과 개별 설정이 있는데 글로벌 설정은 application.yaml 파일에서 수정하면 된다.

 

글로벌 설정 (application.yaml)

spring:
  data:
    web:
      pageable:
        default-page-size: 10
        max-page-size: 1000

 

개별 설정

@GetMapping("/members")
public Page<MemberDto> getMembers(@PageableDefault(size = 5, sort = {"username", "id"}, direction = Sort.Direction.DESC) Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}

 

페이징 정보가 둘 이상인 경우

어떤 요청에는 여러 데이터를 가져와야 하는데 그 여러 데이터가 다 페이징이 필요한 경우 페이징 정보가 둘 이상이 될 수도 있다. 그럴땐 다음과 같이 @Qualifier를 사용하면 된다.

@GetMapping("/members")
public Page<MemberDto> getMembers(@Qualifier("member") Pageable memberPageable,
                                  @Qualifier("order") Pageable orderPageable) {
    return memberRepository.findAll(memberPageable).map(MemberDto::new);
}

 

 

Page를 1부터 시작하기

이게 살짝 아쉬운 부분인데, 이 스프링 데이터 JPA가 제공하는 Web 확장 기능의 PageablePage의 첫 페이지가 0이다. 그래서 1부터 하고 싶은 경우에 난감하긴 하다. 가장 좋은건 0부터 시작하고 0부터 시작하는 것을 규칙으로 만들면 된다. 

 

그런데 만약, 1부터 무조건 시작해야 한다면 다음 두 가지 방법이 있다.

  • Pageable, Page를 파라미터와 응답값으로 사용하지 않고, 직접 클래스를 만들어서 처리한다. 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 레포지토리에 넘긴다. 물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.
  • spring.data.web.pageable.one-indexed-parameterstrue로 설정한다. 그런데 이 방법은 web에서 page 파라미터를 -1 처리할 뿐이다. 따라서 응답값인 Page에 모두 0 페이지 인덱스를 사용하는 한계가 있다.

그러니까 다음과 같이 application.yaml을 설정을 했다.

spring:
  data:
    web:
      pageable:
        one-indexed-parameters: true

 

그런데, 이렇게 하더라도 Page 응답값에서는 적용되지가 않는다. 

GET http://localhost:8080/members?page=1

###
...
"pageable": {
    "pageNumber": 0,
    "pageSize": 10,
    "sort": {
      "sorted": false,
      "empty": true,
      "unsorted": true
    },
    "offset": 0,
    "paged": true,
    "unpaged": false
  },
  "totalPages": 10,
  "totalElements": 100,
  "last": false,
  "number": 0,
  "size": 10,
  "numberOfElements": 10,
  "sort": {
    "sorted": false,
    "empty": true,
    "unsorted": true
  },
  "first": true,
  "empty": false
}
  • 여전히 pageNumber에는 0으로 나온다.

 

728x90
반응형
LIST

'Spring Data JPA' 카테고리의 다른 글

Spring Data JPA (3)  (0) 2024.12.18
Spring Data JPA (1)  (0) 2024.12.12
728x90
반응형
SMALL

참고자료

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

JPASpring Data JPA랑 뭐가 다른건가요?에 대한 질문에 정확한 답변을 할 수 없다면 JPA와 스프링을 다시 공부해야 한다. JPA를 모르면 Spring Data JPA를 제대로 사용할 수 없다. JPA는 데이터 접근 기술이고 ORM이다. 지금은 스프링을 사용하는 개발자들에게는 거의 필수인 데이터 접근 기술인데 JPA를 사용하던, 다른 데이터 접근 기술을 사용하던 기본적인 CRUD 기능은 거의 대부분이 비슷하다. 심지어 이건 관계형 데이터베이스가 아닌 데이터베이스도 마찬가지로 기본적인 CRUD는 거의 대부분이 동일하다. 이렇게 유사한 기능을 가지고 있는데 기술이 가지각색인 경우 스프링은 항상 뭐다? 추상화를 한다

 

그래서, 스프링 진영에서 데이터 접근 기술들이 이것 저것 많지만 기본적인 CRUD는 모두 동일하니 이거 추상화하자! 해서 만든 표준이 Spring Data라는 표준이고 그 표준을 특정 기술(여기서는 JPA)로 구현한 구현체 중 하나가 Spring Data JPA이다.

 

그럼 도대체 JPA가 있는데 뭐 얼마나 유사하다고 이 표준을 만든걸까? 한번 Spring Data JPA를 사용하지 않고 순수 JPA를 사용해서 레포지토리를 만들고 데이터 접근 기술을 사용해보자.

 

엔티티

Member

package cwchoiit.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username) {
        this.username = username;
    }

    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

Team

package cwchoiit.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name", "members"})
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Member> members = new ArrayList<>();
}
  • 간단하게 Member, Team 두 엔티티를 만들었다.
  • 둘은 다대일 양방향 연관관계를 가지고 있고, 그렇기에 연관관계의 주인을 지정해야 한다. JPA 관련 포스팅에서 열심히 말했다. 연관관계의 주인은 항상 테이블 관점에서 외래키를 가지고 있는 쪽이 주인이 되면 된다고 했다. 고로 연관관계의 주인을 Member로 지정했다. 양방향 연관관계에서 연관관계의 주인이 아닌 TeammappedBy 속성으로 "나는 주인인 Member의 필드인 team에 매핑된 필드입니다."라고 알려주어야 한다.
  • 지연로딩은 당연히 모두 다 걸어야 한다.
  • 여기서 @ToString을 유심히 보면, Member에는 Team 필드를 제외한 것을 볼 수 있다. 왜 그러냐면, toString()을 호출할 때 팀까지 넣어버리면 팀을 호출하고, 팀은 멤버 리스트를 가지고 있기 때문에 멤버를 다시 호출한다. 그럼 팀이 멤버를 멤버는 팀을 계속해서 호출하는 문제가 발생해서 스택 오버플로우 에러를 마주하게 된다.

 

순수 JPA 레포지토리

MemberJpaRepository

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;

    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    public Member find(Long id) {
        return em.find(Member.class, id);
    }

    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }

    public long count() {
        return em.createQuery("SELECT COUNT(m) FROM Member m", Long.class)
                .getSingleResult();
    }

    public List<Member> findAll() {
        return em.createQuery("SELECT m FROM Member m", Member.class)
                .getResultList();
    }

    public void delete(Member member) {
        em.remove(member);
    }
}

 

TeamJpaRepository

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Team;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class TeamJpaRepository {

    private final EntityManager em;

    public Team save(Team team) {
        em.persist(team);
        return team;
    }

    public void delete(Team team) {
        em.remove(team);
    }

    public List<Team> findAll() {
        return em.createQuery("SELECT t FROM Team t", Team.class)
                .getResultList();
    }

    public Optional<Team> findById(Long id) {
        Team team = em.find(Team.class, id);
        return Optional.ofNullable(team);
    }

    public long count() {
        return em.createQuery("SELECT COUNT(t) FROM Team t", Long.class)
                .getSingleResult();
    }
}
  • 직접 EntityManager를 주입받아서 순수한 JPA 레포지토리 코드를 작성한 모습이다. 코드만 봐도 알겠지만, TeamMember가 참조하는 객체만 다를뿐 코드가 거의 100% 동일하다. 
  • save(), delete(), findAll(), findById(), count() 모두 둘 다 가지고 있는 메서드이다.
  • @Repository 애노테이션을 달아서 이 클래스를 스프링 컨테이너를 띄울때 컴포넌트 스캔을 할 수 있도록 해줘야 한다. 참고로, @Repository를 달면 스프링은 데이터 접근 예외 추상화 기술도 넣어 프록시를 만든다. 그래서 어떤 데이터베이스를 사용하든지 예외가 발생하더라도 동일한 스프링 데이터 접근 예외가 발생한다.
  • 이후에 Spring Data JPA를 사용하면 이 @Repository 애노테이션은 달 필요가 없다. 왜냐하면 JpaRepository를 상속받는 시점부터 이미 알아서 스프링 데이터 접근 예외 추상화도 해준다. 이건 이후에 직접 사용하면서 다시 보자.

 

순수하게 JPA 레포지토리를 작성하고 보니, 코드가 거의 동일하다. 엔티티가 10개면 10개의 레포지토리가 동일하게 가지는 이 기능들을 매번 구현해줘야 한다. 개발자는 게으르다. 이것을 참을 수 없다. 

 

 

스프링 데이터 JPA 사용

스프링 데이터 JPA를 사용하기 위해선 다음 작업이 필요하다.

package cwchoiit.datajpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
@EnableJpaRepositories(basePackages = "cwchoiit.datajpa.repository")
public class DataJpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
    }

}
  • 엔트리 지점인 메인 클래스에 @EnableJpaRepositories 애노테이션을 달아서 어떤 패키지에 스프링 데이터 JPA 레포지토리를 가지고 있는지 알려줘야 한다. 그래야 그 지점에 가서 레포지토리들을 컴포넌트 스캔으로 빈 등록하기 때문이다.
  • 그런데, 스프링 부트를 사용하는 우리는 이게 필요없다. 스프링 부트가 자동으로 컴포넌트 스캔을 해주는데 어디서부터 해주냐? 바로 이 클래스(DataJpaApplication)가 존재하는 패키지 하위부터 모든 패키지를 다 뒤져서 찾는다. 

 

그래서 다시 제거한 모습으로 놔두자!

package cwchoiit.datajpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DataJpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
    }

}

 

 

스프링 데이터 JPA 공통 인터페이스

이제, 스프링 데이터 JPA가 제공하는 인터페이스를 상속받는 인터페이스를 만들기만 하면 끝이다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}
  • 이게 끝이다.
  • 네? 구현체는요? → 필요가 없다. 스프링 데이터 JPA가 직접 만들어서 꽂아준다.
  • 프록시로 만들어서 공통으로 사용되는 기본적인 CRUD 기능도 가지고 있고, 스프링 데이터 접근 예외 추상화도 적용해서 만들어준다.

 

실제로 확인해볼 수 있는데, 다음과 같이 테스트 코드를 작성해보자.

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void save() {

        log.info("memberRepository class = {}", memberRepository.getClass());

    }
}
  • 위에서 만든 MemberRepository를 주입받아서 이 클래스 정보를 찍어보면 다음과 같이 나온다.

실행 결과

2024-12-12T14:18:13.905+09:00  INFO 15409 --- [    Test worker] c.d.repository.MemberRepositoryTest      : memberRepository class = class jdk.proxy3.$Proxy125
  • JDK 동적 프록시를 사용해서 프록시로 만든 모습을 확인할 수 있다. 

 

스프링 데이터 JPA 사용하여 테스트하기

MemberRepositoryTest

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void crud() {
        Member member1 = Member.builder()
                .username("member1")
                .build();

        Member member2 = Member.builder()
                .username("member2")
                .build();

        memberRepository.save(member1);
        memberRepository.save(member2);

        Member findMember1 = memberRepository.findById(member1.getId()).orElseThrow();
        Member findMember2 = memberRepository.findById(member2.getId()).orElseThrow();

        assertThat(findMember1).isEqualTo(member1);
        assertThat(findMember2).isEqualTo(member2);

        List<Member> members = memberRepository.findAll();
        assertThat(members.size()).isEqualTo(2);

        long memberCount = memberRepository.count();
        assertThat(memberCount).isEqualTo(2);

        memberRepository.delete(member1);
        memberRepository.delete(member2);

        long afterRemovedCount = memberRepository.count();
        assertThat(afterRemovedCount).isEqualTo(0);
    }
}
  • 이런 코드를 작성했다. 어? 나는 findAll(), count(), delete(), findById(), save() 만든 적이 없는데 잘 작성이 된다. 당연히 되지! 스프링 데이터 JPA가 구현체를 다 만들어 놨으니까!

실행 결과

 

 

스프링 데이터 JPA 공통 인터페이스 분석

그럼 스프링 데이터 JPA 공통 인터페이스인 JpaRepository가 어떻게 생긴걸까?

  • 이런 모양으로 생겼다. 참고로, Deprecated 된 메서드도 있다. getOne()이 대표적이다. 그리고 findOne()findById()로 변경되었다. 메서드 명이나 이런게 중요한게 아니고 여기서 핵심은 이런 구조를 가지고 있다는 점이다.
  • 맨 위에서 스프링이 데이터 접근 기술을 추상화했고 그게 Spring Data라고 했다. 그리고 그 여러 데이터 접근 기술 중 하나인 JPA를 가지고 구현한 구현체가 Spring Data JPA일 뿐이다.
  • 그래서 이거 타고 올라가보면, JpaRepository는 패키지 명이 다음과 같다.

  • 보다시피, org.springframework.data.jpa.repository이다.
  • 이 위로 올라가서 PagingAndSortingRepository 올라가보면 패키지 명이 다음과 같다.

  • 보다시피 org.springframework.data.repository이다.
  • 이 말은, 스프링 데이터 JPA보다 위에있는 것들은 JPA뿐 아니라 Spring Data를 구현한 구현체들은 모두 가지고 있는 기능들이란 뜻이다. 그래서 위에서 말한대로 어떤 데이터베이스를 사용하든지 공통 기능을 추상화했다고 말한것이다.

 

스프링 데이터 JPA가 제공하는 공통 CRUD 말고는요?

그럼, save(), delete(), findAll() 등 공통적으로 어떤 데이터베이스든 필요한 메서드는 편리하게 사용할 수 있다는 것을 알았다. 근데 당연히 비즈니스적으로 프로젝트마다 엔티티도 다를 것이다. 예를 들면 어떤 프로젝트는 Member라는 엔티티가 있는가 하면 User라는 엔티티를 가진 프로젝트도 있을 것이다. 이런것까지 스프링 데이터 JPA가 다 만들어주진 않는다. 그럼 뭐 마냥 좋은게 아니네? 라고 생각이 들 수 있는데 이 스프링 데이터 JPA가 또 마법같은 일을 부린다. 

 

바로, 메서드 시그니처만으로 어떤 쿼리를 사용할지를 유추해서 그냥 해당 메서드 쿼리를 만들어버린다! 예를 들면 다음과 같이 코드를 작성하자. 

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByUsername(String username);
}
  • 위에서 만든 스프링 데이터 JPA 공통 인터페이스에 시그니처 하나를 추가했다.
  • 유저이름으로 Member를 찾아내는 메서드이다. 이거 한 줄만 추가하면 스프링 데이터 JPA가 알아서 이 구현체를 만들어낸다!
  • 이 내용은 다음 포스팅에서 본격적으로 다뤄보자!

 

728x90
반응형
LIST

'Spring Data JPA' 카테고리의 다른 글

Spring Data JPA (3)  (0) 2024.12.18
Spring Data JPA (2)  (0) 2024.12.12
728x90
반응형
SMALL
SMALL

이전 포스팅에서 스프링 트랜잭션을 공부했고 그 과정에서 @Transactional 애노테이션을 알아봤다.

이렇게 애노테이션 하나로 트랜잭션을 시작하는 것을 '선언적 트랜잭션'이라고 한다. 

 

@Transactional 애노테이션은 여러가지 옵션들이 있는데 굉장히 중요한 부분이다. 하나씩 차근차근 알아보자!

@Transactional 옵션들

이번엔 @Transactional 애노테이션이 가지고 있는 여러 속성들을 알아보자.

public @interface Transactional {
     String value() default "";
     String transactionManager() default "";
     Class<? extends Throwable>[] rollbackFor() default {};
     Class<? extends Throwable>[] noRollbackFor() default {};
     Propagation propagation() default Propagation.REQUIRED;
     Isolation isolation() default Isolation.DEFAULT;
     int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
     boolean readOnly() default false;
     String[] label() default {};
}

 

value, transactionManager

트랜잭션을 사용하려면, 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 한다. @Transactional 애노테이션도 결국 트랜잭션 매니저를 사용하는거기 때문에 트랜잭션 매니저를 지정해줘야 하고 지정할 때는 value, transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다. 지정하지 않으면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그러나 트랜잭션 매니저가 둘 이상이면 다음처럼 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.

@Transactional("memberTxManager")
public void member() {...}

@Transactional("orderTxManager")
public void order() {...}
  • 참고로, 애노테이션에서 속성이 하나인 경우 위 예처럼 value는 생략하고 값을 바로 넣을 수 있다.

 

rollbackFor

트랜잭션 내 로직을 수행 중 예외가 발생하면 스프링 트랜잭션의 기본 정책은 다음과 같다.

  • 언체크 예외(런타임 예외라고도 함)인 RuntimeException, Error 또는 그 하위 예외가 발생하면 롤백
  • 체크 예외인 Exception 또는 그 하위 예외들은 커밋

이 옵션을 사용해서 기본 정책에 '추가적으로' 어떤 예외가 발생했을 때 롤백을 하라고 지정할 수 있다.

@Transactional(rollbackFor = Exception.class)

이렇게 하면 Exception부터 그 하위 예외들이 발생해도 롤백한다.

rollbackForClassName이라는 속성도 있는데 이는 문자로 넣는 경우이다. 그냥 rollbackFor를 사용하면 된다.

 

noRollbackFor

이는 rollbackFor와 반대 개념이다. 기본 정책에 추가로 어떤 예외가 발생하면 롤백하면 안되는지 지정할 수 있다. 마찬가지로 noRollbackForClassName도 있다. 

 

propagation

이는 트랜잭션 전파에 대한 내용이다. 매우 중요하다. 우선 가능 옵션 리스트는 다음과 같다.

전파타입 설명
REQUIRED ⭐️ 기본값으로 설정되는 전파 타입입니다. 기존에 활성화된 트랜잭션에 자식 트랜잭션이 합류하여 하나의 물리 트랜잭션으로 취급합니다. 기존 트랜잭션이 없다면 새로 트랜잭션을 만듭니다.
REQUIRES_NEW ⭐️ 기존에 활성화된 트랜잭션이 있더라도 합류하지 않고 별개의 트랜잭션으로 취급하여 수행되는 전파 타입입니다.
SUPPORTS 기존에 활성화된 트랜잭션이 있다면 합류를 하고, 활성화된 트랜잭션이 없다면 합류하지 않고 트랜잭션 없이 그대로 작업을 수행합니다. 트랜잭션이 그다지 필요없는 SELECT 쿼리에 적용하면 성능 향상을 기대할 수 있다고도 합니다.
NOT_SUPPORTED 트랜잭션을 지원하지 않는다는 의미입니다. 그래서, 기존 트랜잭션이 없으면 트랜잭션이 없이 진행하고, 기존 트랜잭션이 있어도 트랜잭션이 없이 진행합니다. 
MANDATORY 기존에 활성화된 트랜잭션이 존재할 경우 해당 트랜잭션에 합류하며, 존재하지 않을 경우 예외를 발생시킵니다. 쉽게 말해 기존 트랜잭션이 반드시 있어야 합니다.
NEVER 기존에 활성화된 트랜잭션이 존재할 경우 예외를 발생시키며, 활성화된 트랜잭션이 없을 경우 작업을 수행합니다.
NESTED 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고 기존 트랜잭션이 있으면 중첩 트랜잭션을 만듭니다. 중첩 트랜잭션은 외부(기존) 트랜잭션에 영향을 받지만, 중첩 트랜잭션은 외부 트랜잭션에 영향을 주지 않습니다. 즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 같이 롤백됩니다. (중첩 트랜잭션은 JPA에서는 사용할 수 없는 옵션입니다)

 

참고로, 트랜잭션 옵션 중 isolation, timeout, readOnly는 트랜잭션이 처음 시작될 때만 적용된다. 즉, 트랜잭션에 참여하는 경우에는 적용되지 않는다. 예를 들어, REQUIRED를 통한 트랜잭션 시작이나 REQUIRES_NEW를 통한 트랜잭션 시작 시점에만 적용되고 트랜잭션에 참여하는 트랜잭션은 기존 트랜잭션을 시작할 때 적용했던 옵션을 따라 사용된다. 

 

 

propagation - REQUIRED

기본값은 REQUIRED인데, 이 옵션은 위에 설명한 그대로 기존에 활성화된 트랜잭션이 있으면 그 트랜잭션에 합류한다. 좀 더 정확히 말하면 '물리 트랜잭션''논리 트랜잭션'으로 나뉘어지는데 다음 그림을 보자.

1. 클라이언트에서 트랜잭션을 가지는 서비스를 호출하면 트랜잭션이 시작된다. 

2. 하나의 트랜잭션에서 실행되던 로직에서 새로운 트랜잭션을 가지는 또다른 서비스를 호출하여 트랜잭션 두개가 생성된다.

3. 이 두개의 트랜잭션은 논리 트랜잭션으로 나뉘어지고 이 두개의 논리 트랜잭션을 크게 묶어 하나의 물리 트랜잭션으로 합류된다.

 

이런 흐름이 REQUIRED 옵션이다. 이렇게 하나의 물리 트랜잭션으로 합류가 된다. 그리고 이 물리 트랜잭션이 실제 데이터베이스와 통신하는 트랜잭션이고 논리 트랜잭션들은 애플리케이션 레벨에서 트랜잭션을 시작하고 종료(커밋 또는 롤백)하는 트랜잭션이다. 

 

 

이 경우, 중요한 두가지 규칙이 있다.

  • 두 개의 논리 트랜잭션이 모두 커밋되어야 물리 트랜잭션이 커밋된다.
  • 두 개의 논리 트랜잭션 중 하나라도 롤백이라면 물리 트랜잭션은 롤백된다. 

 

다음 그림을 보고 이와 같이 정하자.

최초 시작되는 트랜잭션을 외부 트랜잭션, 내부 로직에서 또다른 트랜잭션을 만들려 하는 곳을 내부 트랜잭션이라고 칭하자.

 

둘 다 논리 트랜잭션이고 이 두개의 논리 트랜잭션이 모두 커밋되어야 하나의 물리 트랜잭션(실제 데이터베이스에 커밋 또는 롤백을 날리는)이 커밋된다고 했다. 실제로 코드를 통해서 어떻게 동작하는지 눈으로 직접 확인해보자!

@Test
void inner_commit() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());

    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 외부 트랜잭션(outer)를 시작한다. 최초로 트랜잭션이 수행되면 신규 트랜잭션이 되고 그 값을 보관하고 있기 때문에 outer.isNewTransaction()을 호출하면 `true`를 반환한다.
  • 외부 트랜잭션이 수행중인데 내부 트랜잭션(inner)을 추가로 수행했다. 내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다.
  • 내부 트랜잭션이 외부 트랜잭션에 참여한다는 뜻은 내부 트랜잭션이 외부 트랜잭션을 그대로 이어 받아서 따른다는 뜻이다. 다른 관점으로 보면 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
  • 내부 트랜잭션은 이미 진행중인 외부 트랜잭션에 참여하므로 신규 트랜잭션이 아니다. 그러므로 inner.isNewTransaction()을 호출하면 `false`가 된다.
  • 내부 트랜잭션을 커밋한 후 외부 트랜잭션도 커밋했다. (당연히 내부 트랜잭션을 먼저 커밋하고 외부 트랜잭션이 커밋되는게 순서가 맞다)

실행 결과

2024-12-11T16:15:04.484+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 외부 트랜잭션 시작
2024-12-11T16:15:04.485+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-12-11T16:15:04.486+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] for JDBC transaction
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Switching JDBC Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] to manual commit
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : outer.isNewTransaction() = true
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 내부 트랜잭션 시작
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Participating in existing transaction
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : inner.isNewTransaction() = false
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 내부 트랜잭션 커밋
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 외부 트랜잭션 커밋
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2024-12-11T16:15:04.488+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA]
2024-12-11T16:15:04.488+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] after transaction
  • 로그를 보면 꽤나 흥미로운데, 내부 트랜잭션을 시작할 때 `Participating in existing transaction` 이라는 메시지를 확인할 수 있다. 이 메시지는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 뜻이다. 근데 어떻게 알까? 아까 위에서 말한 신규 트랜잭션인지를 확인해서 신규 트랜잭션이 아닌 경우에 전파 속성의 기본값인 기존 트랜잭션에 참여하는 방식을 사용하기 때문이다.
  • 그리고 내부 트랜잭션을 커밋하고 나서 커밋 로그가 전혀 찍혀있지 않다. 그리고 외부 트랜잭션을 커밋한다는 로그가 나오고 나서 커밋 로그가 찍혀있다. 왜 그럴까? 트랜잭션을 커밋하는 순간 그 트랜잭션은 더이상 사용할 수 없다. 아예 끝난다. 그런데 외부 트랜잭션이 남아있는데 내부 트랜잭션을 커밋한다고 해서 물리 트랜잭션을 커밋해버리면 외부 트랜잭션에서는 작업했던 내용을 반영할 수 없기 때문이다. 
  • 그래서 스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다. 따라서 외부 트랜잭션이 커밋되어야만 실제 물리 트랜잭션을 커밋한다는 로그가 찍히는 것이다.
  • 그래서 내부 트랜잭션이 정상적으로 커밋이 되고, 외부 트랜잭션도 커밋이 되어야만 커밋을 한다는 로그가 찍히는 모습이다.

 

여기서 그럼, 둘 중 하나가 롤백을 한다면 결국 물리 트랜잭션이 롤백이 되는데 이 때 외부 트랜잭션과 내부 트랜잭션이 롤백을 할 때 처리되는 방식이 다르다. 이것을 이해해야 한다.

 

외부 트랜잭션이 최초의 트랜잭션이기 때문에 결국 이 외부 트랜잭션이 커밋 또는 롤백을 해야 물리 트랜잭션이 커밋 또는 롤백을 한다.

내부 트랜잭션은 커밋이나 롤백을 해도 물리 트랜잭션은 아무런 작업을 하지 않는다. 왜냐? 외부 트랜잭션이 아직 남아있기 때문에.

 

1.내부 트랜잭션이 커밋되고 외부 트랜잭션이 롤백이 되면 그냥 롤백 처리가 된다.
2.내부 트랜잭션이 롤백되고 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 터진다.

저 부분이 중요하다. 내부 트랜잭션이 롤백을 하면 물리 트랜잭션이 롤백을 바로 하지 않지만(물리 트랜잭션이 롤백 또는 커밋이 되면 데이터베이스에 실제 커밋 또는 롤백을 날리는데 아직 트랜잭션이 끝난게 아니니까) 물리 트랜잭션에 Rollback-Only 라는 마크를 달게 된다.

 

여기서 외부 트랜잭션이 커밋이 된다면 물리 트랜잭션에 커밋을 날리는데 커밋을 할 수 없는것이다. 왜냐? 내부 트랜잭션이 롤백을 했으니까. 그러나 개발자는 외부 트랜잭션을 커밋을 했는데 커밋이 되면 안되니까 UnexpectedRollbackException이 발생하는 것이다. 즉, 스프링 입장에서는 "너가 지금 물리 트랜잭션을 커밋하려고 시도했지만 내부 트랜잭션에서 롤백을 날렸기 때문에 넌 커밋을 할 수 없어"라고 친절하게 알려주는 것. 이것을 코드로 한번 봐보자!

@Test
void inner_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 내부 트랜잭션은 롤백을 하고, 외부 트랜잭션은 커밋을 한다.
  • 이 경우에 전체 트랜잭션인 물리 트랜잭션은 롤백을 하고 UnexpectedRollbackException 예외를 던진다.

실행 결과

  • 실행 결과를 보면 UnexpectedRollbackException이 발생했음을 알 수 있다.
  • 그리고, 내부 트랜잭션을 롤백할 때 찍히는 로그를 보면 `marking existing transaction as rollback-only` 라는 로그가 찍혀있다. 내부 트랜잭션은 물리 트랜잭션에 커밋이나 롤백을 할 수 없기 때문에 현재 이 물리 트랜잭션에 나 롤백했어! 라고 마킹을 하는 것이다. 그래야 외부 트랜잭션에서 커밋이든 롤백이든 뭔가 하려고 할 때 확인하고 롤백을 할 수 있을테니 말이다.

이게 기본 전파 속성 'REQUIRED'의 동작 흐름이다. 

 

그래서 진짜 진짜 중요하게 이해해야 하는 부분이 있는데, 아래 그림을 보자.

 

위 그림처럼 트랜잭션에서 또 다른 트랜잭션을 가지는 내부 로직이 있고 이 세 개의 트랜잭션이 모두 REQUIRED 전파 타입인 경우가 있다고 가정하자.

 

REQUIRED 전파 속성은 모두 커밋이 되어야 물리 트랜잭션이 커밋되고 하나라도 롤백이라면 물리 트랜잭션은 롤백된다고 했다. 

근데 만약, 런타임 예외가 발생한 지점에서 예외 처리를 하지 못하고 자신을 호출한 곳으로 예외를 던졌다고 가정하자. 당연히 이 로직의 트랜잭션은 롤백을 할 것이다. 그러나 이 트랜잭션이 가장 최초에 시작된 트랜잭션이 아니라면 롤백을 바로 데이터베이스에 날리는 게 아니라 Rollback Only 마킹을 하고 끝나는데, 그럼 이 예외를 넘겨받은 바깥쪽 트랜잭션 로직에서 이 예외를 복구했다면? 즉 예외를 잡아서 처리했다면? 이 전체 물리 트랜잭션은 커밋이 성공적으로 될까? 아니다. 그렇지 않다. 왜냐하면 결국 이 REQUIRED 전파 타입은 사용중인 트랜잭션에 참여하기 때문에 내부의 논리 트랜잭션에서 하나라도 rollback only에 마킹을 했다면 무조건 물리 트랜잭션은 롤백을 한다. 근데 예외를 잡아버리니까 정상 흐름을 유지하고 트랜잭션을 커밋을 할 거라고 기대하는 경우가 종종 있을 수 있다. 충분히 그럴수 있다는 생각이든다. 하지만 아니라는 것을 꼭 염두하자.

 

코드를 통해서 직접 확인해보자!

MemberService

package cwchoiit.tx.propagation;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final LogRepository logRepository;

    @Transactional
    public void joinV1(String username) {
        Member member = new Member(username);
        Log logEntity = new Log(username);

        log.info("MemberRepository 호출 시작");
        memberRepository.save(member);
        log.info("MemberRepository 호출 종료");

        log.info("LogRepository 호출 시작");
        logRepository.save(logEntity);
        log.info("LogRepository 호출 종료");
    }

    @Transactional
    public void joinV2(String username) {
        Member member = new Member(username);
        Log logEntity = new Log(username);

        log.info("MemberRepository 호출 시작");
        memberRepository.save(member);
        log.info("MemberRepository 호출 종료");

        log.info("LogRepository 호출 시작");
        try {
            logRepository.save(logEntity);
        } catch (RuntimeException e) {
            log.info("로그 저장에 실패했습니다. logMessage = {}", logEntity.getMessage());
        }
        log.info("LogRepository 호출 종료");
    }
}
  • 간단한 Member 관련 서비스 코드이다. joinV1, joinV2 메서드가 있다.
  • 각 메서드에 @Transactional 애노테이션을 달았고, 아무런 옵션도 주지 않았으니 전파 옵션은 기본값인 REQUIRED이다.

MemberRepository

package cwchoiit.tx.propagation;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em;

    @Transactional
    public void save(Member member) {
        log.info("Saving member {}", member);
        em.persist(member);
    }

    public Optional<Member> findById(String username) {
        return em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList()
                .stream()
                .findFirst();
    }
}
  • MemberRepository에서 save() 메서드를 보면 이 메서드에도 @Transactional 애노테이션이 달려있다.

LogRepository

package cwchoiit.tx.propagation;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            log.info("Log 저장 시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {
        return em.createQuery("SELECT l FROM Log l WHERE l.message = :message", Log.class)
                .setParameter("message", message)
                .getResultList()
                .stream()
                .findFirst();
    }
}
  • LogRepository에도 save() 메서드에 @Transactional 애노테이션이 달려있다.

이런 상태에서 MemberService.joinV1(), joinV2() 호출하면, MemberRepository.save()도 호출하고, LogRepository.save()도 호출한다. 그리고 joinV1, joinV2@Transactional이 붙어있기 때문에 트랜잭션을 최초로 시작하는 지점은 이 곳이고, MemberRepository.save()LogRepository.save()에 붙어있는 @Transactional은 전파 옵션이 REQUIRED이므로 기존 트랜잭션에 참여한다. 

 

즉, 외부 트랜잭션, 내부 트랜잭션, 내부 트랜잭션 이렇게 총 세개의 논리 트랜잭션이 있게 되고, 모두가 커밋해야 물리 트랜잭션이 커밋이 되고 하나라도 롤백이면 물리 트랜잭션은 롤백이다. 이게 REQUIRED 옵션이니까. 그런데 이 코드는 사용자가 회원가입을 할때 호출되는 코드인데 회원가입 시 비즈니스 규칙으로 Log라는 엔티티에 해당 유저를 저장하는 부가 기능이 있을뿐이다. LogRepository.save()에서 어떤 예외가 발생했다고 해서 사용자 회원가입까지 못시키게 해버리면 사용자 입장에서는 원인도 모르고 그냥 회원가입이 안되는 상황을 마주하게 된다. 그게 싫어서 MemberService.joinV2()에서 LogRepository.save()의 예외가 올라오면 그 예외를 잡아버린다고 코드를 작성한 것이다. 이런다고 롤백이 안되는게 아니다! 이미 LogRepository.save()에서 실행된 논리 트랜잭션은 Rollback-only 마크를 찍었다. 그럼 외부 트랜잭션에서 정상흐름으로 커밋을 하더라도 절대 물리 트랜잭션은 커밋되지 않는다. 테스트 코드로 테스트를 해보자.

/**
 * memberService     @Transactional: ON
 * memberRepository  @Transactional: ON
 * logRepository     @Transactional: ON Exception
 */
@Test
void recover_exception_fail() {
    String username = "로그예외_recover_exception_fail";

    assertThatThrownBy(() -> memberService.joinV2(username)).isInstanceOf(UnexpectedRollbackException.class);

    assertThat(memberRepository.findById(username)).isEmpty();
    assertThat(logRepository.find(username)).isEmpty();
}
  • joinV2를 호출하는데 이 메서드는 분명히 UnexpectedRollbackException을 던질 것이다.
  • 그리고 데이터베이스에 Log 엔티티 뿐 아니라, Member 엔티티 역시 저장되지 않았을 것이다. 물리 트랜잭션은 롤백이 되기 때문에.

실행 결과

실행 결과를 보면 테스트는 통과하고 로그는 커밋을 시도했는데 트랜잭션에 rollback-only 마크가 찍혀있기 때문에 롤백을 한다라는 로그가 뚜렷히 보인다. 이 경우, 어떻게 해결할 수 있을까? 가장 간단한 건 바로 다음에 소개할 REQUIRES_NEW 전파 옵션을 사용하면 된다.

 

또다른 방법으로는 MemberRepository.save(), LogRepository.save() 이 두 개가 각각의 트랜잭션을 사용하면 된다. 아 물론 REQUIRES_NEW도 각각의 트랜잭션인데 MemberRepository.save()는 기존 트랜잭션에 참여하는 상태이고(왜냐하면, MemberService.joinV2()@Transactional이 있기 때문에 여기가 최초의 트랜잭션 시작점이라 그렇다) LogRepository.save()는 새로운 트랜잭션을 열어버리는게 REQUIRES_NEW인데, 아예 각각의 트랜잭션이 둘 다 물리 트랜잭션이 되게끔 MemberService.joinV2()에 있는 @Transactional을 없애버리면, 각각 트랜잭션이 둘 다 물리 트랜잭션이 되는 그런 경우로도 해결할 수 있다.

 

근데 이 방법의 문제는, 트랜잭션의 가장 중요한 특성인 원자성을 해친다는 점이다. 결국은 멤버를 저장하고, 로그를 저장하는 작업은 MemberService.joinV2()안에서 모두 일어나는 건데 이 joinV2()가 트랜잭션을 시작해야 이 안에서 일어난 모든 작업이 성공하거나 모든 작업이 실패하게 할 수 있다. 그런데 여기에 트랜잭션을 거는게 아니라, 각각의 save() 메서드에 걸어버리면 어떤건 성공하고 어떤건 실패하는 경우가 되는 것이다. 이게 만약 출금과 입금 작업이라면 큰 문제가 된다. 그러나 지금같은 비즈니스 규칙이 생겨서 로그를 저장하는데 실패하더라도 멤버는 회원가입 시키게 하고 싶다라는 룰이 프로젝트에 만들어졌으면 이런 방식으로도 해결을 할 수 있다는 말이다. 그래서 정답이 딱 있는게 아니라는 말을 하고 싶었다. 

 

 

propagation - REQUIRES_NEW

REQUIRES_NEW는 위에서 설명한 REQUIRED와 다르게 새 트랜잭션을 사용하는 내부 로직이 있으면, 이전 트랜잭션과 완전히 분리된 새로운 트랜잭션에서 동작하는 방식이다. 즉, 또다른 커넥션이 사용된다.

 

기존 트랜잭션(A)이 있는 경우 해당 트랜잭션을 잠시 보류한 뒤 새로운 트랜잭션(B)을 만들어서 그 트랜잭션(B)을 사용하고 내부 로직이 모두 종료되는 시점에 트랜잭션(B)을 커밋 또는 롤백한 후 기존 트랜잭션(A)이 다시 사용된다.

 

그림으로 보면 다음으로 이해할 수 있다.

즉, 완전히 분리된 물리 트랜잭션을 가지며 어느 한쪽이 다른 한쪽에게 영향을 주지 않는다. 다시 말해 기존 물리 트랜잭션이 커밋이나 롤백을 하던, 새로운 물리 트랜잭션이 커밋이나 롤백을 하던 아무런 영향을 서로에게 주지 않는다. 자기 할 것을 한다. 이것도 코드로 한번 봐보자.

@Test
void required_new() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 새 트랜잭션을 만듦.
    TransactionStatus inner = txManager.getTransaction(definition);
    log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());

    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 내부 트랜잭션(inner)을 시작할 때 전파 옵션인 propagationBehaviorPROPAGATION_REQUIRES_NEW 옵션을 주었다.
  • 이렇게 하면 내부 트랜잭션을 시작할 때 기존 트랜잭션에 참여하는 게 아니라 새로운 물리 트랜잭션을 만들어서 시작하게 된다. 
  • 당연히, 각각의 커밋과 롤백이 따로 수행된다.

실행 결과

  • 실행 결과를 보면 외부 트랜잭션, 내부 트랜잭션의 각각 커넥션이 conn0, conn1로 다른 것을 알 수 있고, 내부 트랜잭션을 롤백할때 바로 트랜잭션 롤백 로그가 남는 것을 확인할 수 있다. 
  • 외부 트랜잭션을 커밋할때도 커밋 로그가 따로 남는 것을 확인할 수 있다.

주의

REQUIRES_NEW를 사용할 때 주의할 점은 커넥션이 쉽게 고갈될 수 있다. 만약 고객이 10명 접속했다고 하면 이 전파 옵션을 사용할 경우 쉽게 말해 20개의 커넥션을 사용할 수 있다. 내부 트랜잭션을 모두 이 옵션으로 사용한 경우에 해당하는 말이지만. 그래서, 이 옵션은 주의하면서 사용해야 한다.

 

문제 해결

아까 위에서 겪었던 MemberService, MemberRepository, LogRepository 이 문제를 REQUIRES_NEW로 해결해보자! 건드릴 부분은 딱 한 부분이다. LogRepositorysave() 메서드에 붙어있는 @Transactional에 전파 옵션을 REQUIRES_NEW로 변경만 해주면 된다. 아래 코드를 보자.

package cwchoiit.tx.propagation;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            log.info("Log 저장 시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }
	
    ...
}
  • @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하므로써, 이 메서드가 호출되면 아예 새로운 커넥션을 만든다. 즉, MemberService.joinV2()에서 만든 커넥션과는 별개의 커넥션으로 동작하므로, 이 LogRepository.save()에서 롤백을 하더라도 MemberService.joinV2()에는 아무런 영향을 끼치지 않는다. 
  • 테스트 코드로 실행해보자.
/**
 * memberService     @Transactional: ON
 * memberRepository  @Transactional: ON
 * logRepository     @Transactional: ON (REQUIRES_NEW)
 */
@Test
void recover_exception_success() {
    String username = "로그예외_recover_exception_success";

    memberService.joinV2(username);

    assertThat(memberRepository.findById(username)).isNotNull();
    assertThat(logRepository.find(username)).isEmpty();
}
  • joinV2()는 이제 어떠한 에러도 던지지 않을 것이다. 별개의 트랜잭션을 사용하기 때문에 LogRepository.save()가 롤백을 하더라도 아무런 영향을 받지 않는다.
  • 그렇게 되면, 멤버는 잘 저장이 되고 로그는 남지 않은 그런 상태가 된다. 

실행 결과

 

isolation

트랜잭션 격리 수준을 지정할 수 있다. 기본값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 'DEFAULT'이다. 대부분 데이터베이스에서 설정한 기준을 따른다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다. 

  • DEFAULT: 데이터베이스에서 설정한 격리 수준을 따른다.
  • READ_UNCOMMITTED: 커밋되지 않은 읽기 (특정 레코드를 변경 후 커밋하지 않았을 시점에서도 변경된 값을 적용해서 데이터베이스를 통해 읽어올 수 있음을 의미)
  • READ_COMMITED: 커밋된 읽기 (특정 레코드를 변경 후 커밋하지 않으면 변경된 값이 아닌 직전 커밋 데이터를 읽음)
  • REPEATABLE_READ: 반복 가능한 읽기
  • SERIALIZABLE: 직렬화 가능

timeout

트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정. 기본값은 트랜잭션 시스템의 타임아웃을 사용한다. 운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 꼭 확인하고 사용해야 한다.

 

label

트랜잭션 애노테이션에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 때 사용할 수 있다. 일반적으로 사용하지 않는다.

 

readOnly

트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다. `readOnly=true` 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 동작한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다) 그리고 이 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.

 

readOnly 옵션은 크게 3가지에 적용된다.

  • 프레임워크
    • JdbcTemplate은 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던진다.
    • JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 `flush()`를 호출하지 않는다. 읽기 전용이니 변경에 사용되는 플러시를 호출할 필요가 없다. 추가로 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않는다. 이렇게 JPA에서는 다양한 최적화가 발생
  • JDBC 드라이버 (DB와 드라이버 버전에 따라 다르게 동작하기 때문에 사전 확인 필요)
    • 읽기 전용 트랜잭션에서 변경 쿼리가 발생하면 예외를 던진다.
    • 읽기, 쓰기 데이터베이스를 구분해서 요청한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다.
  • 데이터베이스
    • 데이터베이스에 따라 읽기 전용 트랜잭션의 경우 읽기만 하면 되므로, 내부에서 성능 최적화가 발생한다.

 

 

체크 예외 / 언체크 예외(런타임 예외) 발생 시 커밋 또는 롤백

위 속성을 설명하면서 스프링 트랜잭션의 기본 설정으로 체크 예외가 발생하면 커밋, 언체크 예외가 발생하면 롤백한다고 했다.

실제 그렇게 동작하는지 확인해보자.

 

package com.example.springtx.exception;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.*;

@Slf4j
@SpringBootTest
public class RollbackTest {

    @Autowired RollbackService rollbackService;

    @Test
    void runtimeException() {
        assertThatThrownBy(rollbackService::runtimeException)
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        assertThatThrownBy(rollbackService::checkedException)
                .isInstanceOf(MyException.class);
    }

    @Test
    void checkedRollbackFor() {
        assertThatThrownBy(rollbackService::rollbackFor)
                .isInstanceOf(MyException.class);
    }

    @TestConfiguration
    static class RollbackTestConfig {

        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }

    static class RollbackService {

        /**
         * 런타임 예외(언체크 예외)시 롤백 확인
         * */
        @Transactional
        public void runtimeException() {
            log.info("call runtimeException");
            throw new RuntimeException();
        }

        /**
         * 체크 예외 발생 시 커밋 확인
         * */
        @Transactional
        public void checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        /**
         * 체크 예외 rollbackFor 지정 시 롤백 확인
         * */
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call checkedException rollbackFor");
            throw new MyException();
        }
    }

    static class MyException extends Exception {

    }
}

 

RollbackService 클래스에는 세 개의 메소드가 있다. runtimeException(), checkedException(), rollbackFor().

 

runtimeException() 메소드를 실행하면 언체크 예외를 발생시키고 이는 롤백을 하게 된다.

그 결과를 확인하는 테스트 코드는 RollbackTest.runtimeException()이다. 

 

그리고 실제 커밋을 하는지 롤백을 하는지 로그로 확인해보기 위해서는 다음 설정이 application.yml 파일에 필요하다.

logging:
  level:
    org.springframework.transaction.interceptor: TRACE
    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG
    org.hibernate.resource.transaction: DEBUG

 

이제 실행해보자.

RollbackTest.runtimeException() 결과

로그를 살펴보면 rollback이 실행됐음을 알 수 있다.

 

 

그 다음, 체크 예외를 발생시키면 커밋을 하는 것을 확인하는 테스트 코드는 RollbackTest.checkedException()이다.

RollbackTest.checkedException() 결과

커밋 로그가 찍힌다.

 

이제 체크 예외를 발생시켰지만 rollbackFor로 해당 체크예외를 지정해 놓으면 커밋이 아닌 롤백을 하는지 확인해보는 테스트 코드는 RollbackTest.checkedRollbackFor()이다.

 

RollbackTest.checkedRollbackFor()결과

롤백 로그가 찍힌다.

그럼 왜 체크 예외는 커밋이고 언체크 예외는 롤백일까?

 

예외와 트랜잭션 커밋, 롤백

스프링은 왜 체크 예외는 커밋, 언체크 예외는 롤백할까?

스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임 예외는 복구가 불가능한 예외로 가정한다.

체크 예외 자체를 개발자가 이 예외가 터지는 것 자체가 비즈니스적으로 의미가 있다라고 판단한 예외라고 스프링은 가정하는 것.

 

비즈니스적으로 의미가 있다라고 판단한 예외란 또 어떤걸까? 아래와 같은 비즈니스 요구사항이 있다고 가정해보자.

비즈니스 요구사항

  • 정상 처리: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 '완료'로 처리한다.
  • 시스템 예외: 주문시 내부 복구 불가능한 예외(SQL 에러, 네트워크 에러, 시스템 장애 등)가 발생하면 전체 데이터를 롤백한다.
  • 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 '대기'로 처리한다. 이 경우, "고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다."

이 때, 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정해보면, 이 예외는 시스템에 문제가 있어서 발생하는 예외가 아니다. 시스템은 정상 동작해도 비즈니스 상황적으로 문제가 되기 때문에 발생한 예외이다. 이런 예외를 비즈니스 예외라고 한다. 위 요구사항에서 결제 상태를 대기로 처리하는 것 자체가 롤백될 이유가 없다는 소리다.

 

중요한 건 예외는 시스템 예외비즈니스 예외로 항상 분기되고 비즈니스 예외는 시스템적으로 문제가 생긴게 아닌것임을 인지하는것이 중요하다.

 

728x90
반응형
LIST

+ Recent posts