티스토리 뷰

반응형
자세한 내용은 Github를 참고해주시기 바랍니다.

오늘은 레포지토리에 대해 얘기를 해보겠다. 레포지토리는 일단 설계부터 추상화를 이용해 구체화에 의존하지 않는 것이 핵심이라고 생각한다.

구체화에 의존하는게 왜?

객체지향을 공부했더라면 알 것이다. 구체화에 의존하는 것은 유지보수성을 떨어트린다. 우리가 알아야하는건 저장소이지 구체적으로 어떤 저장소인진 알아야할 이유가 전혀 없다. 오히려 그렇게 된다면 저장소가 바뀔 때마다 우리는 그에 맞춰 서비스도 바꿔주어야한다. 예를들어 우리는 QueryDSL을 이용하다가 갑자기 JOOQ로 넘어가야하는 상황을 가정해보자 그럴려면 우리는 JOOQ에 관련된 레포지토리를 만들고 그걸 서비스에 주입시켜줘야한다. 거기서 만약에 MyBatis까지 넣어준다면 MyBatis에 관련된 레포지토리를 또 주입해주어야한다. 이렇게 되면 MyBatis에 있던 메소드가 JOOQ로 넘어가고 그게 또 QueryDSL로 넘어가면  그 때마다 우리는 불편하지만 서비스를 계속 수정해줘야한다. 이것은 유지보수에 불리한 설계이고 반드시 이걸 하나로 합쳐야할 필요가있다.

 

다형성을 이용한 추상화 전략

추상화에 의존하기 위해 본 프로젝트에서는 EntityRepository를 추상화 시켰고 구현체를 만들어주었다.

interface간의 extends는 확장의 개념으로 생각하면 편하다. JpaRepository와 CustomRepository를 상속받아 구현체들을 확장시켜 EntityRepository를 하나의 추상화된 Repository로 만들었다. 이제 이렇게 되면 구현체인 SimpleJpaRepository와 CustomRepositoryImpl의 구현체가 되는 것이다. 이를 이용하여 우린 결국 엔티티에 대한 저장소만 알면 나머지 구체적인건 추상화된 EntityRepository만 알면 사용가능하다.

 

그렇다면 코드를 살펴보자 

 

MemberRepository.java

public interface MemberRepository extends JpaRepository<Member, Long>, MemberCustomRepository {

    boolean existsByEmail(final Email email);
    boolean existsByNickname(final Nickname nickname);
}

 

사전에 미리 말씀 드리자면 method namedQuery가 아닌이상 무조건 QueryDSL을 사용했다. 이유는 간단하다. 만약 복잡한 쿼리를 JPQL로 표현하려면 다음과 같다. 

  @Query("select new me.golf.blog.domain.member.dto.CustomDto(" +
            "b.title, b.content, m.nickname," +
            " b.status, b.createTime, b.lastModifiedTime) " +
            "from Member as m left join Board as b on m.id = b.memberId " +
            "where m.nickname = :nickname order by b.id")
    Optional<CustomDto> findMember(@Param("nickname") Nickname nickname);

당장은 생각보다 괜찮다고 생각하겠지만 만약에 join이 더 늘어난다면?? 또는 컬럼이 더 늘어난다면 외에도 여러가지 상황이 있을 수 있다. 또한 지금도 너무 복잡하다.... 그리고 Projection을 하기 위해 모든 패키지를 정보를 넣어야하는 사실도 너무 불편하다. 그렇기 때문에 가독성을 높이기 위해 JPQL을 지양하고 QueryDSL을 사용했다. 또한 동적 쿼리를 사용할 수 있는 점도 QueryDSL을 쓰는 이유이다.

 

자 그렇다면 methodQuery부터 보자 

boolean existsByEmail(final Email email);
boolean existsByNickname(final Nickname nickname);

원래는 이 쿼리도 QueryDSL을 쓰려고 했다. 이유는 다음 블로그를 참고하시길 바란다. 

https://jojoldu.tistory.com/516

 

JPA exists 쿼리 성능 개선

Spring Data Jpa를 사용하다보면 해당 조건의 데이터가 존재하는지 확인 하기 위해 exists 쿼리가 필요할때가 많습니다. 간단한 쿼리의 경우엔 아래와 같이 메소드로 쿼리를 만들어서 사용하는데요. b

jojoldu.tistory.com

요약하지면 JPA에서 exists 쿼리를 지원하지 않아 count 쿼리로 1보다 큰지 유무를 반환하는 SQL쿼리는 풀 스캔을 하기 때문에 불필요하게 성능이 좋지 않다. 그래서 QueryDSL에 limit나 fetchOne을 이용하려고 했지만 Spring Data JPA 메서드 쿼리를 사용하면 간단하게 성능 이슈가 사라진다. 그렇다면 날라가는 쿼리를 확인해보자

 

Hibernate: 
    select
        member0_.member_id as col_0_0_ 
    from
        member member0_ 
    where
        (
            member0_.activated = 1
        ) 
        and member0_.email=? limit ?
Hibernate: 
    select
        member0_.member_id as col_0_0_ 
    from
        member member0_ 
    where
        (
            member0_.activated = 1
        ) 
        and member0_.nickname=? limit ?

 

보다 싶이 limit를 걸어서 한 개 이상이 존재한다면 true를 반환하고 있다. 찾는 순간 검색이 종료되기 때문에 인덱스만 잘 걸어 놓으면 매우 빠른 성능으로 조회가 가능하다. 

 

그렇다면 이제 QueryDSL 구현부를 살펴보자.

 

MemberCustomRepository.java

public interface MemberCustomRepository {
    Optional<CustomUserDetails> findUserDetailsByEmail(final Email email);
    
