yml 파일 분리의 필요성

팀 프로젝트를 진행하면서 yml(YAML) 파일의 길이가 꾸준히 늘어나고 있다.

배포하고 도메인까지 연결해보니 로컬에서 테스트할 때와 배포 환경에서 다르게 해줘야할 설정이 점점 많아졌다.

그래서 매번 빌드할때와 테스트할 때 yml 파일을 여러 군데 수정해야하는 불편함까지 생기기 시작했다.

yml 파일를 수정해야 하는 이유를 정리해보면 아래와 같았다.

  • 의존성 추가(Sprind Data Redis, Oauth2 Client 등)
  • DB 설정 분리(로컬은 H2, 배포에선 MySQL)
  • 기능 추가 시 Secret과 같은 환경 변수 설정
  • 쿠키, 스웨거 등의 도메인 설정(로컬에선 localhost, 배포에선 배포 도메인 주소)

로컬에서 테스트 할때와 빌드를 할때 수정할 부분을 최소화 하기 위해 yml 분리 방법을 간단하게 공부하고 정리해보려 한다.

기준은 본인이 스프링을 사용하므로 스프링 기준이다.


yml 파일들을 분리하기 위한 규칙

1. 파일 이름 규칙

yml 파일 이름은 나름의 규칙을 지켜야한다.

application-{profile이름}.yml 의 규칙으로 짓는다.

스프링에서는 {profile이름}으로 yml 파일을 구분하고 활용할 수 있다.

2. application.yml 위치

이건 따로 변경할 수 있다곤 하지만 아래처럼 resources 경로에 둔다.

3. 다른 프로필의 yml 파일 위치

아래처럼 depth를 나눠서 사용할 수도 있지만 본인은 application.yml 과 같은 depth로 사용하려 한다.

project
├── src
│   ├── main
│   ├── java
│   ├── resources
│   │   ├── static
│   │   ├── templates
│   │   ├── yaml
│   │   │   ├── logging
│   │   │   │   └─ application-log.yml
│   │   │   ├── application-dev.yml
│   │   │   └─ application-local.yml
│   │   └─ application.yml

yml 파일 분리 해보기

application.yml

분리하는 기준은 정하기 나름이겠지만 본인은 application.yml 파일에 공통 설정을 담았다.

server:
  port: 8080
spring:
  profiles:
    group:                # yml 파일 여러개를 그룹화 하고싶을 때 사용
      test: local, log   # 이렇게 local, log를 그룹화하고 사용할 때는 active: test
    active: local         # 로컬에서 테스트할 때는 local, 빌드 시엔 prod 로 이 부분만 변경

  data:
    redis:
      host: localhost
      port: 6379
      repositories:
        enabled: false
  main:
    allow-bean-definition-overriding: true

pawland:
  front-test-url: "http://localhost:3000"    # 해당 라인과 아래 라인도 local, prod 로 나누면 하나로 관리할 수 있었을듯
  front-deploy-url: "https://www.pawland.store"
  jwt-key: # JWT 키

gmail:
  host: smtp.gmail.com
  port: 587
  username: hyukkind@gmail.com
  password: # 구글 앱 비밀번호
  smtpProperties:
      auth: true
      starttls-enable: true
      starttls-required: true
      connection-timeout: 5000
      timeout: 5000
      writeTimeout: 5000

aws:
  access-key: # AWS 액세스 키
  secret-key: # AWS 시크릿 키
  s3-image-prefix:  # S3 이미지 프리픽스
  s3-access-point: # S3 액세스 포인트

application-local.yml

로컬에서 사용하는 yml 설정이다.

로컬에서는 인메모리에서 테스트하고, ddl-auto 기능도 마음대로 사용할 수 있다.

spring:
  config:
    active:
      on-profile: local  # 프로필 이름 설정

  datasource:
    url: jdbc:h2:mem:testdb;MODE=MySQL
  hikari.maximum-pool-size: 20
  h2:
    console:
      enabled: true
      path: /h2-console
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: create   # 로컬에선 ddl-auto create로 해도 됨
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        show_sql: true
        dialect: org.hibernate.dialect.H2Dialect
        
pawland:
  back-domain: "localhost"
  back-url: "http://localhost:8080"

application-prod.yml

배포할 때 사용할 yml 설정이다.

배포 환경에서는 ddl-auto 때문에 낭패를 볼 수 있으므로 none으로 설정하고

DB도 새로운 버전으로 배포해도 데이터가 유지되게 MySQL로 바꿔준다.

spring:
  config:
    active:
      on-profile: prod

  datasource:
    url: jdbc:mysql:// # RDS or EC2로 띄운 MySQL 주소
    username: # 사용자
    password: # 비밀번호

  hikari.maximum-pool-size: 20

  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: none   # ddl-auto 는 prod 에선 none
    properties:
      hibernate:
        format_sql: true  # 이 부분도 false 해도 될듯
        show_sql: true    # 이 부분도 false 해도 될듯
        dialect: org.hibernate.dialect.MySQLDialect
        
pawland:
  back-domain: "midcon.store"
  back-url: "https://www.midcon.store"

 

