Thursday, June 7, 2018

JPA 엔티티 이름과 테이블 이름의 불일치: QuerySyntaxException의 근본 원인과 해결 전략

Java Persistence API(JPA)는 자바 개발자에게 객체-관계 매핑(ORM)의 편리함을 제공하며, 데이터베이스와의 상호작용을 비즈니스 로직에 더 가깝게 만들어 줍니다. 개발자는 복잡한 SQL 쿼리를 직접 작성하는 대신, 객체 지향적인 방식으로 데이터를 조작할 수 있습니다. 그러나 이러한 추상화는 때로는 새로운 종류의 혼란과 오류를 야기하기도 합니다. 그중 개발자들이 가장 빈번하게 마주치는 예외 중 하나가 바로 org.hibernate.hql.internal.ast.QuerySyntaxException, 특히 "is not mapped" 라는 메시지와 함께 나타나는 오류입니다.

이 오류는 JPA의 핵심 동작 원리, 즉 JPQL(Java Persistence Query Language)이 데이터베이스 테이블이 아닌, 영속성 컨텍스트에 의해 관리되는 '엔티티(Entity)' 객체를 대상으로 동작한다는 사실을 간과했을 때 발생합니다. 많은 개발자들이 SQL에 익숙하기 때문에, 자신도 모르게 쿼리문에 테이블 이름을 사용하는 실수를 저지릅니다. 이 글에서는 QuerySyntaxException: [EntityName] is not mapped 오류가 발생하는 근본적인 원인을 깊이 있게 파헤치고, 다양한 발생 시나리오와 해결책, 그리고 나아가 이러한 오류를 사전에 방지할 수 있는 견고한 개발 패턴 및 모범 사례에 대해 총체적으로 탐구합니다. 단순히 에러를 해결하는 것을 넘어, JPA의 동작 방식을 근본적으로 이해하는 계기가 될 것입니다.

1. 문제의 핵심: SQL과 JPQL의 근본적인 패러다임 차이

모든 문제의 시작은 JPQL을 SQL의 단순한 별칭(Alias) 정도로 오해하는 것에서 비롯됩니다. 두 언어는 문법적으로 유사해 보일 수 있으나, 그들이 대상으로 하는 것은 완전히 다릅니다.

  • SQL (Structured Query Language): 관계형 데이터베이스 관리 시스템(RDBMS)과 직접적으로 소통하기 위한 언어입니다. SQL의 모든 연산은 데이터베이스 내에 물리적으로 존재하는 테이블(Table)컬럼(Column)을 대상으로 합니다. SELECT * FROM USERS 라는 쿼리는 'USERS'라는 이름의 테이블에서 모든 컬럼을 조회하라는 명백한 지시입니다.
  • JPQL (Java Persistence Query Language): JPA 표준의 일부로, 데이터베이스 독립적인 쿼리를 제공하기 위해 설계되었습니다. JPQL의 가장 중요한 특징은 쿼리의 대상이 물리적인 테이블이 아니라, 애플리케이션의 메모리 위에서 영속성 컨텍스트에 의해 관리되는 엔티티 객체(Entity Object)라는 점입니다. SELECT u FROM User u 라는 쿼리는 'User'라는 이름의 엔티티 클래스의 모든 인스턴스를 조회하라는 객체 지향적인 질의입니다.

JPA 구현체(예: Hibernate)는 이 JPQL 쿼리를 받아서, 현재 사용 중인 데이터베이스의 방언(Dialect)에 맞는 적절한 SQL로 번역하여 실행합니다. 즉, 개발자는 JPQL을 통해 객체 세상의 언어로 이야기하고, JPA가 이를 데이터베이스 세상의 언어로 통역해주는 구조입니다.

이러한 패러다임의 차이를 이해하는 것이 "is not mapped" 오류 해결의 첫걸음입니다. 오류 메시지는 JPA가 개발자가 쿼리에 명시한 이름(예: 'USERS')을 자신이 관리하는 엔티티 목록에서 찾을 수 없다고 알리는 것입니다. JPA의 관점에서 'USERS'는 단지 의미 없는 문자열일 뿐, 'User'라는 자바 클래스와 연관된 엔티티가 아니기 때문입니다.

간단한 예시를 통한 이해

아래와 같은 User 엔티티가 있다고 가정해 보겠습니다.


import javax.persistence.*;
import lombok.Data;

