본문 바로가기
Spring

[Spring] 일정관리 앱 코드 Fix

by worldcenter 2024. 10. 30.

 

 

 

아래 코드는 피드백을 토대로 수정한 내용입니다. 자세한 소스코드는 다음 링크에서 확인 가능합니다.

 

다양한 예외처리 적용하기(@Valid)
package com.sparta.nuricalendaradvanced.domain.schedule.dto;

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

@Getter
@Setter
public class ScheduleRequestDto {

    @Pattern(regexp = "^(\\d{4})-(\\d{2})-(\\d{2})$")
    @Size(max = 10)
    private String date;

    @Size(max = 50)
    private String title;

    @Size(max = 255)
    private String contents;

    @Size(max = 10)
    private String username;

}

 

예외 일관적으로 처리
package com.sparta.nuricalendaradvanced.common.exception;

import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.servlet.ServletException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.io.IOException;
import java.io.UnsupportedEncodingException;

@Slf4j(topic = "exception:")
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(value = ResponseException.class)
    public ResponseEntity<String> responseException(ResponseException e) {
        return ResponseEntity
                .status(e.getResponseStatus().getHttpStatus())
                .body(e.getErrMessage());
    }

    @ExceptionHandler(IOException.class)
    public ResponseEntity<String> responseException(IOException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }

    @ExceptionHandler(ServletException.class)
    public ResponseEntity<String> responseException(ServletException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }

    @ExceptionHandler(UnsupportedEncodingException.class)
    public ResponseEntity<String> responseException(UnsupportedEncodingException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }

    @ExceptionHandler(JsonProcessingException.class)
    public ResponseEntity<String> responseException(JsonProcessingException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }



}
package com.sparta.nuricalendaradvanced.common.exception;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ResponseStatus {

    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원은 존재하지 않습니다."),
    USER_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "이미 가입한 유저 입니다."),
    USER_EMAIL_DUPLICATED(HttpStatus.BAD_REQUEST, "중복된 Email 입니다."),
    USER_PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "암호가 일치하지 않습니다."),
    ADMIN_PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "관리자 암호가 틀려 등록이 불가능합니다."),

    SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 일정이 존재하지 않습니다."),

    COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글이 존재하지 않습니다."),

    TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "토큰을 찾을 수 없습니다."),
    TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다.");



    private final HttpStatus httpStatus;
    private final String errMessage;

}

 

필터에서 로그 추가
package com.sparta.nuricalendaradvanced.common.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 전처리
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String url = httpServletRequest.getRequestURI();
        String userAgent = httpServletRequest.getHeader("user-agent"); // Header 로그 추가
        log.info("url: " + url);
        log.info("userAgent: " + userAgent); // Header 로그 추가

        chain.doFilter(request, response);

        // 후처리
        log.info("비즈니스 로직 완료");
    }
}

 

Early return 

 

`if` 문의 조건이 길어지게 될 경우 해당 조건이 어떤 것을 의도하는지 코드를 읽는사람으로 하여금 파악하는데 어렵게 만듭니다. 이럴 경우 별도 메소드로 분리해서 의도를 설명해주는게 해결책이 될 수 있습니다.

package com.sparta.nuricalendaradvanced.common.filter;

import com.sparta.nuricalendaradvanced.common.exception.ResponseException;
import com.sparta.nuricalendaradvanced.common.exception.ResponseStatus;
import com.sparta.nuricalendaradvanced.common.jwt.JwtUtil;
import com.sparta.nuricalendaradvanced.domain.user.entity.User;
import com.sparta.nuricalendaradvanced.domain.user.repository.UserRepository;
import io.jsonwebtoken.Claims;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.io.IOException;

@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {

    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.jwtUtil = jwtUtil;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ResponseException, ServletException, IOException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String url = httpServletRequest.getRequestURI();

        // Early return으로 수정
        if (StringUtils.hasText(url) &&
                (url.startsWith("/api/user/signup") || url.startsWith("/api/user/signin"))
        ) {
            chain.doFilter(request, response);
            return; 
        }

        String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);

        // Early return으로 수정
        if (!StringUtils.hasText(tokenValue)) {
            throw new ResponseException(ResponseStatus.TOKEN_NOT_FOUND);
        }


        String token = jwtUtil.substringToken(tokenValue);

        // Early return으로 수정
        if (!jwtUtil.validateToken(token)) {
            throw new ResponseException(ResponseStatus.TOKEN_INVALID);
        }


        Claims info = jwtUtil.getUserInfoFromToken(token);

        User user = userRepository.findById(Long.valueOf(info.getSubject())).orElseThrow(() ->
                new ResponseException(ResponseStatus.USER_NOT_FOUND)
        );

        request.setAttribute("user", user);
        chain.doFilter(request, response);
    }


}

 

RESTful API에서 URL에 동사 넣지 않기 & RequestParam 사용
package com.sparta.nuricalendaradvanced.domain.schedule.controller;