참고자료

 

[Spring] 하나의 YAML을 여러 개로 나누어 환경 분리하기

스프링에서는 다양한 환경 변수 및 정보들을 활용하기 위해 yaml 및 properties를 지원한다. 하나의 yaml 파일들을 여러 개의 yaml로 나누는 방법을 익혀보자.

velog.io

 

스프링 시큐리티

나는 스프링 시큐리티가 너무 싫다.

공부하면서 느낀건 편한것도 많지만 간단한 JWT 정도 적용하는데는 굳이 써야하나? 라는 생각이다.

우선 변경되는 부분이 많아서 전에 썼던 프로젝트 코드를 다시 쓰려면 deprecated 된게 한가득이다.

그렇다보니 바꿀 부분이 많아져서 스프링의 장점인 레퍼런스가 많다는 장점이 퇴색된다.

또 스프링 시큐리티라는 컨텍스트에 너무 의존적이라고 생각한다.

그래서 이걸 쓰려면 내부 구현을 속속들이 알아야 의도치 않은 결과를 만나지 않는다.

로그인을 FormData 형식으로 받아서 Json으로 로그인을 하려면 따로 필터를 만들어야 하는것도 불편하다.

복잡한 과정들을 추상화해서 쓰기 편하게 만들었단건 알겠지만 덕분에 공부해야할거도 꽤 있고 복잡하다.

여러데서 쓴다니까 공부는 해둬야지 별 수 있나 싶긴 하지만 그래도 싫은건 싫다.


시큐리티 환경에서 컨트롤러 테스트

무엇보다 내가 싫은건 시큐리티 환경에서 테스트 코드를 작성하기가 너무 번거롭다는 것이다.

아마 지금의 내 JWT 필터 로직이 스프링 시큐리티 컨텍스트와 따로 노는게 문제라고 생각은 하지만,

도무지 시큐리티 컨텍스트 내에 내 JWT 필터 로직을 넣는 방법을 모르겠다.

Argument Resolver로 JWT 기능을 구현했으면 진작 끝냈을것 같다.

이전 JWT 필터 글에서 썼던 대로 기본적으로 모든 요청에 대해 JWT를 검사한다.

따라서 컨트롤러 테스트를 할 때도 이 필터를 거쳐야하기 때문에 JWT를 안넣어주면 필터단에서 걸러진다.

이걸 어떻게 처리해야 하는지 고민하다가 시행착오 끝에 결국 테스트용 SecurityConfig를 만들기로 했다.


구현

TestSecurityConfig

프로덕션 코드에서 검색되지 않도록 테스트 디렉토리에 아래처럼 테스트용 SecurityConfig를 작성한다.

@TestConfiguration
@EnableWebSecurity
@RequiredArgsConstructor
public class TestSecurityConfig {

    private final ObjectMapper objectMapper;
    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
            .requestMatchers("/favicon.ico")
            .requestMatchers("/error")
            .requestMatchers(toH2Console());
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jsonAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> {
                e.authenticationEntryPoint(new Http401Handler(objectMapper));
                e.accessDeniedHandler(new Http403Handler(objectMapper));
            })
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    public JsonAuthFilter jsonAuthFilter() {
        JsonAuthFilter filter = new JsonAuthFilter("/api/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(objectMapper, jwtUtils));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64
        );
    }
}

SecurityConfig

기존 SecurityConfig에 아래처럼 @Profile 애노테이션을 이용해 특정 프로필에서 사용하지 못하게 설정한다.

본인은 test 프로필에서 사용하지 못하게 했다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Profile("!test")
public class SecurityConfig {

사용법

1. 테스트용 config를 Import 및 @ActiveProfiles 설정

테스트 클래스에 아래 스크린샷처럼 @import(TestSecurityConfig.class), @ActiveProfiles("test") 를 붙여준다.

2. 인증 필요한 테스트에 커스텀 @MockUser 애노테이션 적용

아래처럼 커스텀@MockUser 애노테이션을 적용한다.

@WithMockUser

스프링 시큐리티를 사용하면 시큐리티 컨텍스트에서 Principal 정보를 꺼내 쓸 수 있다.

따라서 인증 완료된 유저는 컨트롤러에서 @AuthenticationPrincipal 을 사용하여 사용자 정보를 이용하곤 한다.

하지만 컨트롤러 테스트를 할 때 컨트롤러를 호출하면 인증을 하지 않았기 때문에 UserPrincipal 객체가 null일 것이다.

당연히 실제 환경처럼 컨트롤러 호출 시 쿠키에 Jwt를 넣는 등의 인증 과정을 거치면 되겠지만,

매 테스트마다 유효한 Jwt를 만들어서 넣어줘야한다는 문제가 생긴다.

유저 정보를 DB에 넣고, 유저 정보로 Jwt를 만들고, 요청에 넣어주는건 굉장히 번거롭다.

 

이런 번거로움을 해소하기 위해 스프링 시큐리티에서는 @WithMockUser 라는 애노테이션을 제공한다.

이를 이용하면 시큐리티 컨텍스트에 MockUser 정보를 넣어두기 때문에 인증 없이 컨트롤러를 호출해도 문제없이 사용할 수 있다.

하지만 서비스마다 유저 객체 정보가 다르기 때문에 기본적인 @WithMockUser 를 사용하기에는 한계가 있다.

따라서 이번 글에서는 @WithMockUser 를 커스텀해서 사용하여 서비스에 맞는 MockUser 정보를 이용해볼 것이다.


구현

CustomMockSecurityContext

MockSecurityContext를 만든다.

아래처럼 WithSecurityContextFactory를 구현하고, 시큐리티 컨텍스트에 MockUser 정보를 넣어준다.

@RequiredArgsConstructor
public class PawLandMockSecurityContext implements WithSecurityContextFactory<PawLandMockUser> {

