SpringBoot - 타임라인 서비스를 만들어보자(CRUD)

H_dev·2021년 7월 30일
3
post-thumbnail

📌 타임라인 서비스 소개

이 글에서는 강의를 들으면서 만들어 본 타임라인 서비스라고 하는 소식공유 서비스를 다룬다.
클라이언트가 접하는 화면은 이렇게 생겼다.

공유하고싶은 내용을 입력하고 작성을 누르면 DB에 저장이 되고, 작성된 글들을 DB에서 가져와 아래와 같이 화면에 뿌려준다.
작성된 내용은 수정하거나 삭제가 가능하다. (CRUD 구현)
작성된 내용은 24시간 이내의 것들만 보이게 한다.

그리고 여기에는 다음과 같은 의존성들을 추가한다.

  • Spring web
  • Spring Data JPA
  • Lombok
  • H2 Database

Spring web

기존에 웹 관련 기능에 대해 여러 라이브러리를 하나씩 모두 포함해 설정하던 때와 달리 하나만 추가하면 웹 관련 의존성을 한번에 포함시킬 수 있다.

Spring Data JPA

Spring FrameWork에서 JPA를 편리하게 쓸 수 있도록 해준다.
JPA를 사용하면 DB데이터에 접근할 때, Repository라는 인터페이스를 사용하는데 구현할 필요 없이 JPA Repository만 상속받으면 기본적인 CRUD 메서드를 지원해준다.
아. 업데이트 기능은 없다고 한다.
제공되는 메서드말고 새로운 기능이 필요하다면, 규칙에 따라 메서드 이름만 잘 지정해 주면 JPA 알아서 분석해 코드를 붙여준다.
즉, 개발자가 SQL을 직접 작성하지 않아도 되고, 자동으로 해결해주기 때문에 개발에 집중할 수 있게 해준다.

JPA ❓ ORM ❓

ORM(Object-Relational Mapping) 은 DB테이블과 자바 객체(클래스)를 매핑 -> 객체간의 관계를 바탕으로 SQL을 자동으로 생성해준다.
즉, 객체를 DB에 저장할 수 있고 이에 대해 적절한 SQL을 생성해준다는 것

JPA(Java Persistence API) 는 위의 ORM 기술 표준이다.
특정 기능을 하는 라이브러리가 아니고 인터페이스이다.



Lombok

lombok은 반복되는 자바코드를 줄여주는 코드 다이어트 라이브러리라고 한다.
getter setter 같은 것들만 줄여도 엄청나게 간결해진다.
생성자 또한 매번 만들어주는게 번거롭고, 코드가 복잡해지는데 이것도 한줄로 해결이 가능하다.

  • @(=어노테이션) 기반으로 코드를 자동생성해 편리하다.
  • 코드가 많이 간결해져 직관성을 해칠 수 있어 호불호가 갈린다고 한다.

예시

Lombok 사용 안한 코드
getter, setter만 설정해도 길다 ;;

@Entity
public class Person2 extends Timestamped{

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO) // 자동 증가 명령입니다.
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private int age;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

같은 코드를 lombok을 사용한다면?
이렇게 간단하게 코드를 정리할 수 있다.
실제 사용하는 코드가 아니고 예시일 뿐이다.

@Getter
@Setter
@NoArgsConstructor
@Entity
public class Person2 extends Timestamped{

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO) // 자동 증가 명령입니다.
    private Long id;

    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private int age;
}


H2 Database

공부하면서 가볍게 개발하고 있기 때문에 아직 mysql을 연동하지 않았다.
브라우저 기반의 콘솔로 접속해 이용할 수 있고, 저용량이라 매우 가볍다.
SQL문법도 지원이 되고 빠르다. 테스트용이나 공부용으로 가볍게 할 때 많이 이용된다고 한다.
그리고 큰 특징은 in-memory로 사용이 가능해 서버가 켜져있는 동안에만 작동하게 할 수 있다. (서버가 종료되면 데이터 초기화)




💻 서버 측 코드 설명

Memo.java (테이블 역할)

