Spring JPA와 MyBatis를 이용한 개발 차이를 알아보자. Spring JPA에는 QueryDSL를 추가하여 가급적 실무에서 많이 사용하는 방법을 이용했다. 그외 Spring 설정은 간소화하였고, 게시판 개발등을 통해 비즈니스 로직 구현시 실제 차이점을 알아보도록 한다. 여기에는 두 구성에 대한 차이점만 확인하는 것이므로 JPA, Mybatis의 기술적인 설명은 하지 않겠다.

1. 테이블 엔티티 관계도 및 스크립트는 다음과 같다

erd

CREATE TABLE `user` (
                        `user_code` int(11) NOT NULL AUTO_INCREMENT,
                        `user_id` varchar(20) NOT NULL,
                        `user_name` varchar(20) NOT NULL,
                        `create_date` datetime(6) NOT NULL,
                        PRIMARY KEY (`user_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

insert into `user` (user_id, user_name, create_date) values ('hong', '홍길동', NOW());

CREATE TABLE `board` (
                         `board_no` int(11) NOT NULL AUTO_INCREMENT,
                         `title` varchar(100) NOT NULL,
                         `content` varchar(500) NOT NULL,
                         `user_code` int(11) DEFAULT NULL,
                         `create_date` datetime(6) NOT NULL,
                         PRIMARY KEY (`board_no`),
                         KEY `FK_user_code` (`user_code`),
                         CONSTRAINT `FK_user_code` FOREIGN KEY (`user_code`) REFERENCES `user` (`user_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

user테이블을 생성한 이유는 조인등을 사용하기 위함이다.

2. application.yml 설정

jpa와 mybatis를 동시에 사용하기 위해 아래와 같이 설정한다. 데이터베이스는 동일하게 MariaDB를 사용했다.

server:
  port: 8080

spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:3306/test
    username: root
    password: 1234
    hikari:
      max-lifetime: 30000
  jpa:
    database-platform: org.hibernate.dialect.MariaDB103Dialect
    show-sql: true
    generate-ddl: false
    hibernate:
      ddl-auto: none

    properties:
      hibernate.format_sql: true

mybatis:
  mapper-locations: classpath:mapper/*.xml
  config-location: classpath:mybatis-config.xml

3. gradle 설정

spring, mybatis, jpa, querydsl, mapstruct dependencies 설정

Mybatis 라이브러리

implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'

JPA, QueryDSL, Mapstruct 라이브러리.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.querydsl:querydsl-apt'
implementation 'com.querydsl:querydsl-jpa'
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
testAnnotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"

4. mybatis 개발

엔티티 Board 모델 클래스를 만든다.

@Getter
@Setter
public class Board {
    private int boardNo;
    private String title;
    private String content;
    private int userCode;
    private LocalDateTime createDate;
    private String userId;
    private String userName;
}

엔티티 User 모델 클래스를 만든다.

@Getter
@Setter
@Alias("user")
public class User {
    private int userCode;
    private String userId;
    private String userName;
    private LocalDateTime createDate;
}

SQL xml 개발한다. 게시판의 CRUD 처리 부분이다.

<mapper namespace="com.example.test.mybatis.repository.BoardMyBatisMapper">

    <insert id="insertBoard" parameterType="board">
        INSERT INTO board (title, content, user_code, create_date)
        VALUES(#{title}, #{content}, #{userCode}, NOW())
    </insert>

    <select id="selectBoard" resultType="board">
        SELECT
               b.board_no,
               b.content,
               b.title,
               b.create_date,
               b.user_code,
               u.user_id,
               u.user_name
        FROM board b INNER JOIN user u on b.user_code = u.user_code
        ORDER BY b.board_no DESC
    </select>

    <update id="updateBoard" parameterType="board">
        UPDATE board
        <set>
            <if test='title != null and title !=""'>
                title = #{title},
            </if>
            <if test='content != null and content !=""'>
                content = #{content},
            </if>
        </set>
        WHERE board_no = #{boardNo}
    </update>

    <delete id="deleteBoard" parameterType="board">
        DELETE FROM board WHERE board_no = #{boardNo}
    </delete>

</mapper>

다음은 repository 인테페이스 부분이다.

@Mapper
public interface BoardMyBatisMapper {
    int insertBoard(Board board);
    List<Board> selectBoard();
    int updateBoard(Board board);
    int deleteBoard(Board board);
}

service 클래스를 만든다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MyBatisService {
    private final BoardMyBatisMapper boardMapper;

    @Transactional
    public int insertBoard(Board board) {
        return boardMapper.insertBoard(board);
    }

    public List<Board> selectBoard() {
        return boardMapper.selectBoard();
    }

    @Transactional
    public int updateBoard(Board board) {
        return boardMapper.updateBoard(board);
    }

    @Transactional
    public int deleteBoard(Board board) {
        return boardMapper.deleteBoard(board);
    }

}

이제 control 부분을 만들면 끝난다.


@RestController
@RequestMapping("/api/mybatis")
@RequiredArgsConstructor
public class BoardMybatisController {

    private final MyBatisService myBatisService;

    @GetMapping("/board")
    @ResponseBody
    public ResultJson selectBoard() {
        ResultJson resultJson = new ResultJson();
        resultJson.setItems(myBatisService.selectBoard());
        resultJson.setSuccess(true);
        return resultJson;
    }

    @PostMapping("/board")
    @ResponseBody
    public ResultJson inserBoard(Board board) {
        ResultJson resultJson = new ResultJson();
        boolean result = false;
        if (myBatisService.insertBoard(board) > 0) {
            result = true;
        }
        resultJson.setSuccess(result);
        return resultJson;
    }

    @PutMapping("/board")
    @ResponseBody
    public ResultJson updateBoard(Board board) {
        ResultJson resultJson = new ResultJson();
        boolean result = false;
        if (myBatisService.updateBoard(board) > 0) {
            result = true;
        }
        resultJson.setSuccess(result);
        return resultJson;
    }

    @DeleteMapping("/board")
    @ResponseBody
    public ResultJson deleteBoard(Board board) {
        ResultJson resultJson = new ResultJson();
        boolean result = false;
        if (myBatisService.deleteBoard(board) > 0) {
            result = true;
        }
        resultJson.setSuccess(result);
        resultJson.setSuccess(true);
        return resultJson;
    }
}

5. jpa 개발

Board 엔티티 객체부터 만든다.

@Getter
@Setter
@Entity
@DynamicInsert
@DynamicUpdate
@Table(name = "board")
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_no")
    private Integer boardNo;

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

    @Column(name = "content", nullable = false, length = 500)
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_code")
    private User user = new User();

    @Column(name = "create_date", nullable = false)
    public LocalDateTime createDate;
}

User 엔티티 객체를 만든다.

@Getter
@Setter
@DynamicUpdate
@DynamicInsert
@Entity
@Table(name = "user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_code")
    private Integer userCode;

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

    @Column(name = "user_name", nullable = false, length = 20)
    private String userName;

    @Column(name = "create_date", nullable = false)
    private LocalDateTime createDate;
}

여기서 JPA는 entity 객체를 바로 controller 부분에 사용하는 것은 권장하지 않는다. 그 이유는 테이블과 매핑된 객체이기 때문이다. 그래서 controller단에서 파라미터등으로 받고 처리하는 별도의 클래스가 필요하고 편의상 dto 로 정의한다.

여기서는 게시판(board) 작업만 처리하기 때문에 boardDto만 만든다.


@Getter
@Setter
@NoArgsConstructor
public class BoardDto {
    private int boardNo;
    private String title;
    private String content;
    private int userCode;
    public LocalDateTime createDate = LocalDateTime.now();
    private String userId;
    private String userName;

    @QueryProjection
    public BoardDto(int boardNo, String title, String content, LocalDateTime createDate, int userCode, String userId, String userName) {
        this.boardNo = boardNo;
        this.title = title;
        this.content = content;
        this.createDate = createDate;
        this.userCode = userCode;
        this.userId = userId;
        this.userName = userName;
    }
}

@QueryProjection 어노테이션이 선언된 생성자부분은 Querydsl에서 해당 생성자를 사용하기 위해 만든 것이다. 보통 Querydsl에서 조회 결과등을 entity객체로 리턴하기 보다는 위에 dto를 통해 처리한다.

respository 설정을 한다. jpa는 mybatis처럼 직접 쿼리를 작성하는 부분이 없고 대신 각각 처리하는 클래스가 필요하다.

public interface BoardJpaRepository extends JpaRepository<Board, Integer>, BoardJpaRepositoryCustom {
    List<Board> findAll();
    Board findBoardByBoardNo(Integer boardNo);
}

findAll를 통해 전체 리스트를 가져오고 findBoardByBoardNo를 통해서 특정 게시물을 가져온다. 위 부분에 대한 쿼리는 만들 필요가 없다. 하지만 board와 user를 조인하는 쿼리가 필요한데, 이때 JPQL를 쓰거나 Querydsl를 사용한다. 보통 프로젝트에서는 Querydsl를 많이 사용하기에 Querydsl로 처리한다.

Querydsl를 처리하기 위해선 QuerydslRepositorySupport를 상속받아 설정해주는 작업클래스가 별도로 있는게 편의상 좋다.

public abstract class QuerydslRepositorySupportExtended extends QuerydslRepositorySupport {

    private JPAQueryFactory jpaQueryFactory;

    /**
     * Creates a new {@link QuerydslRepositorySupportExtended} instance for the given domain type.
     *
     * @param domainClass must not be {@literal null}.
     */
    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
    public QuerydslRepositorySupportExtended(Class<?> domainClass) {
        super(domainClass);
    }

    @PostConstruct
    public void validate() {
        super.validate();
        this.jpaQueryFactory = new JPAQueryFactory(getEntityManager());
    }

    protected JPAQueryFactory jpaQueryFactory() {
        return jpaQueryFactory;
    }

    @Nullable
    protected <T> AbstractJPAQuery<T, JPAQuery<T>> query() {
        return getQuerydsl().createQuery();
    }

    @Nullable
    public AbstractJPAQuery<Object, JPAQuery<Object>> query(EntityPath<?>... paths) {
        return getQuerydsl().createQuery(paths);
    }
}

물론 QuerydslRepositorySupport를 extend해서 Querydsl를 처리하는 클래스에서 구현해도 된다.

실제 board, user를 조인하는 querydls를 클래스를 만든다.


@Repository
public class BoardJpaRepositoryImpl extends QuerydslRepositorySupportExtended implements BoardJpaRepositoryCustom {

    private final QBoard qBoard = QBoard.board;
    private final QUser qUser = QUser.user;

    public BoardJpaRepositoryImpl() {
        super(Board.class);
    }

    //QueryResults : 조회한 리스트 + 전체 개수를 포함한 QueryResults 반환. count 쿼리가 추가로 실행된다.
    @Override
    public QueryResults<BoardDto> selectBoardQueryDsl() {
        return jpaQueryFactory().select(new QBoardDto(
                    qBoard.boardNo,
                    qBoard.title,
                    qBoard.content,
                    qBoard.createDate,
                    qUser.userCode,
                    qUser.userId,
                    qUser.userName
                ))
                .from(qBoard)
                .innerJoin(qUser)
                .on(qBoard.user.userCode.eq(qUser.userCode))
                .orderBy(qBoard.createDate.desc())
                .fetchResults();
    }
}

selectBoardQueryDsl 메소드는 리턴값이 QueryResults이고 이것은 실제 select ~ from 쿼리가 수행되면서 select count(*) ~ 쿼리도 함께 수행되는 장점이 있다. 따라서 페이징 처리시 별도 쿼리를 작성할 필요가 없다. BoardJpaRepositoryImpl를 만들었으면 실제 BoardJpaRepository에서 호출할 수 있도록 별도의 커스텀 인터페이스가 필요하다.

public interface BoardJpaRepositoryCustom {
    QueryResults<BoardDto> selectBoardQueryDsl();
}

BoardJpaRepositoryImpl에서 위에 인터페이스를 상속받았으니, 이제 jpa메소드랑 querydsl 메소드를 해당 인테페이스에서 호출 할 수 있게 된다.

이제 service 부분을 구현하자.

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class JpaService {
    private final BoardJpaRepository boardJpaRepository;
    private final BoardMapper boardMapper;

    public QueryResults<BoardDto> selectBoardQueryDsl() { return boardJpaRepository.selectBoardQueryDsl(); }

    @Transactional
    public Board saveBoard(BoardDto boardDto) {
        Board board = boardMapper.toEntity(boardDto);
        board.setTitle(boardDto.getTitle());
        board.setContent(boardDto.getContent());
        board.setCreateDate(LocalDateTime.now());
        return boardJpaRepository.save(board);
    }

    @Transactional
    public Board deleteBoard(BoardDto boardDto) {
        Board board = boardJpaRepository.findBoardByBoardNo(boardDto.getBoardNo());
        if (board == null) return null;
        boardJpaRepository.delete(board);
        return board;
    }
}

mybatis와 비교하면 update, insert가 하나의 save에서 처리된다. jpa에서는 PK 파리미터가 있다면 update 없다면 insert로 처리한다. saveBoard 메소드를 보면 Board board = boardMapper.toEntity(boardDto); 이부분이 보이는데 이것이 바로 dto 객체를 entity로 변환해주는 소스이다. 위 부분을 빼고 실제로 entity로 구현해도 되지만 controller에서는 dto로 파라미터를 처리하고 entity로 변환해주는 것을 권장한다. 이런 작업은 수동으로 해도 되지만 자동으로 구현해주는 것이 바로 mapstruct이다.

mapstruct외 modelmapper도 있는데 아래 블로그를 참조하면 이해하기가 쉬울 것이다.

ModelMapper와 MapStruct에 대해서 학습하기

따라서 여기서는 boardMapper 인터페이스를 별도로 구현한다.

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface BoardMapper {
    @Mapping(target = "user.userCode", source = "userCode")
    Board toEntity(BoardDto boardDto);
    BoardDto toDto(Board board);
}

@Mapping(target = "user.userCode", source = "userCode") 부분을 주의할 필요가 있다. board엔티티를 보면

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_code")
    private User user = new User();

user_code 필드를 다대일로 구현했다. 그러므로 dto > entity로 변환시 이부분에 대한 참조정보가 필요하다. 그래서 위와 같이 처리해야 한다.

이제 마지막으로 control 부분을 구현하자. mybatis와 다른 부분이 거의 없다.

@RestController
@RequestMapping("/api/jpa")
@RequiredArgsConstructor
public class BoardJpaController {

    private final JpaService jpaService;

    @GetMapping("/board")
    @ResponseBody
    public ResultJson selectBoard() {
        ResultJson resultJson = new ResultJson();
        QueryResults<BoardDto> queryResults = jpaService.selectBoardQueryDsl();
        resultJson.setItems(queryResults.getResults());
        resultJson.setTotal(queryResults.getTotal());
        resultJson.setSuccess(true);
        return resultJson;
    }

    @PostMapping("/board")
    @ResponseBody
    public ResultJson inserBoard(BoardDto boardDto) {
        ResultJson resultJson = new ResultJson();
        boolean result = false;
        if (jpaService.saveBoard(boardDto) != null) {
            result = true;
        }
        resultJson.setSuccess(result);
        return resultJson;
    }

    @PutMapping("/board")
    @ResponseBody
    public ResultJson updateBoard(BoardDto boardDto) {
        ResultJson resultJson = new ResultJson();
        boolean result = false;
        if (jpaService.saveBoard(boardDto) != null) {
            result = true;
        }
        resultJson.setSuccess(result);
        return resultJson;
    }

    @DeleteMapping("/board")
    @ResponseBody
    public ResultJson deleteBoard(BoardDto boardDto) {
        ResultJson resultJson = new ResultJson();
        boolean result = false;
        if (jpaService.deleteBoard(boardDto) != null) {
            result = true;
        }
        resultJson.setSuccess(result);
        return resultJson;
    }
}

6. 단순비교

그럼 mybatis와 jpa(querydsl, mapstruct추가)로 개발할때 어떤 것이 더 생산성이 좋을까? 단순 무식하게 일단 클래스 갯수와 소스 라인수(일반적인 주석포함)부터 따져보자.

설정파일

구분 설정파일 라인수
MyBatis Mybastis-config.xml 26
MyBatis DatabaseConfig 11
MyBatis Application.yml에 추가 3
MyBatis build.gradle에 추가 1
JPA QuerydslRepositorySupport.java 45
JPA Application.yml에 추가 9
JPA build.gradle에 추가 25

단순히 봐도 jpa쪽이 설정작업이 좀 많다. 그런데 어차피 설정은 한번만 하면 되기때문에 큰 의미는 없다. 실제 개발소스를 보자.

MyBatis 파일 및 소스

구분 XML Model Repository Service Control 라인수
MyBatis BoardMyBatisMapper.xml         41
MyBatis   Board       20
MyBatis   User       17
MyBatis     BoardMyBatisMapper     15
MyBatis       MyBatisService   37
MyBatis         BoardMybatisController 62

총 7개의 파일을 작성했고, 총 192라인을 작성햇다.

JPA 파일 및 소스

구분 XML Model Repository Service Control 라인수
JPA            
JPA   Board       35
JPA   BoardDto       32
JPA   User       31
JPA     BoardJpaRepository     10
JPA     BoardJpaRepositoryCustom     8
JPA     BoardJpaRepositoryImpl     39
JPA       JpaService   39
JPA         BoardJpaController 62

총 8개의 파일을 작성했고, 총 256라인을 작성했다. 단순무식한 방법으로 비교하면 JPA가 좀 더 작성부분이 많았다(물론 필자가 JPA고수가 아니라 이런 결과가 나올수 있으니 맹신은 하지 않길 바란다)

7. 결론

반복적으로 말하지만, 필자가 JPA고수도 아니고 해서 섵부른 지식으로 결론을 낸다는 것은 주제넘은 일이다. 지극히 개인적인 의견임을 밝힌다. MyBatis와 JPA로 프로젝트를 수행해보면 JPA가 더 끌리는건 사실이지만, 압도적으로 더 생산성이 좋다거나, 성능이 대폭 좋아졌다라는 것은 느끼지 못했다.

일단 JPA를 하면 SQL기반의 노가다 쿼리가 줄어드는건 사실이다. SAVE 메소드 하나만 보더라도 귀찮은 저장을 쿼리없이 해결할 수 있다. MyBatis는 단순 노가다 작업이 많은건 사실이다. 하지만 그에 못지 않게 JPA는 고려해야할 부분이 꽤 많다.

그리고 쿼리작업이 줄어든다고 해서 데이터베이스, 테이블간의 관계, SQL작성 능력이 필요없는 것이 아니라 더 필요하다. 그것을 객체관계로 옮기는 것이기 때문이다.

사실 MyBatis로 개발된 성능 이슈는 대부분 SQL 문제가 대부분이다. 이건 JPA도 마찬가지이다. JPA를 하면 유지보수가 좋아지고, 데이터베이스 변경시 용이하고, 컬럼 변경시 용이하다. 하지만 프로젝트에 따라 다르다고 본다. 따라서 Mybatis에서 문제가 있는 프로젝트를 JPA로 단순 변경한다고 해서 급격한 성능개선은 좀 어렵지 않을까 한다.

JPA는 회사내의 서비스나 솔루션을 개발하고 해당 개발자들이 직접 개발과 유지보수를 하는 경우는 도움이 된다. 솔루션같은 경우는 데이터베이스가 앤드유저 환경에 따라 달라질 수 있으므로 JPA로 하면 손쉽게 즉시 변경이 가능하다. 또한 SQL작업을 하면 컬럼오타등 쿼리오류가 런타임시 발생하지만 Querydsl를 사용하면 컴파일 시점에 에러를 발견할 수 있다는 큰 장점이 있다.

하지만 SI같은 단기성 프로젝트에는 적합하지 않다고 본다. 수개월에 걸쳐서 요구사항분석, 설계, 개발이 진행되어야 하는데 SI특성상 JPA에 익숙치 않은 개발자들이 많이 있다. JPA를 도입하므로 발생되는 학습비용이 만만치가 않다.게다가 유경함자가 있더라도 개발에 참여한 인력이 유지보수도 진행하는 경우도 드물다. 그래서 JPA로 호기롭게 개발했더라도 후에 투입되는 SM인력이 사용할 줄 모르거나 즉각적인 유지보수에 어려움을 겪는다면 담당자 및 최종고객에게 곤란한 상황을 발생시킬 수 있다. 그리고 SI는 앤드유저의 시스템을 구축하는게 대부분이라 데이터베이스 변경등의 경우는 거의 발생하지 않는다.

따라서 프로젝트에 특성에 맞게 사용하는 것이 가장 좋다. 그리고 JPA가 앞으로 주류가 될 확률이 높으니, SI개발을 한다고해서 모르는 건 좋지 않다. SI특성상 신기술의 도입이 다소 늦는건 사실이다. 그러니 기회가 주어진다면 적극적으로 해보길 권장하고 사전에 협업하는 개발자들과 공감은 필수사항이다.

해당 소스는 아래에서 확인이 가능하다.

spring-jpa-mybatis