    private final UserRepository userRepository;

    @Override
    public SecurityContext createSecurityContext(PawLandMockUser annotation) {
        User user = User.builder()
            .email(annotation.email())
            .nickname(annotation.nickname())
            .password(annotation.password())
            .type(annotation.type())
            .profileImage(annotation.profileImage())
            .introduce(annotation.getIntroduce())
            .build();
        userRepository.save(user);

        UserPrincipal userPrincipal = new UserPrincipal(user);
        SimpleGrantedAuthority role = new SimpleGrantedAuthority(annotation.role());
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userPrincipal,
            user.getPassword(),
            List.of(role));

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authenticationToken);
        return context;
    }
}

CustomMockUser

이제 위에서 만든 Mock 시큐리티 컨텍스트를 사용할 커스텀 MockUser 애노테이션을 만든다.

아래처럼 원하는 필드를 설정하고, 기본 값을 정해주면 애노테이션을 붙이면 설정한 MockUser 정보를 이용한다.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = PawLandMockSecurityContext.class)
public @interface PawLandMockUser {

    String email() default "midcondria@naver.com";

    String nickname() default "나는짱";

    String password() default "asd123123";

    String profileImage() default "";

    String getIntroduce() default "";

    LoginType type() default LoginType.NORMAL;

    String role() default "ROLE_USER";

}

사용법

1. 애노테이션만 붙일 때

아래처럼 애노테이션만 붙이면 기본 값으로 설정한 정보를 사용한다.

2. 애노테이션에 원하는 값을 넣을 수 있음

아래처럼 애노테이션에 원하는 값을 넣으면 원하는 설정한 정보를 사용할 수 있다.

인증 메일 관리

이전 글에서 인증 번호를 담은 메일 발송까지는 구현했다.

이제 인증번호를 DB에 저장하고, 사용자가 입력한 인증번호와 대조하여 인증하는 로직을 작성해야 한다.

인증메일 저장 및 인증 로직 구현에는 아래의 두 가지 방법이 있을것 같다.

  • Redis에 저장하고 만료시간 설정을 해둬서 자동으로 오래된 인증 메일이 삭제되게 관리
  • 관계형 DB에 저장하고 주기적으로 오래된 인증 메일을 삭제하는 스케줄러 작성

주기적으로 오래된 인증 메일을 삭제하는건 조건 설정하기 귀찮을것 같아서 레디스를 써서 구현해보려 한다.

스케줄러를 사용하는건 다음에 해봐야지.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • Redis
  • Gmail

1. 사전작업 - Redis 설치

레디스를 쓰려면 우선 EC2든 로컬이든 레디스가 깔려있어야 한다.

인텔리제이 얼티밋 버전은 따로 DB 연결을 관리하는지는 모르겠지만 본인은 흙수저 버전이므로 직접 설치해야 한다.

레디스 설치 및 기본 사용법은 이 글을 참고한다.


2. 스프링부트와 Redis 연동을 위한 설정

2-1. 의존성 추가

스프링 부트에서 레디스를 쓰기 위해 의존성을 추가해줘야한다.

// build.gradle.kts
// 레디스
implementation("org.springframework.boot:spring-boot-starter-data-redis")

2-2. yml 파일 설정

위 의존성을 추가했다면 기본 설정이 돼있어서 yml과 config 설정을 딱히 안해줘도 된다.

그래서 2-2, 2-3은 사실 안해도 되는 설정이긴 하다.

만약 호스트와 포트 번호를 커스텀해서 쓰려면 2-2, 2-3 설정을 하면 된다.

@ConfigurationProperties로 환경 변수 읽어서 설정할거라 당장은 yml의 depth는 딱히 상관없을듯.

// application.yml
spring:
  main:
    allow-bean-definition-overriding: true // Redis Bean 오버라이딩 허용
  data:
    redis:
      host: localhost  // 호스트, localhost = 127.0.0.1 이므로 뭘 입력하든 상관없음
      port: 6379       // 포트번호(레디스 기본값이 6379)

2-3. RedisConfig 추가

일단은 RedisTemplate만 쓸거라 생각하기 때문에 아래처럼 설정했다.

다른데서 보면 그냥 RedisConnectionFactory 부분에서

return new LettuceConnectionFactiory(host, port) 만 해도 되던데 난 안됐다.

