Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a7e60d0
feat(config): add AsyncConfig with s3UploadExecutor thread pool
Jjiggu Jun 4, 2025
71155f8
feat(config): add AwsS3Config for AwsS3Client bean configuration
Jjiggu Jun 4, 2025
68a5c2b
chore(build): add AWS S3 and Resilience4j depende
Jjiggu Jun 4, 2025
27a8dad
feat(s3): implements S3Service with async upload and delete method
Jjiggu Jun 4, 2025
7624cb6
chore(security): permit all requests to /stores/**
Jjiggu Jun 4, 2025
57d554c
feat(store-images): add StoreImage entity
Jjiggu Jun 4, 2025
cfb4cfb
feat(store-images): add endpoints for uploading and deleting store im…
Jjiggu Jun 4, 2025
95c2b71
feat(store-images): add StoreImageRepository
Jjiggu Jun 4, 2025
eb1ee68
feat(store-images): add StoreImageService for batch upload and delete…
Jjiggu Jun 4, 2025
bf8a1de
feat(store-images): add StoreImageUploadResponse dto with fromEntity …
Jjiggu Jun 4, 2025
2be16e4
refactor(store): remove setters, imageUrl, and add update methods
Jjiggu Jun 4, 2025
05f9bb6
refactor(store): remove imageUrl field
Jjiggu Jun 4, 2025
efa11ea
refactor(store): remove imageUrl, add List<StoreImageUploadResponse>
Jjiggu Jun 4, 2025
c2f4ffb
refactor(store): rename fromEntity to of in StoreReadResponse
Jjiggu Jun 4, 2025
55a5b5d
feat(store): include store image information in StoreReadDto mapping
Jjiggu Jun 4, 2025
455f6bf
refactor(store): remove imageUrl
Jjiggu Jun 4, 2025
b38796b
Merge pull request #15 from GTable/feature/#14-image-upload
Jjiggu Jun 4, 2025
651c8d5
feat(store-images): add file count and file size validation for uploads
Jjiggu Jun 4, 2025
26cdf37
refactor(store-image): improve async synchronization and transaction …
Jjiggu Jun 4, 2025
b241273
chore(build): update AWS SDK dependencies to newer versions
Jjiggu Jun 4, 2025
c7b4e21
Merge pull request #17 from GTable/feature/#14-image-upload
Jjiggu Jun 4, 2025
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
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ dependencies {
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

// S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.683'
// Resilience4j
implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1'
implementation 'io.github.resilience4j:resilience4j-bulkhead:1.7.1'
// 비동기 실행
implementation 'org.springframework.boot:spring-boot-starter-aop'

testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.awaitility:awaitility:4.3.0'
testImplementation 'com.h2database:h2'
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/example/gtable/global/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.gtable.global.config;

import java.util.concurrent.Executor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
public class AsyncConfig {
@Bean(name = "s3UploadExecutor")
public Executor s3UploadExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("S3Upload-");
executor.initialize();
return executor;
}
}
33 changes: 33 additions & 0 deletions src/main/java/com/example/gtable/global/config/AwsS3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.gtable.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;

@Configuration
public class AwsS3Config {

@Value("${cloud.aws.credentials.access-key}")
private String accessKey;

@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;

@Value("${cloud.aws.region.static}")
private String region;

@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client)AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(
"/oauth2/authorization/kakao", // 카카오 로그인 요청
"/login/oauth2/code/**", // 카카오 인증 콜백
"/api/refresh-token") // refresh token (토큰 갱신)
"/api/refresh-token",
"/stores/**") // refresh token (토큰 갱신)
.permitAll()
.anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가
)
Expand Down
57 changes: 57 additions & 0 deletions src/main/java/com/example/gtable/global/s3/S3Service.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.example.gtable.global.s3;

import java.io.InputStream;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.ObjectMetadata;

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3Client amazonS3Client;

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

public record S3UploadResult(String key, String url) {
}

@Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL)
@Async("s3UploadExecutor")
public CompletableFuture<S3UploadResult> upload(Long storeId, MultipartFile file) {
try (InputStream inputStream = file.getInputStream()) {
String key = createFileKey(storeId, file.getOriginalFilename());
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());

amazonS3Client.putObject(bucket, key, inputStream, metadata);
String url = amazonS3Client.getUrl(bucket, key).toString();

return CompletableFuture.completedFuture(new S3UploadResult(key, url));
} catch (Exception e) {
throw new RuntimeException("S3 업로드 실패", e);
}
}
Comment on lines +29 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

비동기 업로드 메서드의 에러 처리와 리소스 관리를 개선해 주세요.

Bulkhead 패턴과 비동기 처리 적용은 좋습니다! 하지만 몇 가지 개선이 필요해 보입니다:

  1. try-with-resources 문에서 예외 발생 시 CompletableFuture.completedFuture 대신 CompletableFuture.failedFuture를 반환해야 합니다.
  2. 일반적인 RuntimeException보다 구체적인 예외 타입을 사용하는 것이 좋겠습니다.

다음과 같이 개선해 주세요:

 @Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL)
 @Async("s3UploadExecutor")
 public CompletableFuture<S3UploadResult> upload(Long storeId, MultipartFile file) {
 	try (InputStream inputStream = file.getInputStream()) {
 		String key = createFileKey(storeId, file.getOriginalFilename());
 		ObjectMetadata metadata = new ObjectMetadata();
 		metadata.setContentLength(file.getSize());

 		amazonS3Client.putObject(bucket, key, inputStream, metadata);
 		String url = amazonS3Client.getUrl(bucket, key).toString();

 		return CompletableFuture.completedFuture(new S3UploadResult(key, url));
 	} catch (Exception e) {
-		throw new RuntimeException("S3 업로드 실패", e);
+		return CompletableFuture.failedFuture(new S3UploadException("S3 업로드 실패: " + file.getOriginalFilename(), e));
 	}
 }
🤖 Prompt for AI Agents
In src/main/java/com/example/gtable/global/s3/S3Service.java around lines 29 to
44, the asynchronous upload method currently throws a RuntimeException on error
instead of returning a failed CompletableFuture, which is inconsistent with
async error handling. Modify the catch block to return
CompletableFuture.failedFuture with a more specific exception type related to S3
upload failure. This ensures proper asynchronous error propagation and clearer
exception semantics.


public void delete(String filename) {
try {
amazonS3Client.deleteObject(bucket, filename);
} catch (Exception e) {
throw new RuntimeException("S3 파일 삭제 실패", e);
}
}

private String createFileKey(Long storeId, String filename) {
return "store/" + storeId + "/" + UUID.randomUUID() + "-" + filename;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,12 @@ public class StoreCreateRequest {

private String description;

private String storeImageUrl;

public Store toEntity() {
return Store.builder()
.departmentId(departmentId)
.name(name)
.location(location)
.description(description)
.storeImageUrl(storeImageUrl)
.isActive(false)
.deleted(false)
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public class StoreCreateResponse {
private String name;
private String location;
private String description;
private String storeImageUrl;
private Boolean isActive;
private Boolean deleted;
private LocalDateTime createdAt;
Expand All @@ -31,7 +30,6 @@ public static StoreCreateResponse fromEntity(Store store) {
.name(store.getName())
.location(store.getLocation())
.description(store.getDescription())
.storeImageUrl(store.getStoreImageUrl())
.isActive(store.getIsActive())
.deleted(store.getDeleted())
.build();
Expand Down
8 changes: 5 additions & 3 deletions src/main/java/com/example/gtable/store/dto/StoreReadDto.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.example.gtable.store.dto;

import java.time.LocalDateTime;
import java.util.List;

import com.example.gtable.store.model.Store;
import com.example.gtable.storeImage.dto.StoreImageUploadResponse;

import lombok.AllArgsConstructor;
import lombok.Builder;
Expand All @@ -17,22 +19,22 @@ public class StoreReadDto {
private String name;
private String location;
private String description;
private String storeImageUrl;
private List<StoreImageUploadResponse> images;
private Boolean isActive;
private Boolean deleted;
private LocalDateTime createdAt;

public static StoreReadDto fromEntity(Store store) {
public static StoreReadDto fromEntity(Store store, List<StoreImageUploadResponse> images) {
return StoreReadDto.builder()
.createdAt(store.getCreatedAt())
.storeId(store.getStoreId())
.departmentId(store.getDepartmentId())
.name(store.getName())
.location(store.getLocation())
.description(store.getDescription())
.storeImageUrl(store.getStoreImageUrl())
.isActive(store.getIsActive())
.deleted(store.getDeleted())
.images(images)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class StoreReadResponse {
private List<StoreReadDto> storeReadDtos;
private boolean hasNext;

public static StoreReadResponse fromEntity(List<StoreReadDto> storeReadDtos, boolean hasNext) {
public static StoreReadResponse of(List<StoreReadDto> storeReadDtos, boolean hasNext) {
return StoreReadResponse.builder()
.storeReadDtos(storeReadDtos)
.hasNext(hasNext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ public class StoreUpdateRequest {
private String name;
private String location;
private String description;
private String storeImageUrl;
private Boolean isActive;
}
25 changes: 8 additions & 17 deletions src/main/java/com/example/gtable/store/model/Store.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,48 +36,39 @@ public class Store extends BaseTimeEntity {

private String description;

private String storeImageUrl;

@Column(name = "is_active", nullable = false)
private Boolean isActive = false;

@Column
private Boolean deleted = false;

public Store(LocalDateTime createdAt, Long storeId, Long departmentId, String name, String location,
String description, String storeImageUrl, Boolean isActive, Boolean deleted) {
String description, Boolean isActive, Boolean deleted) {
super(createdAt);
this.storeId = storeId;
this.departmentId = departmentId;
this.name = name;
this.location = location;
this.description = description;
this.storeImageUrl = storeImageUrl;
this.isActive = isActive;
this.deleted = deleted;
}

public void setName(String name) {
public void updateInfo(String name, String location, String description) {
this.name = name;
}

public void setLocation(String location) {
this.location = location;
}

public void setDescription(String description) {
this.description = description;
}

public void setStoreImageUrl(String url) {
this.storeImageUrl = url;
public void markAsDeleted() {
this.deleted = true;
}

public void setIsActive(Boolean isActive) {
this.isActive = isActive;
public void activate() {
this.isActive = true;
}

public void setDeleted(Boolean deleted) {
this.deleted = deleted;
public void deactivate() {
this.isActive = false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
import com.example.gtable.store.dto.StoreUpdateRequest;
import com.example.gtable.store.model.Store;
import com.example.gtable.store.repository.StoreRepository;
import com.example.gtable.storeImage.dto.StoreImageUploadResponse;
import com.example.gtable.storeImage.model.StoreImage;
import com.example.gtable.storeImage.repository.StoreImageRepository;

import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
Expand All @@ -21,6 +24,7 @@
public class StoreServiceImpl implements StoreService {

private final StoreRepository storeRepository;
private final StoreImageRepository storeImageRepository;

@Override
@Transactional
Expand All @@ -38,15 +42,18 @@ public StoreReadResponse getAllStores() {
List<Store> stores = storeRepository.findAllByDeletedFalse();

List<StoreReadDto> storeRead = stores.stream()
.map(StoreReadDto::fromEntity)
.map(store -> {
List<StoreImage> images = storeImageRepository.findByStore(store);
List<StoreImageUploadResponse> imageDto = images.stream()
.map(StoreImageUploadResponse::fromEntity)
.toList();
return StoreReadDto.fromEntity(store, imageDto);
})
Comment on lines +45 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

이미지 데이터 통합 로직의 성능 최적화를 고려해보세요.

각 매장마다 개별적으로 이미지를 조회하는 방식은 N+1 쿼리 문제를 야기할 수 있습니다. 매장 수가 많아질 경우 성능에 영향을 줄 수 있습니다.

다음과 같은 개선을 고려해보시기 바랍니다:

// 배치 조회를 위한 개선된 방법
List<Long> storeIds = stores.stream()
    .map(Store::getStoreId)
    .toList();
Map<Long, List<StoreImage>> imagesByStoreId = storeImageRepository
    .findByStoreIdIn(storeIds)
    .stream()
    .collect(Collectors.groupingBy(image -> image.getStore().getStoreId()));

List<StoreReadDto> storeRead = stores.stream()
    .map(store -> {
        List<StoreImage> images = imagesByStoreId.getOrDefault(store.getStoreId(), List.of());
        List<StoreImageUploadResponse> imageDto = images.stream()
            .map(StoreImageUploadResponse::fromEntity)
            .toList();
        return StoreReadDto.fromEntity(store, imageDto);
    })
    .toList();
🤖 Prompt for AI Agents
In src/main/java/com/example/gtable/store/service/StoreServiceImpl.java around
lines 45 to 51, the current code fetches images for each store individually
causing N+1 query issues. To fix this, first collect all store IDs, then fetch
all images for these stores in a single batch query using a repository method
like findByStoreIdIn. Group the images by store ID into a map, and then when
mapping stores to DTOs, retrieve the images from this map instead of querying
per store. This reduces the number of database queries and improves performance.

.toList();

boolean hasNext = false;

return StoreReadResponse.fromEntity(
storeRead,
hasNext
);
return StoreReadResponse.of(storeRead, hasNext);
}

@Override
Expand All @@ -55,7 +62,12 @@ public StoreReadDto getStoreByStoreId(Long storeId) {
Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId)
.orElseThrow(() -> new EntityNotFoundException(storeId + " store not found."));

return StoreReadDto.fromEntity(store);
List<StoreImage> images = storeImageRepository.findByStore(store);
List<StoreImageUploadResponse> imageDto = images.stream()
.map(StoreImageUploadResponse::fromEntity)
.toList();

return StoreReadDto.fromEntity(store, imageDto);
}

@Override
Expand All @@ -64,20 +76,20 @@ public StoreReadDto updateStore(Long storeId, StoreUpdateRequest request) {
Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId)
.orElseThrow(() -> new EntityNotFoundException(storeId + " store not found."));

if (request.getName() != null)
store.setName(request.getName());
if (request.getLocation() != null)
store.setLocation(request.getLocation());
if (request.getDescription() != null)
store.setDescription(request.getDescription());
if (request.getStoreImageUrl() != null)
store.setStoreImageUrl(request.getStoreImageUrl());
if (request.getIsActive() != null)
store.setIsActive(request.getIsActive());
store.updateInfo(
request.getName(),
request.getLocation(),
request.getDescription()
);

Store updatedStore = storeRepository.save(store);

return StoreReadDto.fromEntity(updatedStore);
List<StoreImage> images = storeImageRepository.findByStore(updatedStore);
List<StoreImageUploadResponse> imageDto = images.stream()
.map(StoreImageUploadResponse::fromEntity)
.toList();

return StoreReadDto.fromEntity(updatedStore, imageDto);
}

@Override
Expand All @@ -86,7 +98,7 @@ public String deleteStore(Long storeId) {
Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId)
.orElseThrow(() -> new EntityNotFoundException(storeId + " store not found."));

store.setDeleted(true);
store.markAsDeleted();
storeRepository.save(store);

return "Store ID " + storeId + " 삭제되었습니다.";
Expand Down
Loading
Loading