Introduction

이 글에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현했습니다.

JWT 와 Spring Security 코드는 인프런 Spring Boot JWT Tutorial (정은구) 강의를 수강하면서 만들고 제 스타일에 맞게 수정했습니다.

오로지 Security 와 인증 로직에 초점을 맞추기 위해 불필요한 코드는 제거했습니다.

구현하고자 하는 전체 로직은 다음과 같습니다.


(2022. 11. 22 추가)


1. JWT

JWT 에 관련된 글은 따로 작성했기 때문에 링크로 대체합니다.


2. Spring Security

Spring Security 는 사용자 정보 (ID/PW) 검증 및 유저 정보 관리 등을 쉽게 사용할 수 있도록 제공합니다.

JWT 와 같이 소개되는 경우가 많은데 스프링 시큐리티는 원래 세션 기반 인증을 사용하기 때문에 JWT 와 별개로 생각해야 합니다.

사용자 로그인 뿐만 아니라 보안 관련된 여러가지 설정들도 제공하기 때문에 실제 업무에서 사용한다면 꼭 개념을 미리 학습하고 사용하는 것을 권장합니다.

여기서는 겉핥기식으로 필요한 시큐리티 정보만 세팅하고 어떤식으로 동작하는 지만 파악합니다.

  • User Role 을 꼭 설정해야 하나요?
    • Spring Security 자체에서 내부적으로 사용하는 것 같음
    • ROLE_USER 처럼 정확히 형식을 지켜줘야 함

// build.gradle
plugins {
    id 'org.springframework.boot' version '2.7.5'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id 'java'
}

group = 'com.tutorial'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

        // security 관련 의존성
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

        // jwt 관련 의존성
    compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}

test {
    useJUnitPlatform()
}

3. Member 도메인 설계

시큐리티 설정을 테스트하기 위한 기본적인 사용자 도메인을 만듭니다.

시큐리티 자체적으로 UserDetails 의 구현체인 User 를 사용하기 때문에 헷갈리지 않도록 Account 또는 Member 로 이름 짓는게 좋습니다. (개인적인 생각)

  • Member 도메인
    • Member
    • MemberRepository
    • MemberService
    • MemberController
    • application.yml: h2 database 설정과 jwt secret key 설정

3.1. Member

@Getter
@NoArgsConstructor
@Table(name = "member")
@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String password;

    @Enumerated(EnumType.STRING)
    private Authority authority;

    @Builder
    public Member(String email, String password, Authority authority) {
        this.email = email;
        this.password = password;
        this.authority = authority;
    }
}
  • 최소한의 정보만을 갖고 있는 Member Entity 입니다.

public enum Authority {
    ROLE_USER, ROLE_ADMIN
}
  • 권한은 Enum 클래스로 만들었습니다.

3.2. MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
    boolean existsByEmail(String email);
}
  • 마찬가지로 최소한의 쿼리만 갖고있습니다.
  • Email 을 Login ID 로 갖고 있기 때문에 findByEmail 와 중복 가입 방지를 위한 existsByEmail 만 추가합니다.

3.3. MemberService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberResponseDto findMemberInfoById(Long memberId) {
        return memberRepository.findById(memberId)
                .map(MemberResponseDto::of)
                .orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다."));
    }

    public MemberResponseDto findMemberInfoByEmail(String email) {
        return memberRepository.findByEmail(email)
                .map(MemberResponseDto::of)
                .orElseThrow(() -> new RuntimeException("유저 정보가 없습니다."));
    }
}
  • memberIdemail 로 회원 정보를 가져오는 Service 입니다.

3.4. MemberController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {
    private final MemberService memberService;

    @GetMapping("/me")
    public ResponseEntity<MemberResponseDto> findMemberInfoById() {
        return ResponseEntity.ok(memberService.findMemberInfoById(SecurityUtil.getCurrentMemberId()));
    }

    @GetMapping("/{email}")
    public ResponseEntity<MemberResponseDto> findMemberInfoByEmail(@PathVariable String email) {
        return ResponseEntity.ok(memberService.findMemberInfoByEmail(email));
    }
}
  • 내 정보를 가져올 때는 SecurityUtil.getCurrentMemberId() 를 사용합니다.
  • API 요청이 들어오면 필터에서 Access Token 을 복호화 해서 유저 정보를 꺼내 SecurityContext 라는 곳에 저장합니다.
  • SecurityContext 에 저장된 유저 정보는 전역으로 어디서든 꺼낼 수 있습니다.
  • SecurityUtil 클래스에서는 유저 정보에서 Member ID 만 반환하는 메소드가 정의되어 있습니다.
  • MemberService 가 아닌 MemberController 에서 사용하는 이유는 MemberService 테스트가 SecurityContext 에 의존적이지 않게 하기 위해서입니다.

3.5. application.yml

spring:

  h2:
    console:
      enabled: true

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true

logging:
  level:
    com.tutorial: debug

# HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
# Secret 값은 특정 문자열을 Base64 로 인코딩한 값 사용 (아래 명령어를 터미널에 쳐보면 그대로 나옴)
# $ echo 'spring-boot-security-jwt-tutorial-jiwoon-spring-boot-security-jwt-tutorial' | base64
jwt:
  secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
  • H2 Database 를 사용하기 위한 기본 설정과 JWT 시크릿 키를 설정해둡니다.
  • 시크릿 키도 원래는 깃헙에 올라가지 않게 별도로 보관하는 것이 안전합니다.

4. JWT 와 Security 설정

  • JWT 관련
    • TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
    • JwtFilter: Spring Request 앞단에 붙일 Custom Filter
  • Spring Security 관련
    • JwtSecurityConfig: JWT Filter 를 추가
    • JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
    • JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
    • SecurityConfig: 스프링 시큐리티에 필요한 설정
    • SecurityUtil: SecurityContext 에서 전역으로 유저 정보를 제공하는 유틸 클래스

4.1. TokenProvider

