728x90

아래 글은 김영한님의 강의 (스프링DB-1편)으로 공부하고 트랜잭션을 사용해보면서 정리한 내용이다.

트랜잭션 격리수준에 대해서는 ([MySQL] 트랜잭션의 격리 수준(Isolation Level)에 대해 쉽고 완벽하게 이해하기 )블로그를 보았다.

 

 

Transaction 어노테이션이 없어도 동작되는 이유가 뭐지?

이 어노테이션은 언제 사용해야하는가?

 

이런 질문에 대한 답을 구하기 전에

먼저 트랜잭션 개념에 대한 정리가 필요했다. 

 

트랜잭션

  1. 트랜잭션은 무엇인가?
    1. 트랜잭션 : 거래 
    2. 트랜잭션은 이런 데이터 상의 거래를 안전하게 처리하고 보장해준다.
  2. 트랜잭션 ACID:
      1. 원자성 (Atomicity):
        • 예시: 은행 계좌 이체
        • 고객이 A 계좌에서 B 계좌로 일정 금액을 이체하는 트랜잭션을 고려하면, 이체 작업은 A 계좌에서의 출금과 B 계좌로의 입금 두 단계로 이루어진다. 이 두 단계는 원자성을 가져야 하며, 하나의 단계가 실패하면 다른 단계도 롤백되어야한다.
      2. 일관성 (Consistency):
        • 예시: 주문 및 재고 관리
        • 상품 주문 트랜잭션에서는 주문이 들어올 때마다 재고가 감소해야 한다. 이때 주문 트랜잭션이 성공하면 재고가 감소하고, 실패하면 재고는 변하지 않아야 한다. 이를 통해 데이터베이스는 일관된 상태를 유지한다.
      3. 격리성(고립성) (Isolation):
        • 예시: 동시 계좌 이체
        • 여러 사용자가 동시에 계좌 간 이체를 시도하는 상황에서, 각각의 트랜잭션이 다른 트랜잭션에 영향을 주지 않아야 한다. A 계좌에서의 이체 작업이 완료되기 전까지는 B 계좌에서의 이체 작업을 다른 트랜잭션이 간섭하지 않도록 보장되어야 한다.
        • 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation level)을 선택할 수 있다.
      4. 지속성 (Durability):
        • 예시: 주문 정보의 저장
        • 고객이 주문을 완료하면 해당 주문 정보는 데이터베이스에 영구적으로 저장되어야 한다. 시스템 장애 또는 다른 문제가 발생하더라도 주문 정보는 지속되어야 하며, 고객이 주문을 확인할 수 있어야 한다. 데이터베이스 트랜잭션의 특성을 나타내는 약어로 Atomicity(원자성), Consistency(일관성), Isolation(고립성), Durability(지속성)을 나타낸다.

 

격리성을 완벽하게 보장하기 위해서는

트랜잭션을 순서대로 실행할 수 있지만 이렇게 동작할 경우 동시처리 성능이 나빠진다.

트랜잭션은 기본적으로 동시에 여러 개가 실행될 수 있다.

격리 수준은 이러한 동시성에서 발생할 수 있는 문제들을 어떻게 제어할 것인지를 정의한다.

즉,트랜잭션 격리 수준은 여러 트랜잭션이 동시에 실행될 때, 특정 트랜잭션이 다른 트랜잭션에서 데이터를 변경하거나 조회할지 등 간섭할지 여부를 제어하는데 사용된다. 이처럼 격리 수준은 트랜잭션 간의 데이터 일관성을 유지하기 위해 중요하다.

 

