이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.

 

1. ATDD란?

 TDD(Test Driven Development)는 많이 알려져 있는 개발 방법론이고, 나 또한 우테캠을 듣기 이전에 이에 대해 어느 정도는 알고 있었다. 하지만 ATDD라는 건 처음 들어봤다. ATDD란 'Acceptance Test Driven Development'의 약자로, 한국말로 하면 '인수 테스트 주도 개발'이다. ATDD는 TDD와 밀접한 관계가 있지만 차이가 있다. ATDD는 고객, 개발자, 테스터 간의 커뮤니케이션을 기반으로 하는 개발 방법론으로써, 고객-개발자-테스터 간의 협업을 강조한다는 점에서 TDD와는 다르다. TDD와 비슷하게, 개발자는 코딩을 시작하기 전 인수 테스트를 먼저 작성해야 한다. 그렇다면 인수테스트란 무엇일까?

 인수테스트는 고객이 제품을 인수하기 전, 명세나 계약의 요구사항이 모두 충족되었는지 확인하기 위해 수행되는 테스트이다. 비단 소프트웨어뿐만 아니라 다른 분야에서도 사용되는 용어다. 보통 마지막 단계에서 수행되는 테스트를 의미하며, 이 테스트가 성공한다면 작업이 끝났다는 것을 의미하기도 한다.

 그렇다면 통합 테스트나 E2E 테스트가 인수 테스트라고 생각할 수도 있겠지만 꼭 그렇진 않다. 이는 테스트의 의도나 구현 방법에 의해 얼마든지 달라질 수 있다.

 

2. ATDD의 장점

 1) 개발을 시작하기 전, 요구사항을 명확히 함으로써 고객, 개발자, 테스터 간의 빠른 피드백을 받을 수 있다.

 2) 개발을 시작하기 전, 인수 테스트 코드를 먼저 작성함으로써 구현할 대상에 대한 이해도를 높힐 수 있다.

 3) 작업의 시작과 끝이 명확해진다. 인수 테스트가 통과한다면? -> 기능 개발이 끝난 것이다.

 4) 리팩토링을 부담없이 할 수 있다. 많은 레거시 코드에서의 비즈니스 로직에는, 테스트하기 쉬운 부분과 어려운 부분이 뒤섞여있다. 따라서 레거시 코드를 리팩토링할 때 많은 어려움이 있다. 이때 인수 테스트 코드를 작성해놓는다면, 인수 테스트 코드의 보호 아래 기존 기능을 망가뜨리지 않고 리팩토링과 기능 추가를 자유롭게 할 수 있다.

 

3. 인수 테스트 규칙

인수 테스트 코드를 작성할 때는 아래의 규칙들을 준수해야 한다.

 1) 코딩을 시작하기 전에 인수 테스트를 먼저 작성한다. 이후 TDD 사이클을 통해 개발을 진행한다.

ATDD + TDD

 2) 인수 테스트 코드는, 내부 구현이나 기술에 의존적이지 않는 블랙 박스 테스트로 작성한다. 쉽게 말하자면, 인수 테스트 코드 import 문에 프로덕션 코드가 없으면 된다.

 

4. 인수 테스트 구현

 위에서 말했던 것 처럼, 인수 테스트의 방식은 상황이나 의도에 따라 여러 가지 형태를 가질 수 있다. 하지만 여기서는 고객은 프론트엔드 개발자 혹은 API 사용자로 설정하고, API 접점에서 테스트하는 E2E 테스트를 인수 테스트로 작성하였다. 테스트를 위한 라이브러리로는 RestAssured를 사용하였다. 

 먼저 모든 인수 테스트 코드에 사용할 공통 클래스인 AcceptanceTest 를 생성한다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class AcceptanceTest {
    @LocalServerPort
    int port;

    @Autowired
    private DatabaseCleanup databaseCleanup;

    @BeforeEach
    public void setUp() throws Exception {
        RestAssured.port = port;
        databaseCleanup.execute();
    }
}

@Service
@ActiveProfiles("test")
public class DatabaseCleanup {
    private final NamedParameterJdbcTemplate jdbcTemplate;

    public DatabaseCleanup(final DataSource dataSource) {
        this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
    }

    @Transactional
    public void execute() {
        final List<String> tableNames = extractTableNames();
        execute("SET REFERENTIAL_INTEGRITY FALSE");

        for (final String tableName : tableNames) {
            execute("TRUNCATE TABLE " + tableName);
        }

        execute("SET REFERENTIAL_INTEGRITY TRUE");
    }

    private List<String> extractTableNames() {
        return jdbcTemplate.query("SHOW TABLES", (resultSet, rowNumber) -> resultSet.getString(1));
    }

    private void execute(final String query) {
        jdbcTemplate.update(query, Collections.emptyMap());
    }
}

 

 SpringBootTest는 ApplicationContext를 전부 올리는 테스트인데, 이는 꽤나 큰 부하이기 때문에 여러 개의 SpringBootTest가 있을 경우 ApplicationContext를 공유하게 된다. h2 데이터베이스도 마찬가지로 공유하게 되는데, 이렇게 되면 한 테스트가 다른 테스트에 영향을 줄 수 있다. 이를 방지하기 위해 DatabaseCleanup 클래스는, 모든 테스트가 시작하기 전, 데이터베이스의 모든 데이터를 삭제해주는 역할을 한다.

 Transactional 어노테이션 붙혀주면 롤백되는 거 아닌가라고 생각할 수 있다. 나도 처음엔 그렇게 생각했다. 하지만 그렇지가 않더라... RestAssured는 테스트 시, 어플리케이션을 톰캣 위에 올려서 테스트 한다. 즉 실제와 똑같은 환경에서 테스트를 진행하기 때문에 테스트 코드에서 시작한 Transaction을 전파(Propagation)할 수가 없다.

 

 인수 테스트를 적용해볼 기능 목록은 아래와 같다.

