Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

이지은님의 블로그

250203 - Java Spring 일정관리 앱 구현과 트러블슈팅: API 명세서, 멀티table과 Repository, Paging, @ExceptionHandler, 공통응답, 유효성 검사, Swagger 본문

TIL

250203 - Java Spring 일정관리 앱 구현과 트러블슈팅: API 명세서, 멀티table과 Repository, Paging, @ExceptionHandler, 공통응답, 유효성 검사, Swagger

queenriwon3 2025. 2. 3. 17:29

▷ 오늘 배운 것

Spring boot 일정관리 개인과제 프로젝트의 도전과제 수행과정을

250131 - Java Spring 일정관리 앱 구현과 트러블슈팅: API 명세서, JDBC 연결, DTO와 Entity, CRUD 구현, 동적쿼리 사용 에 이어 작성해보려고 한다.

 

250131 - Java Spring 일정관리 앱 구현과 트러블슈팅: API 명세서, JDBC 연결, DTO와 Entity, CRUD 구현, 동적

▷ 코드 문제풀이[JAVA] 코드카타 - (56)~(60) [JAVA] 코드카타 - (56)~(60)문제 (56) : 과일 장수과일 장수가 사과 상자를 포장하고 있습니다. 사과는 상태에 따라 1점부터 k점까지의 점수로 분류하며, k

queenriwon3.tistory.com

 

 

<<목차>>

0. Api명세서 작성

1. users테이블 생성

2. 일정 작성(POST) 구현 - uesrs 테이블사용 리팩토링(1)

3. 일정 작성(POST) 구현 - uesrs 테이블사용 리팩토링(2)

4. 일정 조회(GET) 구현 - uesrs 테이블사용 리팩토링

5. 일정 수정(PATCH) 구현 - uesrs 테이블사용 리팩토링

6. 일정 삭제(DELETE) 구현 - uesrs 테이블사용 리팩토링

7. Mapstruct 사용하여 DTO Entity Mapping (트러블 슈팅)

 

8. 멀티 테이블을 사용하면 테이블 만큼의 레포지토리 개수를 사용해야할까?(트러블 슈팅)

    1) 일정 작성(POST) 구현 - 멀티 레포지토리 리팩토링

    2) 조건 일정 조회(GET) 구현 - 멀티 레포지토리 리팩토링

    3) 단일 일정 조회(GET) 구현 - 멀티 레포지토리 리팩토링

    4) 일정 수정(PATCH) 구현 - 멀티 레포지토리 리팩토링

    5) 일정 삭제(DELETE) 구현 - 멀티 레포지토리 리팩토링

9. Pagenation

10. null 처리에 관해서(Optional)(트러블 슈팅)

11. 공통응답

12. 공통예외 처리(트러블 슈팅)

13. 공통예외 처리 완성

14. 유효성 검사(@Valid)

15. API 명세서를 자동으로 출력하기 위해 swagger 사용해보자.(트러블 슈팅)

 

 

 

 


 

0. Api명세서 작성

도전 프로젝트를 진행하면서 데이터 베이스 구성이나, api명세서 내용이 달라지는 부분이 있어 명세서를 다시 수정하게 되었다.

https://flaxen-swan-41e.notion.site/Lv-0-186b649ebbbd80f2a570ccd9ef43adb1?pvs=4(자세한명세서내용-도전API명세서)

 

Lv. 0 기획단계 | Notion

Made with Notion, the all-in-one connected workspace with publishing capabilities.

flaxen-swan-41e.notion.site

 

위 내용은 필수 구현내용의 API 명세서와 크게 다를게 없지만, 링크 안 request 예시나 response 예외를 보면 유저 테이블에 대해 다루기 때문에 테이블을 하나 더 작성해주어야한다.

 

 

 

1. users테이블 생성

