@Transactional

  • 메서드나 클래스에 적용하여, 이 단위가 완전하게 실행되거나 완전히 롤백되도록 보장하는 어노테이션
  • 내부적으로 프록시라는 기술을 사용

 

프록시

  • 실제 객체에 대한 대리 객체를 생성
  • 대리 객체가 호출을 가로채고 추가 작업(로깅, 트랜잭션)을 수행한 후 실제 객체에 전달하는 기술

 

프록시 실습

// TodoProjectApplicationTests.java

@Autowired
    private TodoService todoService;

    @Test
    void logIsProxy(){
        log.info("\nTodoService : {}", todoService.getClass());
    }

-> TodoService의 구현체 TodoServiceImpl이 정상적으로 주입

 

 

👇 TodoServiceImpl에 @Transactional 어노테이션을 적용하고 test

@Transactional
public TodoResponseDto addTodo(TodoRequestDto dto) {
    ...
}

-> TodoServiceImple이 Spring CGLIB 프록시 객체로 감싸져 있음

 

CGLIB

  • 클래스 기반으로 프록시 객체를 생성하는 방식
  • 기존 클래스를 상속해서 프록시를 생성

@Transactional 내부 동작 원리

  1. 해당 메서드가 호출되면 프록시 객체 생성
  2. 프록시 객체가 TransactionInterseptor 실행 
  3. TransactionInterceptor가 트랜잭션 설정
    • @Transactional에 지정한 속성에 따라서 적절한 트랜잭션 매니저 선택
    • 트랜잭션 매니저가 커넥션 생성
    • 커넥션 내부의 setAutoCommit(false) 실행 -> JDBC 연결의 자동 커밋을 비활성화
    • 트랜잭션 동기화 매니저에 커넥션 저장 -> 동일한 커넥션을 하나의 스레드에서 일관되게 사용하도록 보장
  4. 프록시 객체가 실제 타겟 메서드 호출
  5. TransactionInterceptor가 해당 트랜잭션 작업이 끝나면 commit / 예외가 발생하면 rollback

출처 : https://jaimemin.tistory.com/2271

 


 

@Transactional 사용 시 주의사항

1. 하나의 클래스에 A메서드와 @transactional이 적용된 B메서드가 있다고 가정할 때, A메서드가 B메서드를 호출하면 트랜잭션 적용 안 됨

원인 : 같은 클래스 내에서의 메서드 간 호출은 프록시를 거치지 않음

해결 방법 : 두 메서드를 다른 클래스로 분리

 

2. private 메서드에 적용 불가

-> CGLIB는 타겟의 클래스를 상속받아 프록시 생성하므로, private 메서드를 상속받을 수 없어 적용이 불가능하다

 


 

@Transactional 테스트

  • 롤백 테스트
  • @Transactional이 붙은 테스트는 종료 후 트랜잭션이 자동으로 롤백되어, 테스트 중 발생한 데이터 변경 사항이 데이터베이스에 반영되지 않는다.
  • 테스트 간 데이터 충돌을 방지할 수 있다.

 

 

JPA 변경 감지

엔티티 객체의 상태 변화를 추적해서 변경된 데이터를 DB에 자동으로 반영하는 기능

 

@Transactional이 있을 때

JPA 변경 감지를 통해 DB에 데이터가 잘 변경되는 것을 확인할 수 있으므로, 테스트 성공 예측이 가능하다.

테스트가 끝난 후 롤백해서 테스트 결과를 DB에 반영하지 않음

 

@Transactional이 없을 때 (기본 테스트 방식)

변경 감지를 안하기 때문에 DB에 데이터가 잘 변경되는지 확인할 수 없다.

만약 DB에 데이터가 잘 변경되는지 확인하려면 flush()를 실행해야한다.

flush()를 통해서 DB에 테스트 결과를 반영했다면, 테스트가 끝난 후에도 롤백되지 않는다.

 

👇

 

@Transactional 테스트 진행 시 주의사항

실제 서비스 코드에 @Transactional을 사용하지 않는 상황에서, 해당 메서드가 제대로 실행되지 않더라도

@Transactional을 적용한 테스트 코드는 JPA가 변경 감지를 하기 때문에 의도대로 작업이 진행된다고 예상할 수 있다.

문제 1

DB 테이블을 User, Todo 두 개로 분리해서 설계를 했다보니, 

응답 메시지로 반환해야 할 데이터는 User 테이블에도 존재하고, Todo 테이블에도 존재했다.

그런데 반환할 객체 Todo에는 User 테이블에만 존재하는 데이터의 필드가 존재하지 않는 상황이었다.

 

아래 이미지와 같이 Todo 객체는 Todo 테이블의 각 컬럼을 필드로 가지고 있다.

나는 응답할 데이터에 userId가 아닌 userName을 넣어야 했다.


분석 & 해결

가장 처음에는 해당 메서드로 전달된 요청 DTO의 userName을 그대로 객체에 넣을까를 생각했지만,

간단한 토이 프로젝트에서는 문제없이 동작한다고 해도, 나중에 userName에 대해 데이터 변환 과정이 추가된다거나 하는 상황에서는 적용할 수 없는 방식이다.

이러한 추가 기능이 생기지 않더라도 DB에 저장되기도 전의 값을 저장 이후 반환하는 객체에 담는 건 좋지 않은 방법인 것 같아 시도하지 않았다.

 

그래서 생각한 방법은 테이블은 위 이미지대로 설계해두고 Todo 객체에 userName 필드를 추가하는 것이었다.

하지만 튜터님께 이 방법을 사용해도 되냐고 여쭤봤을 때, DB 테이블과 엔티티는 구조를 통일하는 게 옳은 방식이고, 만약 다르게 구현한다면 추후에 문제가 많이 발생할 거라고 하셨다.

 

이후 조언을 얻어 해결한 방법이 Todo 객체가 아닌 DTO에 값을 바로 저장하는 방법이다.

당시 service layer는 repository layer를 호출하는 기능만을 수행하고 있었고, repository는 JDBC로 DB와 연결하는 과정이 있다보니 최대한 객체에 담아서 서비스 레이어에 전달하고, 서비스 레이어가 DTO에 담는 과정을 수행해야 한다!는 강박이 있었다.

하지만 레포지토리 레이어에서 바로 DTO에 값을 담아 반환하는 게 더 효율적이라면 당연히 그렇게 진행해야 했다.

 

이전에는 DTO의 역할이 단순히 엔티티를 캡슐화한 객체라고만 이해했는데, 프로젝트를 진행하면서 모든 형태의 응답 데이터를 DTO에 담아서 보내는 역할이라는 것을 배울 수 있었다.


결과

@Override
    public TodoWithoutIdResponseDto findById(long todoId) throws NoSuchElementException {
        List<TodoWithoutIdResponseDto> todoList = jdbcTemplate.query("select * from todo left join user on todo.user_id = user.id where todo.id = ?", joinRowMapperV2(), todoId);
        TodoWithoutIdResponseDto foundTodo = todoList.stream().findFirst().orElseThrow();

        return foundTodo;
    }