* 로또
  * [ ] 로또를 구매한다.
    * [ ] 입력 값으로 로또 구입 금액, 수동으로 입력된 로또의 리스트를 받는다.
    * [ ] 구입 금액이 1000미만이거나 1000으로 나누어 떨어지지 않거나 숫자가 아닐 경우, 예외를 발생시킨다. (로또 1장 가격은 1000원)
    * [ ] 수동으로 입력된 로또의 수가, 앞서 입력한 구입금액으로 살 수 있는 로또 수의 최댓값을 초과할 경우, 예외를 발생시킨다.
    * [ ] 수동으로 입력된 로또들의 번호는 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있어야 한다. 그렇지 않을 경우 예외를 발생시킨다.
    * [ ] 구입 금액에서 수동으로 입력된 로또의 수를 제외한 나머지에 해당하는 수만큼 자동으로 로또가 생성되어 구매된다.
    * [ ] 토요일 오후 8시 ~ 9시에는 구매가 불가능하다.
    * [ ] 회차별 구매 내역과 구매한 로또들이 DB에 저장된다.
    * [ ] 로또의 번호들은 ','를 구분자로 하는 오름차순으로 정렬된 문자열로 저장된다.
  * [ ] 로또의 회차는 매주 토요일 오후 9시 자동으로 증가한다.
  * [ ] 관리자는 회차별로 당첨번호를 입력할 수 있다.
    * [ ] 당첨 번호는 1~45까지의 중복되지 않는 숫자 6개와 보너스 번호로 이루어져 있어야 한다. 그렇지 않을 경우 예외를 발생시킨다.
    * [ ] 해당 회차의 당첨번호가 이미 입력되었을 경우 예외를 발생시킨다.
  * [ ] 로또 당첨 결과를 조회한다.
    * [ ] 결과에는 미추첨, 낙첨, 당첨번호 3개 일치, 당첨번호 4개 일치, 당첨번호 5개 일치, 당첨번호 5개 일치 & 보너스번호 일치, 당첨번호 6개 일치가 있다.
    * [ ] 미추첨이란, 현재 시간이 아직 해당 회차의 당첨결과 조회 가능 시간(매주 토요일 오후 9시)이 되지 않았거나 아직 당첨번호가 입력되지 않은 상태이다.

 

이에 따른 구현 결과는 아래와 같다.

class LottoAcceptanceTest extends AcceptanceTest {
    private String accessToken;

    @Override
    @BeforeEach
    public void setUp() throws Exception {
        super.setUp();

        // given
        final String loginId = "test1234";
        final String password = "password1234";

        final var user = new HashMap<String, Object>();
        user.put("loginId", loginId);
        user.put("password", password);
        user.put("nickName", "테스트계정");
        user.put("email", "test@test.com");

        사용자_생성_요청(user);

        accessToken = 로그인_요청(loginId, password).jsonPath().get("accessToken");
    }

    @Test
    void 로또() {
        // given
        final var lotto = new HashMap<String, Object>();
        lotto.put("payment", 10000);
        lotto.put("manualLottos", List.of(List.of(1, 2, 3, 4, 5, 6), List.of(45, 44, 43, 42, 41, 40)));

        // when
        final var buyResponse = 로또_구매_요청(lotto, accessToken);

        // then
        로또_구입됨(buyResponse);

        // given
        final var winningResult = new HashMap<String, Object>();
        winningResult.put("winningNumbers", List.of(1, 2, 3, 4, 5, 6));
        winningResult.put("bonusNumber", 7);

        // when
        final var postWinningResultResponse = 관리자가_당첨번호_입력_요청(winningResult, ADMIN_TOKEN);

        // then
        당첨번호_입력됨(postWinningResultResponse);

        // when
        final var winningResultResponse = 당첨결과_조회_요청(accessToken);

        // then
        당첨결과_조회됨(winningResultResponse);
    }
}

public interface LottoAcceptanceTestFixture {
    static ExtractableResponse<Response> 로또_구매_요청(final Map<String, Object> lotto, final String accessToken) {
        return RequestUtil.postWithAccessToken("/lottos", accessToken, lotto);
    }

    static void 로또_구입됨(final ExtractableResponse<Response> response) {
        assertHttpStatusCode(response, HttpStatus.SC_CREATED);
        assertThat(response.header("Location")).isNotNull();
    }

    static ExtractableResponse<Response> 관리자가_당첨번호_입력_요청(
        final Map<String, Object> winningResult,
        final String accessToken
    ) {
        return RequestUtil.postWithAccessToken("/winning-results", accessToken, winningResult);
    }

    static void 당첨번호_입력됨(final ExtractableResponse<Response> response) {
        assertHttpStatusCode(response, HttpStatus.SC_CREATED);
        assertThat(response.header("Location")).isNotNull();
    }

    static ExtractableResponse<Response> 당첨결과_조회_요청(final String accessToken) {
        return RequestUtil.getWithAccessToken("/lottos/me", accessToken);
    }

    static void 당첨결과_조회됨(final ExtractableResponse<Response> response) {
        assertHttpStatusCode(response, HttpStatus.SC_OK);
    }
}

코드를 보고 여러 의문점들이 생길 수 있다. 하나씩 정리해보자.

1) 하나의 테스트 코드가 여러개를 검증한다.

 원래는 하나의 테스트 코드는 하나만 검증하는 게 원칙이다. 하지만 그렇게 되면 중복 코드가 많이 생기게 될텐데, SpringBootTest는 그렇게 구성하기엔 부하가 많이 걸리는 테스트이다. 따라서 사용자의 사용 시나리오를 기반으로 Happy Case에 대해서만 인수 테스트를 작성한다.

2) 기능 목록에는 예외 상황들이 많은데, 이에 대한 테스트 코드가 하나도 없다.

 위와 같은 이유이다. 예외 상황에 대한 테스트까지 추가한다면 시간이 너무 오래 걸릴 것이다. 따라서 인수 테스트에서는 Happy Case에 대해서만 작성하고, 예외 상황에 대해서는 단위 테스트에서 검증하도록 한다.

3) 메서드가 모두 한글로 작성되어있다.

 물론 테스트 코드에선 한글로 많이 작성하기도 한다. 인수 테스트에선 더욱 이렇게 하는 것이 좋은데, 그 이유는 인수 테스트는 오직 개발자만을 위한 것이 아니라 고객, 개발자, 테스터 간의 커뮤니케이션을 위한 것이기도 하기 때문이다.

 이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.

 

