Odit 백엔드의 첫 기능을 만들기 전에 모든 도메인에서 공통으로 사용할 기반부터 정리했다.
이번에 구현한 것은 Entity의 생성·수정 시간을 관리하는 BaseEntity와 API에서 발생한 예외를 일정한 형식으로 전달하기 위한 공통 에러 응답이다.
두 기능 모두 사용자에게 직접 보이는 기능은 아니다.
하지만 도메인이 늘어난 뒤에 추가하려고 하면 이미 작성된 Entity와 Controller를 반복해서 수정해야 한다. 그래서 회원가입이나 커플 스페이스 같은 본격적인 기능을
구현하기 전에 공통 규칙을 먼저 만들기로 했다.
이번 글에서는 다음 내용을 정리해보려고 한다.
BaseEntity를 만든 이유- JPA Auditing으로 생성·수정 시간을 관리하는 방법
- 에러 응답 형식을 공통화한 이유
ErrorCode,OditException,GlobalExceptionHandler의 역할- Bean Validation 실패와 예상하지 못한 예외를 처리하는 방법
- 현재 구조의 한계와 이후 개선할 부분
Entity마다 반복되는 시간 필드
Odit에서는 앞으로 여러 Entity가 만들어질 예정이다.
예를 들면 사용자, 커플 스페이스, 다녀온 장소, 가고 싶은 장소, 사진 정보 등이 있다.
이 데이터들은 대부분 언제 생성되었고 마지막으로 언제 수정되었는지 확인할 수 있어야 한다.
Entity마다 다음 코드를 직접 작성할 수도 있다.
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
하지만 같은 필드와 저장 로직을 모든 Entity에서 반복하면 몇 가지 문제가 생긴다.
- Entity마다 컬럼 이름이나 설정이 달라질 수 있다.
- 생성 시간과 수정 시간을 넣는 코드가 반복된다.
- 시간 관리 정책을 변경할 때 여러 Entity를 함께 수정해야 한다.
- 누락된 Entity가 생겨도 알아차리기 어렵다.
공통 속성이라면 한곳에서 규칙을 정의하고 각 Entity가 상속받도록 하는 편이 일관성을 유지하기 좋다.
그래서 생성 시간과 수정 시간을 BaseEntity로 분리했다.
BaseEntity 구현
현재 BaseEntity는 다음과 같다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
}
각 어노테이션의 역할을 하나씩 정리해보면 다음과 같다.
@MappedSuperclass
BaseEntity 자체는 독립적으로 저장되는 Entity가 아니다.
@MappedSuperclass를 사용하면 BaseEntity를 상속한 Entity의 테이블에 createdAt, updatedAt 컬럼이 포함된다.
즉, base_entity라는 별도의 테이블이 생기는 것이 아니라 공통 매핑 정보만 자식 Entity에 전달한다.
Odit에서 생성 시간과 수정 시간은 독립된 도메인 데이터가 아니라 여러 Entity가 공통으로 가지는 속성이기 때문에 이 방식이 적합하다고 판단했다.
@EntityListeners(AuditingEntityListener.class)
JPA Entity의 저장과 수정 이벤트를 감지해 Auditing 필드를 채울 수 있도록 리스너를 등록한다.
Entity가 처음 저장될 때는 @CreatedDate가 붙은 필드에 시간이 들어가고, Entity가 수정될 때는 @LastModifiedDate가 붙은 필드가 갱신된다.
이 덕분에 Service에서 다음과 같은 코드를 직접 작성하지 않아도 된다.
entity.setCreatedAt(LocalDateTime.now());
entity.
setUpdatedAt(LocalDateTime.now());
시간 기록을 비즈니스 로직에서 분리할 수 있고, 개발자가 값을 넣는 것을 빠뜨릴 가능성도 줄어든다.
@CreatedDate와 @LastModifiedDate
createdAt은 데이터가 처음 생성된 시점을 나타낸다.
한 번 저장된 생성 시간이 이후 수정 과정에서 바뀌면 안 되기 때문에 updatable = false로 설정했다.
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
updatedAt은 마지막 수정 시점을 나타내므로 Entity가 변경될 때마다 갱신될 수 있어야 한다.
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
두 필드 모두 정상적인 데이터라면 반드시 값이 존재해야 하므로 nullable = false를 적용했다.
외부에서 임의로 시간을 변경하지 못하도록 Setter는 만들지 않고 조회에 필요한 Getter만 제공했다.
JPA Auditing 활성화
Entity에 Auditing 어노테이션을 붙이는 것만으로는 동작하지 않는다.
애플리케이션에서 JPA Auditing 기능을 활성화해야 한다.
현재는 애플리케이션 진입점에 @EnableJpaAuditing을 추가했다.
@SpringBootApplication
@EnableJpaAuditing
public class OditApiApplication {
public static void main(String[] args) {
SpringApplication.run(OditApiApplication.class, args);
}
}
이제 앞으로 생성할 Entity는 BaseEntity를 상속하는 것만으로 생성 시간과 수정 시간을 공통으로 관리할 수 있다.
@Entity
public class User extends BaseEntity {
// User의 필드와 비즈니스 로직
}
이번 BaseEntity에는 식별자인 id를 포함하지 않았다.
생성·수정 시간은 대부분의 Entity에서 같은 의미와 정책을 가지지만, 식별자는 도메인이나 저장 방식에 따라 타입과 생성 전략이 달라질 수 있기 때문이다. 공통화의 범위를
넓히기보다 지금 확실하게 동일한 규칙만 묶었다.
Auditing을 사용할 때 주의할 점
JPA Auditing은 Entity의 생명주기 이벤트를 기준으로 동작한다.
따라서 JPA가 Entity의 변경을 감지해 수정하는 일반적인 방식에서는 updatedAt이 갱신되지만, JPQL 벌크 업데이트나 직접 실행한 SQL은 Entity 생명주기를
거치지 않는다.
나중에 성능을 위해 벌크 업데이트를 도입한다면 updatedAt도 함께 변경할지 별도로 결정해야 한다.
또한 현재는 LocalDateTime을 사용하고 있다. Odit이 한국 시간대만 사용하는 초기 단계에서는 충분하지만, 서비스 지역이 넓어지면 서버와 데이터베이스의 시간대 정책을
명확히 하고 Instant 사용 여부도 검토할 필요가 있다.
공통 에러 응답이 필요한 이유
BaseEntity가 데이터의 공통 규칙을 만든다면, 공통 에러 응답은 API 실패의 공통 규칙을 만든다.
공통 처리가 없다면 Controller마다 서로 다른 형태의 에러를 반환하기 쉽다.
{
"message": "사용자를 찾을 수 없습니다."
}
{
"error": "NOT_FOUND",
"reason": "존재하지 않는 사용자입니다."
}
같은 종류의 실패인데도 응답 구조가 다르면 클라이언트는 API마다 별도의 예외 처리를 해야 한다.
또한 HTTP 상태 코드만으로는 정확한 실패 원인을 구분하기 어렵다.
예를 들어 404 Not Found는 사용자를 찾지 못한 경우에도, 커플 스페이스를 찾지 못한 경우에도 사용할 수 있다. 클라이언트가 상황에 맞는 메시지나 화면을 보여주려면
애플리케이션에서 정의한 에러 코드가 필요하다.
그래서 Odit의 기본 에러 응답을 다음 세 필드로 정했다.
{
"code": "COMMON_001",
"message": "잘못된 요청입니다.",
"errors": []
}
code: 클라이언트가 실패 원인을 식별하기 위한 코드message: 사용자 또는 개발자가 확인할 수 있는 기본 메시지errors: 입력값 검증처럼 세부 오류가 여러 개일 때 사용하는 목록
일반적인 비즈니스 예외에서는 errors가 빈 배열이고, 요청값 검증에 실패했을 때는 필드별 오류가 담긴다.
ErrorCode로 에러 정책 모으기
에러의 HTTP 상태, 식별 코드, 기본 메시지는 ErrorCode enum에서 함께 관리한다.
@Getter
public enum ErrorCode {
COMMON_BAD_REQUEST(
HttpStatus.BAD_REQUEST,
"COMMON_001",
"잘못된 요청입니다."
),
COMMON_UNAUTHORIZED(
HttpStatus.UNAUTHORIZED,
"COMMON_002",
"인증이 필요합니다."
),
COMMON_FORBIDDEN(
HttpStatus.FORBIDDEN,
"COMMON_003",
"접근 권한이 없습니다."
),
COMMON_NOT_FOUND(
HttpStatus.NOT_FOUND,
"COMMON_004",
"요청한 리소스를 찾을 수 없습니다."
),
COMMON_INTERNAL_SERVER_ERROR(
HttpStatus.INTERNAL_SERVER_ERROR,
"COMMON_005",
"서버 오류가 발생했습니다."
);
}
문자열과 상태 코드를 예외가 발생하는 위치마다 직접 작성하지 않고 enum으로 모으면 다음과 같은 장점이 있다.
- 같은 실패 상황에 같은 HTTP 상태와 코드를 사용할 수 있다.
- 에러 코드의 중복이나 오타를 줄일 수 있다.
- 어떤 에러가 존재하는지 한곳에서 확인할 수 있다.
- 메시지나 상태 정책이 바뀌어도 수정 범위가 작다.
현재는 모든 도메인에서 사용할 수 있는 COMMON 에러만 정의했다.
앞으로 회원과 커플 스페이스 기능을 구현하면서 USER_001, COUPLE_001처럼 도메인별 에러 코드를 추가할 예정이다.
OditException으로 비즈니스 실패 표현하기
서비스 로직에서 처리할 수 없는 비즈니스 실패가 발생하면 OditException을 던진다.
@Getter
public class OditException extends RuntimeException {
private final ErrorCode errorCode;
public OditException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
예를 들어 요청한 리소스를 찾지 못한 상황은 다음과 같이 표현할 수 있다.
throw new OditException(ErrorCode.COMMON_NOT_FOUND);
OditException은 HTTP 응답을 직접 만들지 않는다.
비즈니스 로직에서는 어떤 실패가 발생했는지만 ErrorCode로 전달하고, 그 실패를 어떤 HTTP 응답으로 바꿀지는 예외 처리 계층이 담당하도록 역할을 나눴다.
초기에는 하나의 공통 예외로 시작한다. 도메인이 복잡해져 예외 타입 자체로 구분할 필요가 생기기 전까지는 ErrorCode만으로도 실패 원인을 충분히 표현할 수 있다고
판단했다.
ErrorResponse로 응답 형식 고정하기
클라이언트에 전달되는 실제 응답 형식은 ErrorResponse가 담당한다.
@Getter
@AllArgsConstructor
public class ErrorResponse {
private final String code;
private final String message;
private final List<ValidationError> errors;
public static ErrorResponse of(ErrorCode errorCode) {
return new ErrorResponse(
errorCode.getCode(),
errorCode.getMessage(),
List.of()
);
}
public static ErrorResponse of(
ErrorCode errorCode,
List<ValidationError> errors
) {
return new ErrorResponse(
errorCode.getCode(),
errorCode.getMessage(),
errors
);
}
public record ValidationError(String field, String message) {
}
}
정적 팩터리 메서드인 of를 통해 일반 에러와 검증 에러의 생성 방법을 구분했다.
일반 에러에서도 errors를 null로 반환하지 않고 빈 배열로 반환한다. 클라이언트는 필드의 존재 여부를 매번 확인하지 않고 동일한 구조로 응답을 처리할 수 있다.
검증 오류 한 건은 필드 이름과 메시지만 필요한 단순한 값이므로 ValidationError를 record로 정의했다.
GlobalExceptionHandler에서 응답으로 변환하기
예외를 한곳에서 처리하기 위해 @RestControllerAdvice를 사용했다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 예외별 처리 메서드
}
@RestControllerAdvice는 여러 Controller에서 발생한 예외를 공통으로 처리하고, 반환값을 JSON 응답 본문으로 변환할 수 있게 한다.
현재는 세 종류의 예외를 처리한다.
1. OditException
@ExceptionHandler(OditException.class)
public ResponseEntity<ErrorResponse> handleOditException(
OditException exception
) {
ErrorCode errorCode = exception.getErrorCode();
ErrorResponse response = ErrorResponse.of(errorCode);
return ResponseEntity.status(errorCode.getStatus())
.body(response);
}
예외가 가진 ErrorCode에서 HTTP 상태와 응답 정보를 가져온다.
Service는 OditException을 던지기만 하고, 모든 Controller는 별도의 try-catch 없이 같은 형식의 응답을 반환할 수 있다.
처리 흐름은 다음과 같다.
Service에서 OditException 발생
→ GlobalExceptionHandler가 예외 처리
→ ErrorCode에서 HTTP 상태와 코드 조회
→ ErrorResponse 생성
→ 클라이언트에 JSON 응답 반환
2. 요청값 검증 실패
Spring의 Bean Validation을 사용한 요청 DTO가 검증에 실패하면 MethodArgumentNotValidException이 발생한다.
이 경우에는 단순히 “잘못된 요청”이라고만 응답하지 않고 어떤 필드가 어떤 이유로 실패했는지 함께 전달한다.
List<ErrorResponse.ValidationError> errors =
exception.getBindingResult()
.getFieldErrors().stream()
.map(fieldError -> new ErrorResponse.ValidationError(
fieldError.getField(),
fieldError.getDefaultMessage()
))
.toList();
예를 들어 이메일과 비밀번호가 모두 검증에 실패했다면 다음과 같은 응답을 받을 수 있다.
{
"code": "COMMON_001",
"message": "잘못된 요청입니다.",
"errors": [
{
"field": "email",
"message": "이메일 형식이 올바르지 않습니다."
},
{
"field": "password",
"message": "비밀번호는 8자 이상이어야 합니다."
}
]
}
하나의 요청에서 발생한 여러 필드 오류를 목록으로 전달하기 때문에 클라이언트는 각 입력창에 맞는 안내를 한 번에 표시할 수 있다.
3. 예상하지 못한 예외
마지막으로 별도로 처리하지 않은 Exception은 서버 내부 오류로 응답한다.
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(
Exception exception
) {
ErrorCode errorCode = ErrorCode.COMMON_INTERNAL_SERVER_ERROR;
ErrorResponse response = ErrorResponse.of(errorCode);
return ResponseEntity.status(errorCode.getStatus())
.body(response);
}
예상하지 못한 예외의 상세 메시지나 스택 트레이스를 응답에 그대로 포함하지 않았다.
내부 구현 정보나 데이터베이스 정보가 외부에 노출될 수 있고, 클라이언트가 처리할 수 있는 정보도 아니기 때문이다. 외부에는 정해진 메시지만 반환하고 상세 원인은 서버 로그에서
확인하는 방식이 적절하다.
다만 현재 구현에는 예외 로깅이 아직 추가되지 않았다. 운영 단계에서는 예상하지 못한 예외를 반드시 로그로 남기고, 요청을 추적할 수 있는 식별자와 모니터링 도구도 함께 도입해야
한다.
Controller가 단순해진다
공통 예외 처리 구조가 없으면 각 Controller에서 예외를 잡고 응답을 직접 만들어야 한다.
try{
service.findById(id);
}catch(
Exception exception){
return ResponseEntity.
status(HttpStatus.NOT_FOUND)
.
body(...);
}
이 방식은 Controller가 늘어날수록 같은 코드가 반복되고, 어떤 Controller는 다른 응답을 반환할 가능성이 생긴다.
공통 처리 구조를 적용하면 Controller는 정상 요청을 Service에 전달하는 역할에 집중할 수 있다.
return ResponseEntity.ok(service.findById(id));
예외가 발생했을 때의 변환 책임은 GlobalExceptionHandler에 모이고, 비즈니스 실패의 의미는 ErrorCode에 모인다.
각 구성 요소의 책임을 정리하면 다음과 같다.
| 구성 요소 | 책임 |
|---|---|
ErrorCode |
HTTP 상태, 에러 식별 코드, 기본 메시지 정의 |
OditException |
비즈니스 로직에서 실패 상황 전달 |
ErrorResponse |
클라이언트에 전달할 JSON 구조 정의 |
GlobalExceptionHandler |
발생한 예외를 HTTP 응답으로 변환 |
구현하면서 정한 기준
이번 공통 기반을 만들면서 모든 경우를 미리 추상화하지는 않았다.
BaseEntity에는 지금 공통이라고 확신할 수 있는 생성 시간과 수정 시간만 넣었다. 에러 처리도 하나의 OditException과 최소한의 공통 코드로 시작했다.
아직 도메인 기능이 없는 상태에서 복잡한 예외 상속 구조나 지나치게 많은 필드를 먼저 만들면 실제 요구사항과 맞지 않는 구조가 될 수 있기 때문이다.
대신 앞으로 기능이 추가되어도 바뀌지 않을 가능성이 높은 규칙은 초기에 고정했다.
- 시간 값은 Service가 아니라 JPA Auditing이 관리한다.
- 생성 시간은 수정할 수 없다.
- 비즈니스 로직은 HTTP 응답을 직접 만들지 않는다.
- API 에러는 항상 같은 JSON 구조로 반환한다.
- 예상하지 못한 내부 예외 정보는 클라이언트에 노출하지 않는다.
공통화는 코드를 무조건 한곳에 모으는 일이 아니라, 여러 도메인에서 실제로 같은 의미를 가지는 규칙을 찾는 일에 가깝다고 느꼈다.
이후 개선할 부분
현재 구조는 프로젝트 초기 단계에서 사용할 수 있는 최소한의 기반이다.
도메인과 운영 환경이 갖춰지면 다음 항목을 보완할 예정이다.
- 사용자, 커플 스페이스, 장소 등 도메인별
ErrorCode추가 - 인증·인가 과정에서 발생하는 Spring Security 예외 응답 통일
- URL 파라미터와 JSON 형식 오류 등 다른 요청 예외 처리
- 예상하지 못한 예외의 로깅과 모니터링
- 요청 추적을 위한 trace ID 또는 correlation ID 검토
BaseEntity의 시간대 정책 정리- Entity 저장·수정과 에러 응답에 대한 테스트 추가
특히 다음 단계에서 Spring Security를 적용하면 인증되지 않은 요청과 권한이 없는 요청도 각각 401, 403과 Odit의 공통 에러 형식으로 반환해야 한다.
마무리
이번 단계에서는 본격적인 도메인 기능을 구현하기 전에 데이터와 API 실패에 대한 공통 규칙을 만들었다.
BaseEntity와 JPA Auditing을 통해 모든 Entity의 생성·수정 시간을 같은 방식으로 관리할 수 있게 되었다.
또한 ErrorCode, OditException, ErrorResponse, GlobalExceptionHandler로 역할을 나누면서 비즈니스 로직과 HTTP
에러 응답 생성을 분리했다.
아직 작은 프로젝트에서는 이런 공통 구조가 오히려 코드가 늘어난 것처럼 보일 수 있다.
하지만 사용자, 커플 스페이스, 장소, 사진처럼 도메인이 하나씩 추가될수록 반복을 줄이고 일관성을 지키는 기반이 될 것이라고 생각한다.
다음 글에서는 Spring Security의 기본 구조를 설정하고, 인증이 필요한 요청과 권한이 없는 요청을 Odit의 공통 에러 응답과 연결하는 과정을 정리해볼 예정이다.
'프로젝트 > ODIT' 카테고리의 다른 글
| [Odit 개발일지 02] 백엔드 공통 기반 만들기: BaseEntity와 공통 에러 응답 (0) | 2026.07.28 |
|---|---|
| [Odit 개발일지 01] 커플 데이트 지도 서비스를 시작하며 (0) | 2026.07.28 |