CREATE TABLE todos
(
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '일정 식별자',
    user_id BIGINT NOT NULL COMMENT '유저 식별자',
    todo VARCHAR(50) NOT NULL COMMENT '할일',
    password VARCHAR(50) NOT NULL COMMENT '비밀번호',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성일',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
    CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE users
(
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '유저 식별자',
    name VARCHAR(50) NOT NULL COMMENT '이름',
    email VARCHAR(50) NOT NULL UNIQUE COMMENT '이메일',
    created_at DATETIME NOT NULL COMMENT '작성일',
    updated_at DATETIME NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일'
)

users테이블에는 유저 id값, 이름, 이메일, 작성일, 수정일이 있어야한다.

제약조건은 id 경우 자동으로 id값이 입력되도록 하고, 이메일은 unique 값으로써, 이메일을 통해 동명이인의 유저를 구분할 있도록 했다.

ERD 다이어그램

sql코드를 통해 생성된 URD 다이어그램이다.

 

 

2. 일정 작성(POST) 구현 - uesrs 테이블사용 리팩토링(1)

Users테이블을 하나 만들었다는 것은 일정 입력에 대한 user에 대한 정보도 추가해 줘야한다는 뜻이다.

위에서 request값 중 name과 email은 users 테이블에 저장되어야하는 것이고 

Todos 테이블에는 todo password 저장되어야 한다.

@Override
public TodoResponseDto createTodo(TodoRequestDto dto) {

    String sql = "SELECT COUNT(*) FROM users WHERE email = ?";
    int count = jdbcTemplate.queryForObject(sql, Integer.class, dto.getEmail());

    if (count > 0) {
        return addTodoForExistingUser(dto);
    } else {
        return addUserAndTodo(dto);
    }
}

따라서, 원래 있던 users email이 입력되어 있는지에 따라 새로운 user인지 아닌지를 판단하여 user_id를 전달해줘야하고 이 user_id에 따라 일정테이블(todos)에도 삽입해주어야한다.

 

과정은 다음과 같다. 

입력받은 email을 통해 존재하는 user인지 판단하고,

이미 있는 user의 경우 있던 user_id를 가져와 일정을 db에 저장한다.

만약 새로운 email, user라고한다면  새로운 user_id 생성하여 users 테이블에 저장하고 user_id 가져와 일정을 db 저장한다.\

 

// 이미 존재하는 user의 경우
private TodoResponseDto addTodoForExistingUser(TodoRequestDto dto) {
    String sql = "SELECT id FROM users WHERE email = ?";
    Long userId = jdbcTemplate.queryForObject(sql, Long.class, dto.getEmail());

    SimpleJdbcInsert jdbcInsertTodos = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsertTodos.withTableName("todos").usingGeneratedKeyColumns("id");

    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date now = new Date();
    String nowTime = sdf.format(now);

    Map<String, Object> parameters = new HashMap<>();
    parameters.put("user_id", userId);
    parameters.put("todo", dto.getTodo());
    parameters.put("password", dto.getPassword());
    parameters.put("created_at", nowTime);
    parameters.put("updated_at", nowTime);

    Number key = jdbcInsertTodos.executeAndReturnKey(new MapSqlParameterSource(parameters));

    ScheduleEntity result = jdbcTemplate.query("SELECT * FROM todos WHERE id = ?", todoRowMapper(), key).get(0);

    return new TodoResponseDto(key.longValue(), dto.getName(), dto.getEmail(), result.getTodo(), result.getCreatedAt(), result.getUpdatedAt());
}

// 존재하지 않는 uesr의 경우
private TodoResponseDto addUserAndTodo(TodoRequestDto dto) {
    SimpleJdbcInsert jdbcInsertUser = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsertUser.withTableName("users").usingGeneratedKeyColumns("id");

    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date now = new Date();
    String nowTime = sdf.format(now);

    Map<String, Object> parametersUser = new HashMap<>();
    parametersUser.put("name", dto.getName());
    parametersUser.put("email", dto.getEmail());
    parametersUser.put("created_at", nowTime);
    parametersUser.put("updated_at", nowTime);

    Number userId = jdbcInsertUser.executeAndReturnKey(new MapSqlParameterSource(parametersUser));

    SimpleJdbcInsert jdbcInsertTodos = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsertTodos.withTableName("todos").usingGeneratedKeyColumns("id");

    Map<String, Object> parametersTodo = new HashMap<>();
    parametersTodo.put("user_id", userId.longValue());
    parametersTodo.put("todo", dto.getTodo());
    parametersTodo.put("password", dto.getPassword());
    parametersTodo.put("created_at", nowTime);
    parametersTodo.put("updated_at", nowTime);

    Number todoKey = jdbcInsertTodos.executeAndReturnKey(new MapSqlParameterSource(parametersTodo));

    ScheduleEntity result = jdbcTemplate.query("SELECT * FROM todos WHERE id = ?", todoRowMapper(), todoKey).get(0);

    // 반환할 응답 DTO 생성
    return new TodoResponseDto(todoKey.longValue(), dto.getName(), dto.getEmail(), result.getTodo(), result.getCreatedAt(), result.getUpdatedAt());
}

이 코드를 통해 todos테이블이 작성됨에 따라 users테이블을 작성할 수 있다.

 

 

 

3. 일정 작성(POST) 구현 - uesrs 테이블사용 리팩토링(2)

위에서 작성한 코드를 보면, 아래 todos 값을 작성하는 것은 동일하기 때문에 이 부분을 공통 부분으로 코드를 작성해줄 수 있다.

따라서 존재하는 유저가 있는지 없는지에 따라 userId 가져오는 코드만 조건문으로 작성해주면 그에 따른 todos 테이블을 작성할 있다.

@Override
public TodoResponseDto createTodo(TodoRequestDto dto) {

    int count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE email = ?", Integer.class, dto.getEmail());
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date now = new Date();
    String nowTime = sdf.format(now);

    Number userId;

    if (count > 0) {
        String sql = "SELECT id FROM users WHERE email = ?";
        userId = jdbcTemplate.queryForObject(sql, Long.class, dto.getEmail());

    } else {
        SimpleJdbcInsert jdbcInsertUser = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsertUser.withTableName("users").usingGeneratedKeyColumns("id");

        Map<String, Object> parametersUser = new HashMap<>();
        parametersUser.put("name", dto.getName());
        parametersUser.put("email", dto.getEmail());
        parametersUser.put("created_at", nowTime);
        parametersUser.put("updated_at", nowTime);

        userId = jdbcInsertUser.executeAndReturnKey(new MapSqlParameterSource(parametersUser));
    }

    SimpleJdbcInsert jdbcInsertTodos = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsertTodos.withTableName("todos").usingGeneratedKeyColumns("id");

    Map<String, Object> parameters = new HashMap<>();
    parameters.put("user_id", userId.longValue());
    parameters.put("todo", dto.getTodo());
    parameters.put("password", dto.getPassword());
    parameters.put("created_at", nowTime);
    parameters.put("updated_at", nowTime);

    Number key = jdbcInsertTodos.executeAndReturnKey(new MapSqlParameterSource(parameters));

    TodosEntity result = jdbcTemplate.query("SELECT * FROM todos WHERE id = ?", todoRowMapper(), key).get(0);

    return new TodoResponseDto(key.longValue(), dto.getName(), dto.getEmail(), result.getTodo(), result.getCreatedAt(), result.getUpdatedAt());
}

 

 

 

4. 일정 조회(GET) 구현 - uesrs 테이블사용 리팩토링

일정을 조회할 때, todosResponseDto는 id, name, email, createdAt, updatedAt 필드가 있다.

이 중 name과 email은 users에 저장되어 있고,

Id, createdAt, updatedAt은 todos 테이블에 저장되어 있다. 이를 user_id를 통해 join하는 방법으로 데이터를 조회할 수가 있다.

 

따라서 다음과 같은 JOIN 이용한 쿼리문 작성할 있도록 수정한다.

select a.id, b.name, b.email, a.todo, a.created_at, a.updated_at
from todos a join users b on a.user_id = b.id 
where 1=1
    and a.user_id = ?
    and (a.updated_at between ? and ?)
order by updated_at desc

 

user_id값을 users 테이블에서 조회하여 가져와 파라미터list 넣어준다.

@Override
public List<TodoResponseDto> findTodoByNameAndUpdatedAt(String name, String updatedAtFrom, String updatedAtTo) {

    StringBuilder sb = new StringBuilder(
            "select a.id, b.name, b.email, a.todo, a.created_at, a.updated_at" +
                    " from todos a join users b on a.user_id = b.id where 1=1");
    List<Object> list = new ArrayList<>();

    if (name != null) {
        Long userId = jdbcTemplate.queryForObject("select id from users where name = ?", Long.class, name);
        sb.append(" and a.user_id = ?");
        list.add(userId);
    }

    if (updatedAtFrom != null && updatedAtTo != null) {
        sb.append(" and (a.updated_at between ? and ?)");
        list.add(updatedAtFrom);
        list.add(updatedAtTo);
    } else if (updatedAtFrom != null) {
        sb.append(" and a.updated_at >= ?");
        list.add(updatedAtFrom);
    } else if (updatedAtTo != null) {
        sb.append(" and a.updated_at <= ?");
        list.add(updatedAtTo);
    }

    sb.append(" order by updated_at desc");

    List<TodoResponseDto> result = jdbcTemplate.query(sb.toString(),
            todoResponseDtoRowMapper(), list.toArray());

    return result;
}

 

 

전체 조회와 단일조회도 똑같이 users테이블과 todos테이블을 join 결과값을 가져와야하므로 조회하는 쿼리문을 알맞게 수정한다.

// 전체 일정 조회
@Override
public List<TodoResponseDto> findTodoAll() {
    return jdbcTemplate.query(
            "select a.id, b.name, b.email, a.todo, a.created_at, a.updated_at" +
                    " from todos a join users b on a.user_id = b.id" +
                    " order by updated_at desc", todoResponseDtoRowMapper());
}

// 단일 일정 조회
@Override
public TodoResponseDto findTodoByIdElseThrow(Long id) {
    List<TodoResponseDto> result = jdbcTemplate.query(
            "select a.id, b.name, b.email, a.todo, a.created_at, a.updated_at" +
            " from todos a join users b on a.user_id = b.id where a.id = ?", todoResponseDtoRowMapper(), id);

    return result.stream().findAny().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "찾을 수 없는 id = " + id));
}

 

 

5. 일정 수정(PATCH) 구현 - uesrs 테이블사용 리팩토링

수정의 경우 name과 todo를 수정할 수 있는데,

