Skip to content

Latest commit

 

History

History
550 lines (411 loc) · 16.8 KB

File metadata and controls

550 lines (411 loc) · 16.8 KB

웹 애플리케이션 개선사항 문서

📋 개요

이 문서는 Spring Boot 웹 애플리케이션에 구현된 두 가지 주요 개선사항을 설명합니다:

  1. 다중 사용자 세션 지원 - 여러 사용자가 동시에 로그인할 수 있도록 개선
  2. 파일 업로드 기능 개선 - 다중 파일 업로드 및 자동 이름 변경 기능

🔐 TASK 1: 세션 처리 (Session Handling)

문제점 (이전)

  • 기존 코드는 새 사용자 로그인 시 기존 세션을 항상 invalidate()하여 다른 사용자의 세션을 강제 종료
  • 따라서 한 번에 1명의 사용자만 로그인 가능

해결책 (현재)

기존 세션 종료 로직을 제거하고, 각 로그인 시 새로운 독립적인 세션 생성

수정된 MemberController.java

로그인 처리 (checkMembers 메서드)

@PostMapping("/api/login_check")
public String checkMembers(@ModelAttribute AddMemberRequest request, Model model,
        HttpServletRequest request2, HttpServletResponse response) {
    try {
        // ✅ 새로운 세션을 생성하여 다른 사용자의 세션을 유지함 (다중 사용자 로그인 지원)
        HttpSession newSession = request2.getSession(true); // 새로운 세션 생성

        // 로그인 인증 확인
        Member member = memberService.loginCheck(request.getEmail(), request.getPassword());

        // 현재 사용자의 세션에만 정보 저장 (각 사용자마다 독립적인 세션 데이터)
        String sessionId = UUID.randomUUID().toString(); // 사용자별 고유 ID
        String email = request.getEmail();
        newSession.setAttribute("userId", sessionId);
        newSession.setAttribute("email", email);
        newSession.setAttribute("memberEmail", member.getEmail());

        model.addAttribute("member", member);
        return "redirect:/board_list";
    } catch (IllegalArgumentException e) {
        model.addAttribute("error", e.getMessage());
        return "login";
    }
}

로그아웃 처리 (member_logout 메서드)

@GetMapping("/api/logout")
public String member_logout(Model model, HttpServletRequest request2,
        HttpServletResponse response) {
    try {
        HttpSession session = request2.getSession(false); // 현재 사용자의 세션 가져오기
        if (session != null) {
            // ✅ 현재 사용자의 세션만 제거 (다른 사용자의 세션은 유지)
            String userEmail = (String) session.getAttribute("email");
            session.invalidate(); // 현재 사용자의 세션만 무효화

            // 현재 사용자의 쿠키만 삭제 (다른 사용자의 쿠키는 유지)
            Cookie cookie = new Cookie("JSESSIONID", null);
            cookie.setPath("/");
            cookie.setMaxAge(0); // 쿠키 삭제
            response.addCookie(cookie);

            System.out.println("사용자 로그아웃: " + userEmail);
        }
        return "login";
    } catch (IllegalArgumentException e) {
        model.addAttribute("error", e.getMessage());
        return "login";
    }
}

핵심 변경사항

항목 이전 현재
로그인 시 기존 세션 invalidate() → 모든 다른 사용자 강제 로그아웃 새로운 세션 생성 → 모든 사용자의 세션 유지
로그아웃 시 불필요한 새 세션 생성 현재 사용자의 세션만 정리
동시 로그인 ❌ 불가능 ✅ 가능 (각 사용자별 독립적인 세션)

세션 설정 (application.properties)

# 세션 타임아웃: 5분 (300초)
server.servlet.session.timeout=300s

# HTTPS 환경에서만 쿠키 전송 (보안)
server.servlet.session.cookie.secure=true

사용 예시

1. 사용자 A (alice@example.com) 로그인
   - 새 세션 ID 생성 (예: abc123...)
   - 세션 속성 저장: userId=abc123, email=alice@example.com

2. 사용자 B (bob@example.com) 로그인 (동시에)
   - 새 세션 ID 생성 (예: def456...)
   - 세션 속성 저장: userId=def456, email=bob@example.com
   - ✅ 사용자 A의 세션은 그대로 유지됨

