ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SpringBoot] 7주차 게시글 + 댓글 동시 저장 서비스 작성, 예외 케이스 적용, 인덱스 생성
    Study/SpringBoot 2026. 5. 20. 18:07

    6주차에서 페이지네이션과 N+1 문제 해결을 통해 '조회' 성능을 튜닝했다면, 7주차의 핵심은 '저장'의 안정성과 '검색' 속도, 그리고 '예외'의 통제다.

    사용자가 글을 작성함과 동시에 첫 댓글을 함께 등록하는 API가 있다고 가정해 보자. 게시글은 DB에 저장되었는데 에러로 인해 댓글 저장이 실패한다면, 이러한 데이터를 사전에 방지하는 Transaction의 강력함과, 수백만 건의 데이터가 쌓였을 때 검색 속도를 빠르게 하는 줄 Index 설정, 그리고 예외 케이스 적용 과정을 기록한다.

    이번 주차 공부 내용

    • 게시글 + 댓글 동시 저장 서비스 작성
    • 예외 케이스 적용
    • 인덱스 생성

     


    기능 구현 Flow(코드 로직)

    Step 1. 게시글 + 댓글 동시 저장 서비스 (Transaction)

    하나의 API 요청으로 두 개의 다른 엔티티(Post, Comment)를 각각의 테이블에 연달아 저장해야 한다. 이 과정은 중간에 에러가 나면 쪼개진 채로 저장되는 것을 막기 위해, 반드시 '둘 다 성공'하거나 '둘 다 실패(Rollback)'해야 하는 하나의 원자적(Atomic) 작업이어야 한다.

    • Client Request: 클라이언트가 게시글 정보와 초기 댓글 내용이 담긴 PostWithCommentRequestDto 데이터를 POST /api/posts/with-comment로 전송한다.
    • Validation (예외 케이스): 저장 로직을 수행하기 전, 요청 유저가 존재하는지, 제목과 내용이 같은 도배성 글인지 등의 비즈니스 룰을 먼저 검증한다.
    • Post Persistence: 전달받은 정보로 Post 엔티티를 생성하고 레포지토리를 통해 DB에 1차로 저장(save)하여 게시글의 고유 ID를 발급받는다.
    • Comment Persistence: 방금 발급된 Post 객체와 연결하여 Comment 엔티티를 생성한 뒤 2차로 DB에 저장한다.
    • Transaction Sync: 이 모든 과정은 하나의 @Transactional 안에서 묶여 실행되며, 단 하나라도 예외가 발생하면 즉시 이전 작업까지 전체 롤백 처리된다.
    // PostService.java 중 일부
    @Transactional // 쓰기 작업이며, 둘 다 성공해야만 DB에 반영됨!
    public Long createPostWithComment(PostWithCommentRequestDto request, String email) {
        // 1. 유저 검증
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new GeneralException("MEMBER404", "존재하지 않는 회원입니다."));
    
        // 2. 게시글(Post) 생성 및 1차 저장 (ID 발급)
        Post post = Post.builder()
                .title(request.getTitle())
                .content(request.getContent())
                .member(member)
                .build();
        Post savedPost = postRepository.save(post);
    
        // 3. 댓글(Comment) 생성 및 2차 저장 (방금 저장한 게시글 매핑)
        Comment comment = Comment.builder()
                .content(request.getCommentContent())
                .post(savedPost)
                .member(member)
                .build();
        commentRepository.save(comment);
    
        return savedPost.getId();
    }
    

     

    하나의 요청이 완벽하게 처리되거나, 아예 없었던 일로 돌아가는 무결성을 보장할 수 있게 되었다.

     

    Step 2. 비즈니스 예외 케이스 적용 및 전역 예외 처리 (Global Exception)

    클라이언트가 잘못된 값을 보내거나 비정상적인 접근을 할 때, 단순한 500 에러 대신 꼼꼼한 예외 케이스를 통과시키고 프론트엔드가 파싱하기 쉬운 공통 포맷으로 응답하도록 구성했다.

    • Exception Definition (예외 케이스): 서비스 단에서 "제목과 내용이 같으면 차단", "댓글에 특정 단어가 있으면 차단"과 같은 비즈니스 룰을 정의하고 빗나갈 경우 GeneralException을 던진다.
    • Exception Catch: 비즈니스 로직에서 던진 커스텀 예외나 DTO 검증 실패(@Valid) 예외가 발생하면, GlobalExceptionHandler가 이를 컨트롤러 밖에서 가로챈다.
    • Format Conversion: 길고 복잡한 에러 스택 대신, 프론트엔드에 필요한 핵심 메시지만 추출한다.
    • Response: 상태 코드 400(Bad Request)과 함께 통일된 JSON 포맷으로 클라이언트에게 반환한다.
    // PostService.java 예외 케이스 적용 부문
    if (request.getTitle().equals(request.getContent())) {
        throw new GeneralException("POST400", "게시글의 제목과 내용은 동일하게 작성할 수 없습니다.");
    }
    
    // GlobalExceptionHandler.java 중 일부
    @RestControllerAdvice
    public class GlobalExceptionHandler {
        @ExceptionHandler(GeneralException.class)
        public ApiResponse<String> handleGeneralException(GeneralException e) {
            return ApiResponse.onFailure(e.getErrorCode(), e.getMessage(), null);
        }
    }
    

    프론트엔드는 이제 400 상태 코드와 명확한 message를 받아 사용자에게 적절한 UI(모달, 경고창 등)를 띄워줄 수 있다.

     

    Step 3. 인덱스(Index) 생성을 통한 검색 성능 튜닝

    데이터가 무수히 많이 있을 때 '제목'으로 게시글을 검색하면, DB는 1번부터 끝까지 모두 뒤지는 풀 스캔(Full Table Scan)을 하게 된다. 이를 방지하기 위해 검색이 잦은 컬럼에 목차(Index)를 달아주었다.

    • Target Selection: 검색 조건으로 가장 많이 사용되는 title(제목) 컬럼을 타겟으로 선정했다.
    • Schema Application: Post 엔티티 상단에 @Table 속성을 부여하여, 애플리케이션이 실행될 때 DDL을 통해 데이터베이스에 인덱스가 자동 생성되도록 명령했다.
    // Post.java 중 일부
    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Table(name = "post", indexes = {
            @Index(name = "idx_post_title", columnList = "title")
    })
    public class Post {
        // ... 필드 내용
    }
    

    서버 실행 시 터미널에 create index 쿼리가 실행되며, 향후 수많은 데이터 속에서도 0.1초 만에 원하는 게시글을 찾아내는 검색 성능을 확보했다.

     


    코드에 사용된 문법(어노테이션) 및 객체 정리

    Step 1. 트랜잭션 단위의 데이터 동시 저장

    핵심 코드: PostService의 동시 저장 메서드와 @Transactional

    @Transactional 
    public Long createPostWithComment(PostWithCommentRequestDto request, String email) {
        Post savedPost = postRepository.save(post);
        commentRepository.save(comment);
        return savedPost.getId();
    }
    


    🔍 주요 어노테이션 및 객체 분석

    • @Transactional: 데이터베이스의 논리적 작업 단위를 지정한다. 메서드 내부의 여러 DB 삽입 작업이 하나의 묶음으로 처리되며, 중간에 런타임 예외(RuntimeException)가 발생하면 스프링이 알아서 이전 DB 작업까지 모두 롤백(Rollback)시켜 데이터의 무결성(All-or-Nothing)을 완벽하게 보호한다.

    Step 2. 예외 케이스 처리 및 전역 핸들러

    핵심 코드: GlobalExceptionHandler 클래스

    @RestControllerAdvice
    public class GlobalExceptionHandler {
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ApiResponse<String> handleValidationException(MethodArgumentNotValidException e) {
            String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
            return ApiResponse.onFailure("COMMON400", errorMessage, null);
        }
    }
    

     

    🔍 주요 어노테이션 및 객체 분석

    • @RestControllerAdvice: 스프링 애플리케이션 전역에서 발생하는 예외를 감지하고 처리하는 AOP(관점 지향 프로그래밍) 기반의 어노테이션이다. 각 컨트롤러마다 try-catch를 적지 않아도 코드를 깔끔하게 유지할 수 있다.
    • @ExceptionHandler: 특정 예외 클래스(예: GeneralException.class)가 발생했을 때 해당 메서드가 에러를 낚아채서 실행되도록 매핑해 준다.
    • @ResponseStatus: 에러 발생 시 클라이언트에게 반환할 HTTP 상태 코드를 강제로 지정한다. (예: 200 OK 대신 400 Bad Request 반환)

     

    Step 3. 데이터베이스 인덱스 설정

    핵심 코드: Post 엔티티의 인덱스 매핑 부분

    @Table(name = "post", indexes = {
            @Index(name = "idx_post_title", columnList = "title")
    })
    public class Post { ... }
    

     

    🔍 주요 어노테이션 및 객체 분석

    • @Table(indexes = ...): JPA를 통해 DB 스키마를 자동 생성할 때, 특정 컬럼에 데이터베이스 인덱스(검색용 목차)를 걸도록 명령한다.
    • @Index: 생성할 인덱스의 이름(name)과 타겟이 될 테이블의 컬럼명(columnList)을 구체적으로 지정한다.

     


    실행 결과

    🟢 1. 정상 작동 테스트

    우선 Authorize를 주고 /api/posts/with-comment 에서 정상 작동하는지 테스트했다.

    정상 작동 테스트
    정상 작동 실행 결과

     

     

    🔴 2. 예외 케이스 테스트 1 (도배 방지)

     다음은 제목과 내용이 같으면 튕겨낸다는 조건이 잘 작동하는지 확인했다.

    제목 = 내용
    실행 결과

     

    🔴 3. 예외 케이스 테스트 2 (댓글 금지어 차단 & 트랜잭션 롤백)

    가장 중요한 트랜잭션 롤백 테스트이다. 댓글에 금지어를 넣어서 댓글 저장을 실패하게 만든다.

    '광고'가 들어간 댓글 실행
    실행 결과

     

     

    🔴 4. DTO 검증 실패 테스트 (@Valid)

    DTO에 걸어둔 @NotBlank 조건이 잘 작동하는지 확인

    제목 비워두고 실행
    실행 결과

    트러블 슈팅 (Troubleshooting)

    Issue 1. 게시글은 저장되고 댓글은 날아가는 사태

    [현상] 초기 개발 단계에서 @Transactional을 붙이지 않은 채 동시 생성 API를 테스트했다. 일부러 비즈니스 예외 케이스 조건에 걸리게 하거나 댓글 길이를 초과시켜 에러를 유발해 보았는데, 서버에 에러 로그가 찍혔음에도 불구하고 DB를 열어보니 '게시글' 데이터는 버젓이 저장되어 있는 현상을 발견했다.

    [원인 분석] 스프링과 DB는 코드를 위에서 아래로 순차적으로 실행하며 즉시 영속화(Commit)한다. postRepository.save()는 성공하여 DB에 저장되었으나, 이어진 댓글 저장 과정에서 예외가 터지며 로직이 중단되었다. 하나의 트랜잭션으로 묶지 않았기에 앞선 작업의 결과가 취소되지 않고 DB에 그대로 남아 데이터 정합성이 처참하게 깨진 것이다.

    [해결 방안 및 배운 점] 해당 서비스 메서드 상단에 @Transactional을 명시했다. 동일한 테스트를 진행하자 예외가 발생하는 순간 스프링이 즉시 롤백을 수행하여, 미리 저장되었던 게시글 데이터까지 DB에서 깔끔하게 원상 복구시키는 것을 확인했다. 백엔드 시스템에서 트랜잭션 경계 설정이 얼마나 중요한지 확인한 귀중한 경험이었다.

     

    Issue 2. 500 에러와 @Valid의 늪

    [현상] 프론트엔드에서 필수 값을 누락하여 POST 요청을 보냈을 때, DTO에 분명 @Valid와 친절한 message를 적어두었음에도 불구하고 클라이언트에게는 그저 500 Internal Server Error와 수십 줄의 에러 스택만 날아왔다.

    [원인 분석] @Valid가 데이터 검증에 실패하면 스프링은 내부적으로 MethodArgumentNotValidException을 발생시킨다. 하지만 이 예외를 가로채서 클라이언트가 이해할 수 있는 형태(400 Bad Request)로 바꿔주는 '핸들러'가 없었기 때문에, 프레임워크의 기본 동작에 따라 최상위 서버 에러(500)로 퉁쳐져서 반환된 것이다.

    [해결 방안 및 배운 점] @RestControllerAdvice를 활용해 전역 예외 처리기를 구축했다. @ExceptionHandler로 해당 예외를 낚아챈 뒤, getBindingResult().getAllErrors().get(0).getDefaultMessage() 코드를 통해 DTO에 작성해 두었던 친절한 메시지("제목은 필수입니다.")만 추출하여 응답하도록 리팩토링했다. 에러 코드 하나, 메시지 한 줄이 프론트엔드의 사용자 경험(UX)을 결정짓는다는 것을 다시금 깨달았다.

제목 없는 코딩 블로그