허용된 파일 형식 지정 : 업로드할 수 있는 파일의 종류를 미리 정해두고, 그 외의 파일을 업로드를 차단합니다.
ex) 이미지 파일을 허용하려고 .jpg, .png, .gif 등으로 제한.
MIME 타입 확인 : 클라이언트에서 전달된 MIME 타입뿐만 아니라 서버에서 실제 파일 내용을 확인하여 위조된 파일 타입을 방지합니다.
String contentType = file.getContentType();
if (!allowedTypes.contains(contentType)) {
throw new InvalidFileTypeException("허용되지 않은 파일 타입입니다.");
}
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=5MB
String originalFilename = file.getOriginalFilename();
String sanitizedFilename = StringUtils.cleanPath(originalFilename);
String uniqueFilename = UUID.randomUUID().toString() + "_" + sanitizedFilename;
try {
// 파일 업로드 로직
} catch (Exception e) {
logger.error("파일 업로드 중 오류 발생: ", e);
throw new FileUploadException("파일 업로드에 실패했습니다.");
}
신입 Java/Spring 백엔드 개발자가 파일 업로드 보안 기능을 실습하며 학습할 수 있는 몇 가지 프로젝트와 실습 과제를 소개하겠습니다. 이러한 실습을 통해 앞서 설명한 보안 고려사항을 직접 구현하고 이해할 수 있을 것입니다.
간단한 Spring Boot 애플리케이션을 만들어 사용자들이 파일을 업로드할 수 있는 기능을 구현합니다. 이 과정에서 파일 타입 검증, 크기 제한, 저장 위치 관리, 파일명 정제, 악성 코드 검사, 접근 제어, 안전한 전송 등 보안 고려사항을 하나씩 적용해봅니다.
spring-boot-starter-web, spring-boot-starter-security, spring-boot-starter-thymeleaf (프론트엔드가 필요한 경우), commons-io 등Spring Initializr를 사용하여 새로운 Spring Boot 프로젝트를 생성합니다. 필요한 의존성을 추가합니다.
Controller 클래스 생성:
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
private final FileStorageService fileStorageService;
@Autowired
public FileUploadController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
try {
fileStorageService.storeFile(file);
return ResponseEntity.ok("파일 업로드 성공");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("파일 업로드 실패");
}
}
}
Service 클래스 생성:
@Service
public class FileStorageService {
private final Path fileStorageLocation;
@Autowired
public FileStorageService(FileStorageProperties properties) {
this.fileStorageLocation = Paths.get(properties.getUploadDir())
.toAbsolutePath().normalize();
try {
Files.createDirectories(this.fileStorageLocation);
} catch (Exception ex) {
throw new FileStorageException("파일 저장 디렉토리를 생성할 수 없습니다.", ex);
}
}
public void storeFile(MultipartFile file) {
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
// 파일명 정제 및 고유 파일명 생성
String uniqueFilename = UUID.randomUUID().toString() + "_" + originalFilename;
try {
// 파일 타입 검증
if (!isAllowedFileType(file)) {
throw new InvalidFileTypeException("허용되지 않은 파일 타입입니다.");
}
// 파일 크기 제한 (예: 5MB)
if (file.getSize() > 5 * 1024 * 1024) {
throw new FileSizeExceededException("파일 크기가 너무 큽니다.");
}
// 파일 저장
Path targetLocation = this.fileStorageLocation.resolve(uniqueFilename);
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
// 악성 코드 검사 (기본 예제로 단순 출력, 실제로는 클램AV 연동 권장)
scanFileForMalware(targetLocation);
} catch (IOException ex) {
throw new FileStorageException("파일을 저장할 수 없습니다.", ex);
}
}
private boolean isAllowedFileType(MultipartFile file) {
List<String> allowedTypes = Arrays.asList("image/jpeg", "image/png", "image/gif");
return allowedTypes.contains(file.getContentType());
}
private void scanFileForMalware(Path filePath) {
// 실제 악성 코드 스캔 도구 연동 권장
// 예: ClamAV와 연동하여 스캔
// 여기서는 간단히 출력만
System.out.println("파일 스캔 완료: " + filePath.toString());
}
}
파일 저장 경로 설정 (application.properties):
file.upload-dir=uploads
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=5MB
파일 저장 속성 클래스:
@Configuration
@ConfigurationProperties(prefix = "file")
public class FileStorageProperties {
private String uploadDir;
// getters and setters
public String getUploadDir() {
return uploadDir;
}
public void setUploadDir(String uploadDir) {
this.uploadDir = uploadDir;
}
}
Spring Security 설정 클래스 생성:
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // CSRF 보호 비활성화 (개발 단계에서만)
.authorizeRequests()
.antMatchers("/api/files/upload").authenticated()
.anyRequest().permitAll()
.and()
.httpBasic(); // 간단한 인증 방식
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 인메모리 사용자 설정 (개발 단계)
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("password"))
.roles("USER");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이 설정은 /api/files/upload 엔드포인트에 접근하려면 인증이 필요하도록 합니다. 기본 인증을 사용하여 인메모리 사용자(user / password)를 설정했습니다. 실제 프로젝트에서는 데이터베이스를 사용한 사용자 관리가 필요합니다.
프론트엔드 없이 테스트하기:
Postman이나 cURL을 사용하여 파일 업로드를 테스트할 수 있습니다.
Postman 예시:
1. POST 요청을 http://localhost:8080/api/files/upload로 보냅니다.
2. Authorization 탭에서 Basic Auth를 선택하고 사용자명과 비밀번호 입력 (user / password).
3. Body 탭에서 form-data를 선택하고 키를 file로 설정한 후 업로드할 파일 선택.
4. 요청 전송 및 응답 확인.
cURL 예시:
curl -u user:password -F "file=@/path/to/your/file.jpg" http://localhost:8080/api/files/upload
서버에서 파일의 실제 내용을 검사하여 MIME 타입을 확인합니다. Apache Tika와 같은 라이브러리를 사용할 수 있습니다.
의존성 추가 (pom.xml):
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.6.0</version>
</dependency>
파일 타입 검사 메소드 수정:
import org.apache.tika.Tika;
private boolean isAllowedFileType(MultipartFile file) {
List<String> allowedTypes = Arrays.asList("image/jpeg", "image/png", "image/gif");
Tika tika = new Tika();
try {
String detectedType = tika.detect(file.getInputStream());
return allowedTypes.contains(detectedType);
} catch (IOException e) {
return false;
}
}
ClamAV와 같은 오픈 소스 바이러스 스캐너를 설치하고 Java 애플리케이션과 연동합니다. 여기서는 개념만 설명합니다.
예시:
private void scanFileForMalware(Path filePath) {
try {
ProcessBuilder pb = new ProcessBuilder("clamscan", filePath.toString());
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new MalwareDetectedException("악성 코드가 감지되었습니다.");
}
} catch (IOException | InterruptedException e) {
throw new FileStorageException("악성 코드 검사 중 오류 발생", e);
}
}
주의: 실제 환경에서는 ClamAV를 서비스로 실행하고 네트워크 프로토콜을 통해 연동하는 것이 일반적입니다.
파일을 웹 루트 외부에 저장하도록 설정합니다. application.properties에서 절대 경로를 지정하거나 환경 변수를 사용하여 외부 경로를 지정합니다.
file.upload-dir=/var/www/uploads
이미 구현한 UUID를 사용한 고유 파일명 생성을 유지합니다. 추가로 파일명에 특수 문자가 포함되지 않도록 정제합니다.
String sanitizedFilename = StringUtils.cleanPath(originalFilename);
if (sanitizedFilename.contains("..")) {
throw new FileStorageException("파일명에 부적절한 문자가 포함되어 있습니다.");
}
커스텀 예외 클래스 생성:
public class FileStorageException extends RuntimeException {
public FileStorageException(String message) {
super(message);
}
public FileStorageException(String message, Throwable cause) {
super(message, cause);
}
}
public class InvalidFileTypeException extends FileStorageException {
public InvalidFileTypeException(String message) {
super(message);
}
}
public class FileSizeExceededException extends FileStorageException {
public FileSizeExceededException(String message) {
super(message);
}
}
public class MalwareDetectedException extends FileStorageException {
public MalwareDetectedException(String message) {
super(message);
}
}
글로벌 예외 처리기 추가:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(FileStorageException.class)
public ResponseEntity<String> handleFileStorageException(FileStorageException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
// 기타 예외 처리
}
로깅 설정:
application.properties에 로깅 레벨 설정
logging.level.org.springframework=INFO
logging.level.com.yourpackage=DEBUG
Service 클래스에서 로그 추가:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class FileStorageService {
private static final Logger logger = LoggerFactory.getLogger(FileStorageService.class);
// 기존 코드...
public void storeFile(MultipartFile file) {
// 기존 코드...
try {
// 기존 파일 저장 로직
logger.info("파일 저장 성공: {}", uniqueFilename);
} catch (Exception e) {
logger.error("파일 저장 실패: ", e);
throw new FileStorageException("파일을 저장할 수 없습니다.", e);
}
}
}
AWS S3와 같은 클라우드 스토리지를 사용하여 파일을 저장하면 보안 관리가 용이해집니다. AWS SDK를 사용하여 S3에 파일을 업로드하는 방법을 학습할 수 있습니다.
의존성 추가 (pom.xml):
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.20.0</version>
</dependency>
S3 연동 서비스 구현 예시:
@Service
public class S3FileStorageService implements FileStorageServiceInterface {
private final S3Client s3Client;
private final String bucketName;
@Autowired
public S3FileStorageService(@Value("${aws.s3.bucket}") String bucketName) {
this.s3Client = S3Client.builder().region(Region.US_EAST_1).build();
this.bucketName = bucketName;
}
@Override
public void storeFile(MultipartFile file) {
String uniqueFilename = UUID.randomUUID().toString() + "_" + StringUtils.cleanPath(file.getOriginalFilename());
// 파일 타입 및 크기 검증
// ...
PutObjectRequest putReq = PutObjectRequest.builder()
.bucket(bucketName)
.key(uniqueFilename)
.contentType(file.getContentType())
.build();
try {
s3Client.putObject(putReq, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
// 클램AV 연동 등 추가 보안 기능
} catch (IOException e) {
throw new FileStorageException("S3에 파일을 업로드할 수 없습니다.", e);
}
}
}
application.properties 설정:
aws.s3.bucket=your-bucket-name
aws.access-key-id=YOUR_ACCESS_KEY
aws.secret-access-key=YOUR_SECRET_KEY
주의: AWS 자격 증명은 안전하게 관리해야 합니다. 환경 변수나 AWS IAM 역할을 사용하는 것이 좋습니다.
Spring Security를 사용하여 CSP 헤더를 설정할 수 있습니다. 이는 업로드된 파일이 의도치 않게 실행되지 않도록 도와줍니다.
SecurityConfig 클래스 수정:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/files/upload").authenticated()
.anyRequest().permitAll()
.and()
.httpBasic()
.and()
.headers()
.contentSecurityPolicy("default-src 'self'");
}
프론트엔드 인터페이스 추가:
파일 다운로드 기능 구현:
데이터베이스 연동:
테스트 케이스 작성:
CI/CD 파이프라인 설정:
파일 삭제 기능 추가:
로그 모니터링 및 알림:
이러한 실습을 통해 파일 업로드 보안 기능을 직접 구현하면서 Java와 Spring의 다양한 기능을 익히고, 보안에 대한 이해도를 높일 수 있을 것입니다. 꾸준한 실습과 학습을 통해 자신만의 프로젝트를 완성해 보세요!