JPA 시작하기

1. JPA란?

1) JPA 도입 이전의 문제들

프로그램에서 다루는 데이터를 계속해서 조회하고 보관하기 위해서는 DB(데이터 베이스)를 사용해야 합니다. 개발자들은 어쩔 수 없이 DB를 조작하고 관리하는 명령어인 SQL에 익숙해졌고, 어쩔 때는 비즈니스 로직을 위한 프로그래밍보다 SQL문 구현에 더 많은 시간을 들이게 됩니다.

특히 객체지향 프로그램에서 DB를 사용하기 위해 SQL문을 직접 다루면서 많은 불편한 점이 부각되었습니다.

  1. 개발자들이 가장 싫어하는 반복적인 작업과 중복되는 코드
  2. SQL 수정이 불러오는 나비효과
  3. 객체 지향의 매력을 누릴 수 없는 엔티티 구조
  4. 이외의 많은 귀찮은 문제들…😕

이 모든 것을 해결하기 위해 JPA를 도입하게 됩니다.

2) 그래서 JPA란?

JPA(Java Persistence API) 는 자바 언어 기반 개발을 위한 ORM(Object-Relational Mapping) 기술 표준입니다.

  • Persistence API는 영속적인 API라고 직역할 수 있습니다. 프로그램 실행이 종료되더라도 데이터는 영속적으로 존재하기 위해 DB를 사용해야 하는데, 이를 위해 개체(Entity)를 DB에 영속적으로 저장하고 관리하기 위한 API를 의미합니다.
  • ORM은 객체와 관계형 DB를 매핑하는 것을 말합니다. 프로그램에서 데이터를 표현하기 위해 타입을 정의하여 하나의 인스턴스(객체)를 생성했다면, 이를 관계형 DB의 테이블 구조와 칼럼 구조에 맞게 매핑하는 과정이 필요합니다.

객체를 DB에 저장하기 위해 직접 SQL문(Insert문)을 사용하는 대신, 마치 자바 컬렉션(자료구조)에 객체를 저장하는 것과 같이 객체 구조에 맞게 알아서 DB 매핑, CRUD 등을 구현해주는 기술을 ORM 기술이라고 합니다. 바로 JPA가 여기에 속하죠!

Java
// 객체 생성
Member member = new Memeber("김싸피", 25);

// 1. SQL문으로 직접 Create 구현
String sql = "INSERT INTO MEMBER(MEMBER_NAME, MEMBER_AGE) VALUES (?, ?)";
pstmt.setString(1, member.getMemberName());
pstmt.setInt(2, member.getMemberAge());
pstmt.executeUpdate(sql);

// 2. jpa로 Create 구현
jpa.persist(member);
// 어딘가 모르게 컬렉션에 저장하는 것과 비슷해 보이지 않나요?
list.add(member);
  • JPA는 자바 ORM 기술에 대한 표준 API 명세입니다. JPA는 여러 편리한 인터페이스들이 구성되어있는 묶음이죠. 따라서 이를 사용하기 위해서는 인터페이스를 구현한 구현체가 있어야 하고, 가장 널리 사용되는 구현체(ORM 프레임워크)가 바로 하이버네이트(Hibernate)입니다.

3) JPA가 문제를 해결하는 방법

앞서 언급한 문제들을 JPA는 어떻게 해결했을까요?

  1. 직접 SQL문을 작성하지 않아도 된다!

    • JPA에 저장할 객체 자체를 바로 전달하면 됨
    • 반복적인 CRUD 작업 생략
    • DDL문 자동 생성 가능
  2. 개체가 변하거나 DB가 변해도 JPA가 맞춤 수정해준다

    • 개체에 필드가 추가되거나 DB 칼럼이 변경되어도 개발자는 JPA에 객체 자체를 전달하므로 관련 코드를 일일히 변경할 일이 없음
  3. 개체와 DB 간 간극을 줄여 객체지향 프로그래밍을 지향한다

    • 두 개체 간 연관관계를 표현하기위해 개체는 참조를, 두 테이블 간 연관관계를 표현하기 위해 DB는 외래키를 사용함
    • JPA는 참조를 외래키로 자동 변환하여 적절한 SQL문을 수행
  4. 다양한 DB 벤더를 독립적으로 사용하는 것은 기본

    • Oracle, MySQL, H2 등 DB 벤더 간 자유로운 교체 가능
    • Dialect 인터페이스를 제공하여 SQL 문법을 교차하여 사용 가능
  5. 뽀나스로 성능까지 향상시켜준다

    • 같은 트랜젝션 하에서는 같은 SQL문을 캐싱 처리(1차 캐싱)
    • 나아가 애플리케이션 종료 전까지 쿼리 결과를 캐싱할 수 있음(2차 캐싱)

