SPRING

[Spring Boot + JDBC] 일정 관리 웹 리팩토링 – 쿼리, 예외, 컬럼 분리

도원좀비 2025. 3. 24. 17:56

유지보수성과 가독성을 높이기 위해 진행한 리팩토링 과정을 정리

🚨 리팩토링 전 문제점

처음 구현 당시에는 빠르게 기능을 완성하는 데 집중했기 때문에 아래와 같은 구조적인 문제들이 있었다.

  • 쿼리문이 JdbcTemplate 내부에 하드코딩돼 있음
  • "author_id", "title" 같은 컬럼명이 문자열로 여기저기 흩어져 있음
  •  예외 메시지, 상태 코드가 컨트롤러나 서비스에서 직접 작성되어 중복 발생

 

🎯 리팩토링 목표

  • 쿼리문을 별도 클래스로 분리해 중복 제거 및 재사용성 향상
  • 컬럼명을 enum으로 상수화하여 오타 방지 및 리팩토링 효율 향
  • 예외 코드와 메시지를 ExceptionCode enum으로 통합 관리

1️⃣ 컬럼 이름 enum 분리

리팩토링 전 : 쿼리문에 직접 테이블명을 입력해서 구현

리팩토링 후 :  중복되는 쿼리문을 enum으로 관리하여 컴파일 타임 오류 감소 + 유지보수 성능 향상

더보기
package com.example.scheduler.repository.column;

import lombok.Getter;

/**
 * 일정(schedule) 테이블의 컬럼명을 정의한 Enum
 */
@Getter
public enum ScheduleColumns {
    SCHEDULE_ID("schedule_id"),       // 일정 ID (PK)
    TITLE("title"),                   // 제목
    CONTENT("content"),               // 내용
    AUTHOR_ID("author_id"),           // 작성자 ID (FK)
    PASSWORD("password"),             // 비밀번호 (수정/삭제 시 검증용)
    CREATE_DATE("create_date"),       // 생성일시
    UPDATED_DATE("updated_date");     // 수정일시

    private final String columnName;

    ScheduleColumns(String columnName) {
        this.columnName = columnName;
    }
}

2️⃣ 쿼리문 분리

JdbcTemplateScheduleRepository에서 각 메서드에서 구현한 쿼리문을 query 패키지의 하위 클래스로 분리

더보기
package com.example.scheduler.repository.query;

import com.example.scheduler.repository.column.AuthorColumns;
import static com.example.scheduler.repository.column.ScheduleColumns.*;

/**
 * 일정(schedule) 관련 SQL 쿼리를 정의한 클래스
 * - 작성자(author) 테이블과의 조인 포함
 * - JdbcTemplateScheduleRepository에서 사용
 */
public class ScheduleQuery {

    // 전체 일정 조회 + 작성자 이름 포함 (최신순 정렬)
    public static String findAllWithAuthor() {
        return "SELECT s." + TITLE.getColumnName() + ", " +
                "s." + CONTENT.getColumnName() + ", " +
                "s." + UPDATED_DATE.getColumnName() + ", " +
                "a." + AuthorColumns.NAME.getColumnName() + " AS author_name " +
                "FROM schedule s " +
                "JOIN author a ON s." + AUTHOR_ID.getColumnName() + " = a." + AUTHOR_ID.getColumnName() + " " +
                "ORDER BY s." + UPDATED_DATE.getColumnName() + " DESC";
    }
}

3️⃣ 예외 코드 Enum화 + 커스텀 익셉션

더보기

(ExceptionCode.java)

package com.example.scheduler.exception.exceptionCode;

import lombok.Getter;

/**
 * 도메인 및 공통 예외 상황에 대한 코드 정의 Enum
 * - 상태 코드, 에러 코드 문자열, 메시지를 포함
 */
@Getter
public enum ExceptionCode {
    // Author 관련
    AUTHOR_NOT_FOUND(404, "AUTHOR_001", "작성자를 찾을 수 없습니다."),
    AUTHOR_INVALID_INPUT(400, "AUTHOR_002", "이름과 이메일을 입력해주세요."),
    AUTHOR_UPDATE_FAILED(404, "AUTHOR_003", "작성자 수정에 실패했습니다."),
    AUTHOR_DELETE_FAILED(404, "AUTHOR_004", "작성자 삭제에 실패했습니다."),
    AUTHOR_EMAIL_DUPLICATED(409, "AUTHOR_005", "이미 사용 중인 이메일입니다."),

    // Schedule 관련
    SCHEDULE_NOT_FOUND(404, "SCHEDULE_001", "존재하지 않는 일정입니다."),
    SCHEDULE_INVALID_INPUT(400, "SCHEDULE_002", "제목과 내용을 입력해주세요."),
    SCHEDULE_UPDATE_FAILED(404, "SCHEDULE_003", "일정 수정 실패"),
    SCHEDULE_DELETE_FAILED(404, "SCHEDULE_004", "일정 삭제 실패"),

