-
달리는 자동차의 타이어를 교체하기 : Stored Procedure 기반 데이터 접근 방식에서 ORM 기반으로 전환 (2)개발/Spring Boot 2025. 3. 20. 09:42
이 글은 Stored Procedure에서 ORM으로 전환하는 여정을 다루는 시리즈의 두 번째 글입니다. 첫 번째 글에서는 기존 시스템의 Stored Procedure 기반 데이터 접근 방식과 그 한계에 대해 살펴보았습니다. 이제 본격적인 전환 과정과 그 과정에서 직면했던 다양한 난관, 그리고 이를 해결하기 위해 취했던 접근 방식에 대해 자세히 설명해보려고 합니다.
Stored Procedure에서 ORM으로의 전환 과정에서의 난관과 해결 전략
1. 기존 SP의 복잡한 의존성 구조 파악
Stored Procedure(SP)에서 ORM으로 전환하는 과정에서 가장 먼저 직면한 문제는 SP의 복잡한 호출 관계였습니다. 기존 시스템에서는 SP들이 다양한 함수(FN)를 호출하며 강한 의존성을 가지고 있었으며, 단순히 하나의 SP를 분석하는 것이 아니라 해당 SP가 호출하는 모든 함수들의 계층 구조를 파악해야 했습니다.
이를 해결하기 위해, 가장 마지막에 호출되는 SP나 함수부터 차근차근 분석하는 방식(Bottom-up Approach)을 채택했습니다. 중첩 호출되는 로직을 정리하고, 데이터 흐름을 명확하게 이해할 수 있도록 했습니다. 예를 들어, 아래 SQL문처럼 SP 내부에서 여러 함수를 호출하는 경우를 살펴봅시다.
SELECT A.BOOKING_ID, FN_GET_CODE_NAME('BOOKING_TYPE', A.BOOKING_CATEGORY) AS BOOKING_CATEGORY_NAME, CONCAT(IFNULL(A.ADDRESS1,''), ' ', IFNULL(A.ADDRESS2,'')) AS FULL_ADDRESS, DATE_FORMAT(A.REQUEST_DATE,'%y/%m/%d') AS REQUEST_DATE, FN_BOOKING_STATUS_CODE(A.BOOKING_ID) AS BOOKING_STATUS, REPLACE(FN_GET_BOOKING_STATUS(A.BOOKING_ID),'(','<br/>(') AS STATUS_DESCRIPTION, IFNULL(CAST(FN_GET_BOOKING_WORK_SEQUENCE('WORK_SEQ', A.BOOKING_ID) AS SIGNED),0) AS WORK_SEQUENCE FROM BOOKING_INFO A WHERE A.BOOKING_CATEGORY = 'BOOK' AND A.BOOKING_TYPE IN ('HOME_SERVICE') AND FN_BOOKING_STATUS_CODE(A.BOOKING_ID) IN ('REQUEST_RECEIVED', 'IN_PROGRESS');
위와 같은 SQL문들이 의미를 모른 채 수십 개가 나열되어 있는 모습은 ORM 방식에 익숙한 개발자들에게는 직관적이지 않습니다. 하지만 포기하지 않고 각 SQL문을 하나씩 분석하며 역할을 파악하는 것이 중요합니다.
2. 호출 함수 추적 및 분석
SP 내부에서 호출되는 함수를 분석해 봅시다. 예를 들어, FN_GET_BOOKING_WORK_SEQUENCE 함수가 어떻게 동작하는지 살펴보면 다음과 같은 패턴이 보입니다.
IF PARAM_GBN = 'START_TIME' THEN -- 생략 ELSEIF PARAM_GBN = 'END_TIME' THEN -- 생략 ELSEIF PARAM_GBN = 'WORK_SEQ' THEN -- 생략 …
이 함수는 다른 여러 함수(FN_GET_DAY_OF_WEEK, FN_GET_WEEKDAY_NAME 등)를 호출하며 높은 의존성을 가집니다. 이를 ORM으로 변환하려면 이러한 의존성을 하나씩 추적하여 분리하는 작업이 필요합니다.
3. 가장 간단한 함수부터 자바 코드로 변환하기
Stored Procedure에서 ORM으로 전환할 때는 비교적 단순한 기능부터 변환하는 것이 좋습니다. 이는 앞서 언급한 Bottom-up 접근법과 연결됩니다. SP의 복잡한 호출 관계를 고려할 때, 가장 마지막에 호출되는 함수나 상대적으로 독립적인 기능을 먼저 변환하는 것이 효과적입니다. 예를 들어, FN_GET_BOOKING_WORK_SEQUENCE에서 호출되는 FN_GET_WEEKDAY_NAME 함수는 SQL의 DAYOFWEEK 함수를 사용하여 특정 날짜의 요일을 반환하는 기능을 수행합니다.
SELECT CASE WHEN DAYOFWEEK(IN_WORK_DATE) = 1 THEN '일' WHEN DAYOFWEEK(IN_WORK_DATE) = 2 THEN '월' WHEN DAYOFWEEK(IN_WORK_DATE) = 3 THEN '화' WHEN DAYOFWEEK(IN_WORK_DATE) = 4 THEN '수' WHEN DAYOFWEEK(IN_WORK_DATE) = 5 THEN '목' WHEN DAYOFWEEK(IN_WORK_DATE) = 6 THEN '금' WHEN DAYOFWEEK(IN_WORK_DATE) = 7 THEN '토' END AS DAY_OF_WEEK
위 코드를 자바로 변환하면 다음과 같습니다.
public class DateUtil { public static String getWeekdayName(LocalDate date) { DayOfWeek dayOfWeek = date.getDayOfWeek(); switch (dayOfWeek) { case SUNDAY: return "일"; case MONDAY: return "월"; case TUESDAY: return "화"; case WEDNESDAY: return "수"; case THURSDAY: return "목"; case FRIDAY: return "금"; case SATURDAY: return "토"; default: return ""; } } }
이처럼 FN_GET_WEEKDAY_NAME 같은 단순한 함수를 먼저 Java 코드로 변환하면, 기존 SP의 로직을 조금씩 이해하고 해체할 수 있는 기반이 마련됩니다. 이후 FN_GET_BOOKING_WORK_SEQUENCE같은 더 복잡한 함수로 확장할 때도 보다 체계적인 방식으로 접근할 수 있습니다.
4. 단순 조회 쿼리 변환과 고려사항
기본적인 SELECT 쿼리는 JPA 또는 Java 코드로 변환하는 작업이 상대적으로 수월했습니다.
왜냐면 JPA에서 제공하는 매직 키워드* 이외에 JPQL*이 있기 때문입니다.예를 들어 다음과 같은 조회 SQL 문이 있다고 가정합시다.
SELECT COUNT(*) AS TOTAL_COUNT, CEIL(COUNT(*) / PAGE_SIZE) AS TOTAL_PAGES, CASE WHEN LENGTH(IFNULL(USER_PHONE_NO,'')) < 11 THEN '01011111111' ELSE IFNULL(USER_PHONE_NO,'') END AS USER_PHONE_NO FROM BOOKING_INFO A WHERE REPLACE(A.PHONE_NUMBER,'-','') = REPLACE(USER_PHONE_NO,'-','') AND A.BOOKING_CATEGORY = 'BOOK' AND A.BOOKING_TYPE IN ('HOME_SERVICE') AND FN_BOOKING_STATUS_CODE(A.BOOKING_ID) NOT IN ('REQUEST_RECEIVED', 'IN_PROGRESS');
먼저, 위 SQL을 단순화 하면 다음과 같습니다.
SELECT … FROM BOOKING_INFO A WHERE A.PHONE_NUMBER = USER_PHONE_NO AND A.BOOKING_CATEGORY = ‘BOOK‘ AND A.BOOKING_TYPE IN (‘HOME_SERVICE’) AND FN_BOOKING_STATUS_CODE(A.BOOKING_ID) NOT IN (‘REQUEST_RECEIVED’, ‘IN_PROGRESS’)
이제 간단히 정리된 위 SQL 문에서 JPQL로 변환할 수 있는 부분을 먼저 살펴봅시다.
WHERE A.PHONE_NUMBER = USER_PHONE_NO AND A.BOOKING_CATEGORY = ‘BOOK‘ AND A.BOOKING_TYPE IN (‘HOME_SERVICE’)
이 조건들은 JPA에서 제공하는 JPQL을 사용해 무리 없이 가져올 수 있다. 하지만 FN_BOOKING_STATUS_CODE 는 DB내 함수를 호출하는 부분이므로, JPA에서 직접 처리할 수 없습니다.
이러한 경우, 해당 로직을 Service 계층의 메서드로 대체해야 합니다. 그러나 JPA에서 조회 시 Service Layer의 Service Method를 직접 호출할 수 없기 때문에, 조회된 결과를 Java Stream을 활용하여 필터링하는 방식으로 처리하도록 했습니다.
먼저 JPQL을 통해 데이터를 가져오고 Java Stream을 활용해 데이터를 정리하는 코드입니다.
@Query(""" SELECT m FROM BookingInfo m WHERE m.phoneNumber = :phoneNumber AND m.bookingCategory = 'BOOK' AND m.bookingType IN ('HOME_SERVICE') """) List<BookingInfo> findAllByPhoneNumberAndBookingCategoryByBookAndBookingTypeInHomeService(String phoneNumber);
List<BookingInfo> bookings = bookingRepository.findAllByPhoneNumberAndBookingCategoryByBookAndBookingTypeInHomeService(phoneNumber) .stream() .filter(booking -> { String statusCode = myBookStatusService.getBookStatusCode(booking.getId()); return !Arrays.asList("REQUEST_RECEIVED", "IN_PROGRESS").contains(statusCode); }) .collect(Collectors.toList());
.. 추후 bookings.size()로 Total Count 값 등을 추출하여 SELECT에서 원하는 최종 형태의 값을 만들어낼 수 있습니다.
단순 조회 쿼리는 비교적 수월하게 JPQL로 변환할 수 있었습니다. 그러나, 데이터베이스 내에서 수행되던 특정 로직(예: FN_BOOKING_STATUS_CODE와 같은 함수 호출)을 ORM에서 그대로 처리하기는 어려웠습니다.
이러한 경우, 서비스 계층(Service Layer)에서 로직을 처리하는 방식으로 접근하는 것이 유지보수에 좋으며, 우리는 Java Stream을 활용한 필터링을 적용하여 데이터를 가공하였습니다. 하지만, 대용량 데이터를 처리할 경우 Java Stream을 통한 후처리는 성능 저하를 유발할 수 있다는 점을 인지해야 합니다. 가능하다면 DB 단에서 수행할 수 있도록 쿼리 최적화 및 네이티브 SQL 활용을 고려해야 합니다.
*매직 키워드 - JPA에서 제공하는 개발자가 쿼리를 직접 작성하지 않아도, 메서드 명만으로 자동으로 SQL을 생성하는 기능
*JPQL - JPA에서 제공하는 객체 지향적인 쿼리 언어입니다.
5. 연관관계와 JOIN
ORM 기반으로 전환하는 과정에서 가장 먼저 고려해야 하는 부분 중 하나가 엔티티 간 연관 관계 설정입니다. 그러나 기존 DB가 ORM을 고려하지 않고 설계된 구조라면, 단순히 연관관계를 설정하는 것이 오히려 성능 및 유지보수 측면에서 비효율적일 수 있습니다.
기존 시스템에서는 JOIN을 활용한 RDBMS 방식으로 데이터를 조회했습니다. 즉, 한번의 쿼리로 여러 테이블을 조인하여 필요한 데이터를 가져오는 방식이 일반적이었습니다. 하지만 ORM에서는 연관관계를 설정하면 즉시 로딩이나 지연 로딩 같은 전략을 고려해야 하고, 잘못 설정하면 N+1과 같은 문제가 발생할 가능성이 높아집니다.
특히, 기존 데이터 모델이 정규화가 과도하게 되어 있는 경우 엔티티 간 연관관계를 설정하면 불필요한 조인이 많아지면서 성능이 저하될 가능성이 큽니다. 예를 들어, @OneToMany, @ManyToOne 같은 매핑을 남발하면 JPA 내부적으로 예상치 못한 JOIN이 발생하거나, 불필요한 데이터 로딩이 증가할 수 있습니다.
이런 문제를 방지하기 위해 불필요한 연관관계 설정을 피하고 식별자를 기반으로 데이터를 조회하는 방식을 유지하는 전략을 선택했습니다.
그럼에도 불구하고 기존 SQL에서는 여러개의 JOIN이 포함된 복잡한 쿼리가 많이 사용됩니다.
그럴 때 사용할 수 있는 방법을 소개하려고 합니다.SELECT A.*, B.*, C.* FROM BOOKING_INFO A JOIN USER_INFO B ON A.USER_ID = B.USER_ID JOIN PAYMENT_INFO C ON A.BOOKING_ID = C.BOOKING_ID
이런 SQL문이 있다고 가정하고 이 JOIN문을 ORM으로 변경하는 두가지 방법을 살펴보겠습니다.
- 단계적 조회(Query Splitting) 방식: IN 절을 활용한 데이터 매칭
ORM에서는 JOIN을 한 번에 실행하는 것이 아니라, 각 테이블을 개별적으로 조회한 후 ID 값을 기반으로 매칭하는 방식을 사용할 수 있습니다. 즉, SQL에서 하는 JOIN을 여러 개의 개별적인 조회로 분리하는 방식입니다.
- USER_INFO에서 모든 USER_ID 조회
@Query("SELECT u.id FROM UserInfo u")
List<Long> findAllUserIds(); - BOOKING_INFO에서 USER_ID를 기반으로 예약 정보 조회
@Query("SELECT b FROM BookingInfo b WHERE b.userId IN :userIds")
List<BookingInfo> findBookingsByUserIds(@Param("userIds") List<Long> userIds); - PAYMENT_INFO에서 BOOKING_ID를 기반으로 결제 정보 조회
@Query("SELECT p FROM PaymentInfo p WHERE p.bookingId IN :bookingIds")
List<PaymentInfo> findPaymentsByBookingIds(@Param("bookingIds") List<Long> bookingIds);
- USER_INFO에서 모든 USER_ID 조회
- 모든 데이터를 조회한 후, 자바에서 매핑하여 연관 데이터 가져오기
- 먼저 개별 테이블의 데이터를 조회한다.
List<UserInfo> users = userRepository.findAll();
List<PaymentInfo> payments = paymentRepository.findAll();
List<BookingInfo> bookings = bookingRepository.findAll(); - 조회한 데이터를 Map으로 변환한다
Map<Long, UserInfo> userMap = users.stream()
.collect(Collectors.toMap(UserInfo::getId, Function.identity()));
Map<Long, PaymentInfo> paymentMap = payments.stream()
.collect(Collectors.toMap(PaymentInfo::getBookingId, Function.identity())); - 필요한 데이터를 매칭하여 사용한다.
List<BookingResponse> responseList = bookings.stream()
.map(booking -> new BookingResponse(
booking,
userMap.get(booking.getUserId()),
paymentMap.get(booking.getId())
))
.collect(Collectors.toList());
- 먼저 개별 테이블의 데이터를 조회한다.
각 방법은 장단점이 존재합니다. 먼저 단계적 조회(Query Splitting) 방식은 IN 절을 활용한 데이터 매칭 방식은 불필요한 데이터 로딩을 방지하고 N+1 문제를 해결하며 JOIN을 최소화 할 수 있으나 IN절의 리스트가 너무 많아지면 성능이 저하될 수 있고, 쿼리 실행 횟수가 증가하므로 네트워크 비용을 고려해야 합니다. 모든 데이터를 조회한 후 자바에서 매핑하여 연관 데이터를 가져오는 방식은 데이터를 한꺼번에 가져올 수 있으며 Java에서 매핑을 하기 때문에 유연한 데이터 조작이 가능하다는 장점이 있으나, 데이터가 많으면 메모리 사용량이 급증할 수 있고 전체 데이터를 가져오므로 네트워크 부하가 커질 수 있는 단점이 있습니다.
Stored Procedure에서 ORM으로의 전환 과정에서 우리는 복잡한 의존성 분석, 단순한 함수부터 단계적으로 변환하는 접근법, 조회 쿼리 변환, 그리고 연관관계 매핑 및 최적화 문제를 해결하는 방법을 살펴보았습니다. 하지만 ORM 도입이 끝이라고 할 수는 없습니다.
다음 글에서는 전환 결과와 달성한 이점, 향후 과제 및 결론에 대해 이야기할 예정입니다. ORM 전환 이후의 운영과정에서 발생한 문제들은 무엇이었고, 어떻게 해결했는지 함께 살펴봅시다.
1편 보러가기 : https://na2ru2.tistory.com/46, https://okky.kr/articles/1529724
'개발 > Spring Boot' 카테고리의 다른 글
달리는 자동차의 타이어를 교체하기 : Stored Procedure 기반 데이터 접근 방식에서 ORM 기반으로 전환 (1) (0) 2025.03.18 (OCP)개방 폐쇄 원칙 위배를 극복한 전략 패턴 적용 사례 (0) 2024.07.12 Redis Cache를 통해 이미지 조회 성능 개선하기 (0) 2024.05.24 이미지 압축, 해제 성능 개선하기 - Snappy 도입을 통하여 (0) 2024.05.24 유지보수가 용이하도록 아키텍처 구성하기 (0) 2024.05.24 - 단계적 조회(Query Splitting) 방식: IN 절을 활용한 데이터 매칭