📦 Redis (Remote Dictionary Server) 란?

오픈 소스 인메모리 데이터 구조 저장소.

❓인메모리란?

데이터를 디스크와 같은 비휘발성 저장 장치가 아닌 주 메모리(RAM)와 같은 휘발성 메모리에 저장하는 방식.

- 빠른 데이터 접근 : 빠른 데이터 처리 속도 제공
- 휘발성 : 전원이 꺼지면 데이터가 사라짐
- 인메모리 데이터베이스 : 데이터를 메모리에 저장하여 빠른 속도로 데이터를 조회하고 처리함 (예 : Redis, Memcached, SAP HANA )

 

  1. 인메모리 저장소:
    • Redis는 데이터를 메모리에 저장하기 때문에 매우 빠른 읽기/쓰기 성능을 제공한다. 이는 디스크 기반 데이터베이스보다 훨씬 빠른 성능을 제공하므로, 캐싱, 세션 관리, 실시간 분석 등 고속 처리가 필요한 애플리케이션에 적합하다.
  2. 다양한 데이터 구조 지원:
    • Redis는 단순한 키-값 저장소 이상으로, 다양한 데이터 구조를 지원한다. 이를 통해 복잡한 데이터를 손쉽게 저장하고 조작할 수 있다.
  3. 영속성 옵션:
    • Redis는 기본적으로 인메모리 데이터 저장소이지만, 데이터를 디스크에 저장하여 영속성을 보장할 수 있습니다. 이를 위해 RDB(Snapshotting)와 AOF(Append-Only File) 두 가지 방식이 제공됩니다.
    • RDB: 특정 간격으로 데이터를 스냅샷으로 저장하는 방식
    • AOF: 모든 쓰기 작업을 로그로 기록하여 장애 시 복구하는 방식
  4. 복제 및 클러스터링:
    • Redis는 데이터 복제를 통해 고가용성을 제공할 수 있다. 마스터-슬레이브 구조를 사용하여 데이터를 복제하며, 슬레이브 노드는 읽기 작업을 분산할 수 있다.
    • Redis 클러스터는 데이터 샤딩을 통해 여러 노드에 데이터를 분산 저장하고, 각 노드가 샤드의 일부를 관리한다. 이를 통해 수평 확장이 가능하며, 대규모의 데이터를 처리할 수 있다.
  5. 트랜잭션 지원:
    • Redis는 간단한 트랜잭션 메커니즘을 제공하여, 여러 명령어를 원자적으로 실행할 수 있다. MULTI, EXEC, WATCH 명령어를 사용하여 트랜잭션을 구현할 수 있다.

Redis 설정

의존성 추가 - build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'

 

Redis 설정 파일 - RedisRepositoryConfig.java

public class RedisRepositoryConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Value("${spring.data.redis.password}")
    private String password;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPassword(password);
        redisStandaloneConfiguration.setPort(port);

        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }
}

 

application.yml 파일 설정

spring:
  data:
    redis:
      host: localhost
      port: 6379

 

Redis에 토큰을 저장하는 기능 추가 - JwtUtil.java

// Redis에 접근하기 위한 RedisTemplate을 추가
@Getter
private final RedisTemplate<String, String> redisTemplate;

public JwtUtil(RedisTemplate<String, String> redisTemplate) {
    this.redisTemplate = redisTemplate;
}
public UserResponse.tokenInfo generateTokens(String userId) {
    String accessToken = createAccessToken(userId);
    String refreshToken = createRefreshToken(userId);

    // Redis에 토큰 저장
    saveTokenToRedis(userId, accessToken, refreshToken);

    UserResponse.tokenInfo tokenInfo = UserResponse.tokenInfo.builder()
            .grantType("Bearer")
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();

    return tokenInfo;
}
private void saveTokenToRedis(String userId, String accessToken, String refreshToken) {
    redisTemplate.opsForValue().set("ACCESS_TOKEN:" + userId, accessToken, accessTokenValidity, TimeUnit.MILLISECONDS);
    redisTemplate.opsForValue().set("REFRESH_TOKEN:" + userId, refreshToken, refreshTokenValidity, TimeUnit.MILLISECONDS);
}
public String refreshAccessToken(String refreshToken) {
    if (validateToken(refreshToken)) {
        Claims claims = getClaimsFromToken(refreshToken);
        String userId = claims.get("userId", String.class);

        //Redis에서 refreshToken 검증
        String redisRefreshToken = redisTemplate.opsForValue().get("REFRESH_TOKEN:" + userId);
        if (redisRefreshToken != null && redisRefreshToken.equals(refreshToken)) {
            return createAccessToken(userId);
        }
    }
    return null;
}

 