@NoArgsConstructor // 기본생성자
@AllArgsConstructor // 매개변수를 모두 받는 생성자
@Getter
@Entity // 테이블과 연계됨을 스프링에게 알려줍니다.
public class Memo extends Timestamped { // 생성,수정 시간을 자동으로 만들어줍니다.
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String contents;

    public Memo(MemoRequestDto requestDto) {
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }

    public void update(MemoRequestDto requestDto) {
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }
}
  • 먼저 스프링에게 Memo 클래스가 테이블(Entity)이 될 것이라고 알려준다.

  • id 부분은 @Id @GeneratedValue 어노테이션으로 인해 primary key로 지정이 되고 추가될때 마다 자동으로 값이 증가된다.

  • 각 컬럼은 @Column 어노테이션을 통해 null 값을 허용하지 않게 한다.

  • 기본 생성자는 @NoArgsConstructor를 통해 자동으로 만들어주고, 매개변수를 모두 받는 생성자는 @RequiredArgsConstructor를 통해 만들어준다.

  • 값을 꺼내오기 위해 @Getter를 설정해준다. @Setter는 무분별한 사용으로 인해 Entity 일관성을 해치는 것을 막기 위해 설정하지 않는다.

  • requestDto를 받는 생성자와 update 메서드는 DTO를 통해 전달받은 값으로 객체를 생성해 DB조작을 하기위한 코드다.

  • 서버클라이언트가 데이터를 주고 받을 때, Memo(Entity) 자체에 데이터를 담아 오고가는 것은 민감정보 같은것들이 노출 될 수 있어서 보안문제가 생길수도 있다. 그리고 변경이 많기 때문에 다른 로직에도 영향을 줄 수 있다고 한다. 이러한 이유로 꼭 필요한 정보만을 요청하고 응답하기 위해 DTO(Data Transfer Object) 를 사용한다.

    DTO


    DTO는 Data Transfer Object의 약자로, 계층 간 데이터 교환 역할을 한다. DB에서 꺼낸 데이터를 저장하는 Entity를 가지고 만드는 일종의 Wrapper라고 볼 수 있는데, Entity를 Controller 같은 클라이언트단과 직접 마주하는 계층에 직접 전달하는 대신 DTO를 사용해 데이터를 교환한다.

    DTO는 그저 계층간 데이터 교환이 이루어 질 수 있도록 하는 객체이기 때문에, 특별한 로직을 가지지 않는 순수한 데이터 객체여야 한다. 또한 DB에서 꺼낸 값을 DTO에서 임의로 조작할 필요가 없기 때문에 DTO에는 Setter를 만들 필요가 없고 생성자에서 값을 할당한다.
    출처 - ohzzi

다음으로는 Memo 클래스에서 상속받은 Timestamped가 무엇인지 살펴보자.

Timestamped.java

@MappedSuperclass // Entity가 자동으로 컬럼으로 인식합니다.
@EntityListeners(AuditingEntityListener.class) // 생성/변경 시간을 자동으로 업데이트합니다.
@Getter
public abstract class Timestamped { // 상속이 되어야만 사용가능

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;
}

이 클래스는 Entity 들의 상위 클래스가 되어 Entity 들의 생성시간, 수정시간을 자동으로 관리하는 역할을 해준다.

  • 이 클래스가 상속이 되면 자동으로 컬럼으로 인식할 수 있도록
    @MappedSuperclass 를 붙여준다.

  • @EntityListeners를 달아 Auditing을 하도록 하고 변경이 일어나면 자동으로 업데이트하게 한다.

  • abstract를 달아 상속을 받야아만 사용할 수 있도록 추상클래스로 만들어준다. new 생성 X

  • @CreatedDate는 Entity가 생성될 때 시간이 자동으로 저장되게 해준다.

  • @LastModifiedDate는 Entity의 값이 변경될 때, 수정시간이 자동으로 저장되게 해준다.

  • 위와 같은 일들을 가능하게 하려면 꼭 필요한것이 한가지 있다.
    바로 Application.java 클래스에서 아래와 같이 추가해줘야 한다.

