diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40005c33..7f5ecca4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: jobs: build: runs-on: ubuntu-latest + environment: dev permissions: contents: read services: @@ -22,29 +23,32 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - + redis: + image: redis:6 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Setup JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'zulu' - - name: Run Redis - uses: supercharge/redis-github-action@1.1.0 - with: - redis-version: 6 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + uses: gradle/actions/setup-gradle@v5 - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Test with Gradle Wrapper - run: ./gradlew clean spotlessCheck test + - name: Build with Gradle + run: ./gradlew clean build env: NEO4J_URI: bolt://localhost:7687 NEO4J_USER: neo4j diff --git a/src/main/java/com/sillim/recordit/feed/repository/FeedImageRepository.java b/src/main/java/com/sillim/recordit/feed/repository/FeedImageRepository.java new file mode 100644 index 00000000..7caac5f3 --- /dev/null +++ b/src/main/java/com/sillim/recordit/feed/repository/FeedImageRepository.java @@ -0,0 +1,6 @@ +package com.sillim.recordit.feed.repository; + +import com.sillim.recordit.feed.domain.FeedImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedImageRepository extends JpaRepository {} diff --git a/src/main/java/com/sillim/recordit/feed/service/FeedCommandService.java b/src/main/java/com/sillim/recordit/feed/service/FeedCommandService.java index 16f7fdf5..40a9d4dd 100644 --- a/src/main/java/com/sillim/recordit/feed/service/FeedCommandService.java +++ b/src/main/java/com/sillim/recordit/feed/service/FeedCommandService.java @@ -9,9 +9,6 @@ import com.sillim.recordit.global.exception.ErrorCode; import com.sillim.recordit.global.exception.common.RecordNotFoundException; import com.sillim.recordit.global.util.FileUtils; -import com.sillim.recordit.rabbitmq.dto.Message; -import com.sillim.recordit.rabbitmq.dto.MessageType; -import com.sillim.recordit.rabbitmq.service.MessagePublisher; import jakarta.transaction.Transactional; import java.io.IOException; import java.util.List; @@ -27,7 +24,7 @@ public class FeedCommandService { private final FeedRepository feedRepository; private final ImageUploadService imageUploadService; - private final MessagePublisher messagePublisher; + private final FeedImageUploader feedImageUploader; public Long addFeed(FeedAddRequest request, List images, Long memberId) { Long feedId = feedRepository.save(request.toFeed(memberId)).getId(); @@ -35,23 +32,22 @@ public Long addFeed(FeedAddRequest request, List images, Long mem if (images == null || images.isEmpty()) { return feedId; } - messagePublisher.send( - new Message<>( - MessageType.IMAGES.name(), - images.stream() - .map( - image -> { - try { - return new FeedImageMessage( - feedId, - generateImageName(image), - image.getContentType(), - image.getBytes()); - } catch (IOException e) { - throw new RuntimeException(e); - } - }), - MessageType.IMAGES)); + + feedImageUploader.uploadImages( + images.stream() + .map( + image -> { + try { + return new FeedImageMessage( + feedId, + generateImageName(image), + image.getContentType(), + image.getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList()); return feedId; } diff --git a/src/main/java/com/sillim/recordit/feed/service/FeedImageUploader.java b/src/main/java/com/sillim/recordit/feed/service/FeedImageUploader.java new file mode 100644 index 00000000..a097bed3 --- /dev/null +++ b/src/main/java/com/sillim/recordit/feed/service/FeedImageUploader.java @@ -0,0 +1,62 @@ +package com.sillim.recordit.feed.service; + +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.sillim.recordit.feed.domain.Feed; +import com.sillim.recordit.feed.domain.FeedImage; +import com.sillim.recordit.feed.dto.FeedImageMessage; +import com.sillim.recordit.feed.repository.FeedImageRepository; +import com.sillim.recordit.feed.repository.FeedRepository; +import com.sillim.recordit.global.exception.ErrorCode; +import com.sillim.recordit.global.exception.common.ApplicationException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FeedImageUploader { + private static final String GCS_HOST = "https://storage.googleapis.com/"; + + @Value("${spring.cloud.gcp.storage.bucket}") + private String bucketName; + + private final Storage storage; + private final FeedRepository feedRepository; + private final FeedImageRepository feedImageRepository; + + @Async + @Retryable( + retryFor = {ApplicationException.class}, + backoff = @Backoff(delay = 2000)) + public void uploadImages(List images) { + for (FeedImageMessage image : images) { + uploadImage(image); + } + } + + public void uploadImage(FeedImageMessage image) { + try { + storage.createFrom( + BlobInfo.newBuilder(bucketName, image.fileName()) + .setContentType(image.contentType()) + .build(), + new ByteArrayInputStream(image.fileBytes())); + } catch (IOException e) { + throw new ApplicationException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + + String imageUrl = GCS_HOST + bucketName + "/" + image.fileName(); + Feed feed = + feedRepository + .findById(image.feedId()) + .orElseThrow(() -> new ApplicationException(ErrorCode.FEED_NOT_FOUND)); + feedImageRepository.save(new FeedImage(imageUrl, feed)); + } +} diff --git a/src/main/java/com/sillim/recordit/global/exception/ErrorCode.java b/src/main/java/com/sillim/recordit/global/exception/ErrorCode.java index 3bc51a2c..a358784b 100644 --- a/src/main/java/com/sillim/recordit/global/exception/ErrorCode.java +++ b/src/main/java/com/sillim/recordit/global/exception/ErrorCode.java @@ -12,6 +12,8 @@ public enum ErrorCode { TOO_MANY_REQUEST("ERR_GLOBAL_004", "너무 많은 요청을 보냈습니다."), UNHANDLED_EXCEPTION("ERR_GLOBAL_999", "예상치 못한 오류가 발생했습니다."), + IMAGE_UPLOAD_FAILED("ERR_IMAGE_001", "이미지 업로드에 실패했습니다."), + ID_TOKEN_UNSUPPORTED("ERR_OIDC_001", "지원되지 않는 ID Token 입니다."), ID_TOKEN_EXPIRED("ERR_OIDC_002", "ID Token이 만료되었습니다."), ID_TOKEN_INVALID_KEY("ERR_OIDC_003", "App Key가 유효하지 않습니다."), diff --git a/src/main/java/com/sillim/recordit/rabbitmq/dto/Message.java b/src/main/java/com/sillim/recordit/rabbitmq/dto/Message.java deleted file mode 100644 index b27556ba..00000000 --- a/src/main/java/com/sillim/recordit/rabbitmq/dto/Message.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sillim.recordit.rabbitmq.dto; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import lombok.ToString; - -@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) -@ToString -public class Message { - - private String title; - private T content; - private MessageType type; - - public Message(String title, T content, MessageType type) { - this.title = title; - this.content = content; - this.type = type; - } -} diff --git a/src/main/java/com/sillim/recordit/rabbitmq/dto/MessageType.java b/src/main/java/com/sillim/recordit/rabbitmq/dto/MessageType.java deleted file mode 100644 index cd230d7a..00000000 --- a/src/main/java/com/sillim/recordit/rabbitmq/dto/MessageType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sillim.recordit.rabbitmq.dto; - -public enum MessageType { - TEXT, - IMAGE, - IMAGES, -} diff --git a/src/main/java/com/sillim/recordit/rabbitmq/service/MessagePublisher.java b/src/main/java/com/sillim/recordit/rabbitmq/service/MessagePublisher.java deleted file mode 100644 index 5519d9d9..00000000 --- a/src/main/java/com/sillim/recordit/rabbitmq/service/MessagePublisher.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.sillim.recordit.rabbitmq.service; - -import com.sillim.recordit.rabbitmq.dto.Message; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class MessagePublisher { - - @Value("${rabbitmq.exchange.name}") - private String exchangeName; - - @Value("${rabbitmq.routing.key}") - private String routingKey; - - private final RabbitTemplate rabbitTemplate; - - public void send(Message message) { - log.info("[RabbitMQ] 메세지 전송: {}", message.toString()); - rabbitTemplate.convertAndSend(exchangeName, routingKey, message); - } -} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c6560cdd..fd056160 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,10 @@ spring: cloud: gcp: storage: + credentials: + location: ${GCP_KEY_LOCATION} bucket: ${GCP_BUCKET_NAME} + project-id: ${GCP_PROJECT_ID} security: oauth2: @@ -77,11 +80,6 @@ spring: username: ${NEO4J_USERNAME} password: ${NEO4J_PASSWORD} -rabbitmq: - queue.name: feedimage.queue - exchange.name: direct.exchange - routing.key: feedimage.key - firebase: credentials: location: ${FIREBASE_KEY_LOCATION} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index bf0326ac..bad381ef 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -27,7 +27,10 @@ spring: cloud: gcp: storage: + credentials: + location: ${GCP_KEY_LOCATION} bucket: ${GCP_BUCKET_NAME} + project-id: ${GCP_PROJECT_ID} security: oauth2: @@ -82,12 +85,6 @@ spring: username: neo4j password: verysecret -rabbitmq: - queue.name: sample.queue - exchange.name: sample.exchange - routing.key: sample.key - - firebase: credentials: location: ${FIREBASE_KEY_LOCATION} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 29570edd..e80794ff 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -18,10 +18,14 @@ spring: dialect: org.hibernate.dialect.MySQLDialect hbm2ddl: auto: validate + cloud: gcp: storage: + credentials: + location: ${GCP_KEY_LOCATION} bucket: ${GCP_BUCKET_NAME} + project-id: ${GCP_PROJECT_ID} security: oauth2: @@ -76,11 +80,6 @@ spring: username: ${NEO4J_USERNAME} password: ${NEO4J_PASSWORD} -rabbitmq: - queue.name: feedimage.queue - exchange.name: direct.exchange - routing.key: feedimage.key - firebase: credentials: location: ${FIREBASE_KEY_LOCATION} diff --git a/src/test/java/com/sillim/recordit/feed/service/FeedCommandServiceTest.java b/src/test/java/com/sillim/recordit/feed/service/FeedCommandServiceTest.java index 876aefe4..dee4d674 100644 --- a/src/test/java/com/sillim/recordit/feed/service/FeedCommandServiceTest.java +++ b/src/test/java/com/sillim/recordit/feed/service/FeedCommandServiceTest.java @@ -12,7 +12,6 @@ import com.sillim.recordit.feed.fixture.FeedFixture; import com.sillim.recordit.feed.repository.FeedRepository; import com.sillim.recordit.member.service.MemberQueryService; -import com.sillim.recordit.rabbitmq.service.MessagePublisher; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; @@ -30,7 +29,7 @@ class FeedCommandServiceTest { @Mock FeedRepository feedRepository; @Mock MemberQueryService memberQueryService; - @Mock MessagePublisher messagePublisher; + @Mock FeedImageUploader feedImageUploader; @InjectMocks FeedCommandService feedCommandService; @Test diff --git a/src/test/java/com/sillim/recordit/feed/service/FeedImageUploaderTest.java b/src/test/java/com/sillim/recordit/feed/service/FeedImageUploaderTest.java new file mode 100644 index 00000000..8d9f24e7 --- /dev/null +++ b/src/test/java/com/sillim/recordit/feed/service/FeedImageUploaderTest.java @@ -0,0 +1,129 @@ +package com.sillim.recordit.feed.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.sillim.recordit.feed.domain.Feed; +import com.sillim.recordit.feed.domain.FeedImage; +import com.sillim.recordit.feed.dto.FeedImageMessage; +import com.sillim.recordit.feed.fixture.FeedFixture; +import com.sillim.recordit.feed.repository.FeedImageRepository; +import com.sillim.recordit.feed.repository.FeedRepository; +import com.sillim.recordit.global.exception.ErrorCode; +import com.sillim.recordit.global.exception.common.ApplicationException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class FeedImageUploaderTest { + + @Mock Storage storage; + @Mock FeedRepository feedRepository; + @Mock FeedImageRepository feedImageRepository; + @InjectMocks FeedImageUploader feedImageUploader; + + @Test + @DisplayName("이미지를 GCS에 업로드하고 FeedImage를 저장한다.") + void uploadImage() throws IOException { + Long feedId = 1L; + Long memberId = 1L; + Feed feed = FeedFixture.DEFAULT.getFeed(memberId); + FeedImageMessage imageMessage = + new FeedImageMessage( + feedId, + "test-image.jpg", + "image/jpeg", + "test-content".getBytes(StandardCharsets.UTF_8)); + ReflectionTestUtils.setField(feedImageUploader, "bucketName", "test-bucket"); + given(feedRepository.findById(feedId)).willReturn(Optional.of(feed)); + + feedImageUploader.uploadImage(imageMessage); + + then(storage).should(times(1)).createFrom(any(BlobInfo.class), any(InputStream.class)); + then(feedImageRepository).should(times(1)).save(any(FeedImage.class)); + } + + @Test + @DisplayName("여러 이미지를 업로드할 수 있다.") + void uploadImages() throws IOException { + Long feedId = 1L; + Long memberId = 1L; + Feed feed = FeedFixture.DEFAULT.getFeed(memberId); + List imageMessages = + List.of( + new FeedImageMessage( + feedId, + "image1.jpg", + "image/jpeg", + "content1".getBytes(StandardCharsets.UTF_8)), + new FeedImageMessage( + feedId, + "image2.jpg", + "image/jpeg", + "content2".getBytes(StandardCharsets.UTF_8)), + new FeedImageMessage( + feedId, + "image3.jpg", + "image/jpeg", + "content3".getBytes(StandardCharsets.UTF_8))); + ReflectionTestUtils.setField(feedImageUploader, "bucketName", "test-bucket"); + given(feedRepository.findById(feedId)).willReturn(Optional.of(feed)); + + feedImageUploader.uploadImages(imageMessages); + + then(storage).should(times(3)).createFrom(any(BlobInfo.class), any(InputStream.class)); + then(feedImageRepository).should(times(3)).save(any(FeedImage.class)); + } + + @Test + @DisplayName("존재하지 않는 피드에 이미지를 업로드하면 예외가 발생한다.") + void uploadImageWithNonExistentFeed() { + Long feedId = 999L; + FeedImageMessage imageMessage = + new FeedImageMessage( + feedId, + "test-image.jpg", + "image/jpeg", + "test-content".getBytes(StandardCharsets.UTF_8)); + ReflectionTestUtils.setField(feedImageUploader, "bucketName", "test-bucket"); + given(feedRepository.findById(feedId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> feedImageUploader.uploadImage(imageMessage)) + .isInstanceOf(ApplicationException.class) + .hasMessage(ErrorCode.FEED_NOT_FOUND.getDescription()); + } + + @Test + @DisplayName("Storage에서 IOException 발생 시 예외가 전파된다.") + void uploadImageThrowsIOException() throws IOException { + Long feedId = 1L; + FeedImageMessage imageMessage = + new FeedImageMessage( + feedId, + "test-image.jpg", + "image/jpeg", + "test-content".getBytes(StandardCharsets.UTF_8)); + ReflectionTestUtils.setField(feedImageUploader, "bucketName", "test-bucket"); + given(storage.createFrom(any(BlobInfo.class), any(InputStream.class))) + .willThrow(new IOException("GCS connection failed")); + + assertThatThrownBy(() -> feedImageUploader.uploadImage(imageMessage)) + .isInstanceOf(ApplicationException.class) + .hasMessage(ErrorCode.IMAGE_UPLOAD_FAILED.getDescription()); + } +}