@Slf4j
@Component
public class TokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어집니다.
  • 생성자
    • application.yml 에 정의해놓은 jwt.secret 값을 가져와서 JWT 를 만들 때 사용하는 암호화 키값을 생성합니다.
  • generateTokenDto
    • 유저 정보를 넘겨받아서 Access Token 과 Refresh Token 을 생성합니다.
    • 넘겨받은 유저 정보의 authentication.getName() 메소드가 username 을 가져옵니다.
    • 저는 username 으로 Member ID 를 저장했기 때문에 해당 값이 설정될 겁니다.
    • Access Token 에는 유저와 권한 정보를 담고 Refresh Token 에는 아무 정보도 담지 않습니다.
  • getAuthentication
    • JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼냅니다.
    • Access Token 에만 유저 정보를 담기 때문에 명시적으로 accessToken 을 파라미터로 받게 했습니다.
    • Refresh Token 에는 아무런 정보 없이 만료일자만 담았습니다.
    • UserDetails 객체를 생생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext 를 사용하기 위한 절차라고 생각하면 됩니다..
    • 사실 좀 불필요한 절차라고 생각되지만 SecurityContextAuthentication 객체를 저장하기 때문에 어쩔수 없습니다.
    • parseClaims 메소드는 만료된 토큰이어도 정보를 꺼내기 위해서 따로 분리했습니다.
  • validateToken
    • 토큰 정보를 검증합니다.
    • Jwts 모듈이 알아서 Exception 을 던져줍니다.

4.2. JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • OncePerRequestFilter 인터페이스를 구현하기 때문에 요청 받을 때 단 한번만 실행됩니다.
  • doFilterInternal
    • 실제 필터링 로직을 수행하는 곳입니다.
    • Request Header 에서 Access Token 을 꺼내고 여러가지 검사 후 유저 정보를 꺼내서 SecurityContext 에 저장합니다.
    • 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않습니다.
    • 그리고 요청이 정상적으로 Controller 까지 도착했다면 SecurityContext 에 Member ID 가 존재한다는 것이 보장됩니다.
    • 대신 직접 DB 를 조회한 것이 아니라 Access Token 에 있는 Member ID 를 꺼낸 거라서, 탈퇴로 인해 Member ID 가 DB 에 없는 경우 등 예외 상황은 Service 단에서 고려해야 합니다.

4.3. JwtSecurityConfig

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> 인터페이스를 구현하는 구현체입니다.
  • 여기서 직접 만든 JwtFilter 를 Security Filter 앞에 추가합니다.

4.4. JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • 유저 정보 없이 접근하면 SC_UNAUTHORIZED (401) 응답을 내려줍니다.

4.5. JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • 유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 SC_FORBIDDEN (403) 응답을 내려줍니다.

4.6. SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

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

    // h2 database 테스트가 원활하도록 관련 API 들은 전부 무시
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .antMatchers("/h2-console/**", "/favicon.ico");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            // CSRF 설정 Disable
        http.csrf().disable()

            // exception handling 할 때 우리가 만든 클래스를 추가
            .exceptionHandling()
            .authenticationEntryPoint(jwtAuthenticationEntryPoint)
            .accessDeniedHandler(jwtAccessDeniedHandler)

            .and()
            .headers()
            .frameOptions()
            .sameOrigin()

            // 시큐리티는 기본적으로 세션을 사용
            // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
            .and()
            .authorizeRequests()
            .antMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()   // 나머지 API 는 전부 인증 필요

            // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
            .and()
            .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }
}
  • Spring Security 의 가장 기본적인 설정이며 JWT 를 사용하지 않더라도 이 설정은 기본으로 들어갑니다.
  • 각 설정에 대한 설명은 주석을 확인하면 됩니다.
  • (2022. 11. 23 추가) Spring Security 의 WebSecurityConfigurerAdapter 가 deprecated 되어 이에 맞게 SecurityConfig 파일의 수정이 있었습니다.

4.7. SecurityUtil

@Slf4j
public class SecurityUtil {

    private SecurityUtil() { }

    // SecurityContext 에 유저 정보가 저장되는 시점
    // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
    public static Long getCurrentMemberId() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getName() == null) {
            throw  new RuntimeException("Security Context 에 인증 정보가 없습니다.");
        }

        return Long.parseLong(authentication.getName());
    }
}
  • JwtFilter 에서 SecurityContext 에 세팅한 유저 정보를 꺼냅니다.
  • 저는 무조건 memberId 를 저장하게 했으므로 꺼내서 Long 타입으로 파싱하여 반환합니다.
  • SecurityContextThreadLocal 에 사용자의 정보를 저장합니다.

5. Refresh Token 저장소

Access Token 과 Refresh Token 을 함께 사용하기 때문에 저장이 필요합니다.

보통은 Token 이 만료될 때 자동으로 삭제 처리 하기 위해 Redis 를 많이 사용하지만, 귀찮으니 일단 임시로 RDB 에 저장하는 방식으로 구현했습니다.

만약 지금 예제처럼 RDB 를 저장소로 사용한다면 배치 작업을 통해 만료된 토큰들을 삭제해주는 작업이 필요합니다.


5.1. RefreshToken

@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshToken {

    @Id
    @Column(name = "rt_key")
    private String key;

    @Column(name = "rt_value")
    private String value;

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}
  • key 에는 Member ID 값이 들어갑니다.
  • value 에는 Refresh Token String 이 들어갑니다.
  • 위에서 언급한대로 RDB 로 구현하게 된다면 생성/수정 시간 컬럼을 추가하여 배치 작업으로 만료된 토큰들을 삭제해주어야 합니다.

5.2. RefreshTokenRepository

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByKey(String key);
}
  • Member ID 값으로 토큰을 가져오기 위해 findByKey 만 추가했습니다.

6. 사용자 인증 과정

지금까지 스프링 시큐리티와 JWT 를 사용하기 위한 설정들을 전부 끝냈습니다.

지금부터는 실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후에 JWT 토큰을 발급하는 과정을 알아봅니다.

  • AuthController
  • AuthService
  • CustomUserDetailsService