1. JWT란?

 JWT(Json Web Token)은 사용자 인증에 필요한 정보를 Json 포맷으로 구성한 토큰이다. 이때 이 값은 암호화되어 있는데, 개인키를 보유한 서버는 이를 통해 토큰의 유효성을 검증할 수 있다. 

 JWT는 각 구성요소가 점(.)으로 구분되어 있으며 Header, Payload, Signature로 구성된다.

 1) Header : 토큰의 타입이나, 서명에 어떠한 암호화 알고리즘이 사용되었는지가 저장된다. 위 그림에서는 HS512 알고리즘이 사용되었다.

 2) Payload : 인증에 필요한 사용자에 대한 실질적인 정보가 저장되는 곳이다. 여기에서 주의사항은 Payload에는 민감한 정보를 담지 않는 것이다. header와 payload의 경우 Base64로 인코딩되어있을 뿐, 암호화되어있는 것이 아니기 때문에, 탈취되었을 경우 누구나 값을 확인할 수 있다. key-value 형태로 저장되며 표준 스펙상 key는 3글자로 되어있다. Payload에 담기는 일반적인 key값들은 아래와 같다.

 - iss(issuer): 토큰 발급자

 - sub(subject): 토큰 제목. 사용자에 대한 식별자가 된다.

 - aud(audience): 토큰 대상자

 - exp(expiration time): 토큰 만료 시간

 - nbf(not before): 토큰 활성화 시간(이 날짜 이전의 토큰은 활성화되지 않음을 보장)

 - iat(issued at): 토큰 발급 시간

 - jti(JWT id): JWT 토큰 식별자(issuer가 여러 명일 때, 이를 구분하기 위한 값)

 3) Signature : 암호화된 서명 값. 토큰의 유효성을 검증하기 위해 사용된다. 구조는 아래 그림에서 확인할 수 있듯이, header와 payload를 합친 값을 개인키로 암호화한 상태이다.

 

2. JWT의 장점과 단점

장점

 1) 세션과 같이 별도의 저장소가 필요하지 않다. 

 2) 별도의 저장소가 없기 때문에 Stateless하고 확장성이 뛰어나다.

 3) 세션 인증과 비교했을 때 성능면에서도 유리하다. (세션의 경우 매번 저장소에 접근하여 사용자 정보를 확인해야함)

 4) 서명을 통한 보안성을 갖춘다.

단점

 1) 사용자 인증에 필요한 정보를 base64 인코딩을 통해 전달하므로, 세션과 비교했을 때 데이터 전달량이 많다(세션의 경우 Session ID만 전달하면 됨). 네트워크 부하가 발생할 수 있다. -> 사용자 인증에 필요한 정보만 포함시키도록 함

 2) Payload는 암호화되지 않기 때문에, 민감한 정보를 저장할 수 없다. -> 민감한 정보를 저장하지 않도록 주의

 3) 토큰이 탈취당하면 만료될 때까지 대체가 불가능하다. -> 액세스 토큰의 만료 시간을 짧게 가져가고, 대신 사용성을 위해 별도의 리프레쉬 토큰을 만들고 이는 별도의 저장소에 저장한다.

 

3. 디펜던시 설정

 인증 기능을 구현하기 위해 아래와 같이 build.gradle 파일 내의 dependencies를 추가해준다.

JWT 관련 라이브러리를 어떤 걸 써야할지 많은 고민을 했었다. JWT관련 구글링을 하면 대부분 아래의 의존성을 추가하여 구현을 하였다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'

헌데 이걸 그냥 쓰기엔 뭔가 마음에 들지 않는 부분들이 많았다.

 

1) Spring boot의 dependency management를 활용한 버전 관리가 불가능하다.

  Spring boot는 각 버전별로 호환성이 검증된 라이브러리들의 버전들을 아래와 같이 제공해준다. 따라서 gradle의 build.gradle이나 maven의 pom.xml에 의존성 추가 시, Spring boot에서 호환성을 검증한 라이브러리라면 버전을 명시할 필요가 없다. 이는 향후 전체 프로젝트의 버전을 업그레이드하고자할 때 Spring boot의 버전만 변경하면 나머지는 알아서 변경된다는 것을 의미한다. 헌데 위에 라이브러리는 Spring에서 버전을 제공하지 않는, 딱히 대중적이지 않은(?) 라이브러리로 보였다.(그렇다고 하기엔 구글링하면 전부 저것만 나옴...)

2) 버전이 0으로 시작한다. 정식 릴리즈가 아니란 뜻일텐데... 가장 최근 업데이트도 2018년인 것으로 보아 더 이상의 추가적인 지원은 없을 것 같았다.

 

 그렇다면 대체 어떤 라이브러리를 써야한단 말인가...? 이전에 MSA를 공부할 때 Spring cloud에서 JWT를 썼던 기억이 있었다. 그래서 스프링에서 제공하는 라이브러리를 검색하기 시작했고 org.springframework.security:spring-security-jwt 를 찾았다!! 근데 어째서인지 Spring boot BOM에 버전이 등록되어있지 않았고, 버전을 명시해서 다운을 받아봤다.

죄다 Deprecated네... 뭘 쓰라는 거지-_-;

 Jwt 라이브러리의 가장 핵심이 되는 Jwt 인터페이스가 Deprecated 되어있었다. 구글링을 하다보니 스프링 공식 홈페이지에서 아래와 같은 내용을 찾을 수 있었다.

 어떤 이유에서인지는 알 수 없으나 Spring Security OAuth 프로젝트는 중단되었고, Spring Security는 이제 Authorization Server에 대한 서포트를 하지 않는다는 내용이다. JWT를 만드는 주체는 Authorization Server가 될테니 비슷한 이유로 Deprecated된 게 아닐까 추측해봤다. 그나저나 그럼 대체 뭘 쓰라는 것인가...?

 

 결국 처음으로 돌아와 io.jsonwebtoken:jjwt 를 쓰기로 했다. 나름 다른 프로젝트에서도 많이 쓰이고 있고, 다들 나와 같은 이유로 많은 고민을 하다가 결국 이 라이브러리를 쓰는 건가 싶었다.

나름 669곳에서 쓰고 있음. 페이스북도 쓰더라...

 io.jsonwebtoken:jjwt을 바로 쓰지 않고 굳이 3개의 디펜던시로 나눠서 의존성을 추가한 이유는, 얘네들은 마지막 릴리즈가 2020년 6월이다. 그렇다면 왜 io.jsonwebtoken:jjwt에는 반영하지 않았을까? 필요한 것만 가져다쓰라는 의도일까? 낸들 알겠는가...;ㅋ

 

4. 인증 구현

@EnableWebSecurity // (1)
@EnableGlobalMethodSecurity(prePostEnabled = true) // (2)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final AuthenticationExceptionHandler authenticationExceptionHandler;
    private final JwtFilter jwtFilter;

    public SecurityConfig(final AuthenticationExceptionHandler exceptionHandler, final JwtFilter jwtFilter) {
        this.authenticationExceptionHandler = exceptionHandler;
        this.jwtFilter = jwtFilter;
    }

    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
            .csrf()
            .disable() // (3)

            .headers()
            .frameOptions()
            .sameOrigin() // (4)

            .and()
            .cors() // (5)

            .and()
            .logout()
            .disable() // (6)
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // (7)

            .and()
            .authorizeRequests()
            .antMatchers(HttpMethod.POST, "/login").permitAll()
            .anyRequest().authenticated()

            .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) // (8)
            .exceptionHandling()
            .authenticationEntryPoint(authenticationExceptionHandler)
            .accessDeniedHandler(authenticationExceptionHandler);
    }

    @Override
    public void configure(WebSecurity web) {
        web
            .ignoring()
            .antMatchers("/h2-console/**", "/favicon.ico");
    }

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

