728x90

개요

레디스 사용을 안해봤기 때문에 어려울 것 같았는데.. 막상하니 너무나도 쉬웠다. 

레디스 설정은 아래 블로그에서 설정하자.
[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정 :: 미정 (tistory.com)

 

[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정

* Docker-Desktop을 이용해 MySQL DBMS를 실행프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를

eesko.tistory.com

 

기존 MySQL을 사용해서 리프레시 토큰을 저장했는데 유효기간이 끝나면 삭제가 되어야 하는데 사진처럼 계속 디비에 남아있는 것을 볼 수 있다. 스케쥴링을 사용해서 매번 지워주는 번거로운 일을 해야 하기 때문에 만약 배포를 하게 되면 너무 번거로울 것 같아서 레디스를 도입하게 되었다. 

레디스의 TTL이라는 기술을 통해 만료시간을 정해주고 만료시간이 지나면 알아서 삭제되도록 구현하도록 하겠다.

 

 

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

 

 

의존성 설치

//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis' 

 

 

 

필요한 환경변수 세팅

spring:
 data:
  redis:
    host: localhost
    port: 6379
    password: 1234

 

 

 

 

RedisConfig 

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        return redisTemplate;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration
                = new RedisStandaloneConfiguration("localhost", 6379);
        redisStandaloneConfiguration.setPassword("1234");
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }
}
  • 여기서 host와 post 번호, password는 환경변수 (yml 파일 등)을 통해 안보이게 설정하는 것이 좋다.
  • 나는 개인 프로젝트로 연습용이라 그냥 서버에서 사용했지만 실제로 배포를 하게 되면 환경변수로 설정해서 보안을 위해 안보이게 하는 것이 좋다.

 

 

TokenService.java

@Service
public class RedisTokenService {

    private final RedisTemplate<String, String> redisTemplate;
    private final String REDIS_REFRESH_TOKEN_KEY_PREFIX = "refreshToken:";

    public RedisTokenService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 리프레시 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 사용자 ID를 값으로 저장합니다.
     * @param refreshToken 리프레시 토큰
     * @param expiration 토큰 만료 시간 (밀리초 단위)
     */
    public void addRefreshToken(String refreshToken, long expiration) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        redisTemplate.opsForValue().set(key, refreshToken, expiration, TimeUnit.MILLISECONDS);
    }

    /**
     * 주어진 리프레시 토큰이 유효한지 확인합니다.
     * @param refreshToken 리프레시 토큰
     * @return 유효성 여부
     */
    public boolean isRefreshTokenValid(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        String storedUserId = redisTemplate.opsForValue().get(key);
        return storedUserId != null;
    }

    /**
     * 주어진 리프레시 토큰을 삭제합니다.
     * @param refreshToken 리프레시 토큰
     */
    public void deleteRefreshToken(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        redisTemplate.delete(key);
    }



    /**
     * 주어진 리프레시 토큰에 대해 저장된 사용자 ID를 조회합니다.
     * @param refreshToken 리프레시 토큰
     * @return 저장된 사용자 ID
     */
    public String getRefreshToken(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        return redisTemplate.opsForValue().get(key);
    }
}
  • 리프레시 토큰을 레디스에 저장하는 로직이다. RedisTemplate를 사용해서 레디스에 저장하는 코드이다.
  • 레디스는 기본적으로 키:값 형태로 저장이 되는데,
  • 키 값으로 REDIS_REFRESH_TOKEN_KEY_PREFIX: {리프레시 토큰 값} 으로 저장을 했고
  • 값으로는 리프레시 토큰 값이 저장되도록 구현했다.

 

@Service
@Slf4j
public class BlackTokenRedisService {
    private final RedisTemplate<String, String> redisTemplate;
    private final String REDIS_BLACKLIST_KEY_PREFIX = "blacklist:";

    public BlackTokenRedisService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 블랙리스트 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 만료 시간을 값으로 저장합니다.
     * @param token 블랙리스트 토큰
     * @param expiration 만료 시간 (밀리초 단위)
     */
    public void addBlacklistedToken(String token, long expiration) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        redisTemplate.opsForValue().set(key, token, expiration, TimeUnit.MILLISECONDS);
    }

    /**
     * 주어진 블랙리스트 토큰이 유효한지 확인합니다.
     * @param token 블랙리스트 토큰
     * @return 유효성 여부
     */
    public boolean isTokenBlacklisted(String token) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        String storedToken = redisTemplate.opsForValue().get(key);
        return storedToken != null;
    }

    /**
     * 주어진 블랙리스트 토큰을 삭제합니다.
     * @param token 블랙리스트 토큰
     */
    public void deleteBlacklistedToken(String token) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        redisTemplate.delete(key);
    }
}
  • 로그아웃시 AccessToken을 Redis의 블랙리스트에 저장한다.
  • 왜냐면 다시 요청이 들어왔을때 더 이상 못쓰는 AccessToken라는 것을 알려줘야하기 때문에..

<진행 로직>

1. 로그인시 Redis에 토큰을 저장함.
2. Redis AccessToken이 저장되어있다는 것은 블랙리스트에 올려져 있다는 것이고 , 사용하면 안됨.
3. 해당 access token redis에 있다면 만료된 토큰인것.
4. 로그아웃 시 Access Token 을 blacklist에 등록하여 만료시키기
5. 로그아웃 시 Redis에 저장된 RefreshToken을 삭제하고 Blacklist Access Token을 등록.
6. 다시 로그인을 할 때 Blacklist 존재하는지 확인 (로그아웃 된 토큰인지)
7. 만약 사용자가 JWt를 통해 인가를 요청했을 시 블랙리스트의 조회를 통해 로그아웃한 JWT 토큰인지 아닌지 판별
 
  • 위와 같은 형태로 진행된다.
  • 즉 로그아웃한 엑세스 토큰은 더이상 사용해서는 안되는 토큰이기 때문에 해킹이 되었을 때 보안에 신경을 쓸 수 있게 된다.
  • 참고로 엑세스 토큰은 인증/인가 전용, 리프레시 토큰은 단순 엑세스 토큰을 재발급 해주는 용도라고 보면 된다.

 

 

Filter

@Slf4j
@RequiredArgsConstructor
public class RedisJwtFilter2 extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;
    private final BlackTokenRedisService blackTokenRedisService;
    private final RedisTokenService redisTokenService;

    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/login", "/api/login", "/de","/api/users/signup","/api/mail/.*","/api/verify-code",
            "/api/check-email","/css/.*","/js/.*", "/api/check-usernick", "/api/refreshToken", "/signup",
            "/oauth2/.*","/login/oauth2/.*", "/api/users/randomNickname", "/files/.*",
            "/api/reset-password", "/api/verify-temporary-password", "/my/change-password",
            "/v3/.*", "/swagger-ui/.*"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String requestPath = request.getRequestURI();
        if (isPermitAllPath(requestPath)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = getToken(request);

        if (!StringUtils.hasText(token)) {
            handleMissingToken(request, response);
        } else {
            handleTokenValidation(request, response, token);
        }
        filterChain.doFilter(request, response);
        log.info("토큰이 있는지 확인 : {}", token);
    }

    private boolean isPermitAllPath(String requestPath) {
        return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
    }

    private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken)) {
            try {
               String storedToken = redisTokenService.getRefreshToken(refreshToken);

                System.out.println(storedToken);

                if (refreshToken.equals(storedToken)) {
                    if (!jwtUtil.isRefreshTokenExpired(refreshToken)) {
                        String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
                        setAccessTokenCookie(response, newAccessToken);
                        getAuthentication(newAccessToken);
                    } else {
                        handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
                    }
                } else {
                    handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
                }
            } catch (ExpiredJwtException e) {
                handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
            }
        } else {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
        }
    }



    private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
        try {
            if (blackTokenRedisService.isTokenBlacklisted(token)) {
                handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
            } else {
                getAuthentication(token);
            }
        } catch (ExpiredJwtException e) {
            handleExpiredAccessToken(request, response, token, e);
        } catch (UnsupportedJwtException e) {
            handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
        } catch (MalformedJwtException e) {
            handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
        } catch (IllegalArgumentException e) {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
        } catch (Exception e) {
            handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
        }
    }

    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
        log.warn("Access token expired: {}", token);
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken) && !jwtUtil.isRefreshTokenExpired(refreshToken)) {
            String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
            setAccessTokenCookie(response, newAccessToken);
            getAuthentication(newAccessToken);
        } else {
            handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
        }
    }

    private String getToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private String getRefreshToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        response.addCookie(accessTokenCookie);
    }

    private void getAuthentication(String token) {
        Claims claims = jwtUtil.parseAccessToken(token);
        String username = claims.getSubject();
        Long userId = claims.get("userId", Long.class);
        String name = claims.get("name", String.class);
        List<GrantedAuthority> authorities = getGrantedAuthorities(claims);

        CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities);
        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
        List<String> roles = claims.get("roles", List.class);
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
        handleException(request, exceptionCode, logMessage, null);
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
        request.setAttribute("exception", exceptionCode.getCode());
        log.error(logMessage, e);
        log.info("로그인을 부탁드립니다. --> 토큰이 없어요.");
    }
}
  • 위 코드에서는 달라진 점은 크게 없다. 레디스 블랙리스트에 있는지 보는 부분 말고...

 

 

 

