이번 프로젝트를 하기 전에, JDBC에 대해 알아보았고, JDBC Template 을 도입하여 프로젝트를 구현했으며 DB 관련 로직을 처리해야 하는 코드가 많았기 때문에 인터페이스와 구현체를 통해서 전반적인 과제를 진행하였다. JDBC → JDBC Template → 인터페이스와 구현체에 대해서 차례로 알아보았다.
🗒️JDBC
JDBC(Java Database Connectivity)는 Java에서 데이터베이스(DB)에 접속하고, SQL을 실행하고, 결과를 받아오는 표준 API이다.
✅ JDBC의 구조
- 드라이버 로드
- DB 연결 (Connection)
- SQL 작성 및 실행 (Statement or PreparedStatement)
- 결과 조회 (ResultSet)
- 자원 해제 (close)
✅ JDBC의 단점
반복되는 코드가 많고 (try-catch-finally, close 등) SQL 작성이 어렵고 길다. 또한 트랜잭션 처리를 직접 해야하고, 코드가 지저분하게 느껴진다. 따라서 JDBC의 단점을 약간 개선해주는 JDBC Template 을 도입하여 프로젝트를 진행했다.
📦JdbcTemplate이란?
스프링에서 JDBC를 쉽게 사용하도록 도와주는 유틸 클래스
SQL을 직접 쿼리문으로 작성해서 실행할 수 있게 도와준다.
JDBC를 사용하는 반복 작업을 대신 처리해주는 도우미 클래스로 JDBC의 불편한 점을 많이 해소해준다.
JDBCTemplate은 커넥션 연결~자원 해제까지 전부 내부에서 자동 처리되고 SQL 실행과 파라미터 바인딩도 간결해진다.
private final JdbcTemmplate jdbcTemplate
public JdbcTemplateScheduleRepository(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
스프링이 JdbcTemplateScheduleRepository를 만들 때 DataSource를 자동으로 주입해준다.
DataSource는 DB 연결 정보를 담고 있는 객체고, 이걸 이용해서 JdbcTemplate 객체를 새로 만들어서 필드로 전달한다.
즉, DataSource는 DB 연결선이고 "JdbcTemplate"은 그 연결선을 통해 SQL을 실행하는 도구인 것이다.
메서드 | 설명 |
---|---|
query() |
SELECT 실행, 결과를 리스트나 객체로 매핑 |
queryForObject() |
하나의 결과만 가져오는 SELECT |
update() |
INSERT , UPDATE , DELETE 실행 |
batchUpdate() |
여러 개의 SQL을 일괄 처리 |
execute() |
DDL 쿼리 또는 트랜잭션 제어 |
따라서 해당 내용을 이용해서 queryForObject()
의 경우, 선택 일정 조회에 사용했고, query()
를 매핑하는 코드에 사용하였다.
🖨️ 인터페이스 +구현체 구조란 ?
인터페이스(Interface)는 "역할(Role)"을 정의하고, 구현체(Impl) 는 그 역할을 실제로 어떻게 수행할 지를 정의한다. 즉, 사용하는 쪽은 "역할"에만 의존하고, "구현"은 나중에 바꿔 끼울 수 있도록 만드는 구조다.
처음에는 인터페이스와 구현체 구조를 이용해서 구현하지 않고, 모든 내용을 레포지토리와 서비스에서 관리했는데 JDBC는 SQL 실행 및 결과 조회까지 코드 상에서 이루어지기 때문에 레포지토리의 부피가 너무 크다는 생각이 들어서 해당 내용을 변경해서 구현했다. 따라서 최종 코드는 다음과 같다.
예시로 일정 목록조회와 관련된 JdbcTemplateScheduleRepository
와 ScheduleRepository
코드만 첨부하였다.
▶️ ScheduleRepository (인터페이스)
public interface ScheduleRepository {
ScheduleDto createSchedule(Schedule schedule); // 일정 생성
List<ScheduleListDto> getAllSchedules(Long userId, LocalDateTime updatedAt, PageRequestDto pageRequestDto); // 일정 전체 조회 (userId)
long countSchedules(Long userId, LocalDateTime updatedAt); // 일정 수 조회
ScheduleDetailDto getDetailSchedule(long scheduleId); // 일정 상세 조회
ScheduleDto updateSchedule(Schedule schedule); // 일정 수정
Schedule findById(Long scheduleId); // 일정 ID로 조회
Long findUserIdByName(String userName); // 작성자 이름으로 사용자 ID 조회
void deleteById(Long scheduleId); // 일정 ID로 삭제
}
▶️ JdbcTemplateScheduleRepository (구현체)
/**
* 일정 목록 조회 (페이지네이션 포함)
* - 작성자 ID(userId)와 수정일(updatedAt)을 조건으로 일정 목록을 조회
* - 조건이 존재할 경우 WHERE 절에 동적으로 추가됨
* - 최신 수정일 기준 내림차순 정렬
* - LIMIT와 OFFSET을 활용해 페이지네이션 처리
*
* @param userId 작성자 ID (Optional)
* @param updatedAt 수정일 기준 (Optional)
* @param pageRequestDto 페이지 번호 및 크기를 담은 객체
* @return 조회된 일정 리스트 (ScheduleListDto 목록)
*/
@Override
public List<ScheduleListDto> getAllSchedules(Long userId, LocalDateTime updatedAt, PageRequestDto pageRequestDto) {
StringBuilder sql = new StringBuilder("""
SELECT s.id, u.name, u.email, s.todo, s.updated_at
FROM schedule s
JOIN users u ON s.user_id = u.id
WHERE 1=1
""");
List<Object> params = new ArrayList<>();
// 작성자 ID 필터링 조건
if (userId != null) {
sql.append(" AND s.user_id = ?");
params.add(userId);
}
// 수정일 필터링 조건 (날짜만 비교)
if (updatedAt != null) {
sql.append(" AND DATE(s.updated_at) = DATE(?)");
params.add(updatedAt);
}
// 정렬 및 페이징 처리
sql.append(" ORDER BY s.updated_at DESC LIMIT ? OFFSET ?");
params.add(pageRequestDto.size()); // LIMIT
params.add(pageRequestDto.offset()); // OFFSET = page * size
// 결과 매핑하여 반환
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new ScheduleListDto(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email"),
rs.getString("todo"),
rs.getTimestamp("updated_at").toLocalDateTime()
), params.toArray());
}
💫 Trouble Shooting : 커스텀 예외 처리
@Valid를 사용해서 유효성 검사를 진행할 때 처음에는 다음과 같이 GlobalExceptionHandler
에 구현했기 때문에 Dto에 내가 설정한 에러 메세지들이 나오지 않았다. 따라서 GlobalExceptionHandler
를 변경할 수 밖에 없었다.
public record ScheduleCreateDto(
@NotBlank(message = "작성자의 이름을 추가해주세요.")
String userName,
@NotBlank(message = "이메일은 공백일 수 없습니다.") @Email(message = "이메일 형식을 지켜주세요")
String email,
@NotBlank(message = "할 일은 공백일 수 없습니다.") @Size(max = 200, message = "최대 200자까지 가능합니다.")
String todo,
@NotBlank(message = "비밀번호는 공백일 수 없습니다.")
String password
) {
}
처음에 구현했던 내용은 다음과 같다. log를 찍어서 예외를 찍어보고, 공통 응답형식에 있는 에러 코드를 반환하였다. 그러나 해당 내용으로는 DTO 유효성 검증 실패 시 발생하는 예외 메세지가 처리되지 않았다.
@ExceptionHandler(value = {MethodArgumentNotValidException.class})
public ResponseEntity<ApiResponseDto<?>> handlerMethodArgumentNotValidException(Exception e) {
log.error(
"handlerMethodArgumentNotValidException() in GlobalExceptionHandler throw MethodArgumentNotValidException : {}",
e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponseDto.fail(ErrorCode.BAD_REQUEST));
}
따라서 밑의 내용과 같이 코드를 구현했다. 위의 코드는 BAD_REQUEST와 관련된 내용을 처리하고, 밑의 코드는 커스텀 에러를 반환한다고 생각했다.
/**
* DTO 유효성 검증 실패 시 발생하는 예외 처리
* - @Valid 어노테이션이 붙은 DTO 필드 검증 실패 시 발생
* - 예: @NotBlank, @Size 등 제약 조건을 위반한 경우
* - 사용자가 설정한 message 값을 추출하여 클라이언트에게 응답
*/
@ExceptionHandler(value = {MethodArgumentNotValidException.class})
public ResponseEntity<ApiResponseDto<?>> handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error("Validation failed: {}", e.getMessage());
// 첫 번째 필드 에러 메시지 추출 (여러 개일 경우 하나만)
String errorMessage = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(fieldError -> fieldError.getDefaultMessage())
.orElse("잘못된 요청입니다.");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponseDto.fail(400, errorMessage)); // 커스텀 메시지 적용
}
그러나 빌드가 되지 않았다 ,,, 다음과 같은 오류를 마주하고 말았다 ㅠㅠ
이처럼 두 개의 @ExceptionHandler(MethodArgumentNotValidException.class)
가 동시에 실행되지 않는 이유는 Spring의 예외 처리 메커니즘이 "단일 매핑 방식"으로 동작하기 때문이었다. Spring은 특정 예외가 발생했을 때, 해당 예외를 처리할 수 있는 단 하나의 @ExceptionHandler
메서드만 실행한다. 두 개의 핸들러가 있으면 Spring이 어떤 걸 실행할지 모호해져서 충돌이 생긴 것이다.
Spring은 예외가 발생했을 때, 아래와 같은 순서로 처리 메서드를 찾고 실행한다.
순서 | 설명 |
---|---|
1 | @ControllerAdvice 또는 해당 Controller 클래스 안에서 |
2 | @ExceptionHandler(ExceptionType.class) 로 선언된 메서드 찾음 |
3 | 가장 먼저 매칭되는 단 하나의 메서드만 실행함 |
4 | 여러 개가 같은 예외 타입을 처리하면, Spring은 모호하다고 판단해서 오류 발생 |
✅ 이렇게 설계된 이유 ? : 하나의 예외는 하나의 책임자만 처리하는 단일책임의 원칙이 바탕이 되기 때문 !
- 중복 예외 핸들러가 있으면 예외를 어떻게 처리할지 예측이 어려움
- 유지보수, 테스트, 디버깅 측면에서 매우 불안정해짐
- Spring은 이런 혼란을 방지하기 위해 단일 예외 핸들러만 허용
Factory method 'handlerExceptionResolver' threw exception with message:
Ambiguous @ExceptionHandler method mapped for
[ExceptionHandler{exceptionType=org.springframework.web.bind.MethodArgumentNotValidException, mediaType=*/*}]:
에러 메세지에서도 Ambiguous mapping conflict
(모호한 매핑 충돌) 을 지적하고 있다. 따라서 위의 에러처리를 지우고 커스텀 메세지를 반환하는 메서드 하나만 남겼더니 그 후 스프링 부트가 잘 실행되었다.
느낀 점
각 코드들의 계층 분리를 명확하게 하고, 단일책임원칙을 지킬 수 있도록 설계하기 위해서 노력했다. 또한 JPA에서 당연하게 생각했던 레포지토리 단의 기능들을 직접 구현하면서 “왜” 를 더 생각해보게 되는 것 같았다. 또한 에러 핸들링에 대해 더 밀도있게 공부할 수 있는 시간이 되어서 유익했다.
'Backend > Spring' 카테고리의 다른 글
적절한 책임 분리 - Helper Class (0) | 2025.04.01 |
---|---|
BaseTimeEntity와 JPA Auditing: 왜 사용할까? (0) | 2025.04.01 |
JPA와 영속성 컨텍스트 (0) | 2025.03.31 |
JWT 토큰 인증 이란? (0) | 2025.03.25 |
쿠키 세션 토큰 비교하기 (0) | 2025.03.24 |