이름의 수정은 먼저 todos 테이블에서 일정 id 값을 찾아 user_id값을 찾고 이 유저에 대한 이름을 수정해야한다.

그리고 입력받은 id값을 이용해서 todos테이블의 일정을 수정한다.

@Override
public int updateNameAndTodo(Long id, String name, String todo, String password) {
    String storedPassword = jdbcTemplate.queryForObject("select password from todos where id = ?", String.class, id);

    if (!storedPassword.equals(password))
        throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호 확인");

    Long userId = jdbcTemplate.queryForObject("select user_id from todos where id = ?", Long.class, id);
    jdbcTemplate.update("update users set name = ? where id = ?", name, userId);

    return jdbcTemplate.update("update todos set todo = ? where id = ?", todo, id);
}

 

 

 

 

6. 일정 삭제(DELETE) 구현 - uesrs 테이블사용 리팩토링

삭제의 경우 todos테이블의 일정만 삭제하기 때문에 users테이블에 대한 코드를 작성할 필요가 없어 전에 있던 코드를 그대로 사용하면 된다.

@Override
public void deleteTodoById(Long id, String password) {
    try {
        String storedPassword = jdbcTemplate.queryForObject("select password from todos where id = ?", String.class, id);
        if (!storedPassword.equals(password))
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호 확인");
    } catch (EmptyResultDataAccessException e) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "없는 id 값");
    }
    jdbcTemplate.update("delete from todos where id = ?", id);
}

 

 

 

 

7. Mapstruct를 사용하여 DTO와 Entity Mapping (트러블 슈팅)

Mapstruct를 사용하면 인터페이스 작성에 따라 구현체를 자동으로 생성하여 같은 이름을 가진 필드끼리 자동으로 매핑시킬 수 있다.

이를 사용하기 위해 블로그 글을 참고 하여 작성해보았다.(https://jong-bae.tistory.com/80)

 

[Mapstruct] @Mapping 활용하기

Mapstruct 를 활용하면 Entity ↔ DTO 간 필드들을 쉽게 맵핑하여 사용할 수 있습니다. 필드가 많아지면 일일이 손수 맵핑하는게 여간 귀찮은 일이 아니며, 생각없이 맵핑하다가 타이핑 실수로 맵핑이

jong-bae.tistory.com

 

 

Mapstruct 사용하기 위해서는 다음과 같은 dependency 추가하여 빌드를 한다.

implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'

 

구현체를 만들어줄 인터페이스를 작성하여 TodosEntity 자동으로 TodoResponseDto 만들어줄 메서드와 반대로 매핑해줄 메서드를 써준다.

@Mapper(componentModel = "spring")
public interface TodosToMapper {

    TodoResponseDto toDTO(TodosEntity e);

    TodosEntity toEntity(TodoRequestDto dto);
}

 

이후 블로그 글에 따라 빌드를 해주었는데 다음과 같은 오류코드를 마주하게 된다.

 

/Users/ijieun/Desktop/coding/scheduleProject/src/main/java/com/example/scheduleproject/mapper/TodosToMapper.java:12: error: Ambiguous constructors found for creating com.example.scheduleproject.entity.TodosEntity: TodosEntity(java.lang.Long, java.lang.String, java.lang.String, java.lang.String, java.lang.String), TodosEntity(java.lang.String, java.lang.String), TodosEntity(java.lang.Long, java.lang.Long, java.lang.String, java.lang.String, java.lang.String, java.lang.String). Either declare parameterless constructor or annotate the default constructor with an annotation named @Default.
    TodosEntity toEntity(TodoRequestDto dto);
                ^

 

 

대충 해석해보자면 생성자를 찾을 없다는 뜻인 같다. 이때 이미 @AllArgsConstructor 작성해줬음에도 불구하고 해당 오류 코드가 발생한 것이다. 그래서 기본 생성자인 @NoArgsConstructor 작성하여 부족한 생성자가 무엇인지를 알아내려고 수정뒤 빌드를 하였다.

 

 

그럼 빌드후 다음과 같은 코드 구현체를 만들수 있다. 기본 생성자가 없어서 이런 오류가 발생한 같았다.

package com.example.scheduleproject.mapper;

import com.example.scheduleproject.dto.TodoRequestDto;
import com.example.scheduleproject.dto.TodoResponseDto;
import com.example.scheduleproject.entity.TodosEntity;
import javax.annotation.processing.Generated;
import org.springframework.stereotype.Component;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2025-01-28T20:42:52+0900",
    comments = "version: 1.5.5.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-8.11.1.jar, environment: Java 17.0.14 (Amazon.com Inc.)"
)
@Component
public class TodosToMapperImpl implements TodosToMapper {

    @Override
    public TodoResponseDto toDTO(TodosEntity e) {
        if ( e == null ) {
            return null;
        }

        TodoResponseDto todoResponseDto = new TodoResponseDto();

        todoResponseDto.setId( e.getId() );
        todoResponseDto.setTodo( e.getTodo() );
        todoResponseDto.setCreatedAt( e.getCreatedAt() );
        todoResponseDto.setUpdatedAt( e.getUpdatedAt() );

        return todoResponseDto;
    }

    @Override
    public TodosEntity toEntity(TodoRequestDto dto) {
        if ( dto == null ) {
            return null;
        }

        TodosEntity.TodosEntityBuilder todosEntity = TodosEntity.builder();

        todosEntity.todo( dto.getTodo() );
        todosEntity.password( dto.getPassword() );

        return todosEntity.build();
    }
}

 

이 구현체로는 아쉬운 점이 있다.

TodoEntity에는 name과 email이 포함되지 않는 반면, TodoResponseDto에는 포함이 된다.

그래서 name과 email을 함께 매개변수로 넣어 TodoResponseDto객체를 만들 수 있도록 인터페이스와 구현체를 수정해주었다.

 

수정한 부분
@Override
public TodoResponseDto toDTO(TodosEntity e, String name, String email) {
    if ( e == null && name == null && email == null ) {
        return null;
    }

    TodoResponseDto todoResponseDto = new TodoResponseDto();

    if ( e != null ) {
        todoResponseDto.setId( e.getId() );
        todoResponseDto.setTodo( e.getTodo() );
        todoResponseDto.setCreatedAt( e.getCreatedAt() );
        todoResponseDto.setUpdatedAt( e.getUpdatedAt() );
    }
    todoResponseDto.setName( name );
    todoResponseDto.setEmail( email );

    return todoResponseDto;
}

 

 

 

 

8. 멀티 테이블을 사용하면 테이블 수 만큼의 레포지토리 개수를 사용해야할까?(트러블 슈팅)

멀티 테이블을 사용하면 각각의 데이터를 정의해줄 entity를 2개 사용해야 한다. 그렇다면 이 각 entity를 처리해줄 Repository 또한 그 갯수만큼 만들어주어야하는가?

이 해답에 대해 찾고 싶었지만 간단한 구글링으로는 찾기 어려워서 챗GPT의 힘을 빌렸다.

챗 GPT는 이렇게 답한다.

 

 

 

1️⃣ 각 테이블이 독립적인 entity이고 개별적으로 접근이 많을 경우