<트랜잭션 격리 수준 Isolation level>

  1. READ UNCOMMITTED (커밋되지 않은 읽기):
    1. 다른 트랜잭션이 커밋되지 않은 데이터를 읽을 수 있다. 이로 인해 Dirty Read(더티 리드), Non-Repeatable Read(반복 불가능한 읽기) 등의 문제가 발생할 수 있다.
      1. 활용 예시: 실시간 보고서 생성 시에 최신 데이터를 빠르게 반영하고자 할 때 사용될 수 있다.
      2. Dirty Read(더티 리드)는 다른 트랜잭션이 아직 커밋되지 않은 데이터를 읽는 현상을 말한다. 트랜잭션이 데이터를 변경하고 커밋하기 전에 다른 트랜잭션이 해당 데이터를 읽으면, 그 읽힌 데이터는 커밋되지 않아 롤백될 수 있으므로 정확하지 않거나 무의미한 데이터가 될 수 있다.
      3. 트랜잭션 격리 수준이 READ COMMITTED 이상으로 설정되면 Dirty Read 문제를 방지할 수 있다. READ COMMITTED 이상의 격리 수준에서는 다른 트랜잭션이 아직 커밋되지 않은 데이터를 읽지 못하도록 보장되기 때문이다.
  2. READ COMMITTED (커밋된 읽기):
    1. 커밋된 데이터만 읽을 수 있다. 다른 트랜잭션이 작업을 커밋하기 전까지 데이터를 읽을 수 없어 Dirty Read는 발생하지 않지만, Non-Repeatable Read 문제가 발생할 수 있다.
      1. 활용 예시: 데이터베이스의 무결성을 중시하는 경우에 사용될 수 있습니다.
    2. Non-Repeatable Read : 트랜잭션이 동일한 쿼리를 수행했을 때, 두 번째 읽기에서 다른 값을 반환하는 문제를 가리킨다. 이는 동일한 트랜잭션 안에서 동일한 데이터를 읽을 때 일관성이 유지되지 않는 상황을 말한다. 
      1. 트랜잭션 A:
        • 시작 시점에 데이터 X를 읽습니다. (값: 100)
      2. 트랜잭션 B:
        • 트랜잭션 A가 진행 중일 때, 데이터 X를 수정하고 커밋합니다. (값: 200)
      3. 트랜잭션 A:
        • 이어서 동일한 데이터 X를 다시 읽으려고 합니다.
        • Non-Repeatable Read 문제: 이전에 읽은 값과 다르게 새로운 값 (200)를 읽게 됩니다.
    3. 이렇게 트랜잭션이 같은 데이터를 두 번 이상 읽을 때, 중간에 다른 트랜잭션이 해당 데이터를 수정하고 커밋하면서 값이 변경되어 버리는 현상이 발생한다.이러한 상황에서는 트랜잭션 내에서 일관성이 깨지게 되어 Non-Repeatable Read 문제가 발생합니다.
    4. 트랜잭션 격리 수준이 REPEATABLE READ 이상으로 설정되면 Non-Repeatable Read 문제는 방지된다. REPEATABLE READ 이상의 격리 수준에서는 동일한 트랜잭션 안에서 같은 데이터를 여러 번 읽어도 그 값이 변경되지 않음이 보장된다.
  3. REPEATABLE READ (반복 가능한 읽기):
    1. 동일한 쿼리를 여러 번 실행해도 항상 동일한 결과를 보장한다. 다른 트랜잭션이 해당 범위의 데이터를 변경할 수 없도록 하지만 새로운 레코드가 추가되는 경우에 부정합이 생길 수 있다. 
    2. 활용 예시: 일정 시간 동안 동일한 데이터를 여러 번 읽어야 하는 작업에서 사용될 수 있습니다.
    3. REPEATABLE READ는 트랜잭션 번호를 참고하여 자신보다 먼저 실행된 트랜잭션의 데이터만을 조회한다. 만약 테이블에 자신보다 이후에 실행된 트랜잭션의 데이터가 존재한다면 언두 로그를 참고해서 데이터를 조회한다.
  4. SERIALIZABLE (직렬화 가능):
    1. 트랜잭션 간에 완전한 격리를 보장한다. 다른 트랜잭션이 해당 범위의 데이터에 접근하지 못하도록 막아주어 가장 엄격한 격리 수준이지만, 접근하지 못해 트랜잭션이 순차적으로 실행되어야하기 때문에 동시성이 낮아질 수 있다. 가장 안전하고 가장 성능이 떨어진다.
    2. 활용 예시: 데이터 정합성이 최우선이 되는 금융 거래와 같이 매우 중요한 트랜잭션에서 사용될 수 있다.

 

 

트랜잭션이 동작하기 위해 필요한 것

0. 자동커밋이 아닌 수동 커밋이어야 한다. 

  • 자동커밋은 set autocommit true; 각각의 쿼리 실행 직후에 자동으로 커밋호출된다. 바로 데이터베이스에 반영되어 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없다.
  • 수동 커밋 은 set autocommit false; 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현한다.

1. DB 서버에 연결하기 위한 커넥션이 필요하다. 

커넥션을 생성해 맺고

2. DB서버는 내부 세션을 만든다. 

3. 커넥션을 통한 모든 요청이 이 세션을 통해 실행하게 된다. 

4. 세션은 트랜잭션을 시작하고 , 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 

5. 사용자가 커넥션을 닫거나 DBA(데이터 베이스 관리자)가 세션을 강제로 종료하면 세션은 종료된다. 

 

커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 만들어진다. 

커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이므로

해당 트랜잭션을 시작한 세션에서만 변경 데이터가 보이고 다른 세션에서는 변경데이터가 보이지 않는다.

