Spring Lv2 과제
회원 가입 API
최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)
로 구성되어야 한다.최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)
로 구성되어야 한다.로그인 API
전체 게시글 목록 조회 API
게시글 작성 API
선택한 게시글 조회 API
선택한 게시글 수정 API
선택한 게시글 삭제 API
요구사항에는 없었지만, 연습해볼 겸 만들어보았다.
<<포함>> 점선 화살표
회원가입, 게시글 작성, 게시글 수정, 게시글 삭제는 로그인이 실행되었다는 전제하에 실행 가능하다.
Board 엔티티와 User 엔티티는 각 테이블에 유일한 값인 id 를 가진다.
Board : User = N : 1 의 다대일 단방향 관계다.
spring.datasource.url=jdbc:mysql://localhost:3306/board
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
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==
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'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// JPA 설정
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
implementation 'mysql:mysql-connector-java:8.0.33'
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
// validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// 추가
implementation group: 'org.javassist', name: 'javassist', version: '3.15.0-GA'
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'
}
tasks.named('test') {
useJUnitPlatform()
}
ResponseEntity 를 이용하여, 메시지와 에러코드를 함께 반환하도록 했다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class BoardController {
private final BoardService boardService;
...
}
@RestController
@RequiredArgsConstructor
// 게시글 작성
@PostMapping("/board")
public ResponseEntity<BoardResponseDto> createBoard(@RequestBody BoardRequestDto requestDto, HttpServletRequest request) {
return ResponseEntity.ok(boardService.createBoard(requestDto, request));
}
@RequestBody
HttpServletRequest
ResponseEntity.ok(body)
// 게시글 전체 조회
@GetMapping("/board")
public ResponseEntity<List<BoardResponseDto>> getBoardList() {
return ResponseEntity.ok(boardService.getBoardList());
}
// 게시글 선택 조회
@GetMapping("/board/{id}")
public ResponseEntity<BoardResponseDto> getBoard(@PathVariable Long id) {
return ResponseEntity.ok(boardService.getBoard(id));
}
// 게시글 수정
@PutMapping("/board/{id}")
public ResponseEntity<BoardResponseDto> updateBoard(@PathVariable Long id, @RequestBody BoardRequestDto requestDto, HttpServletRequest request) {
return ResponseEntity.ok(boardService.updateBoard(id, requestDto, request));
}
// 게시글 삭제
@DeleteMapping("/board/{id}")
public ResponseEntity<MsgResponseDto> deleteBoard(@PathVariable Long id, HttpServletRequest request) {
return ResponseEntity.ok(boardService.deleteBoard(id, request));
}
게시글 수정과 삭제의 경우, 게시글 작성과 같은 이유로 jwt 토큰을 담았다.
게시글 수정에서는 "게시글을 삭제했습니다"라는 메시지만 반환하면 되므로, MsgResponseDto 클래스를 따로 만들어서 반환하도록 했다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {
...
}
@RestController
// 회원 가입
@PostMapping("/signup")
public ResponseEntity<MsgResponseDto> signup(@RequestBody @Valid SignupRequestDto signupRequestDto) {
userService.signup(signupRequestDto);
return ResponseEntity.ok(new MsgResponseDto("회원가입 완료", HttpStatus.OK.value()));
}
ResponseEntity<MsgResponseDto>
회원가입과 로그인의 요구사항에 '회원가입 및 로그인 성공 시 성공했다는 메시지, 상태코드를 함께 반환' 하라는 조건이 있었다.
? 와일드 카드 ResponseEntity<?>
를 사용해도 동일한 결과를 반환한다.
@Valid
객체의 제약 조건을 검증하도록 지시하는 어노테이션
ResponseEntity.ok(new MsgResponseDto("회원가입 완료", HttpStatus.OK.value()))
// 로그인
@PostMapping("/login")
public ResponseEntity<MsgResponseDto> login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
userService.login(loginRequestDto, response);
return ResponseEntity.ok(new MsgResponseDto("로그인 완료",HttpStatus.OK.value()));
}
HttpServletResponse response
@Setter 를 모두 삭제한 이유에 대해서는 아래에 정리해두었다.
참고: Dto 에서의 어노테이션 언제 사용할까
@Getter
@NoArgsConstructor
public class BoardRequestDto {
private String title;
private String contents;
}
@Getter
@NoArgsConstructor
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
}
회원가입에는 username, password 이 필요하다
@NotBlank
: username, password 입력시 공백을 허용하지 않도록 했다.
@Getter
@NoArgsConstructor
public class LoginRequestDto {
private String username;
private String password;
}
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BoardResponseDto {
private Long id;
private String title;
private String username;
private String contents;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
// 생성자
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();
}
}
방법 1 : @AllArgsConstructor 사용
@Getter
@AllArgsConstructor
public class MsgResponseDto {
private String msg;
private int statusCode;
}
@AllArgsConstructor
방법 2 : 생성자를 직접 작성
@Getter
public class MsgResponseDto {
private String msg;
private int statusCode;
public MsgResponseDto(String msg, int statusCode) {
this.msg = msg;
this.statusCode = statusCode;
}
ublic class BoardService {
private final BoardRepository boardRepository;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
...
}
// 게시글 작성
public BoardResponseDto createBoard(BoardRequestDto requestDto, HttpServletRequest request) {
User user = getUserFromToken(request); // getUserFromToken 메서드를 호출
// 여기 3줄은 입문 에서의 '게시글 작성'과 동일
Board board = new Board(requestDto, user);
Board saveBoard = boardRepository.save(board);
return new BoardResponseDto(saveBoard);
}
HttpServletRequest
// 게시글 수정
@Transactional
public BoardResponseDto updateBoard(Long id, BoardRequestDto requestDto, HttpServletRequest request) {
User user = getUserFromToken(request); // getUserFromToken 메서드를 호출
Board board = boardRepository.findByIdAndUserId(id, user.getId()).orElseThrow(
() -> new IllegalArgumentException("해당 사용자의 게시글을 찾을 수 없습니다.")
);
board.update(requestDto);
return new BoardResponseDto(board);
}
// 게시글 삭제
public MsgResponseDto deleteBoard(Long id, HttpServletRequest request) {
User user = getUserFromToken(request); // getUserFromToken 메서드를 호출
Board board = boardRepository.findByIdAndUserId(id, user.getId()).orElseThrow(
() -> new IllegalArgumentException("해당 사용자의 게시글을 찾을 수 없습니다.")
);
boardRepository.delete(board);
return new MsgResponseDto("게시글을 삭제했습니다.", HttpStatus.OK.value());
}
private User getUserFromToken(HttpServletRequest request) {
String token = jwtUtil.getJwtFromHeader(request); // request 에서 Token 가져오기
Claims claims; // JWT 안에 있는 정보를 담는 Claims 객체
if (StringUtils.hasText(token)) { // JWT 토큰 있는지 확인
if (jwtUtil.validateToken(token)) { // JWT 토큰 검증
claims = jwtUtil.getUserInfoFromToken(token); // ture 일 경우, JWT 토큰에서 사용자 정보 가져오기
} else {
throw new IllegalArgumentException("올바른 token 이 아닙니다.");
}
// 검증된 JWT 토큰에서 사용자 정보 조회 및 가져오기
return userRepository.findByUsername(claims.getSubject()).orElseThrow(
() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다.")
);
}
throw new IllegalArgumentException("token 이 없습니다.");
}
StringUtils.hasText(token)
StringUtils.hasText(값)
token != null 으로 token 에 담긴 값의 NULL 유무를 확인해도 무방하다
@Service
@Validated
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
...
}
@Validated
// 회원 가입
public void signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername(); // requestDto 에서 사용자 이름을 조회
String password = requestDto.getPassword(); // requestDto 에서 비밀번호를 조회
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username); // 조회한 이름이 DB 에 있는지 확인
if (checkUsername.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// 조회한 이름이 DB 에 없을 경우, 사용자 등록 (admin = false 일 경우 아래 코드 수행)
User user = new User(username, password);
userRepository.save(user); // DB 에 저장
}
// 로그인
public void login(LoginRequestDto loginRequestDto, HttpServletResponse response) {
String username = loginRequestDto.getUsername();
String password = loginRequestDto.getPassword();
User user = userRepository.findByUsername(username).orElseThrow(
() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다.")
);
// 비밀번호 일치 여부 확인
if (!user.getPassword().equals(password)) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, JwtUtil.createToken(user.getUsername()));
}
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, JwtUtil.createToken(user.getUsername()))
public interface BoardRepository extends JpaRepository<Board, Long> {
List<Board> findAllByOrderByCreatedAtDesc();
Optional<Board> findByIdAndUserId(Long id, Long userId);
}
findAllByOrderByCreatedAtDesc
findByIdAndUserId
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
@Getter, @NoArgsConstructor 를 사용한 이유에 대해서는 아래에 정리해두었다.
참고: Dto 에서의 어노테이션 언제 사용할까
@Entity
@Getter
@Table(name = "board")
@NoArgsConstructor
public class Board extends Timestamped {
...
}
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 = "contents", nullable = false, length = 500)
private String contents;
// board : user = N : 1 다대일 단방향 연관관계
@ManyToOne(fetch = FetchType.LAZY) //
@JoinColumn(name = "user_id", nullable = false)
private User user;
...
}
@ManyToOne(fetch = FetchType.LAZY)
...
// 게시글 작성
public Board(BoardRequestDto requestDto, User user) {
this.title = requestDto.getTitle();
this.username = user.getUsername();
this.contents = requestDto.getContents();
this.user = user;
}
// 게시글 수정
public void update(BoardRequestDto requestDto) {
this.title = requestDto.getTitle();
this.contents = requestDto.getContents();
}
}
this.username = user.getUsername()
this.user = user
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true) // unique = true : username 중복된 값 받지 않기 위해
@Size(min = 4,max = 10, message ="작성자명은 4자 이상 10자 이하만 가능합니다.")
@Pattern(regexp = "^[a-z0-9]*$", message = "작성자명은 알파벳 소문자, 숫자만 사용 가능합니다.") // 정규식
private String username;
@Column(nullable = false)
@Size(min = 8,max = 15, message ="비밀번호는 8자 이상 15자 이하만 가능합니다.")
@Pattern(regexp = "^[a-zA-Z_0-9]*$", message = "비밀번호는 알파벳 대소문자, 숫자만 사용 가능합니다.")
private String password;
...
}
...
public User(String username, String password) {
this.username = username;
this.password = password;
}
}
Lv1 과 동일하다
Lv1 과 동일하다
JwtUtil 클래스를 통해 쿠키를 직접 만들 수 있다.
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
...
}
@Slf4j(topic = "JwtUtil")
@Slf4j
topic
public static final String AUTHORIZATION_HEADER = "Authorization"; // Header KEY 값
public static final String AUTHORIZATION_KEY = "auth"; // 사용자 권한 값의 KEY
public static final String BEARER_PREFIX = "Bearer "; // Token 식별자
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 토큰 만료시간 : 60분 // static 을 추가함
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey (application.properties 에 추가해둔 값)
private String secretKey; // 그 값을 가져와서 secretKey 변수에 넣는다
private static Key key; // Secret key 를 담을 변수 // static 을 추가함
private static final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 사용할 알고리즘 선택 // static 을 추가함
@PostConstruct // 한 번만 받으면 값을 사용할 때마다, 매번 요청을 새로 호출하는 것을 방지
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey); // Base64 를 디코딩
key = Keys.hmacShaKeyFor(bytes);
}
staic final
public static String createToken(String username) {
Date date = new Date();
// 암호화
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID). 여기에선 username 을 넣음
.claim(AUTHORIZATION_KEY, username) // 사용자 권한 (key, value)
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 : 현재시간 date.getTime() + 위에서 지정한 토큰 만료시간(60분)
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘 (Secret key, 사용할 알고리즘 종류)
.compact();
}
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); // body 에 있는 claims 를 가져온다
}