controller.java

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api")
public class RedisController2 {

    private final JWTUtil jwtUtil;
    private final UserServiceImpl userServiceimpl;
    private final PasswordEncoder passwordEncoder;
    private final RedisTokenService refreshTokenService;
    private final BlackTokenRedisService blackTokenRedisService;

    /**
     * 로그인 요청 시 JWT 토큰 발급
     */
    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> login(@RequestBody @Valid UserLoginDto userLoginDto,
                                                      BindingResult bindingResult,
                                                      HttpServletResponse response) {
        log.info("로그인 요청이 들어왔습니다.");
        log.info("아이디 :: {}", userLoginDto.getUsername());
        log.info("비밀번호 :: {}", userLoginDto.getPassword());

        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }

        Optional<User> user = userServiceimpl.findByUsername(userLoginDto.getUsername());

        if (!passwordEncoder.matches(userLoginDto.getPassword(), user .get().getPassword())) {
            return new ResponseEntity("비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED);
        }

        List<String> roles = user.get().getRoles().stream().map(Role::getName).collect(Collectors.toList());

        // 기존 리프레시 토큰 삭제
        refreshTokenService.deleteRefreshToken(String.valueOf(user.get().getId()));

        // 토큰 발급
        String accessToken = jwtUtil.createAccessToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);
        String refreshToken = jwtUtil.createRefreshToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);

        // 리프레시 토큰을 레디스에 저장
        refreshTokenService.addRefreshToken(refreshToken, jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT);

        // 토큰 쿠키 저장
        Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT / 1000));

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);

        // 응답 값
        UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.get().getId())
                .username(user.get().getUsername())
                .build();

        return new ResponseEntity<>(loginResponseDto, HttpStatus.OK);
    }

    /**
     * 로그아웃 요청
     */
    @GetMapping("/logout")
    public void logout(@CookieValue(name = "accessToken", required = false) String accessToken,
                       @CookieValue(name = "refreshToken", required = false) String refreshToken,
                       HttpServletResponse response) {
        log.info("로그아웃 요청이 들어왔습니다.");

        if (accessToken == null) {
            try {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Access token not found in cookies.");
            } catch (IOException e) {
                log.error("로그아웃 중 오류 발생", e);
            }
            return;
        }

        try {
            // JWT 블랙리스트에 추가
            Date expirationTime = Jwts.parser()
                    .setSigningKey(jwtUtil.getAccessSecret())
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getExpiration();

            log.info("만료시간: {}", expirationTime);

            // 블랙리스트에 추가
            blackTokenRedisService.addBlacklistedToken(accessToken, expirationTime.getTime() - System.currentTimeMillis());

            SecurityContextHolder.clearContext();

            // 쿠키 삭제
            Cookie accessCookie = new Cookie("accessToken", null);
            accessCookie.setPath("/");
            accessCookie.setMaxAge(0);
            response.addCookie(accessCookie);

            Cookie refreshCookie = new Cookie("refreshToken", null);
            refreshCookie.setPath("/");
            refreshCookie.setMaxAge(0);
            response.addCookie(refreshCookie);

            // 레디스에서 리프레시 토큰 삭제
            if (refreshToken != null) {
                refreshTokenService.deleteRefreshToken(refreshToken);
            }

            response.sendRedirect("/");  // 리디렉션 처리
        } catch (IOException e) {
            log.error("로그아웃 후 리디렉션 중 오류 발생", e);
        }
    }
}
  • 여기서 로그인에 성공하면 리프레시 토큰을 레디스에 담아준다.
  • 로그아웃 시 엑세스 토큰은 블랙리스트에 담아준다.

 

 

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOauth2UserService customOAuth2UserService;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final BlackTokenRedisService jwtBlacklistService;
    private final RedisTokenService refreshTokenService;
    private final CustomSuccessHandler customSuccessHandler;
    private final JWTUtil jwtUtil; //JWTUtil 주입

    public SecurityConfig(CustomUserDetailsService customUserDetailsService, CustomOauth2UserService customOAuth2UserService,
                          AuthenticationConfiguration authenticationConfiguration, CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
                          BlackTokenRedisService jwtBlacklistService, RedisTokenService refreshTokenService,
                          CustomSuccessHandler customSuccessHandler, JWTUtil jwtUtil) {

        this.customUserDetailsService = customUserDetailsService;
        this.customOAuth2UserService = customOAuth2UserService;
        this.authenticationConfiguration = authenticationConfiguration;
        this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
        this.jwtBlacklistService = jwtBlacklistService;
        this.refreshTokenService = refreshTokenService;
        this.customSuccessHandler = customSuccessHandler;
        this.jwtUtil = jwtUtil;
    }

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",      // 메인페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/login", // 로그인 페이지
            "/api/users/signup" // 회원가입 페이지
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable())
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -> csrf.disable())
                .httpBasic(httpBasic -> httpBasic.disable())
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)
                );
        return http.build();
    }

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

    @Bean
    public CorsConfigurationSource configurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("http://localhost:3000"); 
        config.addAllowedOrigin("*"); 
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE")); 
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
  • 여기서도 크게 달라진건 없고 레디스에 저장하기 위해 리프레시토큰, 블랙리스트 서비스로 변경해주면 된다.

 

 

이렇게 기존 RDB를 사용해서 토큰을 저장했는데 레디스를 사용함으로써 만료시간 지정해주고 더욱 서버의 부담을 줄여줄 수 있도록 구현했다.

 

 

 

728x90
728x90

개요

기존 이미지 업로드는 로컬에서 임의로 파일을 만들어서 구현했지만 이제는 AWS S3를 사용해서 안전하게 이미지를 저장해볼려고 한다.
용도 : 회원정보를 수정할 때 프로필 이미지를 등록

 

1. Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법

S3에 파일을 업로드하는 방법에는 3가지가 있다.

  • Stream 업로드
  • MultipartFile 업로드
  • AWS Multipart 업로드

1.1 Stream 업로드

Stream 업로드 방식은 파일을 chunk 단위로 읽어서 서버에 전송하는 방식이다. 직렬화된 데이터를 순차적으로 보내므로, 대용량 파일을 안정적으로 전송할 수 있지만, 각 chunk를 순차적으로 전송하기 때문에 전체 파일을 한 번에 업로드하는 방식보다 더 많은 시간이 소요될 수 있다.

1.2 MultipartFile 업로드

MultipartFile 업로드는 Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식이다. 대부분의 웹 개발 프레임워크 및 라이브러리에서 기본적으로 지원하는 방식이므로 구현이 간단하다. 또한 사용자가 파일을 선택하고 업로드 버튼을 클릭하는 것으로 파일을 서버에 업로드할 수 있다. 드어마 파일 전체를 메모리에 로드하므로, 대용량 파일을 처리할 때 메모리 부담이 있을 수 있다. 또한 파일을 서버에 업로드할 때 보안 취약성이 발생할 수 있다.

1.3 AWS Multipart 업로드

AWS Multipart 업로드는 AWS S3에서 제공하는 파일 업로드 방식으로 업로드할 파일을 작은 part로 나누어 개별적으로 업로드한다. 파일의 바이너리가 Spring Boot를 거치지 않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도 된다. 업로드 중에 오류가 발생해도 해당 부분만 재시도할 수 있다. 그러나 복잡한 구현을 필요로 하고 여러 요청을 병렬로 보내므로, 이에 따른 네트워크 및 데이터 전송 비용이 발생할 수 있다.

 

  • 원래 Presigned URL을 이용하는 3번 방식을 통해 구현해보고 싶었으나 다음과 같은 이유로 2번을 사용하려고 한다.
  • 3번의 경우 클라이언트로 부터 파일 업로드 요청을 받으면 프론트는 다시 백엔드로 그 파일을 전달해주고 백엔드에서 다시 S3로 업로드하는 불필요한 과정이 발생한다.
  • 프로젝트 규모가 현재 크지 않고, 빠르게 기능 구현을 해야하는 상황에서 나만의 호기심으로 사용해보기엔 구현 복잡도가 높다.
  • 따라서 현실과 타협해 Multipart 파일을 S3에 업로드하는 방법을 통해 이미지 업로드 기능을 구현하려고 한다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

 

 

 

개발 환경 및 S3 버킷 생성

[Spring] S3 이미지 업로드하기 (velog.io)

S3 에 업로드하기 위해서 aws 가입과 S3 발급을 해야 하는데 나는 이분의 블로그를 보고 따라했다. iam 사용자를 만들고 s3 키를 발급받자.

 

 

의존성 설치

// s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

 

 

필요한 환경변수 세팅

application.yml 파일에 다음과 같이 작성한다.

spring:
   servlet:
      multipart:
          maxFileSize: 10MB # 파일 하나의 최대 크기
          maxRequestSize: 30MB  # 한 번에 최대 업로드 가능 용량
cloud:
  aws:
    credentials:
      access-key: {your access-key}
      secret-key: {your secret-key}
    s3:
      bucket: {your bucket name}
    region:
      static: ap-northeast-2
    stack:
      auto: false
  • MultipartFile를 사용하기로 했으니 적절한 파일 크키와 용량을 지정하고 S3 연동을 위한 설정을 해준다.

 

 

S3Config