@Entity // 이 클래스가 JPA 엔티티임을 선언합니다.
@Table(name="USERS") // 실제 데이터베이스의 'USERS' 테이블과 매핑됩니다.
@Data
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userSeq;

    @Column(name = "user_id", length = 50, unique = true, nullable = false)
    private String id;

    @Column(length = 100, nullable = false)
    private String name;

    @Column(length = 255, nullable = false)
    private String password;
}

이 엔티티를 사용하여 모든 사용자를 조회하려고 할 때, 개발자가 흔히 저지르는 실수는 다음과 같습니다.


// 잘못된 접근 방식: JPQL에 테이블 이름을 사용
// EntityManager em;
try {
    String jpql = "SELECT u FROM USERS u"; // 'USERS'는 테이블 이름, 엔티티 이름이 아님!
    List<User> userList = em.createQuery(jpql, User.class).getResultList();
} catch (Exception e) {
    // 여기서 QuerySyntaxException: USERS is not mapped 오류 발생
    e.printStackTrace();
}

위 코드를 실행하면, JPA(Hibernate)는 다음과 유사한 예외를 던집니다.


org.hibernate.hql.internal.ast.QuerySyntaxException: USERS is not mapped [SELECT u FROM USERS u]
    at org.hibernate.hql.internal.ast.QuerySyntaxException.generateQueryException(QuerySyntaxException.java:79)
    ...

올바른 접근 방식은 JPQL에 클래스 이름(정확히는 엔티티 이름)을 사용하는 것입니다.


// 올바른 접근 방식: JPQL에 엔티티 이름을 사용
// EntityManager em;
String jpql = "SELECT u FROM User u"; // 'User'는 클래스 이름이자 기본 엔티티 이름
List<User> userList = em.createQuery(jpql, User.class).getResultList();

// 성공적으로 사용자 목록을 조회함

JPA는 @Entity 어노테이션이 붙은 User 클래스를 "User"라는 이름의 엔티티로 인식합니다. 그리고 em.createQuery("SELECT u FROM User u", ...)를 실행하면, JPA는 이 "User" 엔티티가 @Table(name="USERS")에 의해 "USERS" 테이블과 매핑된 것을 확인하고, 내부적으로 SELECT * FROM USERS와 같은 적절한 SQL을 생성하여 데이터베이스에 전송합니다.

2. @Entity@Table 어노테이션 심층 분석

이 문제를 더 깊이 이해하기 위해서는 JPA가 엔티티를 인식하고 테이블과 매핑하는 두 가지 핵심 어노테이션, @Entity@Table의 역할을 명확히 구분해야 합니다.

2.1. @Entity: 객체의 정체성을 부여하다

@Entity 어노테이션은 특정 자바 클래스가 단순한 POJO(Plain Old Java Object)가 아니라, JPA가 관리하는 '영속성 엔티티'임을 선언하는 마커입니다. 이 어노테이션이 붙는 순간, JPA는 해당 클래스의 인스턴스들을 영속성 컨텍스트에 포함시켜 생명주기를 관리하기 시작합니다.

@Entity 어노테이션에는 name이라는 속성이 있습니다. 이 속성은 JPQL에서 사용될 엔티티의 이름을 명시적으로 지정하는 역할을 합니다.


// @Entity 어노테이션의 name 속성을 사용한 경우
@Entity(name = "Member") // JPQL에서 'User'가 아닌 'Member'로 조회해야 함
@Table(name = "USERS")
public class User {
    // ... 필드 ...
}

위와 같이 엔티티를 정의했다면, JPQL 쿼리는 다음과 같이 작성해야 합니다.


// 엔티티 이름을 'Member'로 지정했으므로 JPQL에서도 'Member'를 사용해야 함
String jpql = "SELECT m FROM Member m WHERE m.name = :name"; // "FROM User u"는 이제 오류 발생

TypedQuery<User> query = em.createQuery(jpql, User.class);
query.setParameter("name", "홍길동");
List<User> result = query.getResultList();

만약 @Entityname 속성을 생략하면, JPA는 기본적으로 **클래스 이름(case-sensitive)을 엔티티 이름으로 사용**합니다. 대부분의 경우, 이 기본 전략을 따르는 것이 코드의 가독성과 일관성을 유지하는 데 도움이 됩니다. 클래스 이름과 JPQL에서 사용하는 이름이 동일하기 때문입니다.

JPA 명명 규칙과 모범 사례

일반적으로 엔티티 클래스의 이름은 파스칼 케이스(PascalCase, 예: UserInfo)를 따릅니다. @Entityname 속성을 별도로 지정하지 않으면, JPQL에서도 이 파스칼 케이스 이름을 그대로 사용해야 합니다. (SELECT ui FROM UserInfo ui). 이는 자바의 명명 규칙을 따르므로 자연스럽습니다.

