JPA를 이해하려면 먼저 ORM을 알아야 한다.
ORM(Object-Relational Mapping)
자바는 클래스와 인터페이스로 이루어진 객체지향 구조이다.
DB는 테이블과 행으로 이루어진 관계형 구조이다.
즉, ORM은 이 서로 다른 두 세계를 중간으로 매핑해주는 기술이다.
특히 JPA는 이 ORM 기술을 자바 표준으로 정립한 것이다.
결론적으로 먼저 말하자면,
JPA를 사용하는 이유
- 생산성 향상: 반복적인 CRUD(Create, Read, Update, Delete) SQL을 직접 작성할 필요가 없다. persist(), find() 같은 메서드만 호출하면 된다.
- 과거에는 단순한 CRUD 기능을 만들 때도 반복적인 SQL문을 작성했어야 했다.
- 하지만, JPA를 사용하면 jpa.save(member) 한 줄이면 SQL insert문을 알려 저장하여, 비즈니스 로직에 집중할 수 있다.
- 유지보수 용이: 필드 변경 시 SQL을 일일이 수정하지 않아도 된다. JPA가 객체 모델을 보고 알아서 SQL을 생성한다.
- DB 테이블에 컬럼을 추가되면, 기존에는 관련된 SQL을 모두 찾아 일일이 수정해야 했다.
- 하지만, JPA에서는 Entity 클래스에 필드 private String newField; 만 한 줄 추가하면, JPA가 알아서 변경된 SQL을 생성하여 DB로 날려준다.
- 데이터베이스 독립성: 특정 DB(MySQL, Oracle, H2 등)에 종속되지 않아서, 설정만 바꾸면 DB를 쉽게 교체할 수 있다.
- 데이터베이스마다 미묘하게 다른 SQL 문법이 존재한다. (MySQL의 LIMIT vs Oracle의 ROWNUM)
- JPA는 설정 파일에서 MySQL을 Oracle으로 바꾸기만 하면, 코드를 한 줄도 수정할 필요 없이 DB를 교체할 수 있다.
그럼 JPA가 무엇인가?
JPA(Java Persistence API)

JPA는 자바 애플리케이션에서 관계형 데이터베이스(RDBMS)를 사용하는 방식을 정의한 자바 표준 인터페이스(Interface)이다.
- 인터페이스와 구현체: JPA는 "기술 명세(껍데기)"이고, 이를 실제로 구현한 기술(알맹이)이 바로 Hibernate(하이버네이트)이다. 우리가 JPA를 쓴다는 것은 실제로는 Hibernate와 같은 구현체를 사용하는 것이다.
- 패러다임의 전환: SQL 중심의 개발(테이블 모델링)에서 객체 중심의 개발(객체 모델링)로 전환하여, 자바 객체를 마치 컬렉션(List)에 저장하듯이 DB에 저장할 수 있게 해 준다.
하이버네이트란?

쉽게 비유하자면 JPA는 자동차 설계 도면이고, 하이버네이트는 그 도면으로 만든 실제 자동차이다.
- 역할: 개발자가 자바 코드로 명령(em.persist())을 내리면, 하이버네이트가 중간에서 이 명령을 해석하여 JDBC API를 통해 DB에 적절한 SQL을 날려준다.
- 특징: JPA 인터페이스의 표준 명세를 아주 충실히 구현했으며, 현재 자바 진영에서 사실상 표준(De facto standard)으로 쓰인다.
영속성 컨텍스트와 DB 관계
영속성 컨텍스트란?