Redis와의 연동 - JwtInterceptor.java

@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
    private final JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws IOException {

        String accessToken = request.getHeader(TokenType.ACCESS_TOKEN.toString());
        String refreshToken = request.getHeader(TokenType.REFRESH_TOKEN.toString());

        if (accessToken != null && jwtUtil.validateToken(accessToken)) {
            String userId = jwtUtil.getUserIdFromToken(accessToken);
            // 토큰 유효성을 검사할 때 Redis에 저장된 토큰과 비교하여 검증
            String redisAccessToken = jwtUtil.getRedisTemplate().opsForValue().get("ACCESS_TOKEN:" + userId);
            if (redisAccessToken != null && redisAccessToken.equals(accessToken)) {
                return true;
            }
        }

        if (refreshToken != null && jwtUtil.validateToken(refreshToken)) {
            String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
            if (newAccessToken != null) {
                response.setHeader(TokenType.ACCESS_TOKEN.toString(), newAccessToken);
                return true;
            }
        }

        response.setStatus(401);
        return false;
    }

}

 


Redis 테스트 

Redis 실행

 

Postman에서 로그인 후 토큰 확인

 

Redis 데이터 확인 - redis.cli

 

AWS CloudFront 란

AWS CloudFront는 AWS의 CDN (Content Delivery Network) 서비스다. CDN 서비스란 클라이언트의 콘텐츠 요청으로 서버에서 받아온 콘텐츠를 캐싱하고 이후 같은 요청이 왔을 때, 캐싱해 둔 콘텐츠를 제공하는 서비스다. 물리적으로 거리가 먼 곳에도 빠르게 요청을 처리할 수 있고 결과적으로 서버의 부하를 낮출 수 있다.

 

Edge Location(Pop) / Regional Edge Caceh(REC)

Edge Location

CloudFront 서비스가 콘텐츠를 캐싱하고 클라이언트에게 제공하는 지점 혹은 캐시 서버, 전 세계 주요 도시에 분포

사용자가 요청한 콘텐츠의 캐시가 Edge Location에 있다면 멀리 있는 서버에 직접 요청이 아닌 가까운 Edge Location에 저장된 캐시를 불러올 수 있다.

 

Regional Edge Cace

사용자가 접근할 수 있게 배포되어 있는 CloudFront 위치, CloudFront가 오리진에 요청하는것을 줄여준다.

캐시가 더 오랫동안 남고 Edge Location 보다 캐시 스토리지 용량이 더 크다.

 

CloudFront 동작 순서

  1. 사용자가 어플리케이션에 요청을 한다.
  2. DNS는 사용자에게 적합한 Edge Location으로 라우팅 한다.
  3. Edge Location에서 캐시를 확인하고 있으면 사용자에게 반환한다.
  4. 없으면 가장 가까운 REC로 캐시가 있는지 확인을 요청한다.
  5. 없으면 오리진으로 요청을 전달한다.
  6. 오리진 > REC > Edge Location > CloudFront가 사용자에게 전달 (캐시도 추가된다.)
  7. REC에 캐시가 있다면 REC는 콘텐츠를 요청한 Edge Location은 이를 사용자에게 반환한다.
  8. Edge Location은 나중을 위해 이 콘텐츠 캐시를 저장한다.

* 오리진 : 콘텐츠가 위치하고 있는 근원(ex. S3 버킷 등)

* DNS (Domain Name System) : 도메인 이름을 ip주소로 변환해주는 시스템

* 라우팅 : 데이터 패킷을 네트워크를 통해 목적지까지 전달하는 과정에서 최적의 경로를 결정하고 그 경로로 패킷을 전송하는 것


S3 이미지 업로드 + CloudFront 이미지 캐싱

 

✅ AWS ClodFront 배포 생성 및 권한 설정