2.2. @Table: 물리적 저장소를 지정하다

@Table 어노테이션은 @Entity로 선언된 객체가 데이터베이스의 어떤 테이블에 저장될지를 지정하는 '매핑' 정보를 담고 있습니다. 이 어노테이션은 JPQL의 동작과는 직접적인 관련이 없으며, 오직 JPQL이 SQL로 변환되는 마지막 단계에서만 참조됩니다.

주요 속성은 다음과 같습니다.

  • name: 매핑될 데이터베이스 테이블의 이름을 지정합니다. 이 값을 지정하지 않으면, JPA 구현체의 기본 명명 전략(Naming Strategy)에 따라 엔티티 이름(주로 클래스 이름을 스네이크 케이스로 변환한 형태, 예: UserInfo -> user_info)으로 테이블 이름이 결정됩니다.
  • schema, catalog: 여러 스키마나 카탈로그를 사용하는 복잡한 데이터베이스 환경에서 테이블의 위치를 명확히 지정할 때 사용합니다.

정리: 역할의 명확한 분리

어노테이션 역할 관련 기술/언어 주요 사용 시점
@Entity 자바 클래스를 영속성 엔티티로 정의하고, 객체 지향 쿼리에서 사용할 이름을 결정. JPQL, Criteria API, QueryDSL 개발자가 em.createQuery() 등으로 쿼리를 작성하는 시점
@Table 엔티티가 저장될 물리적인 데이터베이스 테이블을 지정. SQL JPA가 JPQL을 SQL로 번역하여 데이터베이스에 전송하는 시점

이 표에서 볼 수 있듯이, QuerySyntaxException은 JPQL 단계의 문제이므로, 해결의 열쇠는 @Table이 아닌 @Entity의 설정(또는 기본값)에 있습니다.

3. 오류 발생의 다양한 시나리오와 구체적인 해결 방안

이론을 바탕으로, 실제 개발 환경에서 마주할 수 있는 다양한 "is not mapped" 오류 시나리오와 그에 대한 해결책을 구체적으로 살펴보겠습니다.

시나리오 1: 가장 흔한 실수 - 테이블 이름 사용

  • 현상: 엔티티 클래스 이름 대신 @Table에 지정된 테이블 이름을 JPQL에 사용.
  • 코드 예시 (엔티티):
    @Entity
    @Table(name="TBL_PRODUCT")
    public class Product { /* ... */ }
            
  • 잘못된 쿼리: em.createQuery("SELECT p FROM TBL_PRODUCT p", Product.class)
  • 에러 메시지: QuerySyntaxException: TBL_PRODUCT is not mapped
  • 해결 방안: JPQL에서 테이블 이름(`TBL_PRODUCT`) 대신 엔티티 이름(클래스 이름인 `Product`)을 사용합니다.
    
    // 수정된 쿼리
    em.createQuery("SELECT p FROM Product p", Product.class);
            

시나리오 2: @Entity(name=...) 속성 간과

  • 현상: @Entityname 속성으로 엔티티 이름을 커스텀했으나, JPQL에서는 여전히 클래스 이름을 사용.
  • 코드 예시 (엔티티):
    @Entity(name = "Item")
    @Table(name="TBL_PRODUCT")
    public class Product { /* ... */ }
            
  • 잘못된 쿼리: em.createQuery("SELECT p FROM Product p", Product.class)
  • 에러 메시지: QuerySyntaxException: Product is not mapped
  • 해결 방안: JPQL에서 클래스 이름(`Product`) 대신 @Entity에 명시한 `name` 속성값(`Item`)을 사용합니다.
    
    // 수정된 쿼리
    em.createQuery("SELECT i FROM Item i", Product.class);
            

시나리오 3: 엔티티 스캔(Entity Scan) 실패