    // 인증 관련
    UNAUTHORIZED_AUTHOR(403, "AUTH_001", "작성자만 일정을 수정할 수 있습니다."),
    PASSWORD_MISMATCH(401, "AUTH_002", "비밀번호가 일치하지 않습니다."),

    // 공통
    PAGE_OUT_OF_RANGE(400, "COMMON_001", "요청한 페이지 범위가 유효하지 않습니다.");

    private final int status;
    private final String code;
    private final String message;

    ExceptionCode(int status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
}

 

(CustomException.java)

package com.example.scheduler.exception;

import com.example.scheduler.exception.exceptionCode.ExceptionCode;
import lombok.Getter;

/**
 * 사용자 정의 예외 클래스
 * - 예외 코드(Enum)를 기반으로 상세한 예외 정보를 전달
 */
@Getter
public class CustomException extends RuntimeException{
    // 예외 코드 (상세한 상태, 코드, 메시지 포함)
    private final ExceptionCode errorCode;

    /**
     * 예외 생성자
     *
     * @param errorCode 예외 코드 Enum
     */
    public CustomException(ExceptionCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

}

 

(GlobalExceptionHandler.java)

package com.example.scheduler.exception;

import com.example.scheduler.exception.exceptionCode.ExceptionCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ResponseStatusException;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;

/**
 * 전역 예외 처리 클래스
 * - ControllerAdvice를 통해 모든 예외를 공통 포맷으로 처리
 */
@ControllerAdvice
public class GlobalExceptionHandler {

    // 예외 발생 시 현재 시각 반환 (yyyy-MM-dd HH:mm:ss 포맷)
    private String getNow() {
        return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-mm-dd HH:mm:ss"));
    }

    /**
     * CustomException 처리
     *
     * @param e 커스텀 예외
     * @return ExceptionResponse 객체와 상태 코드
     */
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ExceptionResponse> handleCustomException(CustomException e) {
        ExceptionCode error = e.getErrorCode();

        ExceptionResponse response = new ExceptionResponse(
                error.getStatus(),
                error.getCode(),
                error.getMessage(),
                getNow()
        );

        return new ResponseEntity<>(response, HttpStatus.valueOf(error.getStatus()));
    }
}

4️⃣ 도메인 주도 설계

리팩토링 전 : 서비스에서 전부 해결

리팩토링 후 : 도메인 객체가 스스로 유효성 검증

더보기

리팩토링 전 : (ScheduleServiceImpl.java)

@Override
public ScheduleResponseDto saveSchedule(ScheduleRequestDto dto) {
    if (!authorRepository.existsByAuthorId(dto.getAuthorId())) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "존재하지 않는 작성자입니다.");
    }

    Schedule schedule = new Schedule(dto.getTitle(), dto.getContent(), dto.getAuthorId(), dto.getPassword());
    return scheduleRepository.saveSchedule(schedule);
}

리팩토링 후 : (ScheduleServiceImpl.java).

// 일정 저장
@Override
public ScheduleResponseDto saveSchedule(ScheduleRequestDto dto) {
    // 작성자 존재 확인
    Author author = authorRepository.findByAuthorId(dto.getAuthorId())
            .orElseThrow(() -> new CustomException(ExceptionCode.AUTHOR_NOT_FOUND));

    author.validateExistence();

    // 일정 도메인 생성
    Schedule schedule = new Schedule(dto.getTitle(), dto.getContent(), dto.getAuthorId(), dto.getPassword());
    return scheduleRepository.saveSchedule(schedule);
}

 

(Scherdule.java)

// 작성자 일치 유효성 검사
public void validateAuthor(Long authorId) {
    if (!this.authorId.equals(authorId)) {
        throw new CustomException(ExceptionCode.UNAUTHORIZED_AUTHOR);
    }
}

// 비밀번호 일치 유효성 검사
public void validatePassword(String password) {
    if (!this.password.equals(password)) {
        throw new CustomException(ExceptionCode.PASSWORD_MISMATCH);
    }
}

🔍 테스트 & 디버깅 중 발생한 문제

일정 삭제 시 비밀번호가 맞는데도 "비밀번호가 일치하지 않습니다"라는 예외가 발생
디버깅해보니...

if (this.password.equals(input)) {
    throw new CustomException(...);
}
 

→ ❗ 비교 조건 반대로 작성한 실수 발견
→ !this.password.equals(input) 으로 수정 후 정상 동작


 

🧩마무리

이번 리팩토링은 기능적으로 큰 변화는 없지만,
코드의 안정성과 확장성, 협업 효율성을 모두 높이는 중요한 개선 작업이었다.


📌 GitHub 저장소: https://github.com/sukh115/scheduler

 

GitHub - sukh115/scheduler

Contribute to sukh115/scheduler development by creating an account on GitHub.

github.com