3. 사용자 A 로그아웃
   - 사용자 A의 세션만 invalidate()
   - ✅ 사용자 B의 세션은 유지됨

📁 TASK 2: 파일 업로드 처리 (File Upload Handling)

요구사항

✅ 최대 2개 파일 동시 업로드 ✅ 파일명 중복 시 자동 이름 변경 ✅ 파일 크기 및 타입 검증 ✅ 상세한 에러 처리 및 사용자 친화적 에러 페이지

구현된 컴포넌트

1. FileUploadService.java (핵심 서비스)

주요 기능:

  • 단일 파일 업로드 처리
  • 다중 파일 업로드 처리 (최대 2개)
  • 파일 검증 (크기, 확장자)
  • 중복 파일명 자동 이름 변경 (타임스탐프 기반)

코드 예시:

@Service
public class FileUploadService {

    // 허용하는 파일 확장자
    private static final List<String> ALLOWED_EXTENSIONS = List.of(
        "txt", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
        "jpg", "jpeg", "png", "gif", "zip", "rar"
    );

    /**
     * 다중 파일 업로드 처리 (최대 2개)
     */
    public List<String> uploadFiles(MultipartFile[] files, String userEmail)
            throws IllegalArgumentException, IOException {
        // 파일 개수 검증
        if (files.length > 2) {
            throw new IllegalArgumentException("최대 2개의 파일만 업로드할 수 있습니다.");
        }

        // 사용자별 디렉토리 생성
        Path userUploadPath = Paths.get(uploadFolder, sanitizedEmail).toAbsolutePath();
        if (!Files.exists(userUploadPath)) {
            Files.createDirectories(userUploadPath);
        }

        List<String> uploadedFilenames = new ArrayList<>();

        for (MultipartFile file : files) {
            if (file.isEmpty()) continue;

            // 파일 검증 (크기, 확장자)
            validateFile(file);

            // 중복 파일명 처리
            String finalFilename = handleDuplicateFilename(
                file.getOriginalFilename(), userUploadPath
            );

            // 파일 저장
            Path filePath = userUploadPath.resolve(finalFilename);
            file.transferTo(filePath.toFile());

            uploadedFilenames.add(finalFilename);
        }

        return uploadedFilenames;
    }

    /**
     * 파일 검증 (크기, 확장자)
     */
    private void validateFile(MultipartFile file) throws IllegalArgumentException {
        // 파일 크기 검증 (기본: 10MB)
        if (file.getSize() > maxFileSize) {
            long maxSizeMB = maxFileSize / (1024 * 1024);
            throw new IllegalArgumentException(
                "파일 크기가 " + maxSizeMB + "MB를 초과했습니다."
            );
        }

        // 파일 확장자 검증
        String extension = getFileExtension(file.getOriginalFilename()).toLowerCase();
        if (!ALLOWED_EXTENSIONS.contains(extension)) {
            throw new IllegalArgumentException(
                "허용되지 않는 파일 타입입니다. (" + extension + ")"
            );
        }
    }

    /**
     * 중복 파일명 처리 - 타임스탐프 추가
     * 예: document.pdf → document_20231213_143022_123.pdf
     */
    private String handleDuplicateFilename(String originalFilename, Path directoryPath) {
        Path filePath = directoryPath.resolve(originalFilename);

        if (!Files.exists(filePath)) {
            return originalFilename; // 중복 없음
        }

        // 타임스탐프 기반 새로운 파일명 생성
        LocalDateTime now = LocalDateTime.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss_SSS");
        String timestamp = now.format(formatter);

        String nameWithoutExtension = getFilenameWithoutExtension(originalFilename);
        String extension = getFileExtension(originalFilename);

        String newFilename = nameWithoutExtension + "_" + timestamp + "." + extension;
        return newFilename;
    }
}

2. FileUploadRequest.java (요청 모델)

@Data
public class FileUploadRequest {
    // 사용자 이메일
    private String userEmail;

    // 최대 2개 파일
    private MultipartFile file1;
    private MultipartFile file2;