CloudFront 배포 생성 클릭

 

Origin domain 항목에서 생성한 S3 도메인 선택

 

 

 

 

 

 

 

✔ 위와 같이 설정 완료한 뒤 배포 생성 클릭!

 

 

버킷 메뉴 - 권한 - 버킷 정책에 복사한 정책 붙여넣기

 

 

✅ 코드 수정

UserService.java

@Transactional
public String upload(String userId, MultipartFile image) {
    String profileImage = s3ImageService.upload(image);
    String imageCaching = "https://{CloudFront 도메인 이름}/"+profileImage.split("/")[profileImage.split("/").length-1];
    userDao.uploadProfile(userId, profileImage, imageCaching);

    return userId;
}

@Transactional
public String updateProfile(String userId, MultipartFile image) {
    String imageUrl = userDao.findById(userId).imageUrlS3();
    s3ImageService.deleteImageFromS3(imageUrl);

    return upload(userId, image);
}

 

프로필 이미지를 업로드하고 수정하는 메서드에 CloudFront url을 생성해 DB에 저장하고 조회하는 로직으로 수정

S3 이미지를 업로드 프로세스


  1. 클라이언트에서 이미지를 업로드하면 서버가 이를 수신해 MultipartFile 객체로 처리한다.
  2. MultipartFile을 File 객체로 변환하고 S3 클라이언트를 사용하여 변환된 파일을 S3 버킷에 업로드한다. 여기서 AWS S3 버킷의 이름과 업로드할 파일의 이름을 지정한다.
  3. 업로드된 파일은 S3 버킷에 저장되며, 해당 파일에 접근할 수 있는 URL을 생성하여 클라이언트에게 반환한다. 이 URL은 클라이언트가 파일을 접근하거나 다운로드할 수 있도록 한다. 이 URL을 db에 저장해서 필요할 때 이 URL로 이미지 데이터를 사용할 수 있다.

 

Spring Boot + Gradle 프로젝트 - S3 파일 업로드


✅ build.gradle

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

✅ application.yml

accessKey와 secretKey를 application.yml에 등록해야 하는데 이 Key는 외부에 노출되서는 안된다!!!!

이 Key를 이용하여 누군가 악용하면 AWS 계정에 비용이 청구될 수 있다.

 

따라서 application.yml 파일에 설정을 추가해 주고, application-private.yml 파일을 생성하고 . gitignore 파일에 등록하여 github에 올라가지 않도록 설정해야 한다.

application.yml

 

application-private.yml

 

.gitignore

✅ S3Config.java

ackage com.example.fitwithme.common;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3 amazonS3() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }
}

 

✅ S3ImageService.java

package com.example.fitwithme.application.service;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.util.IOUtils;
import com.example.fitwithme.common.exception.S3ErrorStatus;
import com.example.fitwithme.common.exception.S3Exception;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

@RequiredArgsConstructor
@Component
public class S3ImageService {
    private final AmazonS3 amazonS3;

    @Value("${cloud.aws.s3.bucketName}")
    private String bucketName;

    public String upload(MultipartFile image) {
        if(image.isEmpty() || Objects.isNull(image.getOriginalFilename())){
            throw new S3Exception(S3ErrorStatus.EMPTY_FILE_EXCEPTION);
        }
        return this.uploadImage(image);
    }

