Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
0aa6a8a
feat: Async, Storage Config 추가
plzslp Jun 24, 2026
cbe1223
feat: BinaryContentStorage 인터페이스 및 구현체 작성
plzslp Jun 24, 2026
18f3369
feat: BinaryContent 관련 예외 클래스 추가
plzslp Jun 24, 2026
ffec716
feat: 파일 업로드 관련 yml 설정 추가
plzslp Jun 24, 2026
74b4474
feat: 파일 업로드 상태 schema 추가 및 Content 엔티티에 적용
plzslp Jun 24, 2026
c513ec3
feat: 파일 업로드 이벹트 발행 로직 구현
plzslp Jun 24, 2026
85923bc
test: 기존 Content 테스트 코드 thumbnail_upload_status 추가
plzslp Jun 24, 2026
b43a4f5
refactor: Content-ContentStats OneToOne 설정 및 contentTags 타입 Set으로 변경
plzslp Jun 24, 2026
60b5cf4
refactor: N+1 문제 해결용 EntityGraph 추가
plzslp Jun 24, 2026
ff38247
test: LocalBinaryContentStorage 테스트 코드 작성
plzslp Jun 24, 2026
8db33e9
test: BinaryContentUploadListener 테스트 코드 작성
plzslp Jun 24, 2026
8754c6e
refactor: BinaryContentStorage generateKey() default 메서드 설정
plzslp Jun 24, 2026
7919c0f
refactor: S3StorageProperties 변수명 변경
plzslp Jun 24, 2026
aeeb969
refactor: BinaryContentUploadListener 로깅 강화
plzslp Jun 24, 2026
cf3ae15
refactor: 확장자 화이트리스트 정규화
plzslp Jun 25, 2026
bb15bef
refactor: byte[] 방어적 복사 추가
plzslp Jun 25, 2026
2a437cd
refactor: 예외 계층에 cause 전달 추가
plzslp Jun 25, 2026
b7ef07c
feat: ThreadPoolTaskExecutor 추가 및 MDC, SecurityContext 전파를 위한 Decorat…
plzslp Jun 25, 2026
e91f8aa
refactor: ContentMapper에 정렬 추가
plzslp Jun 25, 2026
5784e61
refactor: ContentTag 생성 시에 ContentTagId값이 바로 할당되도록 수정
plzslp Jun 25, 2026
d0f9a16
refactor: 파일 존재 중 생성자 호출 시 예외 검증 테스트 코드 추가
plzslp Jun 25, 2026
7acd87b
refactor: 기존 컨텐츠 생성 테스트 코드 파일 관련 검증 추가
plzslp Jun 25, 2026
f1201f0
refactor: 코드래빗 리뷰 반영
plzslp Jun 25, 2026
412c498
refactor: 테라폼 설정 기반 application.yml 파일 수정
plzslp Jun 25, 2026
ead4322
refactor: BinaryContent 엔티티 생성 및 DB schema 수정
plzslp Jun 25, 2026
7d4a49a
refactor: 기존 썸네일 업로드 로직 BinaryContent 엔티티 사용 로직으로 리펙토링
plzslp Jun 25, 2026
a2eb9f8
refactor: ContentStats 연관관계 수정 및 BaseUpdatableEntity를 상속받도록 수정
plzslp Jun 25, 2026
cb53452
refactor: Content 관련 리펙토링
plzslp Jun 25, 2026
89a151d
refactor: V7 content_stats watcher_count 타입 수정
plzslp Jun 25, 2026
2a75285
refactor: 기존 테스트 코드 수정
plzslp Jun 25, 2026
2267b1d
refactor: User 엔티티에 BinaryContent 연관관계 추가
plzslp Jun 25, 2026
90ab2ed
refactor: BinaryContent 업로드 상태 로직 별도 Service로 분리
plzslp Jun 25, 2026
623e4c9
refactor: 콘텐츠, 유저 내부 BinaryContent 유니크 제약 추가
plzslp Jun 25, 2026
51b133d
refactor: application.yml 스토리지 설정값 수정
plzslp Jun 25, 2026
7fc4b80
refactor: 코드래빗 피드백 반영
plzslp Jun 25, 2026
a442432
refactor: ContentTag proxy에서 field 접근 문제 수정
plzslp Jun 25, 2026
9820465
test: ContentControllerTest 주석 처리
plzslp Jun 25, 2026
f90ac33
refactor: BinaryContent String 검사 StringUtils 사용 및 커스텀 예외 추가
plzslp Jun 25, 2026
2f834fb
refactor: BinaryContentService 트랜잭션 어노테이션 수정
plzslp Jun 25, 2026
ac34b85
refactor: ContentMapper toDto() 파라미터 간소화
plzslp Jun 25, 2026
b868c89
refactor: ContentTag 생성 로직 외부 서비스로 추출
plzslp Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,7 @@ $RECYCLE.BIN/