6.1. AuthController

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
        return ResponseEntity.ok(authService.signup(memberRequestDto));
    }

    @PostMapping("/login")
    public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto memberRequestDto) {
        return ResponseEntity.ok(authService.login(memberRequestDto));
    }

    @PostMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
        return ResponseEntity.ok(authService.reissue(tokenRequestDto));
    }
}
  • 회원가입 / 로그인 / 재발급 을 처리하는 API 입니다.
  • SecurityConfig 에서 /auth/** 요청은 전부 허용했기 때문에 토큰 검증 로직을 타지 않습니다.
  • MemberRequestDto 에는 사용자가 로그인 시도한 ID / PW String 이 존재합니다.
  • TokenRequestDto 에는 재발급을 위한 AccessToken / RefreshToken String 이 존재합니다.

6.2. AuthService

@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
        if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다");
        }

        Member member = memberRequestDto.toMember(passwordEncoder);
        return MemberResponseDto.of(memberRepository.save(member));
    }

    @Transactional
    public TokenDto login(MemberRequestDto memberRequestDto) {
        // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken = memberRequestDto.toAuthentication();

        // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
        //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 4. RefreshToken 저장
        RefreshToken refreshToken = RefreshToken.builder()
                .key(authentication.getName())
                .value(tokenDto.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        // 5. 토큰 발급
        return tokenDto;
    }

    @Transactional
    public TokenDto reissue(TokenRequestDto tokenRequestDto) {
        // 1. Refresh Token 검증
        if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
        }

        // 2. Access Token 에서 Member ID 가져오기
        Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

        // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
        RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

        // 4. Refresh Token 일치하는지 검사
        if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
        }

        // 5. 새로운 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 6. 저장소 정보 업데이트
        RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
        refreshTokenRepository.save(newRefreshToken);

        // 토큰 발급
        return tokenDto;
    }
}

#회원가입 (signup)

  • 평범하게 유저 정보를 받아서 저장합니다.

#로그인 (login)

  • Authentication
    • 사용자가 입력한 Login ID, PW 로 인증 정보 객체 UsernamePasswordAuthenticationToken를 생성합니다.
    • 아직 인증이 완료된 객체가 아니며 AuthenticationManager 에서 authenticate 메소드의 파라미터로 넘겨서 검증 후에 Authentication 를 받습니다.
  • AuthenticationManager
    • 스프링 시큐리티에서 실제로 인증이 이루어지는 곳입니다.
    • authenticate 메소드 하나만 정의되어 있는 인터페이스며 위 코드에서는 Builder 에서 UserDetails 의 유저 정보가 서로 일치하는지 검사합니다.
    • 그런데 코드상으로는 전혀 구현된게 없는데 어떻게 된 걸까요?
    • 내부적으로 수행되는 검증 과정은 아래의 CustomUserDetailsService 클래스에서 다루겠습니다.
  • 인증이 완료된 authentication 에는 Member ID 가 들어있습니다.
  • 인증 객체를 바탕으로 Access Token + Refresh Token 을 생성합니다.
  • Refresh Token 은 저장하고, 생성된 토큰 정보를 클라이언트에게 전달합니다.

#재발급 (reissue)

  • Access Token + Refresh Token 을 Request Body 에 받아서 검증합니다.
  • Refresh Token 의 만료 여부를 먼저 검사합니다.
  • Access Token 을 복호화하여 유저 정보 (Member ID) 를 가져오고 저장소에 있는 Refresh Token 과 클라이언트가 전달한 Refresh Token 의 일치 여부를 검사합니다.
  • 만약 일치한다면 로그인 했을 때와 동일하게 새로운 토큰을 생성해서 클라이언트에게 전달합니다.
  • Refresh Token 은 재사용하지 못하게 저장소에서 값을 갱신해줍니다.

6.3. CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository.findByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    // DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());

        return new User(
                String.valueOf(member.getId()),
                member.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}
  • UserDetailsService 인터페이스를 구현한 클래스입니다.
  • loadUserByUsername 메소드를 오버라이드 하는데 여기서 넘겨받은 UserDetailsAuthentication 의 패스워드를 비교하고 검증하는 로직을 처리합니다.
  • 물론 DB 에서 username 을 기반으로 값을 가져오기 때문에 아이디 존재 여부도 자동으로 검증 됩니다.
  • loadUserByUsername 메소드를 어디서 호출하는지 내부를 타고 들어가봅니다.

6.3.1. CustomUserDetailsService

loadUserByUsername 는 여러 곳에서 호출하고 있는데 이 중에서 DaoAuthenticationProvider 내부를 확인해봅니다.


6.3.2. DaoAuthenticationProvider

  • username 을 받아서 넘겨주는 retrieveUser 메소드 내부에서 호출합니다.
  • 그럼 이 retrieveUser 는 어디서 호출할까요?

6.3.3. AbstractUserDetailsAuthenticationProvider

  • DaoAuthenticationProvider 의 부모 클래스인 AbstractUserDetailsAuthenticationProvider 에서 호출합니다.
  • 코드를 쭉 보니 받아온 user 변수로 additionalAuthenticationChecks 메소드를 호출합니다.
  • 메소드를 확인해보니 추상 클래스였고, DaoAuthenticationProvider 를 다시 확인해보니 오버라이드 해서 구현이 되어 있었습니다.

6.3.4. 다시 DaoAuthenticationProvider

  • 실제로 비밀번호 검증이 이루어지는 부분입니다 !
  • Request 로 받아서 만든 authentication 와 DB 에서 꺼낸 값인 userDetails 의 비밀번호를 비교합니다.
  • DB 에 있는 값은 암호화된 값이고 사용자가 입력한 값은 raw 값이지만 passwordEncoder 가 알아서 비교해줍니다.
  • 그래서 결국 비밀번호 검증이 시큐리티가 제공하는 클래스에서 이루어지는 것을 확인했는데 로그인 시에 사용되는 AuthenticationManager 와는 무슨 관계일까요?
  • AbstractUserDetailsAuthenticationProviderauthenticate 를 어디에서 호출하는지 확인해봅니다.

  • AbstractUserDetailsAuthenticationProviderauthenticate 는 단 한곳에서 호출합니다.

6.3.5. ProviderManager

  • 여기서도 authenticate 라는 메소드네요.
  • AuthenticationProvider 라는 인터페이스에서 호출하는데요.
  • 이름으로 짐작할 수 있듯이 AbstractUserDetailsAuthenticationProvider 의 상위 인터페이스입니다.
  • 그리고 ProviderManager.authenticate 를 호출하는 곳을 확인해보니 드디어 찾을 수 있었습니다.

6.3.6. AuthService

  • ProviderManagerAuthenticationManager 의 구현체입니다.
  • 지금까지의 탐구 과정을 역으로 다시 가보면 어떤 순서로 비밀번호 검증이 이루어지는 지 알 수 있습니다.

  1. AuthService (그림에서는 오타) 에서 AuthenticationManagerBuilder 주입 받음
  2. AuthenticationManagerBuilder 에서 AuthenticationManager 를 구현한 ProviderManager 생성
  3. ProviderManager 는 AbstractUserDetailsAuthenticationProvider 의 자식 클래스인 DaoAuthenticationProvider 를 주입받아서 호출
  4. DaoAuthenticationProvider 의 authenticate 에서는 retrieveUser 로 DB 에 있는 사용자 정보를 가져오고 additionalAuthenticationChecks 로 비밀번호 비교
  5. retrieveUser 내부에서 UserDetailsService 인터페이스를 직접 구현한 CustomUserDetailsService 클래스의 오버라이드 메소드인 loadUserByUsername 가 호출됨

7. API 호출 테스트

이제 서버를 띄우고 실제로 API 호출을 해봅니다.

API 요청은 인텔리제이에 있는 http Tool 을 사용했습니다.


7.1. 가입

# Request
POST http://localhost:8080/auth/signup
Content-Type: application/json

{
  "email": "test@test.net",
  "password": "1q2w3e4r"
}

# Response
{
  "email": "test@test.net"
}

7.2. 로그인

# Request
POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email": "test@test.net",
  "password": "1q2w3e4r"
}

# Response
{
  "grantType": "bearer",
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g",
  "accessTokenExpiresIn": 1615114280584
}

7.3. 일반 API 요청

# Request
GET http://localhost:8080/api/member/me
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ

# Response
{
  "email": "test@test.net"
}
  • 사용자 요청 -> JwtFiletr (SecurityContext 세팅) -> Controller -> Service

7.4. 재발급

# Request
POST http://localhost:8080/auth/reissue
Content-Type: application/json

{
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g"
}

# Response
{
  "grantType": "bearer",
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDM2NX0.5VXa6Cht_DPEEGe7-BrElvsrs7qRXmVnkDdi4Lm3PxZ0vAgqFdirhe5RlE1D-Wc1zaUepBmGhhw-u-oP_-rbKQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTczNjV9.tZytWyCWkWIYitvT3pa8FSnxilBDMtSevUzKRFK21TGLITf2eLXEwNNS_Q7rylD9uUe3Rx9ZR2NVqE_ZNWxTqg",
  "accessTokenExpiresIn": 1615114365284
}

Reference

  1. IntelliJ IDEA > Preferences > Editor > Inspections 으로 이동
  2. JPA 를 검색한 뒤 Unresolved database references in annotations 체크 해제

Collection 의 합을 구하는 방법

Collection 의 합을 구하는 방법은 reducesum 두 가지가 존재합니다.

단, Stream 에서 sum() 을 사용하려면 IntStream, LongStream, DoubleStream 와 같은 기본형 (Primitive Type) 특화 스트림을 사용해야 합니다.

그래서 보통 mapToInt, mapToLong, mapToDouble 같은 메소드로 스트림을 변환시키고 사용합니다.

 

reduce

reduce(초기값, 연산) 형식으로 사용합니다.

초기값부터 시작하여 각 원소를 차례대로 순회하며 연산을 수행합니다.

이전 연산의 결과를 다음 초기값으로 넘기면서 연산의 결과를 누적해서 총 결과값을 구하는 메서드입니다.

int sum = Stream.of(1, 2, 3).reduce(0, Integer::sum);

 

sum

Stream 의 총합을 구하는 메서드입니다.

기본형 특화 스트림에서만 사용 가능합니다.

int sum = Stream.of(1, 2, 3).mapToInt(e -> e).sum();

 

그렇다면 합을 구할 때 어떤걸 사용할까?

처음 생각 할 때는 reduce 로 한번에 하는게 Stream 처리가 적어서 더 빠를거라고 생각했습니다.

가독성도 크게 차이 안납니다.

그런데 실제로 테스트 해보니 결과는 달랐습니다.

 

Test Code

public static void main(String[] args) {
    Map<String, Integer> hashmap = new HashMap<>();

    for (int i = 0; i < 10000000; i++) {
        hashmap.put(i + "", i);
    }

    // for-loop 시간측정
    long start1 = System.currentTimeMillis();
    int sum1 = 0;
    for (String key : hashmap.keySet()) {
        sum1 += hashmap.get(key);
    }
    long end1 = System.currentTimeMillis();

    // stream mapToInt 시간측정
    long start2 = System.currentTimeMillis();
    int sum2 = hashmap.values().stream().reduce(0, Integer::sum);
    long end2 = System.currentTimeMillis();

    // stream reduce 시간측정
    long start3 = System.currentTimeMillis();
    int sum3 = hashmap.values().stream().mapToInt(i -> i).sum();
    long end3 = System.currentTimeMillis();

    System.out.println("for-loop: " + (end1 - start1));
    System.out.println("int reduce: " + (end2 - start2));
    System.out.println("mapToInt: " + (end3 - start3));
}

Result

for-loop: 303
reduce: 368
mapToInt: 310

 

원인 & 결론

reduce 에는 박싱, 언박싱 비용이 들어갑니다.

내부적으로 합계를 계산하기 위해 Integerint 형으로 언박싱 하고 다시 int -> Integer 로 박싱하는 과정이 숨겨져 있어서 시간이 더 오래 걸립니다.

그래서 reduce 의 성능이 더 느리니 IntStream 에서 제공하는 메서드가 있는 경우에는 해당 메서드를 사용하는 게 유용합니다.

 

Reference

'Language > Java' 카테고리의 다른 글

Java Stream count() 의 비밀  (0) 2021.04.18
Java 8 함수형 인터페이스 (Functional Interface)  (1) 2021.04.10
Mac OS Java (OpenJDK) 설치 및 버전 변경  (2) 2021.03.08
[Java] Static  (0) 2020.09.19
[Java] Map, HashMap, LinkedHashMap  (0) 2020.04.25

1. Collection Sort

Kotlin 에서는 Collection 을 정렬하기 위한 여러가지 유틸리티들을 제공합니다.


1.1. Sort, Sorted

가장 쉬운 방법은 sort 메소드를 호출하는 겁니다.

기본적으로 오름차순으로 정렬합니다.

val list = mutableListOf(1, 2, 7, 6, 5, 6)
list.sort()
println(list)  // [1, 2, 5, 6, 6, 7]

sort 메소드는 해당 Collection 의 원소 위치가 변경됩니다.

기존 Collection 은 그대로 둔 채 새로운 Collection 으로 받길 원한다면 sorted 메소드를 사용해야 합니다.

sorted 메소드를 사용하면 기존 Collection 은 변하지 않습니다.

val list = mutableListOf(1, 2, 7, 6, 5, 6)
val sorted = list.sorted()
println(sorted)  // [1, 2, 5, 6, 6, 7]
println(list)    // [1, 2, 7, 6, 5, 6] (sorted 를 사용했기 때문에 변하지 않음)

내림차순으로 정렬하고 싶다면 sortByDescending 를 사용하거나 reverse 메소드를 사용하면 됩니다.

마찬가지로 sortedByDescending 를 사용하면 원래 Collection 의 변경 없이 내림차순으로 정렬된 값을 구할 수 있습니다.

// 1. sortByDescending 로 내림차순 정렬
list.sortByDescending { it }

val sorted = list.sortedByDescending { it }

// 2. reverse 사용해서 정렬 후 뒤집기
list.sort()
list.reverse()

val sorted = list.sorted().reversed()

1.2. SortBy

만약 Object 의 특정 Property 들을 기준으로 정렬하고 싶다면 sortBy 메소드를 사용하면 됩니다.

sortBy 메소드는 Object 를 받아서 Property 를 반환하는 Lamdba 식을 파라미터로 받습니다.

val list = mutableListOf(1 to "a", 2 to "b", 7 to "c", 6 to "d", 5 to "c", 6 to "e")
list.sortBy { it.second }
println(list)  // [(1, a), (2, b), (7, c), (5, c), (6, d), (6, e)]

sort 와 마찬가지로 기존 Collection 의 변경 없이 정렬된 값을 받고 싶다면 sortedBy 를 사용하면 됩니다.

그리고 내림차순을 지원하는 sortByDescending 도 있습니다.


1.3. SortWith

sortWith 메소드를 사용하면 여러 가지 조건을 섞어서 정렬할 수 있습니다.

sortWith 메소드는 Comparator 를 파라미터로 받습니다.

(Kotlin 에서 Comparator 를 생성하는 여러가지 방법은 다음 챕터에서 다룹니다)

val list = mutableListOf(1 to "a", 2 to "b", 7 to "c", 6 to "d", 5 to "c", 6 to "e")
list.sortWith(compareBy({it.second}, {it.first}))
println(list)  // [(1, a), (2, b), (5, c), (7, c), (6, d), (6, e)]

위 Collection 은 it.second(문자) 로 먼저 정렬된 후에 it.first(숫자) 로 정렬됩니다.

그리고 역시 sortedWith 메소드가 존재하며, 역순으로 정렬할때는 reverse 를 사용하거나 Comparator 를 반대로 수정하면 됩니다.


2. Comparison

Kotlin 은 Comparator 를 만들기 위해 kotlin.comparisons 라는 유용한 패키지를 제공합니다.

이 챕터에서는 아래 컨텐츠를 다룹니다.

  • Comparator creation
  • Handling of null values
  • Comparator rules extension

2.1. Comparator Creation

Kotlin 은 Comparator 를 생성하는 여러 팩토리 메서드를 제공합니다.


2.1.1. naturalOrder

가장 간단한 생성 메서드는 naturalOrder() 입니다.

아무런 파라미터를 필요로 하지 않으며 오름차순을 기본으로 합니다.

val ascComparator = naturalOrder<Long>()

2.1.2. compareBy

여러 개의 속성을 사용하고 싶다면 compareBy 메소드를 사용하면 됩니다.

파라미터로는 Comparable 를 리턴하는 정렬 규칙을 여러 개 사용할 수 있습니다.

그럼 넘겨진 규칙들은 순차적으로 호출 되며 원소들을 정렬합니다.

만약 먼저 나온 규칙에서 원소의 우열이 가려져 정렬 처리가 되었다면 뒤의 규칙들은 확인하지 않습니다.

val complexComparator = compareBy<Pair<Int, String?>>({it.first}, {it.second})

위 코드에서 it.first 값을 사용해 먼저 비교를 하고 값이 같은 경우에만 it.second 비교까지 이루어집니다.


2.1.3. Comparator

간단하게 new Comparator 를 선언해서 만들 수도 있습니다.

자바와 마찬가지로 두 원소에 대한 비교 조건을 넣어줘야 합니다.

val caomparator = Comparator<Int> { a, b -> a.compareTo(b) }

2.2. Handling of null Values

정렬하려는 Collection 이 null 값을 갖고 있을 수도 있습니다.

nullsFirst 또는 nullsLast 와 함께 Comparator 를 사용하면 null 값을 가장 처음 또는 가장 마지막에 위치하도록 설정할 수 있습니다.

val list = mutableListOf(4, null, 1, -2, 3)

list.sortWith(nullsFirst())  // [null, -2, 1, 3, 4]

list.sortWith(nullsLast())  // [-2, 1, 3, 4, null]

list.sortWith(nullsFirst(reverseOrder()))  // [null, 4, 3, 1, -2]

list.sortWith(nullsLast(compareBy { it }))  // [-2, 1, 3, 4, null]

2.3. Comparator Rules Extension

Comparator 오브젝트는 추가적인 정렬 규칙과 혼합되거나 확장할 수 있습니다.

kotlin.comparable 패키지에 있는 then 키워드를 활용하면 됩니다.

첫 번째 비교의 결과가 동일할 때만 두번째 비교가 이루어집니다.

val students = mutableListOf(21 to "Helen", 21 to "Tom", 20 to "Jim")

val ageComparator = compareBy<Pair<Int, String?>> {it.first}
val ageAndNameComparator = ageComparator.thenByDescending {it.second}

// [(20, Jim), (21, Tom), (21, Helen)]
println(students.sortedWith(ageAndNameComparator))

위 코드는 나이가 어린 순으로 먼저 정렬하고 나이가 같으면 이름을 알파벳 역순으로 정렬합니다.


Reference

'Language > Kotlin' 카테고리의 다른 글

Kotlin Collections 와 Sequences 의 차이점 (feat. Java Stream)  (1) 2022.01.27
Kotlin Enum  (0) 2021.10.06
[Kotlin] Swap  (0) 2020.11.11
[Kotlin] For 문  (0) 2020.11.11
[Kotlin] String - drop, dropLast, dropWhile, dropLastWhile  (0) 2020.09.25

Spring 으로 개발하다보면 유닛 테스트 작성은 필수입니다.

많은 사람들이 거쳐가는 프로젝트는 테스트 코드의 크기도 어마어마합니다.

보통 테스트 코드를 작성할 땐 함수명으로 어떤 테스트 인지 명시하는게 관례입니다.

하지만 복잡한 비즈니스 로직을 테스트 하는데 함수명에는 이 정보를 전부 담을 수가 없습니다.

주석을 추가해서 설명을 달아 놓아도 역시 깔끔하지 않습니다.

어떻게 하면 테스트 코드의 가독성을 높일 수 있을까요?


기존의 Test Code

public class DisplayNameTest {

    @Test
    public void testAsuccess() { /* */ }

    @Test
    public void testAfail() { /* */ }

    @Test
    public void test1success() { /* */ }

    @Test
    public void test1success() { /* */ }

    @Test
    public void test2success() { /* */ }

    @Test
    public void test2fail() { /* */}
}

