Backend 18

JpaRepository: 구현 없는 메서드의 비밀

구현 클래스를 만들지 않았는데 save, findById 같은 메서드가 어떻게 생기는지 모르겠다.

근거 · 교안 p115-p121

JpaRepository: 구현 없는 메서드의 비밀 대표 이미지

1장: 인터페이스만 있는데 어떻게 작동하죠?

1. 인터페이스만 있는데 어떻게 작동하죠?

솔라는 노트북 화면의 코드와 자신의 머릿속에 든 지식을 번갈아 보며 미간을 찌푸렸다. 화면에는 방금 작성한 코드가 선명하게 보였다.

// User.java - 엔티티 클래스
@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    // ... 생성자, getter
}

// UserRepository.java - 리포지터리 인터페이스
public interface UserRepository extends JpaRepository<User, Long> {
}

// UserService.java - 서비스 클래스
@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void join(String name) {
        User newUser = new User(name);
        userRepository.save(newUser); // 이 코드가 어떻게?
    }
}

분명히 UserRepository는 인터페이스다. implements 키워드를 써서 이 인터페이스의 메서드들을 하나하나 구현한 클래스를 만든 기억이 없었다. 그런데도 UserService에서 주입받은 userRepository 객체는 save 메서드를 멀쩡하게 실행했다. 애플리케이션을 실행하고 join 메서드를 호출하자, 데이터베이스에 새로운 User가 생성되는 것까지 확인했다.

“말이 안 돼.”

솔라는 혼잣말을 중얼거리며 파일 탐색기를 열어 UserRepositoryImpl.java라는 이름의 파일이 있는지 찾아봤다. 당연히 없었다. 자신이 만들지 않았으니까.

“설계도만 있는데 어떻게 건물이 저절로 지어지지?”

그때, 방으로 들어오던 언니 루나가 솔라의 중얼거림을 들었다.

“무슨 설계도?”

“아, 언니. 이것 좀 봐봐. 내가 학교에서 배운 바로는, 인터페이스는 메서드의 명세, 즉 ‘무엇을 할 것인가’만 정의하는 거잖아. 실제 동작인 ‘어떻게 할 것인가’는 구현 클래스에서 작성해야 하고. 그렇지?”

루나는 말없이 고개를 끄덕였다. 솔라가 노트북 화면을 가리키며 말을 이었다.

“그런데 이건 JpaRepository를 상속한 인터페이스만 만들었는데, 구현 클래스도 없이 save 같은 메서드가 그냥 동작해. 마치 유령이 코드를 대신 짜준 것처럼. 내가 뭘 놓치고 있는 거지?”

솔라의 목소리에는 짜증과 순수한 호기심이 섞여 있었다. 자신이 아는 규칙이 눈앞에서 깨지는 광경을 목격한 사람의 혼란스러움이었다.

루나는 솔라의 노트북 화면을 잠시 들여다보았다. 복잡한 애플리케이션의 일부였다. 그녀는 의자에 앉는 대신, 솔라의 책상 모서리에 가볍게 걸터앉았다.

“네 말이 맞아. 인터페이스는 원래 구현체가 필요하지.”

“그치? 그런데 이건 왜…”

“음, 어쩌면 우리가 뭔가 잘못 봤을 수도 있고, 아니면 정말로 ‘마법’ 같은 일이 벌어지고 있는 걸 수도 있지. 그 현상부터 확실하게 확인해보는 건 어때? 아주 단순한 조건에서.”

루나의 제안은 ‘왜’를 묻기 전에 ‘정말 그런가’를 먼저 보자는 것이었다. 솔라는 잠시 생각에 잠겼다. 자신의 프로젝트가 복잡해서 다른 설정 때문에 우연히 동작하는 것처럼 보이는 걸 수도 있다는 생각이 들었다.

“좋아. 아예 깨끗한 프로젝트에서 딱 저것만 다시 해볼게.”

