Minio, Redis 및 Spring Boot 백엔드 핵심 기술 통합 가이드

Minio 객체 스토리지

핵심 개념 및 클라이언트 사용

  • 객체 (Object): 실제 저장되는 데이터 단위 (예: 업로드된 이미지 파일)
  • 버킷 (Bucket): 객체를 논리적으로 그룹화하는 컨테이너로, 파일 시스템의 폴더와 유사한 역할을 함
  • 엔드포인트 (Endpoint): Minio 서버에 접근하기 위한 네트워크 주소 (예: http://192.168.10.101:9000). 참고로 9000 포트는 API 기본 포트이며, 9001은 웹 관리 콘솔 포트임

웹 콘솔에 로그인하여 버킷을 생성하고 파일을 업로드할 수 있음. 업로드된 파일은 Minio주소/버킷이름/파일명 형태로 접근 가능함. 단, 기본적으로 버킷의 접근 권한이 private으로 설정되어 있어 외부에서 직접 URL 접근이 불가하므로, 아래와 같이 읽기 권한을 허용하는 정책을 설정해야 함.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "*"
                ]
            },
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::demo1/*"
            ]
        }
    ]
}

Java 연동

의존성 추가:

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.3</version>
</dependency>

클라이언트 구현:

package org.example;

import io.minio.*;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

public class MinioIntegrationTest {
    public static void main(String[] args) {
        String endpointUrl = "http://192.168.17.101:9000";
        String accessId = "minioadmin";
        String secretKey = "minioadmin";
        String storageBucket = "test-bucket";

        MinioClient storageClient = MinioClient.builder()
                .credentials(accessId, secretKey)
                .endpoint(endpointUrl)
                .build();

        try {
            boolean isExist = storageClient.bucketExists(BucketExistsArgs.builder().bucket(storageBucket).build());
            if (!isExist) {
                storageClient.makeBucket(MakeBucketArgs.builder().bucket(storageBucket).build());
                String readOnlyPolicy = """
                        {
                          "Statement" : [ {
                            "Action" : "s3:GetObject",
                            "Effect" : "Allow",
                            "Principal" : "*",
                            "Resource" : "arn:aws:s3:::%s/*"
                          } ],
                          "Version" : "2012-10-17"
                        }
                        """.formatted(storageBucket);
                storageClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(storageBucket).config(readOnlyPolicy).build());
            }
            String localFilePath = "D:\\images\\photo.png";
            storageClient.uploadObject(UploadObjectArgs.builder()
                    .filename(localFilePath)
                    .bucket(storageBucket)
                    .object("uploaded_photo.png")
                    .build());
            System.out.println("업로드 완료");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Redis 캐시 시스템

개요

Redis(Remote Dictionary Server)는 메모리 기반의 키-값 저장소로, 캐시 서버로 주로 사용됨. 디스크보다 훨씬 빠른 메모리의 특성을 살려 읽기/쓰기 성능이 뛰어나며, 데이터 영속성을 위해 스냅샷이나 AOF(Append Only File) 방식으로 디스크에 백업함. 만료 시간(TTL) 설정도 가능하여 캐시 용도로 매우 적합함.

주요 데이터 구조 및 명령어

공통 명령어

  • KEYS 패턴: 모든 키 조회. (서비스 중인 환경에서는 성능 저하 우려가 있어 사용에 주의)
  • DBSIZE: 전체 키 개수 확인
  • EXISTS 키: 키 존재 여부 확인 (1: 존재, 0: 없음)
  • DEL 키: 키 삭제
  • TTL 키: 키의 남은 만료 시간(초) 반환. 만료 시간 미설정 시 -1, 키가 없으면 -2 반환
  • SELECT 인덱스: 0~15번까지 존재하는 논리 데이터베이스 전환
  • FLUSHDB / FLUSHALL: 현재 DB / 전체 DB 초기화

String (문자열)

  • SET 키 값 [NX|XX] [EX 초|PX 밀리초]: 값 저장. NX는 키가 없을 때만, XX는 있을 때만 저장. EX/PX로 만료 시간 설정 가능
  • GET 키: 값 조회
  • INCR 키 / DECR 키: 값을 1 증가/감소 (카운터 용도)

List (리스트)

  • LPUSH 키 값 / RPUSH 키 값: 왼쪽/오른쪽에 값 추가
  • LINSERT 키 BEFORE|AFTER 피벗 값: 특정 값 앞/뒤에 새 값 삽입
  • LINDEX 키 인덱스 / LRANGE 키 시작 끝: 인덱스로 조회 / 범위로 조회
  • LPOP 키 / RPOP 키: 왼쪽/오른쪽 값 제거 및 반환
  • LREM 키 개수 값: 지정한 개수만큼 값 제거
  • LSET 키 인덱스 값: 인덱스 위치의 값 수정

Set (집합)

  • SADD 키 멤버: 멤버 추가
  • SMEMBERS 키: 모든 멤버 조회
  • SREM 키 멤버: 멤버 제거
  • SPOP 키: 임의의 멤버 제거 및 반환
  • SRANDMEMBER 키 [개수]: 임의의 멤버 반환 (제거하지 않음)
  • SCARD 키: 멤버 수 확인
  • SISMEMBER 키 멤버: 멤버 존재 여부
  • SINTER 키1 키2 / SUNION 키1 키2 / SDIFF 키1 키2: 교집합 / 합집합 / 차집합

Hash (해시)

  • HSET 키 필드 값: 필드-값 쌍 추가
  • HGET 키 필드: 특정 필드 값 조회
  • HDEL 키 필드: 필드 삭제
  • HEXISTS 키 필드: 필드 존재 여부
  • HKEYS 키 / HVALS 키: 모든 필드 / 모든 값 조회
  • HGETALL 키: 모든 필드와 값 조회

ZSet (정렬된 집합)

  • ZADD 키 [NX|XX] 점수 멤버: 멤버와 점수(score) 추가
  • ZCARD 키: 멤버 수 확인
  • ZSCORE 키 멤버: 멤버의 점수 확인
  • ZRANK 키 멤버 / ZREVRANK 키 멤버: 점수 오름차순/내림차순 기준 순위 반환
  • ZREM 키 멤버: 멤버 제거
  • ZINCRBY 키 증가량 멤버: 멤버의 점수 증가
  • ZRANGE 키 시작 끝 [BYSCORE] [REV] [LIMIT 오프셋 개수] [WITHSCORES]: 범위 조회. BYSCORE 시 점수 구간 검색, REV 시 내림차순, WITHSCORES 시 점수 함께 출력

Spring Boot 연동

Spring Data Redis는 Redis Java 클라이언트(Jedis, Lettuce)를 추상화하여 RedisTemplate을 제공함.

의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.yml 설정:

spring:
  data:
    redis:
      host: 192.168.17.101
      port: 6379
      database: 0

기본 RedisTemplate은 직렬화 방식이 달라 CLI에서 확인 시 키값이 깨져 보이는 문제가 있음. 이를 해결하기 위해 직렬화 방식이 동일한 StringRedisTemplate을 사용하는 것을 권장함.

@SpringBootTest
public class RedisOperationTest {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void insertValue() {
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.set("user:session", "active");
        ops.set("user:age", "28");
    }

    @Test
    public void retrieveValue() {
        String val = stringRedisTemplate.opsForValue().get("user:session");
        System.out.println(val);
    }

    @Test
    public void removeValue() {
        stringRedisTemplate.delete("user:age");
    }
}

Knife4j API 문서화

Knife4j는 OpenAPI 규격에 맞춰 API 문서를 자동 생성하고 온라인 디버깅을 지원하는 도구임.

의존성 추가:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
    <version>4.3.0</version>
</dependency>

설정 클래스 작성:

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("프로젝트 API 명세서")
                        .version("1.0")
                        .description("백엔드 API 문서"));
    }
    
    @Bean
    public GroupedOpenApi memberApi() {
        return GroupedOpenApi.builder().group("회원 관리")
                .pathsToMatch("/api/member/**")
                .build();
    }

    @Bean
    public GroupedOpenApi productApi() {
        return GroupedOpenApi.builder().group("상품 관리")
                .pathsToMatch("/api/product/**")
                .build();
    }
}

DTO 및 컨트롤러에 어노테이션 적용:

@Data
@Schema(description = "회원 정보 DTO")
public class MemberDto {
    @Schema(description = "회원 고유번호")
    private Long id;
    @Schema(description = "이름")
    private String name;
    @Schema(description = "이메일")
    private String email;
}

@RestController
@RequestMapping("/api/member")
@Tag(name = "회원 관리")
public class MemberController {

    @Operation(summary = "ID로 회원 조회")
    @GetMapping("getById")
    public MemberDto getMember(@Parameter(description = "회원 ID") @RequestParam Long id) {
        MemberDto dto = new MemberDto();
        dto.setId(id);
        dto.setName("홍길동");
        dto.setEmail("hong@example.com");
        return dto;
    }
}

객체 파라미터가 중첩되어 표시되는 것을 방지하려면 application.yml에 아래 설정을 추가함.

springdoc:
  default-flat-param-object: true

공통 필드 자동 채우기 및 논리 삭제

데이터베이스의 공통 컬럼(생성일, 수정일, 삭제 여부)은 부모 클래스로 분리하고, MyBatis-Plus의 자동 채우기 기능을 활용함.

@Data
public class BaseDomain implements Serializable {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @JsonIgnore
    @TableField(value = "created_at", fill = FieldFill.INSERT)
    private Date createdAt;

    @JsonIgnore
    @TableField(value = "updated_at", fill = FieldFill.UPDATE)
    private Date updatedAt;

    @TableLogic
    @JsonIgnore
    @TableField(value = "is_deleted")
    private Byte isDeleted;
}

자동 채우기 핸들러 구현:

@Component
public class AutoFillHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        metaObject.setValue("createdAt", new Date());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        metaObject.setValue("updatedAt", new Date());
    }
}

Enum(열거형) 타입 변환 일괄 처리

프론트엔드-백엔드-데이터베이스 간 Enum 변환을 통합 처리하기 위해 BaseEnum 인터페이스와 ConverterFactory를 활용함.

public interface BaseEnum {
    Integer getCode();
    String getDesc();
}

public enum ProductCategory implements BaseEnum {
    ELECTRONICS(1, "전자기기"),
    CLOTHING(2, "의류");

    @EnumValue // MyBatis-Plus 매핑
    @JsonValue  // Jackson(JSON) 매핑
    private Integer code;
    private String desc;

    ProductCategory(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    @Override
    public Integer getCode() { return code; }
    @Override
    public String getDesc() { return desc; }
}

WebDataBinder용 ConverterFactory 구현:

@Component
public class StringToEnumConverterFactory implements ConverterFactory<String, BaseEnum> {
    @Override
    public <T extends BaseEnum> Converter<String, T> getConverter(Class<T> targetType) {
        return source -> {
            T[] constants = targetType.getEnumConstants();
            for (T constant : constants) {
                if (constant.getCode().equals(Integer.parseInt(source))) {
                    return constant;
                }
            }
            throw new IllegalArgumentException("유효하지 않은 코드값입니다.");
        };
    }
}

Mvc 설정에 ConverterFactory 등록:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private StringToEnumConverterFactory enumConverterFactory;

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(enumConverterFactory);
    }
}

Minio 이미지 업로드 서비스 구현

설정 속성 바인딩:

@ConfigurationProperties(prefix = "minio")
@Data
public class MinioProperties {
    private String endpoint;
    private String accessKey;
    private String secretKey;
    private String bucketName;
}

MinioClient Bean 등록 (조건부 활성화):

@Configuration
@EnableConfigurationProperties(MinioProperties.class)
@ConditionalOnProperty(name = "minio.endpoint")
public class MinioConfiguration {
    @Autowired
    private MinioProperties minioProps;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(minioProps.getEndpoint())
                .credentials(minioProps.getAccessKey(), minioProps.getSecretKey())
                .build();
    }
}

업로드 로직 구현:

@Service
public class ObjectStorageService {
    @Autowired private MinioClient minioClient;
    @Autowired private MinioProperties minioProps;

    public String uploadFile(MultipartFile file) throws Exception {
        String bucket = minioProps.getBucketName();
        if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
            String policy = generateReadOnlyPolicy(bucket);
            minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucket).config(policy).build());
        }
        
        String objectName = new SimpleDateFormat("yyyyMMdd").format(new Date()) + 
                            "/" + UUID.randomUUID() + "-" + file.getOriginalFilename();
                            
        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucket)
                .stream(file.getInputStream(), file.getSize(), -1)
                .object(objectName)
                .contentType(file.getContentType())
                .build());

        return String.join("/", minioProps.getEndpoint(), bucket, objectName);
    }

    private String generateReadOnlyPolicy(String bucketName) {
        return """
                {
                     "Statement" : [ {
                       "Action" : "s3:GetObject",
                       "Effect" : "Allow",
                       "Principal" : "*",
                       "Resource" : "arn:aws:s3:::%s/*"
                     } ],
                     "Version" : "2012-10-17"
                   }
                   """.formatted(bucketName);
    }
}

전역 예외 처리

사용자 정의 예외 클래스:

@Data
public class BusinessException extends RuntimeException {
    private Integer errorCode;

    public BusinessException(String message, Integer errorCode) {
        super(message);
        this.errorCode = errorCode;
    }

    public BusinessException(ErrorCodeEnum errorCodeEnum) {
        super(errorCodeEnum.getMessage());
        this.errorCode = errorCodeEnum.getCode();
    }
}

전역 예외 핸들러:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public Result handleGenericException(Exception e) {
        e.printStackTrace();
        return Result.fail();
    }

    @ExceptionHandler(BusinessException.class)
    public Result handleBusinessException(BusinessException e) {
        e.printStackTrace();
        return Result.fail(e.getErrorCode(), e.getMessage());
    }
}

JWT 인증 및 인터셉터

JWT 토큰 생성 및 파싱 유틸리티:

public class JwtTokenProvider {
    private static final long EXPIRATION_MS = 60 * 60 * 1000L;
    private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor("MySecureSecretKeyForJwtGeneration123".getBytes());

    public static String generateToken(Long userId, String account) {
        return Jwts.builder()
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .setSubject("USER_AUTH")
                .claim("uid", userId)
                .claim("account", account)
                .signWith(SECRET_KEY, SignatureAlgorithm.HS256)
                .compact();
    }

    public static Claims parseToken(String token) {
        try {
            return Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            throw new BusinessException(ErrorCodeEnum.TOKEN_EXPIRED);
        } catch (JwtException e) {
            throw new BusinessException(ErrorCodeEnum.TOKEN_INVALID);
        }
    }
}

스레드 로컬을 활용한 사용자 컨텍스트 관리:

public class UserContext {
    private static final ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();

    public static void setCurrentUser(LoginUser user) { threadLocal.set(user); }
    public static LoginUser getCurrentUser() { return threadLocal.get(); }
    public static void clear() { threadLocal.remove(); }
}

인증 인터셉터:

@Component
public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("access-token");
        if (token == null) {
            throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED);
        }
        Claims claims = JwtTokenProvider.parseToken(token);
        LoginUser user = new LoginUser(claims.get("uid", Long.class), claims.get("account", String.class));
        UserContext.setCurrentUser(user);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear();
    }
}

캡차 및 SMS 인증

이미지 캡차 (EasyCaptcha)

@Override
public CaptchaResponse generateCaptcha() {
    SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
    String verCode = captcha.text().toLowerCase();
    String redisKey = "captcha:" + UUID.randomUUID();
    redisTemplate.opsForValue().set(redisKey, verCode, 60, TimeUnit.SECONDS);
    return new CaptchaResponse(captcha.toBase64(), redisKey);
}

알리클라우드 SMS 전송

의존성 및 설정 후 Client Bean 등록:

@Configuration
@EnableConfigurationProperties(AliyunSMSProperties.class)
@ConditionalOnProperty(name = "aliyun.sms.endpoint")
public class AliyunSMSConfiguration {
    @Autowired private AliyunSMSProperties properties;

    @Bean
    public Client smsClient() throws Exception {
        Config config = new Config();
        config.setAccessKeyId(properties.getAccessKeyId());
        config.setAccessKeySecret(properties.getAccessKeySecret());
        config.setEndpoint(properties.getEndpoint());
        return new Client(config);
    }
}

SMS 전송 로직:

@Autowired
private Client smsClient;

public void sendVerificationCode(String phoneNumber, String code) {
    SendSmsRequest smsRequest = new SendSmsRequest();
    smsRequest.setPhoneNumbers(phoneNumber);
    smsRequest.setSignName("알리클라우드 SMS 테스트");
    smsRequest.setTemplateCode("SMS_154950909");
    smsRequest.setTemplateParam("{\"code\":\"" + code + "\"}");
    try {
        smsClient.sendSms(smsRequest);
    } catch (Exception e) {
        throw new RuntimeException("SMS 전송 실패", e);
    }
}

스케줄링 작업

메인 애플리케이션 클래스에 @EnableScheduling 추가 후, 작업 클래스 작성:

@Component
public class DailyBatchJob {
    @Autowired private ContractService contractService;

    @Scheduled(cron = "0 0 0 * * *")
    public void updateExpiredContracts() {
        LambdaUpdateWrapper<Contract> wrapper = new LambdaUpdateWrapper<>();
        wrapper.le(Contract::getEndDate, new Date())
               .eq(Contract::getStatus, ContractStatus.ACTIVE);
        contractService.update(wrapper);
    }
}

MyBatis 다중 테이블 조인 페이징

MyBatis-Plus 페이징 플러그인 사용 시 <collection> 매핑을 사용해야 한다면, 조인 결과를 한 번에 가져오는 중첩 결과 매핑(Nested Results) 대신 개별 쿼리를 호출하는 중첩 조회(Nested Select) 방식을 사용해야 함.

<select id="selectRoomPage" resultMap="RoomPageMap">
    select id, number, rent from room_info
</select>

<resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
    <id column="id" property="id"/>
    <collection property="graphInfoList" ofType="GraphInfo" 
                select="selectGraphByRoomId" column="id"/>
</resultMap>

<select id="selectGraphByRoomId" resultType="GraphInfo">
    select id, url, room_id from graph_info where room_id = #{id}
</select>

커스텀 RedisTemplate 설정

객체 직렬화 시 깨지는 현상을 방지하고 문자열 형태로 저장하기 위해 StringRedisSerializer를 적용한 커스텀 RedisTemplate을 정의함.

@Configuration
public class RedisConfiguration {
    @Bean
    public RedisTemplate<String, Object> customRedisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        return template;
    }
}

태그: MinIO Redis Spring Boot Knife4j MyBatis-Plus

8월 6일 02:49에 게시됨