위의 테스트 코드는 일반적으로 우리가 작성하는 JUnit 테스트 코드입니다.

함수명이 굉장히 짧고 코드 부분을 생략해서 간단해보이지만 실제 업무에서 사용되는 테스트 코드는 이렇게 간단하지 않습니다.


@Nested

코드를 보면 아시겠지만 관심사가 비슷한 메소드가 몇개 보입니다.

똑같은 기능인데 성공 / 실패 여부만 나눠져 있는 메소드죠

같은 기능이니까 한 메소드에 넣어서 성공 / 실패 여부 두가지를 테스트 하면 어떨까? 하는 생각도 들지만 테스트 코드 하나가 너무 비대해집니다.

그리고 여기선 간단하게 성공 / 실패 여부로만 표현했지만 실제 코드에서는 상황이나 조건에 따른 여러 종류의 Exception 을 던져야 할 수도 있습니다.


이런 경우에 @Nested 클래스로 비슷한 함수를 묶어주면 훨씬 알아보기 쉽습니다.

Junit 5 User Guide - Nested Test 를 보면 좋은 예제를 제공해줍니다.

처음 코드를 @Nested 클래스를 사용해서 수정해보겠습니다.

public class DisplayNameTest {

    @Nested
    class testA {

        @Test
        public void success() { /* */ }