Application.java

@EnableJpaAuditing // 이 부분
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}
  • 첫 줄인 @EnableJpaAuditing만 보면 된다.
  • 실행될 때 JPA Auditing을 활성화 시켜줘야 제대로 동작한다.

MemoRequestDto.java

위에서 얘기했던 요청 전달을 위한 DTO를 살펴보자
여기서는 request를 위한 DTO만 사용했다.
response용 DTO도 만들어 사용할 수 있겠지만, 이 프로젝트에서는 response로 DB 데이터를 전달하지 않기 때문에 굳이 사용하지 않았다.

@Getter
public class MemoRequestDto {
    private String username;
    private String contents;
}
  • 먼저 필드는 소식을 작성한 유저의 이름인 username과 글의내용인 contents 두개만 선언해준다.

  • Id는 저장될 때 자동으로 증가되며 생성되기 때문에 요청 데이터를 전달 할때 필요하지 않다.

  • 생성시간수정시간도 저장될 때 자동으로 생성되기 때문에 DTO에는 필요하지 않다.

다음으로 제일 중요한 컨트롤러를 살펴보자!!

MemoController.java

CRUD 같은 비지니스 로직은 Service 단에서 구현해줘야 한다고 알고있지만, 간단하게 만들어 보기 위해 update를 제외한 생성, 삭제, 조회는 컨트롤러에서 빠르게 해결해보자.

@RestController
@RequiredArgsConstructor
public class MemoController {

    private final MemoRepository memoRepository;
    private final MemoService memoService;

    @PostMapping("/api/memos")
    public Memo createMemo(@RequestBody MemoRequestDto requestDto) {
        Memo memo = new Memo(requestDto);
        return memoRepository.save(memo);
    }

    @GetMapping("/api/memos")
    public List<Memo> readMemo() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime before24Hour = LocalDateTime.now().minusDays(1);
        return memoRepository.findAllByModifiedAtBetweenOrderByModifiedAtDesc(now , before24Hour);
    }

    @PutMapping("/api/memos/{id}")
    public Long updateMemo(@PathVariable Long id, @RequestBody MemoRequestDto requestDto) {
        return memoService.update(id, requestDto);
    }

    @DeleteMapping("/api/memos/{id}")
    public Long deleteMemo(@PathVariable Long id) {
        memoRepository.deleteById(id);
        return id;
    }
}
  • Json 형태로 객체 데이터를 반환하기 위해 @RestController를 사용한다.

  • DB조작을 위해 필요한 memoRepository와 update를 위해 필요한 memoService에 @RequiredArgsConstructor를 사용해 final이 붙은 필드에 의존성을 주입해준다.

타임라인 서비스의 API는 간단하게 4가지이다.

메서드URLRETURN기능
POST/api/memosMemoCREATE
GET/api/memosList<Memo>READ
PUT/api/memos/{id}LongUPDATE
DELETE/api/memos/{id}LongDELETE


보기 편하도록 코드를 하나씩 잘라서 다뤄보겠다.

@PostMapping("/api/memos")
public Memo createMemo(@RequestBody MemoRequestDto requestDto) {
        Memo memo = new Memo(requestDto);
        return memoRepository.save(memo);
}
  • POST 에서는 @RequestBodyDTO를 통해 데이터를 전달받아 새 객체를 만들고 Repository를 이용해 save 메서드를 실행하고 return 한다.

  • @RequestBody를 사용하는 이유는 POST 방식에서는 요청메시지의 Body라는 부분에 데이터를 넣어 전송하는데 그것을 꺼내서 받기 위함이다.


@GetMapping("/api/memos")
public List<Memo> readMemo() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime before24Hour = LocalDateTime.now().minusDays(1);
        return memoRepository.findAllByModifiedAtBetweenOrderByModifiedAtDesc(now , before24Hour);
}
  • GET 에서는 24시간 이내의 소식만을 가져오기 위해 시간을 설정해준다.

  • findAllByModifiedAtBetweenOrderByModifiedAtDesc이라는 긴 메소드는 처음 사용해봤다.

