들어가며
스프링부트 애플리케이션이 데이터베이스와 어떻게 통신하는지 궁금했던 적이 있다. 단순히 JPA의 save() 메서드를 호출하면 데이터가 저장되는데, 그 내부에서는 어떤 일이 벌어지는 걸까? 이번 글에서는 스프링부트가 DB와 통신하는 전체 과정을 정리해보았다.
스프링부트에서 DB에 쿼리를 보내는 원리
전체 흐름도
스프링부트 애플리케이션에서 데이터베이스까지 데이터가 전달되는 과정은 다음과 같다.
Controller → Service → Repository → JDBC/JPA → JDBC Driver → Database
각 계층의 역할을 살펴보자.
1. Controller 계층
컨트롤러는 HTTP 요청을 받아 Service로 전달한다.
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody UserDto dto) {
User user = userService.createUser(dto);
return ResponseEntity.ok(user);
}
}
2. Service 계층
비즈니스 로직을 처리하고 Repository를 호출한다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public User createUser(UserDto dto) {
User user = User.builder()
.username(dto.getUsername())
.email(dto.getEmail())
.build();
return userRepository.save(user);
}
}
3. Repository 계층
실제로 데이터베이스와 통신하는 계층이다. 여기서 JDBC나 JPA를 사용한다.
4. JDBC Driver
자바 애플리케이션과 데이터베이스를 연결하는 다리 역할을 한다. MySQL, PostgreSQL, Oracle 등 각 데이터베이스 제조사가 제공하는 드라이버를 사용한다.
5. 커넥션 풀
매번 DB 커넥션을 생성하고 해제하는 것은 비효율적이다. 그래서 스프링부트는 HikariCP 같은 커넥션 풀을 사용해 미리 커넥션을 생성해두고 재사용한다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
```
이렇게 설정하면 스프링부트가 시작될 때 5~10개의 커넥션을 미리 만들어두고, 필요할 때마다 빌려주고 반환받는다.
---
## JDBC 원리와 코드예시
### JDBC란?
JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 접근하기 위한 표준 API이다. 어떤 데이터베이스를 사용하든 동일한 인터페이스로 접근할 수 있다.
### JDBC 동작 원리
JDBC는 인터페이스만 제공하고, 실제 구현은 각 DB 벤더가 JDBC Driver로 제공한다.
```
Java Application
↓
JDBC API (표준 인터페이스)
↓
JDBC Driver (MySQL Driver, Oracle Driver 등)
↓
Database
순수 JDBC 코드
순수 JDBC를 사용하면 다음과 같이 작성한다.
public class UserDao {
public User findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// 1. DB 연결
conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/mydb",
"root",
"password"
);
// 2. SQL 준비
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, id);
// 3. SQL 실행
rs = pstmt.executeQuery();
// 4. 결과를 객체로 변환
if (rs.next()) {
return User.builder()
.id(rs.getLong("id"))
.username(rs.getString("username"))
.email(rs.getString("email"))
.build();
}
return null;
} catch (SQLException e) {
throw new RuntimeException("DB 조회 실패", e);
} finally {
// 5. 리소스 정리 (역순으로!)
closeResources(rs, pstmt, conn);
}
}
public void save(User user) {
String sql = "INSERT INTO users (username, email) VALUES (?, ?)";
try (Connection conn = DriverManager.getConnection(...);
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, user.getUsername());
pstmt.setString(2, user.getEmail());
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("저장 실패", e);
}
}
}
순수 JDBC의 문제점
코드를 보면 알 수 있듯이 문제가 많다.
- 반복되는 커넥션 생성/해제 코드
- 복잡한 리소스 관리 (finally에서 null 체크)
- SQLException 처리
- ResultSet을 객체로 변환하는 반복 코드
Spring JdbcTemplate
스프링은 이런 문제를 해결하기 위해 JdbcTemplate을 제공한다.
@Repository
@RequiredArgsConstructor
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
// 단건 조회
public User findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, this::mapRow, id);
}
// 목록 조회
public List<User> findAll() {
String sql = "SELECT * FROM users";
return jdbcTemplate.query(sql, this::mapRow);
}
// 저장
public int save(User user) {
String sql = "INSERT INTO users (username, email) VALUES (?, ?)";
return jdbcTemplate.update(sql, user.getUsername(), user.getEmail());
}
// 수정
public int update(User user) {
String sql = "UPDATE users SET username = ?, email = ? WHERE id = ?";
return jdbcTemplate.update(sql,
user.getUsername(),
user.getEmail(),
user.getId());
}
// 삭제
public int deleteById(Long id) {
String sql = "DELETE FROM users WHERE id = ?";
return jdbcTemplate.update(sql, id);
}
// ResultSet을 객체로 변환
private User mapRow(ResultSet rs, int rowNum) throws SQLException {
return User.builder()
.id(rs.getLong("id"))
.username(rs.getString("username"))
.email(rs.getString("email"))
.build();
}
}
JdbcTemplate을 사용하면 커넥션 관리, 예외 처리, 리소스 정리를 모두 자동으로 해준다. 개발자는 SQL과 매핑 로직만 작성하면 된다.
배치 처리
대량의 데이터를 처리할 때는 배치를 사용한다.
public void saveBatch(List<User> users) {
String sql = "INSERT INTO users (username, email) VALUES (?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = users.get(i);
ps.setString(1, user.getUsername());
ps.setString(2, user.getEmail());
}
@Override
public int getBatchSize() {
return users.size();
}
});
}
```
하나씩 처리하면 1000번 DB 통신이 필요하지만, 배치를 사용하면 한 번에 처리할 수 있다.
---
## JPA 원리와 코드예시
### JPA란?
JPA(Java Persistence API)는 자바 ORM(Object-Relational Mapping) 표준이다. 객체와 테이블을 매핑해서 SQL을 직접 작성하지 않고도 데이터베이스를 다룰 수 있게 해준다.
### JPA 동작 원리
```
Application → JPA → Hibernate(구현체) → JDBC → Database
스프링부트는 기본적으로 Hibernate를 JPA 구현체로 사용한다.
엔티티 정의
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String username;
@Column(nullable = false, unique = true)
private String email;
@CreatedDate
private LocalDateTime createdAt;
@Builder
public User(String username, String email) {
this.username = username;
this.email = email;
}
public void updateEmail(String email) {
this.email = email;
}
}
Repository 작성
public interface UserRepository extends JpaRepository<User, Long> {
// 메서드 이름으로 쿼리 자동 생성
Optional<User> findByEmail(String email);
List<User> findByUsernameContaining(String username);
// @Query로 직접 JPQL 작성
@Query("SELECT u FROM User u WHERE u.createdAt > :date")
List<User> findRecentUsers(@Param("date") LocalDateTime date);
// Native Query 사용
@Query(value = "SELECT * FROM users WHERE email LIKE %:domain%",
nativeQuery = true)
List<User> findByEmailDomain(@Param("domain") String domain);
}
Service에서 사용
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
// 저장
@Transactional
public User createUser(UserDto dto) {
User user = User.builder()
.username(dto.getUsername())
.email(dto.getEmail())
.build();
return userRepository.save(user);
}
// 조회
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("사용자 없음"));
}
// 수정
@Transactional
public User updateEmail(Long id, String newEmail) {
User user = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("사용자 없음"));
user.updateEmail(newEmail); // 변경 감지(Dirty Checking)
return user; // save() 호출 불필요!
}
// 삭제
@Transactional
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}
영속성 컨텍스트
JPA의 핵심은 영속성 컨텍스트(Persistence Context)이다. 엔티티를 메모리에 캐싱하고 변경을 추적한다.
@Transactional
public void updateUser(Long id) {
// 1. 조회 - DB에서 가져와 영속성 컨텍스트에 저장
User user = userRepository.findById(id).get();
// 2. 수정 - 엔티티만 변경
user.updateEmail("new@email.com");
// 3. 트랜잭션 커밋 시점에 자동으로 UPDATE 쿼리 실행
// save() 호출 불필요!
}
이것이 변경 감지(Dirty Checking)이다. JPA가 엔티티의 변경사항을 추적하고 있다가 트랜잭션 커밋 시점에 자동으로 UPDATE 쿼리를 날린다.
연관관계 매핑
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
// 다대일 관계
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User author;
// 일대다 관계
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
Fetch 전략
// LAZY (지연 로딩) - 필요할 때만 조회
@ManyToOne(fetch = FetchType.LAZY)
private User author;
// EAGER (즉시 로딩) - 항상 함께 조회
@ManyToOne(fetch = FetchType.EAGER)
private User author;
일반적으로 LAZY를 기본으로 사용하고, 필요한 경우에만 Fetch Join을 사용한다.
@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
Optional<Post> findByIdWithAuthor(@Param("id") Long id);
N+1 문제 해결
// 나쁜 예 - N+1 문제 발생
public List<PostDto> getPosts() {
List<Post> posts = postRepository.findAll(); // 1번 쿼리
return posts.stream()
.map(post -> new PostDto(
post.getTitle(),
post.getAuthor().getUsername() // N번 쿼리 (각 Post마다)
))
.toList();
}
// 좋은 예 - Fetch Join 사용
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
public List<PostDto> getPostsOptimized() {
List<Post> posts = postRepository.findAllWithAuthor(); // 1번 쿼리로 해결
return posts.stream()
.map(post -> new PostDto(
post.getTitle(),
post.getAuthor().getUsername()
))
.toList();
}
JDBC vs JPA 비교
JDBC 장점
- SQL을 직접 제어할 수 있다
- 복잡한 쿼리나 성능 최적화에 유리하다
- 학습 곡선이 낮다
JDBC 단점
- 반복적인 코드가 많다
- 객체-테이블 매핑을 직접 해야 한다
- SQL이 자바 코드에 섞여 있다
JPA 장점
- SQL을 직접 작성하지 않아도 된다
- 객체 중심 개발이 가능하다
- 생산성이 높다
- 변경 감지, 캐싱 등 다양한 기능을 제공한다
JPA 단점
- 학습 곡선이 높다
- 복잡한 쿼리는 오히려 어렵다
- 성능 최적화가 까다로울 수 있다
실무에서는?
보통 다음과 같이 선택한다.
- 기본적인 CRUD: JPA
- 복잡한 통계 쿼리: JDBC나 QueryDSL
- 대용량 배치 처리: JDBC Batch
- 동적 쿼리: QueryDSL
하나만 고집하지 않고 상황에 맞게 조합해서 사용하는 것이 좋다.
마치며
스프링부트에서 데이터베이스와 통신하는 방식을 정리해보았다. Controller에서 시작된 요청이 Service, Repository를 거쳐 JDBC를 통해 데이터베이스까지 전달되는 과정을 이해하면, 더 나은 설계와 성능 최적화를 할 수 있다.
JDBC는 직접적이고 명확하지만 반복적인 코드가 많고, JPA는 생산성이 높지만 내부 동작을 이해해야 제대로 사용할 수 있다. 각각의 장단점을 이해하고 적재적소에 활용하는 것이 중요하다.
'Stack > Spring' 카테고리의 다른 글
| Spring AOP로 API 성능 측정하기 - 프로젝트 예제와 함께 (1) | 2026.01.21 |
|---|---|
| N+1 문제 해결로 쿼리 성능 개선하기 (0) | 2022.08.11 |
| JPA 특정 엔티티 삭제시 연관된 엔티티도 함께 삭제하기 (0) | 2022.07.12 |
| (CI/CD) Codedeploy 배포 GitAction 에 GitIgnore 파일 적용하는법 (0) | 2022.07.12 |
| SQL 관계 데이터 베이스 삭제 시 참조키 관련 주의 사항 (0) | 2022.07.12 |