2. 프로젝트 구조

프로젝트 구조는 다음과 같습니다.

Project Directory

먼저 공통적으로 사용되는 클래스인 컨트롤러와 DTO만 확인하고 DAO에 해당하는 인터페이스와 클래스는 다음 목차에서 확인합시다.

MemberApi.java(컨트롤러 역할)

Java
package com.example.querydsl.api;

@RestController
@RequestMapping
@RequiredArgsConstructor
public class MemberApi {
	
	private final MemberRepository memberRepository;
	
	@GetMapping
	public List<Member> getAll() {
		return memberRepository.findAll();
	}

	// 1. JPQL 방식 조회
	@GetMapping("/jpql")
	public List<Member> searchUsingJPQL(@RequestParam String search) {
		return memberRepository.searchByNameJPQL(search);
	}

    // 2. Query Method 방식 조회
	@GetMapping("/method")
	public List<Member> searchUsingQueryMethod(@RequestParam String search) {
		return memberRepository.findByNameContainingOrNickNameContaining(search, search);
	}

    // 3. QueryDSL 방식 조회
	@GetMapping("/qdsl")
	public List<Member> searchUsingQqueryDSL(@RequestParam String search) {
	    return memberRepository.searchByNameQueryDsl(search);
	}
	
    // 4. JPAQueryFactory 방식 조회
	@GetMapping("/factory")
	public List<Member> searchUsingQueryDslWithJPAQueryFactory(@RequestParam String search) {
	    return memberRepository.searchByNameQueryDslWithJPAQueryFactory(search);
	}
	
	@PostConstruct
	private void generateSample() {
		if (memberRepository.count() > 0) {
			return;
		}
		
		memberRepository.save(Member.of("김싸피", "제이지"));
		memberRepository.save(Member.of("이싸피", "라이언"));
		memberRepository.save(Member.of("최싸피", "어피치"));
	}
}

Member.java(DTO 역할)

Java
package com.example.querydsl.domain;

@Getter
@Entity
@Table(name="Member")
@NoArgsConstructor(access=AccessLevel.PRIVATE)
@RequiredArgsConstructor(staticName="of")
public class Member {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Long id;
	
	@NonNull
	private String name;
	
	@NonNull
	private String nickName;
}
  • @NoArgsConstructor : lombok 라이브러리를 위한 애노테이션으로 파라미터가 없는 생성자 생성을 의미
  • @Entity, @Table(name="Member") : 개체와 테이블 매핑을 의미. 테이블 명 명시
  • @Id : 해당 필드가 primary key임을 의미
  • @GeneratedValue : primary key 표현을 위해 자동 생성되는 값을 의미(AI)

다음 목차에서 살펴보는 JPA 역할 클래스와 인터페이스는 모두 com.example.querydsl.repository 패키지에 구현되어 있습니다.


3. Spring + JPA 3가지 방식으로 구현해보기

1) JPQL(Java Persistence Query Language) 방식

테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리

JPQL 분석 -> 적절한 SQL문 생성 -> 데이터 조회 -> 조회 결과를 엔티티 객체로 생성하여 반환

따라서 특정 DB 종류나 벤더에 종속적이지 않으며, 심지어는 DB 벤더 간 서로 다른 SQL 문법을 적절히 번역하여 쿼리를 실행합니다.

MemberRepository.java(인터페이스)