@Configuration
public class S3Config {
    @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;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredentials= new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }
}
  • 버전이 올라가면서 작성법이 많이 달라져서 고생을 좀 했지만 엑세스와 시크릿 키를 통해 접근을 허용해주면 된다.

 

 

 

이미지 S3 업로드 구현

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {

    private final AmazonS3 amazonS3;

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

    private final String DIR_NAME = "{your image file name";

    public String upload(String fileName, MultipartFile multipartFile) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));

        String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
        String uploadImageUrl = putS3(uploadFile, newFileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 파일 삭제
        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }

    private String putS3(File uploadFile, String fileName) {
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucket, fileName).toString();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }
  • S3 에 이미지를 업로드 하는 클래스이다.
  • 각 함수들에 대해서 설명해보겠다.

1. upload 함수

public String upload(String fileName, MultipartFile multipartFile) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));

        String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
        String uploadImageUrl = putS3(uploadFile, newFileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 파일 삭제
        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }
}
  • 여기서 MutipartFile을 File 형태로 변환해주어야 한다.
  • 이 과정에서 로컬에서 파일이 복사되어 저장되기 때문에 로컬에 있는 이미지를 삭제해주어야 한다. 

2. putS3 메서드

    private String putS3(File uploadFile, String fileName) {
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucket, fileName).toString();
    }
  • 이 함수는 MultipartFile형태인 파일을 File 형태로 변환하고 업로드될 파일의 이름을 받아와서 S3에 업로드 하는 함수이다.

3. removeNewFile  메서드

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }
  • 이 함수는 로컬에 저장된 파일을 삭제해주는 메서드이다.

4. Convert  메서드

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }
  • 이 함수는 MultipartFile 형태인 파일을 File 형태로 변환해서 S3에 업로드 하는 메서드이다.

5. getExtension  메서드

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }
  • S3에 저장될 파일의 확장자를 정의해준다.

 

 

UserServiceImpl.java

@Override
public User updateProfileImage(Long userId, MultipartFile file) throws IOException {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID"));

    String fileName = userId.toString(); // 사용자 ID를 파일명으로 사용
    String imageUrl = s3Service.upload(fileName, file); // S3에 이미지 업로드 및 URL 반환

    user.setFilename(file.getOriginalFilename()); // 파일 이름 저장
    user.setFilepath(imageUrl); // 파일 경로(URL) 저장

    return userRepository.save(user); // 사용자 정보 업데이트
}
  • S3에 올라갈 때 사용자 Id를 파일명으로 지정하여 올리기로 결정했고
  • 클라이언트에서 사용자가 이미지를 등록했을 때 동작할 서비스 클래스 코드이다.
  • 그리고 엔티티에 파일경로를 저장하기로 해서 user 엔티티에 2개의 칼럼을 추가해준다.

 

User.java

@Entity
@Table(name = "users")
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username; // 이메일

    @Column(nullable = false)
    private String password;

    private String filename; // 파일 이름

    private String filepath; // 파일 경로
}

 

 

 

Controller.java

@RestController
@Slf4j
@RequiredArgsConstructor
public class S3Controller {


    private final S3Service s3Service;
    private final UserService userService;

    @PostMapping("/api/s3/image")
    public ResponseEntity<String> uploadProfileImage(
            @RequestParam("file") MultipartFile file,
            @RequestParam("userId") Long userId) throws IOException {

        User user = userService.updateProfileImage(userId, file);
        log.info("Uploaded file URL: " + user.getFilepath());
        return ResponseEntity.ok(user.getFilepath());
    }
  • 지정한 저 앤드포인트로 요청을 보내서 서버측에서 요청을 처리할 예정이다.

 

 

 

HTML & JS

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
            // 프로필 이미지 업로드
            $('#profile-form').on('submit', function(event) {
                event.preventDefault();

                let formData = new FormData(this);
                formData.append("userId", $('#userId').val()); // 유저 ID 추가

                $.ajax({
                    type: 'POST',
                    url: '/api/s3/image',
                    data: formData,
                    processData: false,
                    contentType: false,
                    success: function(response) {
                        alert('프로필 이미지가 성공적으로 업로드되었습니다.');
                        $('#profile-image').attr('src', response);
                    },
                    error: function(error) {
                        alert('프로필 이미지 업로드에 실패했습니다.');
                    }
                });
            });
        });
    </script>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 당신의 이메일 주소 :: <span th:text="${user.username}"></span>님</p>
<p>안녕하세요, 당신의 이름 :: <span th:text="${user.name}"></span></p>

<!-- 프로필 이미지 편집 섹션 -->
<h3>프로필 이미지 편집</h3>
<input type="hidden" id="userId" th:value="${user.id}"/>
<form id="profile-form">
    <input type="file" name="file" accept="image/*" required />
    <button type="submit">프로필 이미지 업로드</button>
</form>

<img id="profile-image" th:src="${user.filepath != null ? user.filepath : '/files/lioon.png'}" alt="Profile Image" style="max-width: 150px; max-height: 150px;" />

</body>
</html>

자바스크립트 파일로 지정한 앤드포인트로 요청을 보내면 S3에 업로드가 될 것이다. 

기본 사진은 미리 로컬에 지정해둔 사진으로 보여질 수 있도록 했다. 그 후 사용자가 프로필을 변경하면 변경될 수 있도록..

 

 

 

화면단에서 사진도 잘 올라가고 잘 보인다~~~ㅎㅎ

 

 

 

 

 

 

 

 

 

 

 

이렇게 이미지를 S3에 올리는 부분을 수행했다.

 

 

 

728x90
728x90

* Docker-Desktop을 이용해 MySQL DBMS를 실행

프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.

일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를 사용하냐고 물어보면 

도커(Docker)를 사용하는 이유는 무엇일까? (velog.io)

 

도커(Docker)를 사용하는 이유는 무엇일까?

도커를 사용하면 개발 환경을 이미지화 시키고 해당 이미지를 통해 개발 및 배포 환경을 쉽게 컨테이너화 시켜 구축할 수 있다. 이런 도커를 사용하는 이유에 대해 간단히 알아보자.개발을 하다

velog.io

이 분의 글을 읽으면 좋을 것 같다. 협업 과정에서 고충을 겪을 환경설정의 문제를 해결해준다는 점! 

그럼 첫 번째는 MySQL 설치부터 할 것이다.

아래에 있는 각 단계를 따라오면 된다.

 

도커에서 mysql을 사용하기 위해서 할 일은 다음 4가지이다.

  • Windows를 모두 업데이트 한다.
  • Hyper-V(가상화) 또는 WSL2를 이용해 설치를 한다.
  • Windows 버전에 따라서 설치가 어려울 수도 있다. (Home버전?)
  • Docker-Desktop을 설치한다. → 설치만 ! 실행은 나중에

 

1. 도커 설치

  • Windows용 Docker-Desktop 다운로드

Docker Desktop: The #1 Containerization Tool for Developers | Docker

 

Docker Desktop: The #1 Containerization Tool for Developers | Docker

Docker Desktop is collaborative containerization software for developers. Get started and download Docker Desktop today on Mac, Windows, or Linux.

www.docker.com

도커에서 제공해주는 메뉴얼도 있다.

Install Docker Desktop on Windows | Docker Docs

 

Install Docker Desktop on Windows

Get started with Docker for Windows. This guide covers system requirements, where to download, and instructions on how to install and update.

docs.docker.com

 

 

2. Hyper-V(가상화) 또는 WSL2를 이용해 설치

  • 나는 윈도우에서 사용중이라 이 과정을 걸쳤다.
  • 윈도우 검색에서 “기능”이라고 입력하면 윈도우 기능 켜기/끄기가 보여진다.
  • 여기에서 ‘Hyper-V’ 또는 ‘Windows 하이퍼바이저 플랫폼’을 선택한다.
  • Hyper-V는 하이퍼바이저(Hypervisor)라고 한다. 하이퍼바이저는 단일 물리적 머신에서 여러 가상 머신을 실행하는 데 사용할 수 있는 소프트웨어이다.
  • 선택 → 확인 → 윈도우를 재시작

  • 하이퍼바이저가 실행되려면 BIOS에서 가상화 관련된 옵션이 사용가능해야한다.
  • PC마다 BIOS메뉴 구성은 다를 수 있다.
  • 자신의 메인보드 이름으로 구글에서 검색한다.
  • 보통 BIOS는 컴퓨터가 켜지기 전 DEL , F2 키를 빠르게 입력하면 진입 가능하다. 컴퓨터마다 진입 버튼은 다를 수 있으니 찾아봐야 함

 

3. WSL2 설치

  • 관련메뉴얼이다

WSL 설치 | 마이크로소프트 런 (microsoft.com)

  • Windows의 PowerShell을 관리자 권한으로 실행 후 아래 명령어 2줄을 입력해서 WSL을 활성화 한다.
# WSL2 설치
> dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
> dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
  • 이후 윈도우를 재시작한다.

 

3.1. WSL2 Linux 커널 업데이트

  • 본인 PC의 CPU 아키텍처에 맞춰 설치 파일을 다운로드 한다. 보통 x64용

x64 : https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi

ARM64 : https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_arm64.msi

  • Windows의 PowerShell을 관리자 권한으로 실행 후 다음 명령어 실행
# WSL2 Linux 커널 업데이트
> wsl --set-default-version 2

 

 

WSL2 명령 모음

