CRUD 실습

스프링부트 RESTful API 아키텍처 스타일로 게시판 구현 [탐구/기록]

rexondex 2024. 9. 30. 19:00

 

REST란 무엇인가?

 

REST 아키텍처 스타일을 따르는 웹 API(또는 웹 서비스)를 REST API (또는 RESTful API) 라고 합니다.

https://restfulapi.net/

 


 

REST는 REpresentaitional State Transfer의 약자이며 분산형 하이퍼미디어 시스템을 위한 아키텍처 스타일입니다. 웹 기반 API를 구축하는 데 가장 널리 사용되는 접근 방식 중 하나입니다.

REST는 '프로토콜'이나 '표준'이 아니라 '아키텍처 스타일'입니다. 개발 단계에서 API 개발자는 다양한 방식으로 REST를 구현할 수 있습니다.

다른 아키텍처 스타일과 마찬가지로 REST에도 지침 원칙과 제약이 있습니다. 서비스 인터페이스를 RESTful 이라고 부르려면 이러한 원칙을 충족해야 합니다.

 


 

:: RESTful API 아키텍처 스타일로 Spring 게시판 구현하기 ::

REST 게시판 어플리케이션 계획 구조도

구현할 REST 게시판 어플리케이션의 구조도를 그려보았습니다.

인풋 데이터 또는 클라이언트의 요청이 OAuth 2.0 인증을 거친 후 어디까지 도달하는지를 표현했습니다.​​

 


 

 

디렉터리 구조

 

구조도와 같은 모양으로 클래스와 클래스가 들어갈 패키지를 작성했습니다.

 

그리고 멤버 REST 컨트롤러 클래스를 작성했습니다.​​

@RestController
@RequestMapping("/api/members")
public class MemberController {
    @Autowired
    private MemberService memberService;

    @GetMapping
    public ResponseEntity<List<Member>> getAllMembers() {
        List<Member> members = memberService.getAllMembers();
        return ResponseEntity.ok(members);
    }

    @GetMapping("/me")
    public ResponseEntity<Member> getCurrentUser(@AuthenticationPrincipal CustomOAuth2User customOAuth2User) {
        if (customOAuth2User == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        Member member = customOAuth2User.getMember();
        return ResponseEntity.ok(member);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Member> getMemberById(@PathVariable Long id) {
        Member member = memberService.getMemberById(id);
        return ResponseEntity.ok(member);
    }

    @PostMapping
    public ResponseEntity<Member> createMember(@RequestBody Member member) {
        Member createdMember = memberService.createMember(member);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdMember);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Member> updateMember(@PathVariable Long id, @RequestBody Member memberDetails) {
        Member updatedMember = memberService.updateMember(id, memberDetails);
        return ResponseEntity.ok(updatedMember);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMember(@PathVariable Long id) {
        memberService.deleteMember(id);
        return ResponseEntity.noContent().build();
    }
}
 

 

 

다음은 게시물 REST 컨트롤러입니다.​​

@RestController
@RequestMapping("/api/posts")
public class PostController {
    @Autowired
    private PostService postService;

    @GetMapping
    public ResponseEntity<List<Post>> getAllPosts() {
        List<Post> posts = postService.getAllPosts();
        return ResponseEntity.ok(posts);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Post> getPostById(@PathVariable Long id) {
        Post post = postService.getPostById(id);
        return ResponseEntity.ok(post);
    }

    @PostMapping
    public ResponseEntity<Post> createPost(@RequestBody Post post) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        Member member = customOAuth2User.getMember();

        post.setMember(member);
        Post createdPost = postService.createPost(post);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdPost);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Post> updatePost(@PathVariable Long id, @RequestBody Post postDetails) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        Member member = customOAuth2User.getMember();

        Post post = postService.getPostById(id);
        if (!post.getMember().getId().equals(member.getId())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }

        Post updatedPost = postService.updatePost(id, postDetails);
        return ResponseEntity.ok(updatedPost);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deletePost(@PathVariable Long id) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        Member member = customOAuth2User.getMember();

        Post post = postService.getPostById(id);
        if (!post.getMember().getId().equals(member.getId())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }

        postService.deletePost(id);
        return ResponseEntity.noContent().build();
    }
}

마지막으로 html을 호출하는 엔드포인트를 설정하는 일반 컨트롤러입니다.

 

@Controller
public class MainController {
    @GetMapping("/read-only")
    public String readonly() {
        return "read-only";
    }

    @GetMapping("/main")
    public String main() {
        return "crud-board";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }
}

REST컨트롤러는 정의된 엔드포인트로 요청이 들어오면, 서비스 레이어를 타고 JPA를 상속하는 리포지토리까지 내려와 DB작업을 수행합니다.

일반 컨트롤러는 엔드포인트로 요청이 들어오면 각 요청에 해당하는 html 문서를 반환합니다.​​


Post, Member 두 엔터티는 DB의 테이블 구조와 일치시켰습니다​​

Post Entity

 

Member Entity


이제 어플리케이션을 실행한 후 " localhost:8090/main " 으로 접속을 시도해보겠습니다.

 

login.html

 

"/main" 을 호출했음에도 먼저 "/login"이 호출되었습니다

 

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize -> authorize
                        // 접근 허용 URI
                        .requestMatchers("/", "/login**", "/read-only").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .loginPage("/login") // 로그인 화면 요청
                                .defaultSuccessUrl("/main") // 메인 화면 요청
                                .userInfoEndpoint(userInfoEndpoint ->
                                        userInfoEndpoint.userService(customOAuth2UserService)
                                ) // OAuth2 유저 정보 가공
                );
        return http.build();
    }

