Lilyrs

레거시에서 JPA 기반 서비스로의 이동

지난 글(Long.parseLong 과 JPA 엔티티에서의 시각 표현 관련 성토 글) 이후로 한달을 지나서야 이제야 글을 쓰게 된 이유는 내 귀찮음도 있었겠지만, 회사에 여러가지 어려운 일도 있고 이를 타파하기 위한 일환으로 자발적으로 진행되는 레거시 서비스 개편 작업이 진행중이었기 때문이다. 이 과정속에서 워낙 많은 불확실성이 많았고, 최근 2주간 쭉 물고 해결하고 있던 트랜잭션과 병행처리시 락킹 문제는 이 프로젝트가 정확히 진행되었냐에 대한 너무나 큰 의문을 주었다.

일단 오늘로 기본적으로 ‘한 사이클’을 완전히 돌았다고 할 수 있는 수준에 도달했다. 우려했던 성능 문제도 오히려 스탠드얼론 자바 애플리케이션에 비해 비약적인 성능 향상을 이룰 수 있어 꽤나 만족스러웠다. 기존에 일괄 데이터 처리 소요에 20분 가량을 소비했다면, 현재는 2분 내로 처리가 가능해진 셈이니 내가 작업을 해두고도 부정확하게 구현된 부분이 있나 없나 다시 살펴보게 되는 걱정을 하게 된다. 물론 이제 불확실성이 해소되었으니 미루뒀던 유닛테스트 작성 진도를 뽑아야한다.

이제 본론으로 돌아와서, 중간 회고를 진행하고자 한다. 완전한 회고는 내년 3월 정도는 되어야 가능할 것 같다. 사내에서 이제 이 프로젝트를 교육용으로 활용할 것이고, 개발과정부터 프레임워크 사용 전체를 업무 표준화에 쓰고자 하니까 최종 완성에 시간이 좀 더 필요한 상황이다. (물론 여기까지 시간을 쓸 수 있게 해준 팀원들에게 감사한다.)

경험했던 Legacy System의 특성

대부분 웹 프로젝트는, 웹 애플리케이션과 구동용 서버와 데이터베이스로 구성된다. 데이터베이스와 통신하는 주요 수단은 SQL이다.

SQL의 결과는 삽입/수정/삭제의 경우 영향을 받은 행 수를 알려주고, 조회의 경우 결과를 한 행씩 정리하여 테이블(표)로 표현한다.

데이터베이스 테이블 설계도 Entity와 Relationship 관계에 따라 잘 만들어져있다면 다행이지만 보통은, Key-Value 스토리지 급으로 사용되는 경우도 많고 의도하거나 의도하지 않게 정규화도 이루어지지 않으며 복합키로만 키가 존재하는 경우도 많다.

그리고 SQL 쿼리의 특성이 있다. DELETE와 INSERT, UPDATE 는 constraint 위반이 아니면 오류가 발생하지 않는다. 예를 들어 DELETE FROM Students WHERE name = 'Charlie' 라는 쿼리를 수행할 때, WHERE 절에 일치되는 행이 없는 경우 오류가 발생하는게 아니라 영향을 받은 행(affected rows) 수가 0이 되어 결과가 반환된다.

그리고 데이터베이스에 넣기 직전에 데이터 가공을 SQL 함수를 통해 상당부분 처리하는 경우가 있다. 이로 인해 특정 데이터베이스에서만 지원하는 함수에 많이 의지하게 되는 경우가 많다.

앞으로 바꿀 JPA 기반 서비스의 특성

최종적으로 데이터베이스 제어는 SQL에 의해 이루어지지만, JPA(Java Persistence API)의 구현체를 통하여 이루어지는 엔티티 객체 생성, 수정, 삭제, 조회 작업은 직접적인 SQL 조작이 필요하지 않다.[1] 이 조작들은 JPA 구현체를 통해 엔티티 객체<->DBMS 테이블 상태간 관리를 통해 적절히 동기화되며, 동기화 과정에서 수행되는 실질적 SQL 쿼리는 내부적인 Dialect 변환을 통해 DBMS에 알맞는 적절한 쿼리로 바뀌어 실행된다.

적절히 변환됨에 따라 DBMS에 따라 DDL 실행시 알맞은 자료형을 선택해주기도하고[2], @Id 어노테이션으로 마크해둔 필드에 대해선 인덱스도 자동으로 생성해주며, 키값 선정을 위해 시퀀스(sequence)를 사용하더라도 DBMS에 따라 Native Sequence가 가능한 경우엔 시퀀스를 사용하고, 없다면 Id Generation Table을 별도로 관리하여 시퀀스를 흉내내주기도 한다.

반드시 JPA 엔티티는 @Id 로 지정된 ID 필드가 최소 1개가 필요하다. 이것은 대부분 데이터베이스에 변환될 때 PK로 활용되며, 조회 성능을 위해 자동으로 인덱스를 생성해준다.[3] 여기까지도 큰 걱정은 없으며, JPA 구현체가 생성해준 초기 테이블 생성 DDL 코드를 출력하여 복사해둘 수 있으므로 이걸 바탕으로 최적화된 테이블 설계를 만들어도 좋다.

