프로젝트/[어플리케이션] 토닥

[토닥] 경계선 지능인을 위한 앱 서비스 인증 서비스 Auth-Service

마달랭 2025. 5. 27. 13:39

개요

우선 앱과 웹을 사용하기 위해선 사용자가 필요하다. 사용자 편의성을 위해 OAuth 기반의 소셜 로그인과 같은 기능은 구현하지 않았고 이메일을 통한 회원 가입을 진행하였다. 구글 SMTP을 통한 이메일 인증 절차도 구현할지 논의를 하였지만 프로젝트 특성상 규모가 크지 않을 것을 고려하여 별도 검증 절차를 넣지 않은 점은 참고 바란다.

 

인증 서비스는 MariaDB를 통해 회원 정보를 관리하였고, 프로필 사진과 같은 데이터를 처리하기 위해 AWS S3를 사용해 스토리지에 프로필 이미지를 저장, URL기반의 이미지 파일 관리를 구현하였다. 또한 사용자 식별을 위해 로그인 시 JWT토큰을 발급하고, 만료된 토큰일 경우 토큰을 갱신해 주는 로직이 주된 서비스의 기능이다.

 

 

API 문서

 

소스 코드는 config부터 MVC패턴을 기반으로 나열하고자 한다.

 

 

프로젝트 구조

 

프로젝트 구조는 위 사진과 같다.

 

 

config.AwsS3Config.java

package com.a102.auth.config;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.Bucket;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Slf4j
@Configuration
public class AwsS3Config {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Value("${cloud.aws.s3.bucket}")
    private String bucketName;

    @Bean
    public AmazonS3 s3Client() {
        log.info("Initializing AWS S3 client with region: {}", region);

        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder.standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }

    @PostConstruct
    public void validateAwsConfig() {
        log.info("Validating AWS configuration...");
        log.info("AWS Region: {}", region);
        log.info("AWS Access Key exists: {}", accessKey != null && !accessKey.isEmpty());
        log.info("AWS Secret Key exists: {}", secretKey != null && !secretKey.isEmpty());
        log.info("S3 Bucket name: {}", bucketName);

        // 값 앞뒤 공백 검사
        if (region != null && !region.equals(region.trim())) {
            log.warn("AWS Region contains leading or trailing whitespace!");
        }
        if (bucketName != null && !bucketName.equals(bucketName.trim())) {
            log.warn("S3 Bucket name contains leading or trailing whitespace!");
        }
    }
}


AWS S3초기화 관련 로직이다. AWS관련 환경 변수를 GitLab CI/CD Variables로 받아와 초기화를 진행해 준다. 초기화가 성공적으로 진행되었다면 서버 로그에 S3 버킷이 잘 초기화 되었다는 정보가 출력 된다.

 

 

config.OpenApiConfig.java

package com.a102.auth.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

@Configuration
public class OpenApiConfig {

    @Value("${springdoc.server-url:http://k12a102.p.ssafy.io:8080}")
    private String serverUrl;

    @Bean
    public OpenAPI openAPI() {
        Info info = new Info()
                .title("인증 서비스 API")
                .version("v1.0")
                .description("인증 관련 API 문서");

        // API Gateway URL을 서버 URL로 사용
        Server server = new Server()
                .url(serverUrl)
                .description("API Gateway URL");

        // Bearer Authentication 설정
        SecurityScheme securityScheme = new SecurityScheme()
                .type(SecurityScheme.Type.HTTP)
                .scheme("bearer")
                .bearerFormat("JWT")
                .in(SecurityScheme.In.HEADER)
                .name("Authorization");

        return new OpenAPI()
                .servers(Collections.singletonList(server))
                .info(info)
                .components(new Components().addSecuritySchemes("bearerAuth", securityScheme));
    }
}

 

Swagger관련 로직이다. Auth-Service는 JWT토큰 인증이 필요하지 않지만 다른 서비스의 API 명세 문서와 일관성을 유지하기 위해 JWT관련 정보를 담아주었다. AccessToken을 입력 시 문서 내에선 이동 시에도 유지가 된다.

 

 