이런 게시글형태는 조회를 할때 최신 순으로 정렬을 해야하고, 이 서비스의 컨셉이 24시간 이내의 소식만 가져온다 이기 때문에 Repository를 통해 적절한 메서드를 골라 사용해야하는데 두개가 합쳐진 메서드는 존재하지 않았다.

맨 위쪽에서 설명했듯이 JPA 에는 메서드 이름을 정해진 규칙대로 적절하게 지으면 거기에 해당하는 JPQL(SQL)을 자동으로 붙여준다.
참고 - JPA 공식페이지


해석을 해보면,
find All -모두 찾아라
By ModifiedAt - ModiriedAt(최종 수정시간)을 기준으로
Between - 매개변수 2개를 넘겨줄테니 두 개의 사이에 해당하는 것을
OrderBy - 그리고 정렬해라
ModifiedAt Desc - ModiriedAt(최종 수정시간)을 기준으로 내림차순으로

📝=> 2개의 매개변수 사이 시간대에 있는 게시물들을 최신 순으로 정렬해줘라 라는 뜻이 된다. return은 list 형식으로 된다.



@PutMapping("/api/memos/{id}")
public Long updateMemo(@PathVariable Long id, @RequestBody MemoRequestDto requestDto) {
        return memoService.update(id, requestDto);
}
  • 업데이트를 하기 위한 PUT 요청이다.

  • 뒤에 {id}는 클라이언트 측에서 url에 id값을 넣어 요청하게 되는데 고정된 값이 아니므로 중괄호로 감싸서 받는다.

  • 업데이트를 할 게시글의 id업데이트를 할 내용을 전달받게 되는데
    id는 @PathVariable을 사용해 url의 {id}에 딸려온 값을 받고, 업데이트 내용은 위에서 설명했듯이 @RequestBody와 DTO를 사용해 전달받는다.

  • update는 memoService.java에 구현을 해놨는데, JPA를 사용하는 Repository에 update 기능이 없기 때문에 비지니스 로직을 다루는 service 단에서 @Transactional이라는 어노테이션을 통해 DB에 반영을 시켜줘야 한다. 곧바로 이어질 MemoService.java 에서 다루겠다.



@DeleteMapping("/api/memos/{id}")
public Long deleteMemo(@PathVariable Long id) {
        memoRepository.deleteById(id);
        return id;
}
  • 매우 단순하다. PUT과 같이 URL에 {id}를 전달받는다.

  • 그 id에 해당하는 게시글을 deleteById 메서드를 통해 삭제하고 삭제된 게시글의 id를 반환해준다.



MemoService.java

@RequiredArgsConstructor
@Service
public class MemoService {

    private final MemoRepository memoRepository;

    @Transactional
    public Long update(Long id, MemoRequestDto requestDto) {
        Memo memo = memoRepository.findById(id).orElseThrow(
                () -> new IllegalArgumentException("아이디가 존재하지 않습니다.")
        );
        memo.update(requestDto);
        return memo.getId();
    }
}
  • 현재는 update를 위한 메서드만 존재한다.

  • 당연히 memoRepository가 필요하기 때문에 @RequiredArgsConstructor로 받아준다.

  • 그리고 @Transactional을 붙여 변경되는 값을 실제로 DB에 반영하라고 알려준다.

  • 컨트롤러에서 전달받은 id를 이용해 해당 게시글 객체를 찾는다.

  • 찾은 객체를 멤버 메서드인 update 메서드를 통해 전달받은 DTO 값으로 변경시킨다. (update 메서드는 Memo.java 에 구현되어있음)

  • 그러면 실제로 DB에 반영이 돼서 업데이트 기능이 구현이 된다.

서버 측 코드는 여기까지고 클라이언트 측 코드를 간단하게 알아보자.



👦 클라이언트 측 코드 (축약)

html과 css 같은 부분은 제외하고 필요한 부분만 작성해보려고 한다.
이를테면 요청하고 응답하는 부분 말이다.

작성 요청 코드(POST)