    private String uploadImage(MultipartFile image) {
        this.validateImageFileExtension(image.getOriginalFilename());
        try {
            return this.uploadImageToS3(image);
        } catch (IOException e) {
            throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_UPLOAD);
        }
    }

    private void validateImageFileExtension(String filename) {
        int lastDotIndex = filename.lastIndexOf(".");
        if (lastDotIndex == -1) {
            throw new S3Exception(S3ErrorStatus.NO_FILE_EXTENSION);
        }

        String extension = filename.substring(lastDotIndex + 1).toLowerCase();
        List<String> allowedExtensionList = Arrays.asList("jpg", "jpeg", "png", "gif");

        if (!allowedExtensionList.contains(extension)) {
            throw new S3Exception(S3ErrorStatus.INVALID_FILE_EXTENSION);
        }
    }

    private String uploadImageToS3(MultipartFile image) throws IOException {
        String originalFilename = image.getOriginalFilename(); //원본 파일 명
        String extention = originalFilename.substring(originalFilename.lastIndexOf("."));

        String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename;

        InputStream is = image.getInputStream();
        byte[] bytes = IOUtils.toByteArray(is);

        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentType("image/" + extention);
        metadata.setContentLength(bytes.length);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

        try{
            PutObjectRequest putObjectRequest =
                    new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
                            .withCannedAcl(CannedAccessControlList.PublicRead);
            amazonS3.putObject(putObjectRequest);
        }catch (Exception e){
            throw new S3Exception(S3ErrorStatus.PUT_OBJECT_EXCEPTION);
        }finally {
            byteArrayInputStream.close();
            is.close();
        }

        return amazonS3.getUrl(bucketName, s3FileName).toString();
    }

    public void deleteImageFromS3(String imageAddress){
        String key = getKeyFromImageAddress(imageAddress);
        try{
            amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
        }catch (Exception e){
            throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_DELETE);
        }
    }

    private String getKeyFromImageAddress(String imageAddress){
        try{
            URL url = new URL(imageAddress);
            String decodingKey = URLDecoder.decode(url.getPath(), "UTF-8");
            return decodingKey.substring(1);
        }catch (MalformedURLException | UnsupportedEncodingException e){
            throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_DELETE);
        }
    }
}

 

🔹upload(MultipartFile image)

넘겨받은 이미지 파일이 비어있는 파일인지 검증한다.

uploadImage를 호출하여 S3에 저장된 이미지 객체의 public url을 반환한다.

 

🔹uploadImage(MultipartFile image)

validateImageFileExtension을 호출하여 확장자 명이 올바른지 확인한다.

uploadImageToS3를 호출하여 이미지를 S3에 업로드하고 업로드된 이미지의 public url을 받아서 서비스 로직에 반환한다.

 

🔹validateImageFileExtension(String filename)

filename을 받아서 파일 확장자가  jpg, jpeg, png, gif 중에 속하는지 검증한다.

 

🔹uploadImageToS3(MultipartFile image)

S3에 이미지를 업로드한다.

private String uploadImageToS3(MultipartFile image) throws IOException {
  String originalFilename = image.getOriginalFilename(); //원본 파일 명
  String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); //확장자 명

  String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename; //변경된 파일 명

  InputStream is = image.getInputStream();
  byte[] bytes = IOUtils.toByteArray(is); //image를 byte[]로 변환

  ObjectMetadata metadata = new ObjectMetadata(); //metadata 생성
  metadata.setContentType("image/" + extension);
  metadata.setContentLength(bytes.length);
  
  //S3에 요청할 때 사용할 byteInputStream 생성
  ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); 

  try{
    //S3로 putObject 할 때 사용할 요청 객체
    //생성자 : bucket 이름, 파일 명, byteInputStream, metadata
    PutObjectRequest putObjectRequest =
        new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
            .withCannedAcl(CannedAccessControlList.PublicRead);
            
    //실제로 S3에 이미지 데이터를 넣는 부분이다.
    amazonS3.putObject(putObjectRequest); // put image to S3
  }catch (Exception e){
    throw new S3Exception(ErrorCode.PUT_OBJECT_EXCEPTION);
  }finally {
    byteArrayInputStream.close();
    is.close();
  }

  return amazonS3.getUrl(bucketName, s3FileName).toString();
}

 

🔹deleteImageFromS3(String imageAddress)

이미지의 public url을 이용해서 S3에서 해당 이미지를 삭제한다.

getKeyFromImageAddress를 호출해서 삭제에 필요한 key를 가져온다.

 

🔹getKeyFromImageAddress(String imageAddress)

이미지의 url을 받아와 디코딩한 url을 맨 앞의 '/'를 제거한 key를 반환한다.

 

✅ S3Exception.java

package com.example.fitwithme.common.exception;

public class S3Exception extends RuntimeException{
    private final S3ErrorStatus status;

    public S3Exception(S3ErrorStatus status) {
        super(status.getMessage());
        this.status = status;
    }

    public S3ErrorStatus getStatus() {
        return this.status;
    }
}

 

✅ S3ErrorStatus.java

package com.example.fitwithme.common.exception;