(1) 스프링 시큐리티 설정을 활성화한다.

(2) 메서드 단위로 시큐리티 설정을 추가하고 싶을 때 사용한다.

(3) 백엔드 서버이므로 csrf 설정이 불필요하다.

(4) h2-console을 사용하기 위해 설정한다.

(5) 향후 corsfilter를 사용하기 위해 선언한다.

(6) 스프링 시큐리티에서 디폴트로 설정된 logout을 비활성화한다.

(7) 세션과 비교했을 때 JWT의 장점은 별도의 저장소를 사용하지 않는 Stateless(무상태성)하다는 것이다.

(8) 스프링 시큐리티에서 요청에 대한 인증 여부를 검사하는 필터(UsernamePasswordAuthenticationFilter) 이전에, 별도로 작성한 JwtFilter를 추가한다.

 

@Component
public class JwtFilter implements Filter {
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenParser tokenParser;

    public JwtFilter(final JwtTokenParser tokenParser) {
        this.tokenParser = tokenParser;
    }

    @Override
    public void doFilter(
        final ServletRequest request,
        final ServletResponse response,
        final FilterChain chain
    ) throws ServletException, IOException {
        final HttpServletRequest httpServletRequest = (HttpServletRequest)request;
        final String token = resolveAccessToken(httpServletRequest);

    // (1)
        if (tokenParser.validateAccessToken(token)) {
            final Authentication authentication = tokenParser.extractAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    // (2)
    private String resolveAccessToken(final HttpServletRequest request) {
        final String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (StringUtils.hasText(bearerToken) && bearerToken.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) {
            return bearerToken.substring(BEARER_TYPE.length()).trim();
        }
        return "";
    }
}

(1) Request로부터 전달된 토큰에 대한 유효성 검사를 진행한 후, 유효한 토큰일 경우 토큰에 있는 정보를 바탕으로 Authentication을 생성한다.

(2) Request의 Authorization 헤더 값을 가져와 거기에서 토큰 값을 추출한다.

 

@Component
public class JwtTokenParser {
    private final JwtParser jwtParser;

    public JwtTokenParser(@Value("${custom.jwt.secret-key}") final String secretKey) {
        final Key key = Keys.hmacShaKeyFor(secretKey.getBytes());
        this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
    }

    // (1)
    public Authentication extractAuthentication(final String accessToken) {
        try {
            final Claims claims = jwtParser.parseClaimsJws(accessToken).getBody();
            final Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(AUTHORITY_DELIMITER))
                    .map(Role::of)
                    .collect(Collectors.toList());
            final UserDetails principal = User.builder()
                .username(claims.getSubject())
                .password("N/A")
                .authorities(authorities)
                .build();

            return new UsernamePasswordAuthenticationToken(principal, accessToken, authorities);
        } catch (final JwtException | IllegalArgumentException | NullPointerException exception) {
            throw new BadCredentialsException(exception.getMessage());
        }
    }

    // (2)
    public boolean validateAccessToken(final String token) {
        return validateToken(token, false);
    }

    private boolean validateToken(final String token, final boolean isRefreshToken) {
        try {
            final Claims claims = jwtParser.parseClaimsJws(token).getBody();
            final boolean additionalCondition = (claims.getSubject() != null) ^ isRefreshToken;
            return !claims.getExpiration().before(new Date()) && additionalCondition;
        } catch (final JwtException | IllegalArgumentException exception) {
            return false;
        }
    }
}

(1) 토큰 값으로부터 사용자 정보를 추출하여 Authentication을 생성한다.

(2) 액세스 토큰의 유효성을 검증한다.

 

5. 로그인 구현

@Service
public class LoginService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final TokenService tokenService;

    public LoginService(
        final AuthenticationManagerBuilder authenticationManagerBuilder,
        final TokenService tokenService
    ) {
        this.authenticationManagerBuilder = authenticationManagerBuilder;
        this.tokenService = tokenService;
    }

    public TokenResponse login(final LoginRequest loginRequest) {
        // (1) 
        final AuthenticationManager authenticationManager = authenticationManagerBuilder.getObject();
        final Authentication authentication = authenticationManager.authenticate(loginRequest.toAuthentication());

        // (2)
        final TokenResponse tokenResponse = tokenService.createTokenResponse(authentication);

        return tokenResponse;
    }
}

@Getter
public class LoginRequest {
    @NotBlank(message = "아이디를 입력해주세요.")
    private String loginId;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    public Authentication toAuthentication() {
        return new UsernamePasswordAuthenticationToken(loginId, password);
    }
}

(1) 별도의 AuthenticationManager나 AuthenticationProvider를 정의하지 않는다면, 스프링에서 제공하는 디폴트 빈을 사용하게 된다. 디폴트 빈을 적절히 사용하기 위해선 UserDetailsService와 PasswordEncoder를 빈으로 생성해야한다. PasswordEncoder의 경우 아까 SecurityConfig에서 빈으로 생성하였다.

(2) 토큰을 생성하고 이를 반환한다.

 

@Service
@Transactional(readOnly = true)
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(final UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        return userRepository.findByLoginIdValue(username)
            .map(this::toUserDetails)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }

    private UserDetails toUserDetails(final User user) {
        return builder()
            .username(user.getId().toString())
            .password(user.getPassword())
            .authorities(user.getUserRoles())
            .disabled(user.isDeleted())
            .build();
    }
}

 

아래에 리프레쉬 토큰 관련 코드도 나오는데, 이에 대해서는 뒤에서 다룰 것이다.

@Service
public class TokenService {
    private final JwtTokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final long refreshTokenValidityInMilliseconds;

    public TokenService(
        final JwtTokenProvider tokenProvider,
        final RefreshTokenRepository refreshTokenRepository,
        @Value("${custom.jwt.refresh-token-validity-in-milliseconds}") final long refreshTokenValidityInMilliseconds
    ) {
        this.tokenProvider = tokenProvider;
        this.refreshTokenRepository = refreshTokenRepository;
        this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
    }