커밋하기 전에 롤백을 하면 직전 상태로 복구된다.

 

<락 사용>

 

세션 1이 트랜잭션 시작하고 데이터를 수정하는 동안 아직 커밋하지 않았는데

세션2에서 동시에 같은 데이터를 수정하게 되면 문제가 발생한다.

이를 방지하기 위해 커밋이나 롤백전까지 다른 세션에서 해당 데이터를 수정할 수 없게 락 개념이 사용된다.

락을 획득해서 커밋후에 락을 반납하기까지 다른 세션에서는 락을 획득하기 위해서 대기하는 방식이다. 설정된 락 타임아웃 시간보다 더 오래 대기하면 락 타임아웃 오류가 발생한다.

 

조회시에 보통 락 획득하지 않고 바로 데이터를 조회할 수 있지만 조회시에도 락을 획득하고 싶다면 select for update구문을 사용하면 된다. 이렇게 되면 특정 세션이 조회할 때 락을 가져가 버리기 때문에 해당 데이터를 다른 세션에서 변경할 수 없다.

  • 조회시점에 락 필요한 경우
    • 예) 금액 조회하고 그 금액 정보로 다른 계산을 수행해야할 때

 

<트랜잭션을 어디에서 시작하고 어디에서 커밋해야하는가?>

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야한다. 로직이 잘못되었을 시 롤백하기 위해서이다. 하지만 트랜잭션을 시작하기 위해서는 커넥션이 필요하다. 커넥션만들고 트랜잭션 커밋, 커넥션 종료까지 해야한다.

또한 같은 세션에서 작업하기 위해 같은 케넥션을 유지해아한다.

 

스프링 트랜잭션이 가져다주는 편리함은 무엇일까? 

스프링이 제공하는 트랜잭션 매니저는 

1. 추상화 

package org.springframework.transaction;
 public interface PlatformTransactionManager extends TransactionManager {
     TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
             throws TransactionException;
     void commit(TransactionStatus status) throws TransactionException;
     void rollback(TransactionStatus status) throws TransactionException;
}

 

추상화된 트랜잭션 매니저 인터페이스를 통해 

코드를 변경하지 않고 

다양한 데이터 접근 기술을 사용할 수 있다. 

 

2. 리소스 동기화

  1. 트랜잭션을 유지하기 위해서는 같은 데이터베이스 커넥션을 유지해야한다.
  2. 파라미터로 커넥션을 전달하는 것 대신 트랜잭션 동기화 매니저를 사용한다!

PlatformTransactionManager과 같은 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.

리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.

트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.

 

트랜잭션 동기화 매니저를 보면

쓰레드 로컬을 사용하는 것을 확인할 수 있다.

쓰레드 로컬을 사용해서 각각의 쓰레드마다 별도의 저장소가 부여되어 해당 쓰레드만 해당 데이터에 접근할 수 있게 하여 커넥션을 동기화 해준다.

내부 코드를 보면 자원을 맵으로 저장하고 가져오고 확인하는 로직이 있다

public static Map<Object, Object> getResourceMap() {
		Map<Object, Object> map = resources.get();
		return (map != null ? Collections.unmodifiableMap(map) : Collections.emptyMap());
	}

	/**
	 * Check if there is a resource for the given key bound to the current thread.
	 * @param key the key to check (usually the resource factory)
	 * @return if there is a value bound to the current thread
	 * @see ResourceTransactionManager#getResourceFactory()
	 */
	public static boolean hasResource(Object key) {
		Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
		Object value = doGetResource(actualKey);
		return (value != null);
	}

	/**
	 * Retrieve a resource for the given key that is bound to the current thread.
	 * @param key the key to check (usually the resource factory)
	 * @return a value bound to the current thread (usually the active
	 * resource object), or {@code null} if none
	 * @see ResourceTransactionManager#getResourceFactory()
	 */
	@Nullable
	public static Object getResource(Object key) {
		Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
		return doGetResource(actualKey);
	}
...

 

 

스프링이 제공하는 트랜잭션 매니저를 다시 정리해보면 

트랜잭션 시작1

  1. 서비스 계층에서  transactionManager.getTransaction() 를 호출해서 트랜잭션 시작하면
  2. 트랜잭션 시작하기 위해서 트랜잭션 매니저가 내부에서 DataSource를 사용해 커넥션을 생성
  3. 커넥션을 수동 커밋모드로 변경해서 실제 DB트랜잭션 시작
  4. 커넥션을 트랜잭션 동기화 매니저에 보관
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관! 이로써 멀티 쓰레드 환경에서 안전하게 보관할 수 있다.

