예제

채팅 시스템을 만든다고 가정하자. 각 고객은 채팅방에서 채팅을 송수신한다.

도메인 클래스

먼저 도메인 클래스를 살펴보자. PARTNER는 제외했다.

@Entity(name = "Customer")
@Table(name = "CUSTOMER")
@Getter @Setter
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private long id;

    @Column(name = "NAME")
    @Length(max = 45)
    private String name;
}

@Entity(name = "Room")
@Table(name = "ROOM")
@Getter @Setter
public class Room {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private long id;
}

@Entity(name = "Chat")
@Table(name = "CHAT")
public class Chat {

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(
            name = "ROOM_ID",
            nullable = false
    )
    private Room room;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(
            name = "WRITER_ID",
            nullable = false
    )
    private Customer writer;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private long id;

    @Column(name = "CONTENT")
    private String content;
}

문제

채팅 시스템과 별개로 어드민 시스템을 사용하고 있다. 이 채팅 시스템에는 제휴사가 존재한다. 특정 채팅방의 채팅 목록을 조회하고자 한다. 네이티브 쿼리가 필요한 상황을 위해 채팅 송신자가 제휴사인 경우, 송신자명을 PARTNER 테이블의 NAME 컬럼에서 가져온다고 가정해보자.

고객정보가 들어있는 테이블은 CUSTOMER 이기 때문에 일반 고객은 CUSTOMER 테이블에서 이름을 projection할 수 있다. 그러나 해당 고객이 제휴사인 경우, 즉 PARTNER 테이블에 존재하는 고객인 경우에는 PARTNER 테이블에서의 projection이 필요하다. Customer와 Patner를 매핑해도 되지만 매핑할 수 없는 상황이라고 가정하자.

네이티브 쿼리

위 문제를 SQL로 작성하면 다음과 같다.

SELECT
	CT.ID,
	RM.ID AS ROOM_ID,
	CS.ID AS WRITER_ID,
	IFNULL(PTN.NAME, CS.NAME) AS WRITER_NAME,
	CT.CONTENT
FROM CHAT CT
JOIN ROOM RM
	ON CT.ROOM_ID = RM.ID
JOIN CUSTOMER CS
	ON CT.WRITER_ID = CS.ID
LEFT JOIN PARTNER PTN
	ON CS.ID = PTN.CUSTOMER_ID
WHERE CT.ROOM_ID = ?1

LEFT JOIN PARTNER이 끼어들기 때문에, SQL을 호출해야 한다. 네이티브 쿼리가 필요한 상황이다. Chat 도메인 클래스에 네이티브 쿼리를 추가하자. (추가로 N + 1 문제도 해결할 수 있다.)

@NamedNativeQuery(
        name = "findAllChatsByRoomId",
        query = "SELECT" +
        "	CT.ID," +
        "   RM.ID AS ROOM_ID," +
        "   CS.ID AS CUSTOMER_ID," +
        "	IFNULL(PTN.NAME, CS.NAME) AS CUSTOMER_NAME," +
        "   CT.CONTENT" +
        "FROM CHAT CT" +
        "JOIN ROOM RM" +
        "	ON CT.ROOM_ID = RM.ID" +
        "JOIN CUSTOMER CS" +
        "	ON CT.WRITER_ID = CS.ID" +
        "LEFT JOIN PARTNER PTN" +
        "	ON CS.ID = PTN.CUSTOMER_ID"
        "WHERE CT.ROOM_ID = ?1"
)

결과 매핑

MyBatis를 사용해봤다면 resultMap에 익숙할 것이다. 주로, 객체 내부에 다른 클래스의 인스턴스 변수나 컬렉션 등이 존재할 경우에 사용한다.

JPA의 네이티브 쿼리에서도 마찬가지로, projection 연산 결과를 매핑해줄 방법이 필요하다. 이럴 때 @SqlResultSetMapping를 사용할 수 있다.

@SqlResultSetMapping(
        name = "implicit",
        entities = {
                @EntityResult(entityClass = Chat.class),
                @EntityResult(entityClass = Room.class, fields = {
                    @FieldResult(name = "id", column = "ROOM_ID")
                }),
                @EntityResult(entityClass = Customer.class, fields = {
                    @FieldResult(name = "id", column = "CUSTOMER_ID"),
                    @FieldResult(name = "name", column = "CUSTOMER_NAME")
                })
        },
        // columns 속성을 이용하면 테이블에 존재하는 값이 아닌 projection 결과도 매핑할 수 있다.
        columns = {
                // @ColumnResult(name = "fromPartner", type = Boolean.class)
        }
)

Chat 클래스는 다음과 같아진다.

@SqlResultSetMapping(
        name = "implicit",
        entities = {
                @EntityResult(entityClass = Chat.class),
                @EntityResult(entityClass = Room.class, fields = {
                    @FieldResult(name = "id", column = "ROOM_ID")
                }),
                @EntityResult(entityClass = Customer.class, fields = {
                    @FieldResult(name = "id", column = "CUSTOMER_ID"),
                    @FieldResult(name = "name", column = "CUSTOMER_NAME")
                })
        },
        // columns 속성을 이용하면 테이블에 존재하는 값이 아닌 projection 결과도 매핑할 수 있다.
        columns = {
                // @ColumnResult(name = "fromPartner", type = Boolean.class)
        }
)
@NamedNativeQuery(
        name = "findAllChatsByRoomId",
        query = "SELECT" +
        "	CT.ID," +
        "   RM.ID AS ROOM_ID," +
        "   CS.ID AS CUSTOMER_ID," +
        "	IFNULL(PTN.NAME, CS.NAME) AS CUSTOMER_NAME," +
        "   CT.CONTENT" +
        "FROM CHAT CT" +
        "JOIN ROOM RM" +
        "	ON CT.ROOM_ID = RM.ID" +
        "JOIN CUSTOMER CS" +
        "	ON CT.WRITER_ID = CS.ID" +
        "LEFT JOIN PARTNER PTN" +
        "	ON CS.ID = PTN.CUSTOMER_ID"
        "WHERE CT.ROOM_ID = ?1",
        resultSetMapping = "implicit"
)
@Entity(name = "Chat")
@Table(name = "CHAT")
public class Chat {

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(
            name = "ROOM_ID",
            nullable = false
    )
    private Room room;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(
            name = "WRITER_ID",
            nullable = false
    )
    private Customer writer;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private long id;

    @Column(name = "CONTENT")
    private String content;
}

네이티브 쿼리 호출

@Service
public class ChatService {

    @Autowired
    private ChatRepository chatRepository;

    @PersistenceContext
    private EntityManager em;

    public List<Chat> findAll(long roomId) {

        List<Object[]> resultList = em.createNamedQuery("findAllChatsByRoomId")
                .setParameter(1, roomId)
                .getResultList();

        return resultList.stream()
                .map(o -> (Chat) o[0])
                .collect(Collectors.toList());
    }
}

EntityManager의 createNamedQuery 메소드는 List<Object[]>를 반환한다. 배열의 각 요소에는 @SqlResultSetMapping에 지정한 순서대로 해당 클래스의 인스턴스가 들어있다. Chat을 가장 먼저 선언했기 때문에, 배열의 첫번째에 Chat 클래스의 인스턴스가 들어있으며, Room 클래스와 Customer 클래스의 인스턴스가 설정된 상태로 반환된다.