솔라는 재빨리 새로운 스프링 부트 프로젝트를 생성했다. 그리고는 방금 전의 코드와 거의 똑같은 최소한의 User 엔티티와 UserRepository 인터페이스, 그리고 간단한 실행 코드를 작성했다.

// Application.java - 실행 코드
@SpringBootApplication
public class DemoApplication implements CommandLineRunner {

    @Autowired
    private UserRepository userRepository;

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        System.out.println(">>> 실행 시작");
        userRepository.save(new User("sola"));
        System.out.println(">>> 저장 완료. DB 확인!");
    }
}

솔라는 숨을 참고 애플리케이션을 실행했다. 콘솔 창에 익숙한 스프링 로고가 지나가고, 잠시 후 솔라가 추가한 로그가 나타났다.

>>> 실행 시작
Hibernate: insert into user (name, id) values (?, ?)
>>> 저장 완료. DB 확인!

insert 쿼리가 실행되었다. 솔라는 곧바로 데이터베이스 클라이언트를 열어 user 테이블을 조회했다. ‘sola’라는 이름을 가진 새로운 행이 선명하게 보였다.

솔라는 의자 등받이에 몸을 기댔다. 눈으로 보고도 믿기지 않는다는 표정이었다.

“진짜네. 내가 구현 클래스를 만들지 않았는데도, UserRepository 인터페이스만으로 데이터베이스에 데이터를 저장했어.”

이제 이 현상은 솔라에게 ‘버그일지도 모르는 의심스러운 동작’이 아니라 ‘분명히 존재하는 규칙’이 되었다. 그녀가 배웠던 인터페이스의 법칙에 대한 명백한 예외처럼 보였다.

“알았어. 그럼 이제 내 질문은 이거야. Repository 인터페이스를 만들기만 하면 JPA 기능이 자동으로 활용된다는 건 알겠어. 그런데 이 ‘자동’이라는 편리한 말 뒤에 숨어서, 실제로 내 대신 구현 코드를 만들어주고 실행하는 녀석은 대체 누구야? 마법사가 있다면, 그 마법사의 정체가 궁금해.”

2장: 마법사는 Spring Data JPA

솔라의 책상 위에는 어제와 같은 코드가 떠 있었지만, 그녀의 시선은 다른 곳을 향하고 있었다. 프로젝트의 구조를 보여주는 창이 아니라, build.gradle 파일의 의존성 목록을 뚫어져라 쳐다보고 있었다. 수많은 implementation 라인들 사이에서 한 줄이 유독 눈에 띄었다.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

솔라는 이 라인을 손가락으로 짚으며 중얼거렸다. “마법사가 있다면, 이 재료 목록 어딘가에 이름이 적혀 있을 거야.” 어제는 그저 현상을 관찰하는 데 집중했다면, 오늘은 범인을 색출하려는 탐정의 눈빛이었다. 이 목록에 있는 누군가가 자신도 모르게 UserRepositoryImpl 클래스를 만들어주고 있는 게 틀림없었다. 하지만 이 많은 라이브러리 중에 대체 누가?

그때 방에 들어온 루나가 솔라의 노트북 화면과 손가락을 보고는 가볍게 물었다.

“마법 재료 목록에서 마법사 이름을 찾고 있나 보네.”

솔라는 깜짝 놀라 루나를 쳐다봤다. “어, 언니. 어떻게 알았어? 맞아. 어제 그 마법 같은 일의 배후를 찾는 중이야. 아무래도 이 spring-boot-starter-data-jpa라는 녀석이 제일 유력한 용의자 같긴 한데…”

솔라는 말을 흐렸다. 그녀의 표정에는 확신이 없었다. “그런데 이건 그냥 라이브러리 묶음이잖아. JPA를 편하게 쓰게 해주는 도구일 뿐이지, 내 코드를 대신 짜주는 인공지능 같은 건 아니잖아? ‘편리하다’는 거랑 ‘없는 코드를 만들어준다’는 건 완전히 다른 차원의 이야기라고.”

