본문 바로가기
dev/프로젝트

[프로젝트] Custom Exception & Redis

by dev-everyday 2025. 1. 19.
반응형

1. 피드백 반영

feedback > 포트폴리오에 쓰려면 깃허브 프로젝트 관리를 잘 하자.

1) fix, feat, refactor 로 표시하고 []를 쓰진 말기

2) fix는 코드 수정 시, feat는 없던 기능, refactor는 규모가 있는 코드의 수정 시 fix가 많아지거나 운영 레벨에서 이슈가 있으면 사용하자

3) 커밋 메세지는 웬만하면 영어로 쓰고

4) 몰아서 다 커밋하지 말고 짧게 기능별로 커밋하는 습관을 들여라

5) 관계 없는 애들은 따로따로 커밋하고 이를 습관화하자

result > 피드백 반영 후 새로 커밋 올리기로 했음, 최종적으로 feedback 전부 반영 후 TODO.

 

feedback > 도메인 분리하는 것까진 맞는데 도메인 별로 controller, service, dto, entity 다 분리해라.

result > 수정함

 

feedback > controller에 oauthdto라고 return하는 부분을 requestEntity와 responseEntity로 감싸서 주도록 바꾸자. http 요청과 응답은 header와 body로 이루어져있는데 header에는 metadata 주고 받고 상태 코드나 인증값 등을 포함하고 body에는 데이터가 담긴다. header에 대한 상태값을 body에 넣을 수 없어서 ResponseEntity<OauthDto>로 감싸서 status를 설정해야 하는데 ResponseEntity.status(200).body(oauthService.createOauth(dto)); 이런 식으로 다 바꾸자. 보통 ResponseEntity를 많이 쓴다.

result > ResponseEntity로 감싸서 response 주도록 수정함. ResponseEntity의 역할은 HTTP 응답을 보낼 때 상태 코드, 헤더, 바디를 포함할 수 있고 ResponseEntity.ok(oauthService.createOauth(dto))로 간결하게 바꿨다. 상태 코드는 헤더 상태 코드로 설정하고 @RequestBody를 쓰고 있어서 RequestEntity로 감싸지 않아도 된다. 현재 이제 요청 header에서 정보를 추출하는 게 없어서 냅두기로 함.

@PostMapping
public ResponseEntity<OauthDto> createOauth(@RequestBody OauthDto dto) {
    return ResponseEntity.ok(oauthService.createOauth(dto));
}

 

fix > 기존 Entity들 수정하였음.

- User와 Oauth 따로 분리하지 않고 통합해서 관리

- role 부분은 현재는 따로 테이블 분리하지 않고 그냥 컬럼으로 관리하도록 했고 기본은 User로 설정

package com.fortune.app.user.dto;

import com.fortune.app.user.entity.User;
import lombok.*;

import java.time.LocalDateTime;
import java.util.Date;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDto {
    // User
    private Long userId;
    private String name;
    private Date birth;
    private String nickname;
    // Oauth
    private String provider;
    private String providerUid;
    private String accessToken;
    private String refreshToken;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private String createdBy;
    private String updatedBy;

    public static UserDto mapToDto(User entity) {
        return UserDto.builder()
                .userId(entity.getUserId())
                .name(entity.getName())
                .birth(entity.getBirth())
                .nickname(entity.getNickname())
                .provider(entity.getProvider())
                .providerUid(entity.getProviderUid())
                .accessToken(entity.getAccessToken())
                .refreshToken(entity.getRefreshToken())
                .createdAt(entity.getCreatedAt())
                .build();
    }
}

UserDto와 OauthDto를 통합하였다.

package com.fortune.app.user.dto;

import com.fortune.app.user.entity.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Date;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserRequestDto {
    private String name;
    private Date birth;
    private String nickname;
    private String provider;
    private String providerUid;
    private String accessToken;
    private String refreshToken;

    public static UserRequestDto mapToDto(User entity) {
        return UserRequestDto.builder()
                .name(entity.getName())
                .birth(entity.getBirth())
                .nickname(entity.getNickname())
                .provider(entity.getProvider())
                .providerUid(entity.getProviderUid())
                .accessToken(entity.getAccessToken())
                .refreshToken(entity.getRefreshToken())
                .build();
    }
}

USerRequestDto의 경우에는 update, create 모두 동일한 형식을 사용하고 id는 client에서 전달할 것이라 우선은 id를 제외하고 createdAt, updatedAt, createdBy, updatedBy를 제외하고 생성하였다.

다시 정의한 ERD

fix > 기존에 updateCardLog에서 setUpdated 후 .save를 호출하였는데 이미 dirty checking으로 관리되고 있기 때문에 한 번 더 save를 호출할 필요가 없어서 삭제함.

