게시판 Lv1 - 작성, 전체/선택 조회, 수정/삭제(비밀번호 인증)

박영준·2023년 6월 26일
0

Spring

목록 보기
21/58

Spring Lv1 과제

1. 요구 사항

1) 기본 요구 사항

  1. Entity를 그대로 반환하지 말고, DTO에 담아서 반환

  2. JSON을 반환하는 API형태

  3. PostMan 으로 서버가 반환하는 결과값을 쉽게 확인하기

  4. Use Case 그려보기

2) 기능 요구 사항

  1. 전체 게시글 목록 조회 API

    • 제목, 작성자명, 작성 내용, 작성 날짜를 조회하기
    • 작성 날짜 기준 내림차순으로 정렬하기
  2. 게시글 작성 API

    • 제목, 작성자명, 비밀번호, 작성 내용을 저장하고
    • 저장된 게시글을 Client 로 반환하기
  3. 선택한 게시글 조회 API

    • 선택한 게시글의 제목, 작성자명, 작성 날짜, 작성 내용을 조회하기
      (검색 기능이 아닙니다. 간단한 게시글 조회만 구현해주세요.)
  4. 선택한 게시글 수정 API

    • 수정을 요청할 때 수정할 데이터와 비밀번호를 같이 보내서 서버에서 비밀번호 일치 여부를 확인 한 후
    • 제목, 작성자명, 작성 내용을 수정하고 수정된 게시글을 Client 로 반환하기
  5. 선택한 게시글 삭제 API

    • 삭제를 요청할 때 비밀번호를 같이 보내서 서버에서 비밀번호 일치 여부를 확인 한 후
    • 선택한 게시글을 삭제하고 Client 로 성공했다는 표시 반환하기

2. 유스케이스 다이어그램

실선 화살표

  • 사용자와 유스케이스 간의 상호작용이 있음을 표현했다.
  • 게시글 전체 조회와 선택 조회는 동일한 '조회' 기능이지만, 선택 조회가 전체 조회에 종속적이라고 볼 수는 없다.?
    • 전체 조회를 실행해야만 선택 조회를 실행할 수 있는 것이 아니다.?
    • 따라서, 이 둘은 확장관계가 아니다.?

<<포함>> 점선 화살표

  • 게시글 수정, 게시글 삭제 유스케이스는 비밀번호 인증 유스케이스가 반드시 실행 되어야만 사용가능한 기능이기 때문이다.

3. API 명세서

4. 프로젝트 환경 세팅

(1) 프로젝트 생성 및 Dependencies 설정

(2) application.properties

spring.datasource.url=jdbc:mysql://localhost:3306/memo
spring.datasource.username=root
spring.datasource.password=비밀번호
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update

// 가독성 위해
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

// 추가
spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQLDialect

첫 네줄

  • mysql과 연동을 위해 작성
  • 주소, 이름, 비밀번호 정보를 입력한다

spring.jpa.hibernate.ddl-auto=update

  • JPA에서는 Entity에 테이블을 매핑하면, 쿼리를 사용하지 않고 값을 가져올 수 있다.
  • 옵션
    • create, create-drop, update, validate, none

세 줄

  • sql을 가독성 좋게 보이도록 만들어준다.

spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQLDialect

(3) build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.1'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.SpringProject'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

...

dependencies {
    // JPA 설정
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // MySQL
    implementation 'mysql:mysql-connector-java:8.0.33'

    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // 추가
    implementation group: 'org.javassist', name: 'javassist', version: '3.15.0-GA'
}

...

5. 구현

1) Controller

(1)

@RestController
@RequestMapping("/api")
public class BoardController {
    private final BoardService boardService;		// 2. 넣어주기만 하면 된다

    public BoardController(BoardService boardService) {		// 1. 외부에서 이미 만들어두었던 boardService 를 파라미터로 받아서
        this.boardService = boardService;
    }
    
    ...
}    

@RestController

  • Spring 3 Layer Annotation 中 하나 (Controller, Service, Repository)

    • 각 계층 클래스를 Bean 으로 등록할 때 사용

    • 해당 어노테이션 안에는 이미 @Component 가 추가돼있다

  • 참고: @Controller, @RestController

@RequestMapping("/api")

  • 아래에서 구현된 각 API 의 URL 에 공통적으로 들어가는 부분

  • 동일한 URL 부분의 반복을 줄여 줄 수 있다