# 사용가능한 Linux 배포판 리스트 보기
> wsl --list --online

# 특정 리눅스 배포판 설치하기 (Linux를 사용해보고 싶으면 나중에 설치해보기)
> wsl --install --distribution <Distribution Name>
# ex
> wsl --install --distribution Ubuntu

# 설치된 Linux 배포판 리스트
> wsl --list --verbose

# 특정 리눅스 배포판 삭제하기
> wsl --unregister <Distribution Name>
# ex
> wsl --unregister ubuntu

 

3.2 도커 실행

  • 이전에 다운로드한 Docker Desktop Installer.exe 파일을 실행하면 WSL2 환겨에서 실행할 것인지 묻는 checkbox가 보일 수 있다. (권장)
  • WSL은 Hyper-V 기반이 아닌데, WSL2는 Hyper-V 기반 !
  • 설치 완료 후, PC를 재시작한다.
  • 이후, docker-desktop을 실행하면 처음 보여지는 화면 동의(Accept)한다.
  • 첫 번쨰 항목을 선택하고 Finish 버튼을 클릭한다.
  • 윈도우가 재시작할 때 docker desktop이 자동으로 재시작되길 원한다면 첫 번째 체크박스를 설정한다. (Start Docker Desktop when you sign in to your computer)

 

3.3 WSL에 설치된 배포본을 확인

  • wsl에 docker-desktop이 설치된 것을 확인할 수 있다.
  • docker-desktop을 설치하고 실행하면 WSL2에 docker가 제공하는 리눅스가 설치된다.
  • 이 리눅스가 docker-desktop이며, docker로 실행되는 이미지들은 해당 리눅스 커널 위에서 동작한다고 보면 된다.
# WSL에 설치된 배포본 확인
> wsl --list --verbose

ex

 

 

 

3.4  MySQL DBMS를 실행하기 위한 docker-compose.yml 파일을 준비

  • Windows 10
    • c:/docker/mysql 폴더에 docker-compose.yml 파일을 작성
    • docker-compose.yml
version: "2"

services:
  vacation-db:
    image: mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "루트계정암호" # "root1234"
      MYSQL_DATABASE: "database이름"     # "examplesdb"
      MYSQL_USER: "아이디"               # "dms2756"
      MYSQL_PASSWORD: "암호"             # "123456789"
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./database/init/:/docker-entrypoint-initdb.d/
      - ./database/datadir/:/var/lib/mysql
    platform: linux/x86_64
    ports:
      - 3306:3306

 

3.5 docker-compose up -d

  • yml 파일이 있는 곳에서 터미널을 열고 MySQL 서버를 실행하는 명령어를 입력한다.
# MySQL 서버 실행
> docker-compose up -d
  • -d 옵션은 백그라운드로 실행하는 옵션이다.
  • 파일 내 volumes 설정은 Docker 안에서 실행되는 리눅스의 /docker-entrypoint-initdb.d/ , var/lib/mysql 폴더가 docker-compose.yml 파일이 원래 있는 경로에 database 폴더를 생성하고, 그 아래의 init, datadir 에 생성되도록 한다.
  • 원래 docker는 종료되면 docker에서 실행된 내용들은 모두 사라진다.
  • 이렇게 docker를 실행하는 컴퓨터 쪽의 디스크와 마운트 시킴으로써 사라지지 않고 계속 사용할 수 있게 할 수 있다.
  • # docker-compose.yml의 일부 volumes: - ./database/init/:/docker-entrypoint-initdb.d/ - ./database/datadir/:/var/lib/mysql

 

docker-compose.yml 파일이 있는 경로

  • docker-compose.yml 파일이 있는 곳에 database 폴더가 있는 것을 볼 수 있다.
  • 해당 폴더 안에 mysql의 data가 저장된다.
  • docker-compose down 명령을 실행하면 mysql 서버가 종료된다. database 폴더를 삭제하고 다시 실행하면 모든 데이터가 삭제된다. (주의)
  • docker ps 명령으로 실행되는 이미지에서 container id를 복사 후 mySQL 이미지에 접속한다.
> docker ps
# mysql 접속
> docker exec -it {복사한conainterID} /bin/bash

 

 

그럼 끝났다.

 

 


* Docker-Desktop을 이용해 Redis를 실행 

위의 과정을 통해 도커가 설치되어 있다는 가정 하에 아래를 진행한다.

 

 

1. 도커 폴더 아래 redis 폴더를 생성한다.

2. redis 폴더 아래 docker-compose.yml 작성한다.

services:
  redis:
    image: redis:latest # 최신 Redis 이미지를 사용
    container_name: redis
    command: ["redis-server", "--requirepass", "{password}", "--appendonly", "yes"]
    ports:
      - "6379:6379" # 로컬의 6379 포트를 Redis 컨테이너의 6379 포트에 매핑
    volumes:
      - ./redis-data:/data # 로컬의 'redis_data' 볼륨을 컨테이너의 /data 디렉토리에 매핑하여 데이터 영속성 유지

volumes:
  redis-data:

3. 터미널을 열어서 아래의 명령어를 입력한다.

# 레디스 이미지 가져오기
> docker pull redis

# 이미지 가져왔는지 확인
> cker images

# 레디스 실행
> docker compose up -d

> docker ps 
# 레디스 컨테이너 접속
> docker exec -it {레디스 컨테이너 아이디} redis-cli

# 레디스 암호
> AUTH {password} 

# 모든 키 검색
> KEYS * 

# 키 값 출력
> KEYS refreshToken:* 

# 해당 키에 대한 값 출력
> GET {키 값} 

# 해당키의 유효 시간 출력
> TTL {키 값} 

# 해당키 삭제
> DEL {키 값}

 

 

 

그럼 레디스도 잘 설치될 것이다.

728x90
728x90

개요

저번 포스트에서 2주 체험판 weather api를 사용했는데 2주가 끝나면 3일간의 날씨가 보여줄 수가 있다고 했다.

그래서 현재 코드는 7일간의 날씨가 보이도록 했지만 무료버전으로 바뀌게 되면 오류가 발생할 것 같아서 다른 방안을 찾아 다녔다.

그러다 제일 흔한 OpenWeather API의 무료버전에서 5일간의 날씨를 보여줄 수 있다고 한다.!!! 비록 일주일은 아니지만 5일만의 날씨가 보여지는 것만으로도 만족한다..ㅎㅎㅎ

 

 

프로젝트 구성

Current weather and forecast - OpenWeatherMap

 

Current weather and forecast - OpenWeatherMap

OpenWeather Weather forecasts, nowcasts and history in a fast and elegant way

openweathermap.org

여기서 회원가입 후 My API Keys를 들어가면 기본적인 API 키가 있을 것이다. 그게 끝이다...ㅎㅎ 
그리고 가입한 이메일을 가서 인증을 하고 1시간정도 기다리면 이제 api 키가 활성화가 되어서 사용이 가능해진다.

 

 

  • 이때 발급하면 나오는 API KEY를 application.yml 파일에 저장한다.
  • 알려져서는 안되는 키이다.
weather:
  api:
    key: {API key}

 

  • 그 후에 스프링에서 날씨 api를 불러쓰기 위해서 RestTemplate를 빈으로 등록한다.
@Configuration
public class DemoApplication {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
  • RestTemplate 이란?
    • HTTP 요청을 보내고 응답을 받기 위해 사용하는 클라이언트이다.
    • 외부 RESTful 웹 서비스와 통신할 때 사용된다.
    • 다른 서버의 API를 호출하여 데이터를 가져오거나, 서버로 데이터를 전송할 때 사용된다.
  • 왜 @Bean으로 등록?
    • Spring 컨텍스트 내에서 공유되며, 다른 컴포넌트에서 @Autowired를 통해 주입받아 사용할 수 있다.

 

 

날씨 API call 살펴보기