    Optional<CustomUserDetails> findByIdWithDetails(final Long memberId);

    PageCustomResponse<MemberAllResponse> findAllWithSearch(final MemberSearch memberSearch, final Pageable pageable);

    void increaseBoardCount(final Long memberId);
}

MemberCustomRepositoryImpl.java

@Repository
@RequiredArgsConstructor
public class MemberCustomRepositoryImpl implements MemberCustomRepository {
    private final JPAQueryFactory query;

    public Optional<CustomUserDetails> findUserDetailsByEmail(final Email email) {
        return Optional.ofNullable(
                query.select(Projections.constructor(CustomUserDetails.class,
                                member.id.as("id"),
                                member.email,
                                member.role))
                        .from(member)
                        .where(member.email.eq(email))
                        .fetchOne());
    }

    public Optional<CustomUserDetails> findByIdWithDetails(Long memberId) {
        return Optional.ofNullable(
                query.select(Projections.constructor(CustomUserDetails.class,
                                member.id.as("id"),
                                member.email,
                                member.role))
                        .from(member)
                        .where(member.id.eq(memberId))
                        .fetchOne());
    }

    public PageCustomResponse<MemberAllResponse> findAllWithSearch(final MemberSearch memberSearch, final Pageable pageable) {
        List<MemberAllResponse> members = query.select(Projections.constructor(MemberAllResponse.class,
                        member.id,
                        member.email,
                        member.nickname,
                        member.name))
                .from(member)
                .where(
                        EQ_NICKNAME.eqMemberField(memberSearch.getNickname()),
                        EQ_EMAIL.eqMemberField(memberSearch.getEmail())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        if (members.size() == 0) {
            return PageCustomResponse.of(Page.empty());
        }

        JPAQuery<Long> count = query.select(member.count())
                .from(member);

        return PageCustomResponse.of(PageableExecutionUtils.getPage(members, pageable, count::fetchFirst));
    }

    @Override
    public void increaseBoardCount(final Long memberId) {
        query.update(member).set(member.memberCount.boardCount, member.memberCount.boardCount.add(1))
                .where(member.id.eq(memberId)).execute();
    }
}

 

QueryDSL은 3가지 포인트로 보자.

 

1. Projections.constructor

2. countQuery와 PageableExecutionUtils.getPage(content, pageable, count쿼리 호출 메소드)

3. execute()를 이용한 update연산

 

먼저 Projections.constructor는 DTO AllArg 생성자에 접근하여 DTO 기반의 쿼리를 날릴 수 있도록 QueryDSL에서 지원하는 문법이다. 이를 이용하여 우리는 DB와 애플리케이션간에 통신 비용을 줄일 수도 있고 인덱스를 걸어 커버링 인덱스를 이용하여 빠른 조회를 해올 수 도 있다. 그렇기 때문에 본 필자는 조회 기능 구현 시 자주 쓰는 기능이다. 관련해서 좋은 영상이 하나 있는데 꼭 보시길 바란다.

https://www.youtube.com/watch?v=zMAX7g6rO_Y&t=629s 

(동욱님은 진짜 꼭 본받도록 하자.... 대단하신 분 ㅠㅠ)

 

다음으로는 execute를 이용한 update Query이다. 상당히 중요한 부분인데 이 쿼리를 사용하는 서비스를 살펴보자. 

@Transactional
public Long create(final Board board, final Long memberId) {

    existTitle(board.getTitle());
    Board savedBoard = boardRepository.save(board.addMember(memberId));
    memberRepository.increaseBoardCount(memberId);
    return savedBoard.getId();
}

게시판을 생성할 때 회원 게시판 갯수 카운트를 올려주는 쿼리로 쓰이고 있다. 왜 근데 이렇게 할까? 더티체킹이 있는데??

이유는 다음과 같다.

 

  1. 더티체킹을 하기위해선 Member의 snapshot이 영속성 컨텍스트에 저장되어야한다. <- 이는 큰 메모리 낭비와 불필요한 select문이
    날라간다.

  2. 다시 가져올 이유도 없고 데이터를 바로 불러올 필요도 없다. <- JPA에서는 변경 감지를 통해 컨텍스트에 값을 변경해주고 select시 컨텍스트에 있는 값을 가져온다. (JPQL은 select로 DB에서 가져오고 컨텍스트와 값을 비교한 후 다르다면 context값을 가져온다.)
    그렇지만 다시 가져올 이유가 없으니 바로 데이터만 수정하면 된다.

  3. 동시성을 간편하게 관리한다. <- 기본적으로 애플리케이션에서 동시성을 관리하기란 쉽지 않다. scale out이 되어 서버가 늘어나면 분산락을 고려해야할 뿐만 아니라 여러가지로 골치 아파진다. 하지만 DB는 서버에 비해 scale out을 많이 하지 않는다. 샤딩만 하더라도 어느정도의 트래픽 분산이 가능하기 때문이다. 
    아무튼!! MySQL InnoDB에서는 레코드 단위로 Lock이 걸려 외부에서 해당 Record에는 접근을 할 수 없다. 그리고 DB도 하나만 존재하기 때문에 비교적 간편하게 동시성을 관리할 수 있다.

(실제로 더티체킹을 이용하여 게시판 수를 증가 시켰을 때 동시성 이슈가 발생했었고 위와 같이 동시성 이슈를 해결할 수 있었다.)

 

위와 같은 이유로 더티체킹이 아닌 update 쿼리를 직접 날렸다!!!

 

자 이제 회원에 대한 구현이 다 끝났다. 이 후 게시판 구현에 여러가지가 있지만 고민이다... 회원 게시판에서 설명한 것만 잘 따라가도 충분히 여러분들이 나머지 기능 구현을 할 때 힘들지 않을 것이다... 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함