    public TokenResponse createTokenResponse(final Authentication authentication) {
        final String accessToken = tokenProvider.createAccessToken(authentication);
        final String refreshToken = tokenProvider.createRefreshToken();

        return new TokenResponse(accessToken, refreshToken);
    }
}
@Component
public class JwtTokenProvider {
    static final String AUTHORITIES_KEY = "auth";
    static final String AUTHORITY_DELIMITER = ",";

    private final Key secretKey;
    private final JwtProperty jwtProperty;

    @Autowired
    public JwtTokenProvider(final JwtProperty jwtProperty) {
        this.jwtProperty = jwtProperty;
        this.secretKey = Keys.hmacShaKeyFor(jwtProperty.getSecretKey().getBytes());
    }

    JwtTokenProvider(
        final String secretKey,
        final long accessTokenValidityInMilliseconds,
        final long refreshTokenValidityInMilliseconds
    ) {
        this(new JwtProperty(secretKey, accessTokenValidityInMilliseconds, refreshTokenValidityInMilliseconds));
    }

    public String createAccessToken(final Authentication authentication) {
        final Date now = new Date();
        final Date validity = new Date(now.getTime() + jwtProperty.getAccessTokenValidityInMilliseconds());

        return Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, toString(authentication.getAuthorities()))
            .signWith(secretKey, SignatureAlgorithm.HS512)
            .setIssuedAt(now)
            .setExpiration(validity)
            .compact();
    }

    public String createRefreshToken() {
        final Date now = new Date();
        final Date validity = new Date(now.getTime() + jwtProperty.getRefreshTokenValidityInMilliseconds());

        return Jwts.builder()
            .signWith(secretKey, SignatureAlgorithm.HS512)
            .setIssuedAt(now)
            .setExpiration(validity)
            .compact();
    }

    private static String toString(final Collection<? extends GrantedAuthority> grantedAuthorities) {
        return grantedAuthorities.stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(AUTHORITY_DELIMITER));
    }
}

 

6. 리프레쉬 토큰 구현

 토큰 방식 인증의 장점은 Stateless하다는 것이다. 이로 인해 별도의 저장소를 갖지 않아도 되고 확장에 용이하다. 하지만 이는 단점으로도 작용하는데, 토큰이 탈취되었을 경우 대응법이 마땅히 없다는 것이다(세션의 경우, 저장소에서 탈취당한 값을 삭제하거나 최악의 경우 초기화할 수 있다.). 

 이러한 단점을 보완하기 위해 리프레쉬 토큰을 사용한다. 액세스 토큰의 유효기간을 짧게 두고, 대신 리프레쉬 토큰의 유효기간을 길게 둔다. 액세스 토큰이 만료되었을 때 리프레쉬 토큰을 사용하여 액세스 토큰을 재발급 받는다.

 유효기간이 긴 리프레쉬 토큰이 탈취되었을 경우를 대비하여 리프레쉬 토큰은 별도의 저장소(이 프로젝트에선 Redis를 사용하였다)에 저장한다. 이는 Stateless라는 장점에 위배되는 것이지만, 보안을 위해서 나에게 이것말고 더 좋은 방법이 떠오르지 않았다. 그래도 세션을 사용할 땐 매 요청마다 저장소에 접근해야하지만, 토큰을 사용한다면 리프레쉬 토큰을 통해 액세스토큰을 재발급 받는 경우에만 접근하면 되므로 나름대로 토큰 인증의 장점을 취했다고 볼 수 있다.

 탈취되었을 경우를 대비한 또 하나의 장치는 'Refresh Token Rotation'이다. 액세스 토큰이 재발급될 때마다 리프레쉬 토큰을 재생성한다. 이후에 액세스 토큰 재발급을 위해 들어온 Request 내의 리프레쉬 토큰이 가장 최근에 만들어진 것이 아니라면, 리프레쉬 토큰이 탈취당했다고 보고 저장소에서 해당 사용자의 리프레쉬 토큰을 무효화한다.

 

@Service
public class AuthService {
    private final JwtTokenParser tokenParser;
    private final RefreshTokenValidator refreshTokenValidator;
    private final TokenService tokenService;

    public AuthService(
        final JwtTokenParser tokenParser,
        final RefreshTokenValidator refreshTokenValidator,
        final TokenService tokenService
    ) {
        this.tokenParser = tokenParser;
        this.refreshTokenValidator = refreshTokenValidator;
        this.tokenService = tokenService;
    }

    public TokenResponse refreshToken(final TokenRequest tokenRequest) {
        final String accessToken = tokenRequest.getAccessToken();
        final String refreshToken = tokenRequest.getRefreshToken();

	// (1)
        final Authentication authentication = tokenParser.extractAuthentication(accessToken);
	// (2)
        validateRefreshToken(authentication.getName(), refreshToken);

        final TokenResponse tokenResponse = tokenService.createTokenResponse(authentication);
	// (3)
        tokenService.saveRefreshToken(authentication.getName(), tokenResponse.getRefreshToken());

        return tokenResponse;
    }

    private void validateRefreshToken(final String userId, final String refreshToken) {
        if (!refreshTokenValidator.validate(userId, refreshToken)) {
            // (4)
            tokenService.invalidateRefreshToken(userId);
            throw new BadCredentialsException("리프레쉬 토큰이 유효하지 않습니다.");
        }
    }
}

(1) 이미 만료된 액세스 토큰이지만, 사용자 정보를 가지고 있기 때문에 이를 이용하여 Authentication을 생성한다.

(2) 리프레쉬 토큰에 대한 유효성 검사를 진행한다.

(3) 다시 생성된 리프레쉬 토큰값을 저장한다.

(4) 유효하지 않은 리프레시 토큰이 들어왔을 경우, 토큰 값이 탈취되었다고 보고 해당 유저의 리프레쉬 토큰을 삭제한다.

 

@Component
public class RefreshTokenValidator {
    private final JwtTokenParser tokenParser;
    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshTokenValidator(
        final JwtTokenParser tokenParser,
        final RefreshTokenRepository refreshTokenRepository
    ) {
        this.tokenParser = tokenParser;
        this.refreshTokenRepository = refreshTokenRepository;
    }

    public boolean validate(final String userId, final String refreshToken) {
        final RefreshToken savedRefreshToken = getSavedRefreshToken(userId);
        return tokenParser.validateRefreshToken(refreshToken) && savedRefreshToken.hasSameValue(refreshToken);
    }