JPA 엔티티를 통한 데이터 조회/생성/삭제는 레포지토리에 대한 findById/save/delete 작업을 수행함으로써 이루어진다. 수정 작업은 조회로 받아온 엔티티의 setter를 호출하여 바꾸는 것만으로 충분하다. 트랜잭션이 종료될 때, JPA 구현체가 자동으로 변경사항을 반영해줄 것이다.

JPA 엔티티 객체에는 데이터베이스 커넥션과의 문제가 항상 달려있기 때문에, JPA 구현체의 DB 커넥션 세션 연결 여부에 따라 객체의 라이프사이클이 결정된다. 라이프사이클 상태에 따라서는, 추가 작업을 할 수 없는 상태가 되어 오류가 발생될 수 있다.[4]

이상적인 JPA 엔티티 설계는 ER다이어그램에서 정의한 설계와 비슷해진다. 그러나 레거시 시스템은 만들어질 때 그 부분이 고려되지 않고 만들어진 부분도 많으며, 심플한 유지보수를 위해 PK-FK 관계도 정확하게 정의하지 않았기 때문에 A 테이블에서 쓰던 키를 들고와서 B에서 수동으로 직접 조회해야만 결과를 알 수 있는 경우가 많다.

JPA 엔티티 선언 과정은 POJO 객체를 선언하고 getter/setter를 장착하는 과정과 동일하다. 이로써 길이에 대한 제약사항은 처리할 수 없지만[5] 적어도 각 필드의 자료형이 어떻게 들어가야하는지 간편하게 확인할 수 있다. 즉, 엔티티 작성 과정을 통해 테이블 정의가 어느정도 완성된다.

JOIN에 대한 표현에 취약하다. SQL로 작성할 때는 2중 3중으로 복잡한 JOIN도 많이 했지만 이제는 그런거 하기가 쉽지 않다. 그리고 JOIN의 의미가 많이 퇴색된다.

이걸 한다고 SQL을 완전히 버릴 수도 없으며, 데이터베이스에 대한 지식은 더욱 많이 요구되고, 더불어 JPA 구현체(예를 들면 Hibernate와 같은)에서 어떻게 엔티티 매니저가 동작하고 엔티티 객체의 수명을 관리하는지 잘 알아야할 필요가 있다.

SQL에서 JPA 엔티티간 연산으로 바꿀 때 주의할 쿼리 몇가지

SQL 쿼리를 알맞은 JPA 엔티티 조작으로 변환과정에서 주의해야할 부분이 있다. 앞에서도 언급한 부분인데, 쿼리에선 별 문제 없던 기능이 JPA에 똑같이 구현해두면 문제가 생긴다.

INSERT INTO … SELECT

SELECT 절의 결과를 받아 그대로 지정한 테이블에 삽입하는 쿼리. SELECT의 결과는 한 ID에 대해 단일 결과만 존재하거나 결과가 없을 수 있다고 가정한다.

아무 생각 없이 옮기면 다음처럼 코드를 작성할 수 있다.

1
2
3
4
5
FooEntity foo = fooRepository.getOne(someId);
BarEntity bar = new BarEntity();

BeanUtils.copyProperties(foo, bar);
barRepository.save(bar);

JPA에선 SELECT된 결과가 없는 경우, 즉 fooRepository 에서 해당 ID로 조회한 결과가 없을 경우 이 코드는 바로 오류가 발생된다.

그러나 SQL에서는 오류가 발생하지 않는다. 0개의 행이 삽입되는 것으로 끝나므로 오류가 발생되지 않는다.

이 동작은 Java 8부터 제공되는 Optional<T> 를 활용하여 풀어주면 적절하게 흉내낼 수 있다.

1
2
3
4
5
6
fooRepository.findById(someId).ifPresent((entity) -> {
BarEntity bar = new BarEntity();

BeanUtils.copyProperties(entity, bar);
barRepository.save(bar);
});

그러나 기존에 돌아가고 있는 시스템을 전환하는 것이 아닌 이상, fooRepository 에 해당 ID로 조회되는 정보가 없는 경우가 무엇인지 명확히 파악하고 정리해야한다.

SELECT로 인해 다중 결과가 나오는 경우도 비슷하게 처리해주어야한다.

DELETE FROM … WHERE

위의 경우와 비슷하다. 각 경우를 나눠서 생각해보면,

  • 0개를 삭제: WHERE 절에 매칭되는 행이 없다
  • 1개를 삭제: WHERE 절에 매칭되는 행이 하나이다. (= 고유하게 식별 가능한 ID를 통해서 삭제한다)
  • n>1개를 삭제: WHERE 절에 매칭되는 행이 많다.

SQL에서는 0개 삭제가 가능하다. 이는 오류가 발생되지 않으며, 실제로 직접적으로 쿼리를 다루기 위한 JDBC API의 preparedStatement.executeQuery() 같은 코드는 영향을 받은 행 수 (affected rows)를 반환하는 것으로 끝나며 0은 비정상적인 상황이 아닌 것으로 간주한다.