RedisConnectionFactory를 빈으로 등록해서 써봤는데도 안돼서 connectionFactory.start()를 추가하니까 되더라.

@RequiredArgsConstructor
@ConfigurationProperties(prefix = "spring.data.redis")
public class RedisConfig {

    private final String host;  // localhost
    private final int port;     // 6379

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

    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(host, port);
        connectionFactory.start();
        return connectionFactory;
    }
}

3. MailService 구현

3-1. MailService에서 Redis로 메일 저장

RedisConfig에서 빈 등록한 RedisTemplate를 사용한다.

redisTemplate.opsForValues() 메서드로 ValueOperations를 통해 레디스 DB에 저장한다.

데이터의 만료시간은 3분으로 두고 3분이 지나면 자동 삭제된다.

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MailVerificationService {

    private final MailConfig mailConfig;
    private final JavaMailSender mailSender;
    private final RedisTemplate<String, String> redisTemplate;

    @Transactional
    public void sendVerificationCode(String toEmail) throws MessagingException, UnsupportedEncodingException {
        String verificationCode = generateVerificationCode();
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(toEmail, verificationCode, Duration.ofMinutes(3));
        MimeMessage message = createMessage(toEmail, verificationCode);
        try {
            mailSender.send(message);
        } catch (RuntimeException e) {
            log.error("[메일 전송 실패]");
            throw new MailSendException("메일 전송에 실패했습니다.");
        }
    }
}

3-2. MailService에서 Redis로 인증번호 확인

ValueOperations를 통해 레디스 DB에 저장된 데이터를 조회한다.

만약 인증메일과 인증번호가 일치하면 만료시간 5분짜리 인증 확인용 데이터를 추가한다.

public void verifyCode(String toEmail, String code) {
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    String savedVerificationCode = values.get(toEmail);
    boolean isVerified = code.equals(savedVerificationCode);
    if (isVerified) {
        values.set(toEmail, "ok", Duration.ofMinutes(5));
    } else {
        log.error("[메일 인증 실패]");
        throw new InvalidCodeException();
    }
}

3-3. 테스트코드 작성

테스트 작성 시 레디스를 Mocking해서 테스트할지, 실제 레디스에 저장해서 테스트할지 고민했다.

2-2, 2-3처럼 따로 Bean 오버라이딩을 하니까 MockBean 주입할 방법을 못찾아서 실제 레디스를 쓰는 테스트를 작성했다.

어차피 각 테스트 후에 DB를 flush하기 때문에 당장은 큰 문제가 없을거라고 생각한다.

아마 2-2, 2-3을 생략하면 MockBean 주입해서 Mocking도 가능할듯.

MailServiceTest

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Autowired
private MailVerificationService mailVerificationService;

@AfterEach
void tearDown() {
    redisTemplate.execute((RedisCallback<Object>) connection -> {
        connection.serverCommands().flushDb();
        return null;
    });
}

@DisplayName("이메일 전송 성공 시 인증 번호가 redis에 저장된다.")
@Test
void sendEmail3() throws MessagingException, UnsupportedEncodingException {
    // given
    String toEmail = "test@example.com";
    MimeMessage mimeMessage = new MimeMessage((Session) null);
    when(mailSender.createMimeMessage()).thenReturn(mimeMessage);

    // when
    mailVerificationService.sendVerificationCode(toEmail);
    String result = redisTemplate.opsForValue().get(toEmail);

    // then
    assertThat(result).isNotNull();
    assertThat(result.length()).isEqualTo(6);
}

@DisplayName("올바른 인증번호를 입력하면 redis에 성공 여부를 저장한다.")
@Test
void verifyCode1() {
    // given
    String email = "test@example.com";
    String verificationCode = "123456";
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(email, verificationCode, Duration.ofMinutes(3));

    // when
    mailVerificationService.verifyCode(email, verificationCode);
    String result = values.get(email);

    // then
    assertThat(result).isNotNull();
    assertThat(result).isEqualTo("ok");
}

@DisplayName("틀린 인증번호를 입력하면 예외를 던진다.")
@Test
void verifyCode2() {
    // given
    String email = "test@example.com";
    String verificationCode = "123456";
    String WrongCode = "111111";
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(email, verificationCode, Duration.ofMinutes(3));

    // expected
    assertThatThrownBy(() -> mailVerificationService.verifyCode(email, WrongCode))
        .isInstanceOf(InvalidCodeException.class)
        .hasMessage("인증번호를 확인해주세요.");
}

@DisplayName("메일 인증을 요청하지 않은 이메일로 인증 번호를 입력하면 예외를 던진다.")
@Test
void verifyCode3() {
    // given
    String email = "test@example.com";
    String notRequestedEmail = "midcon@nav.com";
    String verificationCode = "123456";
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(email, verificationCode, Duration.ofMinutes(3));

    // expected
    assertThatThrownBy(() -> mailVerificationService.verifyCode(notRequestedEmail, verificationCode))
        .isInstanceOf(InvalidCodeException.class)
        .hasMessage("인증번호를 확인해주세요.");
}