    private RefreshToken getSavedRefreshToken(final String userId) {
        return refreshTokenRepository.findById(userId)
            .orElseThrow(() -> new NotFoundException("리프레쉬 토큰을 찾을 수 없습니다."));
    }
}

 

7. 마무리

 지금까지 JWT를 이용해 인증 기능을 구현해봤다. 근데 만약 사용자의 권한 변경이 즉각적으로 반영되어야하는 요구사항이 있다면 어떻게 해야할까? JWT의 장점은 Stateless인데, 이 장점 때문에 즉각적인 반영은 불가능하다. 장점이 단점으로 작용하는 순간이다. 만약 최대한 빠른 반영을 위해 액세스 토큰의 유효기간을 짧게 가져간다면, 리프레쉬 토큰 재발급을 위해 저장소에 자주 접근하게 될 것이므로 이 또한 올바른 해결책 같아 보이진 않는다.

 나라면 이러한 요구사항엔 그냥 세션을 쓰겠다. 세션을 Redis에 저장하여 확장성도 확보할 수 있다. 이처럼 항상 옳은 방법은 없다. 요구사항에 맞춰 가장 바람직한 방법을 취하면 된다.

 

 

 

※ 참고

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard

 

[무료] Spring Boot JWT Tutorial - 인프런 | 강의

Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

https://brunch.co.kr/@jinyoungchoi95/1

 

JWT(Json Web Token) 알아가기

jwt가 생겨난 이유부터 jwt의 실제 구조까지 | 사실 꾸준히 작성하고 싶었던 글이지만 JWT를 제대로 개념을 정리하고 구현을 진행해본 적이 없었는데 리얼월드 프로젝트를 진행하면서 JWT에 대한

brunch.co.kr

https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/

 

What Are Refresh Tokens and How to Use Them Securely

Learn about refresh tokens and how they help developers balance security and usability in their applications.

auth0.com

 

 이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.

 

 일반적으로 업무에선 포스트맨을 많이 사용해왔다. 헌데 이와 같은 기능을 인텔리제이에서 자체적으로 제공한다는 걸 알게되었다.

 

1. 기본 사용법

사용법은 아주 간단하다. 프로젝트의 적당한 위치에 아래와 같이 .http 파일을 생성한다. 파일명은 나의 경우 도메인 이름으로 하였다.

 그 다음 아래와 같이 파일 내에 요청을 작성해주면 된다.

###
POST localhost:8080/users
Content-Type: application/json

{
  "loginId": "test1234",
  "password": "password1234",
  "nickName": "테스트계정",
  "email": "test@test.com"
}

 보다 자세한 설명은 인텔리제이 자체에서 제공하는 걸 보는 게 더 나을 것 같다.

 

2. 환경별 변수 지정

 포스트맨과 마찬가지로 환경별 변수를 지정할 수도 있다. 프로젝트 최상단에 아래와 같이 rest-client.env.json를 생성한다.

 그 다음 환경별로 변수를 지정해주면 된다. 예를 들면 로컬 환경과 개발환경, 운영환경의 host는 모두 다를 것이다. 아래와 같이 설정할 수 있다.

{
  "local": {
    "host": "localhost:8080"
  },
  "dev": {
    "host": "dev.com"
  },
  "prod": {
    "host": "prod.com"
  }
}

 그런 다음 .http 파일 내에 아래와 같이 작성해주면 'host'라는 변수를 사용할 수 있다.

###
POST {{host}}/users
Content-Type: application/json

{
  "loginId": "test1234",
  "password": "password1234",
  "nickName": "테스트계정",
  "email": "test@test.com"
}

 

3. 인증

 대부분의 API는 인증이 필요하고, 때문에 세션ID나 토큰 등이 필요하게 된다. 매 요청마다 인증을 할 수는 없는 노릇이니 한 번의 인증으로 모든 요청에서 사용할 수 있도록 설정할 수 있다.(포스트맨에도 이와 같은 기능이 있다.)

 예를 들면 /login API에서 리스판스로 아래와 같은 json 값이 내려온다고 가정하자.

 그러면 아래와 같이 /login API 호출 시 accessToken 값을 저장할 수 있다.

###
POST {{host}}/login
Content-Type: application/json

{
  "loginId": "test1234",
  "password": "password1234"
}

> {% client.global.set("auth_token", response.body.accessToken); %}​

 이렇게 'auth_token'이라는 값으로 저장된 값은 .http 파일 내에서 아래처럼 사용이 가능하다.

###
PUT {{host}}/users/1
Content-Type: application/json
Authorization: Bearer {{auth_token}}

{
  "loginId": "test1234",
  "password": "newPassword1234",
  "nickName": "newNickNm",
  "email": "new@test.com"
}

 

4. 자동 생성

 생성하고자하는 엔드포인트의 컨트롤러 파일에 가면 아래와 같은 아이콘을 확인할 수 있다.

 이 부분을 클릭해주면 .http 파일이 아래와 같이 자동으로 생성된다.

 

 개인적으로 포스트맨보단 이 기능을 사용하는 게 더 바람직한 것 같다. 왜냐하면 Request에 대한 형상 관리 또한 가능해지기 때문이다. 근데 사실 난 이 프로젝트를 진행하면서 모든 API에 대한 인수테스트 코드를 작성하다보니 굳이 이러한 방식의 테스트가 불필요하게 느껴지기도 했다.

 이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.

 

1. Checkstyle

 Checkstyle은 코딩 컨벤션 검사를 위한 도구이다. 자동화된 검사가 가능하며, 설정에 따라 규칙에 어긋나는 코드가 하나라도 있을 경우 빌드가 실패하도록 만들 수도 있다. 사용법은 https://naver.github.io/hackday-conventions-java/#checkstyle 을 참고하면 확인할 수 있다.

 코딩 컨벤션은 기본적으로 https://naver.github.io/hackday-conventions-java 을 따르되 약간의 커스터마이징을 하였다. 커스터마이징 사항은 아래와 같다.

1) 대문자로 표기할 약어를 허용하지 않는다. 즉 DAO와 같은 예외 케이스를 두지 않았다.

2) 메서드명에 한글을 허용하였다. 이는 테스트 코드 작성 시 메서드명을 한글로 만들기 위함이며, 프로덕션 코드에는 허용되지 않는다.

3) 인덴트로 하드탭이 아닌 4 spaces를 사용한다.

4) 줄바꿈줄 바꿈 허용 위치 중 '+' 기호에 한해서만 예외적으로 연산자 후에도 줄 바꿈이 가능하도록 하였다. 이는 IDE가 자동으로 생성해주는 toString() 함수를 매번 수정해주는 것이 너무 불편했기 때문이다.