이 경우는 쿼리 문법 자체의 문제라기보다는, JPA 설정의 문제입니다. JPA가 엔티티 클래스 자체를 인식하지 못하면, 올바른 JPQL을 사용하더라도 "is not mapped" 오류가 발생할 수 있습니다.

  • 현상: 엔티티 클래스가 정의되어 있지만, JPA 설정 파일(persistence.xml)이나 Spring Boot의 @EntityScan 범위에 해당 클래스의 패키지가 포함되지 않음.
  • 코드 예시 (Spring Boot 설정):
    
    // 메인 애플리케이션 클래스
    @SpringBootApplication
    @EntityScan(basePackages = "com.example.project.domain.main") // main 패키지만 스캔
    public class ProjectApplication { /* ... */ }
    
    // 다른 패키지에 존재하는 엔티티
    // package com.example.project.domain.sub;
    @Entity
    public class Order { /* ... */ }
            
  • 쿼리 및 에러: em.createQuery("SELECT o FROM Order o", Order.class)를 실행하면, JPA는 'Order'라는 엔티티를 로드한 적이 없으므로 QuerySyntaxException: Order is not mapped 오류를 발생시킵니다.
  • 해결 방안:
    1. @EntityScan 범위 수정: @EntityScanbasePackages에 해당 엔티티가 위치한 패키지를 추가합니다.
      
      // @EntityScan({"com.example.project.domain.main", "com.example.project.domain.sub"})
      // 또는 상위 패키지를 지정
      @EntityScan("com.example.project.domain")
                      
    2. XML 설정(persistence.xml) 확인: Spring Boot가 아닌 전통적인 JPA 환경이라면, persistence.xml 파일 내에 해당 클래스가 com.example.project.domain.sub.Order 태그로 명시되어 있는지, 또는 클래스가 포함된 jar 파일이 <jar-file>로 지정되었는지 확인합니다.

4. 오류를 원천적으로 방지하는 견고한 개발 전략

문제가 발생했을 때 해결하는 것도 중요하지만, 더 나은 개발자는 애초에 문제가 발생할 여지를 줄입니다. 문자열 기반의 JPQL이 갖는 태생적 한계를 극복하고, 컴파일 시점에 오류를 잡을 수 있는 현대적인 방법들을 적극적으로 활용해야 합니다.

4.1. 대안 1: Native Query의 신중한 사용

만약 특정 데이터베이스에 종속적인 복잡한 쿼리나, JPQL로 표현하기 어려운 기능을 반드시 사용해야 한다면, Native Query를 고려할 수 있습니다. EntityManager.createNativeQuery() 메소드는 JPQL 대신 순수 SQL을 실행하게 해줍니다.


// Native Query에서는 테이블 이름(USERS)을 직접 사용
String sql = "SELECT * FROM USERS WHERE name = ?";

// 두 번째 인자로 결과를 매핑할 엔티티 클래스를 지정
List<User> users = em.createNativeQuery(sql, User.class)
                     .setParameter(1, "홍길동")
                     .getResultList();

장점: SQL의 모든 기능을 활용할 수 있습니다.
단점:

  • 데이터베이스 종속성: 특정 DB의 문법을 사용하면 다른 DB로의 전환이 어려워집니다. JPA의 가장 큰 장점 중 하나인 DB 독립성을 잃게 됩니다.
  • 오타 및 오류에 취약: 쿼리가 단순 문자열이므로 컴파일 시점에 문법 오류를 잡을 수 없습니다. 실행 시점이 되어서야 오류를 발견하게 됩니다.
  • 유지보수 어려움: 객체 모델이 변경되어도 SQL 문자열은 자동으로 업데이트되지 않아, 불일치 문제가 발생할 수 있습니다.

따라서 Native Query는 JPQL로 해결이 불가능한 최후의 수단으로만 신중하게 사용해야 합니다.

4.2. 대안 2: Criteria API (JPA 표준 타입-세이프 쿼리)

JPA 2.0부터 표준으로 포함된 Criteria API는 문자열 기반 JPQL의 단점을 보완하기 위해 등장했습니다. 자바 코드를 사용하여 동적으로 쿼리를 생성하며, 컴파일 시점에 타입 검사가 가능하여 오타나 잘못된 필드 참조 같은 오류를 방지할 수 있습니다.


// EntityManager em;
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);

// FROM 절: User.class를 직접 사용하여 문자열 실수를 원천 방지
Root<User> userRoot = cq.from(User.class); 

cq.select(userRoot); // SELECT u

// WHERE 절
Predicate namePredicate = cb.equal(userRoot.get("name"), "홍길동");
cq.where(namePredicate);

TypedQuery<User> query = em.createQuery(cq);
List<User> users = query.getResultList();

장점:

  • 타입-세이프(Type-safe): userRoot.get("name")과 같이 필드명을 문자열로 쓰지만, Metamodel Generator를 사용하면 userRoot.get(User_.name)처럼 컴파일 시점에 검증 가능한 코드로 개선할 수 있습니다.
  • 동적 쿼리 생성 용이: 조건에 따라 Predicate를 동적으로 추가하거나 정렬 순서를 바꾸는 등 프로그래밍적인 쿼리 생성이 유연합니다.