        @Test
        public void fail() { /* */ }
    }

    @Nested
    class testNumber {

        @Nested
        class test1 {

            @Test
            public void success() { /* */ }

            @Test
            public void fail() { /* */ }
        }

        @Nested
        class test2 {

            @Test
            public void success() { /* */ }

            @Test
            public void fail() { /* */ }

        }
    }
}

전체적인 코드의 양은 늘어났지만 계층적인 구조가 되어 훨씬 알아보기 편해졌습니다.

게다가 클래스로 구분되어 있으니 successfail 을 중복으로 사용해도 전혀 문제가 없습니다.

실제로 테스트를 돌리면 장점이 더 명확하게 나타납니다.


테스트 결과에서도 비슷한 테스트끼리 묶고 결과를 좀더 심플하게 표현할 수 있습니다.


@DisplayName

@Nested 클래스로 계층을 나누어도 여전히 함수명은 알아보기 어렵습니다.

우리가 영어권이 아니라서 그런것 같습니다..그런데 실제로 영어권이더라도 camelCase 또는 snake_case 로 이루어진 영어가 한눈에 읽히는건 쉬운 일이 아닙니다.

JUnit 5 User Guide - Display Names 에 나와있는 예제를 보면 함수명이 길어지니 한눈에 들어오지 않지만 @DisplayName 어노테이션을 사용하면 간단하게 표현할 수 있습니다.