    // 비어있지 않은 파일만 배열로 반환
    public MultipartFile[] getFiles() {
        if (file2 != null && !file2.isEmpty()) {
            return new MultipartFile[] { file1, file2 };
        }
        if (file1 != null && !file1.isEmpty()) {
            return new MultipartFile[] { file1 };
        }
        return new MultipartFile[] {};
    }
}

3. FileController.java (컨트롤러)

기존 기능 유지:

@PostMapping("/upload-email")
public String uploadEmail(
    @RequestParam("email") String email,
    @RequestParam("subject") String subject,
    @RequestParam("message") String message,
    RedirectAttributes redirectAttributes) {
    // 텍스트 파일로 이메일 콘텐츠 저장
    // (기존 기능 유지)
}

새로운 다중 파일 업로드 기능:

@PostMapping("/upload-files")
public String uploadMultipleFiles(
    @RequestParam(value = "file1", required = false) MultipartFile file1,
    @RequestParam(value = "file2", required = false) MultipartFile file2,
    @RequestParam("userEmail") String userEmail,
    Model model,
    RedirectAttributes redirectAttributes) {

    try {
        // 파일 유효성 검증
        if ((file1 == null || file1.isEmpty()) && (file2 == null || file2.isEmpty())) {
            model.addAttribute("errorType", "NO_FILE");
            model.addAttribute("errorMessage", "업로드할 파일을 선택해주세요.");
            return "/error_page/file_upload_error";
        }

        // FileUploadService 사용
        MultipartFile[] files = new MultipartFile[] { file1, file2 };
        List<String> uploadedFilenames = fileUploadService.uploadFiles(files, userEmail);

        // 성공 메시지
        String message = uploadedFilenames.size() + "개의 파일이 성공적으로 업로드되었습니다.\n"
                        + "파일명: " + String.join(", ", uploadedFilenames);
        redirectAttributes.addFlashAttribute("message", message);

        return "redirect:/upload_end";

    } catch (IllegalArgumentException e) {
        // 파일 검증 실패 (크기, 타입)
        model.addAttribute("errorType", "VALIDATION_ERROR");
        model.addAttribute("errorMessage", e.getMessage());
        return "/error_page/file_upload_error";

    } catch (IOException e) {
        // 파일 저장 실패
        model.addAttribute("errorType", "UPLOAD_ERROR");
        model.addAttribute("errorMessage", "파일 업로드 중 오류가 발생했습니다.");
        return "/error_page/file_upload_error";
    }
}

파일 업로드 설정 (application.properties)

# 파일 업로드 활성화
spring.servlet.multipart.enabled=true

# 업로드 디렉토리
spring.servlet.multipart.location=./src/main/resources/static/upload

# 최대 요청 크기 (전체 폼)
spring.servlet.multipart.max-request-size=30MB

# 파일당 최대 크기
spring.servlet.multipart.max-file-size=10MB

파일명 중복 처리 예시

1. 첫 업로드: document.pdf → document.pdf (저장됨)
2. 두 번째 업로드 (같은 이름): document.pdf → document_20231213_143022_123.pdf (자동 이름 변경)
3. 세 번째 업로드: document.pdf → document_20231213_143023_456.pdf (다른 타임스탐프)

형식: [원본파일명]_[yyyyMMdd_HHmmss_SSS].[확장자]
예: report_20231213_143022_123.pdf

허용 파일 타입

문서: txt, pdf, doc, docx, xls, xlsx, ppt, pptx
이미지: jpg, jpeg, png, gif
압축: zip, rar

에러 처리 및 에러 페이지

에러 타입

에러 타입 메시지 HTTP 상태
NO_FILE 업로드할 파일을 선택해주세요 400
VALIDATION_ERROR 파일 크기/타입 오류 400
UPLOAD_ERROR 파일 저장 실패 500
UNKNOWN_ERROR 예상치 못한 오류 500

에러 페이지 (file_upload_error.html)

/error_page/file_upload_error.html
- 에러 타입 표시
- 상세 에러 메시지
- 해결 방법 안내
- 이전으로 돌아가기/홈으로 버튼

사용 방법

HTML 폼 예시

<form action="/upload-files" method="post" enctype="multipart/form-data">
  <!-- 사용자 이메일 -->
  <input type="email" name="userEmail" required />

  <!-- 첫 번째 파일 (필수) -->
  <input type="file" name="file1" required />

  <!-- 두 번째 파일 (선택사항) -->
  <input type="file" name="file2" />

  <button type="submit">업로드</button>