단일 책임 원칙(SRP)과 유지보수 용이라는 장점에 따라 테이블당 하나의 레포지토리를 사용하는 것이 유리하다고 한다.

그리고 추후 배우게 될 JPA나 MyBatis를 사용할 경우 테이블마다 하나의 엔터티 클래스를 만들고, 그에 맞는 레포지토리를 만드는 것이 일반적이다.

 

2️⃣ 특정 쿼리가 여러 테이블을 동시에 조회하고 수정하는 경우

연관관 데이터를 함께 조회할 때 성능 최적화(join사용)를 위해 하나의 레포지토리에서 다수의 테이블을 관리하는것이 유리하다고 한다.

 

 

 

이 특징을 살펴보고 어떤 방식을 선택할지 고민을 해봤다.

단일 책임 원칙(SRP)과 유지보수 용이라는 장점을 살리기 위해 최대한 테이블 당 하나의 레포지토리를 사용하되, join의 사용이 불가피할때만 데이터 조회 최적화를 위해 하나의 레포지토리에서 다수의 테이블을 관리하기로 했다.

 

더군다나 지금은 JPA를 사용하는 것이 아닌 JDBC를 사용하고 있으므로 이 장점을 살리는 유연한 방법을 택하는 것이 좋아보인다.

 

그리고 JPA를 사용한다면 엔티티별 레포지토리를 만드는 것이 일반적이며, 복잡한 조회를 위한 경우에는 @QueryJOIN FETCH를 활용한다고 한다.(물론 그렇다고 JDBC가 테이블당 하나의 레포지토리를 사용하는 사례가 없는 것은 아니다.)

 

따라서

usersRepository는 유저 데이터(users 테이블) 관리, todosRepository는 일정 데이터(todos 테이블)관리 역할을 나누어 유지보수하기 쉽도록 코드를 리팩토링 해보도록 하겠다.

 

 

 

1) 일정 작성(POST) 구현 - 멀티 레포지토리 리팩토링

먼저 service레이어에서 어떤식으로 비즈니스 로직을 작성할지 설계해봤다.

 

1️⃣ password와 passwordCheck가 일치하는지 확인

2️⃣ userId를 불러오기 위해 users 테이블 사용(UsersRepository) (이때, userId가 존재하지 않는다면 신규 user생성하여 userId를 불러옴)

3️⃣ 가져온 userId를 이용하여 todos테이블에 일정 저장(TodosRepository)

4️⃣ 저장한 일정 정보를 users와 todos 각각에서 불러와 TodosRepository생성 후 반환

 

주석 아래에 작성된 코드는 이전에 mapper와 함께 작성한 코드이다.

 

 

리팩토링되어 간결해진 Service
@Transactional
@Override
public TodoResponseDto createTodo(TodoRequestDto dto) {
    if (!(dto.getPassword().equals(dto.getPasswordCheck())))
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password != PasswordCheck");

    // userid를 불러옴 (UsersRepository 사용) (없을시 코드도 작성 + 유저 테이블에 데이터가 생성됨)
    Long userId = usersRepository.findUserIdByEmail(dto.getEmail())
            .orElseGet(() -> usersRepository.saveUser(dto.getName(), dto.getName()));

    // 가져온 userid를 이용해 저장 후 조회
    TodosEntity todosEntity = todosRepository.createTodo(userId, dto);

    // TodoResponseDto 생성
    return todosToMapper.toDTO(todosEntity, dto.getName(), dto.getEmail());
}

 

UsersRepository
public Optional<Long> findUserIdByEmail(String email) {
    String sql = "SELECT id FROM users WHERE email = ?";

    try {
        Long userId = jdbcTemplate.queryForObject(sql, Long.class, email);
        return Optional.ofNullable(userId);
    } catch (EmptyResultDataAccessException e) {
        return Optional.empty();
    }
}
public Long saveUser(String name, String email) {
    SimpleJdbcInsert jdbcInsertUser = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsertUser.withTableName("users").usingGeneratedKeyColumns("id");

    Map<String, Object> parametersUser = new HashMap<>();
    parametersUser.put("name", name);
    parametersUser.put("email", email);
    parametersUser.put("created_at", getNowDatetime());
    parametersUser.put("updated_at", getNowDatetime());

    return jdbcInsertUser.executeAndReturnKey(new MapSqlParameterSource(parametersUser)).longValue();
}

private String getNowDatetime() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date now = new Date();
    return sdf.format(now);
}

user정보가 있을경우 userId를 반환하고, 없을 경우 saveUser로 새로운 유저 정보를 생성하는 메서드를 구현하였다.

이때 현재 날짜를 생성하는 코드는 자주 재사용될 예정이므로 클래스 내부에서 private로 메서드를 작성해주었다.

 

이메일이 유저의 고유한 정보이므로(unique) 이를 이용하여 userId를 가져올 수 있도록 하는데, 이 userId가 존재하지 않을 수 있으므로(신규 유저일경우) Optional을 이용하여 반환하도록한다.

만약에 유저 정보가 없다면(orElse에 걸리게되면) 이름과 이메일을 통해 신규유저를 생성한다.

과정에서 userId 가져올 있다.

 

 

TodosRepository
@Override
public TodosEntity createTodo(Long userId, TodoRequestDto dto) {
    SimpleJdbcInsert jdbcInsertTodos = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsertTodos.withTableName("todos").usingGeneratedKeyColumns("id");

    Map<String, Object> parameters = new HashMap<>();
    parameters.put("user_id", userId);
    parameters.put("todo", dto.getTodo());
    parameters.put("password", dto.getPassword());
    parameters.put("created_at", getNowDatetime());
    parameters.put("updated_at", getNowDatetime());

    Number key = jdbcInsertTodos.executeAndReturnKey(new MapSqlParameterSource(parameters));
    
    return jdbcTemplate.query("SELECT * FROM todos WHERE id = ?", todoRowMapper(), key).get(0);
}

private String getNowDatetime() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date now = new Date();
    return sdf.format(now);
}

가져온 userId는 입력받은 정보를 저장하는데 사용된다. 입력받은 일정정보에 따라 이를 todos테이블에 저장한다.

그런데 앞서 작성했던 todoRowMapper()메서드를 mapper패키지 안의 ToRowMapper 클래스에 모아 두고 싶어서 별도의 클래스를 생성했다. 

 

 

그런데 클래스를 생성해주는 mapping 해주는 코드를 작성하다가 다음과 같은 오류를 마주했다.

Could not autowire. No beans of ‘ToRowMapper’ type found.

 

이 오류에 대해 알아보자면, 현재 ToRowMapper 클래스를 분리했지만, Spring에서 Bean으로 등록되지 않아서 @Autowired로 주입할 없는 상태라는 뜻인 것 같다.

그러니까, 해당 클래스가 Spring의 구성요소로 인정되지 않아 스프링이 관리할 수 없다는 뜻인 것 같다. 

 

이를 해결하기 위한 방법은 간단하다. @ToRowMapper 클래스에 @Component를 추가하면 자동으로 스프링 Bean으로 관리할 수 있다.

 

이를 통해 Spring관련 코드를 작성하기 위해서는 작성하는 클래스들이 Spring Bean으로 관리되어야 한다는 것을 알게되었다.

 

 

 