@Transactional
public CardLogDto updateCardLog(Long cardLogId, CardLogRequestDto dto) {
    CardLog cardLog = cardLogRepository.findById(cardLogId)
            .orElseThrow(() -> new RuntimeException("CardLog not found"));

    Optional.ofNullable(dto.getCardInterpretationId())
            .map(id -> cardInterpretationRepository.findById(id)
                    .orElseThrow(() -> new RuntimeException("CardInterpretation not found")))
            .ifPresent(cardLog::setCardInterpretation);

    Optional.ofNullable(dto.getUserId())
            .map(id -> userRepository.findById(id)
                    .orElseThrow(() -> new RuntimeException("User not found")))
            .ifPresent(cardLog::setUser);

    cardLog.setUpdatedAt(LocalDateTime.now());

    return CardLogDto.mapToDto(cardLog);
}

예시는 CardLog에서 cardLog를 조회하는데 이 때 cardLog Entity가 영속 상태가 된다. 그리고 setUpdatedAt을 하는 순간 dirty checking을 통해서 해당 사항이 DB에 반영된다.

 

fix > delete의 경우 기존에는 findById를 통해서 가져오고 present인 경우 delete하게 하였는데 이 경우에 delete와 select 쿼리 둘 다 작동하는 문제가 있음, 그래서  이를 보완하기 위해서 existsById를 통해서 필요한 값만 가져오도록 하였고 @Transactional의 경우 관련된 cascade delete가 없어서 삭제하였다.

public void deleteCardLog(Long cardLogId) {
    cardLogRepository.findById(cardLogId)
            .ifPresentOrElse(
                    cardLogRepository::delete,
                    () -> {
                        throw new RuntimeException("CardLog not found");
                    }
            );
}
public void deleteCardLog(Long cardLogId) {
    if (!cardLogRepository.existsById(cardLogId)) {
        throw new RuntimeException("CardLog not found");
    }

    cardLogRepository.deleteById(cardLogId);
}

순서대로 전/후 비교이다.

 

fix > Optional.ofNullable 응용하기. 

if (dto.getName() != null) card.setName(dto.getName()); 값이 null이 아닐 때만 setName() 실행 직관적이고 익숙함 중복된 코드 (dto.getName() 2번 호출)
Optional.ofNullable(dto.getName()).ifPresent(card::setName); dto.getName()이 null이 아닐 때만 실행 null 체크를 간결하게 처리 가능 Optional을 이해해야 함

물론 Optional 객체를 생성하는 추가적인 메모리 오버헤드가 발생할 수 있지만 null을 직접 다루지 않고 안전하게 처리가 가능하다는 장점이 있다.

Optional은 Java 8부터 추가된 null을 안전하게 처리하는 Wrapper 클래스로 null이 아닐 경우 실행할 동작을 지정할 수 있다. ofNullable은 value가 null이 아니면 Optional에 감싸서 반환하고 null이면 비어 있는 Optional을 반환한다.

 

todo> 나중에 토큰 쪽 구현할 때 토큰은 RDBMS에 저장 안하고 캐싱해서 레디스를 응용해보자. 만료 date를 refreshToken이랑 같은 주기로 설정하자.

2. DTO Validate & 검증 그룹

@Body에 들어 있는 값을 controller에서 체크하지 않고 DTO에서 @NotNull을 쓰면 되는데 Spring Boot가 너무 최신이라 @NotNull이 deprecated되버렸다.

Spring Boot 최신 버전에서는 Javax Validation API(javax.validation.constraints.NotNull)가 Jakarta Validation API(jakarta.validation.constraints.NotNull)로 변경되었다고 한다.

아래 코드를 build.gradle에 추가해주도록 하자.

    implementation("org.springframework.boot:spring-boot-starter-validation")

@NotNull을 쓸 때 @Valid를 함께 써야 유효성 검사가 적용된다.

DTO에서는 @NotNull을 Controller에서는 @Valid를 적용해주자.

 

a) DTO 수정사항

package com.fortune.app.card.dto;

import com.fortune.app.card.entity.CardInterpretation;
import jakarta.validation.constraints.NotNull;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CardInterpretationDto {
    @NotNull(message = "interpretationId is required.")
    private Long interpretationId;
    @NotNull(message = "cardId is required.")
    private Long cardId;
    @NotNull(message = "fortuneId is required.")
    private Long fortuneId;
    @NotNull(message = "interpretationContent is required.")
    private String interpretationContent;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Long createdBy;
    private Long updatedBy;

    public static CardInterpretationDto mapToDto(CardInterpretation entity) {
        return CardInterpretationDto.builder()
                .interpretationId(entity.getInterpretationId())
                .cardId(entity.getCard().getCardId())
                .fortuneId(entity.getFortune().getFortuneId())
                .interpretationContent(entity.getInterpretationContent())
                .createdAt(entity.getCreatedAt())
                .updatedAt(entity.getUpdatedAt())
                .createdBy(entity.getCreatedBy())
                .updatedBy(entity.getUpdatedBy())
                .build();
    }
}

