전역 예외 처리, 자동 설정, AOP, 트랜잭션과 OpenAPI

[SKALA] 4주차 학습 정리 ③: 컴포넌트 스캔과 Spring 컨테이너, HTTP 요청 바인딩 정리
https://daniellee09.tistory.com/56 [SKALA] 4주차 학습 정리 ②: Spring Boot 설정 관리와 MVC·Actuator 정리Configuration, Profile부터 Spring MVC 요청 처리와 운영 모니터링까지이전 글에서는 객체지향 프로그래밍과 RES
daniellee09.tistory.com
이전 글에서는 컴포넌트 스캔과 Spring 컨테이너, IoC·DI, HTTP 요청 데이터 바인딩, Lombok의 기본 사용법까지 정리했다.
이번 학습은 잘못된 요청을 차단하고 오류를 일관된 형식으로 응답하는 방법에서 출발한다. 이후 Spring Boot의 자동 설정과 로그 관리, AOP를 학습하고, 후반부에서는 JPA Entity와 연관관계, Repository, 트랜잭션, API 문서화까지 다룬다.
입력값 검증
→ 전역 예외 처리
→ Spring Boot 자동 설정
→ 로그와 AOP
→ JPA Entity 매핑
→ Repository와 쿼리
→ 트랜잭션
→ OpenAPI 문서화
1. 입력값 검증과 전역 예외 처리
클라이언트가 서버에 전달하는 데이터는 항상 올바르다고 가정할 수 없다.
예를 들어 회원가입 API에 다음 요청이 들어올 수 있다.
{
"name": "",
"email": "wrong-email",
"age": 12
}
검증 없이 이 데이터가 Service와 Database까지 전달되면 잘못된 정보가 저장되거나 예상하지 못한 예외가 발생할 수 있다.
입력값 검증은 요청 데이터가 서버와 약속한 형식과 범위를 만족하는지 확인하는 과정이다.
Request DTO에서 검증 규칙 정의하기
public class UserCreateRequest {
@NotBlank(message = "이름은 필수입니다.")
private String name;
@Email(message = "올바른 이메일 형식이어야 합니다.")
private String email;
@Min(value = 18, message = "나이는 18세 이상이어야 합니다.")
private int age;
// getter, setter
}
Controller에서는 @Valid를 붙여 검증을 실행한다.
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<String> createUser(
@Valid @RequestBody UserCreateRequest request
) {
return ResponseEntity.ok("가입 완료");
}
}
처리 흐름은 다음과 같다.
JSON 요청
→ UserCreateRequest 객체로 변환
→ Bean Validation 실행
→ 성공하면 Controller 메서드 실행
→ 실패하면 MethodArgumentNotValidException 발생
@RequestBody 객체 검증이 실패하면 Spring MVC가 일반적으로 400 Bad Request를 반환하며, Controller의 실제 비즈니스 로직은 실행되지 않는다.
Controller 검증과 Service 검증의 차이
검증을 모두 Controller에 작성하거나 모두 Service에 작성하는 것은 적절하지 않다.
두 계층이 검증해야 하는 대상이 다르기 때문이다.
| 구분 | Controller | Service |
| 검증 대상 | 요청의 형식과 값 | 비즈니스 규칙 |
| 예시 | 필수값, 이메일 형식, 숫자 범위 | 이메일 중복, 재고 부족, 주문 가능 상태 |
| 목적 | 잘못된 요청을 입구에서 차단 | 업무 규칙의 유효성 보장 |
Controller의 형식 검증
이름이 비어 있는가?
이메일 형식이 맞는가?
나이가 숫자 범위에 맞는가?
Service의 비즈니스 검증
이미 가입된 이메일인가?
상품의 재고가 충분한가?
현재 취소 가능한 주문 상태인가?
잘못된 형식의 데이터는 시스템의 가장 바깥쪽에서 빠르게 차단하는 것이 효율적이다. 반면 데이터가 실제 업무 규칙을 만족하는지는 Service에서 판단해야 한다.
@Valid와 @Validated
@Valid는 Jakarta Bean Validation 표준 어노테이션이다.
일반적인 Request DTO 검증에서는 대부분 @Valid로 충분하다.
@PostMapping
public UserResponse create(
@Valid @RequestBody UserCreateRequest request
) {
return userService.create(request);
}
@Validated는 Spring에서 제공하는 확장 기능으로, 검증 그룹을 지정하거나 메서드 파라미터를 검증할 때 사용할 수 있다.
public interface CreateGroup {
}
public interface UpdateGroup {
}
public class UserRequest {
@NotBlank(groups = CreateGroup.class)
private String name;
@Email(groups = {
CreateGroup.class,
UpdateGroup.class
})
private String email;
@Min(value = 18, groups = CreateGroup.class)
private int age;
}
생성 시에는 모든 값을 검증한다.
@PostMapping
public UserResponse create(
@Validated(CreateGroup.class)
@RequestBody UserRequest request
) {
return userService.create(request);
}
수정 시에는 이메일만 검증할 수 있다.
@PutMapping("/{id}")
public UserResponse update(
@PathVariable Long id,
@Validated(UpdateGroup.class)
@RequestBody UserRequest request
) {
return userService.update(id, request);
}
검증 그룹은 하나의 DTO를 상황별로 다르게 검사할 수 있다는 장점이 있다. 다만 그룹이 많아질수록 DTO가 복잡해질 수 있으므로, 생성용과 수정용 DTO의 구조가 크게 다르다면 클래스를 별도로 나누는 방법도 고려할 수 있다.
Spring Boot의 기본 예외 처리
처리되지 않은 예외는 Spring Boot의 BasicErrorController가 기본적으로 처리한다.
API 요청에서는 다음과 비슷한 JSON 응답이 반환될 수 있다.
{
"timestamp": "2026-07-30T05:00:00.000+00:00",
"status": 404,
"error": "Not Found",
"path": "/api/non-existent-endpoint"
}
기본 응답도 사용할 수 있지만 실제 서비스에서는 다음 문제가 있다.
- 업무에 적합한 에러 코드가 없다.
- API마다 필요한 오류 메시지를 표현하기 어렵다.
- 필드별 검증 오류를 원하는 구조로 반환하기 어렵다.
- 클라이언트가 처리하기 좋은 일관된 형식을 만들기 어렵다.
따라서 전역 예외 처리기를 구성하는 것이 좋다.
@RestControllerAdvice와 @ExceptionHandler
@RestControllerAdvice는 여러 Controller에서 발생한 예외를 한곳에서 처리한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(
MethodArgumentNotValidException.class
)
public ResponseEntity<Map<String, String>>
handleValidation(
MethodArgumentNotValidException exception
) {
Map<String, String> errors = new HashMap<>();
for (FieldError fieldError :
exception.getBindingResult().getFieldErrors()) {
errors.put(
fieldError.getField(),
fieldError.getDefaultMessage()
);
}
return ResponseEntity
.badRequest()
.body(errors);
}
}
검증 실패 시 다음처럼 필드별 원인을 전달할 수 있다.
{
"name": "이름은 필수입니다.",
"email": "올바른 이메일 형식이어야 합니다."
}
@RestControllerAdvice는 @ControllerAdvice와 @ResponseBody를 결합한 형태다. REST API에서는 예외 처리 결과를 JSON으로 반환해야 하므로 @RestControllerAdvice가 편리하다.
일관된 오류 응답 만들기
Map도 사용할 수 있지만, 실제 프로젝트에서는 별도의 오류 DTO를 만드는 것이 관리하기 쉽다.
public record ErrorResponse(
String code,
String message,
Map<String, String> fields
) {
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(
MethodArgumentNotValidException.class
)
public ResponseEntity<ErrorResponse>
handleValidation(
MethodArgumentNotValidException exception
) {
Map<String, String> fields = new HashMap<>();
exception.getBindingResult()
.getFieldErrors()
.forEach(error ->
fields.put(
error.getField(),
error.getDefaultMessage()
)
);
ErrorResponse response = new ErrorResponse(
"INVALID_REQUEST",
"요청값이 올바르지 않습니다.",
fields
);
return ResponseEntity
.badRequest()
.body(response);
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
NoSuchElementException exception
) {
ErrorResponse response = new ErrorResponse(
"RESOURCE_NOT_FOUND",
exception.getMessage(),
Map.of()
);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(response);
}
}
{
"code": "INVALID_REQUEST",
"message": "요청값이 올바르지 않습니다.",
"fields": {
"name": "이름은 필수입니다.",
"email": "올바른 이메일 형식이어야 합니다."
}
}
예외를 한곳에서 관리하면 상태 코드, 에러 코드와 응답 구조를 API 전체에서 통일할 수 있다.
예외 처리 우선순위
Spring MVC는 대략 다음 순서로 예외 처리 방법을 찾는다.
현재 Controller의 @ExceptionHandler
→ @ControllerAdvice의 @ExceptionHandler
→ ResponseStatusException 처리
→ Spring Boot 기본 예외 처리
특정 Controller에서만 필요한 예외라면 Controller 내부에서 처리할 수 있다. 여러 API에서 공통으로 발생하는 예외는 전역 처리기로 분리하는 것이 적절하다.
try-with-resources
파일, 네트워크 연결과 JDBC 자원처럼 사용 후 닫아야 하는 객체는 try-with-resources를 사용하는 것이 안전하다.
try (
BufferedReader reader =
new BufferedReader(
new FileReader("test.txt")
)
) {
String line = reader.readLine();
System.out.println(line);
} catch (IOException exception) {
exception.printStackTrace();
}
괄호 안의 자원은 블록이 끝날 때 자동으로 close()된다. finally에서 직접 자원을 닫는 방식보다 코드가 간결하고 자원 해제를 빠뜨릴 위험이 적다.
2. Spring Boot 자동 설정과 로그 관리
Spring Boot를 사용하면 개발자가 DispatcherServlet, Tomcat, Jackson과 같은 구성 요소를 하나씩 직접 등록하지 않아도 된다.
이를 가능하게 하는 기능이 Auto-Configuration이다.
Auto-Configuration이란?
자동 설정은 클래스패스에 포함된 라이브러리와 현재 환경을 확인하여 일반적으로 필요한 Bean과 설정을 자동 구성하는 기능이다.
Starter 의존성 추가
→ Spring Boot가 클래스패스 확인
→ 관련 Auto-Configuration 적용
→ 필요한 Bean 자동 등록
예를 들어 spring-boot-starter-web을 추가하면 다음과 같은 웹 구성 요소가 준비된다.
내장 Tomcat
DispatcherServlet
Spring MVC
Jackson
기본 오류 처리
spring-boot-starter-data-jpa를 추가하면 다음 구성이 자동화된다.
DataSource
EntityManagerFactory
TransactionManager
Spring Data Repository 지원
Spring Boot는 설정보다 관례라는 방향에 따라, 개발자가 별도로 변경하지 않은 부분에는 보편적인 기본 설정을 적용한다.
Starter와 자동 설정의 관계
Starter는 특정 기능에 필요한 의존성을 모은 패키지다.
자동 설정은 Starter가 포함한 라이브러리를 확인하여 실제 Bean과 환경을 구성한다.
Starter
→ 어떤 라이브러리를 추가할 것인가?
Auto-Configuration
→ 추가된 라이브러리를 기반으로 무엇을 설정할 것인가?
Starter자동으로 준비되는 대표 기능
| Starter | 자동으로 준비되는 대표 기능 |
| Web | MVC, Tomcat, Jackson |
| Data JPA | DataSource, Hibernate, 트랜잭션 |
| Security | Security Filter Chain, 인증 기능 |
| Thymeleaf | TemplateEngine, ViewResolver |
| Validation | Validator |
| Actuator | 운영 Endpoint |
application.yml을 이용한 설정 변경
자동 설정이 제공하는 기본값은 application.yml을 통해 변경할 수 있다.
server:
port: 8081
spring:
datasource:
url: jdbc:mysql://localhost:3306/stockdb
username: stockuser
password: ${DB_PASSWORD}
logging:
level:
org.springframework: INFO
stock:
api:
base-url: https://api.stock.example
timeout: 3s
Spring Boot가 기본 동작을 자동 구성하더라도, 애플리케이션마다 달라지는 값은 외부 설정으로 관리한다.
application.properties와 application.yml을 동시에 둘 수도 있지만, 같은 Key가 중복되면 혼란이 생길 수 있다. 프로젝트에서는 한 가지 형식으로 통일하는 것이 안전하다. 두 파일이 동시에 존재하고 같은 Key가 있으면 properties 값이 우선할 수 있으므로 한 형식만 사용하는 것을 권장한다.
로그가 필요한 이유
운영 중 발생한 문제는 개발자의 컴퓨터에서 그대로 재현되지 않을 수 있다.
로그는 다음 정보를 남기는 핵심 수단이다.
- 어떤 요청이 들어왔는가?
- 어느 메서드에서 오류가 발생했는가?
- 예외의 Stack Trace는 무엇인가?
- 요청 처리 시간이 얼마나 걸렸는가?
- 특정 사용자가 어떤 작업을 수행했는가?
- 장애 직전 시스템 상태는 어땠는가?
로그는 장애 분석뿐 아니라 보안 감사, 서비스 사용 패턴 분석과 운영 지표 수집에도 활용된다.
SLF4J와 Logback의 관계
Spring Boot는 기본적으로 SLF4J와 Logback을 사용한다.
애플리케이션 코드
→ SLF4J API
→ Logback 구현체
→ 콘솔 또는 파일에 로그 기록
SLF4J는 직접 로그를 출력하는 구현체가 아니라 여러 Logging Framework를 동일한 API로 사용할 수 있게 만드는 Facade다.
private static final Logger log =
LoggerFactory.getLogger(OrderService.class);
log.info("주문 처리 시작");
log.debug("주문 데이터: {}", order);
구현체를 Logback에서 Log4j2로 변경하더라도 애플리케이션에서 SLF4J API를 사용했다면 비즈니스 코드의 변경을 줄일 수 있다.
Lombok의 @Slf4j
@Slf4j
@Service
public class OrderService {
public void order(Long id) {
log.debug("주문 데이터 확인. id={}", id);
log.info("주문 처리 시작. id={}", id);
}
}
@Slf4j는 Logger 필드를 자동 생성한다.
private static final Logger log =
LoggerFactory.getLogger(OrderService.class);
패키지별 로그 레벨은 설정 파일에서 지정할 수 있다.
logging:
level:
root: info
com.sk.skala: debug
로그 레벨
TRACE
→ 가장 세밀한 실행 흐름
DEBUG
→ 개발과 디버깅 정보
INFO
→ 운영 중 확인할 주요 업무 흐름
WARN
→ 잠재적인 문제
ERROR
→ 정상 처리에 실패한 오류
운영 환경에서 모든 데이터를 DEBUG로 출력하면 로그가 지나치게 많아지고 민감정보가 노출될 가능성도 높아진다.
비밀번호, JWT, API Key, 카드정보와 개인정보는 로그에 그대로 기록하지 않아야 한다.
3. AOP로 공통 관심사 분리하기
Controller나 Service마다 요청 시간 측정 코드를 작성하면 동일한 코드가 반복된다.
long startedAt = System.currentTimeMillis();
Object result = service.execute();
long elapsed =
System.currentTimeMillis() - startedAt;
log.info("처리 시간={}ms", elapsed);
이처럼 여러 클래스에 반복되지만 핵심 비즈니스 기능과 직접적인 관련이 없는 로직을 횡단 관심사라고 한다.
대표적인 횡단 관심사는 다음과 같다.
- 로깅
- 트랜잭션
- 인증과 권한 검사
- 실행 시간 측정
- 감사 기록
AOP는 이러한 공통 기능을 별도의 모듈로 분리하여 OOP의 역할 분리를 보완한다.
AOP 핵심 용어
| 용어 | 의미 |
| Aspect | 공통 관심사를 모은 클래스 |
| Advice | 실제로 실행할 공통 코드 |
| Join Point | Advice가 적용될 수 있는 지점 |
| Pointcut | 실제 적용 대상을 선택하는 조건 |
| Target | 원래 비즈니스 객체 |
| Proxy | Target을 감싸 공통 기능을 실행하는 객체 |
| Weaving | 공통 기능을 Target에 적용하는 과정 |
Spring AOP에서 Join Point는 주로 Bean 메서드 실행 지점이다.
Advice 종류
| Advice | 실행 시점 |
| @Before | 메서드 실행 전 |
| @After | 메서드 실행 후 |
| @AfterReturning | 정상 반환 후 |
| @AfterThrowing | 예외 발생 후 |
| @Around | 실행 전후 전체 제어 |
@Around는 실제 메서드 실행 여부와 반환값, 예외, 실행 시간을 모두 제어할 수 있어 자주 사용된다.
Pointcut 표현식
execution(* com.example.user.*.*(..))
의미는 다음과 같다.
*
→ 모든 반환 타입
com.example.user.*
→ user 패키지의 모든 클래스
*
→ 모든 메서드
(..)
→ 모든 파라미터
특정 클래스만 지정할 수도 있다.
execution(
* com.example.user.UserService.*(..)
)
특정 이름으로 시작하는 메서드만 선택할 수도 있다.
execution(
* com.example.user.UserService.find*(..)
)
API 요청과 응답 로깅 예제
@Aspect
@Component
@Slf4j
public class ApiLoggingAspect {
@Around(
"execution(public * " +
"com.example.controller..*Controller.*(..))"
)
public Object logApi(
ProceedingJoinPoint joinPoint
) throws Throwable {
long startedAt = System.currentTimeMillis();
String method =
joinPoint.getSignature().toShortString();
log.info(
"[API REQUEST] method={}",
method
);
try {
Object result = joinPoint.proceed();
long elapsed =
System.currentTimeMillis() - startedAt;
log.info(
"[API RESPONSE] method={}, elapsed={}ms",
method,
elapsed
);
return result;
} catch (Throwable throwable) {
log.error(
"[API ERROR] method={}",
method,
throwable
);
throw throwable;
}
}
}
joinPoint.proceed()를 호출해야 실제 Controller 메서드가 실행된다. 이를 호출하지 않으면 원래 메서드는 실행되지 않는다.
Spring Proxy의 동작
Spring AOP와 @Transactional은 Proxy를 기반으로 동작한다.
클라이언트
→ Proxy 호출
→ 공통 전처리 실행
→ 실제 Target 메서드 호출
→ 공통 후처리 실행
→ 결과 반환
Spring 컨테이너에는 상황에 따라 원본 객체 대신 Proxy 객체가 Bean으로 등록된다. 외부에서 Bean을 호출하면 Proxy가 먼저 실행된 뒤 원본 메서드로 작업을 위임한다.
이 원리는 이후 @Transactional의 Self-Invocation 문제와도 연결된다.
4. JPA와 Entity 매핑

