본문 바로가기
개인 공부용/crud.jpa.api

Spring security + Custom exception + Redis + ...

by pon9 2024. 12. 2.

흠~ 거의 다 완성했다. 원래 바로 docker로 aws에 올려서 배포 테스트 해볼랬는데 OAuth 쪽도 관심이 생겨서 기능 확장을 생각중이다.

패키지 또한 엄청 길어졌다. 그리고 뭔가를 또 겁나 많이 추가했다. 하나씩 살펴보자

 

 

1. Spring security

@Configuration
@AllArgsConstructor
public class SecurityConfig {

    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    //유저권한 접근페이지 설정
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf((csrf) -> csrf.disable());

        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
                        .anyRequest().permitAll()
        );

        http.exceptionHandling(exception ->
                exception
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler)
        );

        return http.build();
    }

    //비번 bcrypt 암호화 적용
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

OAuth를 도입하면 권한설정이 좀 더 다채로워질건데 미리 접근권한에 관한 exception 처리를 해두고, 일단은 swagger 관련 페이지들만 permitall로 풀어놨다. 또다른 request들은 원래 authenticated로 막아놓는건데 테스트 단계라 permitall로 설정해뒀다.

 

비밀번호는 bcrypt 암호화 알고리즘으로 처리했다.

@Requestbody로 비밀번호만 받도록 하고 싶어서 비밀번호만 따로 requestdto를 만들고,

비밀번호를 포함한 값이 모두 필요한 경우를 생각해 combined로 묶어서 처리했다.

@RestController
@RequestMapping("/api/boards")
@RequiredArgsConstructor
public class BoardController {

    private final BoardService boardService;
    
    //update는 combined로 처리
    @PutMapping("/{id}")
    public ResponseEntity<BoardResponseDto> updateCount(@PathVariable Long id, @RequestBody BoardCombinedRequestDto boardCombinedRequestDto){
        return ResponseEntity.ok(boardService.updatePost(id, boardCombinedRequestDto));
    }
	
    //delete는 passwordrequest로만 처리
    @DeleteMapping("/{id}")
    public ResponseEntity<BoardResponseDto> deletePost(@PathVariable Long id, @RequestBody BoardPasswordRequestDto boardPasswordRequestDto){
        boardService.deletePost(id, boardPasswordRequestDto);
        return ResponseEntity.noContent().build();
    }
}

//service
if (!passwordEncoder.matches(boardPasswordRequestDto.getPassword(), boardDb.getPassword())) {
            throw new CustomException(ErrorCode.BAD_REQUEST);
        }

password값만 string으로 받아서 처리할 수도 있었으나 그렇게 하니 swagger에서 자꾸 password에 따옴표가 추가되어 나와서 분리해두는게 낫겠다 싶었다.

service계층에서 아래처럼 인코딩을 진행하며 같지 않을 시 exception을 반환한다.

 

 

2. Custom exception

@Getter
@AllArgsConstructor
public enum ErrorCode {

    BAD_REQUEST(HttpStatus.BAD_REQUEST, "비밀번호를 잘못 입력하였습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND, "404 에러: 없는 페이지입니다."),
    FORBIDDEN_OPERATION(HttpStatus.FORBIDDEN, "권한이 없습니다."),
    TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "429 에러: 천천히 하세요"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "500 에러: 서버 관리를 못해서 줴송합니다.."),
    BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "502 에러: 잘못된 접근입니다.");

    //USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 사용자입니다. 회원가입이 필요합니다."),
    //USER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "이미 존재하는 사용자입니다."),
    //LOGIN_FAILURE(HttpStatus.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다.");

    private final HttpStatus status;
    private final String message;
}

ErrorCode를 통해 상태 코드와 에러 메시지를 재사용 가능하게 관리한다. 에러코드를 enum으로 관리해서 각각의 예외처리에 커스텀 에러코드와 메세지를 부여했다. user에 관한 건 일단 만들어두고 주석처리 해뒀다.

HttpStatus 문서에서 이렇게 다양한 에러코드를 볼 수 있어서 원하는 걸 뽑아서 쓰면 된다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<Map<String, Object>> handleCustomException(CustomException e){
        ErrorCode errorCode = e.getErrorCode();
        return ResponseEntity.status(errorCode.getStatus())
                .body(Map.of(
                        "⛔: ", errorCode.getMessage(),
                        "에러코드: ", errorCode.getStatus()


                ));
    }
}

@Getter
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

}

 

