본문 바로가기

Spring

[Spring Boot] 프로젝트 템플릿 만들기(2) - JPA 관련 세팅

SI 프로젝트 특성 상 Kick Off 전에 고객의 요구사항이 어떤 것이 있는지 분석하게 된다. 아직까지 내가 경험한 요구사항 중에서 서버의 기술 스택을 특별히 요구하는 경우는 없었던 것 같다.

 

그래서 초기 DB 를 설계할 때, 어떤 식으로 구성할지 개발자가 직접 선택할 수 있다는 장점이 있었다. 아직은 내가 프로젝트의 서버 구성을 리딩할 연차는 되지 않았기 때문에, 프리랜서의

 

내가 자주 사용하던 방법은 MySQL + JPA 조합의 DB 구성이였는데, 개발을 리딩하는 프리랜서 분들은 대부분 Mybatis를 사용하고 있었다. 두 개를 모두 경험해보면서 내가 편리하다고 느낀 것은 JPA 였다.

 

 

SQL Mapper vs ORM


서버에서 DB로 쿼리를 작성해서 내보내기 위해서 2가지의 방법이 있다.

 

SQL Mapper

  • 서버의 객체와 DB의 필드를 매핑하여 데이터를 객체화하여 리턴한다.
  • SQL을 직접 작성하여 어떤 객체에 매핑할지 직접 바인딩 한다.
  • DBMS에 종속적이다.

ex) MyBatis

 

ORM

  • 서버의 객체와 DB 테이블을 매핑한다.
  • 객체와 테이블 간의 관계를 설정한다.
  • 반복적인 쿼리를 작성할 필요가 없다.
  • DBMS에 종속적이지 않다.

ex) JPA

 

 

내가 느낀 Mybatis의 단점은 다음과 같다.

 

DB 스키마 변경 시, 협업 중인 다른 개발자들도 스키마를 전부 변경해주어야 한다.

 

아직 개발 서버가 구축되지 않았을 때, 로컬에 따로 DB를 만들어 작업을 진행하게 된다. 이 때 스키마가 변경되었을 때, 다른 개발자들도 변경된 스키마를 매번 반영해주어야 한다는 번거로움이 있었다. 사실, DB 스키마는 변경되면 안된다는게 암묵적인 원칙이지만 내가 경험한 프로젝트에서는 아주 빈번하게 발생했다. 

 

>> Hibernate DDL Auto 로 자동화 가능

 

쿼리 문법오류 (Human Error)

 

개발자의 실수로 발생하는 문법오류로 인해 에러가 발생하는 경우가 잦았다.

런타임 에러가 발생하고, 프론트가 쿼리 수정 요청을 하여 다시 반영될 때 까지 소모되는 리소스 낭비가 많았다. 

 

>> Spring Data JPA 쿼리 메소드 / QueryDSL 사용하여 컴파일 단계에서 디버깅

 

반복적인 쿼리 작성

 

간단한 데이터를 가져올 때도 쿼리를 반복적으로 작성해야했다. 한 개의 쿼리로 여러 작업을 커버할 수 있도록 동적 쿼리를 작성할 수 있으나, 너무 많은 if 절이 생성될 경우 가독성이 떨어졌다.

 

>> 단순 쿼리는 쿼리 메소드로 대체 가능

 

 

위와 같은 이유로 JPA + QueryDSL 조합의 ORM 을 채택하였으며, 구현체는 가장 대중적인 Hibernate를 사용하기로 결정했다.

 

 

 

Repository 계층 구조


 

Entity는 다음과 같이 선언하였다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Member extends BaseTimeEntity {
    @Id @GeneratedValue
    private Long id;

    @Column
    private String memberId;

    @Column
    private String deleted;

    @Builder
    public Member(String memberId, String deleted) {
        this.memberId = memberId;
        this.deleted = deleted;
    }
}

 

뼈대만 만드는 목적이기 때문에, id와 MemberId 정도만 선언해두었다. deleted 필드는 Soft Delete 목적으로 선언해두었다.

 