b) Entity 수정사항

package com.fortune.app.card.entity;

import com.fortune.app.card.dto.CardDto;
import com.fortune.app.enums.Orientation;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "card")
//@Getter
//@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Card {
    @Id
    @Column(name = "card_id", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long cardId;

    @Column(name = "name", nullable = false)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(name = "orientation", nullable = false)
    private Orientation orientation;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Long createdBy;
    private Long updatedBy;

    @PrePersist
    public void prePersist() {
        this.createdAt = (this.createdAt == null) ? LocalDateTime.now() : this.createdAt;
        this.createdBy = (this.createdBy == null) ? 1 : this.createdBy;
    }

    public static Card mapToEntity(CardDto dto) {
        return Card.builder()
                .cardId(dto.getCardId())
                .name(dto.getName())
                .orientation(dto.getOrientation())
                .createdAt(dto.getCreatedAt())
                .updatedAt(dto.getUpdatedAt())
                .createdBy(dto.getCreatedBy())
                .updatedBy(dto.getUpdatedBy())
                .build();
    }

    public Long getCardId() {
        return cardId;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Orientation getOrientation() {
        return orientation;
    }

    public void setOrientation(Orientation orientation) {
        this.orientation = orientation;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return updatedAt;
    }

    public void setUpdatedAt(LocalDateTime updatedAt) {
        this.updatedAt = updatedAt;
    }

    public Long getUpdatedBy() {
        return updatedBy;
    }

    public void setUpdatedBy(Long updatedBy) {
        this.updatedBy = updatedBy;
    }

    public Long getCreatedBy() {
        return createdBy;
    }

}

c) Controller 수정사항

@PatchMapping("/{id}")
public ResponseEntity<UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UserRequestDto dto) {
    UserDto userDto = userService.updateUser(id, dto);
    return ResponseEntity.status(HttpStatus.OK).body(userDto);
}

@Validated는 우선은 단순 DTO 유효성 검사만 제공하려고 해서 @Valid만 사용하려고 한다.

@Pattern을 사용하면 정규식 형식으로 지정도 가능하다.

 

validate 범위를 설정하기 위해 검증 그룹을 정의해보자.

검증 그룹이 필요한 이유는 예를 들어 User를 생성할 때와 로그인 할 경우 유효성 검사가 다를 수 있기 때문에 @NotNull과 같은 유효성 검사는 모든 상황에 적용되지만 회원 가입과 로그인에서 다르게 검증을 원하면 검증 그룹을 사용하면 된다.

DTO에서는 @NotNull 설정을 하고 Controller에서는 @Validated한다고 생각하자.

@NotNull은 일회성 검증에만 사용하기 때문에 DTO에서만 쓰고 DB 엔티티에서는 쓰지 않는다.

검증 그룹은 각 도메인 별로 validation 패키지를 만들어서 관리하려고 한다.

package com.fortune.app.user.validation;

public class UserValidationGroups {
    public interface SignUpGroup {
    }

    public interface LogInGroup {
    }
}

UserRequestDto에서 해당 groups를 설정해보자.

회원가입 시는 전부 필요하고 로그인 시에는 provider, providerUid, accessToken만 필수라고 할 때 아래와 같다.

검증 그룹을 사용하려면 @Valid가 아닌 @Validated를 사용해야 한다.

@Valid는 jakarta.validation 기반 유효성 검사로 검증 그룹을 지원하지 않지만 @Validated는 Spring AOP 기반 유효성 검사로 검증 그룹을 지원하기 때문이다.

todo > 빨리 Oauth 붙이자, token에 대한 처리는 SpringSecurity를 쓰거나 session에 넣어놓고 관리하거나 해야할 거 같다.

package com.fortune.app.user.dto;

import com.fortune.app.user.entity.User;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import static com.fortune.app.user.validation.UserValidationGroups.SignUpGroup;
import static com.fortune.app.user.validation.UserValidationGroups.LogInGroup;