@DisplayName@Nested 클래스와 함께 쓰면 더 빛을 발합니다.

위의 코드를 한번 더 수정해보겠습니다.

public class DisplayNameTest {

    @Nested
    @DisplayName("A 테스트")
    class testA {

        @Test
        @DisplayName("성공")
        public void success() { /* */ }

        @Test
        @DisplayName("실패")
        public void fail() { /* */ }
    }

    @Nested
    @DisplayName("숫자")
    class testNumber {

        @Nested
        @DisplayName("1 테스트")
        class test1 {

            @Test
            @DisplayName("성공")
            public void success() { /* */ }

            @Test
            @DisplayName("실패")
            public void fail() { /* */ }
        }

        @Nested
        @DisplayName("2 테스트")
        class test2 {

            @Test
            @DisplayName("성공")
            public void success() { /* */ }

            @Test
            @DisplayName("실패")
            public void fail() { /* */ }

        }
    }
}

어노테이션 때문에 조금 지저분해 보이지만 실제 테스트를 돌리면 결과가 이쁘게 나옵니다.


여기서 한가지 의문이 생길 수 있습니다.

굳이 @DisplayName 어노테이션을 사용해서 코드량을 늘리는 것보다 메소드 명을 한글로 작성하는게 낫지 않을까?

실제로 현업에서도 테스트 코드 작성 시 메소드 이름을 한글로 작성하는 케이스가 많다는 이야기를 종종 들었습니다.

저도 처음엔 고민을 했었는데 다음과 같은 이유들로 @DisplayName 을 쓰기로 결정했습니다.

  • 한글로 작성해도 언더바를 작성해야 해서 가독성이 좋지 않음
  • @Nested 와 함께 쓰려면 클래스를 작성해야 하는데 한글명으로 만드는 것보단 @DisplayName 을 쓰는게 깔끔함
  • 드문 일이지만 외국인과 협업해야하는데 테스트코드명이 전부 한글로 되어 있으면 당황하겠죠? (이러면 DisplayName 도 한글로 못적을 것 같지만..)
  • 가장 주목해야 할 점은 JUnit 개발자들은 영어가 모국어 수준일텐데도 @DisplayName 어노테이션을 추가했다는 점

테스트 결과에서 한글이 제대로 나오지 않는다면?

  1. Preferences > Build, Execution, Deployment > Build Tools > Gradle 로 이동
  2. Run tests usingIntelliJ IDEA 로 변경
  3. Apply and OK 후 적용 안되면 인텔리제이 재시작