데이터 작성 시간, 수정 시간을 기록하기 위해 BaseTimeEntity를 확장하였다.

 

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;

}

 

@MappedSuperclass 로 선언하여 각 JPA 엔티티 클래스에서 자동으로 상속받아 사용하도록 설정했다.

@CreatedDate는 엔티티 생성 시의 시간이 입력되며, @LastModifiedDate는 엔티티 수정 시의 시간이 입력될 것이다.

 

@EntityListners로 엔티티의 변경사항을 추적하는 AuditingEntityListener 클래스를 설정해주었다.

 

 

 

데이터에 접근할 계층으로는 두 개의 인터페이스를 선언했다.

 

 

MemberRepository

// Query Method 사용 시 등록한다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long>, QueryMemberRepository {
    Optional<Member> findByMemberId(String memberId);
}

 

일반적인 JPA Repository 의 형태이다. 이 곳에서는 비교적 간단한 쿼리 메소드만을 선언해 사용할 것이다. 서비스 단에서 의존성 주입을 할 때 MemberRepository 만을 선언해 사용할 수 있도록 QueryMemberRepository를 확장하였다.

 

 

QueryMemberRepository

// Query Method 가 아닌 일반 쿼리문을 작성할 때 사용한다.

public interface QueryMemberRepository {
    List<Member> queryFindAllMember();
}

 

QueryDSL 관련 쿼리를 작성할 인터페이스이다. 해당 인터페이스의 구현체는 아래와 같다.

 

 

QueryMemberRepositoryImpl

@Repository
@RequiredArgsConstructor
public class QueryMemberRepositoryImpl implements QueryMemberRepository {

    private final JPAQueryFactory jpaQueryFactory;
    @Override
    public List<Member> queryFindAllMember() {
    QMember member = QMember.member;
        return jpaQueryFactory
                .select(member)
                .from(member)
                .fetch();
    }
}

 

해당 구현체에서 미리 Configuration 을 통해 빈으로 등록된 JPAQueryFactory를 주입받아 DB에 접근하게될 것이다.

 

 

실제 사용 Case

private final MemberRepository memberRepository;
    @Transactional(readOnly = true)
    public ResponseDto find() {

        List<Member> members = memberRepository.queryFindAllMember();

        if(members == null || members.size() == 0){
            return ResponseDto.success("존재하는 유저가 없습니다.");
        }

        // 컬렉션 내부 타입 변환
        return ResponseDto.success(members.stream()
                .map(memberConverter::convertDataToDto)
                .collect(Collectors.toList()));
    }

 

MemberRepository에서 이미 QueryMemberRepository 인터페이스를 확장했기 때문에, MemberRepository 만으로 두 계층의 함수를 모두 사용할 수 있다.

 

 

Entity - Dto 간의 변환

 

 

[Spring Boot] 프로젝트 템플릿 만들기

진행 배경 회사에서 프로젝트를 진행하면서 타인이 작성한 코드를 유지보수 하거나, 이미 잡혀있는 틀에 개발을 진행하는 경우가 많았다. 내가 직접 프로젝트의 틀을 잡지 않았다보니, 이해되

9401ndk.tistory.com

 

해당 포스팅에서 언급했지만, Entity - DTO 간의 변환을 위해서 MapStruct를 사용할지 고민하다가 결국 사용하지 않았다.  설정이 복잡해질 경우 처음 코드를 접하거나, MapStruct를 사용해보지 않은 개발자가 이해하기가 어려울 거라는 판단이 들었다. DTO >> Entity / Entity >> DTO 두 개의 케이스가 있고 어떤 DTO를 변환하느냐에 따라 또 함수가 추가되어야한다는 번거로움이 있지만, 일단은 한 개의 변환 클래스를 구현했다.

 

 

MemberConverter

// DTO - Entity 간 변환을 수행할 클래스
@Component
public class MemberConverter {

    public MemberInfoResponse convertDataToDto(Member member){
        return MemberInfoResponse.builder()
                .memberId(member.getMemberId())
                .build();
    }
}