응답 메시지로 반환해야 할 데이터는 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) {
...
}
}
아래 코드를 보면 각 필드는 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 라이브러리에서 내부적으로 에러 메시지를 가져오기 때문에 따로 에러 메시지를 설정하는 방법을 찾아보았다.
이 중 알고리즘이 차지하는 공간보다는 수행 시간에 더 관심이 있기 때문에, 알고리즘의 복잡도를 말할 땐 주로 시간 복잡도를 의미한다.
시간 복잡도를 표시하는 방법을 빅오 표기법이라고 하는데,
예를 들어 알고리즘이 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;
}
}
}
}
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;
}
데이터가 각 레이어를 거치는 과정에서 값이 변경되지 않도록, 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 특징
표준 API
대부분의 RDBMS(관계형 DBMS)에 대한 드라이버가 제공되어 여러 종류의 DB 대해 일관된 방식으로 상호 작용할 수 있다.
Database 종류가 바뀌어도 쿼리문이 실행된다.
데이터베이스 연결
SQL 쿼리 실행
Prepared Statement
결과 집합 처리(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
이때 사용할 수 있는 어노테이션이 @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 이다.
@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 + '}';
}
}
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);
}
}