import java.util.Date;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserRequestDto {
    @NotNull(groups = SignUpGroup.class, message = "name is required.")
    private String name;

    @NotNull(groups = SignUpGroup.class, message = "nickname is required.")
    private String nickname;

    @NotNull(groups = SignUpGroup.class, message = "birth is required.")
    private Date birth;

    @NotNull(groups = {LogInGroup.class, SignUpGroup.class}, message = "provider is required.")
    private String provider;

    @NotNull(groups = {LogInGroup.class, SignUpGroup.class}, message = "providerUid is required.")
    private String providerUid;

    @NotNull(groups = {LogInGroup.class, SignUpGroup.class}, message = "accessToken is required.")
    private String accessToken;

    @NotNull(groups = SignUpGroup.class, message = "refreshToken is required.")
    private String refreshToken;

    public static UserRequestDto mapToDto(User entity) {
        return UserRequestDto.builder()
                .name(entity.getName())
                .birth(entity.getBirth())
                .nickname(entity.getNickname())
                .provider(entity.getProvider())
                .providerUid(entity.getProviderUid())
                .accessToken(entity.getAccessToken())
                .refreshToken(entity.getRefreshToken())
                .build();
    }
}
@MethodTimeCheck
@PostMapping("/sign-up")
public ResponseEntity<UserDto> createUser(@Validated(SignUpGroup.class) @RequestBody UserRequestDto dto) {
    return ResponseEntity.status(HttpStatus.CREATED).body(userService.signUp(dto));
}

Controller에 @Validated로 잘 처리만 해줘도 validation 과정이 줄어든다.

3. Custom Exception

common 하위에 GlobalExceptionHandler를 생성하자.

@RestControllerAdvice를 달아주는데 모든 @RestController에서 발생하는 Exception을 잡아주겠다는 의미이다.

 

마음대로 error를 작성하지 말고 fieldErrors 처리를 하면 예쁘게 에러를 확인할 수도 있다.

에러 메세지는 "이메일이 없어용" 이런 식으로 주면 안 된다.

해커들에게 해킹하세요 하는 것과 같으므로 두루뭉술하면서도 확실하게 줘야 한다.

여러 메세지 종류들도 Enum들로 정의하는 버릇을 들이자.

package com.fortune.app.enums;

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

@Getter
public enum ErrorCode {
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "요청하신 정보를 확인해주세요."),
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
    FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다.");

    private final HttpStatus status;
    private final String message;

    ErrorCode(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }
}

해당 파일에 필요한 Exception들을 정의해서 나가면 된다.

서비스 레벨에서 Exception try-catch로 묶는 것도 좋지만 처리하지 못하는 상황일 경우 추가적으로 작성하는 게 좋다.

 

또한 Custom ErrorResponse 객체를 반환하여 일관된 에러 응답을 제공하도록 class ErrorResponse를 정의하였다.

import com.fortune.app.enums.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter
@AllArgsConstructor
public class ErrorResponse {
    private final String message;
    private final List<String> errors; 

    public static ErrorResponse of(ErrorCode errorCode, List<String> errors) {
        return new ErrorResponse(errorCode.getMessage(), errors);
    }
}
더보기

1. 정적 팩토리 메서드

정적 팩토리 메서드 패턴은 개발자가 구성한 static method를 통해 간접적으로 생성자를 호출하는 객체를 생성하는 디자인 패턴이다. 간접적으로 객체 생성 역할을 하는 클래스 메서드를 통해 객체 생성을 유도하는 방식이다.

 

2. 생성자 < 정적 팩토리 ?

정적 팩토리 메서드는 생성 목적에 대한 이름 표현이 가능하다. 생성자 대신 정적 팩토리 메서드를 호출하면 생성될 객체의 특성에 대해 쉽게 묘사할 수 있다. 또한 메서드를 통해 한 단계 거쳐서 생성하기 때문에 인스턴스에 대한 통제 및 관리가 가능하다.

 

3. 네이밍 규칙

from : 하나의 매개 변수를 받아 객체 생성

of : 여러 개의 매개 변수를 받아 객체 생성

getInstance : 인스턴스 생성, 이전에 반환한 것과 같을 수 있음

newInstance : 항상 새로운 인스턴스 생성

 

4.  그래서 이걸 어디에서 써요?

Optional<Integer> value = Optional.of(1000);
List<Integer> list = List.of(1, 2, 3);

아래는 GlobalExceptionHandler 코드로 Exception을 처리하는 것을 정의하였고 ErrorResponse를 사용하였다.

또한 여기서 getField를 통해서 에러를 좀 더 보기 좋게 하였다.

package com.fortune.app.common.exception;

import com.fortune.app.enums.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.http.HttpStatus;

import java.util.List;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + " 필드를 확인해주세요.")
                .collect(Collectors.toList());

        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, errors));
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, List.of(e.getMessage())));
    }
}

 

그리고 이제 비즈니스적 예외를 정의하기 위해 throw할 CustomException과 CustomExceptionHandler를 정의하였다.

package com.fortune.app.common.exception;

import com.fortune.app.enums.ErrorCode;
import lombok.Getter;

@Getter
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}
package com.fortune.app.common.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;

@RestControllerAdvice
public class CustomExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
        return ResponseEntity.status(e.getErrorCode().getStatus())
                .body(ErrorResponse.of(e.getErrorCode(), List.of(e.getMessage())));
    }
}

 

그리고 추가한 CustomException을 throw하는 UserService의 signUp 메서드는 아래와 같다.