5) import문 순서에 대한 규칙 중, nhncorp, naver와 같은 네이버 전용 패키지는 배제하였다. 대신 토이프로젝트 어플리케이션 코드에서 사용하는 minseoklim 패키지가 import문 중 가장 마지막에 위치하도록 하였다.

 

2. Lombok

 롬복은 어노테이션을 통해 반복적인 코드를 자동으로 생성해주는 도구이다. 이를 활용하면 코드를 더 깔끔하게 유지할 수 있다는 장점이 있다. 하지만 편의성만큼이나 단점도 존재한다. 따라서 주의해서 사용해야 하며 때에 따라서는 아예 사용을 금지하는 편이 좋은 것도 있다. 이에 대해서는 https://kwonnam.pe.kr/wiki/java/lombok/pitfall 에 잘 정리되어 있다. 

 lombok.config을 사용하면 특정 롬복 어노테이션 사용 시 오류가 발생하도록 하여 사용을 못하게 강제할 수 있다. 사용법은 간단하다. 프로젝트 최상단에 아래와 같이 lombok.config 파일을 생성한다.

 이후 lombok.config 파일 안에 아래와 같이 사용을 금지할 어노테이션을 지정해주면 된다.

lombok.allArgsConstructor.flagUsage = error
lombok.requiredArgsConstructor.flagUsage = error
lombok.data.flagUsage = error
lombok.value.flagUsage = error
lombok.experimental.flagUsage = error
lombok.toString.flagUsage = error

 

 @EqualsAndHashCode의 경우 불변 객체에 대해서만 사용해줘야 하는데, 사실 이건 롬복의 문제라기 보단 불변 객체에 대해서만 equals()와 hashcode()를 재정의해주는 편이 좋다는 것이다.

 

※ 참고

https://naver.github.io/hackday-conventions-java

 

캠퍼스 핵데이 Java 코딩 컨벤션

중괄호({,}) 는 클래스, 메서드, 제어문의 블럭을 구분한다. 5.1. K&R 스타일로 중괄호 선언 클래스 선언, 메서드 선언, 조건/반복문 등의 코드 블럭을 감싸는 중괄호에 적용되는 규칙이다. 중괄호

naver.github.io

https://kwonnam.pe.kr/wiki/java/lombok/pitfall

 

java:lombok:pitfall [권남]

 

kwonnam.pe.kr

 

 우아한테크캠프를 수료한 지 벌써 3달이 다 되어간다. 총 9주(2021년 11월 1일 ~ 2021년 12월 31일) 과정으로 진행되었으며 정말 유익한 시간이었다. 물론 9주라는 짧은 시간 동안 얼마나 많은 성장을 할 수 있었겠냐만은, 백엔드 개발자로서 어떻게 성장해야 하는지, 그리고 어떤 방향으로 나아가야 하는 지를 깨닫게 해주는 소중한 경험이었다.

 그 이후로는 개인적으로 좀 지치기도 해서 쉬는 시간이 필요했다. 바쁘다는 핑계로 소홀했던 가족들, 여자친구와 더 많은 시간을 보냈고, 개발자로서 앞으로 어떻게 살아가야 할지를 생각하는 시간도 가졌다. 그러다 보니 우아한테크캠프에서 배웠던 내용들을 복습하는데 시간이 좀 걸렸다^^;;

 이 포스팅은 내가 우아한테크캠프에서 배웠던 내용들을 바탕으로 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 진행될 것이다. 우아한테크캠프에서 배웠던 내용들을 기준으로 진행할 것이나, 중간중간 내가 추가하고 싶은 내용이 포함될 수도 있다.

 

 아래의 내용은 우아한테크캠프를 수강하면서 나왔던 요구사항들을 정리한 것이다. 실무에서 이 모든 것들을 지키는 것은 불가능할 것이고 실용적이지도 않다. 하지만 공부하는 과정에서 아래의 다소 극단적인 요구사항들을 지키려고 노력한다면, 그 과정에서 새로운 인사이트를 얻을 수 있을 것이다.

 

# 우아한테크캠프 Pro 내재화 프로젝트

## 프로그래밍 요구사항
* 객체지향 생활 체조 원칙
  * 규칙 1: 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  * 규칙 2: else 예약어를 쓰지 않는다.
  * 규칙 3: 모든 원시값과 문자열을 포장한다.
  * 규칙 4: 한 줄에 점을 하나만 찍는다.
  * 규칙 5: 줄여쓰지 않는다(축약 금지).
  * 규칙 6: 모든 엔티티를 작게 유지한다.
  * 규칙 7: 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  * 규칙 8: 일급 콜렉션을 쓴다.
  * 규칙 9: 게터/세터/프로퍼티를 쓰지 않는다.
* TDD
  * 원칙 1: 실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.
  * 원칙 2: 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  * 원칙 3: 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
* ATDD
  * 코딩을 시작하기 전에 인수 테스트를 먼저 작성한다.
  * 이 프로젝트에서의 인수 테스트는 API 접점에서 코드를 검증하는 E2E 테스트로 한다.
  * API의 Request와 Response 정보 이외의 내부 정보는 최대한 가리는 블랙박스 테스트 형식을 취한다.
  * 인수 테스트 코드는 사용자 스토리를 시나리오 형식으로 표현하여 작성한다.
* 기타
  * 메소드의 길이가 15라인을 넘어가지 않도록 구현한다.
  * 메소드/클래스 분리(SRP)
  * 값을 하드코딩하지 않는다.
  * 필드에 직접 접근하기보단 메시지 방식을 취한다(enum 포함).
  * 자바 코드 컨벤션을 지키면서 프로그래밍한다.
  * 불필요하게 공백 라인을 만들지 않는다.

## 기능 목록 및 commit 로그 요구사항
* 기능을 구현하기 전에 README.md 파일에 구현할 기능 목록을 정리해 추가한다. 이때, 예외 상황에 대해서도 정리한다.
* git의 commit 단위는 앞 단계에서 README.md 파일에 정리한 기능 목록 단위로 추가한다.
* 커밋 메시지는 의미있게 작성한다.

 

일반적으로 intellij와 같은 IDE를 통해 파일명을 변경하게 되면, 아래와 같이 '이름 바꿈'으로 GIT이 인식하도록 IDE가 알아서 처리를 해준다. 이는 IDE가 일반적인 리네임 방법인 mv가 아니라 git mv 를 사용하기 때문이다.