2) 조건 일정 조회(GET) 구현 - 멀티 레포지토리 리팩토링

먼저 앞서 주석을 작성하며 설계한 것처럼 진행되는 과정을 주석으로 작성해보았다.

 

1️⃣ name조건을 입력받았을 경우 users테이블에서 userId를 가져온다.(UsersRepository)

2️⃣ user id값과 수정일을 사용해 join으로 조회

 

역시 주석 아래 쓰여진 코드는 수정 전 코드이다.

 

앞서 멀티 레포지토리에 대해 조사했을 때 조회는 join을 이용한 단일 레포지토리를 사용하는 것이 효율적이라고 했기 때문에 List형태로 조회하는 부분은 join을 사용할 예정이다.

 

 

Service
@Override
public List<TodoResponseDto> findTodoByNameOrUpdatedAt(String name, String updatedAtFrom, String updatedAtTo) {
    List<Long> userIdList = new ArrayList<>();

    // name을 받았다면 UsersRepository 사용하여 user name으로 user id값을 가져옴
    if (name != null) {
        userIdList = usersRepository.findUserIdByName(name);
    }

    // user id 값과 수정일을 사용해 조회
    return scheduleRepository.findTodoByNameAndUpdatedAt(userIdList, updatedAtFrom, updatedAtTo);
}

 

수정전 UsersRepository 
public Long findUserIdByName(String name) {
    return jdbcTemplate.queryForObject("select id from users where name = ?", Long.class, name);
}

먼저 jdbcTemplate.queryForObject()을 이용하여 단일 userId만 추출할 수 있도록 했다.

 

하지만 여기서 만약 이름을 사용하고 있는 유저 id가 여러개라면 어떨까. 과제 구현 사항에서는 동명이인에 관한 유저관리를 하라고 명시되어있었으니, 같은 이름의 여러 사용자가 있을 수 있음을 시사한다.

그럼 검색도 같은 이름의 여러 사용자에 대해 조회되어야 할 것이다. 그래서 단일 Long자료형 값을 반환하는 것이 아닌 List<Long>의 형태로 여러 userId가 반환되어야한다.

 

jdbcTemplate.queryForList()를 사용하여 여러개의 userId가 리스트 형태로 반환되도록 할 수 있다.

수정후 UserRepository
public List<Long> findUserIdByName(String name) {
    return jdbcTemplate.queryForList("SELECT id FROM users WHERE name = ?", Long.class, name);
}

 

 

TodosRepository

 

@Override
public List<TodoResponseDto> findTodoByNameAndUpdatedAt(List<Long> userIdList, String updatedAtFrom, String updatedAtTo) {
    StringBuilder sb = new StringBuilder(
            "select a.id, b.name, b.email, a.todo, a.created_at, a.updated_at" +
                    " from todos a join users b on a.user_id = b.id where 1=1");
    List<Object> list = new ArrayList<>();

    if (!userIdList.isEmpty()) {
        sb.append(" and a.user_id IN (?");
        list.add(userIdList.get(0));
        for (int i = 1; i < userIdList.size(); i++) {
            sb.append(", ?");
            list.add(userIdList.get(i));
        }
        sb.append(")");
    }

    if (updatedAtFrom != null && updatedAtTo != null) {
        sb.append(" and (a.updated_at between ? and ?)");
        list.add(updatedAtFrom);
        list.add(updatedAtTo);
    } else if (updatedAtFrom != null) {
        sb.append(" and a.updated_at >= ?");
        list.add(updatedAtFrom);
    } else if (updatedAtTo != null) {
        sb.append(" and a.updated_at <= ?");
        list.add(updatedAtTo);
    }

    sb.append(" order by updated_at desc");

    return jdbcTemplate.query(sb.toString(), toRowMapper.todoResponseDtoRowMapper(), list.toArray());
}

리스트를 이용한 조회에서는 join을 사용하기로 해서 join쿼리문을 사용했다.

여기서 크게 달라진 점은 userIdList에 값이 있으면 동적쿼리문을 추출한 userId의 갯수의 따라 생성한다.

그래서 이렇게 생성된 쿼리문을 실행시켜 join 사용한 todos users테이블 정보가 모두 포함된 TodoResponseDto 생성할 있다.

 

이렇게 하면 동일이름에 대한 여러 유저의 일정을 조회할 수 있다.

 

 

 

3) 단일 일정 조회(GET) 구현 - 멀티 레포지토리 리팩토링

 

단일 일정 조회는 다음과 같이 설계했다.

1️⃣ 입력받은 id를 통해 userId를 가져와 TodosEntity를 가져온다. (TodosRepository)

2️⃣ userId를 사용하여 TodoResponseDto에 사용될 name과 email을 UsersEntity로 가져온다. (UsersRopository)

3️⃣ TodoResponseDto를 만들기 위해 TodosEntity와 UsersEntity를 이용하여 매핑한다.

 

 

Service
@Override
public TodoResponseDto findTodoById(Long id) {

    // TodosRepository 사용하여 id로 userId를 가져옴
    TodosEntity todosEntity = scheduleRepository.findTodoById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Not Found id"));

    // UsersRepository 사용하여 userId로 name과 email을 가져옴
    UsersEntity usersEntity = usersRepository.findNameAndEmailByUserId(todosEntity.getUserId());

    // DTO = todosEntity + usersEntity
    return todosToMapper.toDTO(todosEntity, usersEntity.getName(), usersEntity.getEmail());
}

 

TodosRepository
@Override
public Optional<TodosEntity> findTodoById(Long id) {
    List<TodosEntity> todosEntityList = jdbcTemplate.query("select * from todos where a.id = ?", toRowMapper.todosRowMapper(), id);
    return todosEntityList.stream().findAny();
}

Id를 통해 todos 테이블에서 TodosEntity를 가져온다. 이때 아무것도 없다면 예외를 던져 NOT_FOUND가 출력되도록한다.

 

 

UsersRepository
public UsersEntity findNameAndEmailByUserId(Long userId) {
    return jdbcTemplate.query("SELECT * FROM users WHERE id = ?",
            toRowMapper.usersRowMapper(), userId).get(0);
}

 

 

ToRowMapper(UsersEntity)
public RowMapper<UsersEntity> usersRowMapper() {
    return new RowMapper<UsersEntity>() {
        @Override
        public UsersEntity mapRow(ResultSet rs, int rowNum) throws SQLException {
            return new UsersEntity(
                    rs.getString("name"),
                    rs.getString("email")
            );
        }
    };
}

가져온 userId 사용하여 users테이블에서 해당되는 row 들고와 매핑한다. 이때 쿼리 출력값을 가져오기 위해 usersRowMapper() 만들어 가져온다.

 

 

이런 방법으로 users 테이블에 있는 값과 todos 테이블에 있는 값을 join없이 가져올 수 있다.(유지보수 및 재사용성 증가)

 

 

 

4) 일정 수정(PATCH) 구현 - 멀티 레포지토리 리팩토링

 

1️⃣ 이름과 일정 모두 입력이 되었는지 확인. 둘 다 입력되지 않았을 때 400에러코드를 던진다.

2️⃣ 수정하고 싶은 일정 id값을 가져온다. 이때 비밀번호를 확인하는 과정이 필요하다.

