스프링부트 DB 작동 방식과 원리

2026. 1. 21. 09:52·Stack/Spring

들어가며

스프링부트 애플리케이션이 데이터베이스와 어떻게 통신하는지 궁금했던 적이 있다. 단순히 JPA의 save() 메서드를 호출하면 데이터가 저장되는데, 그 내부에서는 어떤 일이 벌어지는 걸까? 이번 글에서는 스프링부트가 DB와 통신하는 전체 과정을 정리해보았다.

스프링부트에서 DB에 쿼리를 보내는 원리

전체 흐름도

스프링부트 애플리케이션에서 데이터베이스까지 데이터가 전달되는 과정은 다음과 같다.

 
 
Controller → Service → Repository → JDBC/JPA → JDBC Driver → Database

각 계층의 역할을 살펴보자.

1. Controller 계층

컨트롤러는 HTTP 요청을 받아 Service로 전달한다.

 
 
java
@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를 호출한다.

 
 
java
@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 같은 커넥션 풀을 사용해 미리 커넥션을 생성해두고 재사용한다.

 
 
yaml
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를 사용하면 다음과 같이 작성한다.

 
 
java
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을 제공한다.

 
 
java
@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과 매핑 로직만 작성하면 된다.

배치 처리

대량의 데이터를 처리할 때는 배치를 사용한다.

 
 
java
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 구현체로 사용한다.

엔티티 정의

 
 
java
@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 작성

 
 
java
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에서 사용

 
 
java
@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)이다. 엔티티를 메모리에 캐싱하고 변경을 추적한다.

 
 
java
@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 쿼리를 날린다.

연관관계 매핑

 
 
java
@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 전략

 
 
java
// LAZY (지연 로딩) - 필요할 때만 조회
@ManyToOne(fetch = FetchType.LAZY)
private User author;

// EAGER (즉시 로딩) - 항상 함께 조회
@ManyToOne(fetch = FetchType.EAGER)
private User author;

일반적으로 LAZY를 기본으로 사용하고, 필요한 경우에만 Fetch Join을 사용한다.

 
 
java
@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
Optional<Post> findByIdWithAuthor(@Param("id") Long id);

N+1 문제 해결

 
 
java
// 나쁜 예 - 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
'Stack/Spring' 카테고리의 다른 글
  • Spring AOP로 API 성능 측정하기 - 프로젝트 예제와 함께
  • N+1 문제 해결로 쿼리 성능 개선하기
  • JPA 특정 엔티티 삭제시 연관된 엔티티도 함께 삭제하기
  • (CI/CD) Codedeploy 배포 GitAction 에 GitIgnore 파일 적용하는법
김코딩개발자
김코딩개발자
  • 김코딩개발자
    김코딩의 개발로그
    김코딩개발자
  • 전체
    오늘
    어제
    • 분류 전체보기 (61)
      • 개발이야기 (15)
        • 개발로그 (4)
        • 항해일지 (11)
      • Develop (0)
      • Life (0)
      • Stack (28)
        • C++ (6)
        • Ext.js (1)
        • Spring (18)
        • Java (2)
        • JavaScript (1)
      • TechTrend (0)
      • TechKnowledge (18)
        • CS관련지식 (7)
        • 알고리즘 (9)
        • 네트워크 (2)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    osi 2계층
    데이터 마이그레이션
    OSI 3계층
    올바른 괄호
    직장인
    개발일기
    프로그래머스 멀리뛰기
    서비스 경험
    서비스경험
    네트워크
    관점지향프로그래밍
    프로그래머스
    피보나치수열
    lan 통신
    괄호 회전하기
    SpringBoot DB
    ip통신
    시간복잡도
    Connection Pool
    Spring AOP
    괄호문제
    동적계획법
    aop
    프로그래머스 LV2
    개발입문
    java Stack
    DB원리
    JPA
    오버로딩
    자바스크립트입문
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
김코딩개발자
스프링부트 DB 작동 방식과 원리
상단으로

티스토리툴바