ORM 프레임워크 통합에서 자주 발생하는 안티패턴과 그로 인한 성능·가독성·테스트 문제를 실사례와 함께 짚고, DAO 추상화와 명시적 쿼리, 적절한 로딩 전략, 통합 테스트 등 더 나은 설계 방식을 제안한다.
요즘 대부분의 프로젝트는 어떤 형태로든 ORM 프레임워크를 사용한다. ORM은 성가신 데이터베이스 관련 작업을 감춰 개발을 더 수월하게 만든다. 보안에 도움을 주고, 보일러플레이트를 줄이며, ORM을 쓰지 않을 때 빠지기 쉬운 온갖 함정을 제거해 준다. 솔직히 말해, 오늘날 데이터베이스를 쓰면서 ORM 없이 진행하는 프로젝트가 있다면 그쪽이 더 놀랍다.
ORM 프레임워크가 훌륭하다고 해서 다른 영역에 도전 과제를 유발하지 않는다는 뜻은 아니다. 이 글에서는 내가 겪은 가장 큰 함정 몇 가지를 살펴본다. 여기서 다루지 않은 함정도 분명 더 많겠지만, 특히 치명적이라고 느낀 것들을 골랐다. 문제의 원인은 ORM 자체라기보다, 개발자가 선택한 ORM을 애플리케이션에 어떻게 통합하느냐에 있다.
이야기부터 시작해 보자. 한때 “학교 관리 시스템”이라고 치자(실제는 아니지만 특정하지 않기 위해), 이하 _SMS_라고 부르겠다. 이 프로젝트는 90년대 중반까지 뿌리가 거슬러 올라가는 C# .NET WinForms 모놀리식 애플리케이션이었다. 짐작하듯, 그렇게 오래된 시스템에는 별별 기벽이 많았고, 그중 하나가 ORM을 다루는 방식이었다.
_SMS_는 XPO라는 상용이지만 강력한 ORM을 사용했다. XPO는 현대적인 ORM에서 기대하는 기능을 갖추고 있으며, 여기 이야기의 문제는 XPO 탓이 아니다. 이 이야기의 책임은 개발자에게 있다.
처음에 _SMS_는 소규모 반, 어쩌면 1:1 수업을 하는 개인 교사를 위해 만들어졌다. 데이터 규모가 매우 작았기 때문에 데이터베이스 상호작용은 아주 쉬웠다. 조인을 몇 개를 얹어도 그냥 처리됐다. 그래서 개발자들은 편리하지만 기술적으로는 열악한 방식으로 XPO를 쓰곤 했다.
편의를 위해 _SMS_의 비즈니스 로직은 XPO를 직접 사용했다. 즉, DAO 클래스가 전혀 없었다. 알고리즘이 데이터가 필요하면 XPCollection으로 직접 질의했다. XPCollection은 쿼리를 구성해 DB에서 데이터를 가져오게 해 주는 객체다. 나중에 이야기하겠지만, 비즈니스 로직에서 ORM 도구를 직접 쓰기 시작하면 금세 난장판이 되곤 한다. 그런데 _SMS_는 그렇게 만들어졌다. 또한 _SMS_는 XPO의 인터페이스가 아니라 구체 구현에 의존했으며, 쿼리 객체를 주입하지도 않았다. 이 모든 요소가 겹치면, 코드 어디서든 어떤 방식으로든 데이터베이스를 호출할 수 있고, 그것들을 의미 있게 검증하기는 극도로 어려운 시스템이 된다.
계속하기에 앞서, _SMS_가 엔티티를 다루는 방식과 무관한 다른 문제들도 있었다:
이 모든 게 좋게 들리진 않지만, 수년간 개인 교사들에게는 그럭저럭 잘 동작했다. 그러다 경영진이 타깃을 소규모 사립학교와 느슨하게 맞출 수 있는 다른 조직으로 넓히기로 했다. 그리고 사상 최대 규모의 고객을 유치했는데, 무려 직원 14명짜리 기관이었다! 보통은 고객 데이터를 다른 시스템에서 변환해 그대로 운영에 던져 넣곤 했다. 이번에는 테스트를 먼저 해 보기로 했다. 이 기관의 평판이 좋아 커뮤니티에서 영향력이 컸기에, 경영진이 모든 것이 매끄럽게 진행되도록 압박했다. 최적화 후 데이터베이스 변환에 12시간이 걸렸다. 테스트 환경 중 하나에 복원하고(그때는 GDPR 이전이었다), 애플리케이션을 실행해 로그인했다. 그리고 기다렸다… 또 기다렸다… 약 1분 뒤에야 UI의 멈춤이 풀리고 해당 사용자의 스케줄이 떴다.
큰 DB라서 변환 데이터의 품질이 걱정되던 차에, 이제는 애플리케이션이 멈춘다고? “학생 탭”을 눌러 특정 학생의 모든 정보를 보여주는 화면으로 갔다. 초기 로드에 20초가 걸렸다. 초기화 때문인가? WinForms 문제인지 보려고 학생을 몇 명 바꿔가며 눌러 봤다. 속으로는 아니라는 걸 알았지만, 고객이 며칠 뒤면 라이브로 들어가야 했기에 뭐든 붙잡아 보고 싶었다. 불행히도 학생 선택 목록을 열려고 하자 5분이 걸렸다.
이쯤 되면 상황이 FUBAR라는 걸 안다. 그래도 얼마나 심각한지 봐야 했다. 가장 까다로운 탭이 회계 탭, 즉 기관의 모든 재무 정보가 있는 탭이라는 걸 알고 있었다. 재무 탭을 눌렀다… 약 20분 뒤 애플리케이션이 크래시했다…
명확히 하자. 수천 명의 사용자와 수백만 행의 데이터 이야기가 아니다. 사용자 14명, 여러 테이블에 흩어진 수십만 행 규모였다. _SMS_는 최신 MS SQL을 사용했고, 이 정도 데이터는 문제가 되지 않아야 한다. 하지만 우리에겐 문제였다. MS SQL도, XPO도, C#도, WinForms나 .NET도 잘못이 없었다. 문제는 전적으로 우리 개발자에게 있었다.
우리는 즉시 경영진에 알렸고, 그들은 며칠을 더 벌어 주는 동안 밤낮없이 ASAP로 고치라고 했다.
그 기간 동안 우리는 하루 16시간은 일했고, 집에는 씻고 자러만 갔다. 엄청난 노력을 쏟아 결국 애플리케이션을 실행 가능한 상태로 만들었다. 이런 “소방수” 상황은 미화되기 마련이지만, 실상 우리의 해법은 졸속이었고 처참했다. 최악의 성능 문제는 해결했지만, 다음 날 고객의 말처럼 현재 상태의 애플리케이션은 사실상 쓸 수 없었다.
앞서 넌지시 말했듯, _SMS_가 XPO를 다루는 방식에 심각한 문제가 있었고, 그것이 성능 문제의 핵심이었다.
_SMS_의 엔티티는 데이터베이스 테이블의 관계를 그대로 반영하도록 설계되었다. DB에 외래 키가 있으면 코드의 엔티티 사이에도 관계가 있었다. 우리가 깨달은 건, 이 패턴 때문에 코드를 아주 편하게 그렇게 쓰도록 만들어 왔다는 사실이다. 예컨대 학생의 수업 목록이 필요하면, 굳이 DB를 쿼리할 필요 없이 student.getClasses()를 호출하면 된다. 적절한 함수가 이미 있었고 편리했다. 그리고 그런 관계를 직접 쓰다 보니, 관계를 즉시 로드로 설정해 두었다. 사실상 모든 데이터를 메모리에 올려도 됐던 소규모 고객에서는 문제없었지만, 새로 영입한 더 큰 고객에게는 불가능했다.
문제는 단순히 즉시 로딩을 지연 로딩으로 바꿀 수 없었다는 점이다. 페치 동작이 엔티티에 정의돼 있어서, eager를 lazy로 바꾸면 다른 부분에서 성능 문제를 유발할 위험이 있었다. 하나를 잡으면 X개가 다시 튀어나오는 두더지 잡기 게임이 된다. 근본 원인을 고치자니 고객이 용납하지 못할 몇 달짜리 작업이 된다. 남은 선택지는 병의 원인이 아니라 증상만 완화하는 것뿐이었다.
가장 흔한 “해법”은 XPO의 XPView를 사용하는 것이었다. XPView는 엔티티 대신 데이터의 맵/딕셔너리를 반환한다. XPView를 쓰면 엔티티를 완전히 우회하므로 원치 않는 즉시 로딩을 피할 수 있지만, 동시에 엔티티가 주는 이점도 잃는다. 코드는 끔찍해 보였지만, 최소한 받아들일 만한 속도로는 동작했다. 이런 접근은 당장은 먹히지만, 미래에 반드시 대가를 치르게 된다. 기존 기술 부채를 더 많은 기술 부채로 덮으려 한 셈이다. 코드는 이제 XPO 엔티티와 하드코딩된 문자열 키로 값을 꺼내는 딕셔너리가 뒤섞였다. 전체적으로는 빨라졌지만 더 복잡하고 덜 유연해졌다.
앞서 설명했듯, DAO가 전혀 없었다. 어디서 DB 호출이 트리거되는지 알 수 없었고, 사실상 어디서든 호출이 나갈 수 있었다. 또 다른 흔한 해법은 호출 체인의 최상위에서 데이터를 미리 로드하는 것이었다. XPO가 객체를 캐시에 넣어 주었기 때문에, 적절한 엔티티들을 미리 로드하면 쿼리를 피할 수 있었다. 그 결과 아무 작업도 하지 않고 객체 묶음을 로드해 캐시에 넣어 두는 함수가 많아졌다. 이것도 작동은 하지만, 매우 취약하다. 하위 호출이 바뀌면 초기 로드가 무의미해지고 성능 문제가 재발할 수 있었다(실제로 자주 그랬다).
_SMS_는 첫 “큰 고객” 이후 내내 성능 문제에 시달렸다. 더 큰 사용자가 생길 때마다 애플리케이션이 무너졌고, 우리는 소방수처럼 진화해야 했다. 이후 나는 다른 이유로 프로젝트를 떠났지만, 당시의 상태가 자랑스럽진 않다. 그때는 성능 문제의 핵심 원인을 깨닫지 못했다. 여러 다른 이유 탓으로 돌렸지만, 사실 아키텍처 문제라는 생각은 못 했다. 우리는 ORM 통합을 심각하게 잘못 관리했고, 비즈니스는 큰 대가를 치렀다. 그 프로젝트에서 많은 것을 배웠고, 지금이라면 전혀 다르게 했을 것이다.
_SMS_는 그런 클래스를 전혀 쓰지 않았고, 그 결과 ORM의 복잡성이 비즈니스 로직과 단단히 결합됐다. ORM 전용 클래스를 직접 접근했을 뿐 아니라, XPO가 제공하는 인터페이스조차 사용하지 않았다. 나중에 추상화하려면 고통스러운 과정이 된다. 내가 동의하지 않는 해결책 중 하나는 데이터베이스 자체를 목킹하는 것이다. 일부 상황에서는 적절할 수 있지만, 애플리케이션의 거의 모든 함수가 DB에 접근한다면 테스트 세팅에 엄청난 부가 코드가 생긴다. 또한 ORM을 추상화하지 않으면 테스트가 비가시적인 ORM 내부 동작까지 고려해야 해서 까다로운 상황을 초래한다.
더 나은 접근은 쿼리를 전담 클래스에 넣고 인터페이스 뒤로 숨기는 것이다. 그러면 비즈니스 로직에 대한 테스트를, ORM이나 데이터베이스를 걱정하지 않고 작성할 수 있다.
ORM의 복잡성을 추상화하면 비즈니스 코드와 테스트가 더 깔끔해진다. 모든 것을 추상화해 두면 DB 호출 지점을 명확히 알 수 있고, 그 지점의 쿼리를 데이터베이스에 대해 통합 테스트할 수 있다.
업계는 이미 UI와 비즈니스 로직의 강한 결합이 나쁘다는 데 동의했고, ORM과의 강한 결합에도 같은 논리가 적용된다. 데이터베이스와 ORM은 애플리케이션이 다뤄야 할 의존성이다. 대부분의 서드파티 의존성과 마찬가지로, 비즈니스 로직에서는 이런 의존성을 추상화해야 한다.
대부분의 ORM은 엔티티 내 참조가 아직 로드되지 않았을 때 자동으로 지연 로딩하는 기능을 제공한다. 그러나 명시적으로 쿼리를 다루도록 만들어진 객체(DAO) 밖에서 발생하는 쿼리는 좋지 않은 관행이며, 일어나면 안 된다. 지연 로딩을 피해야 하는 주된 이유는 크게 두 가지다.
NOTE
기본 지연 로딩은 종종 마법 같아 보이고 데모에서는 특히 멋지다. 하지만 편의성은 더 나은 소프트웨어를 보장하지 않는다. 아예 지연 로딩을 허용하지 않고, 로드되지 않은 프로퍼티에 접근하면 예외를 던지는 프레임워크가 있다면 흥미로울 것이다.
예외가 있다면 개발자는 강제로 즉시 로딩을 하게 될 것이고, 특정 용례에 최적의 성능을 내려면 별도의 쿼리를 작성해야 한다는 사실을 개발 초기부터 분명히 알게 된다.
그런 프레임워크가 있는지는 모르겠다. 내가 접해 본 ORM들은 지연 또는 즉시 로딩을 선택할 수는 있어도, 지연 로딩 자체를 명시적으로 금지하도록 하지는 않는다.
지연 로딩 기능이 전혀 없는 ORM이 애플리케이션 아키텍처에 어떤 영향을 주고, 더 나은 해법으로 자연스럽게 이끈다는 가설이 성립하는지 궁금하다.
많은 ORM은 엔티티 안에서 개별 관계를 언제 해소할지 선언할 수 있게 한다. 로드 전략을 엔티티 자체에 적용하면 여러 부작용이 있는데, 가장 큰 문제는 성능 악화로 이어지기 쉽다는 점이다.
우리는 _SMS_에서 엔티티에 즉시 로딩을 정의하는 실수를 했다. 엔티티 수준의 즉시 로딩은 큰 골칫거리가 되어, 특정 객체와의 모든 상호작용이 DB의 거대한 덩어리를 통째로 로드하게 만들었다. 일부 영역에서는 즉시 로딩이 편리했지만, 다른 영역에서는 특정 엔티티를 다루는 것 자체가 감당할 수 없는 성능 문제를 야기했다.
문제의 핵심은, 엔티티 안에 심어 둔 로직과 규칙이 그 엔티티와의 모든 상호작용을 지배한다는 점이다. ORM은 엔티티를 읽어 그 요구사항을 강제하고, 우리는 곤란한 처지에 놓인다. 엔티티에 즉시 로딩을 넣으면 하나의 필요는 충족할지 몰라도, 다른 필요를 소외시킬 수 있다. _SMS_에서는 엔티티 자체가 메모리에 안정적으로 올리기에는 너무 무거워, 아예 엔티티를 우회했다. 대신 값을 맵/딕셔너리에 읽어 처리했다. 작동은 했지만, 우아하다고 하긴 어렵고, 잘못 설계된 엔티티 때문에 강요된 방식이었다.
더 적절한 접근은 쿼리 수준에서 즉시 로딩을 정의하는 것이다. 대부분의 ORM은 DAO 클래스에서 무엇을 즉시 로딩할지 결정할 수 있게 해 준다. 쿼리 수준의 즉시 로딩은 각 쿼리마다 무엇을 미리 로드할지 선택할 수 있어, 많은 데이터를 로드하는 쿼리와 아주 가벼운 쿼리를 용례별로 구분해 둘 수 있다. 부담을 DAO에 두면, 다른 용례에 영향을 주지 않으면서 더 많은 요구를 수용할 수 있다.
하이버네이트 같은 일부 ORM은 트랜잭션 커밋 시 변경된 객체를 암묵적으로 업데이트한다. 이것을 안티패턴으로 볼지 여부는 명시성을 얼마나 중시하느냐에 달려 있다.
내 경험상, 명시적인 코드는 더 읽기 쉽고 테스트도 쉬운 경향이 있다. 코드는 읽기 쉽고 이해하기 쉬워야 하며, 애플리케이션 안에서 암묵적으로 트리거되는 코드는 가독성에 반한다. AOP 같은 기능이 나쁘다는 뜻은 아니지만, 가독성에 미치는 영향을 경계해야 한다. 코드는 읽는 이에게 최소한의 부담만 주어야 한다.
Grady Booch는 이렇게 말했다:
"Clean code is simple and direct. Clean code reads like well-written prose. Clean code never obscures the designer’s intent but rather is full of crisp abstractions and straightforward lines of control."
암묵적 업데이트를 허용하면 선명한 추상화도, 명료한 제어 흐름도 갖지 못한다. 영속화 호출은 독자에게 보이지 않는 배경에서 일어난다. 내가 암묵적 DB 호출을 비판하는 이유는 기능적 관점이 아니라, 가독성 관점에서다. 무언가를 저장하려는 의도가 있다면, 코드가 명시적으로 저장한다고 말해야 한다. 로딩도 마찬가지다. 이는 안티패턴 #2에서 살펴본 내용이다.
이건 ORM 자체와는 직접 관련이 없지만, 의외로 종종 빠지는 부분이다. 대부분의 프로젝트는 유닛 테스트가 있지만, 데이터베이스에 대한 테스트는 생략하기도 한다. 나는 보통 좁은 통합 테스트로 구현한다.
모든 쿼리를 일일이 테스트할 필요는 없다. 특히 ORM이 제공하는 구조에 강하게 의존하는 쿼리는 그렇다. 예컨대 스프링 데이터의 CrudRepository는 내장 함수가 많은데, 그 모두를 테스트하고 싶지는 않을 것이다. 이런 경우 ORM이 객체를 DB로/DB로부터 매핑할 수 있는지만 검증하면 된다. 시스템 테스트가 DB 읽기를 트리거하는 경우도 있으므로, 애플리케이션과 DB 간 계약이 깨지면 그 테스트가 실패할 것이다.
ORM을 사용할 때 테스트 커버리지는 항상 충분한 가치를 제공하지 않는다. 때로는 유사한 쿼리가 이미 검증한 것을 또 검증할 뿐이다. 대신 중요한 것에 집중하자:
지금까지는 개발자가 ORM을 통합하는 과정에서 저지르는 안티패턴을 봤다. 이제 ORM 자체가 초래할 수 있는 아주 교묘한 함정을 하나 보자.
도입부의 SMS 프로젝트에서, 우리는 가끔 ORM이 수준 이하의 쿼리를 생성하는 경우를 발견했다. 일부는 XPO가 쿼리를 구성하는 방식을 들여다보면 금방 잡을 수 있었지만, 때로는 운영에서 사용한 MS SQL 버전에서는 매우 비효율적인 쿼리를 생성하기도 했다. 테스트는 다른 버전에서 돌렸기 때문에 이런 문제는 악몽 같았다. 여기서의 교훈은 XPO가 나쁘다는 게 아니라, ORM은 범용적으로 설계되어 때로는 특정 사례에 맞지 않는 결정을 내린다는 점이다. 교훈은 다음과 같다: ORM이 SQL을 생성하도록 하는 복잡한 쿼리를 작성할 때는, 생성된 SQL을 반드시 재확인하라.
일부 ORM은 똑똑한 척하며, 무엇을 즉시 혹은 지연 로드할지를 개발자가 “제안”만 하게 하고 실제 결정은 프레임워크가 하기도 한다. 대부분의 경우 올바른 결정을 내리지만, 드물게 개발자의 의도와 기대를 거스르는 결정을 내리기도 한다. 내 경험상 이런 일은 크고 난해한 쿼리나 비정형 데이터 처리처럼 특이한 흐름을 다룰 때만 생겼다. 보통은 형편없는 DB 설계를 비즈니스 로직과 쿼리로 보완하려다 발생한다. 여기서도 개발자에게 책임이 있지만, 교훈은 이렇다: 프레임워크가 마음대로 로드 시점을 정할 수도 있는 복잡한 흐름·쿼리를 작성할 때는, 무엇이 언제 로드되는지 반드시 재확인하라.
모든 도구가 그렇듯, 개발자는 손에 쥔 도구를 경계심 있게 사용해야 한다. ORM은 삶을 크게 편하게 해 주지만, 무시하면 큰 대가를 치르게 되는 고유의 설계 과제도 함께 가져온다. 애플리케이션은 ORM 프레임워크를 고려해 설계해야 하며, 그 반대가 되어서는 안 된다.
이 글이 새로울 게 없었다면 잘하고 있다는 뜻이다. 만약 당신의 프로젝트에 여기서 말한 안티패턴들이 있다면, 고치길 권한다. ORM에서 비롯된 기술 부채는 애플리케이션이 변할수록 커지고 퍼진다. 문제는 더 나빠지고 해결은 더 어려워진다. 지금이 가장 좋은 때다.
이 글의 핵심은 ORM 프레임워크가 가져올 수 있는 다양한 도전을 환기하는 것이며, 그 역할을 어느 정도 했길 바란다. 여기의 안티패턴 목록이 전부는 아니다. 내가 모르는 것까지 포함해 더 많을 것이다. 이 글에 적은 것들은 개인적으로 특히 문제가 크다고 느낀 것들이다. 다른 것들을 마주치면 목록에 추가하겠다. 빠뜨린 게 있다고 느끼면 소셜이나 웹사이트로 알려 달라.