트랜잭션 시작2

  1. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다.
  2. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요해서  DataSourceUtils.getConnection() 를 사용해 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내 사용한다. 이로써 같은 커넥션을 사용하고 트랜잭션도 유지된다.
  3. 획득한 커넥션을 사용해서 SQL을 DB에 전달해서 실행한다.
리포지토리(Repository)는 데이터베이스와 관련된 작업을 수행하는 객체로, 주로 데이터베이스와의 상호작용을 추상화하고 데이터 액세스를 담당한다.
예를 들어, 스프링 프레임워크에서는 JPA(Java Persistence API)나 Hibernate와 같은 ORM(Object-Relational Mapping) 기술을 사용할 때 리포지토리를 자주 사용한다. 이를 통해 데이터베이스와의 통신을 추상화하고, 개발자는 객체 지향적인 방식으로 데이터베이스와 상호작용할 수 있다. 

트랜잭션 종료3

  1. 비즈니스 로직 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다
  2. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다
  3. 획득한 커넥션 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  4. 전체 리소스 정리한다.
    1. 트랜잭션 동기화 매니저 정리. 쓰레드 로컬은 사용 후 꼭 정리해야한다.
    2. 자동커밋 true로 되돌린다. 커넥션풀을 고려!
    3. close호출해 커넥션 종료! 커넥션 풀 사용했을시에는 종료하면 커넥션 풀에 반환된다.

 

 

TransactionTemplate 템플릿은

반복되는 성공시 커밋, 롤백 코드를 깔끔하게 해결하고 비즈니스 로직만 남도록 한다.

 

이 경우, 비즈니스 로직이 정상수행 되면 커밋하고

언체크 예외 발생하면 롤백한다. 그 외에 경우 커밋한다.

 

서비스 로직에 비즈니스 로직만 담고 트랜잭션 기술을 처리하는 코드는 없애기 위해서

스프링 AOP를 통해 프록시를 도입했다

프록시를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.

 

스프링이 제공하는 트랜잭션 AOP기능을 사용하면 프록시를 편하게 사용할 수 있다.

 

다음 시간에 트랜잭션 AOP에 대해 공부하고 정리해보자.

728x90
728x90

저번시간에 mock객체를 이용한 테스트코드에 대해 블로그를 적었었다. 

 

테스트 코드를 반영하면서 올린 기존 코드가 수정된 부분이 많아서

참을 수 없어 호다닥 글을 적어보려고 한다.

리팩토링할 수 있어서 행복한 코드의 세계~

 

목표 : http form 데이터 전송으로 처리하던 로직 ->  rest api로 변경하기 위해 json형식으로 데이터를 주고 받기.

목표대로 수정하다보니 하나 둘 씩 기존의 코드의 다른 문제점들도 보이기 시작했는데

 

1. 의미없는 optional 반환 

처음에는 mapper에서만 optiona로 반환으로 수정을 하였다가

굳이 optional로 반환하지 않고 User를 조회하는 mapper에서 있을 경우 User반환, 없을 경우 예외를 반환하기로 하였다.

2. 컨트롤러에서 도메인 객체 등장?

optional<user> 컨트롤러까지 넘어오고 있었다. 그래서 다시 User값을 꺼내는 처리를 컨트롤러에서 하고 있고 도메인인 User객체가 그대로 드러났다. 서비스단에서 처리하여 데이터 전송객체로 반환하도록 수정했다. 

3. 구조 수정

restcontroller와 view리턴하는 컨트롤러를 쪼갰다. api 구조상으로는 좋지 않은 것 같기도 한데 이전보다는 직관적으로 분류가 되었다.

 

 

(하나의 pr에 많은 내용이 담기면 좋지 않은데 바로 수정하게 된다..

그래서 커밋이라도 잘게 쪼개고 메시지를 자세히 담아서 반영하고 있다. )

 

수정한 코드는 이렇다. 

 

로그인 컨트롤러 (확실히 코드가 깔끔해졌다. 짱이다!)

@Slf4j
@RestController
@RequestMapping("/login")
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @PostMapping
    public ResponseEntity<LoginResponseDTO> login(@RequestBody @Valid LoginRequestDTO loginRequestDTO) {

        LoginResponseDTO loginResponseDTO = loginService.checkLoginForm(loginRequestDTO);
        return ResponseEntity.ok(loginResponseDTO);


    }
}

 