config.SecurityConfig.java

package com.a102.auth.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .httpBasic(httpBasic -> httpBasic.disable())
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/auth/**").permitAll()
                        .anyRequest().permitAll())
                .build();
    }
}


Java Security관련 로직이다. 마찬가지로 개발 환경에서는 API 게이트웨이에서 모든 엔드포인트에 대한 접근을 열어둔 상태이기 때문에 현재로선 의미가 없는 로직이다. 하지만 Auth-Service의 포트인 8081은 EC2에서 방화벽을 허용하지 않았기 때문에 서버로의 직접적인 접근은 허용하지 않는다.

 

 

contoller.AuthController.java

package com.a102.auth.controller;

import com.a102.auth.dto.request.LoginRequest;
import com.a102.auth.dto.request.RefreshTokenRequest;
import com.a102.auth.dto.request.SignupRequest;
import com.a102.auth.dto.response.LoginResponse;
import com.a102.auth.dto.response.SignupResponse;
import com.a102.auth.dto.response.TokenResponse;
import com.a102.auth.service.AuthService;
import com.a102.auth.service.S3Service;
import com.amazonaws.services.s3.AbstractAmazonS3;
import com.amazonaws.services.s3.model.Bucket;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.List;

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Tag(name = "인증 API", description = "회원가입, 로그인, 토큰 갱신 등 인증 관련 API")
public class AuthController {
    private final AuthService authService;

    @Operation(summary = "회원 가입", description = "계정 정보를 등록합니다, NOT NULL 목록 : (id, type, name, pwd), UNIQUE : (email)")
    @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<SignupResponse> signup(
            @RequestPart(value = "request") String requestJson,
            @RequestPart(value = "image", required = false) MultipartFile imageFile) throws IOException {

        ObjectMapper objectMapper = new ObjectMapper();
        SignupRequest request = objectMapper.readValue(requestJson, SignupRequest.class);

        SignupResponse signupResponse = authService.signup(request, imageFile);
        return ResponseEntity.ok(signupResponse);
    }

    @Operation(summary = "로그인", description = "로그인 성공 시 JWT토큰 정보를 반환합니다.")
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
        LoginResponse loginResponse = authService.login(request);
        return ResponseEntity.ok(loginResponse);
    }

    @Operation(summary = "토큰 갱신", description = "JWT토큰 갱신을 진행합니다.(토큰 만료 시 활용)")
    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refreshToken(@RequestBody RefreshTokenRequest request) {
        TokenResponse tokenResponse = authService.refreshToken(request);
        return ResponseEntity.ok(tokenResponse);
    }
}


HTTP요청과 URL기반으로 컨트롤러 매핑을 해주기 위한 로직이다. 기본 매핑 정보는 /auth이며 해당 경로로 들어오는 요청들은 API 게이트웨이를 통해 인증 서비스로 라우팅이 된다. 그 외에 Swagger세팅을 위한 주석 처리나 회원 가입 시 프로필 사진을 같이 HTTP요청을 받을 때 application/json타입이 아닌 Multipart/formdata로 받는 등에 대한 특이 사항이 있다.

 

dto.request.LoginRequest.java

package com.a102.auth.dto.request;

import lombok.*;

// LoginRequest.java
@Getter
@Setter
@NoArgsConstructor
public class LoginRequest {
    private String email;
    private String password;
}

 

로그인 요청을 받을 DTO이다. 이메일과 패스워드를 전달 받는다.

 

 

dto.request.RefreshTokenRequest.java

package com.a102.auth.dto.request;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
    private String refreshToken;
}


JWT토큰을 새로 발급하기 위한 로직이다. 리프레시 토큰을 전달 받는다.

 

 

dto.request.SignupRequest.java

package com.a102.auth.dto.request;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;

@Getter @Setter @NoArgsConstructor
public class SignupRequest {
    // 공통 필드 (NOT NULL)
    private String name;
    private String email;
    private String password;
    private String type; // "bid", "parent", "center"

    // 선택적 필드
    private String phone;
    private String address;

    // BIUser 관련 필드
    private String birthDate; // "yyyy-MM-dd" 형식
    private String gender;
    private String specialNeeds;

    // Center 관련 필드
    private String description;
}

 

회원가입 요청 관련 필드를 나열한 DTO다, 이름, 이메일, 비밀번호 및 타입은 NOT NULL로 모두 받아야 하며 type에 따라 추가 정보를 입력 받는다. 관련 서비스 및 레포지토리를 통해 DB와 트랜잭션하여 신규 회원을 알맞게 DB에 삽입하게 된다.

 

 

dto.response.ErrorResponse.java

package com.a102.auth.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
    private String code;
    private String message;
}

 

예외 발생 시 클라이언트에게 예외 정보를 반환해 주기 위한 DTO

 

 

dto.response.LoginResponse.java

package com.a102.auth.dto.response;

import lombok.*;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class LoginResponse {
    @Builder.Default
    private String status = "success";

    @Builder.Default
    private String message = "로그인 성공";

    private String id;
    private String email;
    private String name;
    private String type;
    private String accessToken;
    private String refreshToken;
    private long expiresIn;
}

 

로그인 성공 시 클라이언트에게 유저 정보를 반환하기 위한 DTO

 

 

dto.response.SignupResponse.java

package com.a102.auth.dto.response;

import lombok.*;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SignupResponse {
    @Builder.Default
    private String status = "success";

    @Builder.Default
    private String message = "회원가입 성공";
}


회원가입 성공 시 유저에게 반환하기 위한 DTO

 

 

dto.response.TokenResponse.java

package com.a102.auth.dto.response;

import lombok.*;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenResponse {
    private String id;
    private String email;
    private String type;
    private String accessToken;
    private String refreshToken;
    private long expiresIn;
}

 

토큰 갱신 시 유저 정보와 엑세스 토큰, 리프레시 토큰, 만료 시간을 반환하기 위한 DTO

 

 

entity.BiUser.java

package com.a102.auth.entity;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Table(name = "BiUser")
@Getter @Setter @NoArgsConstructor
public class BiUser {
    @Id
    @Column(name = "user_id")
    private String userId;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", insertable = false, updatable = false)
    private User user;

    @Column(name = "birth")
    private LocalDate birthDate;

    @Column(name = "gender")
    private String gender;

    @Column(name = "image")
    private String image;

    @Column(name = "needs")
    private String specialNeeds;

    @Column(name = "created")
    private LocalDateTime createdAt;

    @Column(name = "updated")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    @Builder
    public BiUser(String userId, LocalDate birthDate, String gender, String specialNeeds) {
        this.userId = userId;
        this.birthDate = birthDate;
        this.gender = gender;
        this.specialNeeds = specialNeeds;
    }
}


경계선 지능인 테이블을 참조하여 각 칼럼을 참조하기 위한 엔티티, 시간 정보는 객체 생성 시 로컬 시간으로 초기화 한다. 나머지 요소는 빌더 어노테이션 생성자를 통해 객체에 멤버 변수 정보를 저장한다.

 

 

entity.Center.java

package com.a102.auth.entity;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "Center")
@Getter @Setter @NoArgsConstructor
public class Center {
    @Id
    @Column(name = "user_id")
    private String userId;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", insertable = false, updatable = false)
    private User user;

    @Column(name = "descr")
    private String description;

    @Column(name = "created")
    private LocalDateTime createdAt;

    @Column(name = "updated")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    @Builder
    public Center(String userId, String description) {
        this.userId = userId;
        this.description = description;
    }
}

 

센터 테이블 관련 엔티티

 

 

entity.Parent.java

package com.a102.auth.entity;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "Parent")
@Getter @Setter @NoArgsConstructor
public class Parent {
    @Id
    @Column(name = "user_id")
    private String userId;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", insertable = false, updatable = false)
    private User user;

    @Column(name = "created")
    private LocalDateTime createdAt;

    @Column(name = "updated")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    @Builder
    public Parent(String userId) {
        this.userId = userId;
    }
}

 

부모 테이블 관련 엔티티

 

 

entity.User.java

package com.a102.auth.entity;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "User")
@Getter @Setter @NoArgsConstructor
public class User {
    @Id
    private String id;

    @Column(nullable = false)
    private String type;

    @Column(nullable = false)
    private String name;

    @Column(unique = true)
    private String email;

    @Column(name = "pwd", nullable = false)
    private String password;

    private String phone;

    @Column(name = "addr")
    private String address;

    @Column(name = "created")
    private LocalDateTime createdAt;

    @Column(name = "updated")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    @Builder
    public User(String id, String type, String name, String email, String password, String phone, String address) {
        this.id = id;
        this.type = type;
        this.name = name;
        this.email = email;
        this.password = password;
        this.phone = phone;
        this.address = address;
    }
}

 

유저 테이블 관련 엔티티

 

 

exeception.GlobalExceptionHandler.java

package com.a102.auth.exeption;

import com.a102.auth.dto.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) {
        log.warn("유효성 검증 예외 발생: {}", e.getMessage());
        ErrorResponse errorResponse = new ErrorResponse("BAD_REQUEST", e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    @ExceptionHandler(UsernameNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUsernameNotFoundException(UsernameNotFoundException e) {
        log.warn("사용자를 찾을 수 없음: {}", e.getMessage());
        ErrorResponse errorResponse = new ErrorResponse("NOT_FOUND", e.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<ErrorResponse> handleBadCredentialsException(BadCredentialsException e) {
        log.warn("인증 실패: 잘못된 자격 증명: {}", e.getMessage());
        ErrorResponse errorResponse = new ErrorResponse("UNAUTHORIZED", e.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
    }

    @ExceptionHandler(InvalidTokenException.class)
    public ResponseEntity<ErrorResponse> handleInvalidTokenException(InvalidTokenException e) {
        log.warn("유효하지 않은, 만료되었거나 손상된 토큰: {}", e.getMessage());
        ErrorResponse errorResponse = new ErrorResponse("UNAUTHORIZED", e.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception e) {
        log.error("처리되지 않은 예외 발생: ", e);
        // 스택 트레이스 전체를 로깅
        log.error("예외 상세 정보: ", e);
        // 예외 발생 위치 추적을 위한 추가 정보
        StackTraceElement[] stackTrace = e.getStackTrace();
        if (stackTrace.length > 0) {
            log.error("예외 발생 위치: {}.{} ({}:{})",
                    stackTrace[0].getClassName(),
                    stackTrace[0].getMethodName(),
                    stackTrace[0].getFileName(),
                    stackTrace[0].getLineNumber());
        }
        ErrorResponse errorResponse = new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다.");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

 

런타임 환경에서 예외 발생 시 예외 정보를 디버깅 및 클라이언트에게 알맞게 반환하기 위한 예외 핸들러 로직

 

 

repository.UserRepository.java

package com.a102.auth.repository;

import com.a102.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, String> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

 

유저 테이블 관련 트랜잭션 시 사용하기 위한 레포지토리 로직, 요청으로 인입된 이메일 기반으로 User테이블에 레코드가 존재할 경우 반환하고, 이메일 중복 여부를 검증하기 위한 로직이 작성되어 있다.

 

기타 레포지토리는 JPA기반 트랜잭션 처리를 하기 때문에 별도 내용이 없으므로 생략하도록 한다.

 

 

 

service.AuthService.java

package com.a102.auth.service;

import com.a102.auth.dto.request.LoginRequest;
import com.a102.auth.dto.request.RefreshTokenRequest;
import com.a102.auth.dto.request.SignupRequest;
import com.a102.auth.dto.response.LoginResponse;
import com.a102.auth.dto.response.SignupResponse;
import com.a102.auth.dto.response.TokenResponse;
import com.a102.auth.entity.*;
import com.a102.auth.exeption.InvalidTokenException;
import com.a102.auth.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
public class AuthService {
    private final UserRepository userRepository;
    private final BIUserRepository biUserRepository;
    private final ParentRepository parentRepository;
    private final CenterRepository centerRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final Logger log = LoggerFactory.getLogger(AuthService.class);
    private final S3Service s3Service;

    private boolean isValidEmail(String email) {
        String regex = "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$";
        return email != null && email.matches(regex);
    }

    private boolean isValidPhoneNumber(String phoneNumber) {
        String regex = "^010-\\d{4}-\\d{4}$";
        return phoneNumber.matches(regex);
    }

    @Transactional
    public SignupResponse signup(SignupRequest request, MultipartFile imageFile) {
        try {
            // 이메일 형식 검증
            if (!isValidEmail(request.getEmail())) {
                throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다.");
            }

            // 전화번호 형식 검증 (null이 아닐 경우)
            if (request.getPhone() != null && !request.getPhone().isEmpty() && !isValidPhoneNumber(request.getPhone())) {
                throw new IllegalArgumentException("전화번호는 010-XXXX-XXXX 형식이어야 합니다.");
            }

            // 이메일 중복 확인
            if (userRepository.existsByEmail(request.getEmail())) {
                throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
            }

            // UUID 생성
            String userId = UUID.randomUUID().toString();

            // User 객체 생성
            String userType = request.getType();

            try {
                User user = User.builder()
                        .id(userId)
                        .type(userType)
                        .name(request.getName())
                        .email(request.getEmail())
                        .password(passwordEncoder.encode(request.getPassword()))
                        .phone(request.getPhone())
                        .address(request.getAddress())
                        .build();

                userRepository.save(user);
            } catch (Exception e) {
                throw e;
            }

            // 사용자 유형에 따라 추가 정보 저장
            try {
            switch (userType) {
                case "bid":
                    LocalDate birthDate = null;
                    if (request.getBirthDate() != null && !request.getBirthDate().isEmpty()) {
                        birthDate = LocalDate.parse(request.getBirthDate());
                    }

                    BiUser biUser = BiUser.builder()
                            .userId(userId)
                            .birthDate(birthDate)
                            .gender(request.getGender())
                            .specialNeeds(request.getSpecialNeeds())
                            .build();

                    // 이미지 파일이 있으면 S3에 업로드
                    if (imageFile != null && !imageFile.isEmpty()) {
                        try {
                            String imageUrl = s3Service.uploadProfileImage(imageFile, userId);
                            biUser.setImage(imageUrl);
                        } catch (RuntimeException e) {
                            throw new RuntimeException("이미지 업로드 중 오류가 발생했습니다.", e);
                        }
                    }

                    biUserRepository.save(biUser);
                    break;

                case "parent":
                    Parent parent = Parent.builder()
                            .userId(userId)
                            .build();

                    parentRepository.save(parent);
                    break;

                case "center":
                    Center center = Center.builder()
                            .userId(userId)
                            .description(request.getDescription())
                            .build();

                    centerRepository.save(center);
                    break;
                }
            } catch (Exception e) {
                throw e;
            }

            return SignupResponse.builder().build();
        } catch (Exception e) {
            throw e;
        }
    }

    @Transactional
    public LoginResponse login(LoginRequest request) {
        // 이메일로 사용자 조회
        User user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        // 비밀번호 검증
        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
        }

        // 토큰 생성
        String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getType());
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());

        return LoginResponse.builder()
                .id(user.getId())
                .email(user.getEmail())
                .name(user.getName())
                .type(user.getType())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .expiresIn(jwtTokenProvider.getAccessTokenValidityInMilliseconds() / 1000)
                .build();
    }

    @Transactional
    public TokenResponse refreshToken(RefreshTokenRequest request) {
        // 리프레시 토큰 검증
        if (!jwtTokenProvider.validateRefreshToken(request.getRefreshToken())) {
            throw new InvalidTokenException("유효하지 않은 리프레시 토큰입니다.");
        }

        // 토큰에서 userId 추출
        String userId = jwtTokenProvider.getUserIdFromRefreshToken(request.getRefreshToken());

        // 사용자 정보 조회
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        // 새로운 토큰 생성
        String accessToken = jwtTokenProvider.createAccessToken(userId, user.getType());
        String refreshToken = jwtTokenProvider.createRefreshToken(userId);

        return TokenResponse.builder()
                .id(userId)
                .email(user.getEmail())
                .type(user.getType())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .expiresIn(jwtTokenProvider.getAccessTokenValidityInMilliseconds() / 1000)
                .build();
    }
}

 

정규식을 통해 이메일 형식이 맞는지, 핸드폰 번호 형식이 일치하는지 여부를 반환하는 함수와 회원가입, 로그인, 토큰 갱신 관련 서비스 로직을 처리하는 로직이다.

 

회원 가입 시 이메일 형식, 전화번호 형식을 검증하고, 이미 사용중인 이메일인이 검증을 마친 후 서비스 로직을 진행해 준다. 기본적으로 User테이블에 레코드를 삽입하고, type에 따라 센터, 부모, 경계선 지능인으로 분류하여 관련 테이블에 레코드를 추가적으로 삽입해 준다. 경계선 지능인인 경우 프로필 사진이 존재하기 때문에 S3와 연동하여 이미지 파일을 업로드 및 URL을 관련 테이블에 삽입해 준다.

 

 

service.JwtTokenProvider.java

package com.a102.auth.service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Base64;
import java.util.Date;

@Component
public class JwtTokenProvider {
    private final String secretKey;
    private final byte[] secretKeyBytes;
    private final long accessTokenValidityInMilliseconds;
    private final long refreshTokenValidityInMilliseconds;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey,
                            @Value("${jwt.access-token-validity-in-milliseconds}") long accessTokenValidity,
                            @Value("${jwt.refresh-token-validity-in-milliseconds}") long refreshTokenValidity) {
        // Base64로 인코딩된 문자열 형태로 저장 (로깅 등에 필요할 수 있음)
        this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        // 실제 서명에 사용할 바이트 배열은 원본 키에서 직접 생성
        this.secretKeyBytes = secretKey.getBytes();
        this.accessTokenValidityInMilliseconds = accessTokenValidity;
        this.refreshTokenValidityInMilliseconds = refreshTokenValidity;
    }

    public String createAccessToken(String userId, String userType) {
        Claims claims = Jwts.claims().setSubject(userId);
        claims.put("userType", userType);

        Date now = new Date();
        Date validity = new Date(now.getTime() + accessTokenValidityInMilliseconds);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(Keys.hmacShaKeyFor(secretKeyBytes))
                .compact();
    }

    public String createRefreshToken(String userId) {
        Claims claims = Jwts.claims().setSubject(userId);

        Date now = new Date();
        Date validity = new Date(now.getTime() + refreshTokenValidityInMilliseconds);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(Keys.hmacShaKeyFor(secretKeyBytes))
                .compact();
    }

    public String getUserIdFromRefreshToken(String token) {
        return Jwts.parser()
                .setSigningKey(Keys.hmacShaKeyFor(secretKeyBytes))
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public String getUserIdFromAccessToken(String token) {
        return Jwts.parser()
                .setSigningKey(Keys.hmacShaKeyFor(secretKeyBytes))
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateAccessToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(Keys.hmacShaKeyFor(secretKeyBytes))
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public boolean validateRefreshToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(Keys.hmacShaKeyFor(secretKeyBytes))
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public long getAccessTokenValidityInMilliseconds() {
        return accessTokenValidityInMilliseconds;
    }
}

 

JWT 토큰 발급과 관련된 비즈니스 로직이다. 환경 변수로 전달된 JWT 토큰 관련 바이트 키와 유효 기간 등을 생성자의 매개변수로 받아 엑세스 토큰과 리프레시 토큰 생성에 사용한다. 유지보수 측면으로 인증 서비스에 기능이 추가될 것을 위해 토큰을 검증하는 로직도 있지만 현재는 API 게이트웨이에서 인증 서비스에 JWT토큰 검증을 별도로 하지 않고 있기 때문에 사용하고 있지는 않다.

 

 

 

service.S3Service.java

package com.a102.auth.service;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.Bucket;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class S3Service {

    private final AmazonS3 s3Client;
    private final String bucketName;

    public S3Service(AmazonS3 s3Client, @Value("${cloud.aws.s3.bucket}") String bucketName) {
        this.s3Client = s3Client;
        this.bucketName = bucketName;
        log.info("S3Service initialized with bucket: {}", bucketName);
    }

    @PostConstruct
    public void init() {
        try {
            log.info("Testing S3 connection...");

            if (s3Client == null) {
                log.error("S3 client is null! Bean injection failed.");
                return;
            }

            List<Bucket> buckets = s3Client.listBuckets();
            log.info("S3 connection successful! Found {} buckets", buckets.size());

            boolean bucketExists = s3Client.doesBucketExistV2(bucketName);
            log.info("Bucket '{}' exists: {}", bucketName, bucketExists);

            if (!bucketExists) {
                log.warn("The specified bucket does not exist or you don't have access to it");
            }
        } catch (Exception e) {
            log.error("Failed to connect to AWS S3: {}", e.getMessage(), e);
        }
    }

    /**
     * 프로필 이미지를 S3에 업로드하고 URL을 반환
     */
    public String uploadProfileImage(MultipartFile file, String userId) {
        if (file == null || file.isEmpty()) {
            log.warn("Empty file provided for user: {}", userId);
            return null;
        }

        try {
            log.info("Uploading profile image for user: {}", userId);
            log.info("File details - Name: {}, Size: {}, Type: {}",
                    file.getOriginalFilename(), file.getSize(), file.getContentType());

            // 파일 확장자 추출
            String originalFilename = file.getOriginalFilename();
            String ext = originalFilename != null
                    ? originalFilename.substring(originalFilename.lastIndexOf("."))
                    : "";

            // 고유한 파일명 생성 (경로 포함)
            String storeFileName = "profiles/" + userId + "/" + UUID.randomUUID() + ext;
            log.info("Generated file path: {}", storeFileName);

            // 메타데이터 설정
            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentType(file.getContentType());
            metadata.setContentLength(file.getSize());

            // S3에 업로드
            log.info("Uploading to S3 - Bucket: {}, File path: {}", bucketName, storeFileName);
            s3Client.putObject(
                    new PutObjectRequest(bucketName, storeFileName, file.getInputStream(), metadata)
            );

            // S3 URL 반환
            String fileUrl = s3Client.getUrl(bucketName, storeFileName).toString();
            log.info("Upload successful, URL: {}", fileUrl);
            return fileUrl;

        } catch (IOException e) {
            log.error("IO Error uploading profile image for user: {}", userId, e);
            throw new RuntimeException("Failed to upload profile image - IO Error", e);
        } catch (Exception e) {
            log.error("Unexpected error uploading profile image for user: {}", userId, e);
            throw new RuntimeException("Failed to upload profile image - " + e.getMessage(), e);
        }
    }

    // 테스트용 메서드 추가
    public boolean testConnection() {
        try {
            s3Client.listBuckets();
            return true;
        } catch (Exception e) {
            log.error("S3 connection test failed: {}", e.getMessage());
            return false;
        }
    }
}

 