영속성 컨텍스트는 "엔티티를 영구 저장하는 논리적인 환경"이다.
물리적인 DB에 저장하기 전, 데이터를 잠시 보관하고 관리하는 '메모리 상의 가상 DB(버퍼)'라고 생각하면 이해하기 쉽다.
우리가 EntityManager를 생성하면 그 안에 영속성 컨텍스트가 1:1로 생긴다.
영속성 컨텍스트 3가지
- 1차 캐시:
- 데이터를 DB에서 바로 조회하는 게 아니라, 먼저 이 메모리 공간(1차 캐시)을 뒤져봅니다. 있으면 DB를 안 거치고 바로 준다. (성능 향상)
- 변경 감지 (Dirty Checking):
- 개발자가 update 쿼리를 안 날려도, 자바 객체 값만 바꾸면 트랜잭션이 끝날 때 알아서 DB를 업데이트한다.
- 쓰기 지연 (Transactional Write-behind):
- save() 할 때마다 DB에 가는 게 아니라, 쿼리를 모아뒀다가 트랜잭션 커밋(commit) 시점에 한방에 쏘아 보낸다.
영속성 컨텍스트의 생명주기 (Entity LifeCycle)
엔티티 객체는 영속성 컨텍스트와 어떤 관계를 맺느냐에 따라 4가지 상태로 구분됩니다. 이 흐름을 아는 것이 가장 중요하다.
- 비영속 (New/Transient): 객체를 막 생성한 상태. 아직 JPA와 전혀 관계없음.
- 영속 (Managed): em.persist(entity)를 통해 영속성 컨텍스트에 들어간 상태. 이제부터 JPA가 관리함.
- 준영속 (Detached): 영속성 컨텍스트에 있다가 쫓겨나거나 나온 상태. 더 이상 관리받지 못함.
- 삭제 (Removed): 삭제된 상태.
실제 동작 흐름 상세 분석 (코드와 함께)
개발자가 코드를 짰을 때 내부에서 어떤 일이 벌어지는지 단계별로 추적해 보면,
// 관리자 생성
EntityManager em = emf.createEntityManager();
// 0. 트랜잭션 가져오기
EntityTransaction tx = em.getTransaction();
// 1. 트랜잭션 시작
tx.begin();
// 2. 비영속 상태 (객체 생성)
Member member = new Member();
member.setId(1L);
member.setName("Gemini");
// -> 여기까지는 그냥 자바 객체일 뿐, DB나 JPA와 아무 관련 없음.
// 3. 영속 상태 (저장)
em.persist(member);
이 시점(em.persist)에서의 내부 동작
- 1차 캐시에 저장: 영속성 컨텍스트 내부에 있는 Map<KEY, VALUE> 저장소에 id: 1L, value: member객체 형태로 저장된다.
- 쓰기 지연 SQL 저장소: JPA가 "아, 이건 Insert 구문이 필요하네?"라고 판단해서 SQL(INSERT INTO MEMBER... )을 생성해 메모리 상의 쿼리 저장소에 쌓아둔다.
- 중요: 아직 실제 DB에는 아무 쿼리도 안 날아갔다.
// 4. 데이터 수정 (변경 감지)
member.setName("AI_Gemini");
이 시점(setName)에서의 내부 동작
- JPA는 1차 캐시에 들어올 때의 최초 상태(스냅샷)를 기억하고 있다.
- 현재 객체의 값(AI_Gemini)과 스냅샷(Gemini)을 비교한다.
- "어? 값이 다르네?" -> JPA가 자동으로 SQL(UPDATE MEMBER... )을 생성해서 쓰기 지연 SQL 저장소에 추가로 쌓아둔다.
// 5. 트랜잭션 커밋 (실제 반영)
tx.commit();
이 시점(tx.commit)에서의 내부 동작 (가장 중요)
- flush() 발생: 영속성 컨텍스트의 변경 내용을 DB에 동기화한.
- SQL 전송: 아까 쓰기 지연 저장소에 모아둔 INSERT, UPDATE 쿼리들이 이 순간 DB로 날아간다.
- DB 커밋: 실제 데이터베이스 트랜잭션을 커밋하여 데이터를 영구 저장한다.
JPA 작성 비교 EntityManager vs JpaRepository
실무에서는 생산성을 위해 주로 2번 방식(Spring Data JPA)을 사용하지만, 원리를 알기 위해 1번도 이해해야 한다.
방식 1: EntityManager 직접 사용 (순수 JPA)
가장 원초적인 방식으로, EntityManager를 주입받아 직접 메서드를 호출한다.
@Repository
public class MemberRepository {
@PersistenceContext
private EntityManager em; // 엔티티 매니저 주입
public void save(Member member) {
em.persist(member); // 영속성 컨텍스트에 저장
}
public Member findOne(Long id) {
return em.find(Member.class, id); // 조회
}
}
방식 2: JpaRepository 상속 (Spring Data JPA)
Spring이 EntityManager 코드를 감싸서 대신 구현해 준다. ( EntityManager 구현되어 있다.)
인터페이스만 만들면 된다.
// 인터페이스만 정의하면 Spring이 구현체를 자동으로 만들어줌 (마법 같은 기능)
public interface MemberRepository extends JpaRepository<Member, Long> {
// 기본 CRUD 메서드(save, findById, delete 등)가 이미 다 만들어져 있음
// 이름으로 찾고 싶다면? 메서드 이름만 규칙에 맞춰 지으면 쿼리 자동 생성
// select m from Member m where m.name = ?
List<Member> findByName(String name);
}
만약, JpaRepository를 쓰면서도 EntityManager가 필요하다면?
가끔은 복잡한 쿼리(동적 쿼리 등) 때문에 JpaRepository가 제공하는 기능만으로는 부족해서 직접 EntityManager를 쓰고 싶을 때가 있다. 그럴 땐 그냥 서비스나 커스텀 리포지토리에서 주입받아 쓰면 된다.
@Service
public class MemberService {
// 1. 기본 기능은 이거 쓰고
@Autowired
private MemberRepository memberRepository;
// 2. 복잡한 건 직접 관리자 소환해서 씀 (스프링이 주입해줌)
@PersistenceContext
private EntityManager em;
public void specialLogic() {
// JpaRepository 기능 사용
memberRepository.findAll();
// EntityManager 직접 사용 (예: QueryDSL이나 네이티브 쿼리 등)
em.createQuery("select m from Member m").getResultList();
}
}
JPA 활용한 통합 예시
Spring Boot 환경에서 Controller - Service - Repository 구조로 JPA를 적용한 전체 흐름이다.
1) Entity (DB 테이블 매핑)
@Entity
@Getter @Setter // Lombok 사용 가정
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
// 편의상 생성자 추가
public Member(String name) {
this.name = name;
}
protected Member() {} // JPA 스펙상 기본 생성자 필수
}
2) Repository (DB 접근 계층 - Spring Data JPA 사용)
public interface MemberRepository extends JpaRepository<Member, Long> {
// 아무것도 안 적어도 save(), findById(), findAll() 등 사용 가능
}
제네릭에는 사용할 Entity와 Id값(PK)을 넣어준다.
여기서, 기본으로 제공하는 메서드가 너무 길어지거나, 조금 복잡한 조건으로 데이터를 가져와야 할 경우, @Query 어노테이션을 사용해 직접 쿼리문을 작성할 수 있다.
1.JPQL (Java Persistence Query Language): SQL과 비슷하게 생겼지만, 테이블이 아니라 "엔티티 객체"를 대상으로 쿼리를 짠다. 대체적으로 많이 사용한다.
- 특징: 특정 DB(MySQL, Oracle 등)에 의존하지 않다. (방언 설정에 따라 알아서 변환됨)
- 문법: FROM Member m (테이블명 tbl_member가 아니라, 클래스명 Member를 쓴다.)
2. Native Query (네이티브 쿼리): "나는 객체고 뭐고 그냥 쌩 SQL을 날리고 싶다!" 할 때 사용한다.
- 특징: DB의 고유 기능을 써야 하거나, 복잡한 통계 쿼리를 짤 때 쓴다. (단, DB를 바꾸면 쿼리도 고쳐야 한다.)
- 설정: nativeQuery = true 옵션을 켜야 한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// 1. [JPQL 사용]
// 설명: Member 엔티티(m)에서 이름이 같고, 나이가 특정 값 이상인 사람 조회
// 특징: 테이블 이름이 아니라 '클래스 이름(Member)'을 사용함
@Query("select m from Member m where m.username = :name and m.age > :age")
List<Member> findUser(@Param("name") String username, @Param("age") int age);
// 2. [Native Query 사용]
// 설명: 실제 DB 테이블(tbl_member)에 직접 SQL을 날림
// 특징: nativeQuery = true 옵션 필수, 실제 테이블명 사용
@Query(value = "select * from tbl_member where username = :name", nativeQuery = true)
Member findByNative(@Param("name") String username);
}
주의할 점: 데이터를 수정/삭제할 때 (@Modifying)
조회(SELECT)가 아니라 UPDATE나 DELETE 쿼리를 직접 짤 때는 반드시 @Modifying 어노테이션을 같이 붙여줘야 한다.
@Modifying // 이게 없으면 "데이터 조회 시도했니?" 하고 에러 냄
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
3) Service (비즈니스 로직 계층)
@Service
@Transactional(readOnly = true) // JPA의 모든 데이터 변경은 트랜잭션 안에서 이루어져야 함
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 회원 가입
@Transactional // 쓰기 작업이므로 readOnly = false (기본값)
public Long join(String name) {
Member member = new Member(name);
memberRepository.save(member); // JPA가 Insert SQL 실행
return member.getId();
}
// 회원 조회
public Member findMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("회원 없음"));
}
// 회원 수정 (중요: 변경 감지 기능 사용)
@Transactional
public void updateMemberName(Long memberId, String newName) {
Member member = memberRepository.findById(memberId).get();
// 별도의 update 호출 없이 값만 변경하면, 트랜잭션 종료 시점에 Update 쿼리 나감
member.setName(newName);
}
}
4) Controller (웹 요청 처리 계층)
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
// 회원 생성 API
@PostMapping
public String createMember(@RequestParam String name) {
Long id = memberService.join(name);
return "회원 가입 완료! ID: " + id;
}
// 회원 조회 API
@GetMapping("/{id}")
public String getMember(@PathVariable Long id) {
Member member = memberService.findMember(id);
return "회원 이름: " + member.getName();
}
}
JPA를 사용 시 주의할 점
1. N+1 문제 (가장 중요)
- 시나리오: Team(팀)과 Member(멤버)가 1:N 관계이다.
- 목표: 팀 10개를 조회하고, 각 팀에 소속된 멤버들의 이름을 출력하고 싶다.
"조회 쿼리를 1번 날렸는데, 조회된 데이터 개수(N)만큼 추가 쿼리가 나가는 현상"
// 1. 팀을 모두 조회 (SELECT * FROM TEAM) -> 쿼리 1번 발생
List<Team> teams = teamRepository.findAll();
// 2. 루프를 돌며 멤버 이름 출력
for (Team team : teams) {
// 여기서 멤버를 사용할 때마다 멤버 조회 쿼리가 추가로 발생!
// 팀이 10개면 쿼리가 10번 더 나감 (총 1 + 10 = 11번)
System.out.println(team.getMembers().size());
}
해결 방법 : 연관된 엔티티를 SQL 한 번에 다 가져오게 만드는 것
// JPQL에서 join fetch 사용
@Query("select t from Team t join fetch t.members")
List<Team> findAllWithMembers();
이렇게 하면 실제 SQL이 INNER JOIN으로 나가서 한 방에 데이터를 다 가져오므로 N+1 문제가 해결된다.
2. 모든 연관관계는 지연 로딩(LAZY)으로 설정하라
JPA에는 데이터를 가져오는 전략이 두 가지가 있다.
- EAGER (즉시 로딩): 내 정보를 가져올 때 연관된 애들도 무조건 다 가져옴.
- LAZY (지연 로딩): 내 정보만 가져오고, 연관된 애들은 실제로 쓸 때 가져옴.
주의: @ManyToOne, @OneToOne은 기본값이 EAGER이다. 이걸 그대로 두면, 나는 그냥 회원 정보만 보고 싶은데 연관된 팀, 주문 내역 등을 다 끌고 와서 예상치 못한 조인 쿼리가 수십 개 발생한다.
해결 방법: 무조건 모든 관계를 LAZY로 설정하기.
@ManyToOne(fetch = FetchType.LAZY) // 필수!
private Team team;
3. 엔티티를 직접 Controller에서 반환하지 마라
DB와 매핑된 Entity를 API 응답으로 바로 내보내면 안 된다.
- 무한 루프 (Infinite Recursion): 양방향 연관관계(회원↔팀)가 걸려있으면, JSON으로 변환하는 과정에서 서로를 계속 호출하다가 StackOverflowError가 터진다.
- API 스펙 변동: 테이블 컬럼 하나 바꿨는데 API 스펙이 바뀌어 버려서 프론트엔드 코드가 다 깨진다.
해결 방법: 반드시 DTO(Data Transfer Object) 라는 별도의 객체를 만들어서 필요한 데이터만 옮겨 담아 반환해야 한다.
4. 벌크 연산(Bulk Operation) 후 영속성 컨텍스트 초기화
JPA는 보통 한 건씩 처리하지만, Update나 Delete를 한 번에 수천 건씩 해야 할 때가 있다. 이를 벌크 연산이라고 한다.
문제점: 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 꽂아버립니다.
// DB에는 나이가 20살로 변경됨.
repository.bulkAgePlus(1);
// 하지만 영속성 컨텍스트(메모리)에는 여전히 19살로 남아있음!
Member member = repository.findById(id); // 19살 출력 (망함)
해결 방법: @Modifying 어노테이션에 옵션을 줘서 연산 후 메모리를 비워야 한다.
@Modifying(clearAutomatically = true) // 쿼리 나가고 나서 영속성 컨텍스트를 깔끔하게 비워줌
@Query("update Member m set m.age = m.age + 1 ...")
int bulkAgePlus(...);
5. Lombok 사용 시 주의 (@Data, @ToString)
편하다고 롬복의 @Data를 막 쓰면 안 된다. 특히 @ToString이 문제다.
Member.toString()을 호출하면 내부의 Team을 호출하고, Team.toString()은 다시 Member를 호출한다. -> 무한 루프 발생한다.
해결 방법: @Data 대신 @Getter, @Setter 정도만 쓰고, @ToString을 쓸 때는 연관관계 필드는 제외해야 한다.
@ToString(exclude = "team") // team 필드는 toString에서 뺌
public class Member { ... }
요약
- N+1 문제: 쿼리 폭탄 조심 -> Fetch Join으로 해결.
- 로딩 전략: 기본값 믿지 말고 전부 LAZY로 설정.
- API 반환: 엔티티 노출 금지 -> DTO 사용.
- 벌크 연산: DB와 메모리 불일치 조심 -> clearAutomatically 사용.
- Lombok: 무한 참조 조심 -> @ToString에서 연관관계 제외.
면접 답변식 요약
JPA의 핵심은 영속성 컨텍스트를 통해 애플리케이션과 데이터베이스 사이에서 데이터를 관리한다는 점입니다.
단순히 쿼리를 대신 날려주는 것을 넘어, 1차 캐시를 통해 조회 성능을 최적화하고, 쓰기 지연(Write-behind)을 통해 트랜잭션 커밋 시점에 쿼리를 한 번에 모아 보낼 수 있습니다. 특히 객체의 값만 바꾸면 트랜잭션 종료 시점에 자동으로 Update 쿼리가 나가는 변경 감지(Dirty Checking) 기능 덕분에, 객체 지향적인 코드를 작성하면서도 데이터 무결성을 유지할 수 있다는 것이 가장 큰 장점이라고 생각합니다.
'Daily Dev Q&A 정리 템플릿' 카테고리의 다른 글
| 26.01.07 ORM이란? (0) | 2026.01.07 |
|---|---|
| 26.01.06 WAS와 웹서버란? (0) | 2026.01.06 |
| 26.01.02 restfull 설계방식에 대해서 설명하고 이를 적용할 때 에매한 경우의 예시를 들어, 보통 이럴 때 어떻게 하는지를 설명해주세요. (0) | 2026.01.02 |
| 26.01.01 session로그인방식과 token로그인 방식 (0) | 2026.01.01 |
| 25.12.31 Spring Security의 인증과 인가에 대해. (0) | 2025.12.31 |