function writePost() {
            // 1. 작성한 메모를 불러옵니다.
            let contents = $('#contents').val();
            // 2. 작성한 메모가 올바른지 isValidContents 함수를 통해 확인합니다.
            if (isValidContents(contents) == false) {
                return;
            }
            // 3. genRandomName 함수를 통해 익명의 username을 만듭니다.
            let username = genRandomName(10);
            // 4. 전달할 data JSON으로 만듭니다.
            let data = {'username': username, 'contents': contents};
            // 5. POST /api/memos 에 data를 전달합니다.
            $.ajax({
                type: "POST",
                url: "/api/memos",
                contentType: "application/json", // JSON 형식으로 전달함을 알리기
                data: JSON.stringify(data),
                success: function (response) {
                    alert('메시지가 성공적으로 작성되었습니다.');
                    window.location.reload();
                }
            });
        }
  • 작성하고 저장 버튼을 눌렀을 때 메모를 불러온 다음, 여기엔 따로 올리진 않겠지만 유효성을 검증하는 isValidContents 함수를 실행하고 문제가 없다면 그대로 내려간다.

  • 그리고 username은 로그인 기능을 따로 만들지 않았기 때문에 익명이다.
    강의 측에서 익명으로 만드는 함수, 유효성 검사 등등 미리 만들어두심

  • 서버와 클라이언트 간 메시지는 json 형식으로 주고받는게 좋고, 많이 사용한다고 한다. 전달할 데이터를 json 형식으로 만들어준다.

  • $.ajax(...) 이게 바로 요청하는 부분이다.
    친절하게도 타입은 post, url은 저거, 전달타입은 json 등등 이런 형식으로 지정할수가 있다. 요청이 되면 위에서 설명했던 서버코드와 매핑해서 기능이 수행되는 것이다.

사실 요청 하는법은 거의 다 비슷하다. 바로 이어서 보자

조회 요청 코드(GET)

이 메서드는 우선 페이지가 로드될 때 자동으로 실행되도록 한다는게 전제이다. 바로 목록을 불러서 뿌려줘야하기 때문이다.

$(document).ready(function () {
            // HTML 문서를 로드할 때마다 실행합니다.
            getMessages();
        })
function getMessages() {
            // 1. 기존 메모 내용을 지웁니다.
            $('#cards-box').empty();
            // 2. 메모 목록을 불러와서 HTML로 붙입니다.
            $.ajax({
                type: 'GET',
                url: '/api/memos',
                success: function (response) {
                    for (let i = 0; i < response.length; i++) {
                        let memo = response[i];
                        let id = memo.id;
                        let username = memo.username;
                        let contents = memo. contents;
                        let modifiedAt = memo.modifiedAt;
                        addHTML(id,username,contents, modifiedAt);
                    }

                }
            })
        }
  • 새로 모두 불러오기 위해서 기존의 내용을 깔끔하게 empty()로 지워준다.

  • 위에서 봤던 것처럼 요청하는 부분은 비슷하다. 다른점은 단지
    response를 통해 받은 게시글 목록들을 하나하나씩 가져와서 화면에 붙여주는 코드만 더해진 것이다. (addHTML(...))

addHTML 함수는 다음과 같다.