AWS S3 관련 서비스 로직이다. AWS 관련 세팅은 위 AwsS3Config에서 진행하였고, 생성자에서 S3 버킷 정보를 입력 받아 S3에 대한 초기화를 진행하고 이미지 파일을 스토리지에 업로드 및 접근 가능한 URL로 변환하여 반환하는 서비스 로직을 담당한다.

 

 

util.JwtUtil.java

package com.a102.auth.util;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import jakarta.annotation.PostConstruct;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.access-token-validity-in-milliseconds}")
    private Long expiration;

    private Key key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
    }

    public String generateToken(String email, String userId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("email", email);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userId)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(key)
                .compact();
    }
}

 

JWT 토큰 반환을 위해 작성된 로직이었으나 JwtTokenProvider클래스의 사용으로 현재는 사용하고 있지 않다.

 

 

AuthServiceApplication

package com.a102.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthServiceApplication.class, args);
    }
}

 

앱의 시작점이다.

 

 

application.yml

server:
  port: 8081

spring:
  application:
    name: auth-service

  servlet:
    multipart:
      enabled: true  # 기본값은 true이므로 생략 가능
      max-file-size: 10MB  # 필요에 따라 조정
      max-request-size: 10MB  # 필요에 따라 조정

  # MariaDB 설정 - 환경 변수 사용
  datasource:
    url: "${DB_URL}"
    username: "${DB_USERNAME}"
    password: "${DB_PASSWORD}"
    driver-class-name: org.mariadb.jdbc.Driver
    hikari:
      maximum-pool-size: 10  # 최대 연결 풀 크기 (서버 상황에 맞게 조정)
      minimum-idle: 7  # 최소 유휴 연결 수
      idle-timeout: 30000  # 연결 유휴 상태 30초 후 풀에서 제거
      connection-timeout: 30000  # 30초 내에 연결 얻지 못하면 예외 발생
      max-lifetime: 1800000  # 연결의 최대 수명 (30분)
      pool-name: RelationHikariCP

  jpa:
    hibernate:
      ddl-auto: none
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MariaDBDialect