4. 결과 확인

서비스 레이어를 작성했으니 컨트롤러는 원하는대로 작성해서 테스트하면 된다.

인증 메일 발송

인증 메일 발송 후 redis-cli 확인

포스트맨으로 인증번호 입력 후 redis-cli 확인

이메일 인증

팀 프로젝트에서 회원가입 기획에서 이메일 인증 기능을 넣기로 했다.

자체 메일 서버를 구축하기 보다는 메일 서버로 Gmail을 이용해서 보내려고 한다.

이번 글에서는 이메일을 발송하기까지만 다루도록 한다.

발송한 이메일을 DB에 저장을 해서 그걸 확인하고 인증하는 과정도 필요하다.

그걸 어떻게 구현할지 아래 두가지 방법 중 고민인데 아직 미정이라서 추후 추가할듯.

  • Redis를 써서 시간 설정을 해두고 자동으로 삭제되게 관리
  • 관계형 DB를 써서 주기적으로 오래된 인증 메일을 삭제

사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • Gmail

1. 사전작업 - SMTP 계정 설정

1-1. 구글 계정 관리로 이동

Gmail을 메일 서버로 이용하기 위해 구글 계정의 앱 비밀번호가 필요하다.

구글 로그인 → 구글 계정 관리 → 검색창에 “앱 비밀번호” 검색해서 이동한다.

앱 비밀번호가 안뜨면 2단계 인증을 하지 않았을 수 있으므로 하도록 한다.

1-2. 앱 비밀번호 만들기

원하는 앱 이름을 입력하고 만든다.

1-3. 앱 비밀번호 발급

이 비밀번호를 이용해서 메일을 보낼 예정이니 잘 저장해둔다.


2. JavaMailSender 설정

2-1. 의존성 추가

이메일 전송을 위해 spring-boot-starter-mail의 의존성을 추가한다.

// build.gradle.kts
// 이메일 인증
implementation ("org.springframework.boot:spring-boot-starter-mail")

2-2. yml 파일 설정

이메일 전송을 위한 yml 설정을 추가한다.

본인이 쓰기 편한 dept로 설정한다.

본인은 @ConfigurationProperties를 쓰기 위해 smptProperties depth에 필요 설정을 몰아뒀음.

아래에서 바꿀건 username, password 뿐일듯

// application.yml
gmail:
  host: smtp.gmail.com            # 구글 이메일 사용 시
  port: 587                       # 포트번호
  username: hyukkind@gmail.com    # 구글 이메일, 다른 메일일 시 MailAuthenticationException 발생
  password: utnncuqkptaweaag      # 앱 비밀번호
  smtpProperties:
      auth: true
      starttls-enable: true
      starttls-required: true
      connection-timeout: 5000
      timeout: 5000
      writeTimeout: 5000

2-3. MailConfig 추가

JavaMailSender를 설정하는 방법은 여러가지가 있겠지만

본인은 MailConfig에서 JavaMailSender의 Bean 주입을 해주었다.

yml파일의 환경 변수를 가져오는건 @Value로 하든 @ConfigurationProperties로 하든 취향대로 하자.

기본적인 골자는 JavaMailSender의 property 설정만 아래처럼 Properties 객체로 넣어주면 된다.

@RequiredArgsConstructor
@ConfigurationProperties(prefix = "gmail")
public class MailConfig {

    private final String host;
    private final int port;
    private final String username;
    private final String password;
    private final SmtpProperties smtpProperties;

    public String getFromEmail(){
        return username;
    }

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");
        mailSender.setJavaMailProperties(getProperties());
        return mailSender;
    }

    private Properties getProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", smtpProperties.auth);
        properties.put("mail.smtp.starttls.enable", smtpProperties.starttlsEnable);
        properties.put("mail.smtp.starttls.required", smtpProperties.starttlsRequired);
        properties.put("mail.smtp.connectiontimeout", smtpProperties.connectionTimeout);
        properties.put("mail.smtp.timeout", smtpProperties.timeout);
        properties.put("mail.smtp.writetimeout", smtpProperties.writeTimeout);
        return properties;
    }

    @Getter
    @ToString
    @RequiredArgsConstructor
    private static class SmtpProperties {
        private final boolean auth;
        private final boolean starttlsEnable;
        private final boolean starttlsRequired;
        private final int connectionTimeout;
        private final int timeout;
        private final int writeTimeout;
    }
}

3. MailService 구현

3-1. MailService 구현

본인은 테스트용 Mock 구현체를 만들 필요가 있지 않을까 싶어서 MailService 인터페이스를 만들었지만

굳이 MailService를 인터페이스로 만들어서 타입 계층을 만들 필요는 없다.

바로 MailService 구현체를 만들어도 무방함.

인증번호는 6자리 랜덤 숫자를 발급하기로 했다.

메일 발송까지만 구현했고, DB에 저장하고 검증하는 로직은 추후 추가 예정이다.

MailVerificationService

@Slf4j
@Service
@RequiredArgsConstructor
public class MailVerificationService implements MailService {