public UserDto signUp(UserRequestDto dto) {
    if (userRepository.existsByEmail(dto.getEmail())) {
        throw new CustomException(ErrorCode.HAS_EMAIL);
    }

    if (userRepository.existsByNickname(dto.getNickname())) {
        throw new CustomException(ErrorCode.HAS_NICKNAME);
    }

    User user = User.builder()
            .name(dto.getName())
            .birth(dto.getBirth())
            .email(dto.getEmail())
            .nickname(dto.getNickname())
            .provider(dto.getProvider())
            .providerUid(dto.getProviderUid())
            .accessToken(dto.getAccessToken())
            .refreshToken(dto.getRefreshToken())
            .build();
    User savedUser = userRepository.save(user);

    return UserDto.mapToDto(savedUser);
}

 

4. Redis 보완

기존에 Redis를 사용한 방식은 아래와 같다.

// redis 적용
public UserDto getUser(Long userId) {
    String redisKey = "user:" + userId;
    Object cachedUser = redisTemplate.opsForValue().get(redisKey);
    if (cachedUser != null) {
        System.out.println("Redis에서 데이터 조회");

        // LinkedHashMap을 UserResponseDto로 변환
        UserDto userDto = objectMapper.convertValue(cachedUser, UserDto.class);
        return userDto;
    }
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("User not found"));
    redisTemplate.opsForValue().set(redisKey, UserDto.mapToDto(user));
    return UserDto.mapToDto(user);
}

이렇게 사용하지 말고 annotation을 활용해서 Redis를 사용해보자.

 

@Transactional(readOnly = true) 어노테이션은 repository를 통해 가져온 데이터를 JPA가 dirty checking 하는데 영속성 컨텍스트에 올려서 CPU 메모리를 잡아 먹는다.

readOnly = true로 설정하면 dirty checking도 하지 않고 영속성 컨텍스트에 엔티티를 저장하지 않아서 select만 해온다면 최적화에 도움이 된다.

결론: @Transactional(readOnly = true)는 오히려 성능을 최적화하며, 불필요한 dirty checking을 방지하기 때문에 CPU/메모리 사용을 줄인다.

 

먼저 @Cacheable(value = "user", key = "#userId" + '_' + "getUser", cacheManager = "redisCacheManager") 어노테이션을 달아준다.

#하고 파라미터로 받은 것을 설정할 수도 있고 클래스명이나 메서드명을 설정할 수도 있다.

클래스명을 쓰고 싶다면 key = "#root.className" 이런 식으로 쓰면 된다.

기본적으로 "user::" 콜론이 두 개 붙는다.

데이터에 대한 key는 겹치지 않도록 단순 userId로 설정하는 것보다 다른 것과 합쳐서 설정하는 게 좋다.

redisTemplate은 key 값과 value 값을 직렬화하는 방식을 정의한다.

 

Spring Cache(@Cacheable, @CachePut, @CacheEvict)와 Redis Key 관리

Spring Cache 어노테이션을 활용해서 CRUD에서 캐싱 관리가 가능하다.

Redis Key Prefix 설정을 통해서 조직별, 도메인별로 관리가 가능하다.

 

또한 Redis Key Prefix를 위해 application.properties에 아래 코드를 추가하자.

spring.cache.redis.use-key-prefix=true
spring.cache.redis.key-prefix=myapp
spring.cache.redis.cache-null-values=false

Redis Key Prefix로 나는 내 github 닉네임을 설정하였고 세 번째 설정의 경우 캐싱된 값이 null인 경우 저장하지 않도록 설정하였다.

이제 예시로는 myapp:user_getUser_1 이런 식으로 저장이 된다.

 

@Cacheable 어노테이션을 사용하기 앞서 RedisConfig를 수정해야한다.

RedisConfig에 @EnableCaching을 추가하여 Spring Cache를 지원하게 하였고 캐시 TTL(유효기간)을 설정하였다.

package com.fortune.app.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
@EnableCaching // ✅ Spring Cache 사용 가능하도록 설정
public class RedisConfig {

    private ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule()); // JavaTimeModule 등록하여 LocalDateTime 지원
        return objectMapper;
    }

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)) // ✅ 캐시 TTL (10분)
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())));

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(cacheConfig)
                .build();
    }
}

 

순서대로 CRUD 캐시 적용한 코드이다.