솔라는 자신이 생각하는 ‘라이브러리’의 역할을 명확히 구분하고 있었다. 라이브러리는 잘 만들어진 부품을 제공할 뿐, 내가 조립하겠다고 선언만 한 설계도를 보고 완성품을 만들어주는 존재는 아니었다.

“좋은 지적이야.” 루나가 말했다. “역할을 나눠서 생각해보자. 네가 만든 UserRepository 인터페이스는 일종의 ‘약속’이지. save라는 이름의 작업을 수행하겠다고 약속한 거야. 그럼 누군가는 그 약속을 ‘이행’해야 해. 그 이행의 주체가 누군지 찾아내면 되겠네.”

루나는 솔라의 맞은편에 앉으며 제안했다. “네 IDE에서 JpaRepository 인터페이스의 원본 코드를 열어봐. 그리고 웹 브라우저에서는 ‘Spring Data JPA’의 공식 문서를 찾아보고. ‘약속’이 적힌 계약서 원본과, 그 계약을 이행하겠다고 주장하는 쪽의 설명을 나란히 놓고 비교해보는 거야.”

솔라는 곧장 루나의 제안을 따랐다. 잠시 키보드를 두드리자, 화면이 두 개로 나뉘었다. 왼쪽에는 IDE가 보여주는 JpaRepository의 소스 코드, 오른쪽에는 Spring Data JPA 공식 문서 사이트가 열렸다.

왼쪽 화면: JpaRepository.java (약속)

/**
 * Saves a given entity. Use the returned instance for further operations...
 *
 * @param entity must not be {@literal null}.
 * @return the saved entity; will never be {@literal null}.
 * @throws IllegalArgumentException in case the given {@literal entity} is {@literal null}.
 */
<S extends T> S save(S entity);

오른쪽 화면: Spring Data JPA 공식 문서 (이행 주장)

Saving Entities Saving an entity can be performed with the save(…) method. It persists or merges the given entity by using the underlying JPA EntityManager… Spring Data JPA provides the implementation for this method out of the box.

솔라의 눈이 두 화면을 빠르게 오갔다. 왼쪽의 Javadoc은 save가 무엇을 하는지에 대한 명세, 즉 ‘약속’이었다. 그리고 오른쪽 문서의 마지막 문장.

”…Spring Data JPA provides the implementation for this method out of the box.”

솔라는 이 문장을 나지막이 소리 내어 읽었다. ‘Spring Data JPA가 이 메서드의 구현을 즉시 제공합니다.’

“아…”

짧은 탄식이 터져 나왔다. 안개가 걷히는 순간이었다. spring-boot-starter-data-jpa는 단순히 편리한 도구 모음, 즉 ‘재료’의 목록이 아니었다. 그 자체가 바로 약속을 이행하는 주체, 즉 ‘마법사’였던 것이다.

솔라는 의자 등받이에 몸을 깊게 묻으며 말했다. “알겠다. 나는 지금까지 Spring Data JPA를 내가 필요할 때 가져다 쓰는 망치나 드라이버 같은 ‘도구’라고만 생각했어. 하지만 그게 아니었네. 이건 내가 ‘집을 지어줘’라는 설계도(인터페이스)만 보여주면, 알아서 집을 지어주는 ‘건설사’ 같은 거였어.”

그녀는 고개를 저었다. “정확히는, JpaRepository라는 표준 설계도를 상속해서 ‘이런 집이 필요해요’라고 말하면, Spring Data JPA라는 건설사가 ‘알겠습니다. 그 설계도대로 우리가 구현해서 지어드리죠’라고 나서는 거구나.”

이제 Repository 인터페이스만 만들면 JPA가 자동으로 활용된다는 문장이 다르게 읽혔다. 이전에는 그저 편리함을 강조하는 마케팅 문구처럼 들렸지만, 이제는 역할과 책임에 대한 기술적인 서술로 보였다. 개발자의 역할은 ‘필요한 기능 명세(인터페이스)를 정의하는 것’까지이고, ‘그 명세를 구현하는 것’은 Spring Data JPA의 역할이었다.

