side project/여행 서비스 플랫폼 (24.05~)

[project_feature](kts, java) Firebase와 miniO를 활용한 Google로그인 사용자 회원가입 진행하기

기록하는 습관. 2024. 6. 15. 16:36

프로젝트 기술

Kotlin, Java(jdk21), Spring Boot(3.2.5), Spring Security(6.2.4), Firebase, miniO



개요

사이드 프로젝트에서 Google 로그인 사용자의 서비스 회원가입을 구현했습니다.
이번 사이드 프로젝트에서 좋은 기회로 FirebaseminiO를 사용할 수 있었습니다.
사용하며 알게 된 내용을 잊지 않기위해 상세히 기록하게 되었습니다.


해당 포스팅 Refactoring

[project_refactoring](kts, java) Firebase와 miniO를 활용한 Google로그인 사용자 회원가입 진행하기



STEP 1. Firebase와 miniO의 사용 목적

STEP 1-1. Firebase

인증은 Firebase에서 인가는 Server(Spring Boot)에서 처리한다.

많은 서비스에서는 사용자를 위한 간편로그인을 제공합니다. 저희 서비스에서도 간편 로그인을 제공하길 원했고 구현에 Firebase를 사용하게 되었습니다.

인증인가흐름

[배경지식]

  1. Client : 보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션 보통은 우리가 개발하려는 서비스
  2. 리소스소유자(Resource Owner) : 플랫폼(Google, Facebook..)에서 리소스를 소유하고 있는 소유자
  3. 인증 서버 (Authentication Server) : 토큰을 발급해주는 주체 서버
  4. 리소스 서버 (Resource Server) : 사용자의 보호된 자원을 호스팅하는 서버
    • 호스팅 서버? - 어떠한 서비스를 빌려서 사용한다는 것


가시적인 간편로그인의 흐름을 보면 위와 같습니다. 서비스에서 Firebase(정확히 Firebase SDK)를 사용하면 인증과 관련한 기능은 Firebase에서 처리를 하고 인가와 관련한 기능은 개발하려는 Server에서 처리하면 됩니다.

STEP 1-1-1 장점

  • Firebase에서는 여러 인증 제공자 (Google, Facebook...)을 지원하기 때문에 간편로그인의 기능 확장에도 용이하다고 할 수 있습니다.
  • 뿐만 아니라 Firebase는 자체적으로 보안에 중점을 두기 때문에 인증 데이터를 안전하게 관리할 수 있다는 장점도 있습니다.


STEP 1-2. miniO

저희 서비스에서는 사용자 프로필 이미지를 저장할 필요성이 있었습니다.
자주 사용하는 AWS S3의 대체 기술로 miniO를 차용했습니다.


STEP 1-2-1. 장점

  • 소프트웨어적인 비용 측면에서 비교해보았을 때, AWS S3는 사용량에 따라 비용이 청구되어지는 반면 miniO는 오픈 소스로 비용이 없다는 장점이 있습니다.

  • 이뿐만아니라, miniOAWS S3와 API가 완벽하게 호환된다는 장점이 있다고합니다. 관련해 세부 내용들은 학습해봐야 할 과제로 남이있습니다.



STEP 2. 구현

STEP 2-1. 종속성 설정

dependencies {
    // miniO
    implementation("io.minio:minio:8.5.10")

    // Firebase
    implementation("com.google.firebase:firebase-admin:8.0.0")
}

FirebaseminiO를 사용하기 위한 종속성 설정을 해주었습니다.


STEP 2-2 Firebase 회원가입 구현하기 (Kotlin + jdk21)

[서비스 흐름]

로그인흘므
  • 우리의 서비스에서 사용자가 Google 로그인을 누릅니다.
  • 이미 가입된 사용자라면 로그인을 합니다.
  • 만약 가입이 된 사용자가 아니라면 회원가입을 진행합니다.

[회원가입에 필요한 데이터]

  1. 사용자가 사용할 서비스 닉네임
  2. 사용자의

STEP 2-2-1 AuthContoller.java

  • AuthController.java
@RestController
public class AuthController {
    private final AuthService authService;

    AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity signUp(@AuthenticationPrincipal Jwt jwt,
                                 @ModelAttribute SignUpRequestDto signUpRequestDto) {
        authService.registerUser(signUpRequestDto, jwt);
        return new ResponseEntity(HttpStatus.OK);
    }
}

[1] @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)

속성값 value는 회원가입을 단순히 요청하는 endpoint를 나타냅니다.
consumes의 경우는 들어오는 데이터 타입을 정의합니다. MediaType.MULTIPART_FORM_DATA_VALUE의 값을 주어 클라이언트가 파일을 포함한 데이터를 멀티파트 형식으로 전송할 수 있도록 했습니다.