" .requestMatchers("/", "/login**", "/read-only").permitAll() " 로 접근이 허용된 URI를 제외하고

필수적으로 로그인하게 설정되어 있어서 '/main' 은 허용된 URI가 아니므로 '/login' 으로 강제 이동했습니다.

로그인 이후에는 기본적으로 " /main " 주소로 리다이렉트 하며

로그인 전에 유저가 먼저 호출한 자원이 있다면 그 URI를 호출합니다.​​


[ 회원가입 및 로그인 ] 버튼을 눌러 로그인할수 있습니다

OAuth는 '구글 API 콘솔'을 이용해 Key를 설정하여 사용자가 구글 로그인이 가능하도록 했습니다.

구글 로그인 이후에는 '/main' 으로 접근할 수 있습니다.​​

로그인 이후의 " /main " 엔드포인트는 ' crud-board.html ' 를 반환합니다

 

[ 다시 로그인하기 ] 를 클릭하면, 새로운 계정 및 회원가입 과정을 다시 수행할 수 있습니다.

다른 계정으로 전환도 가능합니다.

[ 로그인 정보 보기 ] 를 클릭하면, 현재 로그인한 유저의 정보를 확인할 수 있습니다.​​

' localhost:8090/api/members/me ' 는 REST 컨트롤러의 엔드포인트입니다

 

@RestController
@RequestMapping("/api/members")
public class MemberController {
    @Autowired
    private MemberService memberService;

