Spring Boot & REST

2024. 1. 18. 20:21
728x90

 

1. REST (REpresentational State Transfer)

: HTTP를 이용한 서버를 구현할 때 지켜야 하는 설계 원칙.

 

표현을 이용해서 상태를 전달한다.

 

- 서버와 클라이언트 사이의 결합성 감소에 목표를 둔다. -> 성능 향상, 확장성 확보, 사용 편의성 증대.

 

 

6원칙

1) Client - Server Architecture

: 클라이언트와 서버의 역할 분리

- 양측의 독립적 발전 추구. 

- 서버는 데이터가 어떤 방식으로 표현되는지 몰라도 됨 & 클라이언트는 데이터가 어떻게 저장되는지 몰라도 됨.

 

2) Statelessness (상태를 저장하지 않는다.)

: 클라이언트가 보내는 개별적인 요청은 각각의 요청을 표현하기 충분해야 함. "독립적인 요청"

- 즉, 서버/클라이언트가 몇번째 보낸 요청인지, 이전에 보낸 요청에서 데이터가 어떤지? 기억할 필요가 없어야 함.

아래와 같은 요청/응답은 REST를 지키지 않았다.

 

3) Cacheability

: 서버에서 보내주는 응답은 캐시 가능성(캐싱을 할 수 있는지!?)에 대해 표현해 주어야 한다.

(잘 바뀌지 않는 정적 자원들...을 클라이언트 쪽에 저장해두고 필요할 때 써먹는 것... > 캐싱한다...)

- Cache-Control Header

 

4) Layered System

: 클라이언트가 서버에 요청이 도달하기까지의 과정을 알 필요 없고, 영향 받지 않아야 한다.

 

 

5) Code On Demand (필수는 아님. Optional)

: 실행 가능한 코드를 전달함으로써 일시적으로 클라이언트의 기능을 확장

- JavaScript와 브라우저.

 

6) Uniform Interface

: "일관된 인터페이스"를 가지고 있어야 한다.

- REST에서 가장 근본적인 제약사항.

- 서버에 요청하고 있는 자원이 "요청 자체"로 식별이 되어야 한다. 클라이언트가 요구하는 자원이 무엇인지 명확히 표현.

GET /students/{studentId}
// 학생들 중 ID가 studentId인 자원에 대한 요청

- 자원에 대하여 조작할 때 그 자원의 상태나 표현으로 조작이 가능해야 한다. (JSON과 같은 형태로 데이터 표현.)

- 각 요청과 응답은 자기 자신을 해석하기 위한 충분한 정보를 포함해야 한다. (Content-Type 헤더로 명시)

- HAETOAS (Hypermedia As The Engine Of Application State) : 최초의 URI에 접근했을 때 이후 서버가 제공하는 모든 정보에 도달할 수 있는 방법을 알 수 있어야 한다.

 

 

** 실제로 모든 원칙을 다 갖춘다고 하기는 어렵다... 그러나 RESTful한 모습을 목표로 API를 제작해야 한다.

https://en.wikipedia.org/wiki/Richardson_Maturity_Model

 

Richardson Maturity Model - Wikipedia

From Wikipedia, the free encyclopedia The Richardson Maturity Model (RMM) is a maturity model suggested in 2008 by Leonard Richardson which classifies Web APIs based on their adherence and conformity to each of the model's four levels. The aim of the resea

en.wikipedia.org

 

 

2. Spring Boot로 RESTful 구현

* 우선 지켜볼 원칙 > Richardson Maturity Model Level 2 

- URL을 구성할 때 URL이 자원을 논리적이게 잘 표현할 수 있도록

- 자원에 적용할 작업을 HTTP Method로 구분할 수 있도록

 

 

ex) Article Entity & ArticleDto

// Article Entity 
@Entity
@NoArgsConstructor
@Getter
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    private String title;
    @Setter
    private String content;
    @Setter
    private String writer;
}

// ArticleDto
@Getter
@ToString
@NoArgsConstructor
public class ArticleDto {
    private Long id;
    private String title;
    private String content;
    private String writer;

    public ArticleDto fromEntity(Article article) {
        ArticleDto dto = new ArticleDto();
        dto.id = article.getId();
        dto.title = article.getTitle();
        dto.content = article.getContent();
        dto.writer = article.getWriter();
        return dto;
    }
}

 