헌데 필자는 현재까지의 작업 상황을 잠시 stash 한 후 다시 pop하여 사용할 일이 생겼다. 그랬더니 아래와 같은 상황이 벌어졌다-_-

이렇게 되니, 현재까지의 작업 내용을 확인할 때 이름만 변경한 건 무엇이고 실제로 새로 추가한 파일은 무엇인지 파악하기 힘들어졌다. 구글링 끝에 아래와 같은 명령어를 이용하면 된다는 걸 알게 되었다.

git commit --dry-run -a

 

여담으로, git status를 통해 새 파일과 삭제한 파일로 인식되던 것도 git add를 하게되면 알아서 다시 이름 바꾼 상태라는 걸 git이 인식하게 된다. 따라서 위 명령어는 add 하기 이전에 작업 상황을 파악하는데 사용하면 좋을 것 같다.

 

- 참고 : https://stackoverflow.com/questions/2641146/handling-file-renames-in-git

처음엔 1, 11, 111... 이런식으로 수를 늘려가면서 수를 나눠본 후, 처음 나누어 떨어졌을 때의 자리수를 구하면 된다고 생각했다. 코드는 아래와 같다.

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringJoiner;

public class Main {
    public static void main(String[] args) throws IOException {
        final BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        final StringJoiner result = new StringJoiner(System.lineSeparator());

        String input;
        while ((input = br.readLine()) != null) {
            final int n = Integer.parseInt(input);
            result.add(String.valueOf(computeAnswer(n)));
        }

        System.out.println(result);
    }

    private static int computeAnswer(int n) {
        long multiplyer = 1;
        int count = 1;

        while (multiplyer % n != 0) {
            multiplyer = multiplyer * 10 + 1;
            count++;
        }

        return count;
    }
}

하지만 이 코드는 시간초과로 실패했다. 이것 저것 시도를 해보다가 결국 포기하고 구글링을 했다.

 

이 문제는 아래의 나머지 성질을 이용해서 풀 수 있다.

(A + B) % C = (A % C + B % C) % C
(A * B) % C = ((A % C) * (B % C)) % C

이것은 수학 시간에 배우던 분배법칙과 비슷하다. 하지만 '%'라는 기호는 프로그래밍을 하면서 처음 알게되었고 증명이 필요했다.

아... 근데 막상 이 공식을 통해서 어떻게 multiplyer = (multiplyer * 10 + 1) % n 으로 적용해도 된다는 걸 알 수 있는 건지 모르겠다. 아무리 봐도 모르겠네...ㅠ 내가 수학을 하는 건지 코딩을 하고 있는 건지...-_-;; 아무튼 그래서 더 간단한 방법으로 증명하는 건 불가능할까 고민하다가 아래와 같이 증명해봤다.

이렇게 하면, 10A + 1 을 B로 나눴을 때의 나머지가 10R + 1을 B로 나눴을 때의 나머지와 같다는 걸 쉽게 알 수 있다. 비단 10과 1에 국한되진 않을 거다. 아무튼 이 결과를 적용한 코드는 아래와 같다.

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringJoiner;

public class Main {
    public static void main(String[] args) throws IOException {
        final BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        final StringJoiner result = new StringJoiner(System.lineSeparator());

        String input;
        while ((input = br.readLine()) != null) {
            final int n = Integer.parseInt(input);
            result.add(String.valueOf(computeAnswer(n)));
        }

        System.out.println(result);
    }

    private static int computeAnswer(int n) {
        int multiplyer = 1;
        int count = 1;

        while (multiplyer % n != 0) {
            multiplyer = (multiplyer * 10 + 1) % n;
            count++;
        }

        return count;
    }
}

 

이 문제를 통해 알게된 정말 놀라운 사실은, 기본형에 해당하는 수의 크기를 줄이는 것으로 속도를 향상시킬 수 있다는 것이었다. 연산에 드는 시간이 줄긴하겠지만, 이렇게나 차이가 날 줄이야...;

 

 

※ 참고 : https://dynamiccube.tistory.com/4

 요즘은 이력서를 쓸 때 Git과 블로그가 거의 필수라고 한다. 그만큼 지속적으로 블로그에 글을 남기고 운영하는 게 취업에 도움이 된단 얘기겠지. 이 블로그를 처음 만든 것도 같은 이유였다. 더 좋은 회사에 취업하는 데 도움이 될 것 같았다. 하지만 지속적으로 글을 남기는 데는 번번이 실패하고 말았다. 여기에 시간을 쏟는 게 시간 낭비처럼 느껴졌기 때문이다.

 최근에 퇴사를 하면서 인수인계 문서를 작성하게 되었다. 문서를 작성하면서, 내가 이 회사에 다니면서 해온 것들을 다시 한번 정리해 볼 수 있는 계기가 되었다. 문득 '이래서 블로그를 하는 건가?'란 생각이 들었다. 그래서 다시 블로그를 시작해 봐야겠다는 생각을 했고, 지금 이 글을 쓰고 있다.

 내가 앞으로 지속 가능한 블로그 운영을 하기 위해서는 지켜야 할 것들이 몇 가지 있다.

 

1. 조급해하지 않기

 예전에 블로그에 글을 남길 때는 빨리, 더 많이 글을 남겨야 한다는 조급한 마음이 있었던 것 같다. 그래야 블로그에 글이 풍성해지고 취업에도 도움이 될 테니까. 의도가 불순했기(?) 때문에 실패했던 걸까? 블로그는 취업에 도움이 되기 때문에 하는 것이 아니라 나 자신의 역량을 키우기 위해 하는 것이다. 일주일에 1개씩 올려도 좋다. 느리지만 꾸준하게.

 

2. 블로그의 글은 '나 자신'을 위한 것이어야 한다.

 아까와 비슷한 맥락이다. 블로그는 취업에 도움이 되기 때문에 하는 것이 아니라, 내가 공부했거나 겪었던 이슈 등에 대해 글로 다시 한번 정리함으로써, 그 내용을 내 머릿속에 각인시키기 위해 하는 것이다. 책을 내는 게 아니므로 지나치게 잘 정리하려고 애쓸 필요는 없다.

 

 나는 앞으로 지속적인 블로깅을 할 수 있을까? 이렇게 글을 써놓고 또 방치될지도 모를 일이다. 그래도 다시 시도해 보려고 한다. 많은 회사에서 중요하게 생각한다는 건, 그만큼 역량을 키우는 데 도움이 되기 때문일 테니까.

'잡담' 카테고리의 다른 글

면접이란 무엇인가?  (1) 2022.05.12

+ Recent posts