Skip to content

Commit e58323d

Browse files
authored
[feat] 이미지 업로드 기능 구현
2 parents ec872bb + 57517cf commit e58323d

8 files changed

Lines changed: 191 additions & 36 deletions

File tree

build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,15 @@ dependencies {
3232
implementation 'org.springframework.boot:spring-boot-starter-web'
3333
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
3434

35+
// AWS S3 연동 라이브러리
36+
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.0'
37+
3538
// Spring Security
3639
implementation 'org.springframework.boot:spring-boot-starter-security'
3740

41+
// [추가] Spring Security Test 라이브러리
42+
testImplementation 'org.springframework.security:spring-security-test'
43+
3844
// ⬇JWT (Java JWT)
3945
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
4046
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'

src/main/java/com/web/coreclass/CoreclassApplication.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import org.springframework.security.crypto.password.PasswordEncoder;
1212

1313
@SpringBootApplication
14-
@EnableJpaAuditing
14+
//@EnableJpaAuditing
1515
@Slf4j
1616
public class CoreclassApplication {
1717

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.web.coreclass.global.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
5+
6+
@Configuration
7+
@EnableJpaAuditing
8+
public class JpaConfig {
9+
}

src/main/java/com/web/coreclass/global/config/SecurityConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
8484
.requestMatchers(HttpMethod.PUT, "/api/instructor/**").hasRole("ADMIN")
8585
.requestMatchers(HttpMethod.DELETE, "/api/instructor/**").hasRole("ADMIN")
8686

87+
// --- 이미지 업로드 API는 ADMIN 권한 필요 ---
88+
.requestMatchers(HttpMethod.POST, "/api/image/upload").hasRole("ADMIN")
8789
// --- 그 외 모든 요청 ---
8890
.anyRequest().authenticated()
8991
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.web.coreclass.global.s3;
2+
3+
import com.web.coreclass.global.s3.S3Uploader;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RequestPart;
12+
import org.springframework.web.bind.annotation.RestController;
13+
import org.springframework.web.multipart.MultipartFile;
14+
15+
@Tag(name = "Image Upload", description = "이미지 업로드 API")
16+
@RestController
17+
@RequestMapping("/api/image")
18+
@RequiredArgsConstructor
19+
public class ImageController {
20+
private final S3Uploader s3Uploader;
21+
22+
@Operation(summary = "이미지 업로드", description = "이미지 파일을 업로드하고 URL을 반환받습니다.")
23+
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
24+
public ResponseEntity<String> uploadImage(@RequestPart("file") MultipartFile file) {
25+
String imageUrl = s3Uploader.upload(file);
26+
return ResponseEntity.ok(imageUrl);
27+
}
28+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.web.coreclass.global.s3;
2+
3+
import io.awspring.cloud.s3.S3Template;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.web.multipart.MultipartFile;
8+
9+
import java.io.IOException;
10+
import java.util.UUID;
11+
12+
@Service
13+
@RequiredArgsConstructor
14+
public class S3Uploader {
15+
16+
private final S3Template s3Template;
17+
18+
@Value("${spring.cloud.aws.s3.bucket}")
19+
private String bucket;
20+
21+
public String upload(MultipartFile file) {
22+
if (file.isEmpty()) {
23+
throw new IllegalArgumentException("빈 파일은 업로드할 수 없습니다.");
24+
}
25+
26+
try {
27+
// 1. 파일 이름 중복 방지를 위해 UUID 사용 (예: uuid_originalName.png)
28+
String originalFileName = file.getOriginalFilename();
29+
String uuidFileName = UUID.randomUUID() + "_" + originalFileName;
30+
31+
// 2. S3에 업로드 (InputStream 사용)
32+
s3Template.upload(bucket, uuidFileName, file.getInputStream());
33+
34+
// 3. 업로드된 파일의 접근 가능한 URL 반환
35+
return s3Template.download(bucket, uuidFileName).getURL().toString();
36+
37+
} catch (IOException e) {
38+
throw new RuntimeException("이미지 업로드 중 오류가 발생했습니다.", e);
39+
}
40+
}
41+
}
Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,47 @@
11
spring.application.name=coreclass
22

3-
## Test InMemory DB
4-
#spring.datasource.url=jdbc:h2:mem:testdb
5-
#spring.datasource.driverClassName=org.h2.Driver
6-
#spring.datasource.username=sa
7-
#spring.datasource.password=
8-
9-
# DDL(??? ??/??) ??
3+
# DDL ??
104
spring.jpa.hibernate.ddl-auto=update
115

12-
# MySQL Setting
13-
#spring.datasource.url=jdbc:mysql://localhost:3306/sgea?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
14-
#spring.datasource.username=root
15-
#spring.datasource.password=root
16-
#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
17-
# MySQL Setting Deploy
18-
spring.datasource.url=second-generation-backend-db.c1wmgymweu6m.ap-northeast-2.rds.amazonaws.com
19-
spring.datasource.username=admin
20-
spring.datasource.password=admin123!
6+
# ==========================================
7+
# ? Database Setting (???? ??)
8+
# ==========================================
9+
spring.datasource.url=${DB_URL}
10+
spring.datasource.username=${DB_USERNAME}
11+
spring.datasource.password=${DB_PASSWORD}
2112
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
22-
# com.web.coreclass ???(?? ???? ???) ??? ?? ??? DEBUG? ??
13+
14+
# Logging
2315
logging.level.com.web.coreclass=DEBUG
2416

25-
# (Optional) H2 ?? ?? (??? ? ??)
26-
# spring.h2.console.enabled=true
17+
# ==========================================
18+
# ? AWS S3 Setting (???? ??)
19+
# ==========================================
20+
spring.cloud.aws.s3.bucket=${AWS_S3_BUCKET}
21+
spring.cloud.aws.region.static=ap-northeast-2
22+
spring.cloud.aws.credentials.access-key=${AWS_ACCESS_KEY}
23+
spring.cloud.aws.credentials.secret-key=${AWS_SECRET_KEY}
24+
25+
# File Upload Limit
26+
spring.servlet.multipart.max-file-size=10MB
27+
spring.servlet.multipart.max-request-size=10MB
28+
29+
# ==========================================
30+
# ? JWT Setting (???? ??)
31+
# ==========================================
32+
jwt.secret=${JWT_SECRET}
33+
jwt.expiration-ms=3600000
2734

28-
# Swagger Spring UI Setting
29-
# ?? ??? ????? ??
35+
# Swagger Config (?? ?? ??)
3036
springdoc.packages-to-scan=com.web.coreclass
31-
# API? ?? ? ???? ?? ??? ?? ??
3237
springdoc.default-consumes-media-type=application/json;charset=UTF-8
3338
springdoc.default-produces-media-type=application/json;charset=UTF-8
34-
# OpenAPI ?? ??? ?? ?? ?? ( true = ???? )
3539
springdoc.cache.disabled=true
36-
# OpenAPI ?? ??? ?? ??( ??? = /v3/api-docs )
3740
springdoc.api-docs.path=/api-docs/json
38-
# API ???? ???
3941
springdoc.api-docs.groups.enabled=true
40-
# Swagger UI ??? ??
4142
springdoc.swagger-ui.enabled=true
42-
# Swagger UI ?? ?? ( ??? = /Swagger-ui.html )
4343
springdoc.swagger-ui.path=/Swagger-ui.html
44-
# ?? & ????? ?????? ??
4544
springdoc.swagger-ui.tags-sorter=alpha
4645
springdoc.swagger-ui.operations-sorter=alpha
4746

48-
#JWT
49-
jwt.secret=NGMyZDliMGU1N2ZhYjU1NzViYjM2Y2VjYjhiMjYxNGE2M2EwNmMwZjVhZmE1ODRhNjRlN2E3MDIzMmE1ZDA3NQo=
50-
51-
# ?? ?? ?? (1?? = 3600 * 1000ms)
52-
jwt.expiration-ms=3600000
53-
54-
server.address = 0.0.0.0
47+
server.address=0.0.0.0
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.web.coreclass;
2+
3+
import com.web.coreclass.domain.admin.repository.AdminRepository;
4+
import com.web.coreclass.global.s3.ImageController;
5+
import com.web.coreclass.global.config.SecurityConfig; // ⬅️ 실제 설정 가져오기
6+
import com.web.coreclass.global.jwt.JwtAuthenticationFilter;
7+
import com.web.coreclass.global.jwt.JwtProvider;
8+
import com.web.coreclass.global.s3.S3Uploader;
9+
import org.junit.jupiter.api.DisplayName;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.beans.factory.annotation.Autowired;
12+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
13+
import org.springframework.boot.test.mock.mockito.MockBean;
14+
import org.springframework.context.annotation.Import;
15+
import org.springframework.http.MediaType;
16+
import org.springframework.mock.web.MockMultipartFile;
17+
import org.springframework.security.crypto.password.PasswordEncoder;
18+
import org.springframework.security.test.context.support.WithMockUser;
19+
import org.springframework.test.web.servlet.MockMvc;
20+
21+
import static org.mockito.ArgumentMatchers.any;
22+
import static org.mockito.BDDMockito.given;
23+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
24+
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
25+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
26+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
27+
28+
@WebMvcTest(ImageController.class)
29+
// 💡 핵심 1: 실제 SecurityConfig와 필터를 가져와서 환경을 똑같이 맞춥니다.
30+
@Import({SecurityConfig.class, JwtAuthenticationFilter.class})
31+
class ImageControllerTest {
32+
33+
@Autowired
34+
private MockMvc mockMvc;
35+
36+
@MockBean
37+
private S3Uploader s3Uploader;
38+
39+
// 💡 핵심 2: 필터가 동작할 때 필요한 '재료'만 가짜로 넣어줍니다.
40+
@MockBean
41+
private JwtProvider jwtProvider;
42+
43+
// 메인 앱(CoreclassApplication) 실행 시 필요한 빈들 (오류 방지용)
44+
@MockBean
45+
private AdminRepository adminRepository;
46+
47+
// SecurityConfig가 PasswordEncoder를 빈으로 등록하므로,
48+
// 여기서는 MockBean을 쓰지 않고 실제 빈을 사용하거나
49+
// 충돌 방지를 위해 굳이 선언하지 않아도 됩니다. (하지만 명시적 Mocking도 괜찮습니다)
50+
// 여기서는 SecurityConfig의 빈을 사용하도록 MockBean 생략
51+
52+
@Test
53+
@DisplayName("이미지 업로드 테스트: S3에 가지 않고 가짜 URL을 반환한다")
54+
@WithMockUser(roles = "ADMIN") // 관리자 권한으로 실행
55+
void uploadImageTest() throws Exception {
56+
// Given: 가짜 파일 생성
57+
MockMultipartFile fakeFile = new MockMultipartFile(
58+
"file",
59+
"test.png",
60+
MediaType.IMAGE_PNG_VALUE,
61+
"test image content".getBytes()
62+
);
63+
64+
// Mocking: s3Uploader가 호출되면 가짜 URL 반환
65+
given(s3Uploader.upload(any())).willReturn("https://fake-s3-url.com/test.png");
66+
67+
// When & Then
68+
mockMvc.perform(multipart("/api/image/upload")
69+
.file(fakeFile))
70+
// 💡 핵심 3: .with(csrf()) 제거 (SecurityConfig에서 이미 껐으므로 불필요)
71+
// 💡 핵심 4: .contentType(...) 절대 금지 (자동 설정을 방해함)
72+
.andExpect(status().isOk())
73+
.andExpect(content().string("https://fake-s3-url.com/test.png"))
74+
.andDo(print());
75+
}
76+
}

0 commit comments

Comments
 (0)