    @GetMapping("/me")
    public ResponseEntity<Member> getCurrentUser(@AuthenticationPrincipal CustomOAuth2User customOAuth2User) {
        if (customOAuth2User == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        Member member = customOAuth2User.getMember();
        return ResponseEntity.ok(member);
    }
}

' /api/members/me ' 가 호출되면 현재 OAuth 2.0으로 로그인한 유저정보를 가져와 Member 타입( Entity )의 member 객체에 집어넣은 후 응답으로 반환합니다. 스프링에서 이러한 방식으로 엔터티 객체를 웹으로 반환할 때,

내부 로직을 통해 객체의 필드와 정보가 JSON 문자열 형태로 반환되는 것을 확인할 수 있었습니다.

HTTP 'GET' 메소드는 브라우저의 주소창에 엔드포인트 URI를 써서 호출하는 것이 가능합니다.

그래서 서버가 유저정보를 JSON 문자열로 반환한 것을 브라우저에서 확인할 수 있었습니다.​


그리고 로그인한 유저의 정보로 "글쓰기"가 가능하며 자신의 게시물만 수정 및 삭제 가능합니다.

[ Create Post ] - 글 쓰기 ( 현재 유저의 ID를 함께 저장 )

 

[ Edit ] - 제목, 내용 변경 ( 자신의 게시물일때만 가능합니다 )

 

 

[ Delete ] - 게시물 삭제 ( 자신의 게시물일때만 가능합니다 )


다음은 클라이언트 측에서 게시물을 불러오거나 새로고침하는 자바스크립트 비동기 함수입니다.

 

async function fetchPosts() { // 게시물을 불러오는 비동기(async) 함수 선언 
        try {
            // 서버의 엔드포인트로 비동기 요청
            const response = await fetch('http://localhost:8090/api/posts');
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }
            // 반환된 response 정보를 파싱하여 자바스크립트 객체로 변환
            const posts = await response.json();
            const tableBody = document.getElementById('posts-table-body');
            tableBody.innerHTML = ''; // 테이블 비우기
            // 반환받은 posts는 여러개이므로 .forEach()로 분해하여 각 게시물 생성
            posts.forEach(post => {
                const row = document.createElement('tr');
                row.innerHTML = `
                    <td>${post.id}</td>
                    <td>${post.title}</td>
                    <td>${post.content}</td>
                    <td>
                        <button onclick="deletePost(${post.id})">Delete</button>
                        <button onclick="editPost(${post.id})">Edit</button>
                    </td>
                `;
                // 반환받은 데이터로 만든 행(row)을 tableBody에 자식요소로 추가
                tableBody.appendChild(row);
            });
        } catch (error) {
            console.error('There was a problem with the fetch operation:', error);
        }
    }

 

 

jQuery 라이브러리는 사용하지 않았지만, AJAX를 자바스크립트 비동기 함수로 구현했습니다.

비동기 요청을 통해 엔드포인트에 자원을 요청하고 '.json()' 비동기 함수를 통해 json 형식의 응답데이터를 자바스크립트 객체로 변환합니다.

'.getElementById()'와 같은 함수를 통해 DOM요소를 지정하여 동적으로 조작할 수 있었습니다.​


 

이렇게 REST 컨트롤러를 작성하여

기본적인 CRUD가 가능한 게시판을 만들어 보았습니다.

REST 아키텍처를 지켜 작성한 결과

브라우저에서 AJAX를 활용하여 서버에 자원을 요청하고

반환받은 자원을 렌더링(CSR)하여 표현할 수 있었습니다.

서버와 클라이언트의 역할을 명확히 구분지을 수 있어서 편리하다고 느꼈습니다.

서버에 많은 클래스들과 복잡한 패키지를 구성하지 않고

요청한 자원만 반환하면 클라이언트 쪽에서 데이터를 가공 및 렌더링하여 구현하는 방식이

협업을 하는 경우에도 접근이 훨씬 쉬울 것 같았습니다.

마치 메뉴판처럼 서버의 컨트롤러에 있는

엔드포인트 주소가 메뉴라고 생각하고 고객 측이 "이 메뉴를 제공해줘" 라고 하면

서버가 주문받은 메뉴를 제공한다는 비유가 어울리는 것 같습니다.

서버는 요청받은 메뉴를 제공하는 데에 집중하면 되고,​

클라이언트는 제공받은 메뉴를 예를 들어

1)젓가락으로 먹을지, 2)포크로 먹을 지 자유로이 선택할 수 있을 것입니다.

응답받은 데이터를 표현하는 방법은 다양하기 때문입니다. (클라이언트 사이드 렌더링)

그리고 제 3자가 어플리케이션 구조를 들여다봤을때도 코드가 눈에 쉽게 들어올 것 같아서

이러한 부분에서는 큰 이득이 있겠구나 생각했습니다.​