3️⃣ 만약 name이 입력되었다면 UsersRepository에서 name을 업데이트하고

4️⃣ 만약 todo가 입력되었다면 TodosRepository에서 todo를 업데이트 한다.

5️⃣ 이후 업데이트 한 값을 조회해서 출력한다.(만들어진 조회코드 재사용)

 

Service
@Transactional
@Override
public TodoResponseDto updateNameAndTodo(Long id, String name, String todo, String password) {
    // 둘다 입력되지 않았을시 예외던짐
    if (name == null && todo == null) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "항상 필수값임");
    }

    // 해당 id값 일정을 찾아옴(비밀번호 확인)
    TodosEntity todosEntity = todosRepository.findTodoById(id, password)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Not Found id"));

    // name 입력시 UsersRepository에서 값 update(이름수정에대한 값 업데이트)
    if (name != null) {
        usersRepository.updateUserName(todosEntity.getUser_id(), name);
    }

    // todos 입력시 TodosRepository에서 값 update(일정수정에 대한 값 업데이트)
    if (todo != null) {
        todosRepository.updateTodo(id, todo);
    }

    // 업데이트한 값을 조회해서 출력
    return findTodoById(id);
}

 

TodosRepository
@Override
public Optional<TodosEntity> findTodoById(Long id, String password) {
    List<TodosEntity> todosEntityList = jdbcTemplate.query("select * from todos where id = ?", toRowMapper.todosRowMapper(), id);
    if (todosEntityList.isEmpty()) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Not found id");
    }

    TodosEntity todosEntity = todosEntityList.get(0);
    checkPassword(todosEntity.getPassword(), password);
    return Optional.of(todosEntity);
}

@Override
public void updateTodo(Long id, String todo) {
    jdbcTemplate.update("update todos set todo = ? where id = ?", todo, id);
}

private void checkPassword(String storedPassword, String password) {
    if (!storedPassword.equals(password))
        throw new PasswordMismatchException("비밀번호 불일치");
}

 

이 코드를 짜는 과정에 있어

처음에는

List<TodosEntity> todosEntityList = jdbcTemplate.query("select * from todos where id = ?", toRowMapper.todosRowMapper(), id).get(0);

라는 코드를 작성했었다. 하지만 찾을 없는 id 값을 입력했을때 다음과 같은 에러가 출력된다.

 

java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0

 

에러는 당연하게도 내용이 비었으므로 인덱스 0 찾을 없다는 뜻이다. 그럼 찾을 없는 id값을 입력했을 .get(0) 해줄 수가 없다. 따라서 먼저 List형태로 받은다음 비었는지 비어있지 않은지(.isEmpty()) 판단한 .get(0) 해야하겠다. 이와같이 .get(0)을 하고 싶을 경우 null값에 대해 주의사항이 필요하다.

List<TodosEntity> todosEntityList = jdbcTemplate.query("select * from todos where id = ?", toRowMapper.todosRowMapper(), id);
if (todosEntityList.isEmpty()) {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Not found id");
}

TodosEntity todosEntity = todosEntityList.get(0);

 

그리고 updateTodo(Long id, String todo) 또한 작성해주어 할일에 대한 일정을 업데이트 할 수 있도록 한다.

수정을 위해서는 비밀번호의 확인도 필요한데, 이는 나중에 삭제시에도 다시 재활용할 수 있도록 private메서드로 만들어 필요할때마다 호출할 수 있도록 구성했다.

 

 

UsersRepository
public void updateUserName(Long userId, String name) {
    jdbcTemplate.update("update users set name = ? where id = ?", name, userId);
}

코드와 마찬가지로 유저 정보에서 이름을 업데이트 있도록 한다.

 

이름이 모두다 변경이 되었는지 GET 조회 기능으로 체크 또한 가능하다.

 

 

 

5) 일정 삭제(DELETE) 구현 - 멀티 레포지토리 리팩토링

TodosRepository
@Override
public void deleteTodoById(Long id, String password) {
    findTodoById(id, password);
    jdbcTemplate.update("delete from todos where id = ?", id);
}

코드 재사용성을 늘리기 위해 구현되어있는 비밀번호 확인 기능을 실행한다. 이때 반환값은 필요하지 않다.

 

 

 

9. Pagenation

페이징의 관한 내용은 다음 블로그를 참고하여 작성해보았다.

https://velog.io/@sussa3007/Spring-Spring-DATA-JDBC-Pagination-API

 

[Spring] Spring DATA JDBC Pagination API

Spring DATA JDBC에서 Pagination을 적용하기 위해 Pageable 인터페이스를 활용한다.Pagination 기능을 적용하기 위해 Controller에서는 원하는 페이지, 슬라이스 사이즈를 요청 파라미터로 받아 서비스 계층으

velog.io

https://mason-lee.tistory.com/108

 

Spring Data JDBC에서 Offset-Pagination 적용하기

페이지네이션(Pagination) Pagination이란 데이터 베이스에 회원 정보가 100건이 저장되어 있는데 클라이언트 쪽에서 100건의 데이터를 모두 요청하는 것이 아니라 한 페이지에 일정 개수만큼만 나누어

mason-lee.tistory.com

 

 

열심히 자료를 찾아본 결과 PagingAndSortingRepository를 사용하기에는 여러개의 테이블이 사용되기 때문에 어렵다는 생각이 들었다. 따라서 Page 클래스를 커스텀해주기로 해주는 방법을 사용하여 페이지네이션을 구현하기로 결정했다.

 

커스텀한 페이지 클래스는 다음과 같다. 필드에 List화된 data, page size, 그리고 전체 요소와 전체 페이지를 두어 이를 json형태로 응답할 있도록 했다.

package com.example.scheduleproject.dto;

import lombok.Getter;
import java.util.List;

@Getter
public class PageResponseDto<T> {

    private List<T> schedulesData;
    private int page;
    private int size;
    private long totalElements;
    private int totalPages;

    public PageResponseDto(List<T> schedulesData, int page, int size, long totalElements) {
        this.schedulesData = schedulesData;
        this.page = page;
        this.size = size;
        this.totalElements = totalElements;
        this.totalPages = (int) Math.ceil((double) totalElements / size);
    }
}

 

 

Controller
@GetMapping
public PageResponseDto<TodoResponseDto> findTodoByNameOrUpdatedAt(
        @ModelAttribute TodoRequestGetDto dto,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size
        ) {
    return scheduleService.findTodoByNameOrUpdatedAt(dto, page, size);
}

페이징을 사용하기 위해 page와 size를 입력받는다. 여기서 @ModelAttribute를 적용시켜 여러개의 입력값을 받을 수 있도록 한다.

 

TodosRepository
@Override
public PageResponseDto<TodoResponseDto> findTodoByNameAndUpdatedAt(List<Long> userIdList, String updatedAtFrom, String updatedAtTo, int page, int size) {
    ... 생략

    int totalElements = jdbcTemplate.query(sb.toString(), toRowMapper.todoResponseDtoRowMapper(), list.toArray()).size();

    sb.append(" order by updated_at desc limit ? offset ?");
    list.add(size);
    list.add(page * size);

    List<TodoResponseDto> todoResponseDtoList = jdbcTemplate.query(sb.toString(), toRowMapper.todoResponseDtoRowMapper(), list.toArray());

    return new PageResponseDto<>(todoResponseDtoList, page, size, totalElements);

}