JPA에서는 ID를 지정해 삭제를 진행할 경우, 해당 ID로 조회된 결과가 없으면 바로 오류가 발생된다.

ID로 조회하여 매번 존재하는지 확인하고 삭제하며 지우는 방법도 있지만, 효율이 좋지 않기 때문에 @Query 어노테이션을 통해서 JPQL 쿼리를 입력해 기존 SQL처럼 UPDATE 구문을 처리하는 편이 더 낫다.

UPDATE SET … WHERE

DELETE FROM … WHERE 과 동일하다. WHERE 절에 매칭되는 결과가 어떨지에 대해 고려해서 작업을 진행해야한다.

SQL 기반 통계 쿼리

기존에 사용하던 레거시 시스템에서, 통계 처리 코드 등은 JPA로 전환하기 부적절한 경우가 많다. 데이터베이스마다 제각기 다른 통계/집계 함수를 사용하고 있을 수도 있으며, 여러 뷰(view)를 UNION 연산을 통해 합산하여 조회하는 통계도 많다보니 JPA로 흉내내기 어려운 부분들이 있다.

이것들은 그냥 SQL 쿼리로써 활용하는 편이 좋다.

대신 ? 로 치환자를 배치해놓는 방법 대신, 어떤 항목들이 들어가야되는지 이름이 붙는 NamedParameterJdbcTemplate 등을 활용하여 처리해주는 편이 좋을 것 같다.

성능 이슈

현재 진행 중인 프로젝트는 원래 돌던 Java 기반 커맨드라인 도구가 빠르지 않은 상태였는데, Spring Batch에 JPA 도입하고나서 드라마틱하게 빨라진 상황이다.

이전 업무 방식의 병목 지점이 어디였는지 확인이 쉽지 않아, 직접적으로 성능을 비교하긴 어려운 상황이다.

다만 성능에 영향을 줄 수 있는 부분에 대해 정리를 해두려고 한다.

@IdClass 등의 키 클래스와 엔티티 객체에 대한 hashCode/equals

굳이 JPA가 아니더라도, Java에서는 어느 객체라도 hashCode과 equals 구현에 항상 중요도를 두고 있다.

이를 구현함으로써 불필요한 부분에 대한 비교를 줄이고 필요한 부분에만 비교와 해시값 생성을 진행하여 객체 비교에서 효율을 상승시킬 수 있다.

모종의 문제로 한 업무(전체 업무가 아님)에 20분이 걸리던 상황에서, 올바르게 구현된 hashCode와 equals는 1~2분 가량을 단축시켜주었다.

JBoss 위키에 정리된 글에는 이 두 메소드 오버라이딩의 중요성에 대해 정리해두었다. 성능보다는, Hibernate 세션에서의 엔티티 객체 관리 관련 이슈 사항이 있다. Why are equals() and hashcode() important

별도의 인덱스 추가

JPA는 만능이 아니다. Hibernate와 같은 구현체는 PK로 잡힌 칼럼에 대해 자동으로 인덱스를 생성해주기도 하지만, 조회 조건에 따라서 다른 칼럼을 많이 참고한다면 수동으로 인덱스를 추가해주거나 적절한 인덱싱 전략을 선택하는 편이 옳다.

결국 JPA를 도입했지만, SQL DDL을 건들 일이 적잖기 때문에 Flyway 같은 데이터베이스 스키마 형상관리를 진행해야한다. Hibernate에서 초기에 임의로 생성해주는 테이블 레이아웃을 바탕으로 작업을 하면 Flyway에서 사용할 스키마 첫 버전을 작성하는데 도움이 된다. 그리고 이후 JPA 엔티티 수정/추가/삭제는 스키마 형상관리의 차기 버전으로 배포하여 진행해야한다.

그리고 Flyway의 오라클 데이터베이스 지원 버전 한계치에 대해 유의하기 바란다. 좋은 물건 쉽게 도입하고 싶겠지만, 2019년 12월 기준 Flyway 커뮤니티 버전(무료버전)은 오라클 12.2 가 최저 지원 한계선이다. 실제 지원은 10 버전까지도 가능하지만, 엔터프라이즈용으로 유료 구독을 통해 받은 시리얼키를 입력해야 동작한다.


  1. 1.Spring JPA와 Hibernate 조합에서는 @Query(nativeQuery=true) 로 네이티브 DBMS 쿼리를 쓸 수 있게 해준다. 하지만 데이터베이스 종속적 쿼리가 들어간다면 추후 유닛 테스트 구성 등에 불리한 부분이 많다.
  2. 2.예를 들어 적당한 길이의 문자열을 보관하기 위한 열을 선언할 때, H2를 쓸 땐 VARCHAR 를 활용하고 Oracle에서는 VARCHAR2 를 활용한다.
  3. 3.단, 자동으로 DDL 실행하는 걸 허용한 경우에만. 절대 production 환경에 이걸 켜선 안된다.
  4. 4.최근에 이걸로 많이 고생했다.
  5. 5.물론 @Column 어노테이션을 이용해 여러 제약사항을 정의할 수 있지만, 기본 자료형만으로는 길이 제한에 대한 검사가 쉽지 않다.