BoardService 객체 호출 한 부분

  • boardService 객체를 호출한다 (인스턴스화)

  • 단, bean 객체만 주입받을 수 있는데, Service를 빈 객체로 등록했기 때문에 가능하다

  • 사용 목적

    1. 약한 결합을 위해

    2. 아래에 각 기능들을 구현할 때,

      • 원래는 memoService 객체를 일일이 호출했어야 하지만
      • 이 부분 덕에, 일일이 호출하지 않고 여기서 한 번 호출해주기만 하면 된다

(2)

// 게시글 작성
@PostMapping("/board")
public BoardResponseDto createBoard(@RequestBody BoardRequestDto requestDto) {
    return boardService.createBoard(requestDto);
}

@RequestBody

BoardRequestDto requestDto

  • requestDto 매개변수에 데이터를 담아서, boardService의 createBoard메서드로 실어 보낸다
  • requestDto에 담긴 결과값을 BoardResponseDto에 담아서, Client 에게 응답을 보낸다
  • BoardResponseDto createBoard 에서 createBoard 는 함수명인데, boardService의 createBoard메서드명과 이름을 맞춰서 사용하면 구별이 쉬워지므로 이렇게 표현

(3)

// 게시글 전체 조회
@GetMapping("/board")
public List<BoardResponseDto> getBoardList() {
    return boardService.getBoardList();
}
  • List 형태로 BoardResponseDto 에 담아서, Client 에게 응답을 보낸다
  • boardService의 getBoardList()메서드로 결과값을 호출하고, 이를 Client 에게 반환한다

(4)

// 게시글 선택 조회
@GetMapping("/board/{id}")
public BoardResponseDto getBoard(@PathVariable Long id) {
    return boardService.getBoard(id);
}

@PathVariable

(5)

// 게시글 수정
@PutMapping("/board/{id}")
public BoardResponseDto updateBoard(@PathVariable Long id, @RequestBody BoardRequestDto requestDto) {
    return boardService.updateBoard(id, requestDto);
}
  • 게시글 수정을 위해서는 id와 BoardRequestDto의 필드값(title, username, password, contents)이 필요하다

(6)

// 게시글 삭제
@DeleteMapping("/board/{id}") 		// password 를 주소창에 노출시키지 않고, body 로 받았다
public BoardResponseDto deleteBoard(@PathVariable Long id, @RequestBody BoardRequestDto requestDto) {
    return boardService.deleteBoard(id, requestDto);
}
  • password 만 따로 받지 않고, BoardRequestDto 를 매개변수로 받았다.

2) Dto

requestDto

@Getter
public class BoardRequestDto {
        private String title;           // 제목
        private String username;        // 작성자명
        private String password;        // 비밀번호
        private String contents;        // 작성 내용
}
  • 사용자가 요청한 데이터(입력한 값)

responseDto

@Getter
public class BoardResponseDto {
    private Long id;        // 게시글 구분을 위한 id 값
    private String title;       // 제목
    private String username;    // 작성자명
    private String contents;    // 작성 내용
    private LocalDateTime createdAt;        // 게시글 생성 날짜
    private LocalDateTime modifiedAt;       // 게시글 수정 날짜

    private String msg;     // 게시글 삭제 시, 삭제 성공 메시지

    public BoardResponseDto(Board board) {
        this.id = board.getId();
        this.title = board.getTitle();
        this.username = board.getUsername();
        this.contents = board.getContents();
        this.createdAt = board.getCreatedAt();
        this.modifiedAt = board.getModifiedAt();
    }

    // 게시글 삭제 시, 삭제 성공 메시지
    public BoardResponseDto(String msg) {
        this.msg = msg;
    }
}
  • Board라는 Entity에 저장된 값들을 호출해서, getxxx()메서드를 이용해 BoardResponseDto의 필드에 담는다.

  • 삭제 성공 메시지의 경우
    DB에서 불러오지 않고, Service 단에서 BoardResponseDto 객체를 생성하면서 직접 입력할 것이므로 이렇게 작성했다.

3) Service

(1)

@Service
public class BoardService {
    private final BoardRepository boardRepository;

    public BoardService(BoardRepository boardRepository) {
        this.boardRepository = boardRepository;
    }
    
    ...
}    

@Service
(Controller 에서 Spring 3 Layer Annotation 中 하나 (Controller, Service, Repository) 에 대한 설명과 동일)

BoardRepository 객체 호출 한 부분
(Controller 에서 BoardService 객체 호출 한 부분에 대한 설명과 동일)

(2)