우선 조건에 해당되는 리스트의 크기를 totalElements로 받은 다음

페이징을 하기 위해서 입력받은 요청 param으로 받은 page와 size를 쿼리문의 limit와 offset의 파라미터로 입력한다.

이후 PageResponseDto로 반환하도록 한다.

 

 

 

 

10. null 처리에 관해서(Optional)(트러블 슈팅)

totalElements 구하는 과정에서 해당되는 조회값이 아무것도 없으면 다음과 같은 에러를 출력한다.

Unboxing of 'jdbcTemplate. queryForObject("select count(*) from todos", Integer. class)' may produce 'NullPointerException' private int returnToTalElements() { return jdbcTemplate.queryForObject("select count(*) from todos", Integer.class); }

이 에러는 전체를 조회하는 페이지네이션을 적용하면서 생긴에러이다. totalElements를 메서드로 구하고 싶었는데 만약 이 값이 null이 될 수도 있다. 

 

 이때, Null처리를 어떻게 해주면 될까? 해결방법은 총 3가지가 있다.

 

 

1️⃣ Objects.requireNonNullElse 사용

import java.util.Objects;

private int returnTotalElements() {
    return Objects.requireNonNullElse(
        jdbcTemplate.queryForObject("select count(*) from todos", Integer.class), 0
    );
}

Objects.requireNonNullElse()를 사용하여 해당 메서드가 null이 될 경우 0으로 반환할 수 있도록 한다.

 

 

2️⃣ 삼항 연산자 사용

private int returnTotalElements() {
    Integer count = jdbcTemplate.queryForObject("select count(*) from todos", Integer.class);
    return (count != null) ? count : 0;
}

삼항연산자를 이용하여 해당 count가 null인 경우 0으로 리턴하도록 한다.

 

 

3️⃣ Optional 사용

import java.util.Optional;

private int returnTotalElements() {
    return Optional.ofNullable(jdbcTemplate.queryForObject("select count(*) from todos", Integer.class))
                   .orElse(0);
}

Optional.ofNullable()을 사용하여 Null가능성이 있음을 암시한다. 만약 Null일 경우 0을 반환한다. 이번 프로젝트에서는 Optional을 공부하고 싶어서 Optional을 사용하기로 했다.

 

 

이를 이용해 페이지네이션을 이용한 응답이 가능하다.

 

 

 

 

11. 공통응답

공통예외를 처리하기 위해 공통응답 또한 구현하고자 했다.

공통응답에 대해서는 다음 블로그를 참고했다.

https://hongsamm.tistory.com/37

 

 

ApiResponseDto<T>클래스 생성
package com.example.scheduleproject.dto;

import com.example.scheduleproject.exception.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ApiResponseDto<T> {

    private int statusCode;
    private String message;
    private T data;
    private String errorDetails;

    public ApiResponse(int statusCode, String message, T data) {
        this.statusCode = statusCode;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> OK(String message, T data) {
        return new ApiResponse<>(200, message, data);
    }

    public static <T> ApiResponse<T> OK(String message) {
        return new ApiResponse<>(200, message, null, null);
    }

    public static <T> ApiResponse<T> fail(ErrorCode errorCode) {
        return new ApiResponse<>(errorCode.getCode(), errorCode.name(), null, errorCode.getMessage());
    }
}

공통응답을 처리해주기 위한 필드를 상태코드, 메세지, 데이터, 에러 발생시 디테일한 설명으로 설정한다. 

 

 

Controller 수정
@PostMapping
public ApiResponseDto<TodoResponseDto> createTodo(@RequestBody TodoRequestDto dto) {
    TodoResponseDto responseDto = scheduleService.createTodo(dto);
    return ApiResponseDto.OK("일정 등록 완료", responseDto);
}

Controller의 출력을 공통응답으로 출력하게 해줄 수 있도록 코드를 수정한다.

 

그러면 다음과 같이 성공적으로 공통응답이 출력된다. (200 OK 출력으로 일정등록이 완료됨)

 

 

 

12. 공통예외 처리(트러블 슈팅)

코드 수정 전
package com.example.scheduleproject.exception;

import com.example.scheduleproject.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(NoMatchPasswordConfirmation.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<String> handleInvalidPassword(NoMatchPasswordConfirmation ex) {
        log.error("비밀번호 확인 불일치 = {}", ex.getMessage());
        ErrorCode code = ErrorCode.NO_MATCH_PASSWORD_CONFIRMATION;
        return new ApiResponse<>(code.getCode(), code.name(), null, code.getMessage());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResponse<String> handleGeneralException(Exception ex) {
        log.error("공통 예외 = {}", ex.getMessage());
        return new ApiResponse<>(ErrorCode.FAIL.getCode(), ErrorCode.FAIL.name(), null, ErrorCode.FAIL.getMessage());
    }
}

 

우선 @ControllerAdvice 를 사용하여 코드를 작성했다. 

그랬더니 다음과 같이 html형식의 응답과 함께 내부 서버오류 500 발생하는 것을 알게 되었다.

응답을 json형식으로 출력하기위해 다음 블로그를 참고하여 @ControllerAdvice보다는 @RestControllerAdvice을 사용했더니 

https://velog.io/@kiiiyeon/%EC%8A%A4%ED%94%84%EB%A7%81-ExceptionHandler%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC

 

[스프링부트] @ExceptionHandler를 통한 예외처리

@ExceptionHandler는 Controller계층에서 발생하는 에러를 잡아서 메서드로 처리해주는 기능이다.Service, Repository에서 발생하는 에러는 제외한다.간단한 예시부터 살펴보자.이렇게 @Controller로 선언된 클

velog.io

 

 

출력됨을 확인할 있었다.

 

 

수정 후 코드
package com.example.scheduleproject.exception;

import com.example.scheduleproject.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(NoMatchPasswordConfirmation.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<String> handleInvalidPasswordCheck(NoMatchPasswordConfirmation ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.NO_MATCH_PASSWORD_CONFIRMATION);
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResponse<String> handleGeneralException(Exception ex) {
        log.error("공통 예외 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.FAIL);
    }
}

 

 

 

13. 공통에러 처리 완성

발생할 있는 에러를 enum자료형을 만들어 관리하도록 했다. 여기서 생길 수도 있고, 쿼리문에 따라 미처 발견하지 못한 null 처리해야할 부분이 생긴다면 추가될 예정인데, 이럴경우 FAIL 출력될 예정이고 이후 유지보수를 하는 방향으로 계획하고 있다.

package com.example.scheduleproject.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum ErrorCode {
    FAIL(500, "응답 실패"),
    NO_MATCH_PASSWORD_CONFIRMATION(400, "비밀번호 확인 불일치"),
    MISSING_REQUIRED_FIELD(400, "필수 요청 값을 받지 못함"),
    MISSING_PARAMETER_ID(400, "id 파라미터 값을 받지 못함"),
    UNAUTHORIZED(401, "비밀번호 불일치"),
    NOT_FOUND(404, "존재하지 않는 id값");

    private final int code;
    private final String message;
}

 

 

예외를 핸들링할 수 있도록 핸들러클래스를 만들어주었다.

예외들마다 이름이 명확히 출력될 있겠금 커스텀 예외클래스를 만들어주었고 이를 @ExceptionHandler 어노테이션으로 연결했다.

 

package com.example.scheduleproject.exception;

import com.example.scheduleproject.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(NoMatchPasswordConfirmation.class)
    protected ApiResponse<String> noMatchPasswordConfirmationHandler(NoMatchPasswordConfirmation ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.NO_MATCH_PASSWORD_CONFIRMATION);
    }

    @ExceptionHandler(InvalidRequestException.class)
    protected ApiResponse<String> invalidRequestExceptionHandler(InvalidRequestException ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.MISSING_REQUIRED_FIELD);
    }

    @ExceptionHandler(IdNotFoundException.class)
    protected ApiResponse<String> idNotFoundExceptionHandler(IdNotFoundException ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.NOT_FOUND);
    }

    @ExceptionHandler(PasswordMismatchException.class)
    protected ApiResponse<String> passwordMismatchExceptionHandler(PasswordMismatchException ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.UNAUTHORIZED);
    }

    @ExceptionHandler(MissingRequestParameterException.class)
    protected ApiResponse<String> missingRequestParameterExceptionHandler(MissingRequestParameterException ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.MISSING_PARAMETER_ID);
    }

    @ExceptionHandler(Exception.class)
    protected ApiResponse<String> handleGeneralException(Exception ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponse.fail(ErrorCode.FAIL);
    }
}

 

 

 