Java
package com.example.querydsl.repository;

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositorySupport, MemberRepositoryFactory {
	
	// 1. JPQL 방식
	@Query("SELECT m FROM Member m WHERE m.name LIKE %:search% OR m.nickName LIKE %:search%")
	List<Member> searchByNameJPQL(@Param("search") String search);
}
Java
String jpql = "SELECT m FROM Member m WHERE m.name LIKE %:search% OR m.nickName LIKE %:search%";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
  • 쿼리문의 Member는 테이블 이름이 아닌 엔티티 이름
  • name, nickName 역시 엔티티 객체 필드 이름
  • createQuery() 인자는 수행할 jpql 쿼리와 반환 타입
  • Spring 환경에 맞게 Annotation 붙여서 구현 가능

2) Query Method 방식

메서드 이름으로 자동으로 쿼리를 생성하는 마법같은 기능

인터페이스에 그럴듯한 이름으로 메서드를 선언하면 스프링 데이터 JPA가 필요한 쿼리를 자동으로 유추하여 적절한 JPQL 쿼리를 생성하고 실행합니다.

물론 사전에 정의된 규칙을 따르는 그럴듯한 이름이어야 합니다..ㅎㅎ

MemberRepository.java(인터페이스)

Java
package com.example.querydsl.repository;

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositorySupport, MemberRepositoryFactory {

	// 2. Query Method 방식
	List<Member> findByNameContainingOrNickNameContaining(String name, String nickName);
}
  • findByName : name 기반으로 조회
  • Containing : like + 앞, 뒤로 %로 감싸진 쿼리
  • Or : or
  • 위와 같은 메서드명으로 메서드를 선언할 경우 1번 jpql과 동일한 쿼리가 자동으로 생성되어 테이블을 조회하고 엔티티를 반환

3) QueryDSL 방식

JPQL을 생성하는 빌더 클래스. 빌더 패턴을 통해 JPQL을 SQL 텍스트 형태가 아닌 프로그래밍 코드 형태로 구현할 수 있습니다.

SQL문 형태가 아니고 메서드 기반이므로 컴파일 시 자잘한 오류를 막을 수 있습니다. 또한 체이닝을 통해 복잡한 쿼리문을 직관적이고 단순하게 표현할 수 있습니다. 무엇보다 동적 쿼리를 작성하고 수행하기에 편리합니다.

Q로 시작하는 쿼리 전용 클래스를 생성하여 사용해야 합니다.(빌드하면 자동으로 생성됨)

MemberRepositorySupport.java(인터페이스)

Java
package com.example.querydsl.repository;

// 3. QueryDSL 방식을 위한 인터페이스
public interface MemberRepositorySupport {
	List<Member> searchByNameQueryDsl(String name);
}

MemberRepositorySupportImpl.java(클래스)

Java
package com.example.querydsl.repository;

// 3. QueryDSL 방식을 위한 인터페이스 구현체
@Transactional(readOnly = true)
@Repository
public class MemberRepositorySupportImpl extends QuerydslRepositorySupport implements MemberRepositorySupport {
	
	public MemberRepositorySupportImpl(JPAQueryFactory queryFactory) {
		super(Member.class);
	}
	
	// 기본과제 : QueryDSL 방식 작성
	@Override
	public List<Member> searchByNameQueryDsl(String search) {
		QMember member = QMember.member;
		JPQLQuery<Member> jpqlQuery = from(member);

		List<Member> memberList = jpqlQuery.where(member.name.contains(search).or(member.nickName.contains(search)))
										   .fetch();
		
		return memberList;
	}

}
  • 쿼리 전용 클래스인 QMember 객체를 이용함. 이때 from() 인자로 객체를 넘겨줘야 함.
  • where(), contains(), or()가 각각 JPQL 쿼리를 대신하는 빌더 메서드
  • 이름 포함 조회 메서드에 or() 메서드를 체이닝하여 닉네임 포함 조회 메서드를 실행

QuerydslApplication.java(클래스)

Java
package com.example.querydsl;

@EnableJpaAuditing
@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);
	}
}

MemberRepositoryFactoryImpl.java(클래스)

Java
package com.example.querydsl.repository;