'Framework > Spring' 카테고리의 다른 글

Spring Exception Handling  (7) 2021.03.14
Spring Security 와 JWT 겉핥기  (79) 2021.03.07
[Spring] @Before @BeforeClass @BeforeEach @BeforeAll  (0) 2020.11.24
[Spring] Property 값 주입  (1) 2020.11.09
[Spring] IoC, DI  (1) 2020.11.09

Problem


LRU Cache 를 구현하는 문제입니다.

Least Recently Used (LRU) 란 새로운 데이터를 삽입할 때 사용 빈도수가 낮은 데이터부터 삭제하는 캐싱 기법입니다.



Solution

원래 정석대로 구현하자면 Key - Value 를 갖는 Node 클래스를 만들어서 연결 리스트로 구현하면 되지만 Java 에는 LinkedHashMap 이라는 자료구조가 있습니다.

내부적으로 MapLinkedList 를 사용해서 데이터를 넣은 순서를 지켜줍니다.


그리고 이 LinkedHashMap 은 생성자에서 accessOrder 라는 값을 받습니다.

이 값을 true 로 넘겨주면 접근 빈도에 따라서 순서가 바뀌게 됩니다.


예를 들어 아래 코드에서 a, b 순서로 키값을 넣어서 { a=1, b=2 } 순서로 들어있지만, a 에 접근했더니 순서가 바뀝니다.

이처럼 가장 최근에 접근한 값이 연결 리스트의 마지막으로 이동하게 됩니다.

따라서 가장 사용되지 않은 값은 맨 첫번째에 남게 됩니다.

Map<String, String> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("a", "1");
map.put("b", "2"); // {a=1, b=2}
map.get("a");      // {b=2, a=1}
map.forEach((k, v) -> System.out.print(k + ": " + v + ", ")); // b: 2, a: 1

두번째 코드는 LeetCode 에서만 될 것 같은 방법 LinkedHashMap 자체를 상속받아서 구현했습니다.

removeEldestEntry 는 가장 오래된 Entry 를 삭제하기 때문에 좀더 편하게 구현할 수 있습니다.



Java Code

1) LinkedHashMap 을 클래스 내부에 선언해서 구현

class LRUCache {
    private final Map<Integer, Integer> map;
    private final int capacity;

    public LRUCache(int capacity) {
        map = new LinkedHashMap<>(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return map.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        map.put(key, value);

        if (map.size() > capacity) {
            int leastUsedKey = map.keySet().iterator().next();
            map.remove(leastUsedKey);
        }
    }
}

2) LinkedHashMap 을 상속받아서 구현

class LRUCache extends LinkedHashMap<Integer, Integer> {
    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return super.getOrDefault(key, -1);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > capacity;
    }
}

Google Foobar Challenge 후기

Foobar Challenge 는 구글이 숨겨놓은 히든 코딩테스트 입니다.

일반적인 방법으로는 접근할 수 없고 Chrome 검색을 하다보면 화면이 깨지면서 초대 요청이 오는 걸로 알고 있습니다.

저는 우연찮게 좋은 기회가 생겨서 접해볼 수 있었습니다.

사용 가능 언어는 Java 8 과 Python 2.7.13 입니다.

Java 는 그렇다 쳐도 Python 버전이 너무 낮은 것으로 보아 레거시 시스템인 것으로 보이는데..

다른 사람들의 후기를 찾아봤을 때 연락이 오지 않는 경우도 있었다는 걸로 보아 챌린지를 완료 한다고 인터뷰 기회가 주어지는 것은 아닌 것 같습니다.


Problems

Foobar 챌린지에 등장하는 모든 문제는 구글링 하면 찾을 수 있습니다.

보통 코딩 테스트 문제는 기업에서 공개하지 않는 이상 오픈하면 안되는 걸로 알고 있는데..

오픈 마인드인건진 몰라도 문제 전문과 본인 코드까지 올려놓은 사람들이 있더라구요.

문제가 궁금하신 분들은 구글링을 통해 찾으시면 될 것 같아서 간단하게 접근법만 포스팅 하려고 합니다.

문제는 Level 1 부터 시작하며 Level 5 까지 존재합니다.

Level 1: 1 문제
Level 2: 2 문제
Level 3: 3 문제
Level 4: 2 문제
Level 5: 1 문제

Level 1 : Re-ID

소수 (Prime) 를 이어 붙여 만든 "2357111317192329..." 문자열이 존재합니다.

시작 인덱스가 주어지면 해당 인덱스부터 시작하는 5 개의 문자열을 구해야 합니다.

에라토스테네스의 체로 소수를 모두 구해서 이어 붙인 문자열을 만들고 substring 으로 답을 구했습니다.


Level 2 - 1 : En Route Salute

사람들의 이동 방향이 정해지고 만날 때마다 인사를 한다고 할 때 총 몇번의 인사가 이루어지는 지 구하는 문제입니다.

한쪽 방향 사람들만 그룹으로 묶어서 총 몇 번의 인사를 하는지 구한 뒤 2 배 하면 됩니다.


Level 2 - 2 : Power Hungry

주어진 배열의 원소들의 곱 중 가장 큰 값을 구하는 문제입니다.

숫자 크기 때문에 BigInteger 로 풀기 귀찮아서 파이썬으로 풀었습니다.

음수, 양수값을 따로 저장해두고 음수 값이 홀수개면 정렬 후에 마지막 값, 즉 절대값이 가장 작은 값을 제거합니다.

그리고 남은 값들을 전부 곱하면 됩니다.

주의할 점은 두 가지 인데 배열의 길이가 1 이고 홀수인 경우가 존재하기 때문에 예외처리가 필요합니다.

그리고 리턴할 때 문자열로 바꿔서 리턴하는 걸 주의해야 합니다.


Level 3 - 1 : Find the Access Codes

주어진 배열에서 특정 조건을 이루는 3 개 숫자 그룹에 대한 모든 경우의 수를 구하는 문제입니다.

숫자 3 개의 성립 조건을 예로 들면 [a, b, c] 라고 할 때 cb 로 나누어 떨어지고 ba 로 나누어 떨어져야 합니다.

크기가 2000 밖에 되지 않아서 O(n^2) 으로 구했습니다.