    private final MailConfig mailConfig;
    private final JavaMailSender mailSender;

    @Override
    public void sendEmail(String toEmail) throws MessagingException, UnsupportedEncodingException {
        MimeMessage message = createEmailVerificationMessage(toEmail);
        try {
            mailSender.send(message);
        } catch (RuntimeException e) {
            log.error("[메일 전송 실패]");
            throw new MailSendException("메일 전송에 실패했습니다.");
        }
    }

    private MimeMessage createEmailVerificationMessage(String toEmail) throws MessagingException, UnsupportedEncodingException {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message);
        helper.setFrom(mailConfig.getFromEmail(), "나는짱");
        helper.setTo(toEmail);
        helper.setSubject("PAWLAND 이메일 인증");
        String template = createTemplate();
        helper.setText(template, true);
        return message;
    }

    private String createTemplate() {
        String template = "<html><body>";
        template += "<p>안녕하세요, PAWLAND입니다.</p>";
        template += "<p>인증 번호는 아래와 같습니다:</p>";
        template += "<h2>" + generateVerificationCode() + "</h2>";
        template += "<p>이 인증 번호를 입력하여 인증을 완료해주세요.</p>";
        template += "</body></html>";
        return template;
    }

    private String generateVerificationCode() {
        Random random = new Random();
        int randomNumber = random.nextInt(999999) + 1;
        return String.format("%06d", randomNumber);
    }
}

3-2. MailService 테스트

테스트마다 메일이 매번 날아온다면 내 메일함에 테스트용 메일이 가득할 것이라 바람직하지 않다.

그래서 JavaMailSender를 Mocking해서 테스트를 진행한다.

인증번호 생성 로직은 private 메서드라서 어떻게 테스트할까 생각해보았다.

간단한 로직이라 테스트할 필요가 있을까 싶기도 하고 인증번호가 이상하다고 큰 문제가 생기진 않을것 같아서

굳이 추가하지는 않았다.

MailVerificationServiceTest

@SpringBootTest
class MailVerificationServiceTest {

    @Autowired
    private MailConfig mailConfig;

    @MockBean
    private JavaMailSender mailSender;

    @Autowired
    private MailVerificationService mailVerificationService;

    @DisplayName("이메일 전송 성공 Mock 테스트")
    @Test
    void sendEmail1() throws MessagingException, UnsupportedEncodingException {
        // given
        String toEmail = "test@example.com";
        MimeMessage mimeMessage = new MimeMessage((Session) null);
        when(mailSender.createMimeMessage()).thenReturn(mimeMessage);

        // when
        mailVerificationService.sendEmail(toEmail);

        // then
        verify(mailSender).send(mimeMessage);
        verify(mailSender, times(1)).send(mimeMessage);
    }

    @DisplayName("이메일 전송 실패 Mock 테스트")
    @Test
    void sendEmail2() {
        // given
        String toEmail = "test@example.com";
        MimeMessage mimeMessage = new MimeMessage((Session) null);
        when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
        doThrow(new MailAuthenticationException("Authentication failed"))
            .when(mailSender)
            .send(mimeMessage);

        // expected
        assertThatThrownBy(() -> mailVerificationService.sendEmail(toEmail))
            .isInstanceOf(MailSendException.class)
            .hasMessage("메일 전송에 실패했습니다.");
    }
}

4. 결과 확인

Redis

레디스는 인메모리 기반의 NoSQL 데이터베이스이다.

지금껏 괜히 레디스라고 하면 괜히 쓰기 힘들것 같아서 미뤄뒀었다.

그러다가 안해본것들 하나씩 해보기의 일환으로 이번에는 레디스를 써보기로 했다.


Redis 설치

레디스를 쓰려면 우선 EC2든 로컬이든 레디스가 깔려있어야 한다.

인텔리제이 얼티밋 버전은 따로 DB 연결을 관리하는지는 모르겠지만 본인은 흙수저 버전이므로 직접 설치한다.

맥에서는 아마 brew로 다운 받지 않을까 싶은데 윈도우에서는 직접 설치했다.

윈도우 환경에서 레디스를 설치하려면 아래 링크에서 원하는 버전의 msi 확장자로 다운받고 설치한다.

본인은 윈도우 10을 쓰고 있고 Redis는 대충 최신 버전으로 했음(3.0.504)

https://github.com/microsoftarchive/redis/releases

msi 파일 실행하고 설치는 꽤 직관적이므로 설치 과정은 생략한다.

설치가 완료 됐으면 아래처럼 작업 관리자에서 실행중인지 확인할 수 있다.


Redis 사용

윈도우에서는 redis-cli.exe를 사용해서 레디스 상태를 확인할 수 있다.

위에서 설치한 레디스 경로로 가보면 redis-cli.exe가 있을것이다.

redis-cli에 ping을 입력하면 pong으로 응답이 오면 제대로 연결된것이다.

레디스의 기본 포트번호는 6379이다.

만약 이 포트번호를 바꿔서 사용하고 싶다면 redis.windows.conf 설정을 바꾸면 된다.

레디스 데이터베이스 선택하기