로그인 서비스

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {

    private final LoginUserService loginUserService;

    public LoginResponseDTO checkLoginForm(LoginRequestDTO loginRequestDTO) {
        String email = loginRequestDTO.getEmail();
        User userEmail = User.builder().email(email).build();
        String password = loginRequestDTO.getPassword();
        
        User userByEmail = loginUserService.getUserByEmail(userEmail);
        boolean equals = userByEmail.getPassword().equals(password);
        if (equals) {
            return LoginResponseDTO.builder().id(userByEmail.getUserId()).name(userByEmail.getName()).email(userByEmail.getEmail()).build();

        } else {
            throw new NoSuchElementException("비밀번호가 일치하지 않습니다.");
        }


    }


}

이메일 확인하는 로그인유저서비스 (볼수록 이름 마음에 안드는데..흠..)

@Service
@RequiredArgsConstructor
public class LoginUserService {
    private final UserMapper userMapper;

    public User getUserByEmail(User user) {

        return Optional.ofNullable(userMapper.getByEmail(user.getEmail())).orElseThrow(() -> new NoSuchElementException("해당하는 이메일이 없습니다."));
    }
}

 

 

다음 목표: @ExceptionHandler를 이용한 전역 API 예외처리 

사실은 스프링 강의에서 들었던 내용인데 이론만 공부하고 프로젝트에 적용을 안 했었다!

생각보다 ExceptionHandler 사용 방법은 간단했다. 

 

@Controller(뷰 반환)나 @RestController(json또는 xml 데이터 반환)를 사용해  특정 클래스 구조 안에서만

@ExceptionHandler로 예외를 처리할 수도 있지만

 

나는 전역에서 관리하고 싶었고 바디값에 예외를 담아서 json형식으로 응답을 반환할 것이기 때문에 

@RestcontrollerAdvice를 사용했다.

 

@Valid 자바 어노테이션을 이용해 바인딩 오류도 전역에서 처리하고자 하였는데

이상하게 전역에서 예외가 안 잡혀서 낑낑댔다. 그 이유는 곧 나온다. 호이~

 

 

@Valid 자바 어노테이션을 사용할 경우,  발성하는 예외와 

@Validated 스프링 어노테이션을 사용할 경우 발성하는 예외가 다르다는 점을 유의해야하고 

 

찾아보니 

@Valid 자바 어노테이션을 사용할 경우에도 상황에 따라 예외타입이 다르다고 한다.

 

1. @RequestBody에 매핑되는 DTO 클래스 검증:

DTO 에 대한 유효성 검사에서 실패하면 MethodArgumentNotValidException 예외가 발생하고,

이 예외는 스프링의 DefaultHandlerExceptionResolver에 의해 처리되며

기본적으로 HTTP 상태 코드 400 (Bad Request)를 반환한다. 

 

2. @PathVariable 또는 @RequestParam 검증:

경로 파라미터(@PathVariable)나 쿼리 파라미터(@RequestParam) 등에 대한 유효성 검사에서 실패하면 ConstraintViolationException 예외가 발생한다. 

 

이 예외는 스프링의 DefaultHandlerExceptionResolver에 기본 핸들러가 선언되어 있지 않아

기본적으로 HTTP 상태 코드 500 (Internal Server Error)를 반환하는데 이 상황에서는

커스텀 예외 핸들러를 추가하여 HTTP 상태 코드 400으로 처리할 수 있다고 한다. 

 

(@Validated 스프링 어노테이션은 BindException이 유효형 검사에 실패한 경우에 발생)

 

나는 @Valid를 사용하고 있었고 전역에서 예외를 처리해 json으로 반환할 것이기 때문에 

다음과 같이 적용하였다. 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    public ResponseEntity<ExceptionResponse> processValidationError(MethodArgumentNotValidException exception) {
        List<String> Messages = new ArrayList<>();
        exception.getBindingResult().getFieldErrors().forEach((e) -> {
            String errorMessage = e.getField() + ": " + e.getDefaultMessage();
            Messages.add(errorMessage);
        });
        ExceptionResponse elementNotFound = ExceptionResponse.builder().message("유효한 값이 아닙니다").exceptions(Messages).status(HttpStatus.BAD_REQUEST).build();
        return new ResponseEntity<>(elementNotFound, elementNotFound.getStatus());

    }


    @ExceptionHandler(NoSuchElementException.class)
    @ResponseStatus(value = HttpStatus.NOT_FOUND) //명시적 선언, 하지만 ErrorResponse의 status세부 설정이 우선권 가진다.
    public ResponseEntity<ExceptionResponse> handleNoSuchElementException(NoSuchElementException exception) {
        ExceptionResponse elementNotFound = ExceptionResponse.builder().message(exception.getMessage()).exceptions(Collections.singletonList("Element not found")).status(HttpStatus.NOT_FOUND).build();
        return new ResponseEntity<>(elementNotFound, elementNotFound.getStatus());

    }


}

 

