💡 프로젝트 목표 정리
💡 구현하고자 하는 서비스의 전반적인 흐름을 이해하고 기능을 설계하기
💡 API 명세서 작성하기
💡 CRUD 기능이 포함된, REST API 구현하기
💡 3 Layer Architecture 에 따라 각 Layer의 목적에 맞게 프로젝트 개발하기
💡 JDBC를 사용해 DB 연동과 기본적인 SQL 쿼리 작성에 익숙해지기
🚀 역할 분리
- Repository 계층
- DB에서 직접 데이터를 조회하고, RowMapper로 엔티티를 생성
- 엔티티는 DB 구조에 맞춘 순수한 데이터 객체
- Service 계층
- 엔티티를 비즈니스 로직에 맞게 처리하고, 응답 DTO로 변환
- 불필요한 정보 제거 및 응답 형태로 가공
- Controller 계층
- 서비스에서 받은 DTO를 그대로 API 응답으로 반환
💻 개발 과정과 흐름
스프링을 이용해 구현하는게 처음이라 어떤 순서와 개발하면서 어떤 고민을 했는지에 대해 기록하려고 한다.
Step 1. 프로젝트 구조 설정 후 의존성 추가
Step 2. 데이터베이스 설정 ` application.properties`에 DB 연결 정보 설정
application.properties
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/sparta
spring.datasource.username=root
spring.datasource.password=11111111
server.port=8080
db테이블 생성
create table todo (
id int auto_increment primary key ,
title varchar(255) not null ,
author varchar(255) not null,
description text,
password varchar(255) not null,
dueDate date,
completed boolean default false,
createAt timestamp default current_timestamp,
updateAt timestamp default current_timestamp on update current_timestamp
);
(한 번 더 되새김)
step3. 엔티티 클래스 작성
알게된 점1. @Data
- data 어노테이션은 남발하지 않는게 좋다!
- setter 를 포함하고 있어 무분별하게 setter를 남용할 수 있다.
- 바뀌면 안되는 값들이 ( memeberId, password ) 가 바뀌는 경우가 생길 수 있으니 주의할 것
- 하지만 이번 프로젝트에서 Setter를 남발한 거 같다. 필수과제를 다 끝내고 점검을 할 필요가 있는 것 같다.
알게된 점2. @Builder 패턴
강의 실습을 따라하면서 데이터 순서가 바뀌니까 오류가 났던 기억이 있다. 그래서 이번 과제에서는 빌더를 추가해서 작성해봤따. 생성자를 통해 객체를 생성할 수도 있지만 객체를 생성하는 별도의 Builder()를 둬서 객체를 생성할 수 있다.
- 생성자 파라미터가 많은 경우 가독성이 좋지 않아서 사용 -> 가독성을 높일 수 있다!
- 어떤 값을 먼저 설정하던 상관 없다. -> 내가 원하던 바로 그거!
todoEntity.class - db테이블과 직접 매핑 됨
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class TodoEntity {
private Long id;
private String title;
private String description;
private String author;
private String password;
private boolean completed;
private LocalDate dueDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
reponseDto, RequestDto - 계층 간에 데이터를 전달
- responseDto에는 password를 제외한다
step4. Repository 계층 구현
public interface TodoRepository {
int saveTodo(TodoEntity todoEntity); //일정 생성
int updateTodo(TodoEntity todoEntity); // 일정 수정
int deleteTodo(int id, String password)throws TodoNotFoundException; //일정 삭제
TodoEntity findById(int id)throws TodoNotFoundException; //id로 단일 일정 조회/ 단건 조회
List<TodoEntity> findByAuthor(String author); //작성자 이름으로 일정 조회
List<TodoEntity> findAllList(); // 전체 일정 조회(수정일 기준내림차순으로)
List<TodoEntity> findByTitle(String title);// 근데 제목은 여러건이 있을 . 있으니 리스트로 반환함
}
고민했던 점1. Repository계층에서 `findById` 를 `Optional`로 감쌀지
- `Optional`을 사용하면 값이 없는 경우에 대해 처리할 수 있다.
- `orElse`, `orElseThrow`, `ifPresent` 등을 활용하여 깔끔하게 작성 가능
- 예외 처리는 커스텀 예외를 사용하고 핸들러로 관리하려고 했는데
- Repository에서는 Optional을 반환하고, 서비스 계층에서 예외를 던지는게 좋다고 판단했다.
RepositoryImpl의 findById
@Override
public List<TodoEntity> findByAuthor(String author) {
String sql = "SELECT * FROM todo WHERE author = ?";
return jdbcTemplate.query(sql, todoEntityRowMapper, author);
}
고민한 점2. SimpleJdbcInsert 사용 vs sql 직접 작성
Simplejdbc를 사용해보려고 했는데 오류가 발생했다.
근데 수정해서 어떤 오류로 애먹었는지 기억이 안난다. 다른 방법을 찾았나봄.... ( )
고민한 점3. jdbctemplete 사용 시 순서를 지키지 않으면 오류가 발생하는 부분
개수가 많아지면 순서도 헷갈리고 하나를 빼먹었다가 오류가 발생했는데 이게 불편해서 다른 방법을 찾아봤다.
- 바로 `NamedParameterJdbcTemplate` 사용
public int saveTodo(TodoEntity todoEntity) {
String sql = "INSERT INTO schedule (title, author, description, password, createdAt, updatedAt, dueDate, completed) " +
"VALUES (:title, :author, :description, :password, :createdAt, :updatedAt, :dueDate, :completed)";
Map<String, Object> params = new HashMap<>();
params.put("title", todoEntity.getTitle());
params.put("author", todoEntity.getAuthor());
params.put("password", todoEntity.getPassword());
params.put("description", todoEntity.getDescription());
params.put("createdAt", Timestamp.valueOf(todoEntity.getCreatedAt()));
params.put("updatedAt", Timestamp.valueOf(todoEntity.getUpdatedAt()));
params.put("dueDate", Timestamp.valueOf(todoEntity.getDueDate().atStartOfDay()));
params.put("completed", todoEntity.isCompleted());
try {
KeyHolder keyHolder = new GeneratedKeyHolder(); //insert시 자동으로 키 생성
namedParameterJdbcTemplate.update(sql, new MapSqlParameterSource(params), keyHolder); // 순서 무관하게 바인딩
return keyHolder.getKey().intValue();
} catch (DataAccessException e) {
throw new DataAccessException("일정 저장 중 오류가 발생했습니다", e);
}
}
Step5. Service 계층
- 비즈니스 로직 분리 - 비밀번호 검증은 서비스 계층에서 처리하게 한다.
- 예외 처리 - 레파지토리에서 예외처리를 하지 않고 서비스에서 구현하도록 했다.
- DTO 변환 처리 - `TodoEntity`를 `TodoResonseDto`로 변환하는 메서드를 별도로 작성했다.
public interface TodoService {
int saveTodo(TodoRequestDto requestDto);
int updateTodo(int id, TodoRequestDto requestDto);
int deleteTodo(int id, String password);
TodoResponseDto findById(int id);
List<TodoResponseDto> findAllList();
List<TodoResponseDto> findByAuthor(String author); // 작성자
boolean completedTodo(int id);
}
고민했던 점1. Entity에서 Setter의 사용
Entity에서의 Setter 사용은 좋지 않은 듯 하여 업데이트된 Entity를 새로 만들어 저장하는 방식을 생각해봤다.
@Override
public int updateTodo(int id, TodoRequestDto requestDto) throws TodoNotFoundException {
TodoEntity todo = todoRepository.findById(id)
.orElseThrow(() -> new TodoNotFoundException("일정을 찾을 수 없습니다"));
//db에서 todo를 조회해 Optional로 감쌈
if(!todo.getPassword().equals(requestDto.getPassword())){
throw new TodoNotFoundException("비밀번호가 일치하지 않습니다");
}
//entity에서의 setter의 사용이 과연 맞을까?
todo.setTitle(requestDto.getTitle());
todo.setDescription(requestDto.getDescription());
todo.setAuthor(requestDto.getAuthor());
todo.setDueDate(requestDto.getDueDate());
todo.setUpdatedAt(LocalDateTime.now());
return todoRepository.updateTodo(todo);
}
수정본
TodoEntity updatedTodo = TodoEntity.builder()
//.title(requestDto.getTitle())
.description(requestDto.getDescription())
.author(requestDto.getAuthor())
//.password(todo.getPassword())
.completed(requestDto.isCompleted())
.dueDate(requestDto.getDueDate())
//.createdAt(todo.getCreatedAt())
.updatedAt(LocalDateTime.now())
.build();
return todoRepository.updateTodo(updatedTodo);
고민했던 점2. 예외처리 : 서비스 vs 레포지토리
사실 이 부분에 대해서 이후 과제인 거 같았는데 우선 생각해본 김에 정리해봤다.
책임 분리
- Repository의 역할은 데이터 접근, CRUD 수행이다.
- Service의 역할은 비즈니스 로직이다. 데이터 처리와 예외 처리 등을 한다.
- 예외 처리가 비즈니스 로직의 일부라 볼 수 있어 서비스 계층에서 처리하는게 맞았다.
💡 올바른 예외 처리 흐름
- Repository 계층
- 데이터베이스 접근 시 발생하는 SQLException 또는 DataAccessException 등을 던진다
- 불필요한 예외 변환 없이 순수하게 데이터 접근에 집중!
- Service 계층
- Repository에서 발생한 예외를 잡아서 비즈니스 예외로 변환
- 비밀번호 불일치, 데이터 미존재 등과 같은 도메인 중심의 예외로 변경
- Controller 계층
- 서비스 계층에서 던진 예외를 잡아서 적절한 HTTP 응답 코드와 메시지로 변환한다.
Step6. Controller 계층
고민한점1. 일정 등록한 뒤에 목록으로 돌아갈 때 어노테이션 사용
PRG 패턴 ( Post - Redirect - Get )
Http Post 요청에 대한 응답을 다른 URL로의 Get 방식으로 Redirect 하는 것.
이 패턴을 사용하지 않으면 Post 요청 처리 시 새로고침으로 인해 동일한 요청이 연속적으로 보내지는 오류를 경험한 적이 있다.
Post로 값을 받아 무언가를 변경하는 로직이 있다면 문제가 될 수 있다.
컨트롤러에서 사용하는 어노테이션들이 너무 헷갈렸고 이해가 안갔다. 따로 정리를 할 생각이지만 간단하게 적어보려고 한다.
컨트롤러 | 분류 | 설명 | 사용 시점 |
`@Controller ` | 컨트롤러 | - spirng MVC 컨트롤러 클래스임을 나타냄 - 뷰(View)를 반환하는 경우에 사용 |
뷰(View)를 반환해야 할 때, HTML(JSP, Thymeleaf 등)을 렌더링 |
`@RestController ` | 컨트롤러 | @Controller + @ResponseBody 역할을 함. |
JSON 데이터를 반환해야 할 때 REST API 엔드포인트를 만들 때 사용 (@ResponseBody 포함) |
`@RequestMapping ` | 요청 매핑 | - 클래스 또는 메서드 레벨에서 요청 URL을 매핑 - GET, POST, PUT, DELETE 등 모든 HTTP 메서드 지원 - @GetMapping, @PostMapping 등이 있음 |
|
`@PathVariable` | 요청 데이터 바인딩 | - URL 경로 변수 값을 매개변수로 바인딩 할 때 사용 - RESTful API에서 ID 값을 경로에 포함할 때 사용 리소스 조회, 수정, 삭제 시 유용함 |
URL 경로에서 변수를 추출할 때 REST API에서 동적인 경로 값을 사용할 때 |
`@RequestParam` | 요청 데이터 바인딩 | 쿼리스트링 파라미터를 매개변수로 바인딩 할 때 | 검색어, 필터링 옵션 등 간단한 요청값 전달 |
`@RequestBody` | 요청 데이터 바인딩 | - 클라이언트에서 전송한 JSON 데이터를 객체로 변환할 때 사용 - 주로 POST, PUT 요청 - REST API에서 주로 사용 |
REST API에서 클라이언트가 보낸 JSON을 객체로 변환 |
`@ModelAttribute` | 요청 데이터 바인딩 | - 폼 데이터를 객체로 바인딩할 때 사용. - HTML 폼에서 데이터를 전달받아 객체로 변환. |
|
` @ResponseBody` | 응답 처리 관련 | - 메서드의 반환 값을 JSON 또는 XML 형식의 HTTP 응답 본문으로 변환. - @RestController를 사용하면 자동으로 적용됨. |
REST API 응답을 JSON으로 변환할 때 |
@ResponseStatus | 응답 처리 관련 | HTTP 응답 상태 코드를 설정할 때 사용. |
개발 시 겪었던 문제점
문제1. Restful 한 API가 무엇일까
RESTful한 API를 설계하셨나요? 라는게 발제에 있었다. 그래서 개발을 하던 중 이 부분에 대해서 고민을 했었다. 일단 Restful하다는 것을 이해하기 위해 쉽게 예시를 들어 생각해봤다.
🍔 햄버거 가게에 비유하기
1. 자원(Resource)
- 햄버거, 주문 등과 같이 가게에서 관리하는 대상이다
- 주문 목록 `/api/orders` 특정 주문 `/api/orders/1`
2. 행위(HTTP) 메서드
- 햄버거를 만들거나 주문 관리할 때 하는 행동
- GET : 햄버거 목록 보기 ( /api/hamburgers )
- POST : 햄버거 주문 ( /api/orders )
- PUT : 주문 수정 ( /api/orders/1 )
- DELETE : 주문 취소 ( /api/orders/1 )
3. 무상태성
- 요청 간 상태를 저장하지 않고 매번 필요한 정보를 모두 전송
4. 표준 메세지 형식(JSON)
- 직원들끼리 커뮤니케이션에 통일된 양식으로 함
5. URI의 일관성
- 햄버거를 주문할 때 매번 동일한 주소로 접근
- /api/orders → 주문 목록
- /api/orders/1 → 특정 주문
- /api/orders/1/status → 주문 상태
6. 적절한 상태 코드 사용
- 주문 성공 200 OK
- 새로운 주문 성공적으로 생성되면 201 OK
- 주문이 없을 때 404 Not Found
- 잘못된 요청 형식이면 400 Bad Request
올바른 패턴은 자원을 명사로 표현하여 URL에 행위를 포함하지 않는다
GET /api/todos # 모든 일정 조회
GET /api/todos/1 # ID로 단건 조회
POST /api/todos # 일정 추가
PUT /api/todos/1 # 일정 수정
DELETE /api/todos/1 # 일정 삭제
PATCH /api/todos/1/complete # 일정 완료 처리
문제 상황 - 동작을 URI에 포함함
갑자기 소름이 돋아서 내 코드를 살펴보니.... 내 코드에는 동작을 URl에 포함하고 있다.
사진은 GetMapping이었지만 나는 Post에도 전부 나 뭐해요~하고 동작을 포함하고 있었다.
.....
..........
접근 방법 - RESTful 하게 하려면 명사로 자원을 표현하고, 행위는 HTTTP 메서드로 구분해야 한다.
해결 - URI에는 명사만 사용하고, HTTP 메서드로 행위를 구분
다음과 같이 컨트롤러를 수정해볼 예정이다.
메서드 | 경로 | 설명 |
GET | /api/todos | 모든 일정 목록 조회 |
POST | /api/todos | 새로운 일정 추가 |
GET | /api/todos/{id} | 특정 일정 조회 |
PUT | /api/todos/{id} | 일정 수정 |
DELETE | /api/todos/{id} | 일정 삭제 |
PATCH | /api/todos/{id}/complete | 일정 완료 처리 |
(별거아님) 파라미터 사용 관련
접근 방법 : url 확인
- `http://localhost:8080/todo/read?id=4`
- ` @PathVariable `을 사용하면 `http://localhost:8080/todo/read/4` 이런 형식이여야 한다.
- 그래서 발생한 오류 같다.
@GetMapping("/read/{id}")
public String readTodo(@PathVariable int id, Model model) {
TodoResponseDto todo = todoService.findById(id);
model.addAttribute("todo", todo);
return "read"; // read.html
}
해결 방법 : 컨트롤러 수정( 쿼리 파라미터 사용)
- 현재 url을 그대로 사용하고 싶어서 @RequestParm 로 수정
- No static resource 어쩌고를 보니 read 값 이후에 {id}를 받아와서 생긴 문제라 이 부분도 빼줬다.
빼주니 문제 해결 !
문제2 : 나는 왜 모든 id가 0인가
나를 2시간 동안 괴롭혔던
http://localhost:8080/todo/read?id=null
http://localhost:8080/todo/read?id=0
💡 ID가 0으로 저장되는 경우를 생각해보면
- 자동 증가(AUTO_INCREMENT) 설정이 없는 경우
- 데이터 삽입 때 ID를 0으로 삽입하는 경우
- KeyHolder를 사용하지 않는 경우
고민하다가 지피티 형님에게 물어보았다. (주말에 해서 튜터님께 질문을 드릴 수 가 없었다... )
지피티 형님은 앵무새처럼 2시간 동안 같은 말만 반복했다. 이 형님은 바보가 분명하다.
첫 번째 코드는 별도의 메서드를 작성해서 변환하고 두 번째 코드(AI형님의 코드)는 람다식 내부에서 직접 변환한 코드이다.
누가 봐도 첫 번째 코드가 별도 메서드로 분리해서 가독성도 좋고 코드 재사용성도 좋다.
나는 자동 증가도 해놨고 데이터도 0으로 삽입하지 않고 키홀더마저 사용했는데 그러면 뭐가 문제일까?
-> 서비스 계층에서 DTO로 변환할 때 ID가 제대로 전달됐는지
소름이 돋아서 `toResponseDto`를 찾아보니까 id값에 주석처리가 되어있었다.. 이걸 풀어주니 해결 됐다.
postman에서 id와 createdAt에 의문의 null값이 계속 등장했는데 이게 문제였던 거 같다.
문제3. 수정이 안되는 문제
문제 원인 : 엔티티를 새로 생성하는 방식으로 업데이트 함
서비스 계층에가서 UpdateTodoList 구현 부분을 살펴봄
원인 코드 - 새로운 객체를 만들기 때문에 기존 ID와 데이터가 반영이 되지 않음
TodoEntity updatedTodo = TodoEntity.builder()
.title(requestDto.getTitle())
.description(requestDto.getDescription())
.author(requestDto.getAuthor())
//.password(todo.getPassword())
.completed(requestDto.isCompleted())
.dueDate(requestDto.getDueDate())
//.createdAt(todo.getCreatedAt())
.updatedAt(LocalDateTime.now())
.build();
수정할 필드만 업데이트
todo.setTitle(requestDto.getTitle());
todo.setDescription(requestDto.getDescription());
todo.setAuthor(requestDto.getAuthor());
todo.setCompleted(requestDto.isCompleted());
todo.setDueDate(requestDto.getDueDate());
todo.setUpdatedAt(LocalDateTime.now());
나는 왜 이렇게 코드를 짰을까? 아마 엔티티에서 Setter 사용을 지양하고 싶어서 그런 거 같은데 update 목록만 생성자를 추가해줘야 겠다.
문제4. 갑자기 생겨버린 순환 의존성 문제
오류메세지를 보면 스프링부트의 빈의 순환 참조 문제가 발생한 것이라고 한다..
순환 참조는 두 개의 객체(클래스) 또는 빈이 서로를 의존할 때 발생하는 문제를 말한다.
A가 B를 필요로 하고 B도 다시 A를 필요로 하고 그러면 생기는 문제라는데
코드 예시를 보면 이런식으로 `serviceimpl`에서 자기를 생성자로 주입하고 있어서 발생한 문제이다.
@Service
public class TodoServiceImpl implements TodoService {
private final TodoServiceImpl todoServiceImpl;
@Autowired
public TodoServiceImpl(TodoServiceImpl todoServiceImpl) {
this.todoServiceImpl = todoServiceImpl;
}
}
다시 정리해보자면
- TodoServiceImpl을 스프링이 빈으로 만들 때,
- 생성자를 호출하면서 자기 자신을 다시 주입하려고 함,
- 그러면 무한 루프가 발생해서 스프링이 빈을 생성할 수 없음 → 애플리케이션 실행 실패!!!! 🚨
✅ 자기 자신을 주입하지 않도록 앞으로 잘 확인해야겠다. 인텔리제이의 자동완성 기능이 나를 망친다. 🔥 🔥
필수과제 결과물 🔥
1. + 버튼을 누르면 일정 추가가 가능하다
2. 일정을 추가하면 리스트에 추가돼서 보인다.
3. 제목을 클릭하면 id 로 단건 조회가 가능하다.
4. 비밀번호 확인 하고 비밀번호가 맞으면 수정/삭제 버튼이 뜬다.
5. 수정을 누르면 수정 페이지로가서 할 일 목록을 수정할 수 있다.
6. 삭제 버튼을 누르면 삭제 가능
프로젝트 점검
책임과 역할
객체지향은 가독성보다 책임에 집중을 한다. 소프트웨어가 달성하려는 거대한 목적을 위해 객체들이 책임을 나눠가져야 한다.
어 근데 그럼 절차지향(C언어)도 책임을 구현할 수 있는게 아닌가? 맞다.
절차지향도 책임을 구현할 수 있지만 절차지향에서는 책임을 함수(Procedure)에게 할당하고 객체지향에서는 책임을 객체에 할당한다.
객체지향 언어는 객체에 할당돼 있던 책임을 인터페이스로 분할해서 역할을 만든다. 엄밀히 말하자면 객체지향에서 객체는 책임을 가지지 않는다. 인터페이스로 구현한 구현체, 객체를 추상화한 역할에 책임을 할당한다.
그러니 C언어는 절차지향인거고 자바는 객체지향인거다.
역할과 구현을 분리하고 역할에 책임을 할당하는 것이다.
역할을 이용하게되면 실제 객체가 어떤 객체인지 상관하지 않아도 되고 내가 부탁한 책임과 역할을 할 수 있는 객체라면 협력 객체가 어떤 객체인지 신경쓰지 않아도 된다.
만약 새로운 요구사항이 생기게 된다면 그 역할을 하는 새로운 구현체만 만들어 주면 되는 것이다.
그런의미에서 내 코드는 과연 객체지향적으로 작성한 코드일까?
서비스와 그 기능을 하는 구현체를 만들어 역할과 구현을 분리했고 역할에 책임을 할당했지만
나는 내 코드가 아직도 객체지향적으로 잘 작성된 코드인지 모르겠다.
TDA 원칙
TDA 원칙이란 "Tell, Don't Ask"의 줄임말로 말 그대로 "물어보지 말고 시켜라" 라는 것이다.
무분별하게 사용되는 getter, setter의 사용을 줄여라 라는 단편적인 의미로 해석될 수 있다.
게터와 세터는 개발자가 절차지향적인 사고를 하게되는 대표적인 원인이기도 하다.
게터와 세터의 무분별한 남발로 객체는 외부에서도 모든 데이터에 접근할 수 있게 된다. 하지만 이번 과제를 하면서 getter와 setter를 남발하고 싶지 않았지만 남발한 것 같다.
달성한 목표
💡구현하고자 하는 서비스의 전반적인 흐름을 이해하고 기능을 설계하기
💡 API 명세서 작성하기
💡CRUD 기능이 포함된, REST API 구현하기
💡3 Layer Architecture 에 따라 각 Layer의 목적에 맞게 프로젝트 개발하기
💡JDBC를 사용해 DB 연동과 기본적인 SQL 쿼리 작성에 익숙해지기
처음 작성했던 프로젝트 목표에서 이정도는 달성한 것 같다!
(웹은 예전에 했던 css 긁어온겁니다!) 최대한 서비스의 흐름과 전반적인 이해를 하기 위해 노력했다.
restful 하다가 아직도 잘 와닿지 않는다. 미니세션을 한 번 더 봐야겠다!
'TIL' 카테고리의 다른 글
일정 관리 앱 만들기(Spring JPA 사용) (0) | 2025.04.06 |
---|---|
📅 일정 관리 애플리케이션(도전과제3) (0) | 2025.03.25 |
TIL13. 키오스크 도전과제2 (1) | 2025.03.14 |
TIL12. 키오스크 Lv4~5 진행 (0) | 2025.03.13 |
TIL11. 키오스크 Lv2,3 진행 (1) | 2025.03.12 |