@CacheEvict(value = "userList", key = "'user_list'")
@CachePut(value = "user", key = "'user_' + #result.id")
public UserDto signUp(UserRequestDto dto) {
    if (userRepository.existsByEmail(dto.getEmail())) {
        throw new CustomException(ErrorCode.HAS_EMAIL);
    }

    if (userRepository.existsByNickname(dto.getNickname())) {
        throw new CustomException(ErrorCode.HAS_NICKNAME);
    }

    User user = User.builder()
            .name(dto.getName())
            .birth(dto.getBirth())
            .email(dto.getEmail())
            .nickname(dto.getNickname())
            .provider(dto.getProvider())
            .providerUid(dto.getProviderUid())
            .accessToken(dto.getAccessToken())
            .refreshToken(dto.getRefreshToken())
            .build();
    User savedUser = userRepository.save(user);

    return UserDto.mapToDto(savedUser);
}
@Transactional(readOnly = true)
@Cacheable(value = "user", key = "'user_' + #root.methodName + '_' + #userId")
public UserDto getUser(Long userId) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("User not found"));
    return UserDto.mapToDto(user);
}
@Transactional
@CacheEvict(value = "user", key = "'user_' + #root.methodName + '_' + #userId")
@CachePut(value = "user", key = "'user_' + #root.methodName + '_' + #userId")
public UserDto updateUser(Long userId, UserRequestDto dto) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

    if (dto.getEmail() != null && !dto.getEmail().equals(user.getEmail()) &&
            userRepository.existsByEmail(dto.getEmail())) {
        throw new CustomException(ErrorCode.HAS_EMAIL);
    }

    if (dto.getNickname() != null && !dto.getNickname().equals(user.getNickname()) &&
            userRepository.existsByNickname(dto.getNickname())) {
        throw new CustomException(ErrorCode.HAS_NICKNAME);
    }

    Optional.ofNullable(dto.getName()).ifPresent(user::setName);
    Optional.ofNullable(dto.getBirth()).ifPresent(user::setBirth);
    Optional.ofNullable(dto.getEmail()).ifPresent(user::setEmail);
    Optional.ofNullable(dto.getNickname()).ifPresent(user::setNickname);
    Optional.ofNullable(dto.getProvider()).ifPresent(user::setProvider);
    Optional.ofNullable(dto.getProviderUid()).ifPresent(user::setProviderUid);
    Optional.ofNullable(dto.getAccessToken()).ifPresent(user::setAccessToken);
    Optional.ofNullable(dto.getRefreshToken()).ifPresent(user::setRefreshToken);

    user.setUpdatedAt(LocalDateTime.now());

    return UserDto.mapToDto(user);
}
@Transactional
@Caching(evict = {
        @CacheEvict(value = "user", key = "'user_' + #root.methodName + '_' + #userId")
        @CacheEvict(value = "userList", key = "'user_list'", allEntries = true)
})
public void deleteUser(Long userId) {
    if (!userRepository.existsById(userId)) {
        throw new RuntimeException("User not found");
    }

    userRepository.deleteById(userId);
    clearUserCache(userId);
}

 

그리고 updateUserFields와 validateUserFields로 리팩토링하여 complexity를 줄였다.

private void updateUserFields(User user, UserRequestDto dto) {
    Optional.ofNullable(dto.getName()).ifPresent(user::setName);
    Optional.ofNullable(dto.getBirth()).ifPresent(user::setBirth);
    Optional.ofNullable(dto.getEmail()).ifPresent(user::setEmail);
    Optional.ofNullable(dto.getNickname()).ifPresent(user::setNickname);
    Optional.ofNullable(dto.getProvider()).ifPresent(user::setProvider);
    Optional.ofNullable(dto.getProviderUid()).ifPresent(user::setProviderUid);
    Optional.ofNullable(dto.getAccessToken()).ifPresent(user::setAccessToken);
    Optional.ofNullable(dto.getRefreshToken()).ifPresent(user::setRefreshToken);

    user.setUpdatedAt(LocalDateTime.now());
}

private void validateUserFields(UserRequestDto dto, User user) {
    if (dto.getEmail() != null && !dto.getEmail().equals(user.getEmail()) &&
            userRepository.existsByEmail(dto.getEmail())) {
        throw new CustomException(ErrorCode.HAS_EMAIL);
    }

    if (dto.getNickname() != null && !dto.getNickname().equals(user.getNickname()) &&
            userRepository.existsByNickname(dto.getNickname())) {
        throw new CustomException(ErrorCode.HAS_NICKNAME);
    }
}

 

Redis Pub/Sub은 무엇인가

서버가 WebSocket으로 클라이언트와 연결하고 Redis Pub/Sub을 사용해 여러 서버 간 메시지를 실시간으로 공유하는 구조를 웹소켓 + Redis로 설정할 수 있다.

멀티 서버 환경에서 실시간 채팅을 유지하려면 Redis가 필요하고 하나의 서버에서만 WebSocket을 관리하면 부하가 커지고 여러 서버가 메시지를 공유하지 못 한다.

Redis를 사용해 여러 서버가 메시지를 공유하면 안정적으로 운영할 수 있고 채팅방 참여자가 많아도 서버 부하를 줄일 수 있다.