function addHTML(id, username, contents, modifiedAt) {
            // 1. HTML 태그를 만듭니다.
            let temp_html = `<div class="card">
                                <!-- date/username 영역 -->
                                <div class="metadata">
                                    <div class="date">
                                        ${modifiedAt}
                                    </div>
                                    <div id="${id}-username" class="username">
                                        ${username}
                                    </div>
                                </div>
                                <!-- contents 조회/수정 영역-->
                                <div class="contents">
                                    <div id="${id}-contents" class="text">
                                        ${contents}
                                    </div>
                                    <div id="${id}-editarea" class="edit">
                                        <textarea id="${id}-textarea" class="te-edit" name="" id="" cols="30" rows="5"></textarea>
                                    </div>
                                </div>
                                <!-- 버튼 영역-->
                                <div class="footer">
                                    <img id="${id}-edit"token interpolation">${id}')" class="icon-start-edit" src="images/edit.png" alt="">
                                    <img id="${id}-delete"token interpolation">${id}')" class="icon-delete" src="images/delete.png" alt="">
                                    <img id="${id}-submit"token interpolation">${id}')" class="icon-end-edit" src="images/done.png" alt="">
                                </div>
                            </div>`;
            // 2. #cards-box 에 HTML을 붙인다.
            $('#cards-box').append(temp_html);
        }
  • 길어보이지만 별거 없다. 게시글을 붙이기 위한 틀이 필요하다.

  • 그 틀에 전달받은 id, username, contents, modifiedAt${}기호를 통해 알맞게 대입 시켜주면 된다.

  • 게시물 틀을 다 변경했다면, 그 틀이 들어갈 자리의 공간(보통 div)의 id를 가져와 append 해주기만 하면 끝난다.



수정 요청 코드(PUT)

function submitEdit(id) {
            // 1. 작성 대상 메모의 username과 contents 를 확인합니다.
            let username = $(`#${id}-username`).text().trim();
            let contents = $(`#${id}-textarea`).val().trim();
            // 2. 작성한 메모가 올바른지 isValidContents 함수를 통해 확인합니다.
            if (isValidContents(contents) == false) {
                return;
            }
            // 3. 전달할 data JSON으로 만듭니다.
            let data = {'username': username, 'contents': contents};
            // 4. PUT /api/memos/{id} 에 data를 전달합니다.
            $.ajax({
                type: "PUT",
                url: `/api/memos/${id}`,
                data: JSON.stringify(data),
                contentType: "application/json",
                success: function (response) {
                    alert("변경 완료");
                    window.location.reload();
                }
            })
        }
  • 수정할 글의 정보를 가져오고 유효성을 검증한다.

  • id를 전달받아 요청에 ${id}로 넘겨준다. javascript에서는 {} 앞에 $가 붙는다.

  • POST 할 때와 똑같이 json 형식으로 만들어 PUT 방식으로 수정요청을 한다.

  • 서버에서 동작이 완료되면 변경완료 됐다는 메시지를 띄우고 페이지를 새로고침 한다.



삭제 요청 코드(DELETE)

function deleteOne(id) {
            // 1. DELETE /api/memos/{id} 에 요청해서 메모를 삭제합니다.
            $.ajax({
                type: "DELETE",
                url: `/api/memos/${id}`,
                success: function (response) {
                    alert("삭제 완료");
                    window.location.reload();
                }
            })
        }
  • 제일 간단하다. 전달받은 id삭제요청만 하면 된다.

  • 삭제가 성공적으로 되면 삭제완료 메시지를 띄우고 새로고침 한다.



🎈 마치며

여기까지 맨 처음에 보았던 파란색 서비스 화면이 동작하는 기능들을 간단하게 작성해 보았다.

정리하면서 중간중간 들어갔던 이론들을 조금이나마 이해할 수 있게 된것같다.
DTO를 왜 써야하는지.. 뭔지.. 이런것들..
ORM, JPA 이런 것들도 말이다.

제대로 된 포스팅인지도 모르겠지만 정리하고자 한것들은 다 작성한 것 같다.
작성 팁을 좀 더 알아봐야겠다. 아직 부족하다..
앞으로 계속 이런 개발 포스팅이 많아질 것이다.
쓸게 많아.. 강의듣는거, 혼자 만들어보는거, 공모전 통해서 만드는거 등등
github도 잘 정리해가지고 나중에 블로그에 올려보고싶다.
아무튼 오늘은 여기서 마치도록 한다!!

만약 보시는 분이 계시고 틀린 점이 있다면 댓글에 피드백 해주시면 감사하겠습니다.

profile
성장 개발일지

1개의 댓글

안녕하세요 덕분에 스프링 관련 글 잘 보고 공부중입니다!
혹시 깃허브에 파일 올려놓으셨으면 프로젝트좀 보고싶은데 링크 좀 알려주실수있을까요?

답글 달기