Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

co-cherry

@Transactional과 DB 인덱스로 Spring Boot 애플리케이션 성능 최적화하기 본문

Springboot

@Transactional과 DB 인덱스로 Spring Boot 애플리케이션 성능 최적화하기

co-cherry 2026. 5. 15. 17:15

@Transactional 

트랜잭션(Transaction)

데이터베이스의 상태를 변화시키는 하나의 논리적인 작업 단위를 구성하는 연산들의 집합 

여러 개의 DB 작업을 하나의 단위로 묶고 분할할 수 없음 (원자성)

→ 전부 성공 또는 전부 실패만 가능 

트랜잭션의 핵심 기능: ACID

  • Atomicity (원자성) All or Nothing 전부 성공하거나 전부 실패 
  • Consistency(일관성) 트랜잭션 실행 전후로 데이터베이스가 일관된 상태를 유지 
  • Isolation(격리성) 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않음 
  • Durability(지속성) 커밋된 트랜잭션은 영구적으로 저장됨 

왜 트랜잭션이 필요할까?

트랜잭션은 데이터의 정합성을 보장한다. 

여기서, 정합성이란 데이터가 모순 없이 일치하는 상태를 의미한다. 

 

트랜잭션이 있으면,

  • 모두 성공 → 정합성 유지 
  • 하나라도 실패 → 모두 롤백 → 정합성 유지 

@Transactional 

Spring Framework가 제공하는 선언적 트랜잭션 관리 어노테이션 

Spring에게 이 메서드를 트랜잭션으로 실행해달라고 알려 주는 표시 

Spring이 자동으로 트랜잭션을 실행하고 종료함 

  • 개발자가 직접 begin, commit, rollback을 안 써도 됨 

*IOException, SQLException 같은 CheckedException에서는 자동 롤백되지 않음 

 

@Transactional(readOnly = true) 

@Transactional의 옵션 중 하나로 읽기 전용 트랜잭션을 만들 때 사용한다. 

조회만 하는 경우, 이 옵션을 통해 자원을 절약하고 불필요한 업데이트를 방지할 수 있다. 

 

나의 프로젝트에도 이와 같이 게시글 상세 조회에서 적용한 것을 볼 수 있다.

@Transactional(readOnly = true)
    public PostDetailResponseDto getPost(Long postId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND));

        return new PostDetailResponseDto(
                post.getId(),
                post.getTitle(),
                post.getContent(),
                post.getUser().getNickname(),
                post.getCreatedAt()
        );
    }

@Transactional을 이용해 회원 탈퇴를 구현해보자.

회원 탈퇴 시, User의 Comment와 Post, 마지막으로 User 자체를 삭제하는 방향으로 구현할 예정이다. (Hard delete)

 

먼저 CommentRepository와 PostRepository에 삭제 메서드를 작성한다. 

void deleteAllByUserId(Long userId);

 

그리고 서비스에 삭제 로직을 작성한다.

이때 @Transactional 처리를 꼭 해야 작업이 하나의 단위로 처리된다. 

@Transactional
    public void withdraw(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND));

        commentRepository.deleteAllByUserId(userId);
        postRepository.deleteAllByUserId(userId);
        userRepository.delete(user);
    }

 

현재 DB 구조를 보면 Comment는 Post와 Users를 Post는 Users를 참조하고 있는데, 

users 테이블
  └── post 테이블 (user_id → users.user_id 참조)
        └── comment 테이블 (post_id → post.post_id, user_id → users.user_id 참조)

 

여기서 만약 User를 먼저 삭제하면 FK 제약 위반이 발생한다. 

따라서, 참조당하는 쪽은 참조하는 쪽이 없어진 다음에야 삭제가 가능하다. 

또는 CasecadeType.ALL 옵션을 사용하면 JPA가 알아서 자식부터 삭제하지만 지금 같은 경우에는 순서를 꼭 지켜야 한다. 

 

마지막으로, 컨트롤러에 엔트포인트를 추가하면 된다. 

@Operation(summary = "회원 탈퇴", description = "유저의 댓글, 게시글, 계정을 모두 삭제합니다.")
@DeleteMapping("/{userId}")
public ApiResponse<Void> withdraw(@PathVariable Long userId) {
    userService.withdraw(userId);
    return ApiResponse.onSuccess(GeneralSuccessCode.OK, null);
}

 

테스트를 위해 게시글과 댓글을 작성했다. 

 

그리고 회원 탈퇴를 한 후, 

 

다시 아까 작성한 게시글을 조회하면 게시글을 찾을 수 없게 된다. 

 

회원 탈퇴와 동시에 게시글, 댓글이 트랜잭션으로 모두 삭제되었기 때문이다. 

 

Index

데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료 구조 

 

MySQL은 기본적으로 B-Tree 구조의 인덱스를 사용한다. 

인덱스 구조 (created_at 컬럼 기준):

                   [2024-03-15]
                   /          \
          [2024-02-15]      [2024-04-15]
          /        \        /          \
    [2024-01-15] [2024-02-28] [2024-03-31] [2024-05-15]
         ↓          ↓           ↓            ↓
      실제 행     실제 행      실제 행       실제 행

 

인덱스가 없으면 테이블 전체를 순서대로 훑는 Full Scan(O(n))을 하지만,

인덱스가 있으면 B-Tree를 탐색하는 Index Scan(O(log n))으로 편해진다. 

 

Index를 사용하면 좋은 경우

  • WHERE절에 자주 사용되는 컬럼
  • ORDER BY절에 자주 사용되는 컬럼 
  • JOIN 조건으로 사용되는 컬럼
  • 카디널리티가 높은 컬럼 

Index의 장/단점

  • 조회 속도 대폭 향상
  • ORDER BY, GROUP BY 성능 개선
  • MIN/MAX 최적화 
  • 추가 저장 공간 필요
  • INSERT/UPDATE/DELETE 성능 저하
  • 인덱스 유지 비용 발생

인덱스를 직접 적용해보기 위해 Post 엔티티를 다음과 같이 수정했다. 

@Table(name = "post", indexes = {
        @Index(name = "idx_post_created_at", columnList = "created_at")
})

 

이 인덱스를 사용하면 목록 조회 시 정렬 속도와 날짜 범위 필터 속도도 향상된다. 

 

테스트를 위해 explain(쿼리 성능 분석을 위한 도구)을 사용하려고 했지만, 데이터가 적은 탓에 인덱스가 있음에도 불구하고 옵티마이저가 자동으로 Full Scan을 선택했다. 

rows가 적으면 인덱스를 타는 비용이 오히려 Full Scan보다 더 들 수 있어 옵티마이저가 자동으로 선택한 것이다. 

 

데이터를 더욱 확보한 뒤에 정확한 비교를 다시 해보아야겠다! 

'Springboot' 카테고리의 다른 글

게시글 목록 API 구현하기 (페이징 · 검색, N+1)  (0) 2026.05.07
회원가입 및 로그인 구현  (0) 2026.04.28
게시판 CRUD  (0) 2026.03.30
ERD + DB + JPA 시작  (0) 2026.03.24
예외 처리와 Validation  (1) 2026.03.13