  • 그 다음으로 사이트에 들어가서  API -> {원하는 date} -> API doc 를 들어가서 url을 어떤식으로 작성해야 하는지 확인한다.
  • 하지만 무료버전/ 유료버전에 따라 다르기 때문에 잘 보고 해야한다.
  • 나는 현재날씨와 5일간의 날씨 2가지를 다 가져와서 보여지게 할 것이다.
  • 만약 현재 날씨만 가져올 것이면 Current Weather Data에 들어가서 보면 어떤 식으로 불러올지 나와있다.
  • 나는 현재 날씨는 지오코딩 api를 사용했다.

 

지오코딩은 번역기 돌려서..

 

 

 

이렇게 얘기한다 ㅎㅅㅎ지오코딩을 사용해서 현재 위치를 보여준 이유는 공식 문서에서 지오코딩 어쩌고 더 간단하다고 써봤는데... 

 

 

 

 

 

 

 

 

만약 지오코딩을 써서 현재의 날씨를 받아오면 아래와 같은 json으로 출력된다. 

[
   {
      "name":"London",
      "local_names":{
      },
      "lat":51.5073219,
      "lon":-0.1276474,
      "country":"GB",
      "state":"England"
   },

 

  • 그리고 5일간의 날씨 데이터와 3시간 간격으로 보여지도록 할 것이기에 5 Day / 3 Hour Forecast -> API doc 에 들어간다. 

  • 공식 문서에서 알려주는 듯이 api call 형태로 위도와 경도를 받아와서 보여주는 식으로 진행할 것이다. 
  • 그 다음으로 yml 저장한 키를 토대로 api 를 불러오기 위한 컨트롤러를 작성한다.
@RestController
@RequiredArgsConstructor
public class WeatherApiController {

    @Value("${weather.api.key}")
    private String apiKey;

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    @GetMapping("/api/weather")
    public ResponseEntity<String> getWeather(@RequestParam String location) {
        String geocodeUrl = String.format("http://api.openweathermap.org/geo/1.0/direct?q=%s&limit=1&appid=%s",
                location, apiKey);

        try {
            ResponseEntity<String> geocodeResponse = restTemplate.getForEntity(geocodeUrl, String.class);
            if (geocodeResponse.getStatusCode() != HttpStatus.OK || geocodeResponse.getBody() == null) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("위치 정보를 찾을 수 없습니다.");
            }

            JsonNode geocodeJsonArray = objectMapper.readTree(geocodeResponse.getBody());
            if (geocodeJsonArray.isEmpty()) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("위치 정보를 찾을 수 없습니다.");
            }

            JsonNode locationJson = geocodeJsonArray.get(0);
            double lat = locationJson.get("lat").asDouble();
            double lon = locationJson.get("lon").asDouble();

            // 5일 날씨 예보 URL
            String forecastUrl = String.format("https://api.openweathermap.org/data/2.5/forecast?lat=%s&lon=%s&appid=%s&units=metric",
                    lat, lon, apiKey);

            String forecastResponse = restTemplate.getForObject(forecastUrl, String.class);
            return ResponseEntity.ok(forecastResponse);

        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("날씨 정보를 가져오는 데 실패했습니다.");
        }
    }
}
  • 위 코드는 지오코딩으로 현재 날씨에서 json을 받아오고 응답한 뒤, 그 값에서 아래의 코드를 통해 경도와 위도를 받아온다.
            JsonNode locationJson = geocodeJsonArray.get(0);
            double lat = locationJson.get("lat").asDouble();
            double lon = locationJson.get("lon").asDouble();
  • 위에서 봤듯이 json 출력 배열의 첫 번째 요소 중 경도와 위도를 가져오는 부분이다.
  • 특정 위치의 날씨 정보를 가져오는 데 필요하다.

 

 

 

 

HTML, JS 코드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>OMG Travel</title>
    <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js'></script>
    <link rel="stylesheet" href="/css/header.css">
</head>
<body>
<div th:replace="fragments/header :: headerFragment"></div>
<div class="wrapper">
    <div class="container">
        <div class="left-content">
            <div class="weather-section">
                <div class="search-container">
                    <input type="text" id="location-input" class="search-input" placeholder="지역 입력">
                    <button id="search-button" class="search-button">검색</button>
                </div>
                <div>
                    <h3>현재 날씨</h3>
                    <div id="current-weather"></div>
                    <div id="current-date" style="display: none;"></div>
                    <h3>이번 주 날씨</h3>
                    <div class="weather" id="weather-container"></div>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {
<!-- 날씨 API 불러오기 -->
        const weatherContainer = document.getElementById('weather-container');
        const currentWeather = document.getElementById('current-weather');
        const currentDateEl = document.getElementById('current-date');

        function fetchWeather(location) {
            fetch(`/api/weather?location=${encodeURIComponent(location)}`)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP 접속 오류 상태: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    // 날짜별로 날씨 정보를 집계
                    const weatherMap = {};
                    data.list.forEach(item => {
                        const date = new Date(item.dt * 1000);
                        const options = {weekday: 'short', month: 'short', day: 'numeric'};
                        const formattedDate = date.toLocaleDateString('ko-KR', options);

                        if (!weatherMap[formattedDate]) {
                            weatherMap[formattedDate] = {
                                tempSum: 0,
                                count: 0,
                                weatherDescription: item.weather[0].description,
                                iconCode: item.weather[0].icon
                            };
                        }
                        weatherMap[formattedDate].tempSum += item.main.temp;
                        weatherMap[formattedDate].count += 1;
                    });

                    // 평균 온도 계산 및 출력
                    const weatherItems = Object.keys(weatherMap).map(date => {
                        const weatherInfo = weatherMap[date];
                        const averageTemp = (weatherInfo.tempSum / weatherInfo.count).toFixed(1);
                        const iconUrl = `http://openweathermap.org/img/wn/${weatherInfo.iconCode}@2x.png`;

                        return `
                    <div class="weather-item">
                        <div class="day">${date}</div>
                        <div class="temp">${averageTemp}°C</div>
                        <img src="${iconUrl}" alt="날씨 아이콘" class="weather-icon"/>
                        <div>${weatherInfo.weatherDescription}</div>
                    </div>
                `;
                    }).join('');

                    weatherContainer.innerHTML = weatherItems;

                    const today = new Date();
                    const options = {year: 'numeric', month: 'long', day: 'numeric'};
                    const formattedDate = today.toLocaleDateString('ko-KR', options);

                    // 현재 날씨를 보여주는 부분
                    const currentItem = data.list[0];
                    currentWeather.innerHTML = `
                <div class="current-temp">${currentItem.main.temp}°C</div>
                <div class="current-description">${currentItem.weather[0].description}</div>
                <img src="http://openweathermap.org/img/wn/${currentItem.weather[0].icon}@2x.png" alt="날씨 아이콘" class="weather-icon"/>
            `;
                    currentDateEl.innerHTML = `<div>${formattedDate}</div>`;
                })
                .catch(error => console.error('날씨 정보를 가져오지 못했습니다:', error));
        }

        fetchWeather('Seoul'); // 기본값 서울

        document.getElementById('search-button').addEventListener('click', function () {
            const locationInput = document.getElementById('location-input').value;
            if (locationInput) {
                fetchWeather(locationInput);
            } else {
                alert('지역 이름을 입력하세요.');
            }
        });

        document.getElementById('location-input').addEventListener('keypress', function (e) {
            if (e.key === 'Enter') {
                document.getElementById('search-button').click();
            }
        });
    });
</script>
</body>
</html>


위와 같이 코드를 작성하면 화면단에 날씨가 보인다!!

참고로 왜 5일간의 데이터가 보인다고 했는데 실제로 보이는건 오늘 날짜를 포함한 6일의 날씨가 보인다. 이건 

open weather API에서 제공하는 자체가 5일간의 데이터 + 3시간 후 날씨 여서 현재 날씨의 3시간 후 날씨도 같이 보여지는 것이다. 보여지는게 싫다면 자바 코드에서 수정하면 되지만.. 굳이!? 

화면단 구성 완료..... 휴 힘들었다 ㅜ.ㅜ

 

728x90
728x90

개요

멋사프로젝트 중 날씨 API를 불러오는걸 담당했다.

OpenWeather API가 유명해서 그걸 사용할려고 했다가 2024년 6월부터 유료로 바뀌면서 카드등록을 해야한다더라...

*** 알아보니 무료버전을 사용해도 된다고 한다! 다음 포스트에 작성해볼려고 한다.

 

다른 무료로 해주는 곳이 없나 찾다가 Weather API 라는 사이트를 발견했다. 처음 가입하고는 2주동안 프리미엄 혜택을 받고 그 이후로는 무료판으로 돌아가지만 무료판이어도 기능은 괜찮은 것 같아서 이걸로 결정했다. 일단 회원가입을 하고 마이페이지를 들어가면 API KEY가 있을 것이다.


Dashboard - WeatherAPI.com

 

Login - WeatherAPI.com

Quick and Easy Signup for Weather API WeatherAPI.com makes it super easy to integrate our realtime, daily, hourly and 15 min interval weather forecast data, historical weather, marine weather, bulk request, air quality data, autocomplete, time zone, astron

www.weatherapi.com

 

 

 

프로젝트 환경 구성

  • 발급하면 나오는 API KEY를 application.yml 파일에 저장한다.
  • 알려져서는 안되는 키이다.
weather:
  api:
    key: {API key}

 

  • 그 후에 스프링에서 날씨 api를 불러쓰기 위해서 RestTemplate를 빈으로 등록한다.
@Configuration
public class DemoApplication {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

 

  • RestTemplate 이란?
    • HTTP 요청을 보내고 응답을 받기 위해 사용하는 클라이언트이다.
    • 외부 RESTful 웹 서비스와 통신할 때 사용된다.
    • 다른 서버의 API를 호출하여 데이터를 가져오거나, 서버로 데이터를 전송할 때 사용된다.
  • 왜 @Bean으로 등록?
    • Spring 컨텍스트 내에서 공유되며, 다른 컴포넌트에서 @Autowired를 통해 주입받아 사용할 수 있다.

 

 

  • 그 다음으로 yml 파일에 등록한 키를 사용하기 위해 컨트롤러를 만든다.
@RestController
@RequiredArgsConstructor
public class WeatherApiController {
    @Value("${weather.api.key}")
    private String apiKey;

    private final RestTemplate restTemplate;

    /**
     * 일주일 날씨 보여주기
     * @param location Default-Seoul
     * @return url
     */
    @GetMapping("/api/weather")
    public String getWeather(@RequestParam String location) {
        String url = String.format("https://api.weatherapi.com/v1/forecast.json?key=%s&q=%s&days=7", apiKey, location);
        return restTemplate.getForObject(url, String.class);
    }
}

 