기본적으로 0~15의 instance가 떠있으며, 각각의 인스턴스에 데이터를 따로 저장할 수 있다.

아래 커맨드로 데이터베이스를 선택할 수 있다

SELECT 번호 // 기본 값은 0

 

아래처럼 cli 입력부분에서 몇번째 데이터베이스를 쓰는지 확인할 수 있다.

레디스에 데이터 읽기/쓰기

redis-cli에 아래와 같은 커맨드로 입력하면 key, value 형태로 저장된다.

커맨드가 성공하면 OK라는 응답값을 준다.

SET key value

 

redis-cli에 아래와 같은 커맨드로 입력하면 key에 해당하는 value 값이 출력된다.

만약 key에 해당하는 데이터가 없을 때에는 (nil)이라는 응답을 반환한다.

GET key

 

직접 redis-cli로 써보면 아래와 같이 확인할 수 있다.

레디스에 저장된 데이터 삭제하기

redis-cli에 아래처럼 커맨드를 입력하면 key에 해당하는 데이터들이 삭제된다.

삭제에 성공하면 (interger) 1 이라는 값을 반환하고, 실패하면 (integer) 0이라는 값을 반환한다.

DEL key1 key2 key3

 

위 읽기/쓰기에서 이어서 redis-cli를 써서 해보면 아래와 같이 확인할 수 있다.

  • 처음 hi에 해당하는 값을 지울때는 저장된 데이터가 있었으므로 (integer) 1을 반환
  • 이후 조회 시 저장된 데이터가 없으니 (nil) 반환
  • 다시 hi에 해당하는 값을 지울때는 저장된 데이터가 없으므로 0 반환

레디스에 저장된 모든 데이터 삭제하기

테스트코드를 돌릴 때 이상적인 테스트 환경을 보장하기 위해 각각의 테스트 전후로 DB를 비워둔다.

이럴 때 사용하는 레디스 커맨드는 아래와 같다.

FLUSHDB   // 현재 선택한 데이터베이스의 데이터 삭제
FLUSHALL  // 모든 데이터베이스의 데이터 삭제

 

JwtFilter

이번 프로젝트에서는 스프링 시큐리티를 사용하기로 했다.

또한 회원가입/로그인 시 Jwt를 발급하고 쿠키로 Jwt를 받아서 인증을 하는 방식을 채택했다.

그래서 Jwt를 검증하는 필터를 만들 필요가 생겼다.

OncePerRequestFilter는 스프링 시큐리티에서 제공하는 필터가 아니다.

그래서 인증 로직이 시큐리티 컨텍스트에 어거지로 넣는 느낌이 들어서 이게 맞나 싶긴 하다.

하지만 시큐리티 필터들도 기본적으로는 org.springframework.web.filter 에서 파생되지 않을까 싶었고

시간이 부족하기 때문에 우선은 이렇게 구현하기로 했다.

시큐리티는 더 공부하면 더 좋은 방법을 찾을 수 있을지 모르겠다.


구현

JwtAuthFilter

OncePerRequestFilter를 상속받은 JwtAuthFilter를 만든다.

스프링 시큐리티와 따로 노는 느낌이 드는건 이 필터를 빈 등록하면 모든 요청에 대해 필터링을 한다.

시큐리티에서 승인한 URL까지 필터링을 하기 때문에 골치였다.

결국 ExcludeUrlsRequestMatcher를 따로 구현하고,  shouldNotFilter를 오버라이드 해서 필터링 하지 않는 URL들을 설정했다.

JwtUtils에서 Jwt를 검증하고, 만약 잘못된 요청이라면 예외를 던져서 catch 문에서 에러 메시지를 내려줬다.

public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;
    private final RequestMatcher excludeUrlsMatcher;

    public JwtAuthFilter(JwtUtils jwtUtils, RequestMatcher excludeUrlsMatcher) {
        this.jwtUtils = jwtUtils;
        this.excludeUrlsMatcher = excludeUrlsMatcher;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return excludeUrlsMatcher.matches(request);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Cookie[] cookies = request.getCookies();
        try {
            String jwt = jwtUtils.getJwtFromCookie(cookies);
            Authentication authentication = jwtUtils.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request, response);
        } catch (BadCredentialsException e) {
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding(UTF_8.name());
            response.setStatus(SC_UNAUTHORIZED);
            response.getWriter().write(e.getMessage());
        }
    }
}

ExcludeUrlsRequestMatcher

RequestMatcher를 구현하는 구현체를 만들어서 JwtAuthFilter에서 필터링 하지 않는 URL들을 지정했다.

public class ExcludeUrlsRequestMatcher implements RequestMatcher {

    private String[] excludeUrls;

    public ExcludeUrlsRequestMatcher(String... excludeUrls) {
        this.excludeUrls = excludeUrls;
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        AntPathMatcher pathMatcher = new AntPathMatcher();
        return Arrays.stream(excludeUrls)
            .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, requestURI));
    }
}

