우선 시작 하기 앞 서 기본적인 설정들을 세팅해주자,
먼저 스프링 프로젝트를 만들 때 의존성으로
- Lombok, Spring Web, Spring Data JPA, MySQL Driver를 추가해준다.
의존성이란?
의존성 (Dependency)은 프로젝트가 제대로 동작하기 위해 필요한 외부 라이브러리나 외부 코드를 의미하는데,
프로그램을 만들 때 모든 기능을 처음부터 끝까지 직접 개발하는 것은 비효율적이다.
그래서 이미 다른 사람들이 만들어 놓은 편리한 기능(부품)을 가져와 조립해서 사용하는데, 이것이 바로 "의존성"이다.
의존성은 프로젝트를 생성할 당시에 추가하여 사용하면 되는데, 만약 프로젝트를 생성할 때 실수로 추가를 못했다면, build.gradle안에 있는 dependencies에 직접 추가하여 사용해도 된다.
- build.gradle - dependencies
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
"JPA DDL 설정 하기"
Data Definition Language : 데이터 정의어
DDL은 데이터베이스의 "구조"를 정의하거나 변경할 때 사용하는 명령어
- application.properties - 프로젝트 설정 정보 파일
spring.application.name=schedule
spring.datasource.url=jdbc:mysql://localhost:3306/schedule
spring.datasource.username=root
spring.datasource.password=12345678
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jap.hibernate.ddl-auto=create >> 애플리케이션이 시작될 때 기존 테이블을 어떻게 할지에 대한 옵션
create로 해놓으면 기존 테이블 삭제 후 재생성을 의미하며, 개발 초기단계에 사용한다.
3Layer Architecture 기반 패키지 생성