솔라는 키보드에 손을 올렸다. 마법사의 정체를 알아냈지만, 진짜 궁금증은 이제부터 시작이었다.

“좋아, 마법사가 누군지는 알았어. Spring Data JPA. 그런데, 그 마법은 대체 어떻게 부리는 거지? 내 UserRepository를 주입받아서 출력해보면, 분명 객체는 존재하거든. 하지만 내가 만든 클래스는 아니잖아. 그럼 Spring Data JPA가 애플리케이션이 실행될 때, 나 몰래 UserRepositoryImpl.java 같은 파일을 만들어서 컴파일이라도 하는 걸까? 아니면… 눈에 보이지 않는 무언가로 이 인터페이스를 감싸고 있는 건가?“

3장: 런타임에 만들어지는 구현체

3. 런타임에 만들어지는 구현체

솔라는 자신의 노트북 화면에 두 개의 창을 나란히 띄워 놓았다. 왼쪽은 어제 작성했던 간단한 스프링 부트 애플리케이션의 UserService 코드였고, 오른쪽은 프로젝트 전체의 파일 탐색기였다. 그녀는 파일 탐색기의 검색창에 ‘Impl’이라는 단어를 입력하고 엔터를 쳤지만, 결과는 예상대로 텅 비어 있었다. UserRepositoryImpl 같은 파일은 어디에도 존재하지 않았다.

어제 루나와의 대화로 마법사의 정체가 Spring Data JPA라는 것은 알아냈다. 하지만 마법의 ‘방법’은 여전히 수수께끼였다. 솔라의 머릿속은 한 가지 가설로 가득 차 있었다. ‘Spring Data JPA가 애플리케이션이 실행되는 아주 짧은 순간에 UserRepository의 구현체 파일을 몰래 만들었다가, 애플리케이션이 종료될 때 지워버리는 걸까?’ 너무 영화 같은 상상이었지만, 눈에 보이는 증거가 없으니 그런 생각까지 미치게 된 것이다.

“파일이 없다면… 객체는 대체 어디서 온 거지?”

혼잣말을 들은 루나가 커피 잔을 들고 다가왔다. 어제 솔라가 고민하던 지점을 정확히 기억하고 있었다.

“아직도 유령 파일을 찾고 있구나. 건설사가 집을 짓는 건 맞는데, 꼭 벽돌과 시멘트로 된 모델하우스를 미리 지어놔야 하는 건 아니잖아. 주문이 들어오면 그 자리에서 바로 조립해서 보여줄 수도 있지.”

“조립이라고?” 솔라는 루나의 비유를 곱씹었다. “그럼 내 UserRepository는… 실행되는 순간에 조립된 무언가라는 뜻이야?”

“네가 직접 확인해 볼 수 있어.” 루나는 솔라의 코드에서 userRepository 변수가 사용되는 줄을 손가락으로 가리켰다. “파일 시스템에는 존재하지 않는다면, 메모리 위에서 살아 움직이는 그 객체의 정체를 직접 심문해보면 되지. 디버거를 쓰는 거야.”

그 말에 솔라의 눈이 반짝였다. 왜 그 생각을 못 했을까. 그녀는 곧장 userRepository.save()가 호출되는 라인에 중단점(breakpoint)을 설정하고, 애플리케이션을 디버그 모드로 실행했다. 잠시 후, 애플리케이션의 실행이 해당 라인에서 멈췄다. 솔라는 숨을 참고 디버거의 변수 창으로 시선을 옮겼다. 그곳에는 userRepository라는 이름의 변수가 있었고, 그 정체를 파헤치기 위해 옆의 작은 화살표를 클릭했다.

userRepository = {SimpleJpaRepository@1234}

“어…”