SecurityConfig

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ObjectMapper objectMapper;
    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
            .requestMatchers("/favicon.ico")
            .requestMatchers("/error")
            .requestMatchers(toH2Console()); // TODO: 배포 시 제거
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(authorize -> authorize               
                .requestMatchers("/**").permitAll()
            )
            .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jsonAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> {
                e.authenticationEntryPoint(new Http401Handler(objectMapper));
                e.accessDeniedHandler(new Http403Handler(objectMapper));
            })
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    public JwtAuthFilter jwtAuthFilter() {
        return new JwtAuthFilter(jwtUtils, new ExcludeUrlsRequestMatcher(
            "/api/auth/**",
            "/api/v1/auth/**",
            "/swagger-ui/**",
            "/swagger-resources/**",
            "/v3/api-docs/**"));
    }

    @Bean
    public JsonAuthFilter jsonAuthFilter() {
        JsonAuthFilter filter = new JsonAuthFilter("/api/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(objectMapper, jwtUtils));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64
        );
    }
}

결과

이렇게 해두면 ExcludeUrlsRequestMatcher 로 설정한 URL을 제외한 모든 요청에 대해 Jwt 인증을 수행한다.

그런데 스프링 시큐리티 세팅과 너무 따로 노는 느낌이 들어서 좋은 방법이 있다면 수정해보고 싶다.

S3에 이미지 저장

이전 글에서 S3 버킷을 만들어두었다.

이번 글에서는 S3에 이미지를 저장하는 방법을 정리해보려 한다.

최근 알게 된 Presigned URL 방식으로 프론트에서 이미지를 저장하는 방식도 있지만

이번 글에서는 프론트에서 MultipartFile 형태로 이미지를 받아서 저장하는 방식으로 해보았다.

Presigned URL 방식은 추후 따로 글 쓸듯.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • Aws SDK

1. Aws SDK 관련 설정

Aws SDK 의존성 추가

// build.gradle.kts
implementation(platform("software.amazon.awssdk:bom:2.25.35"))
implementation("software.amazon.awssdk:s3")

 

Aws SDK를 사용하는 방법은 두가지가 있다.

  • BOM을 이용한 개별 의존성 추가 방법
  • SDK 전체 추가

본인은 어차피 S3만 쓸것 같기 때문에 S3만 추가하였다.

application.yml 설정

액세스 키, 시크릿 키를 확인하는 방법은 이 글을 확인하자.

이전 글의 마지막에서 만든 액세스 포인트를 활용한다.

aws:
  access-key: # AWS 액세스 키
  secret-key: # AWS 시크릿 키
  s3-access-point: # S3 액세스 포인트

AwsConfig 설정

이미지 업로드에 사용할 S3Client를 빈 등록해준다.

@RequiredArgsConstructor
@ConfigurationProperties(prefix = "aws")
public class AwsConfig {

    private final String accessKey;
    private final String secretKey;
    private final String s3AccessPoint;

    public String getS3AccessPoint() {
        return s3AccessPoint;
    }

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
            .credentialsProvider(getAwsBasicCredentials())
            .region(Region.AP_NORTHEAST_2)
            .build();
    }

    private StaticCredentialsProvider getAwsBasicCredentials() {
        return StaticCredentialsProvider.create(
            AwsBasicCredentials.create(accessKey, secretKey)
        );
    }
}

2. S3Client를 이용하여 이미지 업로드

ImageController

@RequestParam이나 @RequestPart 애노테이션을 이용해서 MultipartFile을 받는다.

@ModelAttribute로 객체로 받아도 무관하다.

@RestController
@RequiredArgsConstructor
public class ImageController {

    private final ImageService imageService;

    @PostMapping("/upload")
    public String uploadFile(@RequestPart MultipartFile file) throws IOException {
        imageService.upload(file);
        return "이미지 저장 성공";
    }
}

ImageService

컨트롤러에서 받은 MultipartFile을 서비스 레이어에서 처리한다.

S3Client.putObject() 메서드가 받는 인자는 PutObjectRequest, RequestBody 두 개이다.아래처럼 MultipartFile의 inputStream으로 RequestBody를 만들어주면 되고key에는 S3에 저장할 파일 이름을 넣어주면 된다.PutObject라는 이름처럼 같은 이름의 파일이라면 덮어 씌우므로 여러 사람이 이미지를 올린다면 UUID 형태로 저장하는게 좋다.

@Service
@RequiredArgsConstructor
public class ImageService {

    private final S3Client s3Client;
    private final AwsConfig awsConfig;

    public void upload(MultipartFile file) throws IOException {
        String fileName = file.getOriginalFilename();
        RequestBody requestBody = RequestBody.fromInputStream(file.getInputStream(), file.getSize());

        s3Client.putObject(builder -> builder
                .bucket(awsConfig.getS3AccessPoint())
                .key(fileName)  // S3에 저장할 파일명
            , requestBody);
    }
}

3. 결과 확인

PostMan 으로 이미지를 저장해보자.

MultipartFile 형태로 데이터를 받으므로 form-data로 요청을 보내야한다.

S3 버켓을 확인하면 이미지도 제대로 저장됨을 알 수 있다.

+ Recent posts