애플리케이션의 Java 객체와 관계형 데이터베이스의 테이블은 구조가 서로 다르다.
Java
객체
상속
참조
컬렉션
RDB
테이블
행과 열
기본키
외래키
ORM은 이 차이를 중간에서 연결하는 기술이다.
JPA는 Java에서 ORM을 사용하기 위한 표준 명세이며, Hibernate 등이 실제 구현체 역할을 한다.
애플리케이션
→ JPA 표준 API
→ Hibernate
→ SQL
→ Database
개발자는 Entity와 Repository를 중심으로 코드를 작성하고, JPA 구현체가 SQL 생성과 객체 매핑을 담당한다.
JPA와 JDBC
구분JPAJDBC
| 구분 | JPA | JDBC |
| 작업 중심 | Entity 객체 | SQL과 ResultSet |
| SQL | 많은 CRUD 자동 생성 | 직접 작성 |
| 매핑 | 객체와 테이블 자동 매핑 | 직접 필드 변환 |
| 트랜잭션 | 선언적으로 관리 가능 | Connection으로 직접 제어 |
| 장점 | 생산성과 객체지향 설계 | SQL 제어가 명확함 |
| 주의점 | ORM과 영속성 이해 필요 | 반복 코드가 많음 |
JPA를 사용해도 SQL과 데이터베이스의 원리를 몰라도 된다는 뜻은 아니다. JPA가 생성하는 SQL과 조회 전략을 이해하지 못하면 불필요한 쿼리나 성능 문제가 발생할 수 있다.
데이터베이스 설정
spring:
datasource:
url: jdbc:h2:mem:shop
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: update
show-sql: true
주요 ddl-auto 값은 다음과 같다.
값동작
| 값 | 동작 |
| create | 시작 시 기존 테이블 삭제 후 생성 |
| create-drop | 시작 시 생성하고 종료 시 삭제 |
| update | Entity 변경을 가능한 범위에서 반영 |
| validate | Entity와 테이블 구조가 맞는지 검사 |
| none | 자동 DDL 수행 안 함 |
운영 환경에서 create나 update에 의존하는 것은 위험할 수 있다. 운영 DB 변경은 Flyway나 Liquibase 같은 Migration 도구를 이용해 명시적으로 관리하는 것이 일반적이다.
Spring Boot는 기본 Connection Pool로 HikariCP를 사용하며 필요하면 Pool 크기와 Timeout 등을 조정할 수 있다.
Entity의 기본 구조
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(
strategy = GenerationType.IDENTITY
)
private Long id;
@Column(
nullable = false,
length = 100
)
private String name;
private int price;
protected Product() {
}
public Product(String name, int price) {
this.name = name;
this.price = price;
}
}
@Entity는 해당 클래스를 JPA가 관리하는 영속 객체로 지정한다.
JPA Entity에는 JPA 구현체가 사용할 수 있는 기본 생성자가 필요하며, 일반적으로 protected로 선언한다.
protected Product() {
}
정확히는 Entity는 추상 클래스가 될 수 있다. 다만 Hibernate의 Proxy와 변경 감지 기능을 안정적으로 활용하려면 Entity 클래스와 영속 필드를 final로 만들지 않는 것이 일반적이다. 자료에서 설명하는 기본 생성자와 명시적인 테이블 매핑은 실무에서도 중요한 기준이다.
기본키 매핑
@Id
@GeneratedValue(
strategy = GenerationType.IDENTITY
)
private Long id;
@Id는 Entity 식별자를 지정하고, @GeneratedValue는 자동 생성 전략을 설정한다.
전략특징
| 전략 | 특징 |
| AUTO | 구현체가 DB에 맞는 전략 선택 |
| IDENTITY | DB의 Auto Increment 사용 |
| SEQUENCE | DB Sequence 사용 |
| TABLE | 별도 Key 테이블 사용 |
MySQL과 MariaDB에서는 IDENTITY가 자주 사용되고, PostgreSQL이나 Oracle 환경에서는 Sequence 전략을 선택할 수 있다.
복합키
두 개 이상의 컬럼 조합을 기본키로 사용해야 한다면 복합키를 정의할 수 있다.
@Embeddable
public class UserStockId
implements Serializable {
private Long userId;
private String ticker;
protected UserStockId() {
}
// equals(), hashCode() 필요
}
@Entity
@Table(name = "user_stocks")
public class UserStock {
@EmbeddedId
private UserStockId id;
private int quantity;
}
복합키 클래스에는 값 비교를 위한 equals()와 hashCode() 구현이 중요하다.
@Column
@Column(
name = "product_name",
nullable = false,
length = 100
)
private String name;
| 속성 | 의미 |
| name | 실제 컬럼명 |
| nullable | null 허용 여부 |
| length | 문자열 길이 |
| unique | 단일 컬럼 Unique |
| updatable | UPDATE 포함 여부 |
| insertable | INSERT 포함 여부 |
| precision | 숫자 전체 자릿수 |
| scale | 소수점 자릿수 |
금액이나 정밀한 수치는 double보다 BigDecimal이 적합하다.
@Column(precision = 12, scale = 2)
private BigDecimal price;
Enum 저장
@Enumerated(EnumType.STRING)
private ProductStatus status;
public enum ProductStatus {
ON_SALE,
SOLD_OUT,
DISCONTINUED
}
EnumType.ORDINAL은 Enum의 순서를 숫자로 저장한다.
ON_SALE → 0
SOLD_OUT → 1
중간에 Enum 항목을 추가하거나 순서를 바꾸면 기존 데이터의 의미가 달라질 수 있다.
따라서 실무에서는 이름을 저장하는 EnumType.STRING이 안전하다.
@Lob과 @Transient
긴 본문이나 바이너리 데이터는 @Lob으로 매핑할 수 있다.
@Lob
private String description;
@Lob
private byte[] profileImage;
DB에 저장하지 않을 계산용 필드는 @Transient를 사용한다.
@Transient
private String passwordConfirm;
@Transient는 Java의 transient 키워드와 목적이 다르다. JPA의 @Transient는 해당 필드를 데이터베이스 컬럼으로 매핑하지 않겠다는 뜻이다.
5. 연관관계 매핑과 Repository
관계형 DB에서는 외래키로 테이블 사이의 관계를 표현한다.
JPA에서는 객체의 참조와 Collection을 이용해 이를 매핑한다.
관계어노테이션예시
| 관계 | 어노테이션 | 예시 |
| N:1 | @ManyToOne | 여러 주문이 한 고객을 참조 |
| 1:N | @OneToMany | 고객 한 명이 여러 주문 보유 |
| 1:1 | @OneToOne | 사용자와 프로필 |
| N:M | @ManyToMany | 사용자와 관심 상품 |
N:1과 1:N 관계
외래키를 가진 쪽이 보통 연관관계의 주인이다.
@Entity
public class OrderItem {
@Id
@GeneratedValue(
strategy = GenerationType.IDENTITY
)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
}
반대편은 mappedBy로 관계의 주인이 아님을 표시한다.
@Entity
public class Customer {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "customer")
private List<OrderItem> orderItems =
new ArrayList<>();
}
OrderItem.customer
→ 외래키를 관리하는 연관관계의 주인
Customer.orderItems
→ 조회를 위한 반대 방향
→ mappedBy 사용
mappedBy의 값은 컬럼명이 아니라 반대편 Entity의 필드 이름이다.
1:1 관계
@Entity
public class UserProfile {
@Id
@GeneratedValue
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "user_id",
unique = true
)
private User user;
}
unique=true를 설정하면 하나의 사용자 ID가 프로필 테이블에 중복 저장되는 것을 방지한다.
N:M 관계와 중간 Entity
JPA는 @ManyToMany와 @JoinTable을 지원한다.
@ManyToMany
@JoinTable(
name = "user_watchlist",
joinColumns =
@JoinColumn(name = "user_id"),
inverseJoinColumns =
@JoinColumn(name = "ticker")
)
private List<Stock> watchList =
new ArrayList<>();
하지만 실제 프로젝트에서는 중간 테이블에 생성일, 수량이나 상태 같은 필드가 추가되는 경우가 많다.
따라서 단순 @ManyToMany보다 중간 Entity를 명시적으로 만드는 방식이 유연하다.
User
1:N
UserWatchlist
N:1
Stock
@Entity
public class UserWatchlist {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
private Stock stock;
private LocalDateTime createdAt;
}
Embedded Value Object
주소처럼 여러 Entity에서 반복되는 값을 하나의 값 객체로 묶을 수 있다.
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
}
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
@Embedded
private Address address;
}
별도의 Address 테이블이 만들어지는 것이 아니라 User 테이블에 city, street, zipcode 컬럼이 포함된다.
Spring Data JPA Repository
Spring Data JPA를 사용하면 Repository 구현 클래스를 직접 작성하지 않고 인터페이스만 선언할 수 있다.
public interface ProductRepository
extends JpaRepository<Product, Long> {
}
JpaRepository<Product, Long>의 의미는 다음과 같다.
Product
→ 관리할 Entity 타입
Long
→ Entity 기본키 타입
Repository 계층은 다음과 같이 확장된다.
Repository
→ CrudRepository
→ PagingAndSortingRepository
→ JpaRepository
일반적인 JPA 프로젝트에서는 JpaRepository를 가장 많이 사용한다.
기본 CRUD 메서드
Optional<Product> findById(Long id);
List<Product> findAll();
Product save(Product product);
void deleteById(Long id);
boolean existsById(Long id);
long count();
페이징과 정렬도 기본 제공된다.
Page<Product> findAll(Pageable pageable);
List<Product> findAll(Sort sort);
save()와 flush()의 차이
Product saved = repository.save(product);
save()를 호출했다고 해서 반드시 그 순간 SQL이 실행되는 것은 아니다.
Entity는 먼저 영속성 컨텍스트에서 관리되고, SQL은 일반적으로 Flush 시점에 DB로 전달된다.
save()
→ 영속성 컨텍스트에 Entity 등록
→ SQL 쓰기 지연 저장소에 작업 준비
→ Commit 직전 Flush
→ SQL 실행
→ Commit
flush()는 영속성 컨텍스트의 변경을 DB에 동기화하지만 트랜잭션을 Commit하는 것은 아니다.
Flush
→ SQL을 DB로 전달
Commit
→ 트랜잭션을 최종 확정
Query Method
메서드 이름을 규칙에 맞게 작성하면 Spring Data JPA가 쿼리를 생성한다.
List<Product> findByNameContaining(
String keyword
);
List<Product>
findByPriceLessThanOrderByPriceAsc(
int maxPrice
);
boolean existsByName(String name);
주요 키워드는 다음과 같다.
And, Or
Between
LessThan, GreaterThan
IsNull, IsNotNull
In, NotIn
StartingWith, EndingWith
Containing
OrderBy
단순한 검색과 정렬에는 매우 편리하지만 조건이 많아지면 메서드 이름이 지나치게 길어진다.
JPQL과 @Query
@Query("""
select p
from Product p
where p.price between :min and :max
""")
List<Product> findByPriceRange(
@Param("min") int min,
@Param("max") int max
);
JPQL은 테이블과 컬럼이 아니라 Entity와 필드 이름을 대상으로 작성한다.
SQL
SELECT *
FROM products
WHERE price > ?
JPQL
select p
from Product p
where p.price > :price
JPA 구현체가 JPQL을 DB에 맞는 SQL로 변환한다.
Native Query
DB 고유 기능이나 복잡한 SQL이 필요하면 Native Query를 사용할 수 있다.
@Query(
value = """
SELECT *
FROM products
WHERE price > :price
""",
nativeQuery = true
)
List<Product> findExpensiveProducts(
@Param("price") int price
);
Native Query는 SQL을 직접 제어할 수 있지만 특정 DB에 종속될 가능성이 높고 Entity 필드명이 아닌 실제 테이블과 컬럼명을 사용해야 한다.
QueryDSL
조회 조건이 실행 시점에 달라지는 동적 쿼리라면 QueryDSL을 고려할 수 있다.
keyword가 있으면 이름 검색
minPrice가 있으면 최소 가격 조건
category가 있으면 카테고리 조건
status가 있으면 상태 조건
QueryDSL은 Java 코드 기반으로 조건을 조립하므로 문자열 JPQL보다 타입 안전성이 높다.
단, Q-Type 생성과 JPAQueryFactory 설정 등 초기 구성이 필요하다.
6. 트랜잭션과 동시성 제어
트랜잭션은 여러 DB 작업을 하나의 논리적인 단위로 묶는 것이다.
예를 들어 상품 주문에는 다음 작업이 포함될 수 있다.
고객 조회
→ 포인트 확인
→ 포인트 차감
→ 재고 차감
→ 주문 저장
모든 작업이 성공하거나, 하나라도 실패하면 전체를 취소해야 한다.
모든 작업 성공
→ Commit
하나의 작업 실패
→ Rollback
트랜잭션의 핵심 속성은 ACID로 정리된다.
| 원칙 | 의미 |
| Atomicity | 모두 성공하거나 모두 실패 |
| Consistency | 전후 데이터 규칙 유지 |
| Isolation | 동시 트랜잭션의 간섭 제어 |
| Durability | Commit 결과를 영구 보존 |
@Transactional 동작 원리
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
@Transactional
public void raisePrice(
Long id,
int amount
) {
Product product = repository.findById(id)
.orElseThrow();
product.changePrice(
product.getPrice() + amount
);
}
}
메서드 안에서 save()를 다시 호출하지 않아도, 조회한 Entity가 영속 상태라면 변경 감지가 동작한다.
트랜잭션 시작
→ Entity 조회
→ 영속성 컨텍스트에서 관리
→ Entity 값 변경
→ Commit 시 변경 감지
→ UPDATE SQL 실행
@Transactional이 붙은 Bean은 Proxy로 감싸진다.
Proxy가 트랜잭션 시작
→ 실제 Service 메서드 호출
→ 정상 종료 시 Flush와 Commit
→ RuntimeException 발생 시 Rollback
트랜잭션 범위를 좁게 유지하기
트랜잭션 안에서 파일 저장이나 외부 API 호출처럼 오래 걸리는 작업을 수행하면 DB Connection과 Lock을 오래 점유할 수 있다.
@Transactional
public void process() {
saveLargeFile(); // 오래 걸림
updateDatabase();
callExternalApi(); // 응답 지연 가능
}
가능하면 DB 작업만 트랜잭션 범위에 포함한다.
public void process() {
String filePath = saveLargeFile();
updateDatabase(filePath);
callExternalApi();
}
@Transactional
public void updateDatabase(String filePath) {
// DB 작업
}
다만 같은 클래스 안에서 @Transactional 메서드를 직접 호출하면 Proxy를 거치지 않는 Self-Invocation 문제가 발생할 수 있다. 따라서 실제로는 트랜잭션 메서드를 별도 Bean으로 분리하는 편이 안전하다.
트랜잭션 전파
이미 트랜잭션이 실행 중일 때 새로운 메서드가 호출되면 기존 트랜잭션에 참여할지 별도로 실행할지를 결정해야 한다.
| 전파 방식 | 동작 |
| REQUIRED | 기존 트랜잭션 참여, 없으면 생성 |
| REQUIRES_NEW | 항상 새로운 트랜잭션 생성 |
| SUPPORTS | 있으면 참여, 없으면 비트랜잭션 |
| MANDATORY | 기존 트랜잭션이 반드시 필요 |
| NOT_SUPPORTED | 트랜잭션 없이 실행 |
| NEVER | 트랜잭션이 있으면 예외 |
| NESTED | Savepoint 기반 중첩 처리 |
기본값은 REQUIRED이며 일반적인 Service 로직에 주로 사용한다.
Self-Invocation 문제
@Service
public class UserService {
@Transactional
public void outer() {
inner();
}
@Transactional(
propagation = Propagation.REQUIRES_NEW
)
public void inner() {
}
}
outer()에서 inner()를 직접 호출하면 호출이 Proxy를 거치지 않는다.
따라서 inner()의 REQUIRES_NEW가 적용되지 않을 수 있다.
외부 객체 → Proxy → outer()
↓
this.inner()
↓
Proxy를 거치지 않음
트랜잭션 경계가 다른 메서드는 별도 Service로 분리한다.
@Service
@RequiredArgsConstructor
public class OuterService {
private final InnerService innerService;
@Transactional
public void outer() {
innerService.inner();
}
}
Rollback 규칙
Spring의 @Transactional은 기본적으로 다음 예외에서 Rollback한다.
RuntimeException
Error
Checked Exception에서는 기본적으로 Rollback하지 않는다.
모든 Exception을 대상으로 Rollback하려면 명시할 수 있다.
@Transactional(
rollbackFor = Exception.class
)
public void process()
throws ExternalSystemException {
}
예외를 try-catch로 잡은 뒤 다시 던지지 않으면 Proxy 입장에서는 메서드가 정상 종료된 것으로 보일 수 있다.
@Transactional
public void process() {
try {
riskyOperation();
} catch (Exception exception) {
log.error("오류", exception);
// 예외를 숨기면 Commit될 수 있음
}
}
따라서 오류를 잡았을 때 Rollback이 필요한지 명확하게 판단해야 한다.
낙관적 Lock
충돌이 자주 발생하지 않는다고 가정하고 Entity 버전으로 변경 여부를 검사한다.
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
@Version
private Long version;
private int stock;
}
사용자 A와 B가 version=1 조회
→ A가 먼저 수정
→ DB version=2
→ B가 version=1 기준으로 수정 시도
→ Version 불일치
→ Optimistic Lock 예외
DB Row를 오래 잠그지 않아 읽기가 많은 환경에서 유리하지만 충돌 시 재시도 처리가 필요할 수 있다.
비관적 Lock
충돌이 자주 발생한다고 보고 조회 시점부터 DB Row를 잠근다.
public interface ProductRepository
extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select p
from Product p
where p.id = :id
""")
Optional<Product> findByIdWithLock(
@Param("id") Long id
);
}
정합성을 강하게 보장할 수 있지만 대기 시간이 증가하고 Deadlock이 발생할 수 있다.
낙관적 Lock
→ 충돌이 적은 환경
→ Version 비교
→ 높은 처리량
비관적 Lock
→ 충돌이 잦은 환경
→ DB Row Lock
→ 강한 정합성
7. OpenAPI를 이용한 REST API 문서화
API를 사용하는 개발자는 다음 내용을 알아야 한다.
- 요청 URI
- HTTP Method
- Path와 Query Parameter
- Request Body 구조
- Response Body 구조
- 상태 코드
- 인증 요구사항
- 필드의 필수 여부와 예시
문서를 별도로 수작업하면 코드가 변경되었는데 문서는 갱신되지 않는 문제가 발생할 수 있다.
Spring Boot에서는 springdoc-openapi를 이용해 Controller와 DTO 코드를 기반으로 OpenAPI 문서를 생성할 수 있다.
의존성 추가
Gradle:
implementation(
'org.springdoc:' +
'springdoc-openapi-starter-webmvc-ui:2.5.0'
)
Maven:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>
springdoc-openapi-starter-webmvc-ui
</artifactId>
<version>2.5.0</version>
</dependency>
문서는 기본적으로 다음 경로에서 확인할 수 있다.
Swagger UI
→ /swagger-ui/index.html
OpenAPI JSON
→ /v3/api-docs
주요 OpenAPI 어노테이션
| 어노테이션 | 용도 |
| @Tag | Controller 단위 API 그룹 |
| @Operation | 개별 API의 설명 |
| @Parameter | Path·Query Parameter 설명 |
| @RequestBody | 요청 본문의 설명 |
| @ApiResponse | 상태 코드별 응답 설명 |
| @Schema | DTO와 필드의 구조 설명 |
| @Hidden | 문서에서 제외 |
주의할 점은 OpenAPI의 @RequestBody와 Spring MVC의 @RequestBody는 서로 다른 패키지의 어노테이션이라는 것이다.
Spring @RequestBody
→ JSON을 Java 객체로 바인딩
OpenAPI @RequestBody
→ 요청 본문을 문서에 설명
문서화 예제
@Tag(
name = "상품 API",
description = "상품 조회와 관리"
)
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService service;
@Operation(
summary = "상품 상세 조회",
description = "상품 ID로 상품을 조회합니다."
)
@ApiResponse(
responseCode = "200",
description = "조회 성공"
)
@ApiResponse(
responseCode = "404",
description = "상품 없음"
)
@GetMapping("/{id}")
public ProductResponse findById(
@Parameter(
description = "상품 ID",
example = "1"
)
@PathVariable Long id
) {
return service.findById(id);
}
}
Response DTO에도 설명을 붙일 수 있다.
@Schema(description = "상품 조회 응답")
public record ProductResponse(
@Schema(
description = "상품 ID",
example = "1"
)
Long id,
@Schema(
description = "상품명",
example = "무선 마우스"
)
String name,
@Schema(
description = "상품 가격",
example = "15000"
)
int price
) {
}
Controller, 메서드와 DTO에 메타데이터를 추가하면 Swagger UI에서 Endpoint와 Schema를 확인하고 직접 요청을 테스트할 수 있다.
전체 흐름 다시 보기
Request DTO
→ @Valid로 형식 검증
검증 실패
→ MethodArgumentNotValidException
→ @RestControllerAdvice
→ 일관된 오류 JSON
Starter 추가
→ Auto-Configuration
→ 필요한 Bean 자동 등록
Service 실행
→ AOP Proxy
→ 로깅·트랜잭션 적용
Entity
→ JPA가 테이블과 매핑
JpaRepository
→ CRUD·페이징·정렬
Query Method / JPQL / QueryDSL
→ 다양한 조회 구현
@Transactional
→ 영속성 컨텍스트
→ 변경 감지
→ Flush
→ Commit 또는 Rollback
OpenAPI
→ Controller와 DTO를 기반으로
→ Swagger 문서 자동 생성
핵심 정리
검증과 예외 처리
Controller
→ 형식 검증
Service
→ 비즈니스 규칙 검증
@RestControllerAdvice
→ 예외를 일관된 JSON으로 변환
자동 설정
Starter
→ 관련 의존성 제공
Auto-Configuration
→ 클래스패스를 기반으로 Bean 자동 구성
로그와 AOP
SLF4J
→ 로그 API
Logback
→ 실제 로그 구현체
AOP
→ 로깅·트랜잭션 등 횡단 관심사 분리
JPA
Java Entity
↕
JPA / Hibernate
↕
Database Table
연관관계
@ManyToOne
→ 외래키를 가진 쪽이 주인인 경우가 일반적
@OneToMany(mappedBy = "...")
→ 반대편 조회 관계
Repository 조회 방식
단순 CRUD
→ JpaRepository
간단한 조건
→ Query Method
복잡한 고정 쿼리
→ @Query와 JPQL
동적 조건
→ QueryDSL
트랜잭션
정상 종료
→ Commit
RuntimeException
→ Rollback
Self-Invocation
→ Proxy를 거치지 않아
@Transactional 미적용 가능
API 문서화
Springdoc OpenAPI
→ Controller·DTO 분석
→ OpenAPI JSON
→ Swagger UI
복습 질문
- 형식 검증과 비즈니스 검증은 각각 어느 계층에서 수행해야 하는가?
- @Valid와 @Validated의 가장 큰 차이는 무엇인가?
- @RestControllerAdvice를 사용하는 이유는 무엇인가?
- BasicErrorController와 전역 예외 처리기의 차이는 무엇인가?
- Starter와 Auto-Configuration은 각각 어떤 역할을 하는가?
- SLF4J와 Logback은 어떤 관계인가?
- AOP에서 Advice와 Pointcut은 무엇을 의미하는가?
- joinPoint.proceed()를 호출하지 않으면 어떻게 되는가?
- JPA와 Hibernate는 어떤 관계인가?
- flush()와 commit()은 무엇이 다른가?
- EnumType.ORDINAL보다 EnumType.STRING이 안전한 이유는 무엇인가?
- mappedBy가 가리키는 것은 DB 컬럼명인가, Entity 필드명인가?
- 실무에서 @ManyToMany 대신 중간 Entity를 만드는 이유는 무엇인가?
- Query Method와 JPQL, QueryDSL은 각각 어떤 조회에 적합한가?
- JPA의 변경 감지는 어느 시점에 UPDATE SQL을 실행하는가?
- @Transactional Self-Invocation 문제가 발생하는 이유는 무엇인가?
- Checked Exception에서도 Rollback하려면 어떻게 해야 하는가?
- 낙관적 Lock과 비관적 Lock은 어떤 상황에 적합한가?
- OpenAPI의 @RequestBody와 Spring의 @RequestBody는 어떤 차이가 있는가?
- Swagger UI와 /v3/api-docs는 각각 무엇을 제공하는가?
'개발 > SKALA 4기' 카테고리의 다른 글
| [SKALA] 데이터 분석 및 Python 기초 ③: Pandas, 분석 자동화 (0) | 2026.08.06 |
|---|---|
| [SKALA] 데이터 분석 및 Python 기초 ① (0) | 2026.08.04 |
| [SKALA] 컴포넌트 스캔과 Spring 컨테이너, HTTP 요청 바인딩 정리 (0) | 2026.07.29 |
| [SKALA] Spring Boot 설정 관리와 MVC·Actuator 정리 (0) | 2026.07.29 |
| [SKALA] 객체지향(OOP)과 Spring Boot 핵심 정리 (0) | 2026.07.27 |