globalexceptionhandler은 Spring Boot의 전역 예외 처리를 위한 핸들러로, 특정 예외 발생 시 공통적으로 처리할 로직을 정의한다.

@RestControllerAdvice는 @ControllerAdvice와 @ResponseBody를 합친것으로, Spring MVC에서 모든 컨트롤러에 전역적으로 영향을 미치는 예외 처리 핸들러로 작동한다. 예외 발생 시 JSON이나 HTTP 응답 형식으로 에러 메시지를 반환할 때 사용된다.

@ExceptionHandler(CustomException.class) 은 customexception 타입의 예외가 발생했을 때 실행될 메서드를 지정한다. Map.of로 에러 메세지와 상태 코드를 반환하는 키-값 쌍을 생성한다.

 

실행 흐름 : 비밀번호 잘못 입력함

1. 컨트롤러에서 비밀번호를 잘못 입력 시 throw new CustomException(errorCode)로 예외를 throw 한다.

2. @RestControllerAdvice로 선언된 GlobalExceptionHandler가 CustomException을 감지한다.
3. handleCustomException 메서드가 실행돼 적절한 HTTP 상태 코드와 메시지가 포함된 응답을 생성한다.
4. 클라이언트는 JSON 형태로 에러 메시지와 상태 코드를 전달받는다.

 

 

3. Redis Cache

@Configuration
@EnableCaching
public class CacheConfig {

    private final ObjectMapper objectMapper;

    public CacheConfig(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("localhost");
        config.setPort(6379);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // key -> string, value -> object
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // Redis와 연결 관리
        template.setConnectionFactory(redisConnectionFactory);
        // key를 string으로 직렬화
        template.setKeySerializer(new StringRedisSerializer());
        // value를 json으로 직렬화 / 역직렬화
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));

        // bean으로 등록
        return template;
    }

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { // Redis 서버와의 연결을 생성하는 데 사용되는 팩토리 클래스
        // 설정 구성
        // ObjectMapper : Java 객체를 JSON으로 직렬화하거나 JSON을 Java 객체로 역직렬화하는 데 사용
        ObjectMapper objectMapper = new ObjectMapper();
        // activateDefaultTyping : 객체가 직렬화될 때 해당 타입 정보를 JSON에 포함시킬 수 있고, 이를 이용해 역직렬화 할 때 어떤 클래스의 인스턴스인지 확인한다.
        objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

        // Redis를 이용해서 Spring Cache를 사용할 때 Redis 관련 설정을 모아두는 클래스
        RedisCacheConfiguration configuration = RedisCacheConfiguration
                .defaultCacheConfig()
                // null을 캐싱 할것인지
                .disableCachingNullValues()
                // 기본 캐시 유지 시간 (Time To Live)
                .entryTtl(Duration.ofSeconds(20))
                // 캐시를 구분하는 접두사 설정
                .computePrefixWith(CacheKeyPrefix.simple())
                // 캐시에 저장할 값을 어떻게 직렬화 / 역직렬화 할것인지
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))); // Value 직렬화
		
        // bean으로 등록
        return RedisCacheManager
                .builder(redisConnectionFactory)
                .cacheDefaults(configuration)
                .build();
    }

}

@Configuration
public class JacksonConfig {
	
    //timemodule(시간 설정)을 json으로 직렬화/역직렬화하고, format을 정함
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.setDateFormat(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        return objectMapper;
    }
}

대부분 주석을 달아놓았다. 아직까지 흐름이 헷갈리기 때문에.. 짧게 요약하면,


1. @EnableCaching 어노테이션으로 스프링 캐시를 활성화한다.

2. RedisConnectionFactory bean을 생성함으로써 redis 서버 연결 정보를 설정한다.(localhost - 포트 6379(기본포트))

3. key를 string 으로 직렬화하고, value는 json으로 직렬화/역직렬화 한다. 이에 jacksonconfig 클래스의 objectmapper가 사용된다.

4. ttl(캐시 만료 시간)은 20초로 지정한다. null값은 캐싱하지 않도록 하고, 단순 key 접두사를 통해 캐시를 구분한다. 

5. GenericJackson2JsonRedisSerializer를 사용하여 json 형식으로 직렬화한다.

 

일단 cache의 구성 흐름은 이러하고, 실제 사용되는 것을 살펴보자.

//read
    @Cacheable(value = "posts", key = "#id", unless="#result == null")
    public BoardResponseDto readPost(Long id) {
        BoardDb boardDb = boardRepository.findById(id)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));

        boardDb.updateCount(boardDb.getCount() + 1);
        boardRepository.save(boardDb);

        updateViewCountAsync(boardDb);
        return BoardMapper.toResponseDto(boardDb);
    }

