Everyday Dev System

myBlog - Version 2.1 본문

내배캠 주요 학습/Spring 숙련

myBlog - Version 2.1

chaeyoung- 2023. 6. 26. 14:52
Version 1.0

2023.06.15 - [내배캠 주요 학습/Spring 입문] - Spring Boot 블로그 서버 구현


github Repo
https://github.com/Chaeyounglim/myblog

<< 주요 추가사항 >>

로그인 및 회원가입을 구현하고,
게시글 작성 및 수정, 삭제 시에 JWT 토큰을 활용하여 작성자 여부를 판단하여 수행합니다. 

 

 

 

1. DB & Entity Diagram 작성

 

DataBase Table

 

Entity Relationship Diagram

 

 

 

 

2. 요구 사항에 따른 API 설계

https://docs.google.com/spreadsheets/d/1JZmNS89s8zFramK_9xyLxhH-iz1DUxvwZ1-F8a9hHng/edit#gid=0

Method URL Request
Header
Request Response Response Header 기능
POST /api/user/signup - {
"username" : "chaeyoung" ,
"password" : "1234"
}
HttpServletResponse response
- 메시지, 상태코드
response.setStatus(401);


{
"msg" : " " ,
"status" : " "
}
  회원가입
POST /api/user/login - HttpServletRequest request

{
"username" : "chaeyoung" ,
"password" : "1234"
}
HttpServletResponse response
- 메시지, 상태코드
response.setStatus(401);


{
"msg" : " " ,
"status" : " "
}
Authentication Token

response.addHeader
(JwtUtil.AUTHORIZATION_HEADER, token)
로그인
GET /api/posts - - {
{
"id" : " " ,
"title" : " ",
"user_name" : " ",
"contents" : " ",
"createAt" : " ",
"modifiedAt" : " "
}.
{
"id" : " " ,
"title" : " ",
"user_name" : " ",
"contents" : " ",
"createAt" : " ",
"modifiedAt" : " "
}
}
  전체
게시글
목록 조회
(작성 날짜 기준
내림차순)
POST /api/posts Authentication Token @AuthenticationPrincipal
UserDetailsImpl userDetails,

@RequestBody @Valid
PostRequestDto requestDto

{
"title" : " ",
"contents" : " "
}
{
"id" : " " ,
"title" : " ",
"user_name" : " ",
"contents" : " ",
"createAt" : " ",
"modifiedAt" : " "
}
  게시글 작성
GET /api/posts/{id} - - {
"title" : " ",
"user_name" : " ",
"contents" : " ",
"createAt" : " ",
}.
  선택한 게시글 조회
PUT /api/posts/{id} Authentication Token @AuthenticationPrincipal
UserDetailsImpl userDetails,

@RequestBody @Valid
PostRequestDto requestDto

{
"title" : " ",
"contents" : " "
}
{
"id" : " " ,
"title" : " ",
"user_name" : " ",
"contents" : " ",
"createAt" : " ",
"modifiedAt" : " "
}
  선택한 게시글 수정
DELETE /api/posts/{id} Authentication Token @AuthenticationPrincipal
UserDetailsImpl userDetails,

@RequestBody @Valid
PostRequestDto requestDto
HttpServletResponse response
- 메시지, 상태코드
response.setStatus(401);

{
"msg" : " " ,
"status" : " "
}
  선택한 게시글 삭제

 

 

요구사항

더보기

<< 추가 요구 사항 >>

  1. 회원 가입 API
    • username, password를 Client에서 전달받기
    • username은 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 한다.
    • password는 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 한다.
    • DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기
  2. 로그인 API
    • username, password를 Client에서 전달받기
    • DB에서 username을 사용하여 저장된 회원의 유무를 확인하고 있다면 password 비교하기
    • 로그인 성공 시, 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급하고, 발급한 토큰을 Header에 추가하고 성공했다는 메시지, 상태코드 와 함께 Client에 반환하기

 

<< 수정된 요구 사항 >>

  1. 전체 게시글 목록 조회 API
    • 제목, 작성자명(username), 작성 내용, 작성 날짜를 조회하기
    • 작성 날짜 기준 내림차순으로 정렬하기
  2. 게시글 작성 API
    • 토큰을 검사하여, 유효한 토큰일 경우에만 게시글 작성 가능
    • 제목, 작성 내용을 저장하고
    • 저장된 게시글을 Client 로 반환하기(username은 로그인 된 사용자)
  3. 선택한 게시글 조회 API
    • 선택한 게시글의 제목, 작성자명(username), 작성 날짜, 작성 내용을 조회하기 (검색 기능이 아닙니다. 간단한 게시글 조회만 구현해주세요.)
  4. 선택한 게시글 수정 API
    • 수정을 요청할 때 수정할 데이터와 비밀번호 같이 보내서 서버에서 비밀번호 일치 여부 확인 한 후
    • 토큰을 검사한 후, 유효한 토큰이면서 해당 사용자가 작성한 게시글만 수정 가능
    • 제목, 작성 내용을 수정하고 수정된 게시글을 Client 로 반환하기
  5. 선택한 게시글 삭제 API
    • 삭제를 요청할 때 비밀번호를 같이 보내서 서버에서 비밀번호 일치 여부 확인 한 후
    • 토큰을 검사한 후, 유효한 토큰이면서 해당 사용자가 작성한 게시글만 삭제 가능
    • 선택한 게시글을 삭제하고 Client 로 성공했다는 메시지, 상태코드 반환하기

 

 

 