public enum S3ErrorStatus {
    OK("Success"),
    EMPTY_FILE_EXCEPTION("첨부된 이미지가 없습니다."),
    IO_EXCEPTION_ON_IMAGE_UPLOAD("IOException이 발생했습니다."),
    NO_FILE_EXTENSION("잘못된 파일 형식입니다."),
    INVALID_FILE_EXTENSION("이미지 파일 형식이 아닙니다."),
    PUT_OBJECT_EXCEPTION("이미지 업로드에 실패했습니다."),
    IO_EXCEPTION_ON_IMAGE_DELETE("이미지 삭제에 실패했습니다.");

    private final String message;

    S3ErrorStatus(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }

}

 

✅ UserController.java

@PostMapping("/Upload-profile")
public ResponseEntity<String> profileUpload(@RequestHeader("ACCESS_TOKEN") String accessToken, @RequestPart(value = "image", required = false) MultipartFile image){
    String userId = jwtUtil.getUserIdFromToken(accessToken);
    String profileImage = userService.upload(userId, image);
    return ResponseEntity.ok(profileImage);
}
    
@PutMapping("/update-profile")
public ResponseEntity<String> updateProfile(@RequestHeader("ACCESS_TOKEN") String accessToken, @RequestPart(value = "image", required = false) MultipartFile image){
    String userId = jwtUtil.getUserIdFromToken(accessToken);
    String profileImage = userService.updateProfile(userId, image);
    return ResponseEntity.ok(profileImage);
}

 

✅ UserService.java

@Transactional
public String upload(String userId, MultipartFile image) {
        String profileImage = s3ImageService.upload(image);
        userDao.uploadProfile(userId, profileImage);

        return userId;
    }
    
@Transactional
public String updateProfile(String userId, MultipartFile image) {
    String imageUrl = userDao.findById(userId).imageUrl();
    s3ImageService.deleteImageFromS3(imageUrl);

    String profileImage = s3ImageService.upload(image);
    userDao.uploadProfile(userId, profileImage);

    return userId;
}

 

이미지 업로드 시 s3 ImageService를 이용하여 이미지를 저장한 뒤, 반환받은 public url을 DB에 저장하는 로직으로 처리했다.


☝ S3를 사용해 이미지를 업로드할 경우의 장점

  1. 내구성과 안전성 : AWS의 S3는 데이터의 내구성과 안정성을 보장합니다. 데이터가 여러 복제본에 걸쳐 저장되므로 데이터 손실 우려가 줄어듭니다.
  2. 확장성 : S3는 거의 무한한 확장성을 제공하므로 대용량 데이터를 저장하고 처리할 수 있습니다.
  3. 다양한 데이터 관리 기능 : 버전 관리, 암호화, 액세스 제어 등 다양한 데이터 관리 기능을 제공하여 데이터를 보다 효율적으로 관리할 수 있습니다.
  4. 저렴한 비용 : S3는 사용한 만큼만 비용을 지불하므로 비용을 절감할 수 있습니다.

Amazon S3에서 로그인하고 버킷 만들기 클릭

 

버킷 생성

 

IAM 생성

 

IAM accessKey, secretKey 얻기

액세스 키와 비밀 액세스 키 저장해두기! → spring properties에 등록해서 S3 bucket에 접근하는 데에 사용

비밀 액세스 키 (secretKey)는 생성 시에만 검색할 수 있기 때문에 미리 저장해둬야 한다!!!

csv 파일 다운로드를 클릭해서 파일로도 저장 가능!

1️⃣ Docker 설치

https://www.docker.com/products/docker-desktop/  도커 홈페이지에 접속해서 OS에 맞는 도커를 내려 받아 설치한다.

설치가 완료되면 다음의 명령어로 버전 확인

 

2️⃣ MySQL Docker 이미지 다운로드

다음 명령어로 MySQL Docker 이미지를 다운로드한다. 태그에 버전을 지정하지 않으면 최신 버전을 다운로드한다.

 

다운로드한 이미지 확인

 

3️⃣ MySQL Docker 컨테이너 생성 및 실행

 

4️⃣ Docker 컨테이너 리스트 출력

 

5️⃣ MySQL Docker 컨테이너 중지/시작/재시작

6️⃣ MySQL Docker 컨테이너 접속

 

+ Recent posts