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;
}
}