
"방금 짠 이 CRUD 코드, 왜 이렇게 짰는지 설명할 수 있어요?"
멘토링을 진행하면서 Spring Boot로 백엔드 서버를 만들고 있다.
솔직히 처음엔 'CRUD 쯤이야...' 라고 생각했다.
Controller 만들고, Service에서 비즈니스 로직 쓱쓱 짜고, Repository로 DB 접근하고. 컴파일 통과, 테스트 통과. 깔끔하게 끝인 줄 알았다.
그런데 멘토님과 대화를 나누다 보니, 내가 짠 이 몇 줄의 코드가 단순히 '돌아가는 것'에서 끝나는 게 아니었다.
이 평범해 보이는 Service 코드가 실제 JVM 위에서, 수많은 스레드에 의해, 메모리(GC)의 감시를 받으며 돌아간다는 걸 간과하고 있었던 거다.
오늘은 "그냥 남들이 다 그렇게 짜니까" 넘어갔던 단순 CRUD 뒤에 숨겨진 프레임워크와 자바의 핵심 동작 원리를 정리해 보려고 한다.
보통 비즈니스 로직을 짤 때 @Service를 붙여서 클래스를 만든다.
그런데 만약 이 Service 안에 데이터(상태)를 저장하는 전역 변수를 두면 어떻게 될까?
@Service
public class TrainerProfileService {
// 🚨 대참사의 시작
private String currentUserName;
public void updateProfile(String name) {
this.currentUserName = name;
// ... 로직 처리
}
}
Spring Boot(기본 내장 Tomcat)는 사용자의 요청이 들어올 때마다 스레드 풀(Thread Pool)에서 스레드를 하나씩 꺼내서 할당하는 Thread-per-Request 방식을 쓴다.
문제는 스프링 컨테이너가 이 @Service 객체를 싱글톤(Singleton)으로 딱 하나만 만들어서 관리한다는 거다.
만약 100명의 사용자가 동시에 프로필을 수정한다고 치자. 100개의 스레드가 단 1개의 Service 객체(메모리 주소)에 동시에 접근해서 저 currentUserName을 바꾸려고 난리를 칠 거다. 당연히 데이터가 뒤섞이는 동시성 이슈가 터진다.
💡 결론
여러 스레드가 동시에 접근하는 Service는 반드시 무상태(Stateless)로 설계해야 한다.
객체의 의존성(Repository 등)은final로 박아둬서 불변성을 보장하고, 변경되는 데이터는 무조건 메서드의 파라미터(지역 변수)로만 주고받아야 안전하다.
JPA를 쓰면 보통 @Entity 객체로 데이터를 조회해온다.
그런데 대량의 데이터를 리스트로 뽑아올 때, 무지성으로 Entity로 다 끌고 오면 어떻게 될까?
JPA는 Entity를 DB에서 가져오는 순간, "아 이건 내가 계속 관리해야 하는 애구나!" 하고 영속성 컨텍스트(1차 캐시)에 올려버린다. 심지어 나중에 수정될까 봐 원본 스냅샷까지 메모리에 복사해 둔다.
만약 데이터 수만 건을 Entity로 가져오면?
이 무거운 객체들이 JVM의 Heap 메모리를 가득 채운다. 트랜잭션이 길어져서 이 객체들이 오랫동안 살아남으면, 가비지 컬렉터(GC)가 얘네를 Old 영역으로 보내버리고, 결국 무거운 Major GC(Stop-The-World)가 터지면서 서버가 순간적으로 멈춰버릴 수 있다.
반면에 JPQL이나 QueryDSL을 써서 Entity가 아니라 내가 만든 순수 DTO로 바로 매핑해서 가져오면 얘기가 달라진다.
JPA는 이걸 보고 "이건 내 관리 대상(Entity)이 아니네?" 하고 영속성 컨텍스트에 올리지 않는다.
덕분에 이 DTO 객체들은 클라이언트에게 응답을 주는 즉시 가비지(Garbage)가 되고, 가볍고 빠른 Minor GC가 슉슉 청소해 버린다. 메모리 낭비가 0에 수렴하는 거다.
💡 결론
데이터를 수정할 일이 없는 단순 리스트 조회(Read-Only) API에서는, 무작정 Entity를 쓰지 말고 DTO 직접 조회(Projection)를 활용해서 영속성 컨텍스트를 우회하자. 이게 곧 메모리 관리이자 성능 최적화다.
우리는 너무나 자연스럽게 Service 메서드 위에 @Transactional을 붙인다.
트랜잭션이 유지되려면 시작부터 끝까지 '같은 DB 커넥션'을 물고 있어야 하는데, 코드를 보면 Service에서 Repository로 커넥션 객체를 파라미터로 넘겨주는 부분이 전혀 없다.
어떻게 커넥션이 유지되는 걸까? 스프링이 마법이라도 부리는 걸까?
스프링은 자바의 ThreadLocal이라는 녀석을 쓴다. 이건 "현재 실행 중인 스레드만 열어볼 수 있는 전용 사물함"이다.
ThreadLocal)을 열어서 아까 넣어둔 커넥션을 꺼내 쓴다. (대박..)만약 이 원리를 모른 채로, @Transactional이 붙은 메서드 안에서 성능 좀 높이겠다고 비동기 스레드(CompletableFuture 등)를 새로 파서 Repository를 호출하면 어떻게 될까?
새로 만든 스레드의 ThreadLocal 사물함은 당연히 텅 비어있다!
스프링이 기존 스레드 사물함에 넣어둔 커넥션을 찾을 수 없으니, 기존 트랜잭션과 완전히 분리되어 버리는 대참사가 일어난다.
💡 결론
@Transactional은 마법이 아니다. 하나의 스레드 내부에서만 커넥션을 공유해 주는ThreadLocal기반의 기술이라는 걸 반드시 알고 써야 한다.
"기능이 돌아가면 끝난 거 아냐?" 라고 생각했던 오만함이 싹 사라졌다.
단순해 보이는 CRUD 코드 한 줄이라도,
어떤 메모리 영역(Stack/Heap)에 올라가는지, 가비지 컬렉터(GC)에 어떤 영향을 주는지, 스레드(ThreadLocal)와 트랜잭션이 어떻게 엮여서 돌아가는지 알고 짜는 것과 모르고 짜는 것은 하늘과 땅 차이인 것 같다.
이제 그냥 생각 없이 키보드부터 두드리는 짓은 못 할 것 같다. (오히려 좋아)
앞으로는 "왜 이렇게 짰어?"라는 질문에 당당하게 이유를 설명할 수 있는 코드를 짜야겠다.