  • 지도 API같은 경우 순수 자바스크립트로 구현할 수 있지만 위에서 발급받은 API key는 노출되어서 안되기 때문에 yml파일에 저장을 한 것이다.
  • 그래서 yml파일에 저장한 키 값을 사용하기 위해 서버에서 코드를 작성해서 자바스크립트에서 저 앤드포인트를 불러오는 방향으로 진행했다.
"https://api.weatherapi.com/v1/forecast.json?key=%s&q=%s&days=7"
  • 위의 url은 weather api에서 날씨 API를 불러오는 URL이다.
  • yml파일에 저장한 키와 일주일 날씨를 가져오는 url 이다.

 

 

 

  • 화면에 보여지는 HTML 과 JS 부분이다.
<!-- src/main/resources/templates/user/home.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>OMG Travel</title>
    <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js'></script>
    <link rel="stylesheet" href="/css/header.css">
</head>
<body>
<div th:replace="fragments/header :: headerFragment"></div>
<div class="wrapper">
    <div class="container">
        <div class="left-content">
            <div class="weather-section">
                <div class="search-container">
                    <input type="text" id="location-input" class="search-input" placeholder="지역 입력">
                    <button id="search-button" class="search-button">검색</button>
                </div>
                <div>
                    <h3>현재 날씨</h3>
                    <div id="current-weather"></div>
                    <div id="current-date" style="display: none;"></div>
                    <h3>이번 주 날씨</h3>
                    <div class="weather" id="weather-container"></div>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {

<!-- 날씨 API 불러오기 -->
        const weatherContainer = document.getElementById('weather-container');
        const currentWeather = document.getElementById('current-weather');
        const currentDateEl = document.getElementById('current-date');

        function fetchWeather(location) {
            fetch(`/api/weather?location=${encodeURIComponent(location)}`)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP 접속 오류 상태: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    const current = data.current;
                    const dailyWeather = data.forecast.forecastday;
                    const daysOfWeek = ['일', '월', '화', '수', '목', '금', '토']; // 요일 배열 수정

                    const today = new Date();
                    const options = { year: 'numeric', month: 'long', day: 'numeric' };
                    const formattedDate = today.toLocaleDateString('ko-KR', options);

                    currentWeather.innerHTML = `
                    <div class="current-location">${data.location.name}</div>
                    <div class="current-temp">${current.temp_c}°C</div>
                    <img src="${current.condition.icon}" alt="${current.condition.text}" class="weather-icon"/>
                `;

                    currentDateEl.innerHTML = `<div>${formattedDate}</div>`; // 현재 날짜 표시

                    weatherContainer.innerHTML = '';
                    dailyWeather.forEach((day, index) => {
                        const dayOfWeek = daysOfWeek[new Date(day.date).getDay()];
                        const formattedDay = new Date(day.date).toLocaleDateString('ko-KR', {
                            month: 'long',
                            day: 'numeric',
                        });
                        const icon = day.day.condition.icon;

                        const weatherItem = document.createElement('div');
                        weatherItem.classList.add('weather-item');

                        weatherItem.innerHTML = `
                        <div class="date">${formattedDay} (${dayOfWeek})</div>
                        <div class="temp">${day.day.avgtemp_c}°C</div>
                        <img src="${icon}" alt="${day.day.condition.text}" class="weather-icon"/>
                    `;
                        weatherContainer.appendChild(weatherItem);
                    });
                })
                .catch(error => console.error('날씨 정보를 가져오지 못했습니다:', error));
        }

        fetchWeather('Seoul'); // Default 값은 서울

        document.getElementById('search-button').addEventListener('click', function() {
            const locationInput = document.getElementById('location-input').value;
            if (locationInput) {
                fetchWeather(locationInput);
            }
        });

        // 엔터 가능
        document.getElementById('location-input').addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                document.getElementById('search-button').click();
            }
        });
    });
</script>
</body>
</html>

기존 css 코드와 날씨 API를 불러오는데 불필요한 코드는 없애고 딱 날씨 API를 호출하는 부분만 남겨놨다.

저렇게 구현하면 화면단에 일주일의 날씨가 보여질 것이다!

 

728x90
728x90

멋사 파이널 프로젝트에서 인증/인가를 담당했다.

이때 로그인시 인증에 대해서 JWT 로그인을 구현하였다.

 

JWT 토큰을 이용한 로그인에는 Access-Token과 Refresh-Token이 쓰이는데,

Access-Token은 인증을 위한 토큰으로 사용되고, Refresh-Token은 액세스 토큰을 재발급 하는데 사용된다.

 

[기존의 Refresh Token 사용 방식]

입력한 회원정보와 가입한 회원정보가 일치할 시, Login을 할때, Refresh-Token을 서버에서 만들어 쿠키에 저장하고 MySQL database에 저장후 액세스 토큰의 유효기간 만료로 인한 토큰 재발급 시, 리프레시 토큰값을 비교하여 일치 할 경우, 새로운 액세스 토큰과 리프레시 토큰을 발급하도록 구현했다.

 

하지만 이 방식의 아쉬운 점은, Refresh-Token을 발급한 이후, 별도의 로그아웃 API 호출이 없는 경우 깔끔하게 토큰이 DB에서 삭제되지 못한다는 점이다.

 

그래서 프로젝트 리팩토링을 진행하면서, MySQL에 저장했던 Refresh-Token을 Redis로 바꾸었다.!!

 


왜 Redis 인가? Redis vs RDB

Redis 는 리스트, 배열 형식의 데이터 처리에 특화되어 있다. 리스트 형 데이터의 입력과 삭제가 MySQL보다 10배 정도 빠르다. 이런 Redis 를 RefreshToken 의 저장소로 사용할 경우, 빠른 접근 속도로 사용자가 로그인시(리프레시 토큰 발급시) 병목이 발생할 가능성을 낮출 수 있다.

 

또 Refresh Token 은 발급된 이후 일정시간 이후 만료가 되어야한다. 리프레시 토큰을 RDB 등에 저장하면, 스케줄러등을 사용해서 만료된 토큰을 주기적으로 DB 에서 제거해 줘야 한다.

그러나 Redis 는 기본적으로 데이터의 유효기간(time to live) 을 지정할 수 있다. 이런 특징들이 바로 Refresh Token 을 저장하기에 적합하다고 생각했다.

 

또한 Redis 는 휘발성이라는 특징으로 인해 데이터가 손실될수도 있는 위험이 있으나, Redis 에 저장될 리프레시 토큰은 손실되더라도 그리 손해가 큰 편은 아니다. 기껏해봤자 RefreshToken 이 없어져서 다시 로그인을 시도해야 하는 정도이다.

Trand-Off를 고려했을 때, 이는 큰 문제가 아니라 생각해서 Redis에 Refresh-Token을 저장하려는 결정을 내렸다.

 


Redis 사용 시 이점

1. 빠른 액세스와 만료 관리

  • Redis는 메모리 기반 데이터 저장소이므로 매우 빠른 읽기/쓰기를 제공
  • 이를 통해 refreshToken의 유효성을 빠르게 확인하고 필요 시 만료시킬 수 있다.
  • 짧은 응답 시간은 보안 이벤트(예: 토큰 탈취 시도)에 빠르게 대응할 수 있게 해준다.

 

2. 간편한 만료 및 삭제

  • Redis는 TTL(Time to Live) 기능을 제공하여 키에 유효기간을 설정할 수 있다.
  • 이를 통해 refreshToken의 자동 만료가 가능하며, 특정 조건에서 토큰을 신속히 무효화할 수 있다.
  • 이는 RDBMS보다 효율적이며 보안 사고 시 피해를 줄일 수 있다

 

3. 세션 무효화 용이성

  • 유저가 로그아웃하거나 비정상적인 활동이 감지될 경우 Redis에서 특정 refreshToken을 쉽게 삭제할 수 있다.
  • RDBMS에서는 복잡한 쿼리와 트랜잭션 처리가 필요할 수 있지만, Redis에서는 단일 명령으로 처리할 수 있어 더 빠르고 효과적

 

4. 스케일링의 용이성

  • Redis는 분산 캐시 시스템을 지원하며, 이를 통해 큰 규모의 사용자를 처리할 때에도 성능을 유지할 수 있다.
  • 이는 특히 대규모 시스템에서 보안 모니터링과 대응 속도를 유지하는 데 중요

 

5. 분리된 저장소

  • refreshToken을 RDBMS가 아닌 Redis에 저장함으로써 데이터베이스와 캐시 간에 책임을 분리할 수 있다.
  • 이는 데이터베이스에 대한 접근을 최소화하여 공격 벡터를 줄일 수 있다.
  • 예를 들어, 데이터베이스가 공격을 받더라도 캐시에 저장된 토큰 정보는 노출되지 않을 수 있다.
728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입에 성공 후 비밀번호를 잊어버렸을 시 코드 구현을 하고자 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