</form>

제공된 예제 페이지

/file_upload_form.html
- 다중 파일 선택 UI
- 파일 크기 클라이언트 검증
- 선택된 파일 정보 표시
- 사용자 친화적 안내

📊 디렉토리 구조

src/main/
├── java/com/waiyannaung/sku/
│   ├── controller/
│   │   ├── MemberController.java        (✅ 수정: 다중 세션)
│   │   └── FileController.java          (✅ 수정: 다중 파일 업로드)
│   └── model/
│       └── service/
│           ├── FileUploadService.java   (✨ 새로 생성)
│           └── FileUploadRequest.java   (✨ 새로 생성)
└── resources/
    ├── templates/
    │   ├── file_upload_form.html        (✨ 새로 생성: 예제)
    │   ├── upload_end.html              (기존 유지)
    │   └── error_page/
    │       └── file_upload_error.html   (✨ 새로 생성)
    └── application.properties           (기존 설정 확인)

✅ 테스트 체크리스트

세션 처리 테스트

  • 사용자 A 로그인
  • 사용자 B 로그인 (동시)
  • 사용자 A 게시판 접근 가능
  • 사용자 B 게시판 접근 가능
  • 사용자 A 로그아웃
  • 사용자 B 여전히 로그인 상태 확인
  • 사용자 B 로그아웃

파일 업로드 테스트

  • 1개 파일 업로드
  • 2개 파일 동시 업로드
  • 3개 파일 업로드 시도 → 에러
  • 10MB 이상 파일 업로드 → 에러
  • 허용되지 않는 파일 타입 (.exe, .bat) → 에러
  • 같은 이름 파일 2번 업로드 → 자동 이름 변경
  • 사용자별 독립적 디렉토리 확인
  • 에러 페이지 표시 확인

🔒 보안 고려사항

세션 보안

  1. 타임스탐프 기반 세션 ID

    • UUID 사용으로 세션 ID 예측 불가능
  2. 세션 타임아웃

    • 5분 (300초) 후 자동 로그아웃
    • 설정: server.servlet.session.timeout=300s
  3. HTTPS 필수

    • 쿠키 안전 전송 설정
    • server.servlet.session.cookie.secure=true

파일 업로드 보안

  1. 파일명 검증

    • 경로 조작 방지 (예: ../../../etc/passwd)
    • 특수문자 제거
  2. 파일 타입 검증

    • 화이트리스트 방식 (허용 목록만)
    • 확장자 기반 검증
  3. 파일 크기 제한

    • 개별 파일: 10MB
    • 전체 요청: 30MB
  4. 사용자별 디렉토리

    • 각 사용자의 파일을 독립적인 디렉토리에 저장
    • 사용자 간 파일 접근 방지

🚀 향후 개선 사항 (옵션)

  1. 파일 스캔

    • 바이러스 스캔 통합 (ClamAV 등)
  2. 파일 다운로드

    • 업로드된 파일 다운로드 기능
  3. 파일 관리

    • 파일 목록 조회
    • 파일 삭제 기능
    • 파일 공유 기능
  4. 로깅 및 모니터링

    • 모든 파일 업로드/다운로드 로깅
    • 의심 활동 감지
  5. 데이터베이스 연동

    • 파일 메타데이터 저장 (DB)
    • 업로드 이력 관리

📞 문제 해결

파일 업로드 실패

증상: 413 Payload Too Large 원인: 파일 크기가 제한을 초과 해결: application.properties에서 spring.servlet.multipart.max-file-size 증가

증상: 400 Bad Request 원인: 허용되지 않는 파일 타입 해결: FileUploadService.javaALLOWED_EXTENSIONS에 파일 타입 추가

세션 관련 문제

증상: 로그인 후 세션이 즉시 만료됨 원인: 세션 타임아웃 설정 너무 짧음 해결: application.propertiesserver.servlet.session.timeout 증가


📝 라이센스 및 버전

  • Spring Boot 버전: 3.5.8
  • Java 버전: 21
  • 작성일: 2024년
  • 상태: 프로덕션 준비 완료 ✅