cloud:
  aws:
    credentials:
      access-key: "${AWS_ACCESS_KEY_ID}"
      secret-key: "${AWS_SECRET_ACCESS_KEY}"
    region:
      static: "${AWS_REGION}"
    stack:
      auto: false  # CloudFormation 스택 자동 감지 비활성화
    s3:
      bucket: "${S3_BUCKET_NAME}"

springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
  server-url: "${API_GATEWAY_URL}"

jwt:
  secret: "${JWT_SECRET}"
  access-token-validity-in-milliseconds: 86400000
  refresh-token-validity-in-milliseconds: 604800000

# 로깅 레벨 설정
logging:
  level:
    com.a102.auth: DEBUG
    org.springframework.web: INFO
    org.hibernate: ERROR
    com.zaxxer.hikari: INFO
    com.zaxxer.hikari.HikariConfig: INFO

 

인증 서비스에 대한 설정 정보이다, 포트와 서비스 이름, MariaDB, JPA, AWS S3, Swagger, JPA와 로깅에 대한 설정 정보가 추가되어 있으며, 민감 정보는 마찬가지로 GitLab CI/CD Variables로 관리되고 있다.

 

 

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.2</version>
        <relativePath/>
    </parent>

    <groupId>com.a102</groupId>
    <artifactId>auth-service</artifactId>
    <version>1.0.0</version>
    <name>auth-service</name>
    <description>Authentication Service for A102 Project</description>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Spring Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- AWS S3 -->
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-java-sdk-s3</artifactId>
            <version>1.12.595</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-aws</artifactId>
            <version>2.2.6.RELEASE</version>
        </dependency>

        <!-- Spring Data JPA -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- MariaDB Driver -->
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- JWT -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>

        <!-- Validation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- SpringDoc OpenAPI (for Swagger) -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.3.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

 

mvn기반의 의존성 관리 파일이다. SpringBoot3.2.2 비저너 기반으로 작성되어 있다.

728x90