1. dto

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class PasswordVerificationRequest {

    private String mail;
    private String tempPassword;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

2. Service

public interface MailService {

    String createTemporaryPassword(String email);

    boolean verifyTemporaryPassword(String email, String tempPassword);

    void sendTemporaryPasswordMail(String email, String tempPassword);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "ch9800113@gmail.com";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 임시 비밀번호 자동 생성 메서드
     */
    private static String generateRandomPassword() {
        int length = 8;
        StringBuilder sb = new StringBuilder(length);
        Random random = new Random();
        for (int i = 0; i < length; i++) {
            sb.append((char) (random.nextInt(10) + '0'));
        }
        return sb.toString();
    }

    /**
     * 임시 비밀번호 전송
     */
    @Override
    public void sendTemporaryPasswordMail(String mail, String tempPassword) {
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("OMG 임시 비밀번호");
            String body = "<h2>OMG에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
            helper.setText(body, true);
            javaMailSender.send(message);
        } catch (MessagingException e) {
            throw new RuntimeException("임시 비밀번호 전송 오류", e);
        }
    }

    /**
     * 임시 비밀번호 생성 및 DB 업데이트
     */
    @Override
    public String createTemporaryPassword(String mail) {
        String tempPassword = generateRandomPassword();
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        user.setPassword(passwordEncoder.encode(tempPassword));
        userRepository.save(user);
        return tempPassword;
    }

    /**
     * 임시 비밀번호 검증
     */
    @Override
    public boolean verifyTemporaryPassword(String mail, String tempPassword) {
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        return passwordEncoder.matches(tempPassword, user.getPassword());
    }
}

 

 

[ 코드 설명 ]

/**
 * 임시 비밀번호 자동 생성 메서드
 */
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    for (int i = 0; i < length; i++) {
        sb.append((char) (random.nextInt(10) + '0'));
    }
    return sb.toString();
}
  • 반환 값: String (문자열)
  • 생성 방식: 이 메서드는 8자리의 숫자로 구성된 문자열을 생성하는 메서드이다.
    • 문자열은 StringBuilder를 사용하여 효율적으로 생성되도록 구현했다.
    • 각 반복에서 random.nextInt(10) + '0'을 통해 0부터 9까지의 숫자를 문자로 변환하여 문자열에 추가한다.
  • StringBuilder 사용이유 ::
    • String은 불변 객체(immutable object)이다. 즉 한 번 생성된 String은 변경할 수 없으며, 문자열의 조작은 새로운 String 객체를 생성하여 처리된다.
    • StringBuilder를 사용하여 문자열을 생성한 후, 최종적으로 toString() 메서드를 호출하여 불변의 String 객체를 반환하도록 구현했다.
    • 위의 코드는 숫자로만 구성했지만, 나중에 보안을 위해 아래처럼 작성하는 것으로 바꾸었다.
  •  
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    for (int i = 0; i < length; i++) {
        sb.append(characters.charAt(random.nextInt(characters.length())));
    }

    return sb.toString();
}
/**
 * 임시 비밀번호 전송
 */
@Override
public void sendTemporaryPasswordMail(String mail, String tempPassword) {
    MimeMessage message = javaMailSender.createMimeMessage();
    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("임시 비밀번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
        helper.setText(body, true);
        javaMailSender.send(message);
    } catch (MessagingException e) {
        throw new RuntimeException("임시 비밀번호 전송 오류", e);
    }
}
  • 반환 값: void
  •  생성 방식 : 이 메서드는 임시비밀번호를 이메일로 전송하는 기능만 수행하고, 결과를 반환할 필요가 없다
    • javaMailSender.send(message); 를 통해 메서드에서 바로 구현하여 바로 메일을 전송하였다.
  • 하지만 저번 포스트에서 회원가입시 이메일 인증 번호 전송 로직을 보면
@Override
public MimeMessage createMail(String mail){
    createNumber(mail);
    MimeMessage message = javaMailSender.createMimeMessage();

    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("이메일 인증번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
        helper.setText(body, true);
    } catch (MessagingException e) {
        e.printStackTrace();
    }

    return message;
}

/**
 * createMail() 메서드의 내용을 이메일 전송
 */
@Async
@Override
public CompletableFuture<Integer> sendMail(String mail) {
    MimeMessage message = createMail(mail);
    javaMailSender.send(message);
    return CompletableFuture.completedFuture(verificationCodes.get(mail));
}
  • 여기서 코드의 반환값은 MimeMessage 이다.
  • 즉 javaMailSender.send(message); 를 하지 않았기 때문에 따로 sendMail 메서드를 통해 이메일 전송을 해줘야 한다.
  • MimeMessage  란? ::
    • MimeMessage  객체는 이메일 메시지를 생성하고 설정하는 데 사용되는 객체이다.
    • 발신자, 수신자, 제목, 본문 내용, 첨부 파일 등 이메일의 모든 구성 요소를 설정할 수 있고 설정된 MimeMessage 객체는 JavaMailSender를 통해 이메일 서버로 전송해야 한다.
  • 나중에 코드 통일성을 위해 하나의 메서드에서 전송될 수 있도록 구현할 예정이다.

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 임시 비밀번호 재발급 발송 메서드
     */
    @PostMapping("/api/users/reset-password")
    public ResponseEntity<String> resetPassword(@RequestBody MailRequest mailRequest) {
        String email = mailRequest.getMail();

        if (userService.existsByUsername(email)) {
            String tempPassword = mailService.createTemporaryPassword(email);
            mailService.sendTemporaryPasswordMail(email, tempPassword);
            return ResponseEntity.ok("임시 비밀번호가 이메일로 발송되었습니다.");
        } else {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("해당 이메일로 가입된 사용자가 없습니다.");
        }
    }

    /**
     * 임시 비밀번호 검증 메소드
     */
    @PostMapping("/api/users/verify-temporary-password")
    public ResponseEntity<String> verifyTemporaryPassword(@RequestBody PasswordVerificationRequest request) {
        boolean isVerified = mailService.verifyTemporaryPassword(request.getMail(), request.getTempPassword());
        return isVerified ? ResponseEntity.ok("Verified") : ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Verification failed");
    }
}
/**
 * 비밀번호 재발급 페이지 이동
 */
@GetMapping("/users/reset-user-password")
public String showResetPasswordForm() {
    return "/user/findPassword";
}

 

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>비밀번호 재발급</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/find-password.css">
</head>
<body>
<form id="emailVerificationForm">

    <h1>비밀번호 재발급</h1>
    <label for="email">가입 이메일</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>
    <button type="button" id="send-code-button">임시 비밀번호 발송</button>
    <span id="emailCheckMessage" class="error"></span>

    <div id="verifyCodeSection">
        <label for="temporaryPassword">임시 비밀번호</label>
        <input type="text" id="temporaryPassword" name="temporaryPassword" placeholder="임시 비밀번호 입력" required />
        <button type="button" id="verify-temporary-password-button">임시 비밀번호 확인</button>
        <span id="verificationMessage" class="error"></span>
    </div>
</form>
<script>
    $(document).ready(function() {
        $('#verifyCodeSection').hide();

        $('#send-code-button').on('click', function() {
            let email = $('#email').val();
            $.ajax({ // 이메일이 데이터베이스에 있는지 확인
                url: '/api/users/check-email',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email }),
                success: function(response) {
                    if (response) {
                        $.ajax({ // 이메일이 존재하면 임시 비밀번호 발송
                            url: '/api/users/reset-password',
                            type: 'POST',
                            contentType: 'application/json',
                            data: JSON.stringify({ mail: email }),
                            success: function(response) {
                                $('#verifyCodeSection').slideDown(); // 인증 코드 입력 섹션 표시
                                alert('임시 비밀번호가 이메일로 발송되었습니다. 이메일을 확인해주세요.');
                            },
                            error: function(error) {
                                alert('임시 비밀번호 발송에 실패했습니다. 다시 시도해주세요.');
                            }
                        });
                    } else {
                        $('#emailCheckMessage').text('해당 이메일로 가입된 사용자가 없습니다.').show();
                    }
                },
                error: function(error) {
                    alert('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.');
                }
            });
        });

        $('#verify-temporary-password-button').on('click', function() {
            const email = $('#email').val();
            const tempPassword = $('#temporaryPassword').val();
            $.ajax({
                url: '/api/users/verify-temporary-password',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email, tempPassword: tempPassword }),
                success: function(response) {
                    if (response === "Verified") {
                        alert("임시 비밀번호가 확인되었습니다. 로그인하세요.");
                        window.location.href = "/signin";
                    } else {
                        $('#verificationMessage').text("임시 비밀번호가 일치하지 않습니다. 다시 시도하세요.").show();
                    }
                },
                error: function(xhr, status, error) {
                    alert("임시 비밀번호 검증에 실패했습니다. 다시 시도하세요.");
                }
            });
        });
    });
</script>
</body>
</html>
  1. 해당 이메일이 디비에 있는지 확인한다.
  2. 이메일이 존재한다면 해당 이메일로 임시 비밀번호를 전송해준다.
  3. 이때 폼에서는 숨겼던 섹션을 표시하고 임시비밀번호를 입력 란을 활성화한다.
  4. 임시비밀번호가 이메일로 전송한 값과 같은지 확인한다.
  5. 이때 임시비밀번호가 디비에 업데이트가 되어서 기존 비밀번호로 인증을 할 수 없게 된다.

 

여기까지 끝-!

 

 

 

 

 


jQuery에서 HTML 요소를 표시하는 두 가지 방법

 

  • jQuery 에서 아래의 두가지가 섹션을 보여주는 방식의 차이라서 정리한다.
// 애니메이션 효과로 서서히 표시
$('#verifyCodeSection').slideDown();

// 즉시 표시
$('#verifyCodeSection').show();

 

 

 