응답 메시지

 


문제 2

addTodo를 위해 userName을 전달했는데, userName이 null값으로 클라이언트에 전달되는 문제 발생


원인 분석

아래 기존 코드를 보면 하나의 레포지토리에서 user, todo 테이블과 연결하고 값을 넣는다.

먼저 user 테이블에 데이터를 저장하면, 자동으로 userId가 생성된다.

이때 생성된 userId를 사용해서 두 테이블을 조인하고, 그 결과값을 DTO에 저장하는 과정이다.

@Repository
public class TodoRepositoryImpl implements TodoRepository {
    private final JdbcTemplate jdbcTemplate;
    LocalDateTime localDateTime = LocalDateTime.now();
    Timestamp timestamp = Timestamp.valueOf(localDateTime);

    public TodoRepositoryImpl(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public TodoResponseDto addTodo(RequestPostDto dto) {
        Todo todo = new Todo(dto.getContents());
        User user = new User(dto.getUserName(), dto.getPassword());

        // 삽입할 테이블, pk 설정
        SimpleJdbcInsert insertUserInfo = new SimpleJdbcInsert(jdbcTemplate);
        insertUserInfo.withTableName("user").usingGeneratedKeyColumns("id");

        SimpleJdbcInsert insertTodoInfo = new SimpleJdbcInsert(jdbcTemplate);
        insertTodoInfo.withTableName("todo").usingGeneratedKeyColumns("id");

        // DB에 전달할 데이터를 파라미터에 저장
        Map<String, Object> userParameters = new HashMap<>();
        userParameters.put("user_name", user.getUserName());
        userParameters.put("password", user.getPassword());

        Map<String, Object> todoParameters = new HashMap<>();
        todoParameters.put("contents", todo.getContents());
        todoParameters.put("created_date", timestamp);
        todoParameters.put("updated_date", timestamp);

        // DB에 데이터 저장 후 userId, todoId 반환
        Number userId = insertUserInfo.executeAndReturnKey(new MapSqlParameterSource(userParameters));
        Number todoId = insertTodoInfo.executeAndReturnKey(new MapSqlParameterSource(todoParameters));

        // 투두 객체 가져오기
        List<TodoResponseDto> todoList = jdbcTemplate.query("select * from todo left join user on todo.user_id = user.id where todo.id = ?", joinRowMapper(), todoId);
        TodoResponseDto foundTodo = todoList.stream().findFirst().orElseThrow(); // orElseThrow() : null이면 예외 발생

        return foundTodo;
    }

 

userId를 user 테이블에서 자동 생성 후 조인을 하면, todo 테이블에도 userId가 외래키로 존재하기 때문에 값이 자동으로 매핑될 것이라고 생각했지만, 자동으로 매핑되지 않아서 생긴 문제인 것 같다.

id user_name
101 Kim
102 Lee
id contents user_id
1 todo1 101
2 todo2 null
3 todo3 102

 

각각 user, todo 테이블이라고 할 때, 위와 같이 user_id값이 null인 레코드가 있다면 조인 결과가 아래와 같아지는 것이다.

나의 경우는 모든 레코드에 대해 user_id를 따로 삽입해주지 않았으므로, 모든 결과값의 user_name이 null로 반환된다.

id contents user_id user_name
1 todo1 101 Kim
2 todo2 null null
3 todo3 102 Lee

 


해결

Number userId = insertUserInfo.executeAndReturnKey(new MapSqlParameterSource(userParameters));

Map<String, Object> todoParameters = new HashMap<>();
todoParameters.put("contents", todo.getContents());
todoParameters.put("user_id", userId);
todoParameters.put("created_date", timestamp);
todoParameters.put("updated_date", timestamp);

 

위와 같이 userId값을 직접 파라미터에 넣어서 DB에 전달해주면 값이 제대로 반환된다.

 


+ 개선한 내용 

Repository Layer의 역할 DB와 연결하고 데이터를 관리하는 코드를 서비스 레이어와 분리하기 위함도 있지만, 

레포지토리 레이어를 패키지로 관리함으로써 각각의 테이블에 접근하는 코드를 분리하는 것이다.

위의 내 코드는 레포지토리 레이어를 제대로 활용하지 않는 코드이다.

이를 개선하기 위해서 UserRepository, TodoRepository를 각각의 자바 클래스로 분리하고 서비스 레이어에서 이를 호출하도록 리팩토링했다.

// 서비스 레이어

public TodoResponseDto addTodo(TodoRequestDto dto) {
        // DB에 User 데이터 삽입해서 자동 생성되는 userId 반환
        long userId = userRepository.addUser(dto.getUserName(), dto.getPassword());

        // userId와 요청 데이터를 DB에 저장하여 Todo 생성
        return todoRepository.addTodo(dto.getContents(), userId);
    }
public class TodoRepositoryImpl implements TodoRepository {
    public TodoResponseDto addTodo(String contents, long userId) {
       ...
    }
}
public class UserRepositoryImpl implements UserRepository{
    public long addUser(String userName, String password) {
        ...
    }
}

문제 1

POST method로 데이터를 서버에 전달했을 때, 406 Not Acceptable을 반환하는 문제 발생

 


원인 분석

error 메시지가 Not Acceptable로 출력돼서 클라이언트의 요청 헤더가 json을 허용하지 않는지 먼저 확인했다.

Accept가 */*로 설정되어 있어서 어떤 타입의 서버 응답도 받아들일 수 있기 때문에, 요청 헤더의 문제는 아니었다.

 

그리고 같은 프로젝트에서 GET method로 값을 반환받을 때는 제대로 동작하기 때문에, 값을 저장하는 과정이 문제가 아니라 저장된 값을 가져오는 과정에서 문제가 있는 것임을 알 수 있다.

 

서버가 데이터를 반환할 때는 ResponseDto에 데이터를 캡슐화하여 Controller로 전달되고, 

해당 프로젝트는 JSON 타입으로 응답을 클라이언트에 반환하도록 설정되어 있기 때문에(@RestController)

MappingJackson2HttpMessageConverter에 의해 데이터를 JSON으로 변환한 뒤 클라이언트로 전달한다.

 

2025.03.20 - [언어, 프레임워크/Spring] - 요청(Request) 데이터 전달 방식

 

요청(Request) 데이터 전달 방식

기본적인 데이터 전달 방식1. GET + Query Parameter(=Query String)일부 서버에서 GET 방식의 데이터 요청은 Body값을 처리하지 않기 때문에 쿼리 파라미터로 전달해야 한다.response.getWriter().write() : 응답의 Bo

go-getter1kim.tistory.com

 

 

아래 코드를 보면 각 필드는 private로 캡슐화되어 있어서, getter가 없으면 Converter도 데이터에 접근하지 못한다.

즉, 이 문제는 접근할 수 없는 데이터를 JSON으로 변환해서 전달하려는 시도로 발생한 것이었다.

package com.example.standardproject1.dto;

import com.example.standardproject1.entity.Member;

public class MemberResponseDto {
    private String name;  // 이름
    private int age;   // 나이

    public MemberResponseDto(Member member){
        this.name = member.getName();
        this.age = member.getAge();
    }
}

 


해결

package com.example.standardproject1.dto;

import com.example.standardproject1.entity.Member;
import lombok.Getter;

@Getter
public class MemberResponseDto {
    private String name;  // 이름
    private int age;   // 나이

    public MemberResponseDto(Member member){
        this.name = member.getName();
        this.age = member.getAge();
    }
}

@Getter를 추가하고 다시 요청을 보내면 응답이 의도대로 돌아오는 것을 볼 수 있다!

 


문제 2 - 기존 코드

클라이언트로부터 전달받은 문자열의 길이가 2 미만이면 유효성을 검증하는 @Valid, @Size(min = 2) 어노테이션으로 에러 메시지를 클라이언트에 전달했다.

에러 메시지가 제대로 출력되긴 하지만, 너무 지저분하게 알려준다는 단점이 있다.

 


원인, 해결

throw new Exception("에러 원인은 이거야!") 처럼 직접 에러를 던지는 상황이었다면 에러 메시지를 지정할 수 있지만, 이 유효성 검증 어노테이션은 jakarta 라이브러리에서 내부적으로 에러 메시지를 가져오기 때문에 따로 에러 메시지를 설정하는 방법을 찾아보았다.

 

2025.03.20 - [언어, 프레임워크/Spring] - Spring Boot의 예외처리 (유효성 검사)

 

Spring Boot의 예외처리 (유효성 검사)

스프링 부트의 예외처리 방법1. try-catch2. @ExceptionHandler, @ControllerAdvice3. @Valid, @Validated4. 유용한 유효성 검증 어노테이션 1. try-with-resource (개선된 try-catch)// 기존 try-catchpublic class TryFinallyExample { publ

go-getter1kim.tistory.com

 

구글링을 통해 @Pattern을 주워 듣고 시도를 해보았으나 에러 메시지가 적용되지 않았고,

다시 찾아보니 유효성 검증 어노테이션 안에 message 속성값을 넣어주면 메시지를 바꿀 수 있었다.

 

 

 

알고리즘 분석에는 2가지 측면이 있다.

알고리즘의 수행시간 분석을 시간 복잡도

알고리즘이 필요로 하는 기억공간 분석을 공간 복잡도

이 중 알고리즘이 차지하는 공간보다는 수행 시간에 더 관심이 있기 때문에, 알고리즘의 복잡도를 말할 땐 주로 시간 복잡도를 의미한다.

 

시간 복잡도를 표시하는 방법을 빅오 표기법이라고 하는데,

예를 들어 알고리즘이 n에 비례하는 수행시간을 가진다고 말하는 대신에, 알고리즘 A의 시간 복잡도가 O(n)이라고 표기한다.

빅오 표기법은 입력의 개수에 따른 기본 연산의 수행 횟수를 나타낸 것으로, 알고리즘의 대략적인 수행시간을 추정할 수 있다.

최악의 경우(자료 집합 중 수행시간이 가장 오래 걸리는 경우)의 수행시간이 알고리즘의 시간 복잡도 척도로 쓰인다.

빅오 표기법에 의한 알고리즘의 수행 시간을 비교하면 아래와 같다.

 

 

O(1) - 상수 시간

int getFirstElement(int[] arr) {
    return arr[0];  // 배열 크기에 상관없이 한 번만 실행됨
}

 

 

O(log n) - 로그 시간

이진 탐색은 배열이 정렬된 상태에서만 사용할 수 있으며, 매번 배열을 절반으로 나누어 값을 찾는다.

배열 크기가 두 배로 증가해도, 비교 횟수는 한 번씩만 증가하므로 O(log n)

int binarySearch(int[] arr, int target) {
    int left = 0;
    int right = arr.length - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        
        if (arr[mid] == target) {
            return mid; // 값 찾음
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    
    return -1; // 값 없음
}

 

 

 

O(n) - 선형 시간

열을 순차적으로 탐색하는 경우

배열 크기 n에 비례하여 연산 횟수가 증가하므로 O(n)

int sum(int[] arr) {
    int total = 0;
    for (int i = 0; i < arr.length; i++) {
        total += arr[i];  // 배열의 모든 요소를 순차적으로 더함
    }
    return total;
}

 

 

O(n log n) - 로그-선형 시간

병합 정렬, 퀵 정렬, 힙 정렬

배열을 반복적으로 절반으로 나누고, 나누어진 배열을 정렬하고 병합하는 방식

배열을 절반씩 나누어 log n 번의 분할이 이루어지고, n 개의 요소를 병합하므로 O(n log n)

void mergeSort(int[] arr, int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2;
        
        mergeSort(arr, left, mid);  // 왼쪽 절반 정렬
        mergeSort(arr, mid + 1, right);  // 오른쪽 절반 정렬
        
        merge(arr, left, mid, right);  // 병합
    }
}

 

 

O(n²) - 이차 시간

버블 정렬, 선택 정렬, 삽입 정렬

이중 반복문을 사용하여 두 요소를 비교하고 교환하는 방식

이중 반복문을 사용하여 배열의 크기 n에 비례하여 연산이 증가하므로 O(n²)

void bubbleSort(int[] arr) {
    int n = arr.length;
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                // Swap
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

 

 

O(2ⁿ) - 지수 시간

피보나치 수열

재귀적으로 두 번씩 호출이 이루어져서 지수적으로 연산 횟수가 증가

n이 커질수록 호출되는 함수의 수가 급격히 증가하므로 O(2ⁿ)

int fibonacci(int n) {
    if (n <= 1) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);  // 재귀 호출
}

 

 

O(n!) - 팩토리얼 시간

순열

모든 순열을 구하는 문제는 가능한 모든 순서를 탐색해야 하므로 O(n!)

void permute(int[] arr, int l, int r) {
    if (l == r) {
        System.out.println(Arrays.toString(arr));
    } else {
        for (int i = l; i <= r; i++) {
            swap(arr, l, i);  // 요소를 교환
            permute(arr, l + 1, r);  // 재귀적으로 순열 생성
            swap(arr, l, i);  // 백트래킹
        }
    }
}

void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

 

// 추후 정렬 공부하고 내용 보완

PCB(Process Control Block)

커널은 프로세스를 생성할 때마다 프로세스 제어 블록(PCB)을 생성하여 프로세스의 정보를 저장한다.

그리고 프로세스 테이블에 PID(프로세스 번호)와 함께 PCB를 연결한다.

 

 

TCB(Thread Control Block)

스레드의 정보를 담는 TCB는 스레드가 생성될 때 만들어지고, 종료되면 제거된다.

운영체제는 TCB를 통해 스레드의 존재를 인식하며 TCB 중 하나를 선택하여 CPU에게 실행시킨다.

TCB 구조

 

커널 레벨 스레드

  • 커널에 의해 생성되고 스케줄링되는 스레드
  • 사용자가 시스템 호출을 통해 커널 레벨 스레드를 만들면 -> TCB가 커널 공간에 만들어지고 -> TCB에 기록된 스레드 주소는 응용프로그램의 코드를 가리키게 된다.
  • 컴퓨터를 부팅할 때 커널을 돕기 위한 목적으로만 만들어지는 커널 레벨 스레드도 존재하는데, 이를 순수 커널 레벨 스레드라고 한다.

 

사용자 레벨 스레드

  • 스레드 라이브러리에 의해 스케줄링되는 스레드
  • 응용프로그램은 스레드 라이브러리 함수를 호출하여 스레드를 생성하고, 스레드 라이브러리는 생성한 사용자 수준 TCB(U-TCB)를 사용자 공간에 저장한다.
  • 사용자 레벨 스레드는, 사용자 주소 공간에 적재된 스레드 라이브러리의 스케줄러 코드에 의해 선택되어 실행된다.

 

사용자 레벨 스레드와 커널 레벨 스레드 1:1 매핑

사용자 레벨 스레드의 개수만큼 커널 레벨 스레드가 생기므로 비용 부담이 크지만, 멀티 코어 CPU에서 높은 병렬성을 얻을 수 있어서 대부분의 운영체제가 1:1 매핑을 적용하고 있다.

 

 

1:1 매핑에서의 멀티 스레드 동작 원리

4개의 코어를 가진 CPU가 탑재된 컴퓨터라는 가정.

1. 응용프로그램이 실행되면(->프로세스가 생성) 커널 공간에 PCB와 TCB4를 생성하고 TCB4의 시작 주소는 main() 함수의 주소를 기록한다.

2. 커널은 TCB4를 스케줄하여 코어3에 할당하고, 코어3은 main() 함수 실행을 시작한다.

3. main() 함수에서 스레드 라이브러리의 함수를 호출하여 새 스레드를 생성할 뿐만 아니라, 시스템 호출을 통해 커널 레벨 스레드 생성을 요청한다.

4. 이 결과 커널에 TCB5가 생성되고, TCB5의 시작 주소에 사용자 스레드2의 주소가 기록된다. (동일한 방식으로 시용자 스레드3과 TCB6 생성)

5.  커널이 TCB6를 선택하여 코어4에 할당하면 사용자 스레드3이 코어4에 의해 실행된다.

6. 이렇게 되면 응용프로그램에서 만든 스레드 2개가 서로 다른 코어에서 동시에 실행된다.

 

DTO, DAO (Spring 개념)

DTO 

데이터가 각 레이어를 거치는 과정에서 값이 변경되지 않도록, DTO 객체를 생성해서 값을 캡슐화하여 전달한다.

DTO를 사용하면 캡슐화를 통해 외부 접근을 방지하는 것 뿐만 아니라, DB에서 가져온 데이터들을 하나의 객체로 묶어서 다른 레이어로 전달할 수 있다.

그리고 DB 구조가 바뀌는 상황에서 프로그램의 전체 API를 수정하지 않고, DTO 구조만 수정하면 된다는 장점이 있다.

 

DAO 

DB에 접근하는 "객체"이기 때문에 DAO(Data Access Object)라고 표현한다.

DAO는 DB와 상호작용하는 로직만 수행하는 객체, Repository는 그것을 객체 지향적으로 구현한 형태

 

  • 객체 지향적 설계 - 메서드로 상호작용을 구현하여, DB 접근 캡슐화

 

 

+ Domain

데이터 + 비즈니스 로직을 담은 객체

  • DB에 저장되는 객체들을 구현한 것으로, DTO와 혼동할 수 있지만 DTO는 비즈니스 로직을 포함할 수 없다.
  • 즉, Domain이 DTO의 역할을 하지만, DTO가 Domain을 대체할 수 있는 것은 아니다.

Repository는 DB에 직접적으로 접근해 도메인 객체를 DB에 저장하고 관리한다.

// 도메인(Entity)에서 비즈니스 로직을 수행

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    public void changeName(String newName) {
        this.name = newName; // 비즈니스 로직 (상태 변경)
    }
}
// Repository는 데이터 저장 & 조회만 담당

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByName(String name);
    ...
}
// 서비스(Service) 레이어에서 비즈니스 로직 호출

@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void updateUserName(Long userId, String newName) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("사용자 없음"));

        user.changeName(newName);  // 비즈니스 로직 실행 (도메인 객체 메서드 호출)
        userRepository.save(user); // 변경된 데이터 저장 (레포지토리 메서드 호출)
    }
}

 


@Transactional (Spring annotation)

메서드나 클래스에 적용하여, 이 단위가 완전하게 실행되거나 완전히 롤백되도록 보장 (트랜잭션 자동 관리)

기본적으로 런타임 예외(unchecked exception)와 Error는 롤백 O, 체크 예외(checked exception)는 롤백 X

  • 체크 예외도 롤백되게 하려면 rollbackFor 속성을 사용
DB에서 롤백이 필요한 상황 예시

예시 1. 데이터베이스 제약 조건 위반 (DataIntegrityViolationException)
ex. 회원가입에서 사용할 이메일을 DB에 저장하는 과정에서 DB에 이미 동일한 이메일이 존재하는 경우, 앞서 저장된 데이터들은 롤백이 필요하다. (UNIQUE 제약 조건 위반)

예시 2. 파일 업로드 후 DB 저장 실패 (SQLException)
ex. 파일이 정상적으로 DB에 업로드됐지만 파일의 메타데이터를 저장하는 과정에서 문제가 생길 수 있어, 파일 업로드의 롤백이 필요하다.

예시 3. 비즈니스 로직에서의 체크 예외 처리 (InsufficientStockException)
ex. 상품 재고가 부족한 상태에서 주문 데이터만 먼저 기록되고, 재고 수량을 수정하는 작업이 실패하면, 주문은 생성되었지만 실제 재고 상태와 일치하지 않게 되므로 주문에 대해 롤백이 필요하다.

propagation 속성값

@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE, timeout = 30, rollbackFor = Exception.class)
public void someMethod() {
    // 트랜잭션 작업
}

 


void와 Void의 차이점 (반환 타입)

void

반환값이 없는 메서드의 반환 타입

 

Void (대문자)

void 타입을 객체로 표현할 수 있도록 하는 자바의 래퍼 클래스이며, 주로 제네릭에서 사용한다.

Optional<T> 역시 null을 객체에 담아 전달할 수 있지만, null이 아닌 값이 올 수도 있다. (슈뢰딩거의 고양이?)

public ResponseEntity<Void> deleteMemo(@PathVariable Long id){

        memoService.deleteMemo(id);

        return new ResponseEntity<>(HttpStatus.OK);
}

orElse 메서드 (Optional 클래스의 메서드)

Optional 객체에 값이 존재하면 그 값을 반환하고, 값이 없으면 orElse(defaultValue) 메서드에 지정된 디폴트값을 반환한다.

Optional<T> optionalValue = Optional.ofNullable(value);
T result = optionalValue.orElse(defaultValue);

 

만약 값이 없을 때 예외를 던지고 싶다면 orElseThrow() 메서드를 사용한다.

String value = null;
Optional<String> optionalValue = Optional.ofNullable(value);

// 값이 없으면 예외를 던짐
String result = optionalValue.orElseThrow(() -> new IllegalArgumentException("Value is required"));

 


가변 인자

메서드의 파라미터 개수를 가변적으로 받을 때 사용한다.

public Menu addMenuItem(MenuItem… menuItem) // ...이 가변 인자로 받겠다는 의미

 

적절한 활용 예시

각 menu menuItem들을 add하려고 , 삽입할 데이터의 개수가 몇개든 한 번에 add 가능

List<MenuItem> menu = new AraryList<>();

public void addMenuItems(MenuItem... menuItem){
    menu.addAll(Arrays.asList(menuItem));
    
    return this;
}
List<Menu> menu = new Array.asList(
	new Menu("햄버거").addMenuItems(
    		new MenuItem("A버거", 1000원),
        	new MenuItem("B버거", 2000원),
        	new MenuItem("C버거", 3000원)
        ),
        new Menu("음료").addMenuItems(
    		new MenuItem("A음료", 1000원),
        	new MenuItem("B음료", 2000원),
        	new MenuItem("C음료", 3000원)
        )
    );

 

단점

메서드 오버로딩 불가

public void foo(int... nums) { }
public void foo(int num) { }
public void foo(int num1, int num2)   // 컴파일러가 어떤 메서드를 호출할지 혼동

 

자바를 사용해서 DB와 상호 작용하기 위한 자바 표준 인터페이스로, DBMS와 통신하여 데이터를 CRUD할 수 있게 해준다.

JDBC 특징

  1. 표준 API
    • 대부분의 RDBMS(관계형 DBMS)에 대한 드라이버가 제공되어 여러 종류의 DB 대해 일관된 방식으로 상호 작용할 수 있다.
    • Database 종류가 바뀌어도 쿼리문이 실행된다.
  2. 데이터베이스 연결
  3. SQL 쿼리 실행
  4. Prepared Statement
  5. 결과 집합 처리(Result Set)
    • 데이터베이스로부터 반환된 결과 집합을 처리할 수 있다.

 

6. 트랜잭션 관리

  • JDBC를 사용하여 데이터베이스 트랜잭션을 시작, 커밋(성공) 또는 롤백(실패)하는 등의 트랜잭션 관리 작업을 수행할 수 있다.

 


Statement VS Prepared Statement

Java에서 데이터베이스에 SQL 쿼리를 실행하기 위한 인터페이스

 

1. Statement

  • DB와 연결되어 있는 Connection 객체를 통해 SQL문을 Database에 전달하여 실행하고, 결과를 반환받는 객체
  • SQL 쿼리를 직접 문자열로 작성하여 DB에 전달
  • 쿼리는 문자열 형태로 전달되고, 실행 시점에 DB에 파싱되어 실행
  • => 실행할 때 마다 쿼리를 파싱하므로 성능에 영향을 미칠 수 있고, 보안 취약점을 가질 수 있다.
public class StatementExample {
    public static void main(String[] args) {
        try {
            // MySqlDriver 파일을 라이브러리에 추가한다.

            // Driver 연결
            Class.forName("mysql.jdbc.driver.MySqlDriver");

            // Database와 연결(계정 접속)
            Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost/mydatabase", "username", "password");

            // Statement 인스턴스 생성
            Statement statement = connection.createStatement();

            // SQL Query 작성
            String query = "SELECT * FROM MEMBER WHERE NAME = 'wonuk'";

            // Query 실행 -> 결과는 ResultSet으로 반환됨
            ResultSet rs = statement.executeQuery(query);

            // 결과 처리
            while (rs.next()) {
                // 결과 처리 로직
            }

            // 수동으로 연결 해제
            rs.close();
            statement.close();
            connection.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

 

 


2. Prepared Statement

  • SQL 쿼리를 미리 컴파일(preCompile)하여 DB에 전송할 때 값만 바뀌는 형태로 전달
    • prepareStatement()를 호출할 때, 쿼리를 DB에 전달하여 미리 컴파일
    • 이후 excuteUpdate()를 호출할 때, ?에 동적인 입력값을 바인딩해서 실행
  • 쿼리가 한 번 컴파일되면 여러 번 실행할 수 있으며, 성능이 향상되고 보안 측면에서 더 안전함
  • 동적인 입력값을 placeholder `?`로 대체하고 파라미터 바인딩을 통해 쿼리를 삽입
public class PreparedStatementExample {
    public static void main(String[] args) {
        try {
            // MySqlDriver 파일을 라이브러리에 추가한다.
            Class.forName("mysql.jdbc.driver.MysqlDriver");

            // Database와 연결
            Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost/mydatabase", "username", "password");

            // SQL Query 작성
            String query = "SELECT * FROM employees WHERE department = ?";

            // PreparedStatement 생성 및 값 설정
            PreparedStatement preparedStatement = connection.prepareStatement(query);
            preparedStatement.setString(1, "HR");  // ? 위치에 HR 바인딩

            // Query 실행
            ResultSet resultSet = preparedStatement.executeQuery();

            // 결과 처리
            while (resultSet.next()) {
                // 결과 처리 코드
            }

            // 연결 해제
            resultSet.close();
            preparedStatement.close();
            connection.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

 


Persistence Framework

JDBC의 한계

  • 간단한 SQL을 실행하는 경우에도 중복 코드가 많다.
  • DB에 따라 일관성 없는 정보를 가진 채로 Checked Exception(SQL Exception) 처리를 한다.
    • Checked Exception인 SQLException은 개발자가 명시적으로 예외처리해야 하는데, DBMS마다 고유한 SQL 문법과 오류 코드 체계를 가지고 있어서 모든 DBMS에 적합한 예외처리를 수행할 수 없다.
  • Connection과 같은 공유 자원을 제대로 반환하지 않으면 한정된 시스템 자원(CPU, Memory)에 의해 서버가 다운되는 등의 문제가 발생한다.
  • SQL Query를 개발자가 직접 작성한다.

Persistence Framework

  • JDBC 처럼 복잡함이나 번거로움 없이 간단한 작업만으로 Database와 연동되는 시스템
  • 모든 Persistence Framework는 내부적으로 JDBC API를 이용하므로 preparedStatement를 기본적으로 사용
  • 크게 SQL Mapper, ORM 두가지로 나눌 수 있다.

SQL Mapper

  • SQL 문의 실행 결과 <-> 객체(Object)의 필드를 매핑하여 데이터를 객체화
  • 대표적인 SQL Mapper로 Spring JDBC Template, MyBatis가 있다.
  • 한계
    • SQL을 직접 다룬다.
    • 특정 DB에 종속적으로 사용하기 쉽다.
    • 테이블마다 비슷한 CRUD SQL, DAO(Data Access Object) 개발이 반복된다 (코드 중복)
    • 테이블 필드가 변경될 시 이와 관련된 모든 DAO의 SQL문, 객체의 필드 등을 수정해야 한다.
    • 객체와의 관계는 사라지고 DB에 대한 처리에 집중하게 된다.
// JDBC template

// 1. XML OR Gradle에 Spring JDBC 의존성 추가
// 2. application.properties OR application.yml에 데이터베이스 연결 설정

@RestController
public class MemberController {
    private final MemberRepository memberRepository;

    public MemberController(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @GetMapping("/members")
    public List<Member> findById(Long id) {
        return memberRepository.findById(id);
    }
}

// Member Object
public class Member {
    private Long id;
    private String name;
    private int age;

    // Getter and Setter methods
}

// Repository Anotation의 역할에 대해 공부해주세요.
@Repository
public class MemberRepository {
    private final JdbcTemplate jdbcTemplate;

    public MemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

		// Member 객체로 리턴한다.
    public List<Member> findById(Long id) {
        String query = "SELECT * FROM MEMBER WHERE id = " + id;
        return jdbcTemplate.query(query, (rs, rowNum) -> {
            Member member = new Member ();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            member.setAge(rs.getInt("age"));
            return member;
        });
    }
}

 

=> 객체 중심의 객체지향데이터 중심의 RDB의 패러다임 불일치 문제를 해결하기 위해 나온 것이 ORM

 

 

[테코톡] Thread Pool


용어 정리

Program

어떤 목적을 달성하기 위해 컴퓨터의 동작들을 하나로 모아놓은 것

 

Process

컴퓨터가 현재 실행중인 프로그램

 

Thread

CPU Core의 실행 단위

 

Kernel

컴퓨터 자원(CPU, 메모리, 파일, 네트워크, 입출력 장치 등)을 관리하는 역할

 

System Call

Kernel이 관리하는 자원을 응용프로그램이 이용할 수 있도록 하는 인터페이스

시스템 콜은 라이브러리 함수 형태로 제공된다.

 

Context Switching

시스템 콜을 호출한 스레드를 커널 모드로 on/off 해주는 것

작업을 시작하게 되면 커널 모드를 on (다른 스레드는 대기)

작업을 마치게 되면 커널 모드를 off (다음 우선순위의 스레드가 커널 모드 on)

 

User Thread

라이브러리를 통해 생성, 관리되는 스레드

 

Kernel Thread

커널에 의해 생성, 관리되는 스레드

운영체제 계층에서 관리하기 때문에 유저 스레드보다 시간이 오래 걸린다.

 

참고


다중 스레드 사용 방식

1. 요청이 들어올 때마다 새로운 스레드 생성

 

단점

  • 스레드 생성 비용(=컴퓨터 자원 소모) 증가
    • 각 스레드의 스택 메모리 생성 증가, 스레드 정보 저장 증가 => 메모리 부족 문제 발생
    • 컨텍스트 스위칭이 빈번해져서 CPU의 스케줄링 부담 증가 => CPU 오버헤드 문제 발생
  • 요청 처리 시간 증가
    • 스레드 생성/제거하는데 걸리는 시간

2. Thread Pool

 

동작 원리

1. 작업 처리 요청

2. 작업 큐(FIFO)에 작업들이 쌓임

3. 스레드 풀에 생성되어 있는 스레드에 각 작업을 할당

4. 스레드의 최대 개수가 정해져 있으므로, 스레드 개수를 넘는 작업이 요청되면 대기하거나/작업 거절

 

 

Thread Pool의 장점 (개선점)

  • 스레드를 재사용 => 요청 처리 시간 감소
  • 스레드 최대 개수 제한 => 스레드 생성 비용 감소
  • 결론적으로 다중 스레드 환경을 안정적으로 관리할 수 있다!

 

자바의 Thread Pool

동작 원리

1. 스레드 풀은 일정 개수(coreThreadPool)까지의 스레드를 생성

2. 작업 큐가 가득 찬 경우, 스레드 풀은 최대 개수(maximumPoolSize)까지 스레드를 생성

3. coreThreadPool을 초과한 스레드가 일정 시간(keepAliveTime)동안 유휴 상태로 존재한다면 해당 스레드를 제거

 

참고


Tomcat의 Thread Pool

대표적인 다중 스레드로 동작하면서 안정적이어야 하는 경우가 바로 Web Server

Tomcat은 자바의 스레드 풀 구현체와 매우 유사한 자체 스레드 풀 구현체를 가진다.

 

  • maxConnections : Tomcat이 동시에 처리할 수 있는 connection의 최대 개수
  • acceptCount : maxConnections 이상의 요청이 들어왔을 때, 요청이 대기하는 큐
  • maxConnections와 acceptCount가 모두 가득 찬 경우, 그 이상의 요청은 거절될 수 있다.

SpringBoot 설정을 통한 Tomcat Thread Pool 설정

스프링부트에서 톰캣을 사용할 때는 application.yml이나 application.properties 파일에 설정

톰캣 공식 문서 참고

 

maxThreads

  • 자바의 maximumPoolSize
  • default = 200
  • 적절한 개수로 설정
    • 너무 적으면 동시에 처리할 수 있는 요청 수가 줄어든다.
    • 너무 많으면 CPU 오버 헤드, 메모리 부족 문제가 발생할 수 있다.

minSpareThreads

  • 자바의 coreThreadPool
  • default = 10
  • 적절한 개수로 설정

maxIdleTime

  • 자바의 keepAliveTime
  • default = 20000 (20초)

maxConnections

  • 실질적인 동시 처리 개수
  • Tomcat 7 버전까지는 Blocking IO 방식
    • 1 Connection 1 Thread
    • 하나의 스레드가 하나의 연결만 처리
    • max-connections == threads.max
  • Tomcat 8 버전부터는 Non-Blocking IO 방식
    • N Connection 1 Thread
    • 하나의 스레드가 여러개의 연결을 처리
    • max-connections > threads.max
    • max-connections가 threads.max보다 적거나 같다면 비효율적
    • default = 10000

acceptCount

  • default = 100
  • 적절한 개수로 설정
    • 너무 많으면 메모리 부족 문제가 발생할 수 있다.
    • 너무 적으면 요청이 몰렸을 때, 들어오는 요청들을 거절하게 된다.

Thread Pool 설정의 필요성

스레드 풀은 응답 시간과, TPS(초당 트랜잭션 수)에 영향을 주는 요소다.

잘 조정된 스레드 풀은 시스템의 성능을 끌어내고 안정적인 앱 운용을 가능하게 한다.

반면에 부적절한 설정은 병목 현상, CPU 오버헤드, 메모리 문제를 유발할 수 있다.

'TIL' 카테고리의 다른 글

시간 복잡도(빅오 표기법)  (0) 2025.03.23
프로세스와 스레드의 동작 원리  (0) 2025.03.23
NullPointerException(NPE) 런타임 에러  (1) 2025.03.11
JVM 메모리 구조  (0) 2025.02.25
이스케이프 시퀀스  (0) 2025.02.22

스프링 부트의 예외처리 방법

1. try-catch

2. @ExceptionHandler, @ControllerAdvice

3. @Valid, @Validated

4. 유용한 유효성 검증 어노테이션

 


1. try-with-resource (개선된 try-catch)

// 기존 try-catch

public class TryFinallyExample {
  public static void main(String[] args) {
    BufferedReader reader = null;
    try {
      reader = new BufferedReader(new FileReader("test.txt"));
      System.out.println(reader.readLine());
    } catch (IOException e) {
      e.printStackTrace();
    } finally {
      // 리소스 수동으로 닫기
      if (reader != null) {
        try {
          reader.close();
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
    }
  }
}

 

BufferedReader 로 파일을 읽어오는 과정에서 에러 발생 시, catch문을 바로 실행하게 된다.

이때 객체 reader를 finally 블록에서 수동으로 닫아줘야 하고, reader.close()를 수행하면서 IOException이 발생할 수 있어서 또 한 번 예외처리가 필요하다.

이렇게 코드를 작성하면 try-catch으로 인해 가독성이 나빠지고, 비즈니스 로직보다 리소스 정리 코드가 더 길어지게 된다.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class TryWithResourcesExample {
  public static void main(String[] args) {
    try (BufferedReader reader
               = new BufferedReader(new FileReader("test.txt"))) {
      System.out.println(reader.readLine());
    } catch (IOException e) {
      e.printStackTrace();
    }
    // 리소스는 자동으로 닫힘
  }
}

 

try-with-resource은 컴파일러가 자동으로 finally 블록을 추가해서 close()를 호출하도록 변환한다.

즉, try 블록이 에러로 인해 실행되지 않더라도 close()가 보장된다.

try문의 소괄호에는 리소스를 자동으로 닫아줘야 하는 대상 코드를 집어넣고, 나머지 코드는 try 블록 안에 넣어주면 된다.

 


2. @ExceptionHandler, @ControllerAdvice, @RestControllerAdvice

스프링은 기본적으로 언체크 에러가 발생하면 자동으로 클라이언트에 아래와 같은 에러 메시지를 보낸다.

자세한 에러 원인을 클라이언트에서 보여주게 되면 보안상의 문제가 생길 수 있기 때문에 이렇게 정형화된 에러 메시지를 띄우게 된다.

{
  "timestamp": "2024-11-21T14:02:00.894+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/unchecked"
}

 

하지만 우리는 각 상황에 맞는 에러 메시지(커스텀 응답)를 클라이언트에 전달하고 싶다.

이때 사용할 수 있는 어노테이션이 @ExceptionHandler, @ControllerAdvice 이다.

 

@ExceptionHandler는 특정한 예외에 대해 예외처리를 한다. (해당 예외의 자식 클래스도 캐치)

하지만 @ExceptionHandler는 코드를 작성한 컨트롤러에서 발생하는 예외만 처리한다.

여러 컨트롤러에서 동일한 예외가 발생하더라도 중복으로 예외처리를 해줘야 한다.

 

이 문제를 해결하기 위한 어노테이션이 @ControllerAdvice 이다.

@ControllerAdvice는 애플리케이션에서 전역적으로 예외를 핸들링할 수 있게 해주는 어노테이션이다.

이를 통해 예외처리 코드의 중복을 해결하고, 정상 동작 시 호출되는 코드와 예외처리 코드를 분리할 수 있다.

(ex.GlobalExceptionHandler 클래스에서 모든 에러처리를 모아서 관리 가능)

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) {
        return ResponseEntity.badRequest().body("Global Error: " + ex.getMessage());
    } // JSON 형식이 아닌 문자열 형식으로 반환
      // Map을 반환하면 @ControllerAdvice여도 JSON 타입 반환
}

@RestController
public class SampleController {
    @GetMapping("/test")
    public String test() {
        throw new IllegalArgumentException("Invalid input!");
    }
}

@RestController
public class DemoController {
    @GetMapping("/demo")
    public String demo() {
        throw new IllegalArgumentException("Invalid input!");
    }
}

 

 

@RestControllerAdvice는 @RestController와 같은 맥락으로 반환값이 자동으로 JSON 형태로 변환되며, REST API에서 발생하는 예외를 처리할 때 사용된다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public String handleException(IllegalArgumentException ex) {
        return "Invalid argument";  // 결과: {"message": "Invalid argument"}
    }
}

3. @Valid, @Validated

지금까지의 예외처리는 요청 파라미터로 들어온 데이터가 조건에 맞지 않는 형식이어도 예외처리 코드를 만날 때까지는 스레드를 차지하며 실행된다.

이런 불필요한 작업을 방지하기 위해 사전에 데이터를 검증하기 위한 어노테이션이 바로 @Valid, @Validated 이다.

이 어노테이션을 활성화하기 위해서는 아래의 의존성을 추가해야 한다.

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation
implementation 'org.springframework.boot:spring-boot-starter-validation:3.3.5'

 

@Valid

@RestController
public class UserController {
  @PostMapping("/user")
  public ResponseEntity<String> createUser(@RequestBody @Valid UserDto userDto){
        return ResponseEntity.ok("User is valid!");
  }
}

@Getter
public class UserDto {
    private Long id;
    @Min(20)  // 20 이상의 데이터만 유효
    private Long age;

    @Override
    public String toString() {
        return "UserDto{id=" + id + ", age=" + age + '}';
    }
}

 

@Valid는 @RequestBody 또는 @ModelAttrubute와 함께 메서드 파라미터에서 사용된다.

@RequestBody 또는 @ModelAttrubute로 인해 객체로 변환되어 전달된 데이터의 유효성을 검증한다.

@Valid는 객체에 대해 동작하는 검증 어노테이션이므로 @RequestParam으로 데이터를 전달받는 상황에서는 사용할 수 없다.

 

@Valid는 왜 객체에 대해서만 동작할까?

1. 모든 요청은 프론트 컨트롤러인 디스패처 서블릿을 통해 컨트롤러로 전달된다. 전달 과정에서는 컨트롤러 메서드의 객체를 만들어주는 ArgumentResolver가 동작하는데, @Valid 역시 ArgumentResolver에 의해 처리가 된다.

2. @RequestBody는 Json 메세지를 객체로 변환해 주는 작업으로 ArgumentResolver의 구현체인 RequestResponseBodyMethodProcessor가 처리한다. 이 내부에 @Valid로 시작하는 어노테이션이 있을 경우에 유효성 검사를 진행한다. 만약 @ModelAttribute를 사용 중이라면 ModelAttributeMethodProcessor에 의해 @Valid가 처리된다.

3. 검증에 오류가 있다면 MethodArgumentNotValidException 예외가 발생하게 되고, 디스패처 서블릿에 기본으로 등록된 Exception Resolver인 DefaultHandlerExceptionResolver에 의해 400 BadRequest 에러가 발생한다.

=> 결론. 전달 과정에서 객체를 만들어주는 ArgumentResolver가 @Valid를 관리하기 때문이다.
UserDto 클래스 내부의 @Min 어노테이션이 @Valid를 통해 각 데이터의 유효성을 검증한다.
💥 만약 @Valid 어노테이션을 메서드 파라미터에 적용하지 않으면 @Min 등의 어노테이션은 유효성 검증을 하지 않는다.
또한 컨트롤러에 한해서만 유효성 검증을 한다.

 


 

@Validated

컨트롤러 뿐만 아니라 모든 스프링 빈에서 유효성을 검사하기 위해 사용하는 어노테이션이다.

클래스에 @Validated를 적용하면, validation을 사용할 수 있다.

아래 코드를 보면 메서드 파라미터 number는 @RequestBody로 전달받은 객체 데이터에 속하지 않기 때문에, 유효성 어노테이션으로 검증할 수 없지만, @Validated 덕분에 @Min(10)이라는 유효성 검사가 가능하다.

@Validated
@RestController
public class ValidatedController {
    @PostMapping("/test-validated/number")
    public String testValid(@Min(10) Integer number, @RequestBody @Valid UserDto userDto) {
        return number + " : " + userDto.toString();
    }
}

@Getter
public class UserDto {
    private Long id;
    @Min(20)
    private Long age;

    @Override
    public String toString() {
        return "UserDto{id=" + id + ", age=" + age + '}';
    }
}

 

 

@Validated 적용 유무에 따른 반환 데이터 차이👇

더보기

@Validated 적용하지 않았을 때, 유효하지 않은 값 요청 시 반환되는 메시지

모든 예외의 최상위 클래스인 Exception의 예외처리 적용됨

-> 예외처리 내부 구문 중 ex.getMessage()는 스프링으로 인해 자동적으로 아래와 같은 예외 메시지를 반환

 

 

@Validated 적용했을 때, 유효하지 않은 값 요청 시 반환되는 메시지

@Validated로 인한 검증 오류 발생 시,  ConstraintViolationException예외 발생

-> ConstraintViolationException의 예외처리가 적용되어 위와 같은 메시지 전달

(@Min은 jakarta 라이브러리에 포함되어 있는 어노테이션이기 때문에, jakarta에 따로 저장된 에러 메세지 Enum을 사용)

 

 

 

또한 @Validated는 그룹을 지정하여 특정 그룹의 유저일 때 실행할 유효성 검사를 구분할 수 있다.

사용 방법은 아래와 같다.

 

1. 마커 인터페이스 정의 (내용이 없는 인터페이스)

public interface UserValidationGroup {} // 사용자
public interface AdminValidationGroup {} // 관리자

 

2. 해당 제약 조건이 적용될 그룹을 groups로 지정

// UserValidationGroup, AdminValidationGroup에만 유효성 검사
@NotEmpty(groups = {UserValidationGroup.class, AdminValidationGroup.class}) 
private String name; 

// UserValidationGroup에만 유효성 검사
@NotEmpty(groups = UserValidationGroup.class) 
private String userId; 

// AdminValidationGroup에만 유효성 검사
@NotEmpty(groups = AdminValidationGroup.class) 
private String adminId;

 

3. 유효성 검증을 적용할 파라미터에 지정

  • @Validated 안에 특정 클래스를 명시하지 않으면, groups값이 지정되지 않은 속성만 처리한다.
  • @Valid or @Validated에 특정 클래스를 명시하면, 지정된 클래스를 groups 값으로 가진 속성만 처리한다.
@PostMapping("/users") 
public ResponseEntity<Void> addUser(
		// UserValidationGroup 에 해당하는 제약 조건만 검증
    @RequestBody @Validated(UserValidationGroup.class) AddUserRequest addUserRequest) {
    
      ...
}

 

GET 방식으로 데이터에 접근할 때와 POST 방식으로 접근할 때, id의 null 값을 허용하는지에 대해 groups를 지정해주면 유용하게 사용할 수 있다.

-> 이 블로그 참고하면 이해하기 쉽다!


4. 유용한 유효성 검증 어노테이션

어노테이션 설명 대상
@Positive 양수만 허용. 0은 제외 숫자형 타입
@PositiveOrZero 0 포함 양수만 허용 숫자형 타입
@Min(1), @Max(100) 최소, 최대값 제한 숫자형 타입
@Size(max = 50) 문자열 길이 제한 문자열
@Email 이메일 형식 체크 문자열

 

REST API의 경우, HTML을 전달하지 않고 HTTP Message Body에 직접 Data를 JSON 형식으로 담아 전달한다.

정적 HTML, View Template 또한 HTTP Message Body에 담겨서 전달된다.


text 타입의 데이터 전달

1. HttpServletResponse

@Controller
public class ResponseBodyController {
	
	@GetMapping("/v1/response-body")
	public void responseBodyV1(HttpServletResponse response
        ) throws IOException {
        
        response.getWriter().write("data");
	}
}

 

2. ResponseEntity<>

문자열과 상태 코드 함께 전달

@GetMapping("/v2/response-body")
public ResponseEntity<String> responseBodyV2() {
		
	return new ResponseEntity<>("data", HttpStatus.OK);
}

 

 

3. @ResponseBody

@Data
@NoArgsConstructor // 기본 생성자
@AllArgsConstructor // 전체 필드를 인자로 가진 생성자
public class Tutor {

    private String name;
    private int age;

}

// TEXT 데이터 통신
@ResponseBody
@GetMapping("/v3/response-body-text")
public String responseBodyText() {
		
	return "data"; // HTTP Message Body에 "data"
}

 


JSON 타입의 데이터 전달

1. @ResponseBody

@Data
@NoArgsConstructor // 기본 생성자
@AllArgsConstructor // 전체 필드를 인자로 가진 생성자
public class Tutor {

    private String name;
    private int age;

}

@ResponseBody
@GetMapping("/v3/response-body-json")
public Tutor responseBodyJson() {
		
	Tutor tutor = new Tutor("wonuk", 100);
		
	return tutor; // HTTP Message Body에 Tutor Object -> JSON
}

 

 

2. ResponseEntity<Object>

데이터 객체와 상태 코드를 하나의 Tutor 객체로 묶어서 전달

@ResponseBody
@GetMapping("/v5/response-body")
public ResponseEntity<Tutor> responseBody() {
		
	Tutor tutor = new Tutor("wonuk", 100);
	
	if (조건) {
		return new ResponseEntity<>(tutor, HttpStatus.OK);
	} else {
		return new ResponseEntity<>(tutor, HttpStatus.BAD_REQUEST);
	}
	
}

 


Server(Spring)에서 HTTP 응답을 Client에 전달하는 세가지 방법

1. 정적 리소스

정적인 HTML, CSS, JS, Image 등을 변경없이 그대로 반환

 

2. View Template

SSR(Server Side Rendering)을 사용할 때 View 반환

@Controller

 

3. HTTP Message Body

응답 데이터를 직접 Message Body에 담아 반환

@ResponseBody, ResponseEntity<Object>