//delete
    @CachePut(value = "posts", key = "#id", unless = "#result == null")
    @CacheEvict(value = "boards", allEntries = true)
    public void deletePost(Long id, BoardPasswordRequestDto boardPasswordRequestDto) {
        BoardDb boardDb = boardRepository.findById(id)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));
        if (!passwordEncoder.matches(boardPasswordRequestDto.getPassword(), boardDb.getPassword())) {
            throw new CustomException(ErrorCode.BAD_REQUEST);
        }
        boardRepository.deleteById(id);
    }

boardservice의 read / delete 기능이다.

@Cacheable(value = "posts", key = "#id", unless="#result == null")

@cacheable

캐시를 "posts" value로 저장하고, key값을 파라미터로 받은 "id"로 지정한다. 값이 null일 경우 결과를 캐싱하지 않는다.

만약 cache에 해당 id키가 존재한다면 redis server를 통해 캐시된 데이터를 반환하고, 메서드는 실행되지 않는다.

@CachePut(value = "posts", key = "#id", unless = "#result == null")
@CacheEvict(value = "boards", allEntries = true)

@cacheput

파라미터로 받은 "id"값을 key로 가지는 "posts"value의 캐시가 업데이트되면, 캐시를 업데이트한다. 마찬가지로 null일 경우 캐싱하지 않는다.

@cacheevict

이 어노테이션을 붙인 메서드가 실행되면, 지정된 캐시에서 데이터가 삭제된다. 이 코드에서는 boards value의 모든 캐시가 삭제된다.

 

실제 사용 흐름 : 같은 글을 두번 읽고, 삭제함

1. id값이 18인 글을 읽었다. 처음 읽는 글이라 20초간 value = posts key = 18로 캐시에 저장된다.

2. 18인 글을 한번더 조회시, 캐시를 사용해 메서드를 실행하거나 db와 상호작용하지 않는다.

3. 18인 글을 삭제 시 id가 18인 posts value 캐시를 업데이트한다. 삭제했으니 값이 null이므로 .. 엉?

그냥 cacheevict에 합쳐도 되려나? 내일 찾아봐야겠다.

4. 아무튼 삭제 시 value 가 board인 모든 캐시가 삭제된다.(board는 게시판 글 목록 페이징 데이터다.)

 

글을 쓰다가 뭔가 잘못된걸 알았다. 하하하 내일 찾아봐야지

 

 

4. Async, dynamicTTL

    @Async
    public void updateViewCountAsync(BoardDb boardDb) {
        boardDb.updateCount(boardDb.getCount() + 1);
        boardRepository.save(boardDb);
        cacheWithDynamicTTL(boardDb); // 비동기 처리
    }

    public void cacheWithDynamicTTL(BoardDb boardDb) {
        String cacheKey = "post: " + boardDb.getId();

        long viewThreshold = 100;   //조회수 임계값
        long likeThreshold = 30;    //좋아요 임계값
        Duration ttl = Duration.ofSeconds(20);  //기본 ttl

        if(boardDb.getCount() > viewThreshold || boardDb.getLiked() > likeThreshold){
            ttl = Duration.ofSeconds(180);  //hot data 는 ttl 3분으로 연장
        }
        redisTemplate.opsForValue().set(cacheKey, boardDb, ttl);    //캐시에 저장, ttl 설정
    }

게시물 조회수와 캐시 TTL을 동적으로 관리하는 비동기 메서드다. 캐시에 저장할 데이터를 조회수와 좋아요 수에 따라 TTL을 다르게 설정한다. 조회수가 100이 넘거나, 좋아요가 30이 넘으면 캐시를 180초간 저장한다.

 

메서드가 비동기로 실행되므로, 호출한 스레드는 작업 완료를 기다리지 않고 다음 작업을 진행할 수 있다. 예를 들어, 조회수 업데이트 작업이 끝날 때까지 클라이언트가 글을 읽기 위해 대기하지 않아도 된다.

조회수나 좋아요 수가 높은 hot data에 대해 TTL을 연장하여 캐시 히트를 높이고, Redis 서버의 부하를 줄일 수 있다.

 

 

너무 많은 걸 적용해서 과부하가 올 것 같은데 내일 또 더 적용할거다. 하하하하ㅏ하하하핳ㅎ oauth랑 aws까지만 하고 캠프 과제 시작하자.