허무하게도 내가 적용이 안된 이유는 

첫번째 - 기존 컨트롤러 메서드에서 try catch를 제거하지 않아서 예외가 catch문으로 넘어가서 전역에서 잡을 수가 없었다!

두번째 - 그리고 컨트롤러 메서드에  bindingresult 파라미터가 사용하지 않더라도 추가되어 있어서 

수동으로 예외를 잡고 있어서 전역에서 호출되지 않았었다. 

 

코드 수정할 때 새로운 코드도 중요하지만 기존 코드의 설정을 잘 제거해놔야겠다.

 

 

이메일 조회 실패시 예외, 해당 메일의 비번 일치여부 실패시 예외를 던져서 실패요인도 담아냈다. 

 

 

 

 

 

 

 

 

728x90
728x90

 

스프링 프로젝트를 시작하면서 공부할게 정말 많다! 

그래도 좋은 점은 어떻게 구현해야하는지 어떤 것이 좋은지 알아가는 시간이 즐겁다

(그만큼 진도가 조금 느리기도 함..)

 

요청이 실패하다가 성공했을 경우 pk인 id값이 autoincrement로 요청이 실패한 횟수만큼 증가해있었다.

이 점을 막을 수 있으려나 고민하다가

(결론적으로 말하면 auto_increment 트랜잭션을 롤백할 수 없기 때문에 값 사이의 갭이 생기는 건 어쩔 수 없다고.. sql 설정을 바꿔주는 방법이 있긴한데 로직으로 처리하고 싶었어서 그것은 스킵! 이것도 할말이 많음)

 

예외처리를 먼저 하게 되었다

 

java.sql.SQLException: Cannot add or update a child row: 
a foreign key constraint fails 
(messycloset.usercloset, CONSTRAINT usercloset_ibfk_1 FOREIGN KEY (clothes_id) 
REFERENCES Clothes (clothes_id))

 

발생한 예외는

유효하지 않은 외래 키값을 가져와 insert하려고 해서 문제가 생긴 것이었다.

요청을 잘못해서 DAO에서 insert를 못했는데

컨트롤러에서는 문제가 없다고 api 응답이 성공적으로 postman에 찍혀서 프론트에서는 감감무소식..안돼~

개인 프로젝트가 그렇듯 하나씩 살을 붙여나갔다.

 

내가 구축한 계층(백엔드만 보자면)은

DAO - Service - Controller 

이렇게 되는데 예외를 어떻게 넘겨주고 잡아서 처리할지 고민이 되었다

 

고민 끝에 

DAO에서 exception을 service layer에 던지고 

service layer에서도 controller 계층으로 던져서 

controller에서 에러코드에 따라 다른 응답이 되도록 처리했다.

 

구현하기 앞서서 예외 Exception에 대해 공부를 하는 

시간을 가졌는데 이 내용을 정리해보고자 한다!!

 

출처 : https://www.javamadesoeasy.com/2015/05/exception-handling-exception-hierarchy.html

 

" Throwable 클래스는 Java 언어의 모든 오류 및 예외의 슈퍼클래스입니다. 이 클래스(또는 해당 하위 클래스 중 하나)의 인스턴스인 객체만 Java Virtual Machine에 의해 던져지거나 Java throw 문에 의해 던져질 수 있습니다. 마찬가지로 이 클래스 또는 해당 하위 클래스 중 하나만 catch 절의 인수 유형이 될 수 있습니다. " 출처: https://docs.oracle.com/javase/8/docs/api/java/lang/Throwable.html

 

예외의 종류

Throwable 클래스 아래에 

Error 클래스와 Exception 클래스가 나뉘어져 있다

 

 

이렇듯 Error 에러는 

즉시 체크가 되지 않고 실행중에  unchecked / runtime exception

문제가 발생한다. 그래서 언제 발생할지 알 수 없으니 예외처리와 같이 처리를 할 수도 없다

에러가 나지 않도록 유념해야한다

 

이처럼 시스템 상의 문제가 발생하는 것으로 보통 메모리 부족, 스택오버플로우와 같은 복구할 수 없는 것들을 말한다.

 

Exception 예외는

컴파일로 즉시 체크가 되어서 checked / compile exception

바로 알려준다. 

그래서 throw를 하든 try catch를 하든

예외처리를 해야한다. 

 

예외를 다루는 방법

1. 예외를 회피한다. throw 쓸거니까, 예외던질거니까 나 호출하는 니가 대신 해결해~~

 

내가 선택한 방법이다. 기존에 있던 try catch를 지우고 

dao에서 예외를 처리하지 않고  throw로 던졌다.

 

