1. spring의 예외처리방식
Spring에서 “예외 처리”는 크게 (1) 예외가 전파/롤백되는 방식과 (2) 컨트롤러 계층에서 HTTP 응답으로 바꾸는 방식으로 나눠서 본다.
1) 예외 전파 & 트랜잭션 롤백 규칙 (@Transactional)
- Unchecked 예외(RuntimeException, Error)
→ 기본적으로 트랜잭션 롤백 - Checked 예외(Exception)
→ 기본적으로 롤백 안 함 (커밋될 수 있음)
설정 가능:
- @Transactional(rollbackFor = Exception.class) : 체크 예외도 롤백
- @Transactional(noRollbackFor = 특정예외.class) : 런타임 예외여도 롤백 제외 가능
주의사항 :
- 예외를 try-catch로 잡고 삼키면(throw 안 하면) 스프링은 “정상 처리”로 보고 롤백 안 됩니다.
- 프록시 기반이라 같은 클래스 내부 self-invocation(자기 메서드 호출)에서는 @Transactional이 적용 안 되는 함정이 있습니다.
ex)
1-1. 기본 롤백 규칙: RuntimeException이면 롤백, Checked Exception이면 기본은 커밋
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository repo;
@Transactional
public void runtimeExceptionRollback() {
repo.save(new Member("a"));
throw new IllegalStateException("런타임 예외"); // ✅ 기본 롤백
}
@Transactional
public void checkedExceptionDefaultCommit() throws Exception {
repo.save(new Member("b"));
throw new Exception("체크 예외"); // ⚠️ 기본은 롤백 안 함(커밋될 수 있음)
}
}
1-2. 체크 예외도 롤백시키기: rollbackFor
@Transactional(rollbackFor = Exception.class)
public void checkedExceptionRollback() throws Exception {
repo.save(new Member("c"));
throw new Exception("체크 예외"); // ✅ 롤백
}
1-3. 예외를 잡아먹으면 롤백 안 됨
@Transactional
public void swallowedExceptionCommit() {
repo.save(new Member("d"));
try {
throw new IllegalStateException("문제");
} catch (Exception e) {
// 예외를 밖으로 던지지 않으면 스프링 입장에선 "정상 종료"로 보고 커밋될 수 있음
}
}
1-4. 잡고 싶으면 롤백 마킹을 직접
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@Transactional
public void catchButRollback() {
repo.save(new Member("e"));
try {
throw new IllegalStateException("문제");
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // ✅ 강제 롤백
}
}
1-5. @Transactional이 “안 먹는” 케이스(자기 자신 메서드 호출)
@Service
public class AService {
@Transactional
public void txMethod() {
// 트랜잭션이 걸리길 기대
}
public void outer() {
txMethod(); // ⚠️ 같은 클래스 내부 호출은 프록시를 안 거쳐서 트랜잭션이 적용 안 될 수 있음
}
}
2) MVC/REST에서 예외를 HTTP 응답으로 변환하는 방식
A. @ExceptionHandler (컨트롤러 내부)
특정 컨트롤러에서만 예외 처리하고 싶을 때.
- @ExceptionHandler(MyException.class)
- 반환은 ResponseEntity 혹은 객체(→ JSON)
B. @ControllerAdvice / @RestControllerAdvice (전역 처리)
프로젝트 전체 컨트롤러에 공통 적용.
- 실무에서 제일 흔한 패턴
- 예외별로 상태코드/메시지 통일 가능
C. ResponseStatusException / @ResponseStatus
- throw new ResponseStatusException(HttpStatus.NOT_FOUND, "없음")
- 또는 커스텀 예외 클래스에 @ResponseStatus(HttpStatus.BAD_REQUEST) 지정
ex)
2-1. DTO 검증 실패 → MethodArgumentNotValidException → @RestControllerAdvice가 JSON 응답으로 변환
DTO
public class SignupRequest {
@NotBlank(message = "사용자 ID는 필수입니다")
@Size(min = 4, max = 20, message = "사용자 ID는 4~20자")
private String userId;
}
Controller
@PostMapping("/api/members")
public ResponseEntity<Void> signup(@Valid @RequestBody SignupRequest req) {
return ResponseEntity.ok().build();
}
전역 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handle(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(fe -> fieldErrors.put(fe.getField(), fe.getDefaultMessage()));
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", "VALIDATION_ERROR");
body.put("errors", fieldErrors);
return ResponseEntity.badRequest().body(body);
}
}
응답 예시(400)
{
"code": "VALIDATION_ERROR",
"errors": {
"userId": "사용자 ID는 필수입니다"
}
}
2-2. 비즈니스 예외(예: 중복 ID) → 커스텀 예외 → Advice에서 409로 변환
public class DuplicateUserIdException extends RuntimeException {
public DuplicateUserIdException(String msg) { super(msg); }
}
@Service
public class MemberService {
public void signup(SignupRequest req) {
if (/*이미 존재*/) throw new DuplicateUserIdException("이미 사용 중인 ID입니다");
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateUserIdException.class)
public ResponseEntity<Map<String, Object>> handleDup(DuplicateUserIdException e) {
return ResponseEntity.status(409).body(Map.of("code","DUPLICATE_ID","message", e.getMessage()));
}
}
3) Spring Boot 기본(자동) 예외 응답
별도 처리 안 하면 Boot가 기본 에러 핸들러로:
- HTML(브라우저) 또는 JSON(API 요청) 형태로 에러 응답 생성
- BasicErrorController + ErrorAttributes 기반
(보통 API 서버면 이 기본 응답 대신, Advice로 “에러 포맷”을 통일합니다.)
ex)
3-1. 예: 존재하지 않는 URL 호출
{
"timestamp": "2026-01-01T06:00:00.000+00:00",
"status": 404,
"error": "Not Found",
"path": "/no-such-api"
}
4) 필터/인터셉터/시큐리티에서의 예외
- Filter 체인에서 터진 예외는 @ControllerAdvice로 안 잡히는 경우가 많습니다.
- Spring Security는 보통:
- 인증 실패: AuthenticationEntryPoint
- 인가 실패: AccessDeniedHandler
쪽에서 응답을 만들어줍니다.
주의사항:
- **@ControllerAdvice는 ‘컨트롤러까지 들어온 예외’**를 주로 처리합니다.
- 필터(Filter)에서 터진 예외는 Advice가 못 잡는 경우가 많습니다.
- Spring Security는 자체 진입점/핸들러로 응답을 만드는 흐름이 일반적입니다.
ex)
4-1. Filter에서 예외가 터짐 → Advice로 안 잡힘(대표 케이스)
@Component
public class ApiKeyFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String apiKey = req.getHeader("X-API-KEY");
if (apiKey == null) {
throw new IllegalStateException("API KEY 없음"); // ⚠️ 여기서 터지면 ControllerAdvice가 못 받을 수 있음
}
chain.doFilter(req, res);
}
}
해결 패턴(필터에서 직접 응답 작성)
if (apiKey == null) {
res.setStatus(401);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"API KEY가 필요합니다\"}");
return;
}
4-2. Interceptor에서 예외/차단 처리
@Component
public class AdminInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
if (!"admin".equals(req.getHeader("X-ROLE"))) {
res.setStatus(403);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"권한 없음\"}");
return false; // ✅ 여기서 요청 중단 (컨트롤러 미진입)
}
return true;
}
}
등록:
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AdminInterceptor adminInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminInterceptor).addPathPatterns("/api/admin/**");
}
}
4-3. Spring Security: 인증/인가 예외는 보통 여기서 응답을 만듦
- 인증 안 됨(401): AuthenticationEntryPoint
- 권한 없음(403): AccessDeniedHandler
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex)
throws IOException {
res.setStatus(401);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"로그인이 필요합니다\"}");
}
}
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ex)
throws IOException {
res.setStatus(403);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"접근 권한이 없습니다\"}");
}
}
Security 설정에 연결:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
RestAuthenticationEntryPoint entryPoint,
RestAccessDeniedHandler deniedHandler) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.exceptionHandling(ex -> ex
.authenticationEntryPoint(entryPoint)
.accessDeniedHandler(deniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.build();
}
2. 해당 예외 처리를 실무에서 사용하지 못할 경우.
1) 트랜잭션 롤백 규칙을 모르면 생기는 문제(정합성/데이터 오염)
- 부분 저장(Partial Commit)
예: 회원 저장은 됐는데, 권한/프로필/연관 테이블 저장 중 예외 → 롤백이 안 돼서 DB에 “반쯤 생성된 회원”이 남음. - 체크 예외로 인해 커밋되는 사고
체크 예외는 기본 롤백이 아니라서, “실패했다고 생각했는데 DB엔 반영”되는 케이스가 생김. - try-catch로 예외를 삼켜서 커밋
에러를 로그만 찍고 정상 리턴 → 스프링은 성공으로 보고 커밋 → 나중에 데이터 꼬임이 누적. - 재처리/복구 비용 증가
데이터가 한번 오염되면 운영에서 수작업 수정, 배치 복구, 고객 응대가 필요해짐.
2) MVC/REST 예외를 응답으로 변환 못하면 생기는 문제(프론트/QA/사용자 경험)
- 에러 응답 포맷이 제각각
어떤 API는 {message}, 어떤 API는 Boot 기본 {status,error,path}…
→ 프론트가 케이스별 파싱 분기 지옥 + 버그 증가. - 필드 검증 에러를 제대로 못 줌
“아이디가 비었어요” 같은 정확한 안내 대신 그냥 500/400만 떨어짐
→ 사용자 이탈 + CS 증가. - 상태코드가 엉망
중복인데 500, 권한없는데 200+메시지…
→ 프론트가 정상/에러 판단 못해서 화면/로직 꼬임. - 디버깅 난이도 상승
QA가 재현해도 “왜 실패했는지” 정보가 없거나 매번 다름 → 수정 속도 느려짐.
3) Spring Boot 기본(자동) 에러 응답에만 의존하면 생기는 문제(보안/일관성/운영)
- 환경에 따라 메시지 노출이 달라짐
개발/운영 설정에 따라 stacktrace/exception 메시지가 노출되거나 숨겨짐 → 보안 리스크/혼선. - API 표준 계약이 깨짐
“우리 API는 항상 code/message/errors 준다” 같은 계약을 못 지킴 → 프론트/외부 연동 불안정. - 로그/모니터링과 연결이 어려움
에러코드 체계가 없으니 대시보드에서 원인 분류(중복/검증/권한/서버오류)가 힘들어짐.
4) 필터/인터셉터/시큐리티 예외를 다루지 못하면 생기는 문제(인증/인가/장애)
- 401/403이 아니라 500이 나감
인증 실패가 서버 장애처럼 보이고, 프론트는 “로그인 유도” 대신 “오류 화면”을 띄움. - CORS/프리플라이트가 깨짐
필터에서 예외나 응답을 잘못 만들면 OPTIONS 요청이 실패 → 브라우저에서 아예 호출 자체가 막힘. - Security 예외 응답 포맷 불일치
일반 API는 {code,message}인데, 인증 실패만 기본 포맷/HTML로 내려감 → 프론트 처리 실패(토큰 만료 처리 등). - 응답이 이미 커밋된 뒤 예외
필터/시큐리티 체인에서 예외 처리 미흡하면 “응답을 쓰다가 또 예외”가 터져 로그만 남고 클라이언트는 애매한 응답을 받음.
* try-catch에 과하게 의존하던가 비즈니스 로직에서 예외를 하나하나 처리할 경우
1) 코드 품질 문제
- 중복 폭발: 모든 메서드에 try { ... } catch (...) { ... }가 깔리면서 같은 응답 생성/로그/메시지 처리 코드가 반복됨.
- 관심사 섞임: 원래 로직(회원가입/주문/결제) + 에러 응답 포맷팅 + 로깅 + 상태코드 결정이 한 덩어리가 됨 → 유지보수 난이도 급상승.
- 읽기/리뷰 난이도 증가: 핵심 로직이 try-catch에 묻혀서 “이 API가 뭘 하는지” 파악이 느려짐. 실수도 늘어남.
- 일관성 붕괴: 어떤 곳은 400, 어떤 곳은 409, 어떤 곳은 200+message… 팀원이 늘수록 더 엉망이 됨.
2) 기능/정합성 문제(특히 트랜잭션)
- 예외 삼킴으로 커밋: 서비스에서 예외를 잡고 정상 리턴하면 스프링은 성공으로 보고 트랜잭션이 커밋될 수 있음 → “반쯤 저장된 데이터”가 남음.
- 롤백 조건 누락: 체크 예외/특정 예외에서 롤백이 필요해도 각 메서드에서 빠뜨리기 쉬움.
- 복구 불가능한 데이터 오염: 한번 꼬인 데이터는 나중에 발견되고, 수정 비용이 훨씬 큼(수동 수정/배치/CS).
3) 장애 대응/운영 문제
- 로그 품질이 나빠짐: 여기저기서 각각 log.error(...) 찍다 보면
- 같은 에러가 여러 번 찍히거나(중복 로그)
- 반대로 중요한 맥락(요청ID/사용자/에러코드)이 누락됨.
- 모니터링/통계가 안 됨: 전역 에러코드/상태코드 체계가 없으니 “검증오류가 많은지”, “권한오류 폭증인지”를 지표로 못 봄.
- 버그 수정 속도 저하: 에러 처리 로직이 분산돼 있어서 수정하려면 파일을 수십 군데 찾아다녀야 함.
4) 클라이언트(프론트/앱) 연동 문제
- 응답 포맷이 들쭉날쭉: 프론트가 케이스별 파싱 분기를 계속 추가하게 됨 → 프론트 버그/개발기간 증가.
- HTTP 의미가 깨짐: “중복”인데 500, “권한 없음”인데 200…
→ 재시도/로그아웃/토스트 메시지 같은 UX 처리를 제대로 못 함. - 국제화/메시지 정책 관리 불가: 메시지를 여기저기 하드코딩하면 문구 수정, 정책 변경(노출 수준 조정)이 거의 불가능해짐.
5) 보안/정보노출 리스크
- 예외 메시지 그대로 노출: try-catch에서 e.getMessage()를 그대로 내려주면 내부 테이블명/쿼리/스택 정보가 새어 나갈 수 있음.
- 필터/시큐리티 예외 대응 실패: 인증/인가에서 발생한 예외를 컨트롤러에서 처리하려다 못 잡고 500으로 터지는 케이스가 생김.
6) 테스트/확장 문제
- 테스트가 무의미해짐: 전역 처리라면 “예외 → 어떤 응답”이 일관되게 검증 가능한데, 분산 처리면 케이스마다 테스트를 새로 짜야 함.
- 요구사항 변경에 취약: “에러 포맷에 traceId 추가” 같은 변경이 오면 전역은 1곳 수정인데, 분산은 전부 수정.
즉 try-catch로 “개별 대응”하면 단기적으로는 빨라 보이지만, 실무에서는 정합성 사고(커밋/롤백) + 응답/상태코드 불일치 + 유지보수 지옥으로 비용이 크게 늘어납니다.
* try-catch를 사용해도 괜찮은것.
A. 외부 경계(네트워크/파일/외부 시스템) 호출
- HTTP 호출(WebClient/RestTemplate), 메시지큐, 파일 IO, S3 업로드 등
- 이유: 여기서 터지는 예외는 도메인(비즈니스) 예외로 치환해야 내부 로직이 깔끔해짐
try {
externalClient.send(...);
} catch (TimeoutException e) {
throw new ExternalTimeoutException("외부 시스템 타임아웃", e);
} catch (Exception e) {
throw new ExternalSystemException("외부 시스템 오류", e);
}
→ 그리고 @RestControllerAdvice에서 ExternalTimeoutException은 504, ExternalSystemException은 502/500 같은 식으로 통일.
B. 리소스 정리 / 보장 (close, unlock, 임시파일 삭제)
- try-with-resources 사용이 정석
- 이유: “실패해도 반드시 정리”가 목적이지, 에러 응답을 만들 목적이 아님
C. “예외가 정상 흐름인” 케이스를 명확히 다룰 때(아주 제한적으로)
예: 캐시 읽기 실패하면 DB로 폴백
try {
return cache.get(key);
} catch (Exception e) {
log.warn("캐시 실패, DB로 폴백", e);
return repo.find(...);
}
*try- catch를 쓰면 안되는 부분.
A. 컨트롤러에서 비즈니스 예외를 try-catch로 응답 만들기
- 문제: 포맷/상태코드가 컨트롤러마다 달라짐
- 정석: 컨트롤러는 얇게, 예외는 @RestControllerAdvice가 응답으로 변환
B. 서비스에서 예외를 잡고 “정상 리턴”
- 문제: 트랜잭션이 커밋될 수 있음(데이터 오염)
- 정석: 서비스는 예외를 던져서 실패를 명확히 만들 것
C. “모든 예외 catch(Exception)”로 뭉개기
- 문제: 원인 분류 불가 + 장애 탐지 늦어짐 + 디버깅 지옥
- 정석: 외부 경계에서만 필요한 범위로 잡고, 내부는 타입별로 처리
전체 예시
시나리오
- 요청: POST /api/members
- 검증: userId 필수, 4~20자
- 비즈니스: userId 중복이면 실패(409)
- 외부: 이메일 인증코드 발송(외부 시스템, 실패 가능)
권장 패턴
원칙
- 컨트롤러: try-catch 없음, 얇게
- 서비스: 비즈니스 예외는 던짐(런타임)
- 외부 호출은 try-catch로 치환(변환) 후 던짐
- 응답 포맷은 @RestControllerAdvice가 통일
1) DTO
public class SignupRequest {
@NotBlank(message = "사용자 ID는 필수입니다")
@Size(min = 4, max = 20, message = "사용자 ID는 4~20자")
private String userId;
@NotBlank(message = "이메일은 필수입니다")
private String email;
}
2) 컨트롤러(try-catch 없음)
@PostMapping("/api/members")
public ResponseEntity<Void> signup(@Valid @RequestBody SignupRequest req) {
memberService.signup(req);
return ResponseEntity.status(201).build();
}
3) 서비스(비즈니스 규칙 + 외부 호출은 치환)
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final MailClient mailClient;
@Transactional
public void signup(SignupRequest req) {
if (memberRepository.existsByUserId(req.getUserId())) {
throw new DuplicateUserIdException("이미 사용 중인 ID입니다");
}
memberRepository.save(new Member(req.getUserId(), req.getEmail()));
// 외부 시스템: 실패 가능 → try-catch로 도메인 예외로 치환 후 throw
try {
mailClient.sendVerificationCode(req.getEmail());
} catch (Exception e) {
throw new ExternalMailSendException("이메일 발송에 실패했습니다", e);
}
}
}
4) 전역 예외 처리(응답 통일)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValid(MethodArgumentNotValidException e) {
Map<String, String> errors = new LinkedHashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage()));
return ResponseEntity.badRequest().body(Map.of("code","VALIDATION_ERROR","errors",errors));
}
@ExceptionHandler(DuplicateUserIdException.class)
public ResponseEntity<?> handleDup(DuplicateUserIdException e) {
return ResponseEntity.status(409).body(Map.of("code","DUPLICATE_ID","message", e.getMessage()));
}
@ExceptionHandler(ExternalMailSendException.class)
public ResponseEntity<?> handleExternal(ExternalMailSendException e) {
return ResponseEntity.status(502).body(Map.of("code","MAIL_SEND_FAILED","message", e.getMessage()));
}
}
비권장
특징
- 컨트롤러에서 try-catch로 응답을 직접 만듦
- 서비스에서 예외를 잡고 “정상 리턴” → 커밋/오염 위험
- 에러 포맷이 API마다 달라질 가능성 높음
1) 컨트롤러가 모든 걸 try-catch로 처리
@PostMapping("/api/members")
public ResponseEntity<?> signup(@Valid @RequestBody SignupRequest req) {
try {
memberService.signup(req);
return ResponseEntity.status(201).body(Map.of("message","ok"));
} catch (DuplicateUserIdException e) {
return ResponseEntity.status(400).body("중복입니다"); // ⚠️ 409가 아니라 400, 포맷도 문자열
} catch (Exception e) {
return ResponseEntity.status(200).body("실패했지만 200"); // ⚠️ 상태코드 의미 붕괴
}
}
문제점
- 중복인데 400, 심지어 어떤 곳은 200… 클라이언트 로직 꼬임
- 응답 포맷이 문자열/객체 뒤섞임 → 프론트 파싱 분기가 어려워짐
2) 서비스에서 예외 삼키기(정합성 사고 포인트)
@Transactional
public void signup(SignupRequest req) {
try {
if (memberRepository.existsByUserId(req.getUserId())) {
throw new DuplicateUserIdException("중복");
}
memberRepository.save(new Member(req.getUserId(), req.getEmail()));
mailClient.sendVerificationCode(req.getEmail());
} catch (Exception e) {
// ⚠️ 로그만 찍고 끝(또는 무시)
// 여기서 메서드가 정상 종료되면 트랜잭션 커밋될 수 있음 → "반쯤 성공" 데이터 생성
}
}
문제점
- 외부 메일 실패가 발생해도 회원은 저장됨(정책이 그게 아니라면 “데이터 오염”)
- 실패를 호출자가 알 수 없음(응답은 성공처럼 나갈 수 있음)
결론: try-catch는 “응답 만들기”가 아니라 “경계 처리”에만
- 외부 호출 실패를 의미 있는 도메인 예외로 변환
- 리소스 정리, 폴백
Q1. @Transactional에서 Runtime 예외는 롤백인데, Checked 예외는 왜 기본 롤백이 아닌가요?
A. 스프링의 기본 정책은 “프로그래밍 오류/예상치 못한 실패(Runtime)”는 롤백, “업무적으로 예상 가능한 예외(Checked)”는 호출자가 처리할 수도 있다고 보고 커밋될 수 있게 둔 겁니다. 그래서 Checked 예외도 롤백이 필요하면 rollbackFor로 명시합니다.
Q2. 서비스에서 try-catch로 예외를 잡아야 하는 경우에도 롤백이 되게 하려면 어떻게 하나요?
A. ① 잡고 다시 throw(도메인 예외로 치환해서 throw)하거나, ② 정말 잡고 끝내야 한다면 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()로 롤백 마킹합니다. 실무에서는 보통 “삼키지 말고 치환해서 던진다”를 기본으로 둡니다.
Q3. self-invocation 때문에 트랜잭션이 안 걸리면 실무에서 어떻게 해결하나요?
A. 가장 흔한 해결은 구조 분리입니다. 트랜잭션이 필요한 메서드를 다른 서비스 빈으로 분리해서 “프록시를 거치게” 만듭니다. 또는 호출 경로를 컨트롤러→서비스 빈으로 유지합니다(같은 클래스 내부 호출 피함). 특수하게 AspectJ weaving을 쓰는 방법도 있지만 일반적이지 않습니다.
Q4. 전역 @RestControllerAdvice를 쓰면 모든 예외를 다 거기서 처리하면 되나요?
A. 원칙적으로는 “컨트롤러 레벨 예외”는 전역 Advice에서 통일하는 게 맞지만, 필터/시큐리티 체인에서 발생한 예외는 Advice 범위를 벗어나기 쉬워서 401/403 같은 건 Security 핸들러(EntryPoint/DeniedHandler)에서 처리합니다. 즉 “레이어별로 책임이 다릅니다.”
Q5. 중복 ID 같은 비즈니스 예외를 409로 주는 이유는 뭔가요?
A. 409 Conflict는 “현재 리소스 상태와 충돌”을 표현하기에 적합합니다(이미 존재하는 ID로 생성 시도). 400으로 처리해도 동작은 하지만, 상태코드를 의미 있게 쓰면 프론트가 예외를 더 정확히 분기(중복 안내/재시도 금지 등)할 수 있습니다.
Q6. @Valid @RequestBody 말고 @RequestParam/@PathVariable 검증은 어떤 예외가 터지고 어떻게 처리하나요?
A. @Validated를 클래스/컨트롤러에 붙이고 @Min, @NotBlank 같은 검증을 파라미터에 걸면 보통 **ConstraintViolationException**이 발생합니다. 전역 @RestControllerAdvice에서 이 예외를 잡아 400으로 통일하고, violation 정보를 원하는 포맷으로 내려줍니다.
Q7. JSON이 깨졌거나 타입이 안 맞으면(예: age에 "abc") 검증 예외가 아니라 뭐가 터지나요?
A. 컨트롤러 진입 전 Jackson 파싱 단계에서 실패하므로 보통 HttpMessageNotReadableException(또는 그 내부 cause)이 발생합니다. 이건 검증 실패와 성격이 달라서 “요청 바디 형식 오류”로 400을 내려주고 메시지는 일반화하는 게 안전합니다.
Q8. 전역 핸들러에 @ExceptionHandler(Exception.class) 같은 마지막 처리기를 두면 뭐가 위험하죠?
A. 너무 넓게 잡으면 원인 분류가 어려워지고(모든 게 500), 실수로 비즈니스 예외까지 500으로 덮어버릴 수 있습니다. 그래서 “구체 예외 핸들러를 위에 두고”, 마지막 핸들러는 진짜 예측 불가 오류만 처리하게 하며, 응답 메시지는 일반화하고 상세 원인은 로그로 남깁니다.
Q9. 컨트롤러에서 응답 바디를 직접 쓰는 것 말고 “표준 에러 응답 DTO”를 만들어서 통일하려면 어떻게 하나요?
A. ErrorResponse(code, message, errors, timestamp, path, traceId) 같은 DTO를 만들고, 전역 핸들러와 Security 핸들러(EntryPoint/DeniedHandler)가 모두 동일 DTO를 반환하게 맞춥니다. 이렇게 하면 프론트가 항상 같은 규격으로 처리합니다.
Q10. 외부 메일 발송이 실패했을 때, “회원 저장은 커밋하고 메일은 나중에 재시도”하고 싶으면 어떻게 설계하나요?
A. 같은 트랜잭션 안에서 외부 호출 실패를 throw하면 저장이 롤백됩니다. 커밋을 유지하려면 트랜잭션을 분리해야 합니다. 대표 선택지는
- 회원 저장 커밋 후 **이벤트 발행(비동기)**로 메일 발송
- 장애 내구성이 더 필요하면 아웃박스 패턴(DB에 발송 작업을 기록하고 워커가 처리)
즉 “DB 정합성 트랜잭션”과 “외부 부수효과”를 분리합니다.
'Daily Dev Q&A' 카테고리의 다른 글
| Garbage Collection (0) | 2026.01.07 |
|---|---|
| 자바 가상 머신 (0) | 2026.01.07 |
| 스프링 프레임 워크 (0) | 2025.12.22 |
| 트랜잭션 (0) | 2025.12.21 |
| 람다표현식 (0) | 2025.12.19 |