3Layer Architecture에 의해,
Controller > Service > Repository 순서로 계층이 형성되고,
각 상위 계층에서 하위 계층을 호출할 수 있다.
Controller는 Service를 호출하고, Service는 Repositoey를 호출한다.
단, Controller가 Repository를 호출하는 것은 불가능하다.
계층을 뛰어 넘어서 호출하는 것은 불가능!!
Repository >> "창고 관리인"
Repository는 DB와 소통을 하는데, 이 때 다리를 놔주는게 Entity,
Entity는 JPA(DB)에 데이터를 저장할 때 사용하는 객체인데, JPA(DB) 원본과 유사하다고 볼 수 있다. 이러한 Entity를 클라이언트에게 직접 전달해줄 수 없으니, DTO를 사용하는 것이다.
여기서 확실하게 짚고 갈 부분은, 우리가 DB와 유사한 Entity를 활용하지만,
Entity는 DB의 정보를 담고있는 모델일 뿐이다.
💡 Entity가 JPA의 관리 대상인 것은 맞지만, 어디까지나 관리받는 대상일 뿐이지, 그 관리를 실제 수행하고, DB와 대화하는 실행자는 Repository라는 것을 기억하며 가자.
"Entity 클래스"
Schedule Entity
package entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Entity
@Table(name = "schedules")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Schedule extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, nullable = false)
private String title;
@Column(length = 100, nullable = false)
private String contents;
@Column(length = 50, nullable = false)
private String writer;
@Column(length = 50, nullable = false)
private String password;
public Schedule(String title, String contents, String writer, String password) {
this.title = title;
this.contents = contents;
this.writer = writer;
this.password = password;
}
public void update(String title, String writer) {
this.title = title;
this.writer = writer;
}
}
- @Entity >> 해당 클래스가 Entity임을 선언해주는 어노테이션
- @Table >> Entity와 매핑할 테이블 정보를 지정해주는 어노테이션
- @NoArgsConstructor >> 인자가 없는 생성자, 기본 생성자를 만들어주는 어노테이션
- (access = AccessLevel.PROTECTED) >> 접근 제한을 Protected로 설정
현재 BaseTimeEntity를 상속 받았는데, 이 부분은 아래 BaseTimeEntity에서 확인,
- @GeneratedValue(strategy = GenerationType.IDENTITY >> DB에서 id값을 1,2,3,4,5,6,7..... 순서대로 자동 생성해준다.
각 필드값에 @Column(length = 50, nullable = false)로 컬럼 어노테이션을 매핑했는데,
length는 길이를 의미하고, nullable = false는 null을 허용하지 않겠다는 의미이다.
🚀 트러블 슈팅 1. writer 필드
Schedule 엔티티 클래스의 필드값에 private String writer라는 작성자에 대한 필드를 보고, 처음엔 이것을 "사람의 이름이니까 변경되면 안되지 않나?"라고 생각을 했다. 그래서 updatable = false를 걸어줬었는데, 내가 지금 무엇을 만들고 있는지와 updatable = false의 의미를 자세히 생각해보니,상당히 잘못 생각하고 있었다는 것을 금방 느꼈다.
- updatable = false를 걸어주게 되면 "수정이 불가능"해진다, 클라이언트에게 PUT요청이 와도 Update메서드를 활용하여 수정하는 것이 안된다. 그러면 만약, 사용자가 일정관리 앱에서 일정을 만들 때 실수로 작성자 이름에 오타를 냈다면, writer 필드를 수정 불가능하게 만들었기 때문에 변경할 수 없게된다. 일정을 다 지워버리고 다시 만들어야 된다. 이건 말이 안된다.
- 지금 만드는 것은 "일정을 관리해주는 앱"이다. 사람에 관련된 앱을 만드는게 아니다! 포커싱을 일정 관리 앱에 둬야지, writer라는 필드값에 둘 이유가 없다..
- 사용자의 id(고유값)과 writer(필드값)은 매우매우 다른 것이다. 처음에 나는 이것을 마치 고유한 값 처럼 생각을 하고 있었던 것 같다. 물론 상황에 따라 필드값에도 updatable = false를 걸어줄 수도 있을 것이다. 하지만 지금 과제에서는 일정 제목(title)과 작성자명(writer)만 수정 가능하다고 대놓고 나와 있다. 이 것을 updatable = false를 걸어주게 되면 수정이 불가능해지니 바로 전복되는 것이다.
여기서 unique = true와 updatable = false의 차이를 비교해보자,
- unique = true는 "너랑 똑같은 녀석은 이 동네에서 너 하나뿐이어야해"라는 느낌으로, 중복방지를 해주는데, 수정은 가능하다.
- updatable = false는 "너는 한번 태어났으면 절대 변하면 안돼"의 느낌으로, 수정을 방지, 즉 불변성을 유지해주는 것이다.
두개 다 무결성을 유지하기 위한 수단이지만 성격은 완전히 다르다.
그래서, 이메일, 아이디 처럼 하나만 존재해야 하는 값에는 unique = true를 사용하고,
한 번 저장되면 바뀌지 않아야 할 값들에는 updatable = false를 사용한다.
만약, writer 필드에 unique = true를 걸게되면 동명이인이 있을 때 둘 중 한명은 일정관리 앱을 사용 못할 것이고, 한 명의 사용자가 여러개의 일정을 동시에 올리는 것도 불가능 하게 될 것이다.
- 작성자 수정이 필요한 이유 : 오타, 담당자 변경 등등 여러 상황이 있을 수 있음.
- unique = true를 빼야하는 이유 : 똑같은 사람이 일정을 여러개 올릴 수 있음, 동명이인
Schedule Entity의 생성자
public Schedule(String title, String contents, String writer, String password) {
this.title = title;
this.contents = contents;
this.writer = writer;
this.password = password;
}
생성자에는 id(고유의 값)을 제외한 나머지 필드를 넣어준다. 그 이유는 id는 DB에서 만들어주기 때문이다.
그리고 BaseTimeEntity를 상속받았으니, 현재 이 필드에는 LocalDateTime createdAt; 과 LocalDateTimeModifiedAt;이 들어있는 것이다.
나중에 ResponseDTO를 만들 때 필드값과 생성자에 LocalDateTime createdAt과 LocalDateTimeModifiedAt을 추가해줘야 에러가 발생하지 않는다.
Schedule Entity 내부의 update 메서드
public void update(String title, String writer) {
this.title = title;
this.writer = writer;
}
💥 트러블 슈팅 2. update 메서드만 왜 Entity에 있는거야?
update 메서드만 Entity 내부에 있는 가장 큰 이유는 객체지향의 원칙 때문이라고 한다.
"자기 데이터는 자기가 관리한다"는 느낌 같은데, 한 마디로 캡슐화를 한 것 같다. 그리고 나중에 수정할 때 규칙이 생겼다면, 서비스 코드를 건드리지 않고, Entity 내부의 update 메서드만 고쳐주면 된다.
그러면, update말고, save, find, delete도 Entity에 만들어 놔도 될까? -->> 안된다!
"내 몸을 바꾸는 것 vs 세상에서 사라지거나 나타나는 것"
- Update >> 이건 내 상태(Entity)를 바꾸는 것, 붕어빵으로 예를 들면 붕어빵 안에 든 팥을 크림으로 바꾸는건 붕어빵(Entity)스스로 할 수 있음,
- Save / Find / Delete >> 이건 DB라는 창고(세상)와 상호작용, 붕어빵이 스스로 창고로 집어 넣거나(save), 창고에서 자기 스스로를 꺼내거나(find), 스스로를 소멸시키는(delete)는 행위는 불가능!
그래서 아까 위에서 말한 창고관리인 "Repository"가 필요하다!
Update만 Entity의 특별대우를 받는 이유는, 변경 감지(Dirty Checking) 때문이다!
변경감지, 더티체킹은 update 메서드가 호출되서 데이터가 단 한 글자라도 바뀌면 JPA가 데이터가 변한 것을 감지하여 트랜젝션이 끝나는 시점에 자동으로 업데이트 쿼리를 날려주는 JPA의 기능이다.
Update 메서드는 JAP가 더티체킹을 통해 자동으로 DB에 업데이트를 해주는데,
Save, Find, Delete같은 기능들은 "창고에 넣어라", "창고에서 빼라" 같이 명시적인 명령이 필요하기 때문에, 반드시 Repository를 거쳐야된다.
Entity는 데이터 그 자체이고, Repository는 그 데이터를 관리하는 창고지기인데, Update(수정, 고치기)는 스스로 할 수 있지만, 창고에 들어가고 나가는 건 창고지기(Repository)의 도움이 필요하다.
위치 이유
데이터 변경 (Update) Entity 객체의 상태를 직접 관리(캡슐화)
데이터 저장 (Save) Repository DB라는 저장소에 객체를 밀어 넣는 행위이므로
데이터 조회( Find) Repository 수많은 데이터 중 조건에 맞는 객체를 찾아오는 행위이므로
데이터 삭제 (Delete) Repository DB에서 해당 데이터를 영구히 제거하는 행위이므로
BaseTime Entity
package entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime modifiedAt;
}
@EntityListeners(AuditingEntityListener.class) >> 엔티티에 리스너를 연결해주는 어노테이션
@CreatedDate >> 엔티티의 생성 시간을 자동으로 관리해주는 어노테이션
@LastModifiedDate >> 엔티티의 수정 시간을 자동으로 관리해주는 어노테이션
BaseTimeEntity 클래스는 JPA Auditing을 활용하여 작성일과 수정일을 자동으로 관리해주기 위해 생성한 클래스이다.
JPA Auditing 기능을 사용하기 위해서는 메인 메서드가 있는 클래스에 꼭 @EnableJpaAuditing 어노테이션으로 Auditing 인프라를 활성화 해줘야한다!
LocalDateTime createdAt; >> createdAt 은 작성일
처음 생성된 시각을 자동으로 기록하는 필드
LocalDateTime modifiedAt; >> modifiedAt 은 수정일
수정(업데이트)시점을 자동으로 기록하는 필드
작성일은 변경할 수 없으며, 수정일은 수정 완료 시, 수정한 시점으로 변경되어야 한다는 조건이 있었으므로,
createdAt에는 @Column에 updatable = false를 걸어줬다.
BaseTimeEntity를 Schedule에게 상속해줘서 작성일과 수정일을 자동으로 관리하게 해준 것이다.
🚀 트러블 슈팅 3. Entity와 JPA의 관계
Schedule 엔티티 내부에 있는 Schedule 생성자는 DTO의 Response를 위한 것이 아니다!
엔티티에 정의된 생성자는 DB에 새로운 데이터를 넣을때(저장할 때) 객체를 만들기 위한 용도이고, 응답(Response)을 위한 용도가 아니다, 엔티티의 생성자는 Service에서 new Schedule(...)을 호출할 때 사용되고, 응답시점에는 이미 DB에 저정된 엔티티를 가져와서 DTO의 생성자에 엔티티의 값들을 옮겨 담아 만든것이다.
엔티티 생성자 -> "이 데이터를 생성할 때 최소한으로 필요한 정보가 무엇일까?"
DTO 생성자 -> "클라이언트에게 어떤 정보까지 보여줄까?"
Entity는 JAP(DB)에 데이터를 저장할 때 사용하는 객체여서 Entity는 거의 DB 원본과 유사하다고 볼 수 있고, 이 Entity를 클라이언트에게 직접적으로 전달 할 수 없으니까 DTO라는 데이터 전달 객체를 만들어서 보여준다.
그리고 3-Layer 계층에 의해서 Controller는 Service를 호출하고, Service는 Repository를 호출하고, Repository는 DB와 직접 소통을 하는데, 이 때 Repository와 DB간의 소통에서 다리를 놔주는게 Entity인 것이고, 그러다 보니 DB 원본과 유사한 Entity를 보호하기 위해 DTO를 만들어서 클라이언트와 소통한다.
"엔티티는 DB라는 안전한 금고 안에 있는 "원형"이고, DTO는 그 금고에서 필요한 것만 꺼내서 예쁘게 포장해 클라이언트에게 전달하는 "선물 상자"이다!
CreateSchduleRequest 주문서를 받아서 Schedule(금고 안의 원형 엔티티)로 만들고, 다시 CreateScheduleResponse를 만들어서 클라이언트에게 전달
여기서 영속성과 비영속성을 짚고 넘어가자,
DTO는 영속 상태가 될 수 없다.
영속 상태는 오직 엔티티에게만 허락 되어 있는데,
영속 상태라는 것은 "JPA 영속 컨텍스트가 관리하고 있는 상태"를 뜻한다. 즉, 이 객체가 변하면 나중에 DB에 자동으로 반영해준다는 것이고, JAP가 찜해놓은 상태이다.
DTO(Request, Response) > 그냥 자바 객체, JPA는 DTO가 누군지도 모르고, DTO의 필드 값이 바뀌어도 DB에 전달해주는 것이 아님,
엔티티 -> JPA 영속 컨텍스트가 관리하는 객체
Service 레이어에서 new Schedule(....) 엔티티를 막 생성한 직후는 아직 비영속 상태이고, scheduleRepository.save(); 이런 식으로 메서드를 실행하거나 @Transactional 안에서 조회되면 엔티티는 영속 상태가 된다. 그렇게 되면 JPA에서 이 엔티티를 관리하기 시작
응답을 줄 때 (Response) 영속 상태인 엔티이에서 데이터를 꺼내서 DTO에 담는데, 이 때 DTO는 영속상태가 되는 것이 아니고 영속 상태인 엔티티의 복사본을 가진 일반 객체인 것이다.
RequestDOT : 영속성과 상관없는 단순 요청 데이터
Entity : save( ) 되거나 조회는 순간 영속 상태가 됨
ResponseDTO : 영속 상태인 엔티티의 데이터를 복사해서 담은 일반 객체
"Repository"
ScheduleRepository는 JPA와 소통하는 녀석이다. 그래서 JpaRepository를 상속해주고, 파라미터에 Entity와 고유의 값(id)의 타입을 같이 넣어준다. 아마도 id는 Entity클래스에서 @GeneratedValue(strategy = GenerationType.IDENTITY를 설정해놔서 DB가 자동으로 값을 생성해주기 때문에 파라미터로 전달하는 것 같다.
package com.example.schedule.repository;
import com.example.schedule.entity.Schedule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
List<Schedule> findAllByWriterOrderByModifiedAtDesc(String writer);
List<Schedule> findAllByOrderByModifiedAtDesc();
List<Schedule> findAllByWriterOrderByModifiedAtDesc(String writer);
>> 이것은 전체 조회를 할 때 작성자명은 기준으로 등록된 일정을 전부 조회하는 기능이다.
전체조회를 할 때는 조회 결과가 10개일 수도 있고, 100개일 수도 있고, 아니면 없을 수도 있다.
자바는 규칙상 하나의 변수에는 하나의 객체만 담을 수 있어서 전체 조회의 경우 List를 사용해준다. 또한 List는 결과가 없을 경우 빈 배열을 반환해줘서 에러를 방지하기 좋고, 동적으로 배열의 길이를 늘렸다 줄였다 해줘서 전체 조회를 할 때는 효율적이다.
findAllByWriterOrderByModifiedAtDesc(String writer) 이 부분을 해석해보자면,
- findAll >> 전체 조회를 하겠다.
- ByWriter >> 여기서 By는 SQL문의 WHERE절이라고 생각하면 되는데, By뒤에는 "조건 필드"가 붙는다. 한마디로 Writer라는 필드값을 조건으로 작성자가 일치하는 것만 골라주라는 것이다.
- OrderBy >> OrderBy는 똑같이 By가 붙어 있지만 이녀석은 조건이 아니다, 정렬의 시작을 알리는 것이라고 생각하면 되는데, OrderBy 뒤에는 정렬의 기준이 되는 필드가 온다. OrderByModifiedAt >> 수정일을 기준으로 정렬을 하겠다는 의미, 앞서 작성일은 수정이 불가능하다는 조건이 있었으므로, updatable = false를 걸어주었었다. 반면 수정일을 기준으로 정렬이 되야한다는 조건이 있었으므로, ModifiedAt을 활용한다.
- Desc >> 내림차순을 의미한다.
findAllByOrderByModifiedAtDesc( );
이 메서드는 By와 OrderBy사이에 조건 필드가 없다, 한 마디로 작성자의 기준으로 조회할 것이라는 조건이 없는 것이다.
조건 없이 모든 일정을 조회할 때 사용을 하는데, 마찬가지로 수정일을 기준으로 내림차순된 결과를 보여준다.
위에 메서드는 ByWriter가 있기 때문에, 파라미터로 작성자를 받아서 해당 작성자의 일정을 보여주지만,특정 작성자만 조회하는 것이 아닌, 전체조회이기 때문에 파라미터로 받는 값이 없다.
이렇게 두개를 만들어 놓은 이유는 사용자가 조회를 할 때 특정 작성자의 일정을 전체 조회하고 싶을 때는 findAllByWriterOrderByModifiedAtDesc(String writer);가 호출될 것이고, 반대로 단순 전체일정을 조회해보고 싶을 때는 findAllByOrderByModifiedAtDesc( );이 사용될 것이다. 좀 더 디테일하게 표현해보면,
writer가 null이 아닐 때 -> findAllByWriterOrderByModifiedAtDesc(String writer);
writer가 null일 때 -> findAllByOrderByModifiedAtDesc( );
있다가 Sevice에서 비즈니스 로직 중에 조회하는 로직을 보면 if문을 활용하여 scheculeRepository를 호출해서 해당 메서드들을 정의해놓은 것을 볼 수 있다.
그리고 Controller에서는 Service에서 리턴 해준 값을 파라미터에 Strign writer로 받아주고, @RequestParam(required = false)를 걸어줘서 wirter 필드가 조회 조건으로 포함될 수도 있고, 포함되지 않을 수도 있다는 "필수가 아니다" 라고 명시를 해준 것을 볼 수 있다.
🚀 트러블 슈팅 4. 일정을 조회할 때 데이터를 찾는 로직을 왜 엔티티가 아닌 레포지토리에 만드는가?
엔티티는 데이터 그 자체이자 개별 객체의 규칙을 담는 곳이다.
예를 들어, Schedule 엔티티는 "내 제목은 이거야","내 작성자는 누구야" 등등 자신의 상태만 알고 있음,
엔티티는 DB 전체에 어떤 데이터가 있고, 다른 일정이 몇개나 있는지 등은 전혀 알지 못한다.
ex) 학생 한명이 전교생 명단을 다 들고 있진 않음
레포지토리는 말 그대로 저장소이고, 이것을 관리하는 대리인 같은 느낌이다.
예를 들어 "이 일정을 작성한 작성자 A씨의 데이터를 모두 가져와줘"라고 요청한다면, 특정 객체 하나가 할 수 있는 일이 아닐것이다. DB라는 거대한 창고를 뒤져서 조건에 맞는 엔티티들을 찾아오는 역할을 레포지토리에게 위임!
한마디로,
엔티티 > DB의 정보를 담고 있는 데이터 모델,
레포지토리 > 엔티티들이 모여 있는 창고 전체를 관리하는 관리자.
엔티티가 JPA의 관리 대상인 건 맞지만,
어디까지나 관리받는 대상일 뿐, 그 관리를 실제로 수행하고 DB와 대화하는 실행자는 레포지토리!
"RequestDTO(요청DTO)"
Controller와 Service 로직을 보기 전에 DTO들을 먼저 살펴보고 가자,
DTO란?
DTO는 Data Transfer Object의 약자로, "데이터를 전송하는 객체"이다.
🚀 트러블 슈팅 5. Entity vs DTO
앞서 얘기했듯이 Entity는 JAP(DB)에 데이터를 저장할 때 사용하는 객체여서 Entity는 거의 DB 원본과 유사하다고 볼 수 있다.
이러한 Entity를 클라이언트에게 직접적으로 전달 하게 되면 민감 정보가 들어있을 경우 전부 노출될 것이다. 그래서 우리는 DTO라는 데이터 전달 객체를 만들어서 보여주는 것이다.
그리고 3레이어 계층에 의해서 Controller는 Service를 호출하고, Service는 Repository를 호출하고, Repository는 DB와 직접 소통을 하는데, 이 때 Repository와 DB간의 소통에서 다리를 놔주는게 Entity인 것이고, 그러다 보니 DB 원본과 유사한 Entity를 보호하기 위해 DTO를 만들어서 클라이언트와 소통하는 것이다.
"Service (비즈니스 로직)"
import com.example.schedule.dto.*;
import com.example.schedule.entity.Schedule;
import com.example.schedule.repository.ScheduleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ScheduleService {
private final ScheduleRepository scheduleRepository;
@Transactional
public CreateScheduleResponse save(CreateScheduleRequest request) {
Schedule schedule = new Schedule(
request.getTitle(),
request.getContents(),
request.getWriter(),
request.getPassword()
);
Schedule savedSchedule = scheduleRepository.save(schedule);
/**
CreateScheduleResponse response = new CreateScheduleResponse(
savedSchedule.getId(),
savedSchedule.getTitle(),
savedSchedule.getContents(),
savedSchedule.getUsername(),
savedSchedule.getPassword()
);
*/
return new CreateScheduleResponse(
savedSchedule.getId(),
savedSchedule.getTitle(),
savedSchedule.getContents(),
savedSchedule.getWriter(),
savedSchedule.getCreatedAt(),
savedSchedule.getModifiedAt()
);
}
// 단 건 조회
@Transactional(readOnly = true)
public GetScheduleResponse getOne(Long scheduleId){
Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("없는 일정 입니다.")
);
return new GetScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getWriter(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
}
// 여러개 조회
@Transactional(readOnly = true)
public List<GetScheduleResponse> getAll(String writer){
List<Schedule> schedules;
if(writer != null) {
schedules = scheduleRepository.findAllByWriterOrderByModifiedAtDesc(writer);
} else{
schedules = scheduleRepository.findAllByOrderByModifiedAtDesc();
}
List<GetScheduleResponse> dtos = new ArrayList<>();
for (Schedule schedule : schedules) {
GetScheduleResponse dto = new GetScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getWriter(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
dtos.add(dto);
}
return dtos;
}
@Transactional
public UpdateScheduleResponse update(Long scheduleId, UpdateScheduleRequest request) {
Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("없는 일정 입니다.")
);
schedule.update(
request.getTitle(),
request.getWriter()
);
return new UpdateScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getWriter(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
}
@Transactional
public void delete(Long scheduleId){
boolean existence = scheduleRepository.existsById(scheduleId);
if(!existence){
throw new IllegalStateException("없는 일정입니다.");
}
scheduleRepository.deleteById(scheduleId);
}
}
@Service >> 서비스임을 선언해주는 어노테이션
@RequiredArgsConstructor >> 꼭 필요한 필드들만 골라서 생성자의 매개변수로 만들어주는 어노테이션, Controller나 Service처럼 다른 객체를 주입 받아 써야하는 클래스에서 주로 쓰임. (final)
Service는 Repository의 상위계층이므로, private final ScheduleRepository scheduleRepository; 필드값으로 선언해준다.
Service는 실제 비즈니스 로직이 구현되는 곳인데, @Trasactional 어노테이션을 붙여주어, 한 번 성공하면 다 성공하고, 한 번 이라도 실패하면 다 실패하는 원자성을 부여한다.
save (생성하면 저장하는 메서드)
@Transactional
public CreateScheduleResponse save(CreateScheduleRequest request) {
Schedule schedule = new Schedule(
request.getTitle(),
request.getContents(),
request.getWriter(),
request.getPassword()
);
Schedule savedSchedule = scheduleRepository.save(schedule);
return new CreateScheduleResponse(
savedSchedule.getId(),
savedSchedule.getTitle(),
savedSchedule.getContents(),
savedSchedule.getWriter(),
savedSchedule.getCreatedAt(),
savedSchedule.getModifiedAt()
);
}
클라이언트로 부터 요청이 들어오면 Controller를 거쳐서 Service에게 온다. 그러면 이 Service는요청에 대한 비즈니스 로직을 수행한다. save같은 경우 보면 매개변수에 CreateScheduleRequest request 클라이언트의 요청을 받아주고 있는데,
이 요청 정보들을 엔티티 내부에 Schedule 생성자를 호출하고, getter로 request에 접근해서 데이터를 가져와주고, 그 데이터를 schedule 변수안에 넣어준다. 그 다음 Repository를 호출해서 save를 수행한다. 이렇게 되면 savedSchedule이라는 녀석은 DB를 갔다온 상태, 즉 영속된 객체가 되어있는 것이다. 그런데, 우리가 Controller에게 다시 응답을 돌려줘야하는데, CreateScheduleResponse 타입으로 맞춰줘야 하기 때문에, 새로운 CreateScheduleResponse를 만들어주고, DB에 갔다온 savedSchedule에 getter로 접근하여 데이터를 참조해준다. 그리고 이것을 Controller에게 반환해준다.
단건 조회
@Transactional(readOnly = true)
public GetScheduleResponse getOne(Long scheduleId){
Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("없는 일정 입니다.")
);
return new GetScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getWriter(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
}
@Trasactional (readOnly = true) >> 읽기 전용이다.
단건 조회이므로, 어떤 사용자를 조회할지 매개변수에 Long scheduleId값을 받아준다.
그리고 조회했을 때 없을 수도 있으므로, orElseThrow로 Optional을 벗겨줘야 하는데,
🚀 트러블 슈팅 6. Optional을 벗겨낸다고요?
예를 들어서 scheduleRepository.findById(scheduleId)를 호출 하면,
스프링은 Schedule을 그냥 주지 않고 Optional<Schedule> 이라는 상자에 담아서준다. 그 이유는 있을 수도 있고, 없을 수도 있다는 것을 미리 경고해주는 것인데, orElseThrow를 사용하여 있으면 내용물을 담아주고,
없으면 예외를 던져줘 라고 조회결과가 없을 수도 있는 상황에 대비하는 것이다.
그래서 정리 해보면, 스프링은 있을수도 있고 없을 수도 있는 데이터에 대해서는 Optional로 담아주는데, orElseThrow를 사용하여 있을 때는 데이터를 담아주고, 없으면 미리 약속한 예외를 던져주는 것으로, Optional을 벗겨준다고 표현을 한다.
상자에 묶인 상태
Optional<Schedule> maybeSchedule >> 아직 못씀
Optional 벗기기 >> .orElseThrow( )
상자에서 탈출
Schedule schedule >> 이제 마음껏 쓸 수 있음
ex) schedule.getId( )
단건 조회도 마찬가지로 리턴 타입을 맞춰줘야 하므로, GetScheduleResponse 새로운 객체를 생성해주고, getter로 schedule에 접근하여 필요한 데이터들을 가져온다. 그리고 이 것을 Controller에게 리턴해준다.
여러개 조회
@Transactional(readOnly = true)
public List<GetScheduleResponse> getAll(String writer){
List<Schedule> schedules;
if(writer != null) {
schedules = scheduleRepository.findAllByWriterOrderByModifiedAtDesc(writer);
} else{
schedules = scheduleRepository.findAllByOrderByModifiedAtDesc();
}
List<GetScheduleResponse> dtos = new ArrayList<>();
for (Schedule schedule : schedules) {
GetScheduleResponse dto = new GetScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getWriter(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
dtos.add(dto);
}
return dtos;
}
여러개를 조회하는 메서드를 만들 때는 List를 사용한다.
위에서 언급했듯이 조회결과가 1개일 수도, 100개일 수도, 아니면 0개일 수도 있어서 여러 조회 결과를 동적으로 담아주기 위해 List를 사용하고, 만약 결과가 없다면 빈 배열을 반환해준다.
아까 ScheduleRepository에 만들었던 findAllByWriterOrderByModifiedAtDesc(String writer); 메서드와 findByAllOrderByModifiedAtDesc( ); 메서드를 if문을 활용하여 만약, writer가 null이 아니라면 findAllByWriterOrderByModifiedAtDesc(String writer);를 사용하여 조회하고, 그렇지 않다면, 즉 writer가 없이 전체조회를 한다면 findByAllOrderByModifiedAtDesc( );를 호출하여 조회한다.
두 조회 결과는 수정일 기준으로 내림차순되어 결과가 반환되는 것은 동일하다.
그리고 밑에 있는 List<GetScheduleResponse>는 GetScheduleResponse 타입의 dtos라는 새로운 ArrayList를 만들어 준 것이다. 이유는, 현재 schedules 안에 조회하려는 사용자의 정보들이 들어있는데, 아직 타입이 Schedule이다.
반환 타입은 GetScheduleResponse로 반환되어야 하기 때문에, 이것을 맞춰주기 위해 dtos List를 새로 만들어주고,
for문을 활용하여 schedules 안에 데이터를 schedule로 옮겨준다. 하지만 아직까진 여전히 타입이 Schedule이다.
그래서 GetScheduleResponse 타입의 dto라는 새로운 객체를 생성해주고, getter로 데이터를 옮긴 schedule에 접근하여 필요한 필드들을 가져와준 다음 이 데이터들을 dto 변수에 저장을 해줌으로써 GetScheduleResponse 타입의 리스트 안에 들어갈 자격을 만들어준 것이다.
타입을 맞춰준 dto안에 있는 데이터들을 dtos.add(dto)를 통해 dtos List에 저장해주고, dto를 리턴해준다.
수정하는 메서드 (update)
@Transactional
public UpdateScheduleResponse update(Long scheduleId, UpdateScheduleRequest request) {
Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("없는 일정 입니다.")
);
schedule.update(
request.getTitle(),
request.getWriter()
);
return new UpdateScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getWriter(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
}
클라이언트가 요청한 수정할 사용자의 id 값을 매개변수로 받아준다.(Long scheduleId), 그리고 수정사항에 대한 내용은 UpdateScheduleRequest DTO를 매개변수로 받아준다. PUT요청은 수정할 사용자의 고유 id값도 전달해줘야하지만, 어떤 수정을 할건지 수정 내용은 RequestBody에 담아서 주기 때문에 이렇게 받아준 것이다.
수정할 사용자를 찾아야되기 때문에, findById를 활용하고, 만약 조회했을 때 없을 수도 있으므로, orElseThrow로 Optional을 벗겨준다. 이렇게되면 없을 경우는 예외를 던져주고, 있을 경우는 schedule.update 메서드를 실행시켜 요청한 데이터들을 수정한다.
과제 조건중에 일정 제목과 작성자만 수정이 가능하다는 조건이 있었으므로, title과 writer만 요청 데이터에서 가져와주고,
리턴 타입을 UpdateScheduleResponse로 맞춰준 다음 리턴해준다.
여기서 헷갈리지 말아야할 부분이, 클라이언트가 요청을 일정 제목과 작성자만 수정 요청을 했어도, 응답을 돌려줄 때는 나머지 필드에 대한 내용도 같이 돌려주는 것이 좋다. 그래야지 클라이언트가 수정된 내용이 어떤것이며 기존의 내용들은 어떻게 되어 있었는지 한번에 확인이 가능하다.
delete 삭제 메서드
@Transactional
public void delete(Long scheduleId){
boolean existence = scheduleRepository.existsById(scheduleId);
if(!existence){
throw new IllegalStateException("없는 일정입니다.");
}
scheduleRepository.deleteById(scheduleId);
}
delete는 클라이언트가 어떤 사용자를 삭제할지 요청을 보낸 것을 매개변수로 받아준다. (Long scheduleId)
삭제할때 중요한 것은 이 사용자가 존재하는지 안하는지를 알아야된다. 없는 사용자를 삭제하는 것은 불가능 할 것이다.
그래서 boolean 타입으로 existence 변수를 만들어주고, scheduleRepository를 호출하여 existsById로 있는지 없는지 확인해준다. 여기서 주의할 점이 existence는 그냥 지역변수를 만들어준것이고, existsById는 findById같은 문법이다.
findById와 existsById의 차이점은, findById는 데이터를 다 긁어와서 상자에 담아온다. 반면 existsById는 해당 데이터가 있는지 없는지만 보고 오는 것이다. 우리가 삭제를 실행할 때는 어차피 해당 id값을 삭제시켜버리는 것이 때문에, 다른 내용들은 궁금하지 않다, 단지 삭제시킬 id가 존재하는지 안하는지만 궁금하면 된다. 삭제시켜버리면 싹다 없어지는데, 굳이 다른 내용들까지 긁어 올 필요가 없는 것이다.
exists + By + Id >> 존재하니? + ~을 기준으로 + Pk(id)값
그래서 삭제할 값이 없는 경우면, throw new로 예외를 던져주고, 삭제할 값이 있다면 scheduleRepository를 호출하여 deleteById를 실행한다.
"Controller"
컨트롤러는 클라이언트의 요청을 가장 먼저 받아서 Service에게 넘겨주고, 나중에 응답 받는 값을 다시 클라이언트에게 내려주는 역할을 한다. 실제 비즈니스 로직은 Service가 담담을 하고, Controller는 클라이언트의 요청을 처리해주는 역할을 한다.
import com.example.schedule.dto.*;
import com.example.schedule.service.ScheduleService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
@PostMapping("/schedules")
public ResponseEntity<CreateScheduleResponse> createSchedule(@RequestBody CreateScheduleRequest request) {
CreateScheduleResponse result = scheduleService.save(request);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
@GetMapping("/schedules/{scheduleId}")
public ResponseEntity<GetScheduleResponse> getOneSchedule(@PathVariable Long scheduleId) {
GetScheduleResponse result = scheduleService.getOne(scheduleId);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
@GetMapping("/schedules")
public ResponseEntity<List<GetScheduleResponse>> getAllSchedules(@RequestParam(required = false) String writer) {
List<GetScheduleResponse> result = scheduleService.getAll(writer);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
@PutMapping("/schedules/{scheduleId}")
public ResponseEntity<UpdateScheduleResponse> updateSchedule(
@PathVariable Long scheduleId,
@RequestBody UpdateScheduleRequest request)
{
UpdateScheduleResponse result = scheduleService.update(scheduleId, request);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
@DeleteMapping("/schedules/{scheduleId}")
public ResponseEntity<Void> delete(@PathVariable Long scheduleId) {
scheduleService.delete(scheduleId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
@RestController >> REST API를 처리하도록 되어 있는 어노테이션인데, @Controller와 @ResponseBody가 합쳐져있는 어노테이션이다, 실제로 @RestController 내부로 들어가 보면,
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
이렇게, @Controller와 @ResponseBody가 있는 것을 확인할 수 있다.
@Target, @Retention, @Documented이란 것은 메타 어노테이션인데, 어노테이션을 만들때 사용하는 어노테이션이다.
상위계층 Controller가 Service를 호출한다.
private final ScheduleService scheduleService;
🚀 트러블 슈팅 7. ResponseEntity는 뭐에요? Entity인가요?
ResponseEntity >> "HTTP 응답의 전권을 쥐고 있는 마법의 봉투"
- ResponseEntity 의 정체
클라이언트에게 데이터를 보낼 때는 단순히 데이터(Body)만 가는게 아니다. HTTP 규격에 맞춘 정보들이 같이 가야하는데, ResponseEntity는 이러한 구성요소들을 모두 담을 수 있는 객체라고 보면 이해가 좀 쉬운것 같다.
- ResponseEntity
1. Status (상태코드) >> 2xx, 4xx, 5xx 등등
2. Headers(헤더) >> JSON 데이터, 부가 정보들
3. Body(본문) >> CreateScheduleResponse
만약 CreateScheduleResponse DTO만 리턴해주면 데이터와 상태코드가 전달 되긴 하는데, 기본값인 200OK로 고정되어서 전달됨. POST요청이면 201Created를 전달해주는게 맞는데, 기본 상태코드로만 전달이 되다 보니, ResponseEntity를 사용해서 상태코드를 강제로 지정할 수 있음.
- ResponseEntity<CreateScheduleResponse>
>> 제네릭을 사용하는 이유는 ResponseEntity라는 봉투 안에는 CreateScheduleResponse 타입의 알맹이가 들어있을 거야 라고 명시해주는 것
총 정리해보자면, ResponseEntity와 Entity는 전혀 다른 존재이다. ResponseEntity는 스프링 프레임 워크에서 제공하는 응답용 클래스인데, 클라이언트에게 응답을 줄 때 CreateScheduleResponse DTO만 전달 하게 되면 데이터와 상태코드 모두 전달이 되긴 하지만, 상태코드가 기본값인 200OK로 고정되어 있음, 그래서 ResponseEntity로 감싸서 보내주게 되면 상태코드를 우리가 강제로 지정할 수 있고, 그 외의 세부사항들을 개발자가 직접 결정해서 데이터와 함께 전달해줄 수 있다.
Post 매핑
@PostMapping("/schedules")
public ResponseEntity<CreateScheduleResponse> createSchedule(@RequestBody CreateScheduleRequest request) {
CreateScheduleResponse result = scheduleService.save(request);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
클라이언트에게 우리가 지정해줄 상태코드를 전달해주기 위해 ResponseEntity로 감싸준다.
POST 요청의 경우 클라이언트가 등록할 필드값들을 @RequestBody로 넘겨주기 때문에, 이것을 매개변수로 받아준 것이다.
그리고 요청받은 데이터들을 Service에게 넘겨줘여하므로, scheduleService.save(request) 서비스를 호출하여 save메서드로 request를 넘겨주고, Controller -> Service -> Repository -> Service -> Controller 이렇게 DB에 갔다와서 요청을 처리하고 돌아온 데이터를 result에 담아준다. 그리고 이 응답을 최종적으로 클라이언트에게 리턴해주는 것이다.
그리고 CreateScheduleResponse result와 body(result)의 result는 같기 때문에
return ResponseEntity.status(HttpStatus.CREATED).body(scheduleService.save(request)); 이런식으로 리턴값을 적어도 된다.
Get 매핑 (단건 조회)
@GetMapping("/schedules/{scheduleId}")
public ResponseEntity<GetScheduleResponse> getOneSchedule(@PathVariable Long scheduleId) {
GetScheduleResponse result = scheduleService.getOne(scheduleId);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
GET 요청의 경우 Body가 필요없다. 그 이유는 URL 경로로 전달해줘도 충분하기 때문에 굳이 무거운 짐들을 들고 왔다갔다 할 필요가 없다. 그리고 단건 요청의 경우 어떤 사용자를 조회할지 클라이언트가 id값을 보내준다.(scheduleId)
근데 GET요청이므로, URL에 담아서 보내주기 때문에 @PathVariable로 받아준 것이다.
scheduleService를 호출해주고, Service에 있는 getOne이라는 메서들 통해 해당 scheduleId를 조회하고 돌아오면, 조회된 데이터를 result에 담아준다. 그 다음 최종 응답을 클라이언트에게 보내준다.
Get 매핑(전체 조회)
@GetMapping("/schedules")
public ResponseEntity<List<GetScheduleResponse>> getAllSchedules(@RequestParam(required = false) String writer) {
List<GetScheduleResponse> result = scheduleService.getAll(writer);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
전체를 조회하는 GET요청에서는 클라이언트가 URL에 사용자 id값을 보내줄 필요가 없다. 어차피 전체를 조회할 것이기 때문이다.
과제 조건중에, 작성자명을 기준으로 등록된 일정 목록이 전부 조회되야하고, 수정일을 기준으로 내림차순으로 정렬되어 결과가 나오기 때문에, 아까 Service 레이어에서 만들었던 전체조회 getAll 메서드를 보면 findByWriterOrderByModifiedAtDesc(String writer); 와 findAllByOrderByModifiedAtDesc( );가 생각날 것이다.
그런데, 작성자명이 포함될 수도 있고, 포함되지 않을 수도 있다는 조건이 있었으므로, @RequestParam (required = false)를 걸어줌으로써 String writer라는 필드는 필수는 아니야, 포함될 수도 포함 안될 수도 있다는 것을 명시해준 것이다.
이렇게 되면 클라이언트로부터 요청을 받아서 Service에게 넘겨줄 때 writer가 있는지 없는지에 따라 findByWriterOrderByModifiedAtDesc(String writer);와 findAllByOrderByModifiedAtDesc( ); 둘중 하나를 사용하여 결과를 내려줄 것이다.
writer를 넘겨받았다면 -> 해당 writer가 작성한 일정을 전부 보여줘
writer가 없고, 단순 전체조회 -> 모든 일정내용을 전부 조회
Put 매핑(수정)
@PutMapping("/schedules/{scheduleId}")
public ResponseEntity<UpdateScheduleResponse> updateSchedule(
@PathVariable Long scheduleId,
@RequestBody UpdateScheduleRequest request)
{
UpdateScheduleResponse result = scheduleService.update(scheduleId, request);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
PUT요청은 클라이언트가 어떤 사용자(scheduleId)를 수정할지, 수정 내용은 무엇인지 (@RequestBody)를 요청해준다.
그래서 id값은 URL에 보내줘서 @PathVariable로 받아주고, 나머지 수정 내용은 @RequestBody로 받아준다.
이후는 마찬가지이다. shcduleService를 호출하고, 수정할 id값과 수정 내용이 담긴 request를 Service에게 넘겨준다.
Service가 받아서 update메서드를 실행하고, 작업이 다 끝난 데이터들을 다시 Controller에게 응답해주면,
그 응답 값을 result에 담아 클라이언트에게 상태코드와 같이 응답을 내려준다.
Delete 매핑, (삭제)
@DeleteMapping("/schedules/{scheduleId}")
public ResponseEntity<Void> delete(@PathVariable Long scheduleId) {
scheduleService.delete(scheduleId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
클라이언트가 삭제할 사용자의 id값을 URL로 넘겨준다. 그리고 우리는 @PatheVariable로 받아준다.
scheduleService를 호출하고, 삭제할 사용자의 id(scheduleId)값을 Service에게 넘겨주면, 해당 메서드를 실행하고 처리된 결과물을 Controller에게 응답해준다. 그럼 Controller는 다시 이 결과를 클라이언트에게 상태코드와 함께 응답을 내려준다.
삭제에서는 클라이언트가 요청한 id값을 삭제하고나면 데이터가 DB에서 완전히 사라지기 때문에 존재하지 않게 된다. 아예 없어지기 때문에 build( );로 비어있는 값을 돌려주는 것이다. 그리고 완전히 삭제되어 보여줄 값이 없으므로, 당연히 scheduleService.delete(scheduleId);로 수행된 결과 값도 받아줄 변수를 만들 필요가 없다.
참고로 삭제는 204 NO_CONTENT가 올바른 상태코드이다. 만약 ResponseEntity로 감싸서 상태코드를 지정해주지 않는다면 기본 상태코드인 OK로 클라이언트에게 전달될 것이다. 완전 틀린것은 아니지만, 정확하고 세밀한 의미를 전달해주는 것이 올바른 백엔드 개발자라고 생각한다.
이번 과제를 진행하며, 전반적인 API의 흐름, 3레이어 아키텍쳐들의 소통 과정 등을 이해했던 시간인것 같다.