1) 작업을 위한 URL이 자원을 식별할 수 있도록 구성해보자.

  • 전체 게시글들에 대해 작업하기 위한 URL : /articles
  • 어떤 특정 게시글에 대해 작업하기 위한 URL : /articles/{articleId}

 

2) 자원에 어떤 행동을 할지를 HTTP Method로 구분해보자.

  • CREATE : 새로운 자원 생성. => POST Method
  • READ : 이미 존재하는 자원 조회 => GET Method
  • UPDATE : 이미 존재하는 자원 수정 => PUT Method (REST의 상태 표현의 관점에서 PUT은 객체의 새로운 상태를 놓겠다!라는 개념.... PATCH는 이미 있는 존재를 조금만 수정한다...의 의미이므로 PUT 메서드가 조금 더 REST의 목적에 맞다.)
  • DELETE : 삭제하기 위한 자원 => DELETE Method 

 

==> 종합해보면

게시글 생성 POST /articles
게시글 전체 조회 GET /articles
게시글 단일 조회 GET /articles/{articleId}
게시글 수정 PUT /articles/{articleId}
게시글 삭제 DELETE /articles/{articleId}

 

코드 구현

더보기

- Controller

// ArticleController class

@Slf4j
@RestController
@RequestMapping("/articles")
@RequiredArgsConstructor
public class ArticleController {
    private final ArticleService service;

    // 게시글 생성
    @PostMapping
    public ArticleDto create(@RequestBody ArticleDto dto) {
        return service.create(dto);
    }

    // 게시글 전체 조회
    @GetMapping
    public List<ArticleDto> readAll() {
        return service.readAll();
    }

    // 게시글 단일 조회
    @GetMapping("/{articleId}")
    public ArticleDto read(@PathVariable Long articleId) {
        return service.read(articleId);
    }

    // 게시글 수정
    @PutMapping("/{articleId}")
    @Transactional
    public ArticleDto update(@PathVariable Long articleId,
                             @RequestBody ArticleDto dto) {
        return service.update(articleId, dto);
    }

    // 게시글 삭제
    @DeleteMapping("/{articleId}")
    @Transactional
    public void delete(@PathVariable Long articleId) {
        service.delete(articleId);
    }
}

 

- Service

// ArticleService class

@Service
@RequiredArgsConstructor
@Slf4j
public class ArticleService {
    private final ArticleRepository repository;

    // 게시글 생성
    public ArticleDto create(ArticleDto dto) {
        Article article = new Article(dto.getTitle(), dto.getContent(), dto.getWriter());
        return ArticleDto.fromEntity(repository.save(article));
    }

    // 게시글 전체 조회
    public List<ArticleDto> readAll() {
        List<ArticleDto> articleList = new ArrayList<>();
        for (Article article : repository.findAll()) {
            articleList.add(ArticleDto.fromEntity(article));
        }
        return articleList;
    }

    // 게시글 단일 조회
    public ArticleDto read(Long id) {
        Optional<Article> optionalArticle = repository.findById(id);
        if (optionalArticle.isPresent()) { // 있으면
            Article article = optionalArticle.get();
            return ArticleDto.fromEntity(article);
        } // 없으면 특정 예외를 발생시킨다.
        throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }

    // 게시글 수정
    public ArticleDto update(Long id, ArticleDto dto) {
        Optional<Article> optionalArticle = repository.findById(id);
        if (optionalArticle.isPresent()) {
            Article article = optionalArticle.get();
            article.setTitle(dto.getTitle());
            article.setContent(dto.getContent());
            article.setWriter(dto.getWriter());
            return ArticleDto.fromEntity(repository.save(article));
        } //없으면 특정 예외 발생
        throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }

    // 게시글 삭제
    public void delete(Long id) {
        if (repository.existsById(id)) {
            repository.deleteById(id);
        } else throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }
}

- ArticleRepository

// ArticleRepository interface
public interface ArticleRepository extends JpaRepository<Article, Long> {
}

 

+ 댓글 기능 구현 : 댓글은 게시글에 종속되므로 URL도 맞춰서 작성한다.

Comment Entity & CommentDto

// Comment Entity

@Entity
@Getter
@NoArgsConstructor
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    private String content;
    @Setter
    private String writer;
    @Setter
    @ManyToOne
    private Article article;

    public Comment(String content, String writer, Article article) {
        this.content = content;
        this.writer = writer;
        this.article = article;
    }
}