### 로그 디렉토리 ###
.logs/

### 로컬 업로드 파일 디렉토리 ###
uploads/
uploads-test/
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.codeit.team5.mopl.binarycontent;

import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import org.springframework.util.StringUtils;

public interface BinaryContentStorage {

Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp");

default String generateKey(UUID contentId, String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
String lowerExt = extension != null ? extension.toLowerCase(Locale.ROOT) : null;
String normalizedExt = (lowerExt != null && ALLOWED_EXTENSIONS.contains(lowerExt))
? "." + lowerExt
: "";
return "thumbnails/" + contentId + "/" + UUID.randomUUID() + normalizedExt;
}

String toUrl(String key);

void store(String key, byte[] bytes);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.codeit.team5.mopl.binarycontent.entity;

import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import java.util.Objects;
import com.codeit.team5.mopl.binarycontent.exception.InvalidBinaryContentUrlException;
import org.springframework.util.StringUtils;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "binary_contents")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BinaryContent extends BaseUpdatableEntity {

@Column(nullable = false, length = 512)
private String url;

@Enumerated(EnumType.STRING)
@Column(name = "upload_status", nullable = false, length = 20)
private BinaryContentUploadStatus uploadStatus;

public static BinaryContent pending(String url) {
BinaryContent binaryContent = new BinaryContent();
if (!StringUtils.hasText(url)) {
throw new InvalidBinaryContentUrlException();
}
binaryContent.url = url;
binaryContent.uploadStatus = BinaryContentUploadStatus.PENDING;
return binaryContent;
}

public void updateUploadStatus(BinaryContentUploadStatus status) {
this.uploadStatus = Objects.requireNonNull(status, "status must not be null");
}
Comment thread
plzslp marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.codeit.team5.mopl.binarycontent.entity;

public enum BinaryContentUploadStatus {
PENDING,
COMPLETED,
FAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codeit.team5.mopl.binarycontent.event;

import java.util.UUID;

public record BinaryContentUploadEvent(
UUID binaryContentId,
String key,
byte[] bytes
) {
Comment thread
plzslp marked this conversation as resolved.
}
Comment thread
plzslp marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.codeit.team5.mopl.binarycontent.exception;

import com.codeit.team5.mopl.global.exception.suggestion.BusinessExceptionSuggestion;
import java.util.UUID;
import org.springframework.http.HttpStatus;

public class BinaryContentNotFoundException extends BusinessExceptionSuggestion {

public BinaryContentNotFoundException(UUID id) {
super(HttpStatus.NOT_FOUND, "BinaryContent를 찾을 수 없습니다: " + id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.codeit.team5.mopl.binarycontent.exception;

import com.codeit.team5.mopl.global.exception.suggestion.BusinessExceptionSuggestion;
import org.springframework.http.HttpStatus;

public class BinaryContentStorageException extends BusinessExceptionSuggestion {

public BinaryContentStorageException(HttpStatus status, String message) {
super(status, message);
}

public BinaryContentStorageException(HttpStatus status, String message, Throwable cause) {
super(status, message);
initCause(cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.codeit.team5.mopl.binarycontent.exception;

import org.springframework.http.HttpStatus;

public class FileStorageException extends BinaryContentStorageException {

public FileStorageException(String key) {
super(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 실패: " + key);
}

public FileStorageException(String key, Throwable cause) {
super(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 실패: " + key, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.codeit.team5.mopl.binarycontent.exception;

import com.codeit.team5.mopl.global.exception.suggestion.BusinessExceptionSuggestion;
import org.springframework.http.HttpStatus;

public class InvalidBinaryContentUrlException extends BusinessExceptionSuggestion {

public InvalidBinaryContentUrlException() {
super(HttpStatus.INTERNAL_SERVER_ERROR, "BinaryContent url은 비어있을 수 없습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.codeit.team5.mopl.binarycontent.exception;

import java.nio.file.Path;
import org.springframework.http.HttpStatus;

public class UploadDirectoryInitException extends BinaryContentStorageException {

public UploadDirectoryInitException(Path uploadDir) {
super(HttpStatus.INTERNAL_SERVER_ERROR, "업로드 디렉토리 생성 실패: " + uploadDir);
}

public UploadDirectoryInitException(Path uploadDir, Throwable cause) {
super(HttpStatus.INTERNAL_SERVER_ERROR, "업로드 디렉토리 생성 실패: " + uploadDir, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.codeit.team5.mopl.binarycontent.listener;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class BinaryContentUploadListener {

private final BinaryContentStorage binaryContentStorage;
private final BinaryContentService binaryContentService;

@Async("binaryContentUploadExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(BinaryContentUploadEvent event) {
try {
binaryContentStorage.store(event.key(), event.bytes());
binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.COMPLETED);
log.debug("파일 업로드 완료 - binaryContentId: {}", event.binaryContentId());
} catch (Exception e) {
binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.FAILED);
log.error("파일 업로드 실패 - binaryContentId: {}", event.binaryContentId(), e);
}
}
Comment thread
plzslp marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.codeit.team5.mopl.binarycontent.repository;

import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BinaryContentRepository extends JpaRepository<BinaryContent, UUID> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.codeit.team5.mopl.binarycontent.service;

import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.exception.BinaryContentNotFoundException;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BinaryContentService {

private final BinaryContentRepository binaryContentRepository;

@Transactional
public void updateUploadStatus(UUID binaryContentId, BinaryContentUploadStatus status) {
binaryContentRepository.findById(binaryContentId)
.orElseThrow(() -> new BinaryContentNotFoundException(binaryContentId))
.updateUploadStatus(status);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.codeit.team5.mopl.binarycontent.storage.local;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.exception.FileStorageException;
import com.codeit.team5.mopl.binarycontent.exception.UploadDirectoryInitException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "local", matchIfMissing = true)
public class LocalBinaryContentStorage implements BinaryContentStorage {

private final Path uploadDir;
private final String baseUrl;

public LocalBinaryContentStorage(LocalStorageProperties properties) {
this.uploadDir = Paths.get(properties.uploadDir()).toAbsolutePath().normalize();
this.baseUrl = properties.baseUrl();
init();
}

@Override
public String toUrl(String key) {
return baseUrl + "/" + key;
}

@Override
public void store(String key, byte[] bytes) {
Path destination = uploadDir.resolve(key);
try {
Files.createDirectories(destination.getParent());
Files.write(destination, bytes);
} catch (IOException e) {
throw new FileStorageException(key, e);
}
log.debug("파일 저장 완료: {}", destination);
}

private void init() {
try {
Files.createDirectories(uploadDir);
} catch (IOException e) {
throw new UploadDirectoryInitException(uploadDir, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.codeit.team5.mopl.binarycontent.storage.local;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "mopl.storage.local")
public record LocalStorageProperties(
String baseUrl,
String uploadDir
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.codeit.team5.mopl.binarycontent.storage.s3;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

@Component
@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "s3")
public class S3BinaryContentStorage implements BinaryContentStorage {

private final S3StorageProperties properties;

public S3BinaryContentStorage(S3StorageProperties properties) {
this.properties = properties;
}

@Override
public String toUrl(String key) {
return "https://" + properties.bucketName() + ".s3." + properties.region() + ".amazonaws.com/" + key;
}

@Override
public void store(String key, byte[] bytes) {
throw new UnsupportedOperationException("S3 스토리지가 아직 구현되지 않았습니다.");
Comment thread
plzslp marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.codeit.team5.mopl.binarycontent.storage.s3;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "mopl.storage.s3")
public record S3StorageProperties(
String accessKey,
String secretKey,
String bucketName,
String region
) {
Comment thread
plzslp marked this conversation as resolved.

}
29 changes: 29 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.global.async.CompositeTaskDecorator;
import com.codeit.team5.mopl.global.async.MdcTaskDecorator;
import com.codeit.team5.mopl.global.async.SecurityContextTaskDecorator;
import java.util.List;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {
Comment thread
plzslp marked this conversation as resolved.

@Bean(name = "binaryContentUploadExecutor")
public Executor binaryContentUploadExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("binary-upload-");
executor.setTaskDecorator(new CompositeTaskDecorator(
List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator())));
executor.initialize();
return executor;
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/StorageConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.binarycontent.storage.local.LocalStorageProperties;
import com.codeit.team5.mopl.binarycontent.storage.s3.S3StorageProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties({LocalStorageProperties.class, S3StorageProperties.class})
public class StorageConfig {

}
Loading
Loading