JPA의 추상화는 무엇을 가리고, 왜 조심해야 할까요? #43
Replies: 5 comments
-
|
결론부터 말하면 JPA의 추상화는 쉽다가 아니라 다르게 어렵다입니다. 코드량은 줄지만 그 자리에 들어오는 건 암묵적 동작, 시점 의존성, 프록시·캐시·flush 같은 보이지 않는 메커니즘이고, 이걸 모르고 쓰면 "분명히 코드는 옳은데 동작이 이상하다"는 종류의 버그가 생깁니다. 1. JPA가 추상화하는 것 — 무엇이 가려지는가먼저 JPA가 무엇을 가리는지 알아야 그게 왜 위험할 수 있는지 보입니다. JPA는 다음 네 가지를 개발자에게서 가려서 코드량을 줄입니다.
JdbcTemplate은 이 모든 것을 드러내 놓고 씁니다. SQL을 직접 작성하고, 호출 즉시 실행되며, 캐시도 dirty checking도 없습니다. 코드량이 많은 대신 모든 일이 코드에 그대로 적혀 있습니다. JPA의 위험은 이 가려진 메커니즘들이 개발자의 직관과 어긋날 때 드러납니다. 아래는 가장 자주 부딪히는 함정들입니다. 2. N+1 문제 — 가장 유명하고 가장 자주 만나는 함정현상List<Reservation> reservations = reservationRepository.findAll(); // 쿼리 1번
for (Reservation r : reservations) {
System.out.println(r.getMember().getName()); // 쿼리가 N번 더 나간다
}코드만 보면 "예약 목록을 가져와서 회원 이름을 출력한다"는 단순한 로직입니다. 그런데 예약이 100건이면 SQL이 101번 나갑니다. 예약 조회 1번 + 각 예약의 회원 조회 100번. 왜 이런 일이 생기나
왜 위험한가코드에 SQL이 안 보이니까 이 버그는 코드 리뷰로 잡기 매우 어렵습니다. 개발 환경에선 데이터가 적어 안 보이다가, 운영에서 데이터가 100배가 되면 갑자기 느려집니다. 해결책으로 JdbcTemplate이라면 이런 함정 자체가 없습니다. 회원이 필요하면 JOIN 쿼리를 처음부터 직접 작성하니까요. N번 나가는지 1번 나가는지가 코드에 그대로 적혀 있습니다. 3. 영속성 컨텍스트 — 같은 객체인 줄 알았는데 다른 객체현상@Transactional
void doSomething() {
Reservation r1 = repository.findById(1L);
Reservation r2 = repository.findById(1L);
System.out.println(r1 == r2); // true ← 같은 객체!
}같은 트랜잭션 안에서 같은 ID로 두 번 조회하면 두 번째는 DB에 안 가고 영속성 컨텍스트(1차 캐시)에서 꺼냅니다. 그래서 두 변수가 같은 인스턴스를 가리킵니다. 이게 의도된 동작이긴 한데, 모르면 함정이 됩니다. 함정 사례@Transactional
void updateName() {
Reservation r1 = repository.findById(1L);
// 누군가 DB에서 직접 UPDATE를 실행함
jdbcTemplate.update("UPDATE reservation SET name = 'changed' WHERE id = 1");
Reservation r2 = repository.findById(1L);
System.out.println(r2.getName()); // 'changed'? 아니다. 캐시된 이전 값이 나온다
}영속성 컨텍스트가 캐시를 가지고 있어서, JPA가 모르는 외부 변경은 안 보입니다. 같은 트랜잭션 안에서 보이는 데이터가 실제 DB와 다를 수 있다는 거죠. 이걸 "동일성 보장"이라고 부르며 장점으로 소개되지만, 위험한 함정이기도 합니다. JdbcTemplate은 캐시가 없습니다. 매번 DB를 조회하니 항상 최신값이 보입니다. 놀랄 일이 없습니다. 4. Dirty Checking — save 안 했는데 UPDATE가 나간다현상@Transactional
void changeName(Long id) {
Reservation r = repository.findById(id);
r.setName("new name");
// repository.save(r) 호출 안 함
}
// 트랜잭션 끝나면 UPDATE 쿼리가 자동으로 나간다
왜 위험한가(a) 의도치 않은 변경이 DB로 새어 나간다@Transactional
Reservation findAndProcess(Long id) {
Reservation r = repository.findById(id);
// 어떤 계산을 위해 임시로 값을 바꿈
r.setStatus(TEMP);
int result = calculateSomething(r);
return r;
// 트랜잭션 끝나면 status가 TEMP로 DB에 저장된다 ← 의도와 다름
}조회 메서드인 줄 알았는데 부수효과로 UPDATE가 나갑니다. JdbcTemplate이라면 (b) 어느 필드 변경이 DB로 나갈지 코드만 보고 알 수 없다dirty checking은 엔티티의 모든 필드를 검사합니다. 영속성 컨텍스트에 있는 객체의 setter를 호출하는 모든 코드가 잠재적으로 UPDATE를 유발합니다. "이 메서드는 어떤 SQL을 만드나"를 코드에서 추적하기 어렵습니다. JdbcTemplate에서는 5. Flush 타이밍 — SQL이 언제 나가는지 모른다현상@Transactional
void doSomething() {
Reservation r = new Reservation(...);
repository.save(r); // 여기서 INSERT 안 나간다 (경우에 따라)
// ... 다른 로직 ...
// 트랜잭션 끝나거나 다음 쿼리 전에 flush 시점에 INSERT 나간다
}JPA는 SQL을 호출 즉시 보내지 않습니다. flush 시점에 모아서 보냅니다. flush는 트랜잭션 커밋 직전, 또는 JPQL 쿼리 실행 직전 등에 자동으로 일어납니다. 왜 위험한가(a) 디버깅 어려움
(b) 제약 조건 위반 시점이 코드와 멀어진다@Transactional
void doSomething() {
repository.save(reservation1); // 중복 UNIQUE 위반인 데이터지만 INSERT 안 나감
// ... 50줄 후 ...
repository.findAll(); // ← 여기서 flush가 일어나며 위 INSERT가 실행 → 여기서 예외
}예외가 터지는 위치가 실제 원인 위치와 멀어집니다. 스택 트레이스를 보고 "findAll에서 UNIQUE 위반?" 하다가 원인을 50줄 위에서 찾아야 합니다. JdbcTemplate은 호출 즉시 실행됩니다. 6. LazyInitializationException — 트랜잭션 밖에서 터지는 NPE급 함정현상@Transactional
public Reservation findReservation(Long id) {
return repository.findById(id); // member는 LAZY로 매핑됨
}
// Controller에서
public ReservationResponse get(Long id) {
Reservation r = reservationService.findReservation(id);
return new ReservationResponse(r.getMember().getName()); // ← LazyInitializationException!
}트랜잭션이 끝나 영속성 컨텍스트가 닫힌 뒤에 LAZY 연관관계를 접근하면 예외가 터집니다. 왜 위험한가코드만 보면 "회원 이름을 반환한다"인데 트랜잭션 경계 바깥이라서 터집니다. 이걸 피하려면 트랜잭션 경계와 연관관계 접근 위치를 항상 의식해야 합니다. 즉 트랜잭션 경계가 비즈니스 로직뿐 아니라 데이터 접근 가능 여부까지 결정합니다. JdbcTemplate에서는 이런 일이 없습니다. SELECT 시점에 JOIN으로 다 가져왔거나, 안 가져왔으면 그냥 null입니다. 트랜잭션 경계가 끝나도 객체에 들어 있는 데이터는 그대로 보입니다. 7. EAGER 로딩의 함정 — "안전하려고" 했던 게 폭탄이 된다LAZY가 헷갈리니까 EAGER로 매핑하면 안전할까요? 더 큰 문제가 생깁니다. 현상@Entity
class Reservation {
@ManyToOne(fetch = FetchType.EAGER)
Member member;
@ManyToOne(fetch = FetchType.EAGER)
Theme theme;
}
@Entity
class Member {
@OneToMany(fetch = FetchType.EAGER)
List<Reservation> reservations;
}
연관 객체 그래프 전체가 메모리에 로딩됩니다. 객체 1개를 가져왔는데 DB에서 수백 건이 끌려옵니다. 또는 카테시안 곱 때문에 메모리가 터지기도 합니다. 이게 N+1보다 더 큰 문제일 수 있는 게, 무엇이 추가로 로딩되는지 코드에 안 보입니다. JdbcTemplate은 가져올 데이터를 SELECT 절에 명시합니다. 가져오는 것만 가져옵니다. 8. 프록시 객체 — equals, instanceof가 깨진다현상JPA가 LAZY를 구현하기 위해 사용하는 게 프록시 객체입니다. 이전 CGLIB 토론에서 다룬 것과 같은 원리입니다. Member m = reservation.getMember();
m instanceof Member // true
m.getClass() == Member.class // false ← 실제는 Member$HibernateProxy
m.equals(otherMember) // 의도와 다르게 동작할 수 있음왜 위험한가
JdbcTemplate은 그냥 평범한 객체를 만들어 반환합니다. 객체가 객체로 보이고, 비교는 비교대로 됩니다. 9. 동등성 vs 동일성 — equals/hashCode가 어렵다JPA 엔티티의
이 문제는 JPA의 본질적 어려움입니다. 객체의 생명주기(영속화 전/후)와 동등성 개념이 충돌하기 때문입니다. JdbcTemplate은 DB 행 단위로 매번 새 객체를 만드니, 일반 자바 객체처럼 자유롭게 10. 트랜잭션 전파 함정 — 자기 호출이 동작하지 않는다이건 JPA 자체의 문제는 아니지만, JPA를 쓰는 환경에서 더 자주 만납니다. 현상@Service
class ReservationService {
public void outer() {
inner(); // self-invocation
}
@Transactional
public void inner() {
// 트랜잭션이 시작되지 않는다!
}
}이전 CGLIB·프록시 토론에서 다룬 self-invocation 문제입니다. JPA를 쓰면 트랜잭션이 더 빈번하고 길어져서, 이 함정에 부딪힐 가능성이 더 큽니다. 11. 그래서 결론 — "복잡한 것을 가려두고 쉽게 쓴다"가 아니라 "다른 종류의 복잡함을 떠안는다"저는 JPA vs JdbcTemplate의 트레이드오프를 이렇게 봅니다.
JPA의 진짜 가치는 코드량 절감이 아니라 풍부한 도메인 모델 표현과 객체 지향적 사고의 자연스러움입니다. 객체 그래프를 자연스럽게 다루고, 변경 감지로 의도가 명확해지는 면이 있습니다. 다만 그 대가가 큽니다. 자동화된 모든 동작 하나하나를 이해하고 통제할 수 있어야 안전하게 쓸 수 있습니다. 안 그러면 "왜 SQL이 이렇게 나가지?", "왜 데이터가 안 보이지?", "왜 갑자기 UPDATE가?" 같은 질문에 답할 수 없게 됩니다. 12. 그래서 어떻게 선택할까저는 다음 기준이 합리적이라고 생각합니다. JdbcTemplate이 잘 맞는 경우
JPA가 잘 맞는 경우
제 입장미션 단계에서 JdbcTemplate을 쓰는 건 좋은 선택이라고 봅니다. JPA의 추상화 아래에 무엇이 있는지를 먼저 이해해야 JPA를 안전하게 쓸 수 있기 때문입니다. SQL이 어떻게 나가는지, 트랜잭션이 어떻게 묶이는지, 커넥션이 어떻게 재사용되는지를 JdbcTemplate에서 명시적으로 다뤄본 사람이, JPA로 옮겨갔을 때 함정에 빠질 가능성이 훨씬 적습니다. "코드량이 많아도 명시적인 게 낫다"는 직관은 학습 단계에서 특히 옳다고 생각합니다. JPA로 옮겨가는 건 그 다음의 선택입니다. 13. 한 줄 정리
저는 이 트레이드오프가 학습 단계에선 JdbcTemplate 쪽이 유리하다고 생각합니다. 무엇이 일어나는지 보이는 환경에서 먼저 익숙해진 다음, JPA로 옮겨가야 그 추상화가 무엇을 가리는지를 알고 쓸 수 있기 때문입니다. |
Beta Was this translation helpful? Give feedback.
-
1. JPA의 추상화는 개발을 더 쉽게 만들어준다고 생각하시나요, 아니면 다른 종류의 복잡함을 만든다고 생각하시나요?JPA의 추상화는 코드를 짜는 과정 자체는 쉽게 만들어주지만, 다른 종류의 복잡함을 만든다고 생각합니다. 하지만 동시에 실제 동작이 코드에 바로 드러나지 않는다는 단점이 있습니다. 그래서 JPA는 단순히 쉽다기보다는, 영속성 컨텍스트와 객체 사이의 매핑의 동작 원리를 이해해야 하는 기술에 가까운 것 같습니다. 2. JPA를 사용할 때 가장 조심해야 하는 함정은 무엇이라고 생각하시나요?영속성 컨텍스트를 제대로 이해하지 못한 채 코드를 작성하는 것이라고 생각합니다. 말씀해주신 LazyInitializationException, dirty checking, flush 타이밍 등의 모든 문제들이 결국 대부분은
를 잘 모를 때 발생한다고 느꼈고, 결국 이는 영속성 컨텍스트와 관련 된 것들이라고 생각했습니다. |
Beta Was this translation helpful? Give feedback.
-
|
저는 JPA의 추상화는 “어려움의 위치를 바꾸는 것”이라고 생각합니다. JPA는 개발자가 직접 SQL을 작성하고 실행 시점을 관리하는 부담을 줄여주지만, 그 대신 내부에서 많은 일을 자동으로 처리합니다. 대표적으로 메서드 호출이 어떤 SQL로 변환되는지, 영속성 컨텍스트의 1차 캐시가 언제 사용되는지, Dirty Checking으로 언제 UPDATE가 발생하는지, 그리고 Flush가 정확히 어느 시점에 일어나는지가 코드에 명확히 드러나지 않습니다. 예를 들어 N+1 문제는 LAZY 로딩 때문에 연관 객체를 조회할 때마다 추가 쿼리가 발생할 수 있습니다. 영속성 컨텍스트의 경우 같은 트랜잭션 안에서 동일한 ID의 엔티티를 다시 조회하면 DB가 아니라 1차 캐시에서 가져오기 때문에 동일성을 보장해주는 장점이 있습니다. Dirty Checking 역시 엔티티 필드를 단순히 임시로 바꿨다고 생각했는데, 트랜잭션이 끝날 때 자동으로 UPDATE가 발생할 수 있습니다. 또한 Flush 시점이 메서드 호출 시점과 다르기 때문에, 실제 원인은 앞선 코드에 있는데 예외는 뒤쪽의 조회 쿼리나 트랜잭션 종료 시점에서 발생할 수도 있으며 이 부분이 JPA 디버깅을 어렵게 만드는 가장큰 닫점이라고 생각합니다. 또한 LAZY 로딩은 트랜잭션 경계부분에서도 많은 부분을 고려해야합니다. 그래서 저는 JPA를 사용할 때 “JPA가 알아서 해준다”라고 생각하기보다, 'JPA가 대신 해주는 일을 내가 설명할 수 있어야 한다'고 생각합니다. 결국 JPA의 진짜 가치는 단순한 코드량 절감이 아니라, 객체 중심으로 도메인 모델을 표현할 수 있게 해준다는 점에 있다고 생각합니다. 다만 그 가치를 제대로 누리려면 SQL, 트랜잭션, 영속성 컨텍스트, Flush, 프록시 같은 내부 동작을 이해하고 통제할 수 있어야 합니다. 현재로서는 솔직히 방대한 JPA의 대부분의 추상화를 모두 늘 인지하는것은 불가능하며 너무 위험이 크다고 생각하고이습니다. 개인적으로 코드량을 줄여주는 편의성과 '서비스의안정성과 신뢰성'은 비교하는 것 조차 좋지 않은 생각이라고 생각합니다. 이 점은 AI를 사용할 때와도 비슷하다고 생각합니다. AI가 답을 빠르게 만들어준다고 해서 그 분야의 기본 개념과 판단 기준을 몰라도 되는 것은 아닙니다. 오히려 내부 원리를 모른 채 편리함만 따라가면, 실제로 알아야 하는 것을 등한시하게 되고, 자연스럽게 |
Beta Was this translation helpful? Give feedback.
-
|
키오스크를 도입하면서, 편리함을 느끼는 사람이 있는 반면, 불편함을 느끼는 사람이 존재합니다. 자동화의 이점과 결점이라 생각합니다. 이번 미션에서 JdbcTemplate을 사용하며 느낀 단점은 과거경험을 떠올렸을 때, JPA의 이점은 다양한 어노테이션으로 객체와 DB의 패러다임 불일치를 쉽게 해소해준다는 점입니다. JdbcTemplate으로 쿼리와 매핑을 직접 다루다보면, 이 객체는 DB에 어떻게 표현되어야하는 지, 그리고 그 반대는 어떻게해야하는 지 매번 고민하게 만듭니다. 결국 객체와 DB간의 상호작용에 대해서 개발자가 고민해야할 부분을 JPA가 대신 해주는 것뿐이라 생각하며, 이를 인지하지 못하고 개발하는 것을 조심해야한다고 생각합니다. |
Beta Was this translation helpful? Give feedback.
-
|
JPA를 사용하는 경우, 추상화된 인터페이스만으로 영속 레이어에 접근하여 데이터베이스와의 연결과정과 DB와 java의 모델 불일치 문제를 쉽게 해결해줍니다. 즉, 추상화 레이어에 감춰 내부 구현은 개발자가 신경쓰지 않아도 되기에 실제 SQL 쿼리나 매핑과정이 감춰진다는 특징이 있습니다. 이 특징은 개발자의 입장에서 비즈니스 로직에 집중하고, 코드 작성의 편의성을 높여준다는 장점이 있습니다. 특히, 디비와 java의 모델 불일치 문제를 해결하여, 디비 모델을 객체지향적으로 변환해주는 과정을 개발자에게 감추기 때문에 객체 지향설계에 더 집중할 수 있습니다. 하지만, 이런 간편함은 내부 구현을 감추기 때문에, DB 모델을 어떻게 객체로 매핑하는지 어떤 쿼리를 사용하여 데이터베이스에 접근하는지 구현하는 과정을 생략합니다. 즉, 이 영속성 레이어에서 문제가 생겼을 때, 원인을 추적하고 해결하는데 어려움을 겪을 수 있습니다. 편의성을 얻음으로써, 지연 로딩, N+1 문제, 영속성 컨텍스트와 같은 새로운 고려사항이 발생합니다. 또한, 이런 추상화는 사실 역으로 개발자의 자유도를 떨어뜨리기도 합니다. 내부 구현을 감추기에 이에 더 종속되고, 각 구현체마다의 기본 구현에 따라가도록 개발을 해야하는 경우도 발생합니다. (사실 이는 추상화 자체의 단점에 가깝기는 한데요, 저수준 구현에 대해 제어권을 포기하는대신 생산성을 얻는 경우이기 때문입니다.) 그래서 추상화 계층의 편의성을 안전하게 사용하기 위해서 개발자는 내부 구현의 흐름과 위험성에 대해 충분히 인지하고 구현 이후 실제 흐름을 검증하는 과정이 필요하다고 생각합니다. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
JPA는 SQL 작성량을 줄여주고 객체 중심으로 데이터를 다룰 수 있게 해주는 강력한 기술입니다.
하지만 JPA가 편리한 이유는 많은 동작을 대신 처리해주기 때문이고,
그 말은 반대로 많은 동작이 코드에 직접 보이지 않는다는 뜻이기도 합니다.
예를 들어 JPA를 사용하면 SQL 생성, 영속성 컨텍스트, dirty checking, flush 타이밍, lazy loading, proxy 같은 메커니즘을 프레임워크가 관리합니다.
JdbcTemplate을 사용할 때는 SQL이 언제 실행되고 어떤 값이 변경되는지가 코드에 명시적으로 드러나지만, JPA에서는 실제 SQL 실행 시점이나 변경 감지 시점이 개발자의 직관과 다르게 동작할 수 있습니다.
그래서 저는 JPA가 단순히 “더 쉬운 기술”이라기보다, 명시적인 SQL 작성의 복잡함을 줄이는 대신 보이지 않는 메커니즘을 이해해야 하는 기술이라고 생각합니다.
이와 관련해서 여러분의 생각이 궁금합니다.
Beta Was this translation helpful? Give feedback.
All reactions