// 게시글 작성
public BoardResponseDto createBoard(BoardRequestDto requestDto) {
    Board board = new Board(requestDto);        // RequestDto -> Entity
    Board saveBoard = boardRepository.save(board);      // DB 저장
    BoardResponseDto boardResponseDto = new BoardResponseDto(saveBoard);        // Entity -> ResponseDto

    return boardResponseDto;
}
  • RequestDto -> Entity : requestDto 열쇠를 담아서, Board 객체로 변환

  • DB 저장

    • 변환된 결과를 담은 board를 담아 boardRepository의 save 메서드를 호출해서 DB에 저장하고,
    • 저장한 그 결과값을 saveBoard변수에 담는다
  • Entity -> ResponseDto : saveBoard변수에 담긴 결과값을 담아서, BoardResponseDto 객체로 변환

  • return boardResponseDto : 변환 후, 그 결과값을 반환

  • 아래 두 줄을 다음처럼 간략히 표현할 수도 있다.

    // 수정 전
        BoardResponseDto boardResponseDto = new BoardResponseDto(saveBoard);        // Entity -> ResponseDto
    
        return boardResponseDto;
    
    // 수정 후    
    return new BoardResponseDto(saveBoard);

(3)

// 게시글 전체 조회
public List<BoardResponseDto> getBoardList() {
    return boardRepository.findAllByOrderByModifiedAtDesc().stream()        // DB 에서 조회한 List -> stream 으로 변환
            .map(BoardResponseDto::new)     // stream 처리를 통해, Board 객체 -> BoardResponseDto 로 변환
            .toList();      				// 다시 stream -> List 로 변환
}
  • boardRepository.findAllByOrderByModifiedAtDesc() : boardRepository의 findAllByOrderByModifiedAtDesc()메서드를 호출
  • stream().map(BoardResponseDto::new).toList()
    • BoardResponseDto 객체를 List 형태로 호출

(4)

// 게시글 선택 조회
public BoardResponseDto getBoard(Long id) {
    // 해당 id 가 없을 경우
    Board board = boardRepository.findById(id).orElseThrow(
            () -> new IllegalArgumentException("아이디가 존재하지 않습니다.")
    );

    // 해당 id 가 있을 경우
    return new BoardResponseDto(board);
}
  • boardRepository의 findById메서드로 매개변수로 넣은 해당 id값을 찾
    • 없을 경우, orElseThrow 를 통해 예외를 던져준다
    • 있을 경우, 해당 id값으로 찾은 결과를 board에 담아, 그것을 BoardResponseDto객체에 담아서 반환

(5)

// 게시글 수정
@Transactional
public BoardResponseDto updateBoard(Long id, BoardRequestDto requestDto) {
    Board board = findBoard(id);        // DB에 해당 id 의 게시글이 존재하는지 확인

    // 비밀번호 일치 여부 확인
    if (board.getPassword().equals(requestDto.getPassword())) {
        board.update(requestDto);       // 일치하면 게시글 수정
    } else {
        return new BoardResponseDto("비밀번호가 일치하지 않습니다.");
    }

    return new BoardResponseDto(board);
}

...

// id 일치 여부 확인 (공용 사용되는 부분)
private Board findBoard(Long id) {
    return boardRepository.findById(id).orElseThrow(() ->
            new IllegalArgumentException("선택한 게시글은 존재하지 않습니다.")
    );
}

@Transactional

  • 트랜잭션 환경으로 만들어야 영속성 컨텍스트가 유지되고, 변경 감지가 가능해진다
  • 없을 경우, 수정이 불가능해진다

findBoard(id)

  • 게시글 수정, 삭제에는 비밀번호 인증이 공통적으로 들어간다.
    • 따라서, 두 기능마다 따로 구현하지 않고, 공통적으로 findBoard()메서드를 호출하도록 한다.

board.getPassword().equals(requestDto.getPassword())

  • board 엔티티에 저장된 password 와 사용자가 requestDto 로 입력한 password를 equals()메서드를 이용해서 일치 여부를 비교한다
  • 일치할 경우, board에 접근하여 requestDto 대로 update한다.

(6)

// 게시글 삭제
public BoardResponseDto deleteBoard(Long id, BoardRequestDto requestDto) {
    Board board = findBoard(id);

    // 비밀번호 일치 여부 확인
    if (board.getPassword().equals(requestDto.getPassword())) {
        boardRepository.delete(board);
    } else {
        return new BoardResponseDto("비밀번호가 일치하지 않습니다.");
    }

    return new BoardResponseDto("게시글을 삭제했습니다.");        // 게시글 삭제 시, 삭제 성공 메시지
}