14. 유효성 검사(@Valid)

마지막 과제는 유효성검사를 하는 것이라 다음 블로그를 참고하여 유효성 검사를 해보려고 한다.

https://velog.io/@kbk3771/Spring-Boot-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-Validation-%ED%99%9C%EC%9A%A9

 

[Spring Boot] 유효성 검사를 위한 Validation 활용

validation 유효성 검사 > 회원가입 시 사용자가 입력한 정보가 서버로 전송되기 전에 특정 규칙에 맞게 입력됐는지, 아이디와 닉네임이 중복됐는지 등 확인하는 검증 단계가 필요하다. 1. build.gradle

velog.io

 

 

먼저 유효성검사를 사용하기 위해 의존성 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

어디에 유효성 검사를 적용시키면 좋을지에 생각해봤는데 사용자로부터 받는 입력에 대해 검사해야하므로 TodoRequestDto에 유효성 검사를 해주기로 했다.

필드마다 유효성을 규제할 있는 어노테이션을 붙여 관리할 있으며, 만약 유효하지 않은 값이 들어올 경우 자동으로 예외처리가 있도록 했다.

package com.example.scheduleproject.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;

@Getter
public class TodoRequestDto {

    @NotBlank(message = "name은 필수 입력값 입니다.")
    private String name;

    @Email
    @NotBlank(message = "email은 필수 입력값 입니다.")
    private String email;

    @NotBlank(message = "todo는 필수 입력값 입니다.")
    @Size(max = 200, message = "todo는 200자까지 작성 가능합니다.")
    private String todo;

    @NotBlank(message = "password는 필수 입력값 입니다.")
    private String password;
    private String passwordCheck;

    // 사용하지 않아도 됨
    public boolean areAllNotNull() {
        return name != null && email != null && todo != null && password != null && passwordCheck != null;
    }
}

 

유효성에 대한 예외처리는 다음 블로그 글을 참고했다.

https://velog.io/@jungseo/Spring-MVC-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC

 

Spring에서의 예외 처리

Spring MVC / @ExceptinHandler / @RestControllerAdvice / Exception throw, catch / HttpStatus

velog.io

 

 

Exception Handler 클래스
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponseDto<String> handleValidationExceptionHandler(MethodArgumentNotValidException ex) {
    String errorMessage = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .findFirst()
            .orElse("입력값이 올바르지 않습니다.");

    return new ApiResponseDto<>(400, "BAD_REQUEST", null, errorMessage);
}

만약 이메일 형식을 어겼을 경우 메세지로 지정한 문자열을 다음과 같이 출력한다.

 

 

 

 

15. API 명세서를 자동으로 출력하기 위해 swagger를 사용해보자.(트러블 슈팅)

다른 블로그내용을 참고해보려고 했다가 다른 블로그 모두 swagger 열었을 다음과 같은 화면이 보였다.

 

Unable to render this definition

The provided definition does not specify a valid version field.

Please indicate a valid Swagger or OpenAPI version field. Supported version fields are swagger: "2.0" and those that match openapi: 3.x.y (for example, openapi: 3.1.0).

 

Handler dispatch failed: java.lang.NoSuchMethodError: 'void org.springframework.web.method.ControllerAdviceBean.<init>(java.lang.Object)'

 

 

이에 튜터님한테 여쭤보았더니 블로그 글 보다는 공식문서를 좀 더 참고하라고 조언해주셨으며,  (https://springdoc.org/#Introduction)

 

OpenAPI 3 Library for spring-boot

Library for OpenAPI 3 with spring boot projects. Is based on swagger-ui, to display the OpenAPI description.Generates automatically the OpenAPI file.

springdoc.org

 

 

가장 최신 버전의 swagger의존성을 추가해 주기로 했다.

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

 

.properties
springdoc.swagger-ui.path=/swagger-ui.html

springdoc.api-docs.path=/api-docs

springdoc.default-consumes-media-type=application/json
springdoc.default-produces-media-type=application/json

springdoc.swagger-ui.operations-sorter=alpha
springdoc.swagger-ui.tags-sorter=alpha

springdoc.swagger-ui.disable-swagger-default-url=true
springdoc.swagger-ui.display-query-params-without-oauth2=true
springdoc.swagger-ui.doc-expansion=none

처음에는 타 블로그 글을 참고하면서, swaggerConfig를 작성하기도 했었다. 하지만 가장 최신버전은 config를 작성하지 않아도 자동 api명세서를 작성해준다고 한다.

 

하지만 그럼에도 위 에러코드는 계속 발생했다.

튜터님의 솔루션과 stackoverflow를 참고했더니 @RestControllerAdvice에 @Hidden을 작성해주면 해결된다고 한다.

https://stackoverflow.com/questions/79274106/how-to-use-both-restcontrolleradvice-and-swagger-ui-in-spring-boot

 

How to use both @RestControllerAdvice and Swagger UI in Spring boot

I have a very simple Spring boot project contains Swagger UI: <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artif...

stackoverflow.com

@Hidden
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(NoMatchPasswordConfirmation.class)
    protected ApiResponseDto<String> noMatchPasswordConfirmationHandler(NoMatchPasswordConfirmation ex) {
        log.error("예외 발생 = {}", ex.getMessage());
        return ApiResponseDto.fail(ErrorCode.NO_MATCH_PASSWORD_CONFIRMATION);
    }
}

 

이유는 ControllerAdvice와 swagger가 일부 충돌하는 부분이 있어 ControllerAdvice를 숨겨주어야한다는 것이다. 

이를 해결해주면 다음과 같이 해당 링크에 swagger화면이 잘 출력되는 것을 확인 할 수 있다.

http://localhost:8080/swagger-ui/index.html