// 4. QueryDSL JPAQueryFactory 방식을 위한 인터페이스 구현체
import static com.example.querydsl.domain.QMember.member;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class MemberRepositoryFactoryImpl implements MemberRepositoryFactory {

	private final JPAQueryFactory jpaQueryFactory;
	
	// 심화과제 : JPAQueryFactory와 BooleanExpression 방식 작성
	@Override
	public List<Member> searchByNameQueryDslWithJPAQueryFactory(String search) {
		List<Member> memberList = jpaQueryFactory.select(member)
					   .from(member)
					   .where(nameContain(search)
					   .or(nickNameContain(search)))
					   .fetch();
		return memberList;
	}
	
	private BooleanExpression nameContain(String search) {
		if (StringUtils.isNullOrEmpty(search)) return null;
		return member.name.contains(search);
	}
	
	private BooleanExpression nickNameContain(String search) {
		if (StringUtils.isNullOrEmpty(search)) return null;
		return member.nickName.contains(search);
	}
}
  • JPAQueryFactory는 JPA 역할을 수행하도록 엔티티를 관리하는 EntityManager 객체를 간접적으로 생성하여 반환해줍니다.
  • 스프링 부트 기본 설정을 담당하는 @SpringBootApplication Annotation을 가진 클래스에 JPAQueryFactory 메서드를 선언하여 객체를 주입받도록 합니다. @Bean Annotation이 객체를 주입해줍니다.
  • JPAQueryFactory 인스턴스를 이용하여 QueryDSL을 좀 더 직관적으로 구현할 수 있습니다.
  • BooleanExpression을 이용하여 if 체크를 메서드로 따로 빼줄 수 있고, null이 발생할 경우 조건절에서 자동으로 제외할 수 있습니다.

4. 맞닥뜨린 에러와 디버깅 과정

1) QueryDSL 방식에서 체이닝 단계 오류

Java
// 🙅🏻 틀린 방법
List<Member> memberList = jpqlQuery.where(member.name.contains(search))
                                   .or(member.nickName.contains(search))
								   .fetch();
Java
// 🙆🏻 맞는 방법
List<Member> memberList = jpqlQuery.where(member.name.contains(search).or(member.nickName.contains(search)))
								   .fetch();

JPQLQueryor()를 메서드로 갖고있지 않습니다…! 따라서 where() 메서드 밖에서 or() 메서드로 체이닝을 시도할 수 없고, Member 엔티티에 대해 or() 메서드를 사용해야 합니다.

2) H2 메모리 DB를 MySQL DB로 변경하기 위한 application.properties 설정 빈 등록 에러

예제에서는 간단한 테스트를 위해 H2 메모리 DB를 사용했지만 실제 서비스는 RDBMS 서비스를 사용하는 경우가 많아 MySQL DB로 변환하는 과정을 거치게 되었습니다.

build.gradle

dependencies {
	implementation 'mysql:mysql-connector-java'
}

application.properties

# spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/moviedb?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC 
spring.datasource.username=ssafy
spring.datasource.password=ssafy

spring.jpa.database=mysql 
spring.jpa.show-sql=true

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb

스프링 부트 외부 설정 값을 변경하기 위해 application.properties 파일에 MySQL 관련 설정 내용을 넣어주었는데 이때, spring.datasource.url을 설정해주면 알아서 driver를 찾기 때문에 첫번째 라인이 필요 없었습니다. 오히려 빈 등록에 방해가 되어 해당 라인은 주석처리 하였습니다.


🤓 느낀점

1학기까지는 MyBatis 프레임워크를 이용하여 SQL문은 직접 작성하되, 매핑을 프레임워크의 도움을 받는 방식으로 개발했었습니다. 처음 JPA 사용 방식을 공부해봤는데 정말 신세계가 따로 없는 것 같습니다!

이번 과제를 통해 모호하게 느껴지던 Persistence API와 ORM 개념을 확실히할 수 있었습니다. 또한 JPA가 도입되어야만 했던 이유, 특히 객체지향적인 프로그래밍을 위해 엔티티와 DB 간극을 줄이는데 기여하는 JPA의 특성을 이해하는 기회가 되어 2학기 프로젝트에서는 꼭 JPA를 사용해보고자 합니다.


References

ShiningDr.log - Querydsl Repository 확장에 대한 고찰 gunju-ko - JPA 캐시 shane park - STS 로 Spring Boot 프로젝트 만들기