[멀트파트 형식이란?]
멀티파트 형식은 웹에서 파일 업로드 및 폼 데이터를 전송하는 데 사용되는 표준 형식입니다. 이는 특히 HTML 폼에서 파일과 텍스트 필드를 함께 전송할 때 유용합니다. 멀티파트 형식은 MIME(Multipurpose Internet Mail Extensions) 표준을 따르며, 다양한 종류의 데이터를 하나의 요청으로 전송할 수 있게 해줍니다.

[특징]

경계(boundary): 각 파트는 경계 문자열로 구분됩니다. 경계 문자열은 클라이언트가 전송하는 데이터의 각 부분을 구분하는 데 사용됩니다.

헤더: 각 파트는 자체적인 헤더를 가질 수 있습니다. 예를 들어, 파일 파트는 Content-DispositionContent-Type 헤더를 가질 수 있습니다.

바디: 각 파트의 실제 데이터가 포함된 부분입니다. 텍스트 필드는 텍스트 데이터를 포함하고, 파일 필드는 파일의 바이트 데이터를 포함합니다.


[2] @AuthenticationPrincipal Jwt jwt

저희 서비스에서 현재는 Spring Security의 UserDetailsService를 따로 커스텀하여 구현하지 않았습니다.
따라서 Resource Server에서 JWT토큰을 파싱하고 검증한 Jwt객체를 @AuthenticationPrincipal로 받아 직접적으로 사용했습니다.


[3] @ModelAttribute SignUpRequestDto signUpRequestDto

// 요청하는 파일 바이너리 데이터 예시

POST /api/signup HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="nickname"

JohnDoe
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="instagram"

john_doe
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="profileImage"; filename="profile.jpg"
Content-Type: image/jpeg

ÿØÿà..JFIF..ÿÛ„..C.....C...................................................
...........................................................................
ÿÀ..ÿÄ..µ..................ÿÄ..µ..................ÿÚ.„.....
(binarized image data continues here)
------WebKitFormBoundary7MA4YWxkTrZu0gW--

초기 작성시 @ReqeustBody로 작성했습니다. 기본적으로 @RequestBody는 JSON형식으로 처리하는데 이는 파일의 바이너리 데이터를 직접 포함하기에는 부적합하다고 합니다. 간단히 말해 파일의 이름만 전달하는 형태가 되기 때문에 이미지에 대한 정보는 담지 않게 된다는 의미입니다. 멀티파트 요청은 파일의 내용을 바이너리 데이터로 포함하게 되고 이를 바인딩하여 해결할 수 있는 녀석이 @ModelAttribute입니다.


@ModelAttribute를 조사한 내용으로 자세히 설명해보겠습니다.

  • @ModelAttribute는 HTML 폼 데이터나 JSON 객체를 Java 객체로 바인딩할 때 사용합니다.

  • 요청된 매개 변수를 자동으로 객체의 필드에 매핑합니다.

  • 주로 application/x-www-form-urlencoded 또는 multipart/form-data로 전송된 폼 데이터를 바인딩합니다.

  • @ModelAtrribute는 전체 폼 데이터를 하나의 객체로 바인딩합니다. 따라서 일반적인 폼 데이터를 처리하는데 적합합니다.

위에서 보신 것 처럼 저는 @ModelAttribute로 작성하여 이미지 파일을 처리하도록 했습니다.
코드 리뷰를 통해 @RequestPart로 사용하여 리팩토링을 진행했으며 위에 기술한 리팩토링 게시글에서 확인이 가능합니다..



STEP 2-2-2 AuthService.java (+ MinioService.kts, RestException)

  • AuthService.java
package io.growth6.pickok.java.service;

import io.growth6.pickok.domain.user.User;
import io.growth6.pickok.domain.user.UserRepository;
import io.growth6.pickok.internal.exception.RestException;
import io.growth6.pickok.java.dto.SignUpRequestDto;
import io.growth6.pickok.java.mapper.UserMapper;
import io.growth6.pickok.service.MinioService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.util.Optional;

@Service
public class AuthService {
    private static final Logger logger = LoggerFactory.getLogger(AuthService.class);

    private final UserMapper userMapper;
    private final MinioService minioService;
    private final UserRepository userRepository; // This type is defined Kotlin

    public AuthService(UserMapper userMapper,
                       MinioService minioService,
                       UserRepository userRepository) {
        this.userMapper = userMapper;
        this.minioService = minioService;
        this.userRepository = userRepository;
    }

    /*
     *   User의 객체를 받아서 회원가입을 담당하는 함수
     *   @param requestDto 사용자의 서비스 개인정보를 담고있는 DTO
     *   @param jwt 헤더, 페이로드, 서명값이 담긴 jwt 객체
     * */
    public void registerUser(SignUpRequestDto requestDto, Jwt jwt) {
        logger.info("Registering user with UID: {}", jwt.getClaimAsString("sub"));
        User user = createUser(requestDto, jwt);
        userRepository.save(user);
        logger.info("User registered successfully with UID: {}", jwt.getClaimAsString("sub"));

    }