그래서 dao를 사용하는 service계층에서 예외를 받았고 이걸 다시 컨트롤러로 throws 했다.

 

참고로 SQLException이

RuntimeException가 아니라 checked exception로 분류되는 이유는

데이터베이스 연산 중 발생할 수 있는 예외를 개발자가 바로 명시적으로 처리하도록 하기 위해서이다. 

checked exception 은 컴파일러가 예외를 감지해서 개발자에게 예외처리를 강제하도록 한다. 

그래서 try catch나 throws로 명시적 예외처리하지 않으면 컴파일 오류난다.

 

2. 예외를 처리한다.

 

controller 계층에서 service내 함수를 호출하는데 이때

그쪽에서 던진 예외를 받게된다. catch로 잡았다.

 

 

SQLExceptionHandler라는 클래스를 만들어서 

받은 예외코드에 따라 응답메시지로 보낼 문구를 리턴되도록 했다.

 

 

 

 

3. 예외를 바꿔준다.(예외전환)

 

SQLException 중 어떤 에러인지 다른 에러로 바꿔서 파악할 수 있게 해주는 방법

public void add(User user) throws DuplicateUserIdException{
	try{
	    // add 작업
	} catch(SQLException e){
	    if(e.getErrorCode()==중복코드){
	        throw new DuplicateUserIdException(e);
	    } else{
	        throw e;
	    }
	}
}

 

getErrorCode 메서드 같은 경우, DB 제조사별로 제각각의 에러코드를 반환한다는 점 때문에 DB에 독립적인 프로그래밍이 거의 불가능하다.  출처: https://joont.tistory.com/157 [Toward the Developer:티스토리]

 

내가 짠 코드도 같은 문제가 있다. 에러코드가 문자 자체가 같아야 그 경우에 맞게 예외를 다루고 있어서 

orm 라이브러리를 변경해 다른 에러코드가 나오면 해결이 힘들어진다.

추후에 어노테이션을 사용해 이 부분을 변경해봐야겠다!

 

스프링에서는 이런 걱정을 위해 인터페이스를 통한 독립적인 예외처리가 가능하도록 추상화를 해 두었다고 한다.

JDBC , jpa 등 DAO에 쓰이는 라이브러리가 달라서 예외 클래스도 다른데

다른 예외를 발생시키지만 공통된 상위 예외 DataIntegrityViolationException클래스를 가지도록 해두었다.

(어떻게 사용하고 처리할지는 아직 더 공부해봐될 부분이다!)

 

예외를 다루는 좋지 않은 방법

 

1. 무작정 try catch

 

예외를 잡고 적절하게 처리를 해야하는데

아무 처리 없이 catch로 잡는다면

의미없는 코드가 되고 예외 발생의 원인을 찾기도 어렵다. 차라리 throws하는게 낫다고 한다.

 

2. 무작정 throws

 

마찬가지로 계속해서 예외를 처리하지 않고 넘기기만 한다면

예외가 어디서 발생했는지 파악하기 힘들다. 

throws Exception이 발생했구나.. 그래서..? 예외..? 그래서?

이렇게 된다.

 

 

예외를 다룰 때 주의사항

1. 예외타입!

 

예외를 catch로 잡아서 처리하려는데

잡으려는 예외가 아닌 다른 예외 타입으로 지정해주면 

예외는 잡히지 않는다..

 

+

코드를 수정하다가 분명 내가 컨트롤러에서 요청이 잘못되었다는 bad request로 상태를 담아 

응답으로 보냈었는데

막상 프론트에서 열어본 응답값의 상태는 500 internal server에러였다

아까까지만 해도 400 bad request 으로 잘찍혔는데 무슨일이야? 뭐를 수정했지?

 

알고보니~~

 

 

서비스

catch (SQLException e) {
           System.out.println("e.getSQLState() 여긴service = " + e.getSQLState());
           throw new RuntimeException(e.getCause());
       }

 

컨트롤러

@PostMapping
public ResponseEntity<String> addCloset(@RequestBody UserCloset userCloset) {
    try {
        closetService.addCloset(userCloset);
        return ResponseEntity.ok("Item added successfully");
    } catch (SQLException e) {
        System.out.println("컨트롤러 catch= " + e.getMessage());
        String errorMessage = SQLExceptionHandler.handleSQLException(e);
	return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
    }
}

 

 

DAO에서 SQLException으로 넘겨준 에러를

service 계층에서 받아서 runtime exception을 발생시켜 보내고

컨트롤러에서 SQLException으로 받으려고 하는 이상한 짓을 하고 있었다..

 

500 서버 오류는 주로 서버에서 요청을 처리하는 동안 예상치 못한 상황이 발생했을 때 일어난다.