@RestController
@RequestMapping("/api/schedule")
@RequiredArgsConstructor
public class ScheduleController {

    private final ScheduleService scheduleService;


    @PostMapping() // /submit url을 삭제
    public ResponseEntity<ScheduleResponseDto> submitSchedule(@RequestBody @Valid ScheduleRequestDto requestDto,
                                                              HttpServletRequest req) throws JsonProcessingException {
        User user = (User) req.getAttribute("user");
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(scheduleService.submitSchedule(requestDto, user));
    }

    @PutMapping() // /update url을 삭제
    public ResponseEntity<ScheduleResponseDto> updateSchedule(@RequestParam Long id,
                                                              @RequestBody ScheduleRequestDto requestDto) throws ResponseException, JsonProcessingException {
        return ResponseEntity
                .status(HttpStatus.OK)
                .body(scheduleService.updateSchedule(id, requestDto));
    }

    @DeleteMapping() // /delete url을 삭제
    public ResponseEntity<Void> deleteSchedule(@RequestParam Long id) {

        scheduleService.deleteSchedule(id);
        return ResponseEntity
                .status(HttpStatus.NO_CONTENT)
                .build();

    }


}

 

정적 팩토리를 이용하여 entity 객체 생성
package com.sparta.nuricalendaradvanced.domain.schedule.entity;

@Entity
@Getter
@NoArgsConstructor
public class Schedule extends Timestamped {

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

    @Column(nullable = false, length = 10)
    private String date;

    @Column(nullable = false, length = 50)
    private String title;

    @Column(nullable = false)
    private String contents;

    @Column(nullable = false)
    private String weather;

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

    @OneToMany(mappedBy = "schedule", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
    private List<Comment> commentList = new ArrayList<>();


    public static Schedule from(ScheduleRequestDto requestDto, User user, String weather) {
        Schedule schedule = new Schedule();
        schedule.initData(requestDto, user, weather);
        return schedule;
    }

    private void initData(ScheduleRequestDto requestDto, User user, String weather) {
        this.date = requestDto.getDate();
        this.title = requestDto.getTitle();
        this.contents = requestDto.getContents();
        this.weather = weather;
        this.user = user;
    }

    public Schedule update(ScheduleRequestDto requestDto, String weather) {
        this.date = requestDto.getDate();
        this.title = requestDto.getTitle();
        this.contents = requestDto.getContents();
        this.weather = weather;
        return this;
    }
}

 

IoC를 위반하지 않도록 코드 수정

 

`Schedule` Entity 가 RequestDTO 에 대해서 알고있는 것은 클린코드 관점에서 의존성 규칙을 위배했다고 볼 수 있습니다. 즉, 더 높은 수준의 객체인 Entity 가 더 낮은 수준의 객체인 DTO 를 의존하고 있어 변경에 취약한 구조가 만들어졌다고 볼 수 있습니다. 

package com.sparta.nuricalendaradvanced.domain.schedule.service;

import java.time.format.DateTimeFormatter;

@Slf4j(topic = "Schedule Logic")
@Service
@RequiredArgsConstructor
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final WeatherApiService weatherApiService;


    public ScheduleResponseDto submitSchedule(ScheduleRequestDto requestDto, User user) throws JsonProcessingException {

        Schedule schedule = Schedule.from(requestDto, user, findWeatherData());
        Schedule savedSchedule = scheduleRepository.save(schedule);

        // 변경 전
        return savedSchedule.to();
        
        // 변경 후
        return ScheduleResponseDto.of(savedSchedule);

    }
}

 

외부 API 조회 기능 추가

 

Weather 외부 API를 사용하여 날씨 정보를 조회하는 기능을 추가했습니다.

package com.sparta.nuricalendaradvanced.common.api;

@Slf4j(topic = "WeatherApi")
@Service
public class WeatherApiService {

    private final RestTemplate restTemplate;

    public WeatherApiService(RestTemplateBuilder builder) {
        restTemplate = builder.build();
    }

    public List<WeatherDto> searchWeather() throws JsonProcessingException {
        URI uri = UriComponentsBuilder
                .fromUriString("https://f-api.github.io")
                .path("/f-api/weather.json")
                .encode()
                .build()
                .toUri();
        log.info("uri: " + uri);

        ResponseEntity<String> responseEntity = restTemplate.getForEntity(uri, String.class);
        log.info("statusCode: " + responseEntity.getStatusCode());
        log.info("Body: " + responseEntity.getBody());

        return fromJSONtoWeather(responseEntity.getBody());


    }


    public List<WeatherDto> fromJSONtoWeather(String responseEntity) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();

        return objectMapper.readValue(responseEntity, new TypeReference<List<WeatherDto>>() {
        });


    }

    public Optional<String> getWeatherByDate(List<WeatherDto> weatherDtoList, String date) {
        return weatherDtoList.stream()
                .filter(weatherDto -> weatherDto.getDate().equals(date))
                .map(WeatherDto::getWeather)
                .findFirst();
    }


}