지난 두달 동안, 싸피에 입과하려고 준비 하느라 포스팅을 좀 소홀히 했다..
결과적으로 싸피에 입과하지 못했고 (면접에서 탈락 🥲), 학교도 졸업했으니 취준생의 신분으로써...
하나의 프로젝트를 완벽하게 끝내는 것이 좋다고 생각하여, Jelog 프로젝트를 다시 시작 하려한다.
처음으로 진행했던 백엔드 프로젝트였기에, 아주 엉성한 부분이 많다.
그 부분 부터 차근차근 수정 해가면서 좀 더 정교한 백엔드 개발을 해보자..
우선, DB 설계가 가장 중요하기 때문에 Jelog ERD 부터 그려보았다.
내 생각대로, 그리고 다른 이들의 블로그 ERD 를 참고하면서 그려보았다.
우선, 사용자와 게시글은 1:N 식별 관계로 정의하였다.
게시글은 사용자로 부터 작성되므로, 사용자가 없으면 게시글은 존재할 수 없으니 식별 관계, 사용자는 여러개의 게시글을 작성할 수 있으니까 1:N 관계로 설계했다.
그 다음, 게시글과 댓글, 게시글과 이미지 역시 1:N 식별 관계로 정의하였다.
ERD 를 설계하면서 느낀 점이, 블로그에서 비식별 관계로 정의할 관계가 무엇이 있을까이다.
내 생각엔 모든 관계가 인과 관계가 있다고 생각돼서... 전부 식별 관계가 돼 버렸다.
ERD 설계도 대로 우선 테이블부터 생성하자.
간단한 테이블들은 쉽게 생성 할 수 있으니, 조인 된 테이블들만 설명하겠다.
Post 테이블
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long postId;
@ManyToOne(targetEntity = User.class)
@JoinColumn(name = "user_id")
private User user;
@Column
private String title;
@Column
private String content;
@Builder
public Post(User user, String title, String content) {
this.user = user;
this.title = title;
this.content = content;
}
}
Post 테이블의 경우 User 테이블의 PK 인 user_id
를 조인한다.
Comment 테이블
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long commentId;
@ManyToOne(targetEntity = Post.class)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(targetEntity = User.class)
@JoinColumn(name = "user_id")
private User user;
@Column
private String content;
@Builder
public Comment(Post post, User user, String content) {
this.post = post;
this.user = user;
this.content = content;
}
}
Comment 테이블의 경우 Post 테이블과 1:N 식별관계로, Post 테이블의 PK들(post_id
, user_id
)과 조인한다.
ERD 설계도를 토대로 테이블들을 생성해준다.
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder encoder;
// C : 회원 등록
public Long register(AddUserRequestDto requestDto){
userRepository.findByUserEmail(requestDto.getUserEmail()).ifPresent(user -> {
throw new AppException(ErrorCode.USEREMAIL_DUPLICATED, requestDto.getUserEmail() + "는 이미 존재하는 계정입니다.");
});
return userRepository.save(
User.builder()
.userEmail(requestDto.getUserEmail())
.userPw(encoder.encode(requestDto.getUserPw()))
.userIcon(requestDto.getUserIcon())
.build()
).getUserId();
}
// R : 로그인
public boolean login(LoginRequestDto requestDto){
User user = userRepository.findByUserEmail(requestDto.getUserEmail())
.orElseThrow(() -> new AppException(ErrorCode.USEREMAIL_NOTEXIST, requestDto.getUserEmail() + "는 존재하지 않는 계정입니다."));
return encoder.matches(requestDto.getUserPw(), user.getUserPw());
}
// U : 회원 정보 변경
// D : 회원 삭제
public boolean delete(Long userId){
try {
userRepository.deleteById(userId);
return true;
} catch (Exception e){
return false;
}
}
}
기존에 작성했던 서비스를 전부 갈아 엎고, 다시 작성한다.
우선, 회원 가입 할 때 비밀번호를 BCryptPasswordEncoder
를 통해 암호화 하여 DB에 저장한다.
@Configuration
public class EncoderConfig {
@Bean
public BCryptPasswordEncoder encoder(){
return new BCryptPasswordEncoder();
}
}
EncoderConfig 클래스를 통해 BCryptPasswordEncoder
를 Bean
으로 등록하여 의존 관계를 설정한다.
@AllArgsConstructor
@Getter
public enum ErrorCode {
USEREMAIL_DUPLICATED(HttpStatus.CONFLICT, "중복된 이메일"),
USEREMAIL_NOTEXIST(HttpStatus.CONFLICT, "존재하지 않는 이메일");
private HttpStatus httpStatus;
private String message;
}
그리고, 동작 중에 발생하는 에러들을 ErrorCode
Enum 상수로 등록하여 문제를 쉽게 대응할 수 있도록 한다.
@RestControllerAdvice
public class ExceptionManager {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> runtimeExceptionHandler(RuntimeException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
}
@ExceptionHandler(AppException.class)
public ResponseEntity<?> appExceptionHandler(AppException e) {
return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(e.getErrorCode().name() + " ::: " + e.getMessage());
}
}
ErroCode
에 등록된 에러들을 다룰 Handler
역시 정의한다.
스프링 시큐리티를 통하여 인증, 권한 부여 등을 이용하자.
추후에 JWT 를 이용한 인증을 이용할 예정이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final UserService userService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> {
requests.requestMatchers("/api/user/login", "api/user/register", "/api/article/**").permitAll();
requests.anyRequest().authenticated();
})
.sessionManagement(
sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}
SecurityFilterChain
을 bean
으로 등록하여, 리소스의 권한을 설정한다.
로그인 하기 위한 /api/user/login
, 가입 하기 위한 /api/user/register
, 게시글을 보기 위한 /api/article/**
의 접근은 가능하도록 설정한다.
이 외의 모든 접근은 인증된 사용자만 접근 할 수 있도록 설정한다.
회원 가입
회원 가입 실패
로그인
로그인 실패
회원 탈퇴
JWT 미구현으로 시연 불가.
application.yaml
을 수정하여 필요한 환경설정을 한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework:spring-context'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
annotationProcessor 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
jjwt
의 0.12.3
버전을 기준으로 작성된 프로젝트이다.
public class JwtUtil {
public static String createJwt(String userEmail, String secretKey, Long expiredMs){
return Jwts.builder()
.claim("userEmail", userEmail)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public static boolean isExpired(String token, String secretKey){
return Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration()
.before(new Date());
}
}
Jwt 을 생성하고, 유효한지 확인 해줄 JwtUtil
클래스를 작성한다.
사용자의 이메일과 암호키를 받아, Jwts
를 통해 토큰을 발행한다.
claim 을 통해 userEmail
을 토큰에 명시해주고, 발행 일자와 만료 시기를 지정해준다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
logger.info("Authorization : " + authorization);
if(authorization == null || !authorization.startsWith("Bearer ")){
logger.error("Authorization 이 없습니다.");
filterChain.doFilter(request, response);
return;
}
// Token 꺼내기
String token = authorization.split(" ")[1];
// Token 유효 검사
if(JwtUtil.isExpired(token, secretKey)){
logger.error("만료된 Token 입니다.");
filterChain.doFilter(request, response);
}
// UserEmail 꺼내기
String userEmail = "";
// 권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userEmail, null, List.of(new SimpleGrantedAuthority("USER")));
// Detail 삽입
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
API 요청이 생길 때 마다 동작할 JwtFilter
를 작성한다.
추상화된 doFilterInternal
메소드를 명시해준다.
서블렛으로 들어온 요청의 헤더에서 AUTHORIZATION
를 얻어 오고, 해당 autorization
이 유효한 값인지 확인한다.
유효한 경우 authorization
으로부터 토큰을 얻어온다.
토큰이 만료되었는 지 검사한 후, 만료되지 않았을 때 토큰에서 userEmail
을 꺼내온다.
모든 조건이 만족하면, Spring Security 의 권한 부여를 위한 토큰을 새로 생성한다.
해당 토큰에 디테일(현재 요청에 대한 추가 정보)를 삽입한뒤, SecurityContextHolder
에 생성한 토큰을 등록한다.
정의한 JwtFilter 를 추가하여, 인증된 사용자만 API에 접근할 수 있도록 수정하자.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final UserService userService;
@Value("${jwt.secret}")
private String secretKey;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> {
requests.requestMatchers("/api/user/login", "api/user/register", "/api/article/**").permitAll();
requests.anyRequest().authenticated();
})
.sessionManagement(
sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(new JwtFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
application.yaml
에 정의한 암호키를 가져와서 사용한다.
.addFilterBefore()
메소드를 통해 정의한 JWT 필터를 Spring Security 필터 체인에 추가하여, 새로운 HTTP 요청이 들어올 때 마다 JWT의 유효성을 검사하고, 유효한 경우 사용자를 Spring Security의 인증 Context에 등록한다.
JWT 발행
유효하지 않은 JWT
결과
2024-01-05T18:52:23.703+09:00 INFO 12946 --- [nio-8080-exec-8] com.example.jelog.jwt.JwtFilter : Authorization : null
2024-01-05T18:52:23.706+09:00 ERROR 12946 --- [nio-8080-exec-8] com.example.jelog.jwt.JwtFilter : Authorization 이 없습니다.
유효한 JWT
결과
2024-01-05T18:52:36.592+09:00 INFO 12946 --- [nio-8080-exec-9] com.example.jelog.jwt.JwtFilter : Authorization : Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyRW1haWwiOiJ0ZXN0QHRlc3QuY29tIiwiaWF0IjoxNzA0NDQ4MzQzLCJleHAiOjE3MDQ0NTE5NDN9.iivHfnkE9NcJOUSBKWqdojI40bmMxklzBUsO-gUt_08