배열을 한번 돌면서 한 원소에 대하여 같은 배열에 있는 약수들의 갯수를 구하고 다시 돌면서 특정 원소의 약수의 약수 갯수를 더해주면 됩니다.


Level 3 - 2 : Bomb, Baby!

M, F 폭탄이 각각 1 개씩 있을 때 주어진 Input 만큼의 숫자로 복제하기 위해선 몇 번의 세대가 지나야 하는지 구하는 문제입니다.

한 세대가 지날때마다 M → F 또는 F → M 만큼의 폭탄을 복제할 수 있습니다.

(M, F) 의 다음 세대는 (M + F, F) 또는 (M, M + F) 가 됩니다.

숫자 크기가 10^50 이라서 파이썬으로 풀었습니다.

주어진 input 값부터 시작해서 최종적으로 (1, 1) 이 될 수 있는지를 확인합니다.

단순하게 빼기를 사용하면 시간초과가 나기 때문에 나누기와 나머지 연산을 사용해서 최적화 하면 됩니다.


Level 3 - 3 : Prepare the Bunnies' Escape

벽이 존재하는 2 차원 배열에서 오른쪽 아래에서 왼쪽 위로 이동하는 최단 거리를 구하는 문제입니다.

필요 시 벽을 한번 부술 수 있습니다.

백준의 벽부수고 이동하기 문제와 완전 동일합니다.

BFS 로 풀었습니다.


Level 4 - 1 : Running with Bunnies

영어와 한국어 능력 부족으로 해석에 굉장히 애를 먹은 문제입니다

음수 가중치가 존재하는 그래프에서 제한 시간 내에 시작 노드에서 최대한 많은 다른 노드를 방문하고 도착지에 가야 합니다.

처음 접근한 방법은 Bellman-Ford 알고리즘으로 음수 가중치를 찾고 DFS 를 사용했습니다.

여기서 한가지 간과한 점이 있는데 음수 사이클만 찾으면 된다고 생각했는데 모든 점에서 나머지 점까지의 최단 거리 또한 구해야 합니다.

그래서 플로이드 와샬로 최단 거리를 모두 구한 다음 음수 사이클 여부를 체크하고 DFS 로 답을 구했습니다.


Level 4 - 2 : Free the Bunny Prisoners

문제를 설명하긴 좀 어려운데 Combination 을 사용해서 푸는 문제입니다.


Level 5 : Dodge the Lasers!

문자열 n 이 주어지면 floor(1 * sqrt(2)) + .. + floor(n * sqrt(2)) 를 구하는 문제입니다.

n1 부터 10^100 까지의 범위입니다.

단순히 sqrt 와 루프를 사용하면 시간초과가 납니다.

접근법을 몰라서 인터넷에 있는 풀이를 참고했습니다.

그런데 봐도 무슨 얘긴지 잘 모르겠네요.


챌린지를 마치고

Level 5 까지 제출하고 나면 모든 챌린지가 끝나고 토끼가 뛰어다니는 화면이 나옵니다. (캡쳐는 못했네요)

회사 일도 겸하고 있어서 문제 요청만 해두고 널널하게 생각나면 풀고 그랬었는데 Level 4 까지는 풀만한 문제들이 나온 것 같습니다.

지인들 중에는 Level 3 부터 완전 어려운 문제들이 나온 케이스도 있는 걸로 보아 문제 푸는 시간도 고려해서 난이도가 결정되는 것 같기도 합니다. (순전히 개인적인 추측입니다)

Level 3 문제를 전부 풀면 연락처와 본인 정보를 입력할 수 있는데 서론에서도 언급했듯이 실제로 연락받는 케이스는 드물다고 합니다.

재미있는 경험이었습니다.

Problem

 

문제에서 요구하는 사항에 맞게 RandomizedSet 을 구현하는 문제입니다.

 

insert(val), remove(val), getRandom() 함수를 구현해야 하는데 모두 O(1) 의 시간복잡도를 가져야 합니다.



Solution

ArrayList<Integer>HashMap<Integer, Integer>을 이용하여 만들 수 있습니다.

 

처음에는 HashSet<Integer>을 사용하려고 했는데 getRandom() 함수에서 값을 꺼낼 때 반복문(iterator)을 사용하기 때문에 O(n) 의 시간이 걸렸습니다.

 

그래서 index-value 를 key-value 로 하고 value-index 를 key-value 로 하는 HashMap 두개를 이용해서 풀었습니다.

 

그런데 index-value 가 key-value 인 HashMap 은 결국 List 와 다를게 없어서 List 로 수정하여 최종 구현하였습니다.

 

insert(val)getRandom() 은 별다른 설명이 필요 없을 것 같고 결국 remove(val) 을 어떻게 O(1) 시간에 하느냐가 관건입니다.

 

가장 고민했던 부분은 중간에 있는 val 을 삭제할 경우 List 의 index 에 구멍이 생겨서 랜덤값이 제대로 뽑히지 않는다는 점이었습니다.

 

그래서 중간에 있는 값을 삭제하는 대신에 List 의 가장 마지막 값으로 삭제하는 위치를 채우는 방법을 사용했습니다.

 

다음 그림과 같이 List 값이 들어있다고 가정합니다.

 

 

 

여기서 remove(9) 를 호출하면 두번째 index 값이 지워져야 합니다.

 

하지만 index 값을 지우는 대신에 마지막에 있는 14 값으로 채워넣고 마지막 index 를 삭제 해버리면 list 내의 index 는 순차적으로 모두 존재함이 보장됩니다.

 



Java Code

class RandomizedSet {
    Map<Integer, Integer> map;
    List<Integer> list;

    public RandomizedSet() {
        map = new HashMap<>();
        list = new ArrayList<>();
    }

    public boolean insert(int val) {
        if (map.containsKey(val)) {
            return false;
        }

        map.put(val, list.size());
        list.add(val);
        return true;
    }

    public boolean remove(int val) {
        if (!map.containsKey(val)) {
            return false;
        }

        int lastIndex = list.size() - 1;
        int lastValue = list.get(lastIndex);
        int deletedIndex = map.get(val);

        list.set(deletedIndex, lastValue);
        map.put(lastValue, deletedIndex);

        list.remove(lastIndex);
        map.remove(val);
        return true;
    }

    public int getRandom() {
        int rand = (int) (Math.random() * list.size());
        return list.get(rand);
    }
}

+ Recent posts