클라이언트가 채팅방에 들어오면 Redis를 구독하고 메시지가 들어오면 Redis가 발행하고 모든 구독자가 해당 메시지를 받는다.

더보기

🚀 3️⃣ Redis Pub/Sub + WebSocket 흐름

📌 예제: 1번 유저가 채팅방을 만들고, 다른 유저와 실시간 채팅을 하는 과정

1️⃣ 1번 유저가 채팅방을 만듦

  • 서버(WebSocket)가 소켓 공간을 생성 (/chat/{roomId})
  • Redis와 연결하여 채팅방을 Pub/Sub 채널로 등록

2️⃣ 1번 유저가 채팅방에 입장

  • 서버(WebSocket)가 유저를 채팅방 Redis 채널에 Subscribe(구독)

3️⃣ 다른 유저가 메시지를 보냄

  • 서버(WebSocket)가 메시지를 Redis에 Publish(발행)
  • Redis가 해당 채널을 구독한 모든 서버(WebSocket)에 메시지를 전송

4️⃣ 채팅방의 모든 참여자가 메시지를 실시간으로 받음

  • 각 서버(WebSocket)가 Redis에서 Subscribe(구독)한 메시지를 받아서 클라이언트에 전송

결과: WebSocket을 통해 클라이언트와 실시간 연결을 유지하고, Redis Pub/Sub을 사용하여 여러 서버 간 메시지를 동기화하여 실시간 채팅을 구현할 수 있음!

 

@Component
@RequiredArgsConstructor
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private final RedisPublisher redisPublisher; // Redis에 메시지 발행
    private final RedisSubscriber redisSubscriber; // Redis 메시지 구독

    private static final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        String userId = session.getId();
        sessions.put(userId, session);
        System.out.println("WebSocket 연결됨: " + userId);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        String payload = message.getPayload();
        redisPublisher.publish("chatRoom", payload); // ✅ 메시지를 Redis에 발행 (Pub)
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        sessions.remove(session.getId());
        System.out.println("WebSocket 연결 종료됨: " + session.getId());
    }
}

유저가 채팅방에 접속하면 WebSocket 연결
유저가 메시지를 보내면 Redis에 Publish

 

@Component
@RequiredArgsConstructor
public class RedisPublisher {

    private final StringRedisTemplate redisTemplate;

    public void publish(String channel, String message) {
        redisTemplate.convertAndSend(channel, message); // ✅ Redis 채널에 메시지 발행
    }
}

WebSocket이 메시지를 받으면 Redis Pub/Sub 채널에 메시지를 보냄

 

@Component
@RequiredArgsConstructor
public class RedisSubscriber {

    private final ObjectMapper objectMapper;