단점:

  • 가독성 및 직관성 저하: 코드가 장황하고 복잡해져서, 간단한 쿼리조차도 SQL이나 JPQL에 비해 한눈에 파악하기 어렵습니다.

4.3. 대안 3: QueryDSL (가장 강력하고 권장되는 대안)

QueryDSL은 Criteria API의 장점(타입-세이프, 동적 쿼리)과 JPQL의 장점(간결함, 가독성)을 결합한 매우 강력한 오픈소스 프레임워크입니다. Annotation Processor를 사용하여 엔티티 클래스에 대한 Q-타입(Query Type) 클래스를 자동으로 생성하며, 이를 통해 마치 자바 메소드를 호출하듯 자연스럽게 쿼리를 작성할 수 있습니다.

먼저, 빌드 도구(Maven/Gradle)에 QueryDSL 설정을 추가하여 Q-타입 클래스(예: QUser.java)를 생성해야 합니다. 생성이 완료되면 다음과 같이 쿼리를 작성할 수 있습니다.


// JPAQueryFactory는 재사용 가능하도록 Bean으로 등록하는 것을 권장
// JPAQueryFactory queryFactory = new JPAQueryFactory(em);

// 생성된 Q-타입 클래스 사용
import static com.example.project.domain.QUser.user;

List<User> users = queryFactory
    .selectFrom(user) // "FROM User u" 와 동일
    .where(user.name.eq("홍길동")) // 타입-세이프 조건
    .orderBy(user.userSeq.desc())
    .fetch();

장점:

  • 최고 수준의 타입-세이프: 모든 쿼리 요소(엔티티, 필드, 함수)가 코드로 표현되므로, 필드명을 잘못 쓰거나 지원하지 않는 연산을 사용하면 즉시 컴파일 오류가 발생합니다. "is not mapped" 오류는 아예 발생할 수 없습니다.
  • 뛰어난 가독성: 코드가 SQL과 매우 유사하여 직관적이고 이해하기 쉽습니다.
  • 동적 쿼리 최적화: BooleanBuilder나 where 절의 다중 인자를 활용하여 매우 깔끔하고 유연하게 동적 쿼리를 구현할 수 있습니다.
  • IDE 자동완성 지원: 개발 환경의 강력한 자동완성 기능 덕분에 생산성이 크게 향상됩니다.

단점:

  • 초기 설정의 번거로움: 프로젝트에 QueryDSL 의존성과 Annotation Processor를 설정하는 과정이 다소 필요합니다.

현대적인 JPA 기반 프로젝트에서는 초기 설정의 번거로움을 감수하고서라도 QueryDSL을 도입하는 것이 장기적인 안정성과 유지보수성 측면에서 압도적으로 유리합니다.

결론: 패러다임의 이해가 곧 실력이다

JPA에서 발생하는 QuerySyntaxException: [EntityName] is not mapped 오류는 단순한 문법 실수가 아니라, JPA의 근본적인 객체 지향적 철학을 충분히 이해하지 못했음을 보여주는 신호입니다. JPQL은 데이터베이스의 물리적 구조(테이블, 컬럼)가 아닌, 애플리케이션의 논리적 구조(엔티티, 필드)를 기반으로 동작한다는 핵심 원칙을 기억해야 합니다.

이 오류를 마주쳤을 때, 해결책은 명확합니다. 쿼리문에서 사용한 이름이 @Table(name=...)에 지정된 테이블 이름이 아닌, @Entity(name=...)에 명시되었거나(명시되지 않았다면 클래스 이름과 동일한) 엔티티 이름인지 다시 한번 확인하는 것입니다. 더 나아가, 엔티티 클래스가 JPA의 스캔 범위에 올바르게 포함되어 있는지도 점검해야 합니다.

궁극적으로는 문자열 기반 쿼리가 내포하는 위험에서 벗어나 QueryDSL과 같은 타입-세이프 쿼리 빌더를 도입하는 것이 현명한 선택입니다. 이를 통해 "is not mapped"와 같은 런타임 예외를 컴파일 시점의 오류로 전환하고, 더 안정적이고 유지보수하기 쉬운 데이터 접근 계층을 구축할 수 있습니다. 이 글에서 다룬 깊이 있는 원인 분석과 다양한 해결 전략들이 여러분의 JPA 개발 여정에 튼튼한 발판이 되기를 바랍니다.


0 개의 댓글:

Post a Comment