런타임 오류를 발생시켰는데 SQLException으로는 해결이 안되어 500 에러가 떴던 것이다.

 

 

RuntimeException은 언체크 예외(unchecked exception)이기 때문에, 컴파일러가 강제로 예외 처리를 요구하지 않는다.

따라서 예외를 던질 때 try catch 블록으로 묶지 않아도 되고, throws 선언도 필요하지 않는다.

 

하지만 아래코드의 경우에서

SQLExceptionRuntimeException의 하위 클래스가 아니기 때문에

이 코드는 컴파일 오류를 발생시키는 것을 볼 수 있다.

 

 Exception 예외타입은 상위 클래스이기 때문에 처리할 수 있다.

 

 

이처럼 예외를 잡아서 처리하려고 할 때

적절한 예외 클래스를 사용해야한다. 

 

 

 

 

그렇다면 RuntimeException은  언제 쓰일까?

 

 

아래 출처 chatgpt: 

RuntimeException 및 그 하위 클래스들은 주로 프로그래머의 실수에 기인하는 예외 상황이나 논리 오류를 나타내는 데 사용됩니다. 이러한 예외는 주로 프로그램의 로직이나 흐름에 오류가 있는 경우 발생하며, 컴파일러가 강제적인 예외 처리를 요구하지 않기 때문에 비교적 자유롭게 사용됩니다.

여러 상황에서 RuntimeException을 사용할 수 있습니다:

  1. 논리 오류(Legal Argument): 예를 들어, 메소드에 잘못된 인수가 전달된 경우 또는 배열에서 범위를 벗어나는 인덱스로 접근할 때 발생할 수 있습니다.
  2. 널 포인터 예외(NullPointerException): 객체가 null인 상태에서 객체의 메소드나 속성에 접근하려고 할 때 발생합니다.
  3. 캐스팅 오류(ClassCastException): 잘못된 형변환이 일어날 경우 발생합니다.
  4. 인덱스 오류(ArrayIndexOutOfBoundsException): 배열이나 리스트에서 범위를 벗어나는 인덱스로 접근할 때 발생합니다.

다만, 이러한 예외를 남용하지 않도록 주의해야 합니다. 예외는 예상치 못한 상황에 대한 메커니즘이므로, 합리적인 이유 없이 일반적인 흐름 제어를 위해 예외를 사용하는 것은 권장되지 않습니다. 일반적인 코드 로직에서는 명시적인 조건문 등을 활용하여 예외를 최소화하고 프로그램의 안정성을 높이는 것이 좋습니다.

 

그렇구나..

 
SQLException에러를 갑자기 runtime으로 던진 이유는 SQLException을 처리하지 못했을 경우 
컴파일 오류가 생기니 runtimeException을 발생시킨다라는 대목을 읽고 저렇게 짰었는데 역시 모르고 짜니까 문제가 생긴다. 
 
 

 

2. try-catch 그리고 finally

신기한게 return 을 쓰면서 finally를 하면 덮어씌워지기가 된다. 

return 이 없을 때는 try 블록을 실행하고 finally블록 실행이 잘되는데

finally를 쓸 때 주의해야하는 부분이다.

 

 

 

 

도움 많이 받은 자료들:

https://joont.tistory.com/157

 

예외처리, 스프링 예외처리

이번에는, 프로그램을 만들때 중요하지만 대부분의 개발자가 귀찮아 하는 예외처리에 대해 알아보겠습니다.잘못된 예외처리는 찾기 힘든 버그를 낳을 수 있고, 더욱 난처한 상황을 만들 수 있

joont.tistory.com

https://velog.io/@coalery/finally-evaluation-with-spec

 

Finally... 어라?

finally가 일으키는 이런저런 기묘한 동작! ECMAScript 명세와 함께 알아봅니다.

velog.io

https://youtu.be/bCPClyGsVhc?feature=shared

 

728x90
728x90

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 - 인프런 | 강의

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 원

www.inflearn.com

728x90
728x90

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B8%B0%EB%B3%B8%ED%8E%B8/

 

스프링 핵심 원리 - 기본편 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., 스프링 핵심 원리를 이해하고, 성장하는 백엔드 개발자가 되어보세요! 📢

www.inflearn.com

공부 기록

728x90
728x90

공부 필기 기록

 

읽으면서 너무 재밌었던 책!

저자의 설명도 좋았는데 드립이 너무 웃겼다ㅋㅋㅋ 주변에 마구 추천했다

 

자바가 동작하면서 메모리 상에서 어떤일이 일어나는지를 자세히 살펴볼 수 있어서 좋았다

728x90

+ Recent posts