    @EventListener
    public void onMessage(Message message, byte[] pattern) {
        String payload = new String(message.getBody(), StandardCharsets.UTF_8);
        System.out.println("Redis에서 메시지 수신: " + payload);
        
        // ✅ WebSocket을 통해 모든 유저에게 메시지 전달
        for (WebSocketSession session : ChatWebSocketHandler.sessions.values()) {
            try {
                session.sendMessage(new TextMessage(payload));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

Redis에서 메시지를 수신하면 WebSocket을 통해 모든 클라이언트에 전송

 

@Configuration
public class RedisConfig {

    @Bean
    public RedisMessageListenerContainer redisContainer(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

Redis Pub/Sub을 설정하여 서버가 메시지를 구독할 수 있도록 구성

5. 카프카(kafka)

카프카는 분산 메시징 시스템으로 대량의 데이터를 빠르고 안정적으로 처리하는 데 사용된다.

데이터 스트리밍, 실시간 로그 처리, 마이크로서비스 간 메세지 큐 역할을 수행한다.

즉, 고성능&분산형&실시간 데이터 스트리밍 플랫폼이라 할 수 있다.

Pub/Sub 모델을 기반으로 주고 받고 마이크로 서비스 간 비동기 통신 및 이벤트 기반 아키텍처에 많이 사용된다.

실시간 로그 수집 및 분석, 마이크로서비스 간 메시지 큐 역할, 실시간 이벤트 스트리밍(주문 처리, 결제) 등을 구현할 수 있다.

카프카는 Productor, Broker, Topic, Consumer로 구성되는데 Productor는 데이터를 카프카로 보내는 역할을 한다.

Broker는 카프카 클러스터에서 실행되는 서버 역할로 Producer가 보낸 데이터를 저장하고 Consumer에게 전달한다.

여러 개의 브로커로 구성되어 분산 저장이 가능하다.

토픽은 데이터를 저장하는 논리적 단위이고 Consumer는 카프카에서 데이터를 읽는 역할로 특정 topic을 구독하고 새로운 메시지를 가져 간다.

핵심 개념은 파티션, 오프셋이 있는데 파티션은 topic을 여러 개의 파티션으로 분할해 병렬 처리가 가능하게 한다. 각 Partition은 여러 개의 카프카 브로커에 분산 저장되고 Consumer 그룹이 여러 개의 partition을 동시에 처리 가능하다.

오프셋은 consumer가 마지막으로 읽은 메시지의 위치를 저장하는 값으로 중복 처리 없이 데이터를 가져갈 수 있다.

카프카 vs 레디스 pub/sub

카프카는 메시지를 저장하면서 스트리밍을 지원하고 Redis는 실시간 Pub/Sub을 제공하며 메시지를 저장하지 않는다.

로컬에서 테스트하려면 사양이 좋아야 해서 적용은 일단은 안 할 예정이다.

 

6. MyBatis

현재 JPA + Hibernate로 구현된 코드를 MyBatis로 변경하는 과정은 어떨까?

JPA에서 MyBatis로 변경 시 직접 SQL을 작성해야하고 Dirty Checking과 DDL 자동 생성은 지원하지 않는다.

우선 build.gradle에 MyBatis를 추가한다.

dependencies {
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.1'
}

그리고 application.properties에 MyBatis TypeAlias 설정 및 위치를 설치한다.

spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

mybatis.mapper-locations=classpath*:mapper/*.xml

mybatis.type-aliases-package=com.fortune.app.user.dto

DTO와 VO를 자동으로 매핑할 수 있도록 type-aliases-package를 지정한다.

mapper/*.xml 경로엔 MyBatis XML Mapper 파일을 위치하도록 설정한다.

 

기존 JP Repository 제거 후 MyBatis Mapper 인터페이스를 생성한다.

@Mapper
public interface UserMapper {

    UserDto getUserById(@Param("id") Long id);
    boolean existsByEmail(@Param("email") String email);
    boolean existsByNickname(@Param("nickname") String nickname);

    void insertUser(UserDto userDto);
    void updateUser(UserDto userDto); 
    void deleteUser(@Param("id") Long id); 
}

 

MyBatis XML Mapper 파일을 작성하는데 resources/mapper/UserMapper.xml 로 생성한다.

MyBatis는 UserMapper 인터페이스와 1:1 매칭되는 XML 파일에서 SQL을 관리한다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.fortune.app.user.mapper.UserMapper">

    <select id="getUserById" resultType="com.fortune.app.user.dto.UserDto">
        SELECT * FROM users WHERE id = #{id}
    </select>

    <select id="existsByEmail" resultType="boolean">
        SELECT COUNT(*) > 0 FROM users WHERE email = #{email}
    </select>

    <select id="existsByNickname" resultType="boolean">
        SELECT COUNT(*) > 0 FROM users WHERE nickname = #{nickname}
    </select>

    <insert id="insertUser">
        INSERT INTO users (name, birth, email, nickname, provider, provider_uid, access_token, refresh_token, created_at, updated_at)
        VALUES (#{name}, #{birth}, #{email}, #{nickname}, #{provider}, #{providerUid}, #{accessToken}, #{refreshToken}, NOW(), NOW())
    </insert>

    <update id="updateUser">
        UPDATE users
        SET name = #{name},
            birth = #{birth},
            email = #{email},
            nickname = #{nickname},
            provider = #{provider},
            provider_uid = #{providerUid},
            access_token = #{accessToken},
            refresh_token = #{refreshToken},
            updated_at = NOW()
        WHERE id = #{id}
    </update>

    <delete id="deleteUser">
        DELETE FROM users WHERE id = #{id}
    </delete>

</mapper>

그리고 기존 UserService에 MyBatis를 적용하는데 JPA를 UserMapper로 교체한다.

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserMapper userMapper;

    public UserDto getUser(Long userId) {
        UserDto user = userMapper.getUserById(userId);
        if (user == null) {
            throw new CustomException(ErrorCode.USER_NOT_FOUND);
        }
        return user;
    }

    public void signUp(UserDto userDto) {
        if (userMapper.existsByEmail(userDto.getEmail())) {
            throw new CustomException(ErrorCode.HAS_EMAIL);
        }
        if (userMapper.existsByNickname(userDto.getNickname())) {
            throw new CustomException(ErrorCode.HAS_NICKNAME);
        }
        userMapper.insertUser(userDto);
    }

    public void updateUser(UserDto userDto) {
        userMapper.updateUser(userDto);
    }

    public void deleteUser(Long userId) {
        userMapper.deleteUser(userId);
    }
}

 

7. TODO 및 다음 멘토링

- Redis 캐시 동작 테스트까지 해보기

- 깃허브 레포지토리 정리

- AWS 계정 생성하고 결제 준비해오기

- queryDSL은 심화로 혼자 해보기

- Oauth 구현하기 + session

 

- 클라우드 환경에서 CICD

- onpremiss와 클라우드 환경의 차이

- 동시성 처리

반응형