솔라는 잠시 할 말을 잃었다. 그녀가 예상했던 것은 UserRepository 인터페이스나, 혹시 모를 UserRepositoryImpl 같은 이름이었다. 하지만 화면에 나타난 것은 생전 처음 보는 SimpleJpaRepository라는 이름이었다. 솔라는 그 객체를 더 깊게 파고들었다. 그 객체의 클래스 정보를 자세히 들여다보자, 더 기묘한 사실이 드러났다. 실제 주입된 객체의 타입은 com.sun.proxy.$Proxy로 시작하는, 알아볼 수 없는 이름이었다.

“이게… 뭐야? SimpleJpaRepository? 그리고 $Proxy? 내가 만든 클래스가 아니잖아.”

“바로 그거야.” 루나가 조용히 말했다. “그게 바로 Spring Data JPA가 너를 위해, 실행 시간에 동적으로 만들어낸 구현체의 진짜 모습이야. 네가 UserRepository라는 설계도만 주면, 스프링이 시작될 때 이 설계도를 구현하는 대리인(Proxy) 객체를 메모리에 생성해. 그리고 그 대리인 객체를 네 코드에 주입해 주는 거지.”

솔라는 디버거 창의 낯선 클래스 이름에서 눈을 떼지 못했다. com.sun.proxy.$Proxy108. 이 암호 같은 이름이 모든 질문의 답이었다. Spring Data JPA는 물리적인 .java 파일을 만드는 게 아니었다. 애플리케이션이 동작하는 바로 그 순간, JVM 메모리 위에서 UserRepository 인터페이스의 역할을 대신 수행할 가짜 객체를 만들어냈던 것이다. 그리고 그 프록시 객체는 내부에 SimpleJpaRepository라는, 실제 JPA 기능을 수행하는 핵심 로직을 품고 있었다. 우리가 save를 호출하면, 프록시 객체가 그 요청을 가로채서 SimpleJpaRepository에게 전달하고, 최종적으로 데이터베이스 작업이 일어나는 구조였다.

“그렇구나… 마법이 아니라… 대리인이었어.” 솔라가 중얼거렸다. “나는 UserRepository랑 대화한다고 생각했는데, 실제로는 Spring Data JPA가 보낸 대리인이 내 말을 듣고 대신 일을 처리해주고 있었던 거네.”

이제 Repository 인터페이스만 만들면 JPA를 자동으로 활용한다는 문장이 완전히 새로운 의미로 다가왔다. 이것은 추상적인 약속이 아니라, ‘런타임에 프록시 구현체를 생성하여 주입한다’는 구체적인 기술 동작에 대한 설명이었던 것이다.

모든 의문이 풀린 솔라는 확신에 찬 얼굴로 키보드를 두드렸다. 그녀는 UserRepository 인터페이스에 새로운 메서드를 추가했다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByName(String name); // 새로운 메서드 추가
}

이 메서드 역시 구현 코드는 어디에도 없었다. 하지만 이제 솔라는 두렵지 않았다.

“언니, 나 이제 이것도 어떻게 동작할지 알 것 같아.” 솔라는 자신이 추가한 코드를 가리키며 말했다. “내가 findByName을 호출하면, 이 요청도 프록시 객체가 받겠지. 그리고 Spring Data JPA는 ‘find…By…’라는 메서드 이름의 규칙을 분석해서, ‘아, name 필드로 데이터를 조회하라는 뜻이구나’ 하고 알아들을 거야. 그런 다음 SELECT * FROM user WHERE name = ? 같은 적절한 쿼리를 만들어서 실행해주겠지. 맞지?”

루나는 아무 말 없이 미소만 지었다. 솔라가 이미 답을 찾았다는 것을 알았기 때문이다. 그녀는 더 이상 편리함 뒤에 숨은 원리를 두려워하지 않았다. 눈에 보이지 않는 구현의 뒤편을 직접 추적하고 그 원리를 꿰뚫어 본 지금, 솔라는 프레임워크가 제공하는 기능을 예측하고 제어할 수 있는 자신만의 ‘동적 구현 추적기’를 손에 넣은 셈이었다.