    /*
    *   최종적으로 저장해야할 User객체를 만드는 함수
    *   @param requestDto 사용자의 서비스 개인정보를 담고있는 DTO
    *   @param jwt 헤더, 페이로드, 서명값이 담긴 jwt 객체
    * */
    public User createUser(SignUpRequestDto requestDto, Jwt jwt) {
        String profileStr = convertMultipartFileToString(requestDto.getProfileImage());
        verifyUidDuplicate(userRepository.existsByUid(jwt.getClaimAsString("sub")));
        return createUser(requestDto, jwt, profileStr);
    }

    /*
    *   ProfileStr이 존재할 때와 존재하지 않았을 때를 구분하여 User객체를 만드는 함수
    *   @param requsetDto 사용자가 회원가입할 때 입력하는 정보
    *   @param jwt 헤더, 페이로드, 서명값이 담긴 jwt 객체
    *   @param profileStr miniO에 저장된 profile의 이름
    *   @return 사용자 객체 반환
    * */
    public User createUser(SignUpRequestDto requestDto, Jwt jwt, String profileStr) {
        return Optional.ofNullable(profileStr)
                .map(profile -> userMapper.toUser(requestDto, jwt.getClaimAsString("sub"), profile))
                .orElseGet(() -> userMapper.toUser(requestDto, jwt.getClaimAsString("sub")));
    }

    /*
     *   MultipartFile을 String으로 변환시켜주는 함수
     *   @param multipartFile 요청 MultipartFile객체
     *   @return multipartFile이 String으로 변환된 값
     * */
    public String convertMultipartFileToString(MultipartFile multipartFile) {
        return Optional.ofNullable(multipartFile)
                .filter(file -> !file.isEmpty())
                .map(file -> {
                    try {
                        return minioService.putObject(file);
                    } catch (Exception e) {
                        logger.error("Failed to upload file to Minio", e);
                        throw new RuntimeException("Failed to upload file to Minio", e);
                    }
                })
                .orElse(null);
    }

    /*
    *   User가 이미 존재하는지 확인하는 함수
    *   @param existingUid 요청 uid를 대입하여 판별진행
    *
    *   @exception
    *     1. user가 이미 존재하는 경우 estException.Companion.conflict() 발생
    * */
    public void verifyUidDuplicate(boolean existingUid) {
        if(existingUid) {
            throw RestException.Companion.conflict("User with UID already exists", "Duplicate UID");
        }
    }

    /*
     *   User가 이미 존재하는지 확인하는 함수
     *   @param userOptional userOptional 객체를 받아와서 판별진행
     *
     *   @exception
     *    1. user가 이미 존재하는 경우 estException.Companion.conflict() 발생
     * */
    public void verifyUidDuplicate(Optional<User> userOptional) {
        userOptional.ifPresent(user -> {
            throw RestException.Companion.conflict("User with UID " + user.getUid() + " already exists.", "DUPLICATE_USER");
        });
    }
}

코드는 길지만 특별히 설명할 부분은 없고 팀원분께서 작성해주신 MinioService 코틀린 클래스만 보고 넘어가겠습니다. RestException의 경우도 같이 봐주면 좋을 것 같긴하지만, 제가 이해를 하지 못해 이 부분은 넘기고 추후에 다룰 수 있으면 다루겠습니다 :(


  • MinioService.kts
@Service
class MinioService(
    private val minioClient: MinioClient,
    private val minioProperties: MinioProperties,
) {
    fun putObject(file: MultipartFile): String {
        val filename = digest(file.inputStream)

        val args =
            PutObjectArgs.builder()
                .bucket(Constants.MINIO_BUCKET_NAME)
                .`object`(filename)
                .stream(file.inputStream, file.size, -1)
                .contentType(file.contentType)
                .build()

        minioClient.putObject(args)

        return "${minioProperties.endpoint}/${Constants.MINIO_BUCKET_NAME}/$filename"
    }

    private fun digest(inputStream: InputStream): String {
        val bytes = inputStream.readAllBytes()
        val result = MessageDigest.getInstance("SHA3-384").digest(bytes)
        return Base64.getUrlEncoder().encodeToString(result)
    }
}

해당 부분은 사용자에게 받는 이미지 파일을 MiniO의 bucket에 저장하는 로직을 처리하는 부분입니다.
digest함수는 파일의 해쉬값을 사용하여 bucket에 minio service에 저장되는 파일의 이름이 중복되지 않도록 하기위해 작성되었다고 합니다. 이 부분은 본인이 명확히 이해가 되지 않아 살펴볼 필요가 있습니다.


@ConfigurationProperties(prefix = "minio")
data class MinioProperties(
    val endpoint: String,
    val accessKey: String,
    val secretKey: String,
)

더불어 minioProperties의 경우는 miniO service에 접근하기 위한 정보를 따로 관리하는 코틀린의 데이터 클래스입니다. yml파일에 minio로 작성된 부분 하위 endpoint, accessKey, secretKey의 부분을 가져옵니다.