// id 일치 여부 확인 (공용 사용되는 부분)
private Board findBoard(Long id) {
    return boardRepository.findById(id).orElseThrow(() ->
            new IllegalArgumentException("선택한 게시글은 존재하지 않습니다.")
    );
}

findBoard(id)
(게시글 수정에서와 동일)

board.getPassword().equals(requestDto.getPassword())

  • controller 에서와 같이, password 를 받지 않고 BoardRequestDto 를 받아온다
    • 따라서, 비밀번호 일치 여부를 위해 BoardRequestDto 안에 있는 password 정보를 조회해서 비교한다
    • BoardRequestDto 로 이 안의 모든 데이터를 받아왔지만, 그 중 password 만 비교에 사용하는 것도 가능하다.
    • 일치하는 비밀번호만 입력해도 삭제가 가능하다. (Postman 테스트 中)
  • 일치할 경우, boardRepository 에 접근하여 해당 board를 삭제한다.

4) Repository

public interface BoardRepository extends JpaRepository<Board, Long>  {
    List<Board> findAllByOrderByModifiedAtDesc();
}

JpaRepository<Board, Long>

  • JpaRepository<@Entity 클래스, @id의 데이터 타입>

findAllByOrderByModifiedAtDesc()

  • Query Methdo 기능
    • 메서드 이름으로 SQL을 생성할 수 있다.
    • 해당 interface와 매핑된 table에 요청할 SQL을 메서드 이름으로 선언

5) Entity

@Entity
@Getter
@Table(name = "board")
@NoArgsConstructor
public class Board extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "username", nullable = false)
    private String username;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "contents", nullable = false, length = 500)
    private String contents;

    // 게시글 작성
    public Board(BoardRequestDto requestDto) {
        this.title = requestDto.getTitle();
        this.username = requestDto.getUsername();
        this.password = requestDto.getPassword();
        this.contents = requestDto.getContents();
    }

    // 게시글 수정
    public void update(BoardRequestDto requestDto) {
        this.title = requestDto.getTitle();
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }
}

@Entity : JPA 가 관리할 수 있는 Entity 클래스로 지정

@Table(name = "board") : 매핑할 테이블 이름을 지정

Board extends Timestamped : Timestamped 클래스를 상속받는다

@NoArgsConstructor : 참고: 생성자 - 7. 어노테이션

@Setter 를 사용하지 않았다.

this.password = requestDto.getPassword() 를 삭제했다.

  • BoardService 에서 비밀번호 일치여부를 확인한 후, password 는 수정하지 않도록 한다.
  • 게시글을 수정할 비밀번호가 필요한 거지, 비밀번호를 변경하는 게 아니기 때문

6) Timestamped

(1) Timestamped

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {

    @CreatedDate
    @Column(updatable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime modifiedAt;
}

@MappedSuperclass

  • JPA Entity 클래스들이 해당 추상 클래스를 상속할 경우, 추상 클래스에 선언한 멤버 변수를 Column 으로 인식할 수 있다
    • 즉, createdAt, modifiedAt 를 Board 객체의 Column으로 인식하게 하기 위함이다.

@EntityListeners(AuditingEntityListener.class) : 해당 클래스에 Auditing 기능(자동으로 시간 넣기) 추가

@CreatedDate : Entity 객체 생성 후 저장될 때, 시간이 자동 저장됨

@LastModifiedDate : 조회한 Entity 객체 값 변경 시, 변경된 시간이 자동 저장됨

@Column(updatable = false)

  • updatable = false 속성일 경우
    • 최초 생성 시간만 저장된다
    • 수정해도, 최초 생성 시간은 변하지 않게 된다

@Temporal(TemporalType.TIMESTAMP) : 날짜 데이터와 매핑 시에 사용

  • Temporal 의 시간 타입 3가지 종류
    • DATE : 2023-01-01
    • TIME : 20:21:14
    • TIMESTAMP : 2023-01-01 20:21:14.99993939

(2) SpringProjectApplication

@EnableJpaAuditing
@SpringBootApplication
public class SpringProjectApplication {
    ...
}

@EnableJpaAuditing

  • Timestamped 클래스에서 Auditing 기능을 사용하기 위해서는 SpringProjectApplication에 해당 어노테이션을 입력해야 한다
  • Auditing 기능을 사용하겠다고 Spring Boot 에게 알려주기 위함
profile
개발자로 거듭나기!

0개의 댓글