3. Entity 작성

 

1. username은  최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 한다.
2. password는  최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 한다.

 

Entity code :

더보기
package com.sparta.myblog.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @OneToMany(mappedBy = "user")
    private List<Post> postList = new ArrayList<>();

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;


    public User(String username, String password, UserRoleEnum role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }
}

 

package com.sparta.myblog.entity;

import com.sparta.myblog.dto.PostRequestDto;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

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

    @Column(nullable = false)
    private String title;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

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

    @OneToMany( mappedBy = "post", cascade = CascadeType.ALL)
    private List<Comment> commentList = new ArrayList<>();

    public Post(PostRequestDto requestDto, User user) {
        this.title = requestDto.getTitle();
        this.contents = requestDto.getContents();
        this.user = user;
    }

    public void addComment(Comment comment) {
        this.commentList.add(comment);
    }

    public void update(PostRequestDto requestDto) {
        this.title = requestDto.getTitle();
        this.contents = requestDto.getContents();
    }

}

 

 

package com.sparta.myblog.entity;

import jakarta.persistence.*;
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 Timestamped {

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

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

 

 

package com.sparta.myblog.entity;


public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

 

 

4. Dto 작성

회원가입시, 
DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기.

로그인시, 
로그인 성공 시, 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급하고,
발급한 토큰을 Header에 추가하고 성공했다는 메시지, 상태코드 와 함께 Client에 반환하기.


클라이언트로 부터 요청받을 때는 username , password 가 필요하며,
클라이언트에게 응답을 보낼 때는 따로 Dto 클래스 타입이 필요하지 않다.

대신, 서버가 클라이언트에 응답을 보낼 때에 Header에 저장되는 HttpServletResponse에
상태 코드와 메시지를 set 하는 절차가 필요하다.

 

Dto Code:

더보기
package com.sparta.myblog.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserRequestDto {
    // login and sing up Request DTO

    @Pattern(regexp = "^[a-z0-9]{4,10}$" , message = "정규식에 맞지 않습니다.")
    @NotBlank
    private String username;


    @Pattern(regexp = "^[a-zA-Z0-9]{8,15}$", message = "정규식에 맞지 않습니다.")
    @NotBlank
    private String password;
}

 

 

 

 

 

 

 

5. 회원가입 기능 구현


<< 회원 가입 API >> 

username, password를 Client에서 전달받기
username은 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 한다.
password는 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 한다.

DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기

 

 

1) Entity 작성

1. username은  최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 한다.
2. password는  최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 한다.

 

Entity code :

더보기
package com.sparta.myblog.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @OneToMany(mappedBy = "user")
    private List<Post> postList = new ArrayList<>();

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;


    public User(String username, String password, UserRoleEnum role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }
}

 

package com.sparta.myblog.entity;

import com.sparta.myblog.dto.PostRequestDto;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

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

    @Column(nullable = false)
    private String title;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

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

    @OneToMany( mappedBy = "post", cascade = CascadeType.ALL)
    private List<Comment> commentList = new ArrayList<>();

    public Post(PostRequestDto requestDto, User user) {
        this.title = requestDto.getTitle();
        this.contents = requestDto.getContents();
        this.user = user;
    }

    public void addComment(Comment comment) {
        this.commentList.add(comment);
    }

    public void update(PostRequestDto requestDto) {
        this.title = requestDto.getTitle();
        this.contents = requestDto.getContents();
    }

}

 

 

package com.sparta.myblog.entity;

import jakarta.persistence.*;
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 Timestamped {

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

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

 

 

package com.sparta.myblog.entity;


public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

 

 

2) Dto 작성

회원가입시, 
DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기.

로그인시, 
로그인 성공 시, 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급하고,
발급한 토큰을 Header에 추가하고 성공했다는 메시지, 상태코드 와 함께 Client에 반환하기.


클라이언트로 부터 요청받을 때는 username , password 가 필요하며,
클라이언트에게 응답을 보낼 때는 따로 Dto 클래스 타입이 필요하지 않다.

대신, 서버가 클라이언트에 응답을 보낼 때에 Header에 저장되는 HttpServletResponse에
상태 코드와 메시지를 set 하는 절차가 필요하다.

 

Dto Code:

더보기
package com.sparta.myblog.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserRequestDto {
    // login and sing up Request DTO

    @Pattern(regexp = "^[a-z0-9]{4,10}$" , message = "정규식에 맞지 않습니다.")
    @NotBlank
    private String username;


    @Pattern(regexp = "^[a-zA-Z0-9]{8,15}$", message = "정규식에 맞지 않습니다.")
    @NotBlank
    private String password;
}

 

 

 

 

3) Controller 클래스 구현

  • 위에 해당 API 명세서를 참고하여 맵핑되는 URL을  적어주었다.
  • UserService 클래스를 멤버변수로 포함하여 생성자 메서드에서 초기화된다.
  • UserRequestDto 클래스에 명시해둔 예외처리를 실행하고 message를 출력하는 부분을 구현하였다.
  • 예외처리가 모두 통과되어 없을 경우에, userService.signUp() 를 실행하고 회원가입을 진행한다.
package com.sparta.myblog.controller;


import com.sparta.myblog.dto.UserRequestDto;
import com.sparta.myblog.service.UserService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.util.List;

@Slf4j
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // 회원가입
    @PostMapping("/user/signup")
    public void signUp(@Valid @RequestBody UserRequestDto userRequestDto, BindingResult bindingResult, HttpServletResponse res) throws IOException {
        // 1. Validation 예외처리
        List<FieldError> fieldErrors = bindingResult.getFieldErrors();
        if (fieldErrors.size() > 0) {
            for (FieldError fieldError : bindingResult.getFieldErrors()) {
                log.error(fieldError.getField() + " 필드 : " + fieldError.getDefaultMessage());
            }
        }else {
            // 2. userService 에서 signup 하고
            // 3. HttpServletResponse 에 (msg, status set 하기)
            userService.signUp(userRequestDto,res);
        }
    }

    @PostMapping("/user/login")
    public void login(@RequestBody UserRequestDto userRequestDto) {

    }
}

 

 

4) Service 클래스 구현

  • Controller 클래스에 의해 호출된 메서드가 실행된다.
  • UserRepository 클래스를 멤버변수로 포함하여 생성자 메서드에서 초기화된다.
  • responseResult() 메서드를 통해 클라이언트에 응답 메시지와 상태 코드를 반환할 수 있다.
package com.sparta.myblog.service;

import com.sparta.myblog.dto.UserRequestDto;
import com.sparta.myblog.entity.User;
import com.sparta.myblog.entity.UserRoleEnum;
import com.sparta.myblog.repository.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    // 회원가입 메서드
    public void signUp(UserRequestDto requestDto, HttpServletResponse res) throws IOException {
        // 1. 요청 받은 회원가입 정보 변수에 저장 및 비밀번호 암호화
        String username = requestDto.getUsername();
        String password = passwordEncoder.encode(requestDto.getPassword());

        // 2. 회원 중복 확인
        // 2-1. DB에 해당 username 에 대한 row 가 있다면 checkUsername 변수에 저장.
        Optional<User> checkUsername = userRepository.findByUsername(username);
        if( checkUsername.isPresent() ) { // 2-2. 중복된 회원이 있을 경우
            responseResult(res,400,"중복된 사용자가 있습니다.");
            log.error("중복된 사용자가 존재합니다.");
        }else { // 2-2. 중복된 회원이 없을 경우 가입 시도
            // 3. 사용자 ROLE 부여
            UserRoleEnum role = UserRoleEnum.USER;

            // 4. 해당 정보를 생성자 메서드로 User 객체 생성 후 DB 에 저장
            User user = new User(username, password, role);
            userRepository.save(user);

            // 5. Client 에 반환할 데이터 및 log 출력
            responseResult(res, 200, "회원가입 성공");
            log.info("회원가입에 성공하였습니다.");
        }
    }

    // Client 에 HttpServletResponse 를 통해 반환할 msg, status 세팅 메서드
    private void responseResult(HttpServletResponse response, int statusCode, String message) throws IOException {
        String jsonResponse = "{\"status\": " + statusCode + ", \"message\": \"" + message + "\"}";

        // Content-Type 및 문자 인코딩 설정
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        // PrintWriter 를 사용하여 응답 데이터 전송
        PrintWriter writer = response.getWriter();
        writer.write(jsonResponse);
        writer.flush();
    }

}

 

 

5) Repository클래스 구현

  • Spring JPA 에서 제공하는 (자동 생성하는) JpaRepository라는 인터페이스를 상속한다.
  • Entity 타입과 Entity의 @Id 타입을 <> 안에 기재한다.
  • 해당 클래스 내에서 Query String 명명 규칙에 맞게 메서드를 만들 수 있고, 이를 통해 DB에 접근한다.
  • 이는 JPA 내부에서 제공하는 편리한 기능으로, 직접 SQL Query문을 작성하지 않도록 수고를 덜어준다.
package com.sparta.myblog.repository;

import com.sparta.myblog.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    // Query 문 : select * from users where username = ? ;
}

 

 

 

 

결과 :

서버 측,

 

클라이언트 측,