// CommentDto 
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class CommentDto {
    private Long id;
    @Setter
    private String content;
    @Setter
    private String writer;

    // static factory method
    public static CommentDto fromEntity(Comment comment) {
        return new CommentDto(
                comment.getId(),
                comment.getContent(),
                comment.getWriter());
    }
}
게시글에 댓글 추가 POST /articles/{articleId}/comments
게시글의 전체 댓글 조회 GET /articles/{articleId}/comments
댓글 수정 PUT /articles/{articleId}/comments/{commentId}
댓글 삭제 DELETE /articles/{articleId}/comments/{commentId}

 

코드 구현

더보기

- Controller

// CommentController class
@Slf4j
@RestController
@RequestMapping("articles/{articleId}/comments")
@RequiredArgsConstructor
public class CommentController {
    private final CommentService service;

    // 게시글에 댓글 추가
    @PostMapping
    public CommentDto createComment(@PathVariable Long articleId,
                                    @RequestBody CommentDto dto) {
        return service.createComment(articleId, dto);
    }

    // 게시글의 전체 댓글 조회
    @GetMapping
    public List<CommentDto> readAll(@PathVariable Long articleId) {
        return service.readAll(articleId);
    }

    // 댓글 수정
    @PutMapping("/{commentId}")
    public CommentDto updateComment(@PathVariable Long articleId,
                                    @PathVariable Long commentId,
                                    @RequestBody CommentDto dto) {
        return service.update(articleId, commentId, dto);
    }

    // 댓글 삭제
    @DeleteMapping("/{commentId}")
    public void deleteComment(@PathVariable Long articleId,
                              @PathVariable Long commentId) {
        service.delete(articleId, commentId);
    }
}

 

- Service

// CommentService class

@Slf4j
@Service
@RequiredArgsConstructor
public class CommentService {
    private final CommentRepository commentRepository;
    private final ArticleRepository articleRepository;

    // 게시글에 댓글 추가
    public CommentDto createComment(Long articleId, CommentDto dto) {
        Optional<Article> optionalArticle = articleRepository.findById(articleId);
        if (optionalArticle.isPresent()) {
            Comment newComment = new Comment(dto.getContent(), dto.getWriter(), optionalArticle.get());
            return CommentDto.fromEntity(commentRepository.save(newComment));
        } else {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
    }

    // 댓글 전체 조회
    public List<CommentDto> readAll(Long articleId) {
        // 게시글 존재 여부에 따른 에러 반환.
        if (!articleRepository.existsById(articleId)) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        List<CommentDto> commentDtoList = new ArrayList<>();
        for (Comment comment : commentRepository.findAllByArticleId(articleId)) {
            commentDtoList.add(CommentDto.fromEntity(comment));
        }
        return commentDtoList;
    }

    // 댓글 수정하기
    public CommentDto update(Long articleId, Long commentId, CommentDto dto) {
        // 수정대상 댓글이 존재하기는 하는지 ?
        Optional<Comment> optionalComment = commentRepository.findById(commentId);
        // 없으면 404
        if (optionalComment.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }

        Comment comment = optionalComment.get();
        // 댓글 - 게시글 불일치
        if (!articleId.equals(comment.getArticle().getId())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }

        comment.setContent(dto.getContent());
        comment.setWriter(dto.getWriter());
        return CommentDto.fromEntity(commentRepository.save(comment));
    }

    // 댓글 삭제하기
    public void delete(Long articleId, Long commentId) {
        Optional<Comment> optionalComment = commentRepository.findById(commentId);
        if (optionalComment.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }

        Comment comment = optionalComment.get();
        if (!articleId.equals(comment.getArticle().getId())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }
        commentRepository.deleteById(commentId);
    }

}

 

- Repository

// CommentRepository Interface

public interface CommentRepository extends JpaRepository<Comment, Long> {
    List<Comment> findAllByArticleId(Long articleId);
}

 

 

728x90

'Programming > Spring, SpringBoot' 카테고리의 다른 글

File Handling (정적파일과 Multipart/form-data)  (0) 2024.01.22
Query Parameter  (0) 2024.01.22
연습문제(로깅)  (0) 2024.01.15
Profiles  (0) 2024.01.15
Logging  (0) 2024.01.15

BELATED ARTICLES

more