⚙️ 설계

📌 ERD

USER
userIdusernameemailpasswordroleschedulesSchedule
scheduleIdtitlecontentweatherusercommentsComment
commentIdusernamecontentschedule댓글 조회 Response
usernamecontent댓글 수정 Request
commentIdcontentpassword댓글 삭제 Request
commentIdpassword댓글 등록 Request
contentpassword유저 조회 Response
userIdusernameemailroleschedules유저 등록 Request
usernameemailpassword유저 삭제
userIdpassword일정 조회 Response
scheduleIdtitlecontentweatherusernamecommnets일정 등록 Request
titlecontentpassword일정 수정 Request
scheduleIdupdate contentpasswordrole일정 삭제 Request
scheduleIdpasswordrole페이징 Response
titlecontentcount(coment)createAtupdateAtusername로그인 Request
emailpassword{
"username" : "user1",
"email" : "asdf@naver.com",
"password" : "1234"
}
{
"userId" : "1",
"password" : "1234"
}
{
"email" : "asdf@naver.com",
"password" : "1234"
}
{
"title" : "TIL",
"content" : "Today Study Spring!!",
"password" : "1234"
}
{
"scheduleId" : "1",
"content" : "Today Study Spring!!",
"password" : "1234"
}
{
"scheduleId" : "1",
"password" : "1234"
}
{
"content" : "Good",
"password" : "1234"
}
{
"commentId" : "1",
"content" : "LGTM",
"password" : "1234"
}
{
"commentId" : "1",
"password" : "1234"
}
💡요구사항 분석
401을 반환합니다.400을 반환합니다.401을 반환합니다.403을 반환합니다. —> 권한이 없는 유저가 일정 수정 및 삭제를 요청했을 때Email 검증username 글자 수 검증title 글자 수 검증API 를 사용하여 JSON 데이터 받아오기Feign Client 를 활용하여 JSON 가져오기JSON 데이터 파싱해서 원하는 데이터 가져오기JWT 발급1 : N , N : 1 , 양방향 매핑N + 1 문제 발생)@Query 쿼리 파라미터 사용countQuery 사용Pagaable 객체 사용🧑💻 코드
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<String> authorizationHandler(AuthorizationException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.FORBIDDEN);
}
@ExceptionHandler(NoResultDataException.class)
public ResponseEntity<String> getExceptionHandler(NoResultDataException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
@ExceptionHandler(MissMatchPasswordException.class)
public ResponseEntity<String> passwordHandler(MissMatchPasswordException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getMessage());
}
@ExceptionHandler(TokenNotFoundException.class)
public ResponseEntity<String> tokenInfoHandler(TokenNotFoundException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
@ExceptionHandler(TokenExpiredException.class)
public ResponseEntity<String> expiredHandler(TokenExpiredException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> validHandler(ConstraintViolationException ex){
Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
String e = "";
for (ConstraintViolation<?> constraintViolation : constraintViolations) {
e = constraintViolation.getMessage();
}
return new ResponseEntity<>(e, HttpStatus.BAD_REQUEST);
}
}
AuthorizationExceptionNoRequestDataExceptionMissMatchPasswordExceptionTokenNotFoundExceptionTokenExpiredExceptionConstraintViolationException@FeignClient(name = "weatherInfo", url = "https://f-api.github.io")
public interface WeatherClient {
@GetMapping("/f-api/weather.json")
List<Weather> getWeather();
}
FeignClient 를 활용합니다.getWeather() 메소드는 설정된 URI를 통해 데이터를 받아옵니다.@Service
@RequiredArgsConstructor
public class WeatherService {
private final WeatherClient weatherClient;
public String findWeatherByDate() {
Date today = new Date();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM-dd");
String formatDate = simpleDateFormat.format(today);
List<Weather> weathers = weatherClient.getWeather();
return weathers.stream().filter(weather -> weather.getDate().equals(formatDate))
.map(Weather::getWeather).findFirst().orElse(null);
}
}
파싱합니다.@Slf4j
@Component
public class JwtProvider {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String AUTHORIZATION_KEY = "auth";
public static final String BEARER_PREFIX = "Bearer ";
private final long TOKEN_TIME = 60 * 60 * 1000L;
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] decode = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(decode);
}
public String createToken(String username, Role role) {
Date date = new Date();
return BEARER_PREFIX + Jwts.builder()
.setSubject(username)
.claim(AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
public void addJwtToCookie(String token, HttpServletResponse response) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20");
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token);
cookie.setPath("/");
response.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
log.error(e.getMessage());
}
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
log.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
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();
}
public String getTokenFromRequest(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
}
createTokenJWT 토큰을 생성합니다.addJwtToCookieJWT 토큰을 쿠키에 저장합니다.substringTokenvalidateTokengetUserInfoFromToken@RequiredArgsConstructor
@Order(1)
public class JwtFilter implements Filter {
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String requestURI = request.getRequestURI();
if(StringUtils.hasText(requestURI) && (requestURI.startsWith("/api/login") || requestURI.startsWith("/api/user"))){
filterChain.doFilter(servletRequest, servletResponse);
} else{
String tokenFromRequest = jwtProvider.getTokenFromRequest(request);
if(StringUtils.hasText(tokenFromRequest)){
String token = jwtProvider.substringToken(tokenFromRequest);
if(!jwtProvider.validateToken(token)){
throw new TokenExpiredException();
}
Claims info = jwtProvider.getUserInfoFromToken(token);
userRepository.findByUsername(info.getSubject())
.orElseThrow(NoResultDataException::new);
filterChain.doFilter(servletRequest, servletResponse);
}else{
throw new TokenNotFoundException();
}
}
}
}
/api/login, /api/user 로그인과 회원가입은 토큰검증을 진행하지 않습니다.URI는 토큰 유효성 검증을 진행합니다.@Query(value = "select " +
"new org.example.todolistproject.dto.page.PageResponseDto(" +
"s.title, " +
"s.content, " +
"count (c), " +
"s.createdAt," +
"s.modifiedAt, " +
"s.user.username) " +
"from Schedule s left join s.comments c " +
"group by s.title, s.content, s.createdAt, s.modifiedAt, s.user.username " +
"order by s.modifiedAt desc ",
countQuery = "select count (s) from Schedule s")
Page<PageResponseDto> findAllWithComment(Pageable pageable);
Comment 엔티티의 수를 각 Schedule별로 집계하기 위해 GROUP BY를 사용합니다.countQuery@Entity
@Getter
@NoArgsConstructor
public class User extends TimeStamped{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@Size(max = 10, message = "10글자 이내로 작성해주세요.")
private String username;
@Email(message = "이메일 형식으로 입력해주세요.")
private String email;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
@OneToMany(mappedBy = "user")
private final List<Schedule> schedules = new ArrayList<>();
public User(String username, String email, String password, Role role) {
this.username = username;
this.email = email;
this.password = password;
this.role = role;
}
}
@Entity
@Getter
@NoArgsConstructor
public class Schedule extends TimeStamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "schedule_id")
private Long scheduleId;
@Size(max = 10, message = "10글자 이내로 작성해주세요.")
private String title;
private String password;
@Setter
private String content;
@Setter
private String weather;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@JsonManagedReference
@OneToMany(mappedBy = "schedule", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
public void addUser(User user) {
user.getSchedules().add(this);
this.user = user;
}
public Schedule(String title, String password, String content, String weather) {
this.title = title;
this.password = password;
this.content = content;
this.weather = weather;
}
}
@Entity
@Getter
@NoArgsConstructor
public class Comment extends TimeStamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long commentId;
private String username;
private String password;
@Setter
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "schedule_id")
@JsonBackReference
private Schedule schedule;
public void addSchedule(Schedule schedule) {
schedule.getComments().add(this);
this.schedule = schedule;
}
public Comment(String username, String password, String content) {
this.username = username;
this.password = password;
this.content = content;
}
}
외래 키가 있는 곳이 연관관계 주인입니다.mappedBy 를 사용하지 않습니다.XXXToOne(fetch = FetchType.LAZY)LAZY로딩을 설정하여 N + 1 문제를 해결합니다.JOIN시에는 주의해야합니다.@Enumerated(EnumType.STRING)Enum 타입을 데이터베이스의 문자열로 저장하기 위함입니다.🙏 프로젝트 구조가 많이 크기때문에 여기에 다담지 못한 내용은 추후에 업로드 예정입니다.
🧑💻 톺아보기
QueryDsl을 고려해 볼 수 있을 거 같습니다.@Setter 가 들어가 있기 때문에 리펙토링이 필요합니다.