728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입 시 받는 이메일 인증은 사용자 식별 및 보안 강화를 위해 필요한 기술이다. 만약 이메일 인증과 같은 인증 기술이 없다면 한 사람이 10개 혹은 1000개의 계정을 무한대로 생성할 수 있다는 것인데, 이는 스팸이나 부정 사용 등 서비스 품질을 하락시킬 수 있다. 이메일 인증과 관련해서 구현하기까지 수많은 구글링과 시행착오가 있어서 정리해놓을려고 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

 

1. dto

이메일 인증에 필요한 dto 를 작성한다.

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class MailVerificationRequest {

    private String mail;
    private int code;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

 

2. Service

  • 구현하고자 하는 기능에 맞게 코드를 짠다.
public interface MailService {

    MimeMessage createMail(String mail);

    boolean verifyCode(String email, int code);

    CompletableFuture<Integer> sendMail(String mail);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "메일을 보낼 구글 이메일";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 인증 코드 자동 생성 메서드
     */
    public static void createNumber(String email){
        int number = new Random().nextInt(900000) + 100000; // 100000-999999 사이의 숫자 생성
        verificationCodes.put(email, number);
    }

    /**
     * 이메일 전송
     */
    @Override
    public MimeMessage createMail(String mail){
        createNumber(mail);
        MimeMessage message = javaMailSender.createMimeMessage();

        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("이메일 인증번호");
            String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
            helper.setText(body, true);
        } catch (MessagingException e) {
            e.printStackTrace();
        }

        return message;
    }

    /**
     * createMail() 메서드의 내용을 이메일 전송
     */
    @Async
    @Override
    public CompletableFuture<Integer> sendMail(String mail) {
        MimeMessage message = createMail(mail);
        javaMailSender.send(message);
        return CompletableFuture.completedFuture(verificationCodes.get(mail));
    }

    /**
     * 이메일 인증 코드 검증
     */
    @Override
    public boolean verifyCode(String mail, int code) {
        Integer storedCode = verificationCodes.get(mail);
        return storedCode != null && storedCode == code;
    }
}

1) 주어진 이메일 주소에 대해 6자리 인증 코드를 생성하고 verificationCodes 맵에 저장한다.
{이메일 : 인증코드} 형태로 저장될 것이다.

2) 입력한 이메일 주소로 발송할 이메일 메시지를 작성한다.

3) 2에서 생성한 이메일 메시지를 비동기적으로 발송한다.

4) 사용자가 입력한 인증코드와 실제 발송된 인증코드와 일치하는지 확인한다.

 

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 인증번호 발송 메소드
     */
    @PostMapping("/api/users/mail")
    public CompletableFuture<String> mailSend(@RequestBody MailRequest mailRequest) {
        return mailService.sendMail(mailRequest.getMail())
                .thenApply(number -> String.valueOf(number));
    }

    /**
     * 인증번호 검증 메소드
     */
    @PostMapping("/api/users/verify-code")
    public String verifyCode(@RequestBody MailVerificationRequest verificationRequest) {
        boolean isVerified = mailService.verifyCode(verificationRequest.getMail(), verificationRequest.getCode());
        return isVerified ? "Verified" : "Verification failed";
    }
}
  • 해당 앤드포인트로 요청이 들어오면 해당 요청을 수행한다.

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원가입</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/signup.css">
</head>
<body>
<form id="signup-form" action="/signup" method="post">
    <h1>회원가입</h1>
    <hr/>

    <div th:if="${error}" class="error-message">
        <p th:text="${error}"></p>
    </div>

    <label for="email">이메일</label>
    <input type="email" id="email" name="username" placeholder="이메일 입력" required/>
    <button type="button" id="check-email-button" class="light-button">중복확인</button>
    <span id="emailCheckMessage" class="error"></span>

    <button type="button" id="send-code-button" class="light-button" style="display: none;">인증 코드 발송</button>

    <div id="verifyCodeSection">
        <label for="verificationCode">인증 코드</label>
        <input type="text" id="verificationCode" name="verificationCode" placeholder="인증번호 입력" required/>
        <button type="button" id="verify-code-button" class="light-button">이메일 인증</button>
        <span id="verificationMessage" class="error"></span>
    </div>

    <button type="submit" id="signup-button" disabled>회원가입</button>
</form>
<script src="/js/signup.js"></script>
</body>
</html>
$(document).ready(function() {
    function validateForm() {
        let isValid = true;

        // 이메일, 닉네임 중복 및 비밀번호 일치 여부 검사
        if ($('#emailCheckMessage').hasClass('error')) {
            isValid = false;
        }
        $('#signup-button').prop('disabled', !isValid);
        return isValid;
    }

    // 이메일 중복 검사
    // 1. 이메일 중복되면 인증 메일 보내기 버튼은 숨김
    // 2. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
    $('#check-email-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/check-email',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                if (response) {
                    $('#emailCheckMessage').text("아이디가 이미 존재합니다.").removeClass('success').addClass('error');
                    $('#send-code-button').hide();  
                } else {
                    $('#emailCheckMessage').text("사용 가능한 아이디입니다.").removeClass('error').addClass('success');
                    $('#send-code-button').show();  
                }
                validateForm();
            },
            error: function(error) {
                $('#emailCheckMessage').text('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.').removeClass('success').addClass('error');
                $('#send-code-button').hide();  
                validateForm();
            }
        });
    });

    // 인증 메일
    $('#send-code-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/mail',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                $('#verifyCodeSection').show(); 
                alert('인증 메일이 발송되었습니다. 인증 번호를 확인해주세요.');
            },
            error: function(error) {
                alert('메일 발송에 실패했습니다. 다시 시도해주세요.');
            }
        });
    });

    // 인증 코드 확인
    $('#verify-code-button').on('click', function() {
        let email = $('#email').val();
        let code = $('#verificationCode').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/verify-code',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email, code: code }),
            success: function(response) {
                if (response === 'Verified') {
                    $('#verificationMessage').text('인증 성공').removeClass('error').addClass('success');
                } else {
                    $('#verificationMessage').text('인증 실패. 올바른 코드를 입력하세요.').removeClass('success').addClass('error');
                }
            },
            error: function(error) {
                $('#verificationMessage').text('인증 실패. 다시 시도해주세요.').removeClass('success').addClass('error');
            }
        });
    });

    // 인증 코드 발송 버튼 --> 초기 상태에서는 비활성화
    $('#send-code-button').hide();

    // 인증 코드 입력 란 숨기기
    $('#verifyCodeSection').hide();
});
  • 사실 HTML 폼에서 닉네임, 연락처, 생년월일 등 적는 란이 있지만 이메일 인증에 필요한 코드만 남겨두었다.
  1. 이메일이 중복되어 있는지 확인한다. 이 부분은 위에 자바 코드에는 없다.
  2. 이메일이 중복되지 않았다면 해당 이메일로 인증번호를 보낸다.
  3. 인증번호 발송 버튼이 활성화된다. 
// 이메일 중복 검사
    // 1. 이메일 중복되면 인증 메일 보내기 버튼은 숨김
    // 2. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
    $('#check-email-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/check-email',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                if (response) {
                    $('#emailCheckMessage').text("아이디가 이미 존재합니다.").removeClass('success').addClass('error');
                    $('#send-code-button').hide();  // 오류가 있으면 인증 코드 발송 버튼 숨김
                } else {
                    $('#emailCheckMessage').text("사용 가능한 아이디입니다.").removeClass('error').addClass('success');
                    $('#send-code-button').show();  // 이메일 체크 통과 시 버튼 표시
                }
                validateForm();
            },
            error: function(error) {
                $('#emailCheckMessage').text('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.').removeClass('success').addClass('error');
                $('#send-code-button').hide();  // 오류가 있으면 인증 코드 발송 버튼 숨김
                validateForm();
            }
        });
    });

4) 인증코드 입력 섹션이 활성화된다.

// 인증 메일
    $('#send-code-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/mail',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                $('#verifyCodeSection').show(); // 인증 코드 입력 섹션 표시
                alert('인증 메일이 발송되었습니다. 인증 번호를 확인해주세요.');
            },
            error: function(error) {
                alert('메일 발송에 실패했습니다. 다시 시도해주세요.');
            }
        });
    });

5) 해당 이메일과 입력한 코드를 /api/users/verify-code 앤드포인트에서 수행한다.

$('#verify-code-button').on('click', function() {
    let email = $('#email').val();
    let code = $('#verificationCode').val();

    $.ajax({
        type: 'POST',
        url: '/api/users/verify-code',
        contentType: 'application/json',
        data: JSON.stringify({ mail: email, code: code }),
        success: function(response) {
            if (response === 'Verified') {
                $('#verificationMessage').text('인증 성공').removeClass('error').addClass('success');
            } else {
                $('#verificationMessage').text('인증 실패. 올바른 코드를 입력하세요.').removeClass('success').addClass('error');
            }
        },
        error: function(error) {
            $('#verificationMessage').text('인증 실패. 다시 시도해주세요.').removeClass('success').addClass('error');
        }
    });
});

 

 

 

5. 결과

 

위에처럼 작성 후 실행하면

해당 이메일로 인증 번호가 전송된다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

다